Compare commits

..

57 Commits

Author SHA1 Message Date
selune 545191528d Preserve Result overlay WIP and original native acceptance documents (#6201) 2026-10-31 12:59:28 +01:00
selune b3a01bf400 Preserve CPU compatibility WIP and exact C28 host; validation remains incomplete 2026-10-31 12:25:43 +01:00
selune 7cf392d7c3 Preserve every-pass CPU source and reviewed SHA-state recovery; validation pending 2026-10-31 10:40:53 +01:00
selune dbc6041ba7 archive: preserve reviewed CPU high-pass film and Interactive source, unrun 2026-10-31 09:12:32 +01:00
selune 53ef5bb7c2 docs(render): preserve exact C24 quality-test macro recovery WIP 2026-10-31 05:38:12 +01:00
selune 53a60b772d chore: preserve reviewed U04-owner-integration source proposal and provenance 2026-10-31 04:41:55 +01:00
selune 57257eaecd chore: preserve reviewed CPU directional-shadow UI source checkpoint 2026-10-31 04:25:53 +01:00
selune 299374885f chore: preserve reviewed quality buttons and TIR test-call source checkpoint 2026-10-31 04:10:19 +01:00
selune eb848ff3c8 archive: coherent C22 render source increment and exact bindings (unrun) 2026-10-31 02:55:45 +01:00
selune c42b4c1e19 archive: reviewed CPU directional RGB consumer source (uncompiled and unrun) 2026-10-31 02:39:07 +01:00
selune 55dc8e2e2c archive: align dielectric lowering test contract and initial docs (unrun source) 2026-10-31 02:04:42 +01:00
selune 7a66117304 archive: preserve reviewed physical-glass library staging (unrun source) 2026-10-31 01:55:58 +01:00
selune fffecd56a9 archive: preserve reviewed CPU RGB lighting prerequisite (unrun source) 2026-10-31 01:26:14 +01:00
selune 7464a9ba23 docs: preserve original native progressive and glass acceptance procedures (not run) 2026-10-31 01:19:01 +01:00
selune f76b8b8720 archive: preserve reviewed CPU direct-visibility owner successor (unrun source) 2026-10-31 01:14:16 +01:00
selune 4f492f6ae3 archive: preserve reviewed Raster local-light runtime consumer (unrun source) 2026-10-31 00:55:46 +01:00
selune f346faa785 archive: preserve minimal carried Render image-test recovery (unrun source) 2026-10-31 00:48:30 +01:00
selune 169ed3b934 wip(render): preserve bounded CPU RGB direct-visibility foundation 2026-10-30 23:53:51 +01:00
selune 25c9527bbc docs(render): correct archived U17 source test-module label 2026-10-30 23:36:26 +01:00
selune 9e41909c05 wip(render): preserve adopted C20 corrections and independent Raster light helper 2026-10-30 23:23:28 +01:00
selune 6f7f101846 wip(render): preserve strict dielectric transport, saved policy and original acceptance source 2026-10-30 22:42:33 +01:00
selune f1697fa9db wip(render): preserve explicit-light color correction and connected UI documentation 2026-10-30 22:32:10 +01:00
selune a46f1b8373 wip(render): preserve coherent preview, saved-lighting and exposure source 2026-10-30 22:10:24 +01:00
selune 928d07706d wip(render): preserve reviewed HDR test, sampling presets and CPU exposure proposals 2026-10-30 21:05:54 +01:00
selune 9001a87ea4 docs(render): update issue progress with exact C17 partial test evidence 2026-10-30 20:27:32 +01:00
selune 5a555d4741 feat(render): preserve reviewed paused camera and Raster directional source units 2026-10-30 19:36:10 +01:00
selune f0477d474b docs(render): publish honest U01-U17 evidence milestone charts and remaining work 2026-10-30 19:15:09 +01:00
selune 7ef3f51de4 feat(render): preserve C16 fixture defaults and automatic Result preference with exact formatting bridge 2026-10-30 19:00:37 +01:00
selune 415fb00232 feat(render): preserve exact adopted 19-path source with reviewed progressive final feedback and optics 2026-10-30 18:09:40 +01:00
selune 0175e0c5e6 feat(render): preserve manual final feedback, progressive optics and precise cancellation repairs as reviewable WIP 2026-10-30 17:33:55 +01:00
selune c2e0a97c74 feat(render): preserve protected CPU feedback composition with source-reviewed error accounting 2026-10-30 16:06:23 +01:00
selune a82f4ffd82 feat(render): preserve coherent reviewed CPU partial-stream source with corrected visibility and accounting 2026-10-30 15:30:15 +01:00
selune c504532744 docs(render): select explicit bounded feedback work envelope preserving ordinary render allowance 2026-10-30 15:15:14 +01:00
selune 84704806bb fix(render): preserve reviewed partial-producer tick correction and outstanding work-budget limit 2026-10-30 14:57:43 +01:00
selune 02ad76be21 feat(render): preserve unrun partial-film host producer and narrow-card fix proposals 2026-10-30 14:46:28 +01:00
selune 791c1366e7 docs(render): attribute original long-scene fixture assets 2026-10-30 14:38:17 +01:00
selune 9dfebc997c test(render): preserve original detailed scene and synthetic HDR without claiming execution 2026-10-30 14:36:55 +01:00
selune 1e27ac72bb docs(render): preserve actual narrow-window Pause regression and stopped test result 2026-10-30 14:32:03 +01:00
selune ee779b6f79 docs(render): record completed build and selection stop without claiming test passes 2026-10-30 14:29:02 +01:00
selune b50e70451d docs(render): index current candidate and reviewed parity preparation truthfully 2026-10-30 14:10:10 +01:00
selune 5de18a496e docs(render): preserve reviewed parity preparation and original unrun acceptance cases 2026-10-30 13:30:34 +01:00
selune f6d3362ed2 docs(render): checkpoint complete current53-path C12 candidate without validation claims 2026-10-30 13:11:54 +01:00
selune 7a979a80b2 docs(render): preserve independently reviewed CPU nested-box V2 source 2026-10-30 13:03:44 +01:00
selune 129c2a1c81 docs(render): preserve real Interactive light tests and blocked glass engine work 2026-10-30 12:57:32 +01:00
selune 8790d601cc docs(render): preserve progressive Area increment and unreviewed CPU nested-box proposals 2026-10-30 12:31:41 +01:00
selune b73cf3edd1 docs(render): preserve reviewed help GPU diagnostics and Raster receiver fixes as unrun WIP 2026-10-30 12:20:40 +01:00
selune ac96dc66e9 docs(render): preserve unrun EV HDR and retained-final acceptance journeys 2026-10-30 12:00:50 +01:00
selune 738cfbaf9b docs(render): preserve immediate-release and selection regressions as unrun WIP 2026-10-30 11:56:01 +01:00
selune 174c59209c Archive unrun hidden-wait input guard prototype and original cases 2026-10-30 10:30:06 +01:00
selune 8f13d82145 Preserve combined Interactive follow-ups, partial native findings and admission stop 2026-10-30 10:05:46 +01:00
selune b94c65b005 Archive unrun native Interactive placement and waiting-canvas proposals 2026-10-30 09:36:20 +01:00
selune 83e18240cb docs(render): record scoped C7 validation and native acceptance boundary 2026-10-30 07:23:34 +01:00
selune 297bd144df docs(render): preserve engine test correction and native acceptance plan
Source archive requested by Judit; no production registration. C7 strict Clippy and wasm check passed; native build/tests and application acceptance remain pending.
2026-10-30 07:11:52 +01:00
selune 2cbad02957 docs(render): preserve test-helper correction and pending parity follow-ups
Source archive only, not a qualified production feature. C3 selected tests passed before a test-helper lint failure; corrected source and conditional CPU/Raster patches await validation.
2026-10-30 00:21:33 +01:00
selune d66268179d docs(render): preserve complete reviewed WIP source and pending parity proposals
Source-preservation checkpoint requested by Judit; not a qualified feature commit. Combined engine/UI compilation cancelled for resources; full gates and native acceptance remain pending. Production baseline unchanged.
2026-10-29 22:38:45 +01:00
lillith a077906234 feat(render): add bounded CPU Interactive request lifecycle 2026-10-29 20:18:50 +01:00
lillith b2733d607a claim: #8936 2026-10-29 18:16:13 +01:00
1413 changed files with 481521 additions and 139815 deletions
+1 -3
View File
@@ -1,6 +1,6 @@
## Change
Start with what this is, in plain words someone new to the project would understand: what it does and who it's for. Don't assume anyone knows the name of your tool, project or feature. Then describe the problem and resulting behavior. Link the issue when applicable.
Describe the problem and resulting behavior. Link the issue when applicable.
Changes a default: no
@@ -14,8 +14,6 @@ What did you run, and what were the results? Include expected and measured value
## Screenshots of the behavior (when observable)
Put the picture or video here in the description. A file in the diff doesn't count: a maintainer has to be able to see what this is without opening the changed files.
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
+3 -4
View File
@@ -1,7 +1,6 @@
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
# in ci.yml).
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
name: Kernel paused
on:
+3 -18
View File
@@ -1,6 +1,6 @@
# Three quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
# for an issue a lower-numbered one already closes, the "Changes a default:" line is filled in, and
# a PR adding to the showcase shows a picture. None is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
# Two quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
# for an issue a lower-numbered one already closes, and the "Changes a default:" line is filled in.
# Neither is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
name: PR checks
on:
@@ -48,21 +48,6 @@ jobs:
const r = await checks.checkDefaults({ github, context, core });
core.info(JSON.stringify(r));
picture:
name: Picture check
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/pr-checks
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const checks = require('./scripts/pr-checks/pr-checks.js');
const r = await checks.checkPicture({ github, context, core });
core.info(JSON.stringify(r));
# Labelling needs write access, which a fork PR's token never has, so it is its own job and best
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
+2 -2
View File
@@ -350,7 +350,7 @@ jobs:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-15
runs-on: macos-14
strategy:
fail-fast: false
matrix:
@@ -384,7 +384,7 @@ jobs:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-15
runs-on: macos-14
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+4 -151
View File
@@ -84,8 +84,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| assets/fonts/craft-ui-sc/CraftUI-SC.otf | Adobe / Google Inc. (Noto Sans SC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/SC Regular, glyph-subset for assets/locales/zh.json and renamed Craft UI SC (OFL reserved-name); licence text in assets/fonts/craft-ui-sc/OFL.txt; web UI Simplified Chinese | SIL OFL 1.1 |
| assets/fonts/craft-ui-tc/CraftUI-TC.otf | Adobe / Google Inc. (Noto Sans TC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/TC Regular, glyph-subset for assets/locales/zh-TW.json and renamed Craft UI TC (OFL reserved-name); licence text in assets/fonts/craft-ui-tc/OFL.txt; web UI Traditional Chinese | SIL OFL 1.1 |
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
@@ -127,8 +125,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/appearances/opaque_finishes.rs | SolidCraft contributors | original illustrative opaque finish values; no external dataset, textures or measured engineering data | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/craft-ui-sc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-SC.otf | SIL OFL 1.1 |
| assets/fonts/craft-ui-tc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-TC.otf | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
@@ -189,7 +185,11 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/platinum-9.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/cupertino.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
@@ -609,152 +609,6 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| assets/icons/ui/design/svg/model/timeline_stop.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-recent-snap.json | SolidCraft contributors | Original preference messages for the recent centre and midpoint snap | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_horizontal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_vertical.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_angle.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_parallel.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_perpendicular.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_external.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_at.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_two.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_bisector.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-circle.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-circle.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-flower.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-flower.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-involute.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-involute.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-line.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-line.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-parabola.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-parabola.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-spiral.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-spiral.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-wave.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-wave.json`; gitignored) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/print_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/locales/fi.json | SolidCraft contributors | Original Finnish translation of SolidCraft's English catalog, AI-assisted (Claude Code) clean-room from the English and not yet reviewed line by line by a native speaker; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_axis_point.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-2x.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-2x.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-4x.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-4x.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-8x.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-8x.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-off.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/aa-off.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/agent-pointer-speed.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/agent-pointer-speed.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/ao-on.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/ao-on.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/assembly-move.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/assembly-move.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/browser-card.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/browser-card.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/browser-column.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/browser-column.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-axes.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-axes.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-bends.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-bends.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-draft.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-draft.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-failed.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-failed.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-fixed.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-fixed.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-projected.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/colour-vision-projected.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/direct-interaction.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/direct-interaction.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/frame-rate.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/frame-rate.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-bottom.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-bottom.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-in-browser.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-in-browser.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-left.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-left.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-under-browser.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/history-under-browser.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/layout-schematic.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/layout-schematic.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/measure-axis-distances.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/measure-axis-distances.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/mouse-mapping.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/mouse-mapping.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/navigator-axes.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/navigator-axes.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/navigator-cube.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/navigator-cube.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orbit-centre.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orbit-centre.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orbit-cursor.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orbit-cursor.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orbit-origin.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orbit-origin.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orientation-y-up.dark.svg | SolidCraft contributors | original SolidCraft artwork: the Orientation figure copied from the app and extracted from the Preferences redesign board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orientation-y-up.light.svg | SolidCraft contributors | original SolidCraft artwork: the Orientation figure copied from the app and extracted from the Preferences redesign board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orientation-z-up.dark.svg | SolidCraft contributors | original SolidCraft artwork: the Orientation figure copied from the app and extracted from the Preferences redesign board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/orientation-z-up.light.svg | SolidCraft contributors | original SolidCraft artwork: the Orientation figure copied from the app and extracted from the Preferences redesign board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/quick-measure.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/quick-measure.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/select-bodies.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/select-bodies.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/select-faces-edges.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/select-faces-edges.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-conflict.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-conflict.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-exit-keep.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-exit-keep.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-exit-previous.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-exit-previous.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-exit-unless-turned.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-exit-unless-turned.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-guides.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-guides.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-hide-glyphs.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-hide-glyphs.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-state-icons.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/sketch-state-icons.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/style-shaded-edges.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/style-shaded-edges.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-coarse.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-coarse.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-fine.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-fine.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-normal.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-normal.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-ultra.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tess-ultra.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-dark.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-dark.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-gruvbox.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-gruvbox.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-imported.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-imported.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-light.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-light.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-system-dark-half.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-system-dark-half.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-system-light-half.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/theme-system-light-half.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-cursor.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-cursor.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-extrude.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-extrude.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-hole.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-hole.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-placement.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/tool-placement.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/vertex-markers.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/vertex-markers.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-angles.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-angles.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-grid.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-grid.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-perspective.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-perspective.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-shadow.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| docs/design/preferences/figures/view-shadow.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 |
| `assets/locales/pending/sketch-offset-instances.json` | SolidCraft contributors | Original offset instance-count label and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_profile.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original analytic head and shoulder geometry for settings profiles | MIT OR Apache-2.0 |
## Code adapted from sibling projects
@@ -814,7 +668,6 @@ copied into SolidCraft.
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/accesskit_macos | accesskit_macos 0.26.3, https://github.com/AccessKit/accesskit (The AccessKit contributors) | MIT OR Apache-2.0 | the subclassing adapter and its tests from 0.27.2: dropping the adapter keeps AppKit's key-value observers (vendor/README.md) |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
+6 -14
View File
@@ -19,13 +19,12 @@ Questions, ideas or want to say what you're working on? Join the [Discord](https
## Kernel work is paused
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
change `crates/kernel/`, `crates/geom/` or `vendor/truck*`, or issues and discussions about kernel
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
including the dialogs and tools for these features.
We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull
requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and
lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes
`crates/kernel/`. The pinned issue on the tracker explains it. New ones get the `kernel-paused`
label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes and data loss
are still taken as issues, and everything outside the kernel carries on as normal.
## Get it running (about 10 minutes on a fast machine)
@@ -347,13 +346,6 @@ without pushes. These automatic cleanup deadlines are separate from the daily pr
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
taken are never freed automatically.
**A PR has to explain itself.** Start the description with what it is, in plain words someone new
to the project would understand: what it does and who it's for. Don't assume anyone knows the name
of your tool, project or feature. If there's anything to look at, show it in the description: a
picture or video has to be visible there without opening the changed files. "The GIF is the change"
doesn't count. A PR that doesn't do this waits until it does. For a PR that adds to
`docs/showcase.md` or `docs/screenshots/`, the Picture check fails until the description shows one.
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
is required for changes or bugs that can be demonstrated in the app, including kernel,
Generated
+20 -39
View File
@@ -60,6 +60,8 @@ dependencies = [
[[package]]
name = "accesskit_macos"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
@@ -4208,7 +4210,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4240,16 +4242,15 @@ dependencies = [
"windows-sys 0.61.2",
"winit",
"x11rb",
"zbus 5.19.0",
]
[[package]]
name = "solidcraft-3dx-client"
version = "0.30.1"
version = "0.26.1"
[[package]]
name = "solidcraft-cli"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"image",
"log",
@@ -4261,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4270,7 +4271,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"log",
"serde",
@@ -4287,7 +4288,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"image",
"log",
@@ -4308,7 +4309,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"serde",
"serde_json",
@@ -4317,7 +4318,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"image",
"log",
@@ -4337,7 +4338,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4355,14 +4356,13 @@ dependencies = [
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-testkit",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "solidcraft-kernel"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"log",
"proptest",
@@ -4382,9 +4382,8 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"image",
"serde_json",
"solidcraft-engine",
"solidcraft-gpu",
@@ -4392,7 +4391,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"image",
"moxcms",
@@ -4401,13 +4400,12 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcraft-geom",
"web-time",
"weezl",
]
[[package]]
name = "solidcraft-sketch"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"harfrust",
"serde",
@@ -4420,18 +4418,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.30.1"
[[package]]
name = "solidcraft-ui-egui"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"arboard",
"egui",
@@ -4450,7 +4444,6 @@ dependencies = [
"solidcraft-gpu",
"solidcraft-space-mouse",
"syn 2.0.119",
"sys-locale",
"tiny-skia",
"toml",
"ttf-parser",
@@ -4462,7 +4455,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"eframe",
"egui",
@@ -4566,18 +4559,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"js-sys",
"libc",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4585,7 +4566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
@@ -6017,7 +5998,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.30.1"
version = "0.26.1"
dependencies = [
"proc-macro2",
"serde",
+1 -6
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.30.1"
version = "0.26.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -28,8 +28,6 @@ solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
# foundation
thiserror = "2"
@@ -167,9 +165,6 @@ egui-wgpu = { path = "vendor/egui-wgpu" }
# winit 0.30 never binds and eframe 0.36 still requires winit 0.30.
winit = { path = "vendor/winit" }
# macOS: dropping the accessibility adapter leaves AppKit's key-value observers intact (#7535).
accesskit_macos = { path = "vendor/accesskit_macos" }
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
# builds, since every mutant rebuilds the crates above the one it changes.
[profile.mutants]
+3 -4
View File
@@ -42,8 +42,8 @@
</p>
<p align="center">
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
</p>
![SolidCraft: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo.gif)
@@ -98,7 +98,7 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
runs its own design session, or with `--connect` it drives the running app so you can watch.
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). Tools, access rules and an example session:
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
[docs/mcp.md](docs/mcp.md).
## Scripts
@@ -150,4 +150,3 @@ Tinkercad are trademarks of their respective owners, named only to describe comp
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://solidcraft.io/app
+1 -3
View File
@@ -662,9 +662,7 @@ completed drawings publish atomically. The browser still exports synchronously.
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
sampling. A fold crosses the sheet's own outer edges the same way, slanted or on any curve,
with the outline exact in the flat pattern and its DXF, and flanges and other folds still go
on the panels.
sampling.
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
exact holes and retained islands, including the union of multiple selected profiles, and
+12 -40
View File
@@ -3,7 +3,7 @@
## Supported versions
SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please
update to the [latest release](https://github.com/tangency-labs/solidcraft/releases/latest) before
update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before
reporting, and check whether the problem is still on `main`.
| Version | Supported |
@@ -15,46 +15,18 @@ reporting, and check whether the problem is still on `main`.
## Reporting a vulnerability
Please **don't open a public issue** for a security problem. Report it privately through GitHub:
open the repository's [Security tab](https://github.com/tangency-labs/solidcraft/security) and choose
open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose
**Report a vulnerability**
([direct link](https://github.com/tangency-labs/solidcraft/security/advisories/new)).
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
Please include what you found, the steps you ran to reproduce it (a design file, command or MCP
request), the version and platform, and what an attacker gains. I'll acknowledge the report as soon
as I can, keep you updated in the advisory, and credit you when the fix is published unless you'd
rather stay anonymous.
Please include what you found, how to reproduce it (a design file, command or MCP request), the
version and platform, and the impact you expect. I'll acknowledge the report as soon as I can,
keep you updated in the advisory, and credit you when the fix is published unless you'd rather
stay anonymous.
## What counts as a security issue
Areas where reports are especially useful:
Advisories are for problems where someone gets something they shouldn't, without already having
access to your computer or your account. For example:
- opening a design, STEP, STL or 3MF file from someone else runs code, or reads or overwrites files
it shouldn't
- a website or another computer can reach or control SolidCraft
- an agent gets past a prompt that promised to ask you first
- the web app reaches beyond the page's own storage
- a release package or an update can be tampered with
- an API key or token gets sent somewhere it shouldn't go
## What to report as a normal bug
Please [open an issue](https://github.com/tangency-labs/solidcraft/issues/new) for these. They're
still worth fixing, but they aren't security issues:
- anything that needs a program already running on your computer, or the control token. A program
running as you can already do what you can.
- MCP's allowed folders. They guard against accidents, they aren't a security boundary, and an agent
with a shell of its own can already reach those files. Getting around them isn't a vulnerability.
- crashes, hangs, or heavy memory or CPU use, unless they also lead to something in the list above
- anything that only happens in an unusual setup you'd have to choose on purpose
## Before you send it
- **Run it.** Reproduce the problem on the latest release. If you only read the code, say so, and
expect it to wait.
- **Check it yourself.** If a scanner or an AI agent found it, read the code and confirm it first.
These tools tend to overrate what they find.
- **Say what's gained.** Name what the attacker needs and what they get. I set the final severity.
Reports that don't fit will be closed with a short note, or moved to the issue tracker.
- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks)
- the MCP server and the control API (file access outside the allowed folders, command injection)
- the web app (anything reaching beyond the page's own storage)
- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap)
+3 -38
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 561 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -403,7 +403,6 @@ syn 1.0.109 (MIT OR Apache-2.0)
syn 2.0.119 (MIT OR Apache-2.0)
syn 3.0.6 (MIT OR Apache-2.0)
synstructure 0.13.2 (MIT)
sys-locale 0.3.2 (MIT OR Apache-2.0)
tempfile 3.27.0 (MIT OR Apache-2.0)
termcolor 1.4.1 (Unlicense OR MIT)
thiserror 1.0.69 (MIT OR Apache-2.0)
@@ -1837,8 +1836,8 @@ approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, at
cgmath 0.18.0, chardetng 0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1,
multiversion_no_op 1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0,
rustls-platform-verifier 0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions
1.1.0, sys-locale 0.3.2, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol
0.13.2, zeroize 1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
1.1.0, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize
1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
================================================================================
Apache License
@@ -13571,32 +13570,6 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
sys-locale 0.3.2
================================================================================
MIT License
Copyright (c) 2021 1Password
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
tempfile 3.27.0
================================================================================
@@ -16826,14 +16799,6 @@ Bundled fonts (SIL Open Font License, Version 1.1)
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
- CraftUI-SC.otf (assets/fonts/craft-ui-sc/CraftUI-SC.otf)
Copyright 2014-2021 Adobe / Google Inc. (Noto Sans SC subset for SolidCraft web zh)
Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/SC)
- CraftUI-TC.otf (assets/fonts/craft-ui-tc/CraftUI-TC.otf)
Copyright 2014-2021 Adobe / Google Inc. (Noto Sans TC subset for SolidCraft web zh-TW)
Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/TC)
- IBMPlexSans-Variable.ttf (assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf)
Copyright 2017 IBM Corp. with Reserved Font Name "Plex"
Source: https://github.com/IBM/plex
@@ -66,7 +66,7 @@ fn run() -> Result<(), String> {
let mut session = Session::default();
eprintln!("Generating original CAD scene from {recipe_path}");
let generation_start = Instant::now();
let (commands, command_timings) = session.run_script_timed(&recipe, false, &mut |_, _| {}).map_err(|e| e.to_string())?;
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
let generation_seconds = generation_start.elapsed().as_secs_f64();
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
+37 -62
View File
@@ -2,7 +2,6 @@
//!
//! ```text
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
//! [--param-check off|warn|strict]
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
@@ -58,7 +57,7 @@ struct Command {
}
/// `run`'s lines in the usage.
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
/// `eval`'s lines in the usage.
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
/// `script`'s lines in the usage.
@@ -172,11 +171,7 @@ fn main() -> ExitCode {
Some("oracle") => oracle::run(&args[1..]),
Some("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => {
let result = cmd_mcp(&args[1..]);
solidcraft_mcp::cleanup_scratch_dir();
result
}
Some("mcp") => cmd_mcp(&args[1..]),
Some("crashes") => crashes::run(&args[1..]),
Some("icon") => cmd_icon(&args[1..]),
Some("licences" | "licenses") => {
@@ -304,36 +299,18 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else {
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
run_script_warning(&mut s, path, &script)?;
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
// (stderr, so stdout stays the caller's).
let mut warn = |step: usize, r: &Value| {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
};
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
}
Ok(s)
}
/// Run `script`, read from `path`, on `s`. At the warn level each step's `param_warnings` go to
/// stderr as the step completes, even when a later step fails (stdout stays the caller's).
fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec<Value>, String> {
s.run_script_each(script, &mut |step, r| print_param_warnings(path, step, r)).map_err(|e| e.to_string())
}
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
fn print_param_warnings(path: &str, step: usize, r: &Value) {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
}
/// `--param-check off|warn|strict`: how calls that break a command's parameter contract are
/// treated (docs/mcp.md); off when the option isn't given.
fn parse_param_check(cmd: &str, args: &[String]) -> Result<solidcraft_engine::schema::ParamCheck, String> {
match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => Err(format!("{cmd}: --param-check wants off, warn or strict")),
None => Ok(solidcraft_engine::schema::ParamCheck::Off),
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("{cmd}: --param-check wants off, warn or strict, not `{v}`"))
}
}
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
@@ -390,15 +367,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
fn cmd_run(args: &[String]) -> Result<(), String> {
install_renderer();
let json_out = json_out(args)?;
// Before the option check, so a bare `--param-check` gets the words `mcp` uses.
let check = parse_param_check("run", args)?;
known_args(
"run",
args,
&["--in", "--out", "--save", "--json-out", "--param-check"],
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
true,
)?;
known_args("run", args, &["--in", "--out", "--save", "--json-out"], &["--quiet", "--results", "--timings", "--profile", "--allow-errors"], true)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
@@ -411,25 +380,23 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
let (mut s, results) = if want_results || timings {
// Collect every command's result or time (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load_checked(b, check)?,
Some(b) => load(b)?,
None => Session::default(),
};
s.param_check = check;
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
let script = path.ok_or(needs)?;
let v = read_json(script)?;
let v = read_json(path.ok_or(needs)?)?;
let out = if timings {
let (r, mut t) = s.run_script_timed(&v, profile, &mut |step, r| print_param_warnings(script, step, r)).map_err(|e| e.to_string())?;
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
if want_results && let Some(o) = t.as_object_mut() {
o.insert("results".into(), Value::Array(r));
}
t
} else {
Value::Array(run_script_warning(&mut s, script, &v)?)
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
};
(s, Some(out))
} else {
(open(base, path, check)?, None)
(open(base, path)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
@@ -454,16 +421,16 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
}
/// The session `run`, `eval` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone, with scripts run at `check`.
fn open(base: Option<&str>, path: Option<&String>, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load_checked(b, check)?;
run_script_warning(&mut s, p, &read_json(p)?)?;
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
Ok(s)
}
(Some(b), None) => load_checked(b, check),
(None, Some(p)) => load_checked(p, check),
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(usage()),
}
}
@@ -506,7 +473,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let mut s = open(flag(args, "--in"), positional(args))?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
@@ -600,7 +567,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let mut s = open(flag(args, "--in"), positional(args))?;
// How the bodies are drawn and where the model is cut, through the commands the app's
// Appearance dialog and Section Analysis run (`preview`).
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
@@ -724,8 +691,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How calls that break a command's parameter contract are treated, headless or bridged.
let param_check = parse_param_check("mcp", args)?;
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
let param_check = match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
None => solidcraft_engine::schema::ParamCheck::Off,
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
}
};
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => {
@@ -733,7 +709,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
};
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
if let Err(e) = remote.try_connect() {
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
@@ -753,8 +729,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
let auto = !args.iter().any(|a| a == "--headless");
if auto && let Some(port) = discover_app() {
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
return serve_mcp(Box::new(remote), scope);
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
}
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
-107
View File
@@ -263,110 +263,3 @@ mod setup {
assert!(json_of(&pkg.join("claude_desktop_config.json"))["mcpServers"]["solidcraft"].is_object());
}
}
/// Scratch files belong to the CLI process; exercise real startup, requests and shutdown.
mod scratch_cleanup {
use super::*;
use std::path::{Path, PathBuf};
fn command(case: &str) -> (PathBuf, Command) {
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-lifecycle-{case}-{}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"));
command
.args(["mcp", "--headless"])
.env("TMPDIR", &dir)
.env("TMP", &dir)
.env("TEMP", &dir)
.env("SOLIDCRAFT_RECOVERY_DIR", dir.join("recovery"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
(dir, command)
}
fn scratch_folders(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.file_name().unwrap().to_string_lossy().starts_with("solidcraft-mcp-"))
.collect()
}
fn initialize() -> Value {
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})
}
fn run(mut command: Command, request: Value) -> std::process::Output {
let mut child = command.spawn().unwrap();
writeln!(child.stdin.take().unwrap(), "{request}").unwrap();
child.wait_with_output().unwrap()
}
#[test]
fn eof_removes_only_the_process_scratch_folder() {
let (dir, command) = command("eof");
let unrelated = dir.join("solidcraft-mcp-unrelated");
std::fs::create_dir(&unrelated).unwrap();
let out = run(command, initialize());
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let reply: Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(reply.get("result").is_some(), "{reply}");
assert_eq!(scratch_folders(&dir), vec![unrelated]);
assert!(dir.join("recovery").is_dir(), "cleanup must leave the recovery folder alone");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn startup_error_removes_the_process_scratch_folder() {
let (dir, mut command) = command("startup-error");
let out = command.args(["--param-check", "invalid"]).stdin(Stdio::null()).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success());
assert!(stderr.contains("--param-check wants off, warn or strict"), "{stderr}");
assert!(scratch_folders(&dir).is_empty(), "startup error left a scratch folder in {dir:?}");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn shutdown_preserves_nonempty_scratch_folder() {
let (dir, mut command) = command("nonempty");
let mut child = command.spawn().unwrap();
let mut input = child.stdin.take().unwrap();
writeln!(input, "{}", initialize()).unwrap();
let mut reply = String::new();
BufReader::new(child.stdout.take().unwrap()).read_line(&mut reply).unwrap();
let reply: Value = serde_json::from_str(&reply).unwrap();
assert!(reply.get("result").is_some(), "{reply}");
let scratch = scratch_folders(&dir);
assert_eq!(scratch.len(), 1);
let saved = scratch[0].join("keep.txt");
std::fs::write(&saved, "saved output").unwrap();
drop(input);
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(std::fs::read_to_string(saved).unwrap(), "saved output");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn failed_screenshot_scratch_folder_is_removed_without_a_path_scope() {
let (dir, mut command) = command("screenshot");
command.arg("--allow-any-path");
// A headless server cannot capture a window. The request still allocates a scratch
// path before the backend refuses it, without relying on a GPU or installed renderer.
let out = run(
command,
json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "screenshot", "arguments": {"source": "window"}},
}),
);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let reply: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(reply["result"]["isError"], true, "{reply}");
assert!(reply["result"]["content"][0]["text"].as_str().unwrap().contains("the window gave no picture"), "{reply}");
assert!(scratch_folders(&dir).is_empty(), "failed screenshot left a scratch folder in {dir:?}");
std::fs::remove_dir_all(dir).unwrap();
}
}
-44
View File
@@ -288,47 +288,3 @@ fn undoing_an_edit_after_opening_reuses_the_saved_results() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
/// A design saved before body names were pinned (#6403) pins them as it opens, as its first
/// command would, and opening it is no change. That command pinned them as part of itself, so
/// undoing it went back to the unpinned design, which no evaluated result matched: the first
/// undo after opening from the command line evaluated every feature again (82-93 s in a
/// 5,484-feature design); `doc.open` pinned them after opening, which marked the design changed.
#[test]
fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
let mut built = Session::default();
for c in (plate.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let mut doc = built.design().into_owned();
for f in &mut doc.features {
f.body_names.clear();
}
doc.reserved_body_names.clear();
let unpinned = Session::new(doc);
let design = unpinned.design();
let saved = unpinned.model.saved(&design).unwrap();
let bytes = solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(&saved))
.unwrap();
let path = temp("unpinned");
std::fs::write(&path, &bytes).unwrap();
let file = path.to_string_lossy().to_string();
// As the command line opens a design (`load`), and as the app does (`doc.open`).
let (doc, saved) = solidcraft_engine::io::read_design_file(&file, None::<fn(solidcraft_engine::io::DesignPreview)>).unwrap();
for (how, mut s) in [("opened_at", Session::opened_at(doc, saved.as_ref(), Some(&file))), ("doc.open", open(&path))] {
assert_eq!(s.model.last_recomputed, 0, "{how}: taken from the file");
assert!(s.doc.features.iter().any(|f| !f.body_names.is_empty()), "{how}: the names are pinned");
assert!(!s.is_dirty(), "{how}: opening is no change");
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
// As in a large design, whose evaluation cache keeps only a few results: undo finds the
// results in the model itself or evaluates them again.
*s.model.cache.lock().unwrap() = Default::default();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.last_recomputed, 0, "{how}: the first undo evaluates nothing");
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "{how}: the states read from the file");
assert!(!s.is_dirty(), "{how}: undone to the design as opened");
}
let _ = std::fs::remove_file(&path);
}
+1 -75
View File
@@ -229,9 +229,7 @@ fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let reopened = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert_eq!(reopened.metadata, document.metadata);
// #4907: `run --save` maps to Save As, and a borrowed design name
// follows the destination file's stem instead of keeping the old name.
assert_eq!(reopened.name, format!("resaved-{index}"));
assert_eq!(reopened.name, document.name);
assert_eq!(reopened.features, document.features);
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
@@ -498,78 +496,6 @@ fn timings_and_profile_report_each_command() {
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
}
/// #6921: `--param-check` checks a script's steps against their contracts on every path `run`
/// takes (a script alone, on an `--in` design or script, with `--results`, `--timings` or
/// `--profile`), and a warning reaches stderr even when a later step fails.
#[test]
fn param_check_reaches_every_script_run_takes() {
let dir = Scratch::new();
let typo = dir.0.join("typo.json");
let step = json!({"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "operaton": "new"}});
std::fs::write(&typo, json!({"commands": [step.clone()]}).to_string()).unwrap();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let saved = dir.0.join("saved.solidcraft");
let start = dir.script();
let (typo, base, saved_arg, start) = (typo.to_str().unwrap(), base.to_str().unwrap(), saved.to_str().unwrap(), start.to_str().unwrap());
let paths = [&[][..], &["--in", base], &["--in", start], &["--results"], &["--timings"], &["--timings", "--profile"]];
let run = |level: Option<&str>, extra: &[&str]| {
std::fs::remove_file(&saved).ok();
let check = level.map(|l| ["--param-check", l]).into_iter().flatten();
cli().args(["run", typo, "--save", saved_arg]).args(check).args(extra).output().unwrap()
};
// Off, the default: the step runs as it always has, without a word.
let off = run(None, &[]);
assert!(off.status.success() && !String::from_utf8_lossy(&off.stderr).contains("operaton"), "{off:?}");
assert_eq!(body_count(&saved), 1);
// Warn: each path runs the step, saves, and reports it on stderr; `--results` keeps it too.
for extra in paths {
let output = run(Some("warn"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{extra:?}: {stderr}");
assert!(saved.exists(), "{extra:?}");
if extra == ["--results"] {
let results: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(results[0]["param_warnings"][0].as_str().is_some_and(|w| w.contains("operaton")), "{results}");
}
}
// Strict: on each path the run stops at the step, and nothing is written.
for extra in paths {
let output = run(Some("strict"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("unknown key `operaton`"), "{extra:?}: {stderr}");
assert!(!saved.exists(), "{extra:?}");
}
// Warn, with a later step that fails: the run fails, and the first step's warning is still
// reported, timed or not.
let failing = dir.0.join("failing.json");
let fail = json!({"command": "solid.extrude", "params": {"sketch": "Nope"}});
std::fs::write(&failing, json!({"commands": [step, fail]}).to_string()).unwrap();
for extra in [&[][..], &["--timings"]] {
let output = cli().arg("run").arg(&failing).args(["--param-check", "warn"]).args(extra).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("no sketch `Nope`"), "{extra:?}: {stderr}");
}
// A level it doesn't know and a missing value fail.
for (bad, says) in [
(&["--param-check", "loud"][..], "--param-check wants off, warn or strict, not `loud`"),
(&["--param-check"], "run: --param-check wants off, warn or strict"),
] {
let output = cli().args(["run", typo]).args(bad).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{bad:?}: {stderr}");
assert!(stderr.contains(says), "{bad:?}: {stderr}");
}
}
fn render_setup_script(dir: &Scratch, job: Value) -> PathBuf {
let path = dir.0.join("render.json");
std::fs::write(
@@ -123,42 +123,3 @@ fn a_design_naming_a_sketch_and_its_extrude_alike_replays_from_its_script() {
assert_eq!(names, ["Block", "Block"], "{first:#}");
assert_eq!(second["commands"], first["commands"], "the replayed design's script has the same steps");
}
/// #10230: script export must disclose the print-face state that replay leaves out.
#[test]
fn script_export_warns_about_omitted_print_faces_on_stderr() {
let dir = std::env::temp_dir().join(format!("solidcraft-print-face-script-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let saved = dir.join("block.sc");
let exported = dir.join("block.json");
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap();
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 0]})).unwrap();
s.execute("file.save", &json!({"path": saved})).unwrap();
let native = std::fs::read(&saved).unwrap();
for to_file in [false, true] {
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"));
cmd.arg("script").arg(&saved);
if to_file {
cmd.arg("--out").arg(&exported);
}
let out = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{stderr}");
assert!(stderr.contains("note: the script leaves out print-face assignments (body.print_face)"), "{stderr}");
let script = if to_file {
assert!(out.stdout.is_empty(), "--out must keep stdout empty");
std::fs::read(&exported).unwrap()
} else {
out.stdout
};
let script: Value = serde_json::from_slice(&script).unwrap();
let mut replayed = Session::default();
replayed.run_script(&script).unwrap();
assert!(replayed.doc.print_faces.is_empty());
assert_eq!(replayed.doc.features, s.doc.features);
assert_eq!(std::fs::read(&saved).unwrap(), native, "export must not change the source design");
}
std::fs::remove_dir_all(dir).unwrap();
}
+1 -1
View File
@@ -34,7 +34,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
-4
View File
@@ -22,10 +22,6 @@
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/CraftUI-SC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/OFL.txt" data-target-path="fonts/craft-ui-sc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/CraftUI-TC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/OFL.txt" data-target-path="fonts/craft-ui-tc" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
+8 -27
View File
@@ -15,9 +15,6 @@ type Reply = Result<(String, Vec<u8>), String>;
pub struct Download {
reply: Rc<RefCell<Option<Reply>>>,
pending: Option<(String, Vec<u8>)>,
/// The downloaded design being opened (a few features a frame, #10198): the answer to come,
/// the name it takes and the file it was read from.
opening: Option<(std::sync::mpsc::Receiver<serde_json::Value>, String, String)>,
}
impl Download {
@@ -36,14 +33,13 @@ impl Download {
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
self.opened(app);
if let Some(reply) = self.reply.borrow_mut().take() {
match reply {
Ok(file) => self.pending = Some(file),
Err(error) => app.set_status(format!("Could not open design link: {error}"), true),
}
}
if self.pending.is_none() || self.opening.is_some() || app.file_picker_busy() {
if self.pending.is_none() || app.file_picker_busy() {
return;
}
if solidcraft_ui_egui::documents::any_dirty(app) {
@@ -75,33 +71,18 @@ impl Download {
}
}
let Some((name, bytes)) = self.pending.take() else { return };
// The extension selects the design loader even when the URL has no extension. Named
// after the link, as the progress card shows the file's name.
let file: String = name.chars().map(|c| if matches!(c, '/' | '\\') { '_' } else { c }).collect();
let path = format!("/linked/{}.{}", if file.is_empty() { "design" } else { &file }, solidcraft_engine::io::DESIGN_EXT);
vfs::insert(&path, bytes);
// Opened as any design is, with its progress and Cancel; finished in `opened`.
self.opening = Some((app.open_path_answered(&path), name, path));
self.opened(app);
}
/// The downloaded design is in: it is an unsaved copy under the link's name.
fn opened(&mut self, app: &mut SolveApp) {
let Some((rx, _, _)) = &self.opening else { return };
let (ok, cancelled) = match rx.try_recv() {
Ok(reply) => (reply["ok"].as_bool() == Some(true), reply["error"].as_str().is_some_and(|e| e.contains("cancelled"))),
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => (false, false),
};
let Some((_, name, path)) = self.opening.take() else { return };
if ok && app.session.path.as_deref() == Some(path.as_str()) {
// The extension selects the design loader even when the URL has no extension.
let path = &format!("/linked/design.{}", solidcraft_engine::io::DESIGN_EXT);
vfs::insert(path, bytes);
app.open_path(path);
if app.session.path.as_deref() == Some(path.as_str()) {
app.session.path = None;
app.session.doc_mut().name = name;
app.session.mark_unsaved();
} else if !ok && !cancelled {
} else {
app.set_status("Could not open design link: the downloaded file is not a valid SolidCraft design", true);
}
let _ = vfs::remove(&path);
let _ = vfs::remove(path);
}
}
+35 -210
View File
@@ -119,43 +119,6 @@ fn closes_design(key: &str, command: bool, shift: bool, alt: bool, design_shown:
design_shown && command && !shift && !alt && key.eq_ignore_ascii_case("w")
}
/// The settings a tab last wrote to the browser's storage, or loaded from another tab (#8644).
/// Every open tab of the app shares that storage, and eframe saves each tab's whole copy every
/// 30 s and when the page loses focus, so a tab still holding old settings put them back over a
/// change made in another. A change is now written in the frame it is made, and the other tabs
/// load it ([`settings_written`]).
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Default, Debug)]
struct PrefsWriter {
written: Option<String>,
}
#[cfg(any(target_arch = "wasm32", test))]
impl PrefsWriter {
/// `prefs` if they are not the settings written or loaded last (they count as written from
/// now on).
fn take(&mut self, prefs: String) -> Option<String> {
if self.written.as_deref() == Some(prefs.as_str()) {
return None;
}
self.written = Some(prefs.clone());
Some(prefs)
}
/// The settings the tab holds after loading another tab's: the tab doesn't write them back.
fn loaded(&mut self, prefs: String) {
self.written = Some(prefs);
}
}
/// The settings another tab wrote, from a `storage` event (the browser sends one to every other
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
/// data), leave this tab's settings as they are.
#[cfg(any(target_arch = "wasm32", test))]
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
}
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
@@ -254,29 +217,9 @@ mod web {
on_key.forget();
}
/// Settings another tab wrote, waiting for this tab's next frame (#8644).
type PrefsInbox = Rc<std::cell::Cell<Option<String>>>;
/// Settings another tab of the app writes go to `inbox`, and this tab draws a frame, which
/// loads them.
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
let ctx = ctx.clone();
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
inbox.set(Some(prefs));
ctx.request_repaint();
}
});
let _ = window.add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_storage.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
const CHINESE_SC_FONT_PATH: &str = "./fonts/CraftUI-SC.otf";
const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<(String, Vec<u8>), String>>>>;
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
struct Shell {
app: SolveApp,
@@ -294,63 +237,18 @@ mod web {
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand script fonts: fetched only when a CJK language is selected.
/// On-demand CJK font: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
/// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
/// The fetched Chinese font's bytes (Simplified or Traditional for this session).
chinese_bytes: Option<Vec<u8>>,
/// Which Chinese face `chinese_bytes` is: `"zh"` or `"zh-TW"`.
chinese_kind: Option<&'static str>,
/// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, or `"zh-TW"`).
font_fetch_kind: Option<&'static str>,
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
font_language: String,
space_mouse: SpaceMouse,
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
other_tab_prefs: PrefsInbox,
}
impl Shell {
/// The settings as they are kept between visits: with the user's own language, not a
/// session-only English fallback.
fn persisted_prefs(&mut self) -> String {
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
let prefs = self.app.prefs();
self.app.preferences.language = live;
prefs
}
/// A settings change goes to the browser's storage in the frame it is made, and from
/// there to the other open tabs (#8644). Input is what changes settings, so a frame
/// without any is skipped; eframe's own save still catches anything else.
fn store_prefs(&mut self, ctx: &egui::Context) {
if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) {
return;
}
let prefs = self.persisted_prefs();
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
let stored =
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
if !matches!(stored, Some(Ok(()))) {
log::warn!("settings: the browser's storage did not take them; they are saved again later");
self.prefs_writer = crate::PrefsWriter::default();
}
}
/// Settings another tab wrote replace this tab's, so this tab's own saves keep them
/// instead of putting its older copy back (#8644).
fn load_other_tabs_prefs(&mut self) {
let Some(prefs) = self.other_tab_prefs.take() else { return };
self.app.load_prefs(&prefs);
let held = self.persisted_prefs();
self.prefs_writer.loaded(held);
}
/// Files from the computer, writes to store, messages and the autosave.
fn files_tick(&mut self, ctx: &egui::Context) {
if !self.launch.persists() {
@@ -375,8 +273,7 @@ mod web {
// Designs opened from the computer's disk (the files window): open them.
let opened: Vec<_> = self.files.opened.borrow_mut().drain(..).collect();
for path in opened {
// A few features a frame, with progress and Cancel (#10198).
self.app.open_path_async(&path);
self.app.open_path(&path);
self.files.panel.set(false);
}
// Recent designs whose file is gone are dropped from the list.
@@ -417,7 +314,7 @@ mod web {
path
};
match intent {
Intent::Open => self.app.open_path_async(&path),
Intent::Open => self.app.open_path(&path),
Intent::Insert | Intent::Theme => self.app.drop_path(&path),
}
self.files.panel.set(false);
@@ -581,7 +478,7 @@ mod web {
let path = format!("{}{name}", files::OPFS);
match what {
"open" => {
self.app.open_path_async(&path);
self.app.open_path(&path);
self.files.panel.set(false);
}
"insert" => {
@@ -683,102 +580,65 @@ mod web {
files::autosave(&self.files, None);
}
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
_ => true,
}
}
/// Fetch and install a script font when a CJK language is selected. If that fails, this
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
/// session falls back to English (no tofu); the saved language preference is kept.
fn tick_font(&mut self, ctx: &egui::Context) {
let done = self.font_result.borrow_mut().take();
if let Some(res) = done {
let kind = self.font_fetch_kind.take();
let installed = match res {
Ok((path, bytes)) => {
match kind {
Some("zh") => {
self.chinese_bytes = Some(bytes);
self.chinese_kind = Some("zh");
}
Some("zh-TW") => {
self.chinese_bytes = Some(bytes);
self.chinese_kind = Some("zh-TW");
}
_ => {
self.cjk_bytes = Some(bytes);
}
}
log::info!("loaded web script font from {path}");
Ok(bytes) => {
self.cjk_bytes = Some(bytes);
true
}
Err(err) => {
let path = match kind {
Some("zh") => CHINESE_SC_FONT_PATH,
Some("zh-TW") => CHINESE_TC_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
log::warn!("failed to fetch script font from {path}: {err}");
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
false
}
};
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
if installed {
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
} else if solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) {
log::warn!("CJK font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
self.apply_fonts(ctx);
ctx.request_repaint();
}
let ready = self.script_font_ready();
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
if self.font.tick(&mut self.app.preferences.language) {
self.fetch_font(ctx);
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
if self.app.preferences.language != self.font_language {
self.apply_fonts(ctx);
}
}
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
/// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback
/// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
solidcraft_ui_egui::fonts::install_web_script_fonts(ctx, self.cjk_bytes.clone(), self.chinese_bytes.clone(), effective);
match self.cjk_bytes.clone() {
Some(bytes) => {
solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language);
}
None => solidcraft_ui_egui::fonts::setup_fonts(ctx),
}
self.font_language = effective.to_owned();
self.font_language.clone_from(&self.app.preferences.language);
}
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
/// fetch the font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx, "ja");
self.fetch_font(ctx);
}
}
fn fetch_font(&mut self, ctx: &egui::Context, kind: &'static str) {
let path = match kind {
"zh" => CHINESE_SC_FONT_PATH,
"zh-TW" => CHINESE_TC_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
self.font_fetch_kind = Some(kind);
fn fetch_font(&self, ctx: &egui::Context) {
let slot = self.font_result.clone();
let ctx = ctx.clone();
let path = path.to_owned();
wasm_bindgen_futures::spawn_local(async move {
let res = fetch_font_bytes(&path).await.map(|bytes| (path, bytes));
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
*slot.borrow_mut() = Some(res);
ctx.request_repaint();
});
@@ -787,8 +647,6 @@ mod web {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// First: a language another tab chose fetches its font this frame.
self.load_other_tabs_prefs();
self.tick_font(ctx);
self.app.logic(ctx);
self.api.poll();
@@ -831,7 +689,6 @@ mod web {
self.recovery_prompt(&ctx);
}
self.watch_text_for_font(&ctx);
self.store_prefs(&ctx);
}
fn persist_egui_memory(&self) -> bool {
self.launch.persists()
@@ -840,8 +697,11 @@ mod web {
if !self.launch.persists() {
return;
}
let prefs = self.persisted_prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
// Persist the user's own language, not a session-only English fallback.
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
self.app.preferences.language = live;
}
}
@@ -962,12 +822,6 @@ mod web {
let (sender, receiver) = std::sync::mpsc::channel();
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
app.embedded = launch.embed;
// Listening before reading: settings another tab writes from now on are
// newer than those read here.
let other_tab_prefs = PrefsInbox::default();
if launch.persists() {
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
}
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
}
@@ -1008,14 +862,9 @@ mod web {
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
cjk_bytes: None,
chinese_bytes: None,
chinese_kind: None,
font_fetch_kind: None,
font_language,
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
other_tab_prefs,
}))
}),
)
@@ -1152,28 +1001,4 @@ mod tests {
assert!(!closes_design("w", false, false, false, true));
assert!(!closes_design("s", true, false, false, true));
}
/// A tab writes its settings when they change, and not again while they stay the same; the
/// settings it loaded from another tab aren't written back (#8644).
#[test]
fn changed_settings_are_written_once_and_loaded_ones_not_at_all() {
let mut writer = PrefsWriter::default();
assert_eq!(writer.take(r#"{"a":1}"#.into()).as_deref(), Some(r#"{"a":1}"#), "the first settings are written");
assert_eq!(writer.take(r#"{"a":1}"#.into()), None, "the same settings are not written again");
assert_eq!(writer.take(r#"{"a":2}"#.into()).as_deref(), Some(r#"{"a":2}"#), "a change is written at once");
writer.loaded(r#"{"a":3}"#.into());
assert_eq!(writer.take(r#"{"a":3}"#.into()), None, "settings from another tab are not written back");
assert_eq!(writer.take(r#"{"a":4}"#.into()).as_deref(), Some(r#"{"a":4}"#), "this tab's next change is written");
}
/// Only the settings another tab wrote are loaded: not other keys, and not settings taken
/// away by clearing the site's data (#8644).
#[test]
fn only_settings_another_tab_wrote_are_loaded() {
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
}
}
-5
View File
@@ -104,10 +104,5 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
raw-window-handle = "0.6"
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# The desktop's dark or light preference through the XDG Desktop Portal (src/desktop_theme.rs,
# #7944); already in the tree through eframe's accessibility support (accesskit_unix).
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
[build-dependencies]
embed-resource = "3.0.11"
+19 -241
View File
@@ -22,10 +22,6 @@ const MAX_BAD_LINES: usize = 8;
/// How long a subscribed connection waits after a `design.changed` line before the next: what
/// changes meanwhile (a parameter dragged) goes out as one line, the latest (#6985).
const EVENT_GAP: Duration = Duration::from_millis(100);
/// How long a new connection has to show the token. A client connects when it has a request to
/// send, so this is plenty; one that doesn't send it loses the connection, and its seat among
/// [`MAX_CONNECTIONS`] goes to someone else.
const TOKEN_WAIT: Duration = Duration::from_secs(5);
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
@@ -229,53 +225,23 @@ fn unauthorized() -> Value {
/// Refuse the connection: the reply, then the end of what we send, then what the client had
/// already sent read and dropped (a little, briefly). Closing a socket with unread input resets
/// the connection, and a reset can reach the client before it has read the refusal.
fn refuse(out: &mut TcpStream, reader: &mut BufReader<ClientInput>) {
fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
use std::io::Read;
let _ = send(out, &unauthorized());
let _ = out.shutdown(std::net::Shutdown::Write);
reader.get_mut().deadline = Some(Instant::now() + Duration::from_millis(500));
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_millis(500)));
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
}
/// What a client sends. Until it shows the token, all of its reads share one deadline, so neither
/// blank lines nor a line sent a byte at a time buy it more time. After that it may stay idle as
/// long as it likes.
struct ClientInput {
socket: TcpStream,
deadline: Option<Instant>,
}
impl ClientInput {
/// No deadline any more: the client showed the token.
fn admit(&mut self) -> std::io::Result<()> {
self.deadline = None;
self.socket.set_read_timeout(None)
}
}
impl std::io::Read for ClientInput {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(deadline) = self.deadline {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::ErrorKind::TimedOut.into());
}
self.socket.set_read_timeout(Some(left))?;
}
self.socket.read(buf)
}
}
/// How long a request waits for the app's reply: most requests, and a batch (or every request,
/// on a patient connection); and how long a new connection has to show the token.
/// on a patient connection).
#[derive(Clone, Copy)]
struct Waits {
short: Duration,
long: Duration,
token: Duration,
}
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600), token: TOKEN_WAIT };
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) };
/// A request that timed out but is still running in the app (#1618): its id and method, and where
/// its reply will land.
@@ -447,37 +413,11 @@ impl Lines {
}
}
/// A subscribed connection's seat on the watch: it leaves exactly once, whichever thread sees
/// the connection's end first — the connection's own thread at the end of the stream, or the
/// events thread at the first write a closed client refuses. The seat used to wait for the
/// events thread alone, and that thread only leaves its wait when the design moves, so the
/// count could outlive the connection by a second or more (#8315).
struct Seat(Mutex<Option<Subscriber>>);
impl Seat {
fn new(counted: Subscriber) -> Self {
Seat(Mutex::new(Some(counted)))
}
/// Leave the watch now: the seat goes once, whoever gets here first.
fn release(&self) {
let counted = self.0.lock().unwrap_or_else(PoisonError::into_inner).take();
drop(counted);
}
}
impl Drop for Seat {
fn drop(&mut self) {
self.release();
}
}
/// A connection's `control.subscribe` (#6985): its `design.changed` lines, sent from a thread of
/// their own until the connection ends.
struct Subscription {
lines: Arc<Lines>,
stop: Arc<AtomicBool>,
seat: Arc<Seat>,
}
impl Subscription {
@@ -493,17 +433,15 @@ impl Subscription {
) -> Option<(Self, std::io::Result<()>)> {
let lines = Arc::new(Lines { socket: out.try_clone().ok()?, turn: Mutex::new(()) });
let stop = Arc::new(AtomicBool::new(false));
let seat = Arc::new(Seat::new(counted));
let first = lines.turn.lock().unwrap_or_else(PoisonError::into_inner);
let (l, s, watch_seat) = (lines.clone(), stop.clone(), Arc::clone(&seat));
let (l, s) = (lines.clone(), stop.clone());
// The connection's seat on the watch rides the events thread: it leaves when the thread
// does, so the next failed write to a closed client retires it even while the connection's
// own thread is slow to see the end of the stream (#7226). The connection's own thread
// leaves it too, so a closed connection stops counting at once (#8315).
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, watch_seat)).ok()?;
// own thread is slow to see the end of the stream (#7226).
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, counted)).ok()?;
let sent = send(&mut &lines.socket, reply);
drop(first);
Some((Subscription { lines, stop, seat }, sent))
Some((Subscription { lines, stop }, sent))
}
}
@@ -516,9 +454,6 @@ fn design_of(reply: &Value) -> Option<(String, u64)> {
impl Drop for Subscription {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// The seat on the watch goes now: the connection is over, and nothing counts it any more
// (#8315).
self.seat.release();
// The socket closes now, not when the thread next looks; a line blocked on a client that
// stopped reading ends too.
let _ = self.lines.socket.shutdown(std::net::Shutdown::Both);
@@ -526,10 +461,10 @@ impl Drop for Subscription {
}
/// The `design.changed` lines of a subscribed connection, each time the design differs from the
/// last one it was told of (`seen`), until `stop`. Holds the connection's seat on the watch
/// (`seat`), which it leaves when the thread ends: on `stop`, or on the first write a closed
/// client refuses (#7226).
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), seat: Arc<Seat>) {
/// last one it was told of (`seen`), until `stop`. Owns the connection's seat on the watch
/// (`counted`), so the seat leaves exactly when this thread does: on `stop`, or on the first
/// write a closed client refuses (#7226).
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), _counted: Subscriber) {
while !stop.load(Ordering::Relaxed) {
let Some(now) = design.wait(&seen, stop, Duration::from_secs(1)).filter(|now| *now != seen) else { continue };
if lines.send(&json!({"event": "design.changed", "params": {"document_id": now.0, "revision": now.1}})).is_err() {
@@ -538,17 +473,6 @@ fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen:
seen = now;
std::thread::sleep(EVENT_GAP);
}
// The client is gone: it is not counted on the watch any more, whatever the connection's own
// thread still has to notice (#8315).
seat.release();
}
/// What this app's control channel does beyond the methods every version has, for a client to
/// find out before it relies on it (an older app answers `control.capabilities` with "unknown
/// method"). `param_check`: the levels `engine.execute`, `engine.batch` and `engine.script` take
/// as a request's own parameter check (#9184).
fn capabilities() -> Value {
json!({"param_check": solidcraft_engine::schema::ParamCheck::ALL.map(|l| l.name())})
}
/// Write `reply` on the connection: through its subscription once it has one.
@@ -577,7 +501,7 @@ fn serve_with(
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(ClientInput { socket: read, deadline: Some(Instant::now() + waits.token) });
let mut reader = BufReader::new(read);
let mut bad = 0usize;
let mut authenticated = false;
let mut pending: Option<Pending> = None;
@@ -588,10 +512,6 @@ fn serve_with(
let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
Ok(None) => break,
// Closed without a word: a client that connected early and only now sends its first
// request would read a line here as the reply to it. It finds the connection closed
// and opens another.
Err(e) if !authenticated && matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => break,
Err(e) => {
let _ = answer(&mut out, events.as_ref(), &json!({"ok": false, "error": e.to_string()}));
break;
@@ -603,12 +523,7 @@ fn serve_with(
// The first request must carry the token; later ones on the same connection may.
if !authenticated {
match token_of(&line) {
Some(t) if control_auth::tokens_match(&t, token) => {
if reader.get_mut().admit().is_err() {
break;
}
authenticated = true;
}
Some(t) if control_auth::tokens_match(&t, token) => authenticated = true,
_ => {
refuse(&mut out, &mut reader);
break;
@@ -620,20 +535,15 @@ fn serve_with(
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A request that timed out may have finished since (#1618). `control.running` and
// `control.capabilities` only look, so they leave that reply where it is.
let late = pending.as_ref().filter(|_| !matches!(method.as_str(), "control.running" | "control.capabilities")).and_then(|p| match p
.reply
.try_recv()
{
// A request that timed out may have finished since (#1618). `control.running` only
// looks, so it leaves that reply where it is.
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
Ok(reply) => Some(reply),
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
Err(TryRecvError::Empty) => None,
});
let mut r = if method == "control.running" {
json!({"ok": true, "result": running.result(connection)})
} else if method == "control.capabilities" {
json!({"ok": true, "result": capabilities()})
} else if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
@@ -718,80 +628,6 @@ fn serve_with(
mod tests {
use super::*;
/// A connection that doesn't show the token in time is closed without a word, however it fills
/// the time. One that does keeps working when it idles for longer than that.
#[test]
fn the_token_has_a_time_limit_and_an_authenticated_client_may_idle() {
use std::io::Read;
let token = "0123456789abcdef0123456789abcdef";
let limit = Duration::from_millis(500);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
static RUNNING: Running = Running::new();
let waits = Waits { short: Duration::from_secs(10), long: Duration::from_secs(10), token: limit };
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH));
}
});
let open = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(20))).unwrap();
s
};
// Sends `drip` every 50 ms until the server closes; what it read, and when it closed.
let closed_after = |drip: &[u8]| {
let mut s = open();
let start = Instant::now();
s.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
let mut got = Vec::new();
loop {
let mut buf = [0u8; 256];
match s.read(&mut buf) {
Ok(0) => break,
Ok(n) => got.extend_from_slice(buf.get(..n).unwrap()),
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
assert!(start.elapsed() < Duration::from_secs(20), "still open");
let _ = s.write_all(drip);
}
Err(_) => break,
}
}
(got, start.elapsed())
};
for drip in [&b""[..], b"\n", b"\r\n \n", b" ", b"{"] {
let (got, took) = closed_after(drip);
assert!(got.is_empty(), "{drip:?} was answered: {}", String::from_utf8_lossy(&got));
assert!(took >= limit.saturating_sub(Duration::from_millis(50)), "{drip:?} closed after {took:?}");
}
let mut s = open();
let mut lines = BufReader::new(s.try_clone().unwrap());
let mut ask = |line: Value| -> Value {
writeln!(s, "{line}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
};
assert_eq!(ask(json!({"id": 1, "method": "ui.view", "token": token}))["ok"], true);
std::thread::sleep(limit * 3);
assert_eq!(ask(json!({"id": 2, "method": "ui.view"}))["ok"], true, "idle for longer than the limit");
// A wrong token is still answered, at once.
let mut wrong = open();
writeln!(wrong, "{}", json!({"id": 1, "method": "ui.view", "token": "nope"})).unwrap();
let mut l = String::new();
BufReader::new(wrong).read_line(&mut l).unwrap();
assert!(l.contains("unauthorized"), "{l}");
}
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
/// is queued to run unseen behind it.
@@ -815,7 +651,7 @@ mod tests {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100), token: TOKEN_WAIT };
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
static RUNNING: Running = Running::new();
std::thread::spawn(move || {
if let Some(stream) = listener.incoming().flatten().next() {
@@ -881,7 +717,7 @@ mod tests {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60), token: TOKEN_WAIT };
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
@@ -914,9 +750,6 @@ mod tests {
let mut watcher = open();
let (r, rows) = running(&mut watcher, &|_| true);
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
// What the app's control channel does beyond the methods every version has (#9184).
let r = watcher(json!({"id": 0, "method": "control.capabilities", "token": token}));
assert_eq!((&r["ok"], &r["result"]["param_check"]), (&json!(true), &json!(["off", "warn", "strict"])), "{r}");
let mut export = open();
let path = "/private/part.step";
@@ -948,9 +781,6 @@ mod tests {
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
assert_eq!(own.len(), 1, "{r}");
assert_eq!(own[0]["command"], "file.export", "{r}");
// So may it ask what the app can do: answered at once, not refused as busy.
let r = export(json!({"id": 8, "method": "control.capabilities"}));
assert_eq!(r["result"]["param_check"], json!(["off", "warn", "strict"]), "{r}");
gate_tx.send(()).unwrap();
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
@@ -1056,58 +886,6 @@ mod tests {
assert!(!WATCH.subscribed());
}
/// #8315: a closed connection leaves the watch at once. The seat of a subscribed connection
/// goes when the connection's own thread sees the end of the stream, not only when the
/// events thread notices: that thread waits for the design to move, so a connection that
/// closed while the design stood still kept the watch counting it for a second or more.
#[test]
fn a_closed_connection_leaves_the_watch_at_once() {
static RUNNING: Running = Running::new();
static WATCH: DesignWatch = DesignWatch::new();
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
// The window names the design once and then leaves it alone: nothing moves it, so the
// events thread has nothing to write and only the end of the connection can retire it.
std::thread::spawn(move || {
for req in rx {
if req.method == "control.subscribe" {
WATCH.publish("d-1", 3);
let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}}));
} else {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, WAITS, &RUNNING, &WATCH));
}
});
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut out = s.try_clone().unwrap();
let mut lines = BufReader::new(s);
writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
let reply: Value = serde_json::from_str(&l).unwrap();
assert!(reply["ok"] == true, "{reply}");
assert!(WATCH.subscribed(), "the connection is subscribed");
// Let the events thread settle into its wait: the design stands still, so it has nothing
// to write, and only the end of the connection can retire it.
std::thread::sleep(Duration::from_millis(200));
// The client goes away without asking anything else.
drop((out, lines));
let deadline = Instant::now() + Duration::from_millis(400);
while WATCH.subscribed() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!WATCH.subscribed(), "a closed connection is not counted on the watch");
}
/// A timed-out request that only reads is not said to "still apply", nor is one that only
/// writes a file (an export, #1989); a change is, by name.
#[test]
-195
View File
@@ -1,195 +0,0 @@
//! Linux: the desktop's dark or light preference, for Theme: System (#7944).
//!
//! The window toolkit does not pass it on everywhere: on GNOME (Wayland, or X11 through XWayland)
//! the window never learns it, nor when it changes, so the app kept its colours. This reads the
//! freedesktop setting through the XDG Desktop Portal's Settings interface on the session bus
//! (`org.freedesktop.appearance color-scheme`, then GNOME's `color-scheme` and `gtk-theme`), the
//! same setting other applications follow, and inside a Flatpak too. It runs on a thread of its
//! own: the first reading, then the portal's `SettingChanged` signal; where the signal can't be
//! had, a reading every few seconds. Without a portal it asks `gsettings` instead, on the same
//! thread. The window reads the last answer with an atomic load
//! (`solidcraft_ui_egui::Services::desktop_dark`), and the decision from the raw values is
//! `solidcraft_ui_egui::prefs::desktop_prefers_dark`.
use std::io::Read;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use zbus::zvariant::{OwnedValue, Value};
const UNKNOWN: u8 = 0;
const DARK: u8 = 1;
const LIGHT: u8 = 2;
/// How often to read again where the portal's change signal is not available.
const POLL: Duration = Duration::from_secs(5);
/// How long one `gsettings` reading may take before it is given up.
const GSETTINGS_TIMEOUT: Duration = Duration::from_secs(2);
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const APPEARANCE: &str = "org.freedesktop.appearance";
const GNOME: &str = "org.gnome.desktop.interface";
/// Start following the desktop's preference; the closure answers the latest reading (`None`
/// until there is one, or when the desktop says nothing).
pub fn start(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
let state = Arc::new(AtomicU8::new(UNKNOWN));
let (thread_state, ctx) = (state.clone(), ctx.clone());
if let Err(e) = std::thread::Builder::new().name("desktop-theme".into()).spawn(move || follow(&thread_state, &ctx)) {
log::warn!("desktop theme: no thread to follow it: {e}");
}
Box::new(move || match state.load(Ordering::Relaxed) {
DARK => Some(true),
LIGHT => Some(false),
_ => None,
})
}
/// Keep `state` on the desktop's preference, asking for a frame whenever it changes.
fn follow(state: &AtomicU8, ctx: &egui::Context) {
let publish = |dark: Option<bool>| {
let code = match dark {
Some(true) => DARK,
Some(false) => LIGHT,
None => UNKNOWN,
};
if state.swap(code, Ordering::Relaxed) != code {
ctx.request_repaint();
}
};
let portal = match portal() {
Ok(p) => Some(p),
Err(e) => {
log::info!("desktop theme: no settings portal ({e}); asking gsettings");
None
}
};
// The portal first; gsettings where there is none or it says nothing (its backend missing).
let read = |portal: Option<&zbus::blocking::Proxy<'static>>| portal.and_then(read_portal).or_else(read_gsettings);
let from_portal = portal.as_ref().and_then(read_portal);
let first = from_portal.or_else(read_gsettings);
publish(first);
// Wait for the portal's signal only when the portal answers: else the reading is gsettings',
// which only a new reading notices.
if let Some(p) = portal.as_ref().filter(|_| from_portal.is_some()) {
match p.receive_signal("SettingChanged") {
Ok(changes) => {
for message in changes {
if relevant(&message) {
publish(read(Some(p)));
}
}
log::info!("desktop theme: the portal's change signal ended; reading every few seconds");
}
Err(e) => log::info!("desktop theme: no change signal ({e}); reading every few seconds"),
}
} else if portal.is_none() && first.is_none() && !gsettings_available() {
// Neither the portal nor gsettings: nothing to follow; the window toolkit's theme stays.
return;
}
loop {
std::thread::sleep(POLL);
publish(read(portal.as_ref()));
}
}
/// The portal's Settings interface on the session bus.
fn portal() -> zbus::Result<zbus::blocking::Proxy<'static>> {
let connection = zbus::blocking::Connection::session()?;
zbus::blocking::Proxy::new(&connection, PORTAL, PORTAL_PATH, SETTINGS)
}
/// One reading through the portal.
fn read_portal(portal: &zbus::blocking::Proxy<'static>) -> Option<bool> {
let appearance = read_setting(portal, APPEARANCE, "color-scheme").and_then(|v| as_u32(&v));
// The GNOME settings only matter when the freedesktop one has no preference.
if matches!(appearance, Some(1 | 2)) {
return solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, None, None);
}
let gnome = read_setting(portal, GNOME, "color-scheme").and_then(|v| as_string(&v));
let gtk_theme = read_setting(portal, GNOME, "gtk-theme").and_then(|v| as_string(&v));
solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, gnome.as_deref(), gtk_theme.as_deref())
}
/// A setting through `ReadOne` (portal version 2), else the older `Read`.
fn read_setting(portal: &zbus::blocking::Proxy<'static>, namespace: &str, key: &str) -> Option<OwnedValue> {
let one: zbus::Result<OwnedValue> = portal.call("ReadOne", &(namespace, key));
one.or_else(|_| -> zbus::Result<OwnedValue> { portal.call("Read", &(namespace, key)) }).ok()
}
/// Whether a `SettingChanged` signal is about a setting the preference is read from.
fn relevant(message: &zbus::Message) -> bool {
let body = message.body();
let Ok((namespace, key, _)) = body.deserialize::<(String, String, OwnedValue)>() else { return false };
matches!((namespace.as_str(), key.as_str()), (APPEARANCE, "color-scheme") | (GNOME, "color-scheme" | "gtk-theme"))
}
/// The value inside the variants `Read` wraps it in (a portal answers one or two deep).
fn unwrap_variants<'v, 'a>(value: &'v Value<'a>) -> &'v Value<'a> {
let mut value = value;
for _ in 0..4 {
match value {
Value::Value(inner) => value = &**inner,
_ => break,
}
}
value
}
fn as_u32(value: &Value<'_>) -> Option<u32> {
match unwrap_variants(value) {
Value::U32(n) => Some(*n),
Value::I32(n) => u32::try_from(*n).ok(),
Value::U8(n) => Some(u32::from(*n)),
_ => None,
}
}
fn as_string(value: &Value<'_>) -> Option<String> {
match unwrap_variants(value) {
Value::Str(s) => Some(s.as_str().to_string()),
_ => None,
}
}
/// Without a portal: GNOME's settings through `gsettings` (`None` when it is not there).
fn read_gsettings() -> Option<bool> {
let gnome = gsettings("color-scheme");
let gtk_theme = gsettings("gtk-theme");
solidcraft_ui_egui::prefs::desktop_prefers_dark(None, gnome.as_deref(), gtk_theme.as_deref())
}
fn gsettings_available() -> bool {
gsettings("gtk-theme").is_some()
}
/// `gsettings get org.gnome.desktop.interface <key>`, its quoted answer, or `None` when the
/// program is missing, fails or does not answer in time.
fn gsettings(key: &str) -> Option<String> {
let mut child = std::process::Command::new("gsettings")
.args(["get", GNOME, key])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(None) if started.elapsed() < GSETTINGS_TIMEOUT => std::thread::sleep(Duration::from_millis(20)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = String::new();
child.stdout.take()?.take(4096).read_to_string(&mut out).ok()?;
let value = out.trim().trim_matches('\'').to_string();
(!value.is_empty()).then_some(value)
}
+10 -93
View File
@@ -5,25 +5,12 @@
//! for recovery as a forced quit does, and starts again ([`restart`]), and the new start says what
//! happened ([`note`]). A device lost again soon after such a restart starts the next one on the
//! software renderer, which doesn't depend on the graphics driver.
//!
//! The old process ends without tearing down its GPU objects ([`end`]): on a lost NVIDIA device
//! destroying the window's swap chain busy-waits in the driver and never returns (#9044).
use crate::startup;
/// Set on the start after a lost device: when the device was lost (seconds since 1970).
pub const LOST_ENV: &str = "SOLIDCRAFT_DEVICE_LOST";
/// Set on the start after a lost device: why it was lost, as wgpu said it. The old process's
/// session journal, which recorded it, is gone by then.
pub const REASON_ENV: &str = "SOLIDCRAFT_DEVICE_LOST_REASON";
/// The longest reason passed on ([`REASON_ENV`]) and shown, in characters.
const REASON_CHARS: usize = 300;
/// How long the process may take to end ([`end`]) before it is aborted.
const END_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
/// A loss this recent (seconds) counts for the start after it: its note, and a second loss
/// within it starts on the software renderer. Inherited by later starts, an older stamp is
/// ignored.
@@ -45,54 +32,27 @@ pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool
after_recent_loss || cpu_renderer
}
/// `reason` on one line, at most [`REASON_CHARS`] long; None when there is nothing to say.
pub fn reason(reason: Option<&str>) -> Option<String> {
let line = reason?.split_whitespace().collect::<Vec<_>>().join(" ");
(!line.is_empty()).then(|| line.chars().take(REASON_CHARS).collect())
}
/// What the start after a lost device tells the user, `software` when it runs on the software
/// renderer, with what the driver reported ([`REASON_ENV`]).
pub fn note(software: bool, reason: Option<&str>) -> String {
/// renderer.
pub fn note(software: bool) -> String {
let mut note = String::from(
"The graphics device was reset (the graphics driver stopped responding), so SolidCraft closed and started again. Unsaved changes were kept for recovery.",
);
if software {
note.push_str(" This start uses software rendering, which doesn't depend on the graphics driver.");
}
if let Some(reason) = self::reason(reason) {
note.push_str(&format!(" The graphics driver reported: {reason}"));
}
note
}
/// The command that starts `exe` again with `args`, the loss stamped at `lost_at` and its
/// `reason`, on the software renderer when `software`.
fn command(
exe: std::path::PathBuf,
args: impl IntoIterator<Item = std::ffi::OsString>,
software: bool,
lost_at: u64,
reason: &str,
) -> std::process::Command {
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at`, on the software
/// renderer when `software`.
pub fn restart(software: bool, lost_at: u64) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = std::process::Command::new(exe);
command.args(args).env(LOST_ENV, lost_at.to_string());
match self::reason(Some(reason)) {
Some(reason) => command.env(REASON_ENV, reason),
// Not one inherited from an earlier loss.
None => command.env_remove(REASON_ENV),
};
command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string());
if software {
command.env(startup::RENDERER_ENV, "cpu");
}
command
}
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at` and its `reason`,
/// on the software renderer when `software`.
pub fn restart(software: bool, lost_at: u64, reason: &str) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = command(exe, std::env::args_os().skip(1), software, lost_at, reason);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
@@ -104,23 +64,6 @@ pub fn restart(software: bool, lost_at: u64, reason: &str) {
}
}
/// End the process now, without tearing down what is left on the lost device: the window's swap
/// chain, the viewport's buffers and pipelines. On NVIDIA's Vulkan driver destroying the swap
/// chain of a lost device busy-waits forever, which left the old window frozen at 100% of a core
/// beside the new one (#9044). Should the exit itself get stuck in a library's exit handler, the
/// process is aborted after [`END_GRACE`].
pub fn end() -> ! {
let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| {
std::thread::sleep(END_GRACE);
eprintln!("SolidCraft graphics: still closing after the device was lost; aborting");
std::process::abort();
});
if let Err(e) = watchdog {
log::warn!("graphics: no exit watchdog: {e}");
}
std::process::exit(0)
}
#[cfg(test)]
mod tests {
/// Only a recent, well-formed stamp counts: a later start that inherited it says nothing.
@@ -141,34 +84,8 @@ mod tests {
assert!(!super::restart_on_software(false, false));
assert!(super::restart_on_software(true, false));
assert!(super::restart_on_software(false, true));
assert!(super::note(true, None).contains("software rendering"));
assert!(!super::note(false, None).contains("software rendering"));
assert!(super::note(false, None).contains("kept for recovery"));
}
/// The start after a loss is told why (#9044): the old process's journal held the reason, and
/// it is deleted as that process closes.
#[test]
fn the_next_start_is_told_why_the_device_was_lost() {
let exe = std::path::PathBuf::from("solidcraft");
let args = ["--control".into(), "7979".into()];
let command = super::command(exe.clone(), args, true, 1_000, "Lost: Device lost\n (vkQueueSubmit)");
let env = |command: &std::process::Command, name: &str| {
command.get_envs().find(|(k, _)| *k == name).map(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
};
assert_eq!(env(&command, super::LOST_ENV), Some(Some("1000".into())));
assert_eq!(env(&command, super::REASON_ENV), Some(Some("Lost: Device lost (vkQueueSubmit)".into())), "on one line");
assert_eq!(env(&command, crate::startup::RENDERER_ENV), Some(Some("cpu".into())));
assert_eq!(command.get_args().collect::<Vec<_>>(), ["--control", "7979"]);
// No reason: none inherited from an earlier loss either.
let command = super::command(exe, Vec::new(), false, 1_000, " ");
assert_eq!(env(&command, super::REASON_ENV), Some(None));
assert_eq!(env(&command, crate::startup::RENDERER_ENV), None);
let note = super::note(false, Some("Lost: Device lost"));
assert!(note.ends_with("The graphics driver reported: Lost: Device lost"), "{note}");
assert!(!super::note(false, Some(" \n ")).contains("reported"));
let long = "x".repeat(1_000);
assert_eq!(super::reason(Some(&long)).map(|r| r.chars().count()), Some(300));
assert!(super::note(true).contains("software rendering"));
assert!(!super::note(false).contains("software rendering"));
assert!(super::note(false).contains("kept for recovery"));
}
}
+10 -315
View File
@@ -22,8 +22,6 @@
mod app_icon;
mod come_forward;
mod control_server;
#[cfg(target_os = "linux")]
mod desktop_theme;
mod device_lost;
#[cfg(target_os = "windows")]
mod native_crash;
@@ -99,86 +97,6 @@ struct App {
nav_bridge: nav_bridge::NavBridge,
/// Where the next file dialog opens: it follows the open design (#7756).
dialog_folder: DialogFolder,
/// Settings go to `app.ron` in the frame they change (#8275).
prefs_writer: PrefsWriter,
}
/// Settings written to the settings file soon after they change (#8275). eframe saves them
/// only every 30 s and when the window closes normally, so a change was lost whenever the app
/// did not get that far: ended from the Task Manager, stopped by a sign-out or shutdown, or
/// crashed or hung in the graphics driver on the way out.
///
/// Some settings change continuously (a panel's width while its edge is dragged), so the file
/// is written at most once per [`PrefsWriter::INTERVAL`], and a change made in between is written
/// when that interval ends.
#[derive(Default)]
struct PrefsWriter {
/// The settings written (or read at start) last.
written: Option<String>,
/// The previous frame had input that can change settings: what it changed shows in this
/// frame's settings.
input_before: bool,
/// When the settings were last written by this writer.
last_write: Option<std::time::Instant>,
/// A change waits until then to be written.
pending_until: Option<std::time::Instant>,
}
impl PrefsWriter {
/// The least time between two writes of the settings file.
const INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
/// The settings as they were read at start: they are on disk already.
fn read(prefs: String) -> Self {
Self { written: Some(prefs), ..Self::default() }
}
/// Whether this frame's settings are worth comparing: input that can change them came in this
/// frame or the one before, or a waiting change's time has come. Pointer motion alone isn't
/// such input; eframe's own save still catches anything else.
fn due(&mut self, input: bool, now: std::time::Instant) -> bool {
let due = input || self.input_before || self.pending_until.is_some_and(|at| now >= at);
self.input_before = input;
due
}
/// Write `prefs` and flush them to disk if they differ from the settings written last and the
/// last write is at least [`Self::INTERVAL`] old. Returns how long a change waits otherwise:
/// the caller asks for a frame then, so an idle app still writes it.
fn store(&mut self, storage: &mut dyn eframe::Storage, prefs: String, now: std::time::Instant) -> Option<std::time::Duration> {
if self.written.as_deref() == Some(prefs.as_str()) {
self.pending_until = None;
return None;
}
if let Some(wait) = self.last_write.map(|at| Self::INTERVAL.saturating_sub(now.saturating_duration_since(at))).filter(|w| !w.is_zero()) {
self.pending_until = Some(now + wait);
return Some(wait);
}
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs.clone());
storage.flush();
self.written = Some(prefs);
self.last_write = Some(now);
self.pending_until = None;
None
}
}
/// Input that can change a setting: a click, a key, typed or pasted text, a wheel or a pinch.
/// Pointer motion, which comes every frame while the mouse moves, cannot by itself.
fn changes_settings(event: &egui::Event) -> bool {
matches!(
event,
egui::Event::PointerButton { .. }
| egui::Event::Key { .. }
| egui::Event::Text(_)
| egui::Event::Paste(_)
| egui::Event::Cut
| egui::Event::Ime(_)
| egui::Event::MouseWheel { .. }
| egui::Event::Zoom(_)
| egui::Event::Rotate(_)
| egui::Event::AccessKitActionRequest(_)
)
}
impl eframe::App for App {
@@ -266,21 +184,11 @@ impl eframe::App for App {
solidcraft_ui_egui::space_mouse::buttons_hook(&mut self.solve, ctx, self.space_mouse.buttons(), raw);
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
content_ui(&mut self.solve, ui);
// After the frame's UI, so a setting changed in it (Preferences' OK) is written now.
let now = std::time::Instant::now();
if self.prefs_writer.due(ui.input(|i| i.events.iter().any(changes_settings)), now)
&& let Some(storage) = frame.storage_mut()
&& let Some(wait) = self.prefs_writer.store(storage, self.solve.prefs(), now)
{
ui.ctx().request_repaint_after(wait);
}
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
let prefs = self.solve.prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs.clone());
self.prefs_writer.written = Some(prefs);
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
}
fn on_exit(&mut self) {
// The agent chat's agent and everything it started end with the app.
@@ -294,29 +202,13 @@ impl eframe::App for App {
}
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
// Before a restart (`Drop for App`) starts the next instance, which would otherwise get
// nothing from the 3D mouse driver (`solidcraft_3dx_client::stop`).
// Before a restart below starts the next instance, which would otherwise get nothing from
// the 3D mouse driver (`solidcraft_3dx_client::stop`).
#[cfg(target_os = "macos")]
solidcraft_3dx_client::stop();
solidcraft_engine::crash::end_session();
// The next start (after a lost device, or into an update) begins once the app is dropped
// (`Drop for App`), when the settings are on disk.
}
}
impl Drop for App {
/// eframe drops its settings storage first, which waits for `app.ron` to be written, then the
/// app, then the window and its GPU surface. So the next start begins here: started from
/// `on_exit`, it read `app.ron` while the old process was still writing it, found it
/// unreadable and moved it aside, resetting every setting (#9044).
fn drop(&mut self) {
if let Some(gpu) = &self.gpu
&& let Some(lost_at) = gpu.lost_at
{
device_lost::restart(gpu.restart_on_software, lost_at, &gpu.reason);
// The window's GPU surface is still to be dropped, and on a lost device that may
// never return (#9044).
device_lost::end();
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
}
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
@@ -344,8 +236,6 @@ struct LostWatch {
restart_on_software: bool,
/// When the loss was seen: the app is closing to start again.
lost_at: Option<u64>,
/// Why, as wgpu said it: passed to the next start ([`device_lost::REASON_ENV`]).
reason: String,
}
impl App {
@@ -359,7 +249,6 @@ impl App {
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
gpu.lost_at = Some(device_lost::now_secs());
gpu.reason = reason;
self.solve.quit_requested = true;
}
}
@@ -533,12 +422,6 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
graphics_adapters: Vec::new(),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
window_system: window_system_now(),
// Linux: the desktop's dark or light setting, which the window is not told on every
// desktop (#7944). Windows and macOS: the window's own theme.
#[cfg(target_os = "linux")]
desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)),
#[cfg(not(target_os = "linux"))]
desktop_dark: None,
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
@@ -818,24 +701,6 @@ fn msaa() -> Option<u16> {
/// (`solidcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Linux: the window manager's own decorations (server-side, or the compositor's frame) can be
/// asked for instead of the application bar ([`titlebar_choice`]). Elsewhere always `true`.
fn app_titlebar_wanted() -> bool {
if !cfg!(all(unix, not(target_os = "macos"))) {
return true;
}
let setting = graphics().and_then(|v| v.get("titlebar").and_then(serde_json::Value::as_str).map(str::to_string));
titlebar_choice(solidcraft_engine::geom::env::var("TITLEBAR").as_deref(), setting.as_deref())
}
/// Linux: whether the application bar is the title bar. `SOLIDCRAFT_TITLEBAR` (else
/// `graphics.json`'s `titlebar`) set to `system` gives the window the desktop's own title bar and
/// buttons; `app` (or anything else, or nothing) keeps the application bar as the title bar.
fn titlebar_choice(env: Option<&str>, setting: Option<&str>) -> bool {
let choice = env.map(str::trim).filter(|v| !v.is_empty()).or(setting.map(str::trim));
!choice.is_some_and(|v| v.eq_ignore_ascii_case("system"))
}
const DEPTH_BITS: u8 = 24;
/// A 32-bit float depth buffer, drawn with reversed depth (1 near, 0 far) so its precision follows
/// the distance from the eye (#1434). Only where every adapter offers it with a stencil
@@ -885,16 +750,6 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
// SYSTEM_VERSION_COMPAT=1 makes the OS report the real (older) version so wgpu selects
// a standard the compiler accepts. The variable must be set before the process starts, so
// we detect OCLP here and re-exec immediately, before any macOS framework call (#3080, #4605).
#[cfg(target_os = "macos")]
if let Err(e) = startup::relaunch_with_version_compat() {
log::warn!("startup: OCLP check failed (continuing normally): {e}");
}
// Validate before legacy adoption, crash markers or any user preference read.
let settings = settings_dir().map_err(|error| eframe::Error::AppCreation(error.into()))?;
if std::env::var_os("SOLIDCRAFT_SETTINGS_DIR").is_none() {
@@ -966,11 +821,7 @@ fn main() -> eframe::Result {
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
let reason = std::env::var(device_lost::REASON_ENV).ok();
if let Some(reason) = device_lost::reason(reason.as_deref()) {
log::warn!("graphics: the last launch lost its device ({reason})");
}
notes.push(device_lost::note(cpu_renderer, reason.as_deref()));
notes.push(device_lost::note(cpu_renderer));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
@@ -1006,7 +857,7 @@ fn main() -> eframe::Result {
eframe::egui_wgpu::WgpuSetup::CreateNew(setup) => setup.instance_descriptor.backends,
_ => eframe::wgpu::Backends::all(),
};
let custom_titlebar = CUSTOM_TITLEBAR && app_titlebar_wanted() && !startup::system_title_bar(cfg!(target_os = "windows"), started);
let custom_titlebar = CUSTOM_TITLEBAR && !startup::system_title_bar(cfg!(target_os = "windows"), started);
eframe::egui_wgpu::set_startup_hook(startup_step);
solidcraft_engine::crash::phase("window");
let options = eframe::NativeOptions {
@@ -1110,7 +961,6 @@ fn main() -> eframe::Result {
lost: rs.device_lost.clone(),
restart_on_software: device_lost::restart_on_software(after_loss, cpu_renderer),
lost_at: None,
reason: String::new(),
});
if !safe_mode {
app.preferences.graphics_adapter =
@@ -1126,12 +976,6 @@ fn main() -> eframe::Result {
})
.collect();
let info = rs.adapter.get_info();
if !app.preferences.graphics_adapter.is_empty() && !info.name.eq_ignore_ascii_case(app.preferences.graphics_adapter.as_str()) {
eprintln!(
"SolidCraft graphics: preferred adapter {:?} was not used; using {:?} instead",
app.preferences.graphics_adapter, info.name
);
}
let samples = scene_msaa_request(saved_samples);
let supported = scene_sample_choices(depth_bits, STENCIL_BITS, |depth| {
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
@@ -1172,10 +1016,6 @@ fn main() -> eframe::Result {
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
app.session.undo.clear();
// Built straight into the session, so nothing asked for a fit: frame it at the
// home view as any opened design is (#9195). The fit waits for the first layout.
app.cam.set_view(solidcraft_engine::render::StandardView::Iso);
app.fit_view();
}
// Lock files next to open designs: a design open on another computer says so (#5467),
// the files given at launch included.
@@ -1236,8 +1076,6 @@ fn main() -> eframe::Result {
mark("app created");
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
// Before `app` moves: the settings as read, already on disk.
prefs_writer: PrefsWriter::read(app.prefs()),
solve: app,
starting,
frames: 0,
@@ -1261,13 +1099,12 @@ fn main() -> eframe::Result {
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
/// the crash journal keeps it until the app runs (a window opened later goes through the same
/// steps), and the adapter once it is picked.
/// the crash journal keeps it, and the adapter once it is picked.
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
}
solidcraft_engine::crash::startup_step(step);
solidcraft_engine::crash::phase(step);
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
@@ -1429,148 +1266,6 @@ mod backend_tests {
assert!(!prefer_x11(true, true, Some("GNOME"), over(Some("wayland"))));
assert!(prefer_x11(true, true, Some("niri"), over(Some("x11"))));
}
#[test]
fn the_desktops_title_bar_only_when_asked_for() {
use super::titlebar_choice;
// Nothing set: the application bar is the title bar, as before.
assert!(titlebar_choice(None, None));
// graphics.json's `titlebar`.
assert!(!titlebar_choice(None, Some("system")));
assert!(titlebar_choice(None, Some("app")));
// SOLIDCRAFT_TITLEBAR wins over the file; empty counts as unset; case and spaces don't matter.
assert!(!titlebar_choice(Some(" System "), Some("app")));
assert!(titlebar_choice(Some("app"), Some("system")));
assert!(!titlebar_choice(Some(""), Some("system")));
// Anything unknown keeps the application bar.
assert!(titlebar_choice(Some("ssd"), None));
}
}
#[cfg(test)]
mod prefs_writer_tests {
use super::PrefsWriter;
use solidcraft_ui_egui::prefs::{NavigatorStyle, STORAGE_KEY};
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// eframe's settings file: what was flushed to disk is what the next start reads.
#[derive(Default)]
struct Disk {
pending: HashMap<String, String>,
flushed: HashMap<String, String>,
flushes: usize,
}
impl eframe::Storage for Disk {
fn get_string(&self, key: &str) -> Option<String> {
self.pending.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.pending.insert(key.into(), value);
}
fn remove_string(&mut self, key: &str) {
self.pending.remove(key);
}
fn flush(&mut self) {
self.flushed = self.pending.clone();
self.flushes += 1;
}
}
fn new_app() -> solidcraft_ui_egui::SolveApp {
solidcraft_ui_egui::SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default())
}
/// #8275: Preferences' View navigator and Display quality, changed and confirmed, are on disk
/// in that frame, so the next start has them even when this one never closes normally (ended
/// from the Task Manager, a sign-out, a hang or crash in the graphics driver on the way out).
#[test]
fn changed_settings_reach_the_disk_in_the_frame_they_change() {
let mut app = new_app();
let mut disk = Disk::default();
let mut writer = PrefsWriter::read(app.prefs());
let now = Instant::now();
assert_eq!(writer.store(&mut disk, app.prefs(), now), None);
assert_eq!(disk.flushes, 0, "the settings read at start are not written again");
app.preferences.navigator_style = NavigatorStyle::Cube;
app.preferences.display_quality = "fine".into();
assert!(writer.due(true, now), "a frame with input is checked");
assert_eq!(writer.store(&mut disk, app.prefs(), now), None);
assert_eq!(disk.flushes, 1, "flushed at once, not left for eframe's next save");
assert_eq!(writer.store(&mut disk, app.prefs(), now + Duration::from_secs(5)), None);
assert_eq!(disk.flushes, 1, "unchanged settings are not written again");
// The next start reads them.
let mut next = new_app();
let stored = solidcraft_ui_egui::prefs::stored(|k| disk.flushed.get(k).cloned()).expect("settings on disk");
next.load_prefs(&stored);
assert_eq!(next.preferences.navigator_style, NavigatorStyle::Cube);
assert_eq!(next.preferences.display_quality, "fine");
assert!(disk.flushed.contains_key(STORAGE_KEY));
}
/// A frame without input is checked only right after one with input (whose clicks the UI
/// handled in that frame); after that, idle frames cost nothing. Pointer motion, which comes
/// every frame while the mouse moves, doesn't count as input.
#[test]
fn frames_are_checked_with_input_and_once_after_it() {
let now = Instant::now();
let mut writer = PrefsWriter::default();
assert!(!writer.due(false, now));
assert!(writer.due(true, now));
assert!(writer.due(false, now), "the frame after input");
assert!(!writer.due(false, now));
let click = egui::Event::PointerButton {
pos: egui::Pos2::ZERO,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
};
assert!(super::changes_settings(&click));
assert!(super::changes_settings(&egui::Event::Text("5".into())));
assert!(!super::changes_settings(&egui::Event::PointerMoved(egui::Pos2::ZERO)));
assert!(!super::changes_settings(&egui::Event::MouseMoved(egui::Vec2::X)));
}
/// A setting that changes in every frame (a panel's width while its edge is dragged) doesn't
/// write the file in every frame: at most once a second, and the last change is on disk at
/// most a second after it was made, with a frame asked for then so an idle app writes it.
#[test]
fn a_continuous_change_is_written_once_a_second_and_its_end_soon_after() {
let mut app = new_app();
let mut disk = Disk::default();
let mut writer = PrefsWriter::read(app.prefs());
let start = Instant::now();
let frame = Duration::from_millis(16);
let mut asked = None;
// 1.5 s of dragging at 60 frames a second, with input in every frame.
for n in 0..94u32 {
let now = start + frame * n;
app.preferences.browser_width = 200.0 + n as f32;
assert!(writer.due(true, now));
asked = writer.store(&mut disk, app.prefs(), now);
}
assert_eq!(disk.flushes, 2, "the first change at once, then one a second later");
let wait = asked.expect("the last width waits, and a frame is asked for when it can be written");
assert!(wait <= PrefsWriter::INTERVAL);
// The drag ends: no more input. Frames before the wait is over don't even look.
let end = start + frame * 93;
assert!(writer.due(false, end + frame), "the frame right after input");
assert!(!writer.due(false, end + frame * 2), "an idle frame before the wait is over");
// The frame asked for writes the last width.
let then = end + wait;
assert!(writer.due(false, then));
assert_eq!(writer.store(&mut disk, app.prefs(), then), None);
assert_eq!(disk.flushes, 3);
let mut next = new_app();
let stored = solidcraft_ui_egui::prefs::stored(|k| disk.flushed.get(k).cloned()).expect("settings on disk");
next.load_prefs(&stored);
assert_eq!(next.preferences.browser_width, 293.0, "the width the drag ended at");
assert!(!writer.due(false, then + frame), "nothing waits any more");
}
}
#[cfg(test)]
-1
View File
@@ -101,7 +101,6 @@ impl NativeMenu {
])?;
for (action, label) in [
("docs", "Documentation"),
("updates", app.tr("update.check_for_updates")),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
+10 -111
View File
@@ -137,11 +137,8 @@ pub struct AdapterChoice {
pub compatible: bool,
}
/// Choose an adapter by name. The environment override follows wgpu's case-insensitive
/// substring convention among surface-compatible adapters. A saved preference prefers an
/// exact (then case-insensitive) name among compatible adapters, and if the named card is
/// only listed as incompatible with the surface still picks it (#8341: otherwise the
/// preference is kept in the dialog while wgpu silently uses another GPU).
/// Choose only surface-compatible adapters. Names saved in preferences are exact; the
/// environment override follows wgpu's case-insensitive substring convention.
/// No match leaves selection to the normal wgpu request.
pub fn choose_adapter(adapters: &[AdapterChoice], environment: Option<&str>, preference: Option<&str>) -> Option<usize> {
// wgpu's environment helper panics on a missing name; an override must remain recoverable.
@@ -152,14 +149,12 @@ pub fn choose_adapter(adapters: &[AdapterChoice], environment: Option<&str>, pre
return Some(index);
}
}
let name = preference.filter(|s| !s.is_empty())?;
let find = |pred: &dyn Fn(&AdapterChoice) -> bool, only_compatible: bool| {
adapters.iter().enumerate().find(|(_, a)| (!only_compatible || a.compatible) && pred(a)).map(|(i, _)| i)
};
find(&|a| a.name == name, true)
.or_else(|| find(&|a| a.name.eq_ignore_ascii_case(name), true))
.or_else(|| find(&|a| a.name == name, false))
.or_else(|| find(&|a| a.name.eq_ignore_ascii_case(name), false))
if let Some(name) = preference.filter(|s| !s.is_empty())
&& let Some((index, _)) = compatible().find(|(_, a)| a.name == name)
{
return Some(index);
}
None
}
/// Leave Automatic on wgpu's normal request path; named choices may defer to it too.
@@ -297,71 +292,6 @@ pub fn window_instance_flags(flags: eframe::wgpu::InstanceFlags, intel_mac: bool
if intel_mac && env.map(str::trim).is_none_or(str::is_empty) { flags - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL } else { flags }
}
/// The environment variable that makes the macOS compatibility shim report an older OS
/// version to wgpu's Metal backend. On OCLP-patched machines the Metal compiler accepts
/// only up to the MSL standard of the original GPU, not the one the patched OS reports,
/// so wgpu picks a standard that the compiler refuses (`invalid value 'metal3.2'`).
/// Setting this variable before the process starts causes the OS to report the older
/// version and wgpu selects a supported MSL standard (#3080, #4605).
#[cfg(target_os = "macos")]
pub const SYSTEM_VERSION_COMPAT_ENV: &str = "SYSTEM_VERSION_COMPAT";
/// The NVRAM variable that OpenCore Legacy Patcher writes on every OCLP-booted system.
/// Its presence is the primary signal that `SYSTEM_VERSION_COMPAT=1` is needed.
#[cfg(target_os = "macos")]
pub const OCLP_NVRAM_KEY: &str = "4D1FDA02-38C7-4A6A-9CC6-4BCCA8B30102:OCLP-Version";
/// `true` if the OCLP NVRAM key is present on this system.
///
/// Runs `nvram <key>` and treats exit 0 as present. Any error (missing binary,
/// permission denied, key absent) returns `false` — this is a best-effort probe.
#[cfg(target_os = "macos")]
pub fn oclp_nvram_present() -> bool {
std::process::Command::new("nvram").arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
}
/// Whether to re-launch with `SYSTEM_VERSION_COMPAT=1`: it is not set yet and OCLP shows through
/// the NVRAM key. The probe runs only when it is needed, so a re-launched process (variable
/// set) never starts `nvram`.
#[cfg(target_os = "macos")]
fn compat_wanted(compat_set: bool, nvram: impl FnOnce() -> bool) -> bool {
!compat_set && nvram()
}
/// If this process is running under OCLP and `SYSTEM_VERSION_COMPAT` has not been set,
/// re-launch the current binary with `SYSTEM_VERSION_COMPAT=1` added to the environment,
/// then exit with the child's status code.
///
/// `SYSTEM_VERSION_COMPAT=1` must be present **before** the process starts: setting it
/// with `std::env::set_var` inside `main` is too late because the macOS frameworks have
/// already queried the OS version by then. Re-launching is the only safe approach that
/// works without `unsafe` (a true `execve` replacement would require it).
///
/// Returns `Ok(false)` immediately when:
/// - `SYSTEM_VERSION_COMPAT` is already set (re-launched process, no loop), or
/// - the OCLP NVRAM key is absent.
///
/// Returns `Err` only when `std::env::current_exe()` fails or the child process cannot
/// be spawned; the caller should log the error and continue normally.
///
/// Never returns `Ok(true)`: a successful re-launch exits the current process.
#[cfg(target_os = "macos")]
pub fn relaunch_with_version_compat() -> Result<bool, String> {
// A set variable means this is the re-launched process: no loop.
if !compat_wanted(std::env::var_os(SYSTEM_VERSION_COMPAT_ENV).is_some(), oclp_nvram_present) {
return Ok(false);
}
log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?;
let status = std::process::Command::new(&exe)
.args(std::env::args_os().skip(1))
.env(SYSTEM_VERSION_COMPAT_ENV, "1")
.status()
.map_err(|e| format!("could not re-launch with {}: {e}", SYSTEM_VERSION_COMPAT_ENV))?;
std::process::exit(status.code().unwrap_or(1));
}
/// Windows with OpenGL: the window keeps the system title bar (#2063). The window without OS
/// decorations keeps its caption style while having no frame, and some OpenGL drivers (older
/// Intel HD/UHD) then show the picture a caption's height lower than the window, so it no longer
@@ -421,8 +351,7 @@ mod tests {
assert_eq!(choose_adapter(&cards, None, Some("removed")), None);
assert_eq!(choose_adapter(&cards, Some("missing"), Some("Discrete small")), Some(1));
assert_eq!(choose_adapter(&cards, Some(""), Some("Discrete small")), Some(1));
assert_eq!(choose_adapter(&cards, None, Some("discrete small")), Some(1), "preference matches without regard to case");
assert_eq!(choose_adapter(&cards, None, Some("Discrete")), None, "a preference is the full name, not a substring");
assert_eq!(choose_adapter(&cards, None, Some("Discrete")), None, "saved names are exact");
}
#[test]
@@ -431,20 +360,11 @@ mod tests {
assert_eq!(choose_adapter(&cards, None, None), None);
assert_eq!(choose_adapter(&cards, Some(""), Some("")), None);
cards[2].compatible = false;
// Environment still needs a compatible adapter; a preference keeps the named card (#8341).
assert_eq!(choose_adapter(&cards, Some("large"), Some("Discrete large")), Some(2));
assert_eq!(choose_adapter(&cards, Some("large"), Some("Discrete large")), None);
assert_eq!(choose_adapter(&cards, Some("small"), None), Some(1));
assert_eq!(choose_adapter(&[], None, None), None);
}
#[test]
fn preference_keeps_a_named_card_even_when_surface_says_incompatible() {
let mut cards = cards();
cards[1].compatible = false;
assert_eq!(choose_adapter(&cards, None, Some("Discrete small")), Some(1));
assert_eq!(choose_adapter(&cards, None, Some("discrete SMALL")), Some(1));
}
#[test]
fn automatic_adapter_preserves_the_previous_request_configuration() {
use eframe::egui_wgpu::WgpuSetupCreateNew;
@@ -494,27 +414,6 @@ mod tests {
assert_eq!(window_instance_flags(on, false, None), on);
}
#[cfg(target_os = "macos")]
#[test]
fn oclp_relaunch_needs_the_signal_and_no_compat_variable() {
use super::compat_wanted;
let probed = std::cell::Cell::new(false);
let probe = |found: bool| {
let probed = &probed;
move || {
probed.set(true);
found
}
};
// The re-launched process has the variable: no probe, no second re-launch.
assert!(!compat_wanted(true, probe(true)));
assert!(!probed.get());
// The OCLP NVRAM key is the signal.
assert!(compat_wanted(false, probe(true)));
// No signal: an ordinary Mac starts as it is.
assert!(!compat_wanted(false, probe(false)));
}
#[test]
fn switches_read_from_the_environment() {
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
Binary file not shown.
-96
View File
@@ -1,96 +0,0 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
Subset for SolidCraft web UI (glyphs used by the zh locale); renamed Craft UI SC (no Reserved Font Name).
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
-96
View File
@@ -1,96 +0,0 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
Subset for SolidCraft web UI (glyphs used by the zh-TW locale); renamed Craft UI TC (no Reserved Font Name).
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+2 -5
View File
@@ -188,7 +188,7 @@ surface = [
sketch = [
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
"blend", "circle", "circle2", "circle3", "circle_tt",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse", "equation_curve",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
"extend", "finish", "fit_section", "include",
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
@@ -204,10 +204,7 @@ constraint = [
"c_polygon", "c_smooth", "c_symmetry", "c_tangent",
]
construction = [
"centerline", "construction", "construction_line", "construction_line_angle",
"construction_line_bisector", "construction_line_horizontal", "construction_line_parallel",
"construction_line_perpendicular", "construction_line_tangent_at", "construction_line_tangent_external",
"construction_line_tangent_two", "construction_line_vertical", "infinite",
"centerline", "construction", "infinite",
]
mesh = [
"mesh_body",
@@ -70,16 +70,6 @@ configuration_table false
conic false
constrainer false
construction false
construction_line false
construction_line_angle false
construction_line_bisector false
construction_line_horizontal false
construction_line_parallel false
construction_line_perpendicular false
construction_line_tangent_at false
construction_line_tangent_external false
construction_line_tangent_two false
construction_line_vertical false
contact_all false
contact_new false
contact_off false
@@ -105,7 +95,6 @@ edit false
ellipse false
emboss false
environment_map false
equation_curve false
error false
explode false
export false
@@ -181,7 +170,6 @@ pipe false
plane false
plane_along_path false
plane_angle false
plane_axis_point false
plane_midplane false
plane_perpendicular false
plane_tangent false
@@ -202,7 +190,6 @@ polygon false
polygon_circumscribed false
polygon_edge false
presspull false
print_face false
project false
project_surface false
rack_pinion false
@@ -237,7 +224,6 @@ settings_monitor false
settings_mouse false
settings_palette false
settings_plug false
settings_profile false
sheet false
sheet_rules false
shell false
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Settings profile</title>
<circle cx="32" cy="19" r="8" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 53C12 44.7157 20.9543 38 32 38C43.0457 38 52 44.7157 52 53" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 413 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Construction line through two points</title>
<path d="M8 52L56 12" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="20" cy="42" r="5.8" fill="#ffffff"/>
<circle cx="20" cy="42" r="3.4" fill="#236ead"/>
<circle cx="44" cy="22" r="5.8" fill="#ffffff"/>
<circle cx="44" cy="22" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 472 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Construction line at an angle</title>
<path d="M10.26 48.66L58.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M28.65 33.23A24 24 0 0 1 34.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10.26 48.66L54.26 11.66" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="10.26" cy="48.66" r="5.8" fill="#ffffff"/>
<circle cx="10.26" cy="48.66" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 646 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Angle bisector</title>
<path d="M13.92 12.18L9.92 52.17L54.92 44.17" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12.11 30.29A22 22 0 0 1 31.58 48.33" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M9.92 52.17L47.3 11.83" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Horizontal construction line</title>
<path d="M8 32L56 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="32" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="32" cy="32" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 362 B

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Parallel construction line</title>
<path d="M9 37L41 9" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M23 55L55 27" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Perpendicular construction line</title>
<path d="M8 44L56 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 8L32 56" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 34L42 34L42 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 490 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Tangent at a curve point</title>
<circle cx="32" cy="34.26" r="17" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M8 17.26L56 17.26" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="32" cy="17.26" r="5.8" fill="#ffffff"/>
<circle cx="32" cy="17.26" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 480 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Tangent through an external point</title>
<circle cx="21.74" cy="38.77" r="14" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M53.6 8.23L28.55 55.9" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="51.74" cy="11.77" r="5.8" fill="#ffffff"/>
<circle cx="51.74" cy="11.77" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 502 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Common tangent to two curves</title>
<circle cx="20" cy="33.59" r="12" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="46" cy="33.59" r="8" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M6 19.27L58 27.36" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 509 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Vertical construction line</title>
<path d="M32 8L32 56" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="32" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="32" cy="32" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 360 B

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Equation curve</title>
<path d="M12 8L12 56M6 32L58 32" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12 32C15.42 24.83 18.72 18 22.5 18C26.28 18 29.58 24.83 33 32C36.42 39.17 39.72 46 43.5 46C47.28 46 50.58 39.17 54 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="12" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="12" cy="32" r="3.4" fill="#236ead"/>
<circle cx="54" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="54" cy="32" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 666 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Plane through axis and point</title>
<path d="M26 15L56 32.32L38 42.71L8 25.39Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="0.1 5"/>
<path d="M15 18.4L53 40.34" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="22" cy="29" r="3.1" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Print Face</title>
<path d="M9.72 26.17L4.5 20.95L4.5 40.85L9.72 46.07Z" fill="#b8c3cf"/>
<path d="M9.72 26.17L4.5 20.95L4.5 40.85L9.72 46.07Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M18.77 23.15L9.72 26.17L9.72 46.07L18.77 43.05Z" fill="#328bd3"/>
<path d="M18.77 23.15L9.72 26.17L9.72 46.07L18.77 43.05Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M13.55 17.93L18.77 23.15L9.72 26.17L4.5 20.95Z" fill="#e2e8ef"/>
<path d="M13.55 17.93L18.77 23.15L9.72 26.17L4.5 20.95Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M50.45 37.54L38.27 25.35L38.27 33.88L50.45 46.07Z" fill="#b8c3cf"/>
<path d="M50.45 37.54L38.27 25.35L38.27 33.88L50.45 46.07Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M59.5 34.53L50.45 37.54L50.45 46.07L59.5 43.05Z" fill="#8e9faf"/>
<path d="M59.5 34.53L50.45 37.54L50.45 46.07L59.5 43.05Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M47.31 22.34L59.5 34.53L50.45 37.54L38.27 25.35Z" fill="#e2e8ef"/>
<path d="M47.31 22.34L59.5 34.53L50.45 37.54L38.27 25.35Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M50.45 46.07L38.27 33.88" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M59.5 43.05L50.45 46.07" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M24.17 34.2L28.31 34.2" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M35.31 34.2L27.31 38.7L27.31 29.7Z" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+17 -692
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2746
View File
File diff suppressed because it is too large Load Diff
+5 -1386
View File
File diff suppressed because it is too large Load Diff
+2 -90
View File
@@ -1,42 +1,22 @@
{
"A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centres of its round edges",
"Base Center": "Base Centre",
"Center X": "Centre X",
"Center Y": "Centre Y",
"Center of mass": "Centre of mass",
"Centre to centre": "Centre to centre",
"Circle center": "Circle centre",
"Colour": "Colour",
"Copy all licences": "Copy all licences",
"Customize Toolbar": "Customise Toolbar",
"Customize…": "Customise…",
"Face center": "Face centre",
"Favorites": "Favourites",
"Favourite": "Favourite",
"Mass Center": "Mass Centre",
"Modeled": "Modelled",
"Select the centre the pattern turns around first": "Select the centre the pattern turns around first",
"Snap to face and edge centres": "Snap to face and edge centres",
"Specialise": "Specialise",
"cmd.appearance_favorite": "Favourite Look",
"cmd.component_cycle_color": "Cycle Component Colour",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.inspect_colored_components": "Enable Component Colours",
"cmd.inspect_colored_components_disable": "Disable Component Colours",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.render_aging_describe": "Layered Surface Aging",
"cmd.render_effects_describe": "Lens and Creative Effects",
"cmd.sketch.construction_line": "Construction Line",
"cmd.sketch.construction_line.angle": "Construction Line at Angle",
"cmd.sketch.construction_line.bisector": "Angle Bisector",
"cmd.sketch.construction_line.horizontal": "Horizontal Construction Line",
"cmd.sketch.construction_line.parallel": "Parallel Construction Line",
"cmd.sketch.construction_line.perpendicular": "Perpendicular Construction Line",
"cmd.sketch.construction_line.tangent_at": "Tangent at Curve Point",
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
"cmd.sketch_arc_center_point": "Centre Point Arc",
"cmd.sketch_circle_center": "Centre Diameter Circle",
"cmd.sketch_rectangle_center": "Centre Rectangle",
@@ -47,63 +27,29 @@
"color.edit": "Click to edit colour",
"color.gamma": "Showing colour values in 0-255 gamma space",
"color.linear": "Showing colour values in 0-1 linear space",
"color.recent": "Recent colours",
"color.recent_help": "Colours you pick in any colour field appear here",
"color.saturation": "Saturation (per cent)",
"color.selected": "Selected colour",
"color.step_down": "Step down",
"color.step_up": "Step up",
"color.value": "Value, the brightness (per cent)",
"dxf.cancelled": "DXF export cancelled",
"dxf.cancelling": "Cancelling DXF export",
"help.apache": "Apache licence 2.0",
"help.appearance.assign": "Gives the selected bodies, faces or components a colour or a material look, without changing their weight. Use it to tell parts apart or to preview a finish.",
"help.face.delete": "Removes the selected faces and lets the neighbouring faces close the gap, or leaves the gap open as a surface. Use it to take off a rounded edge, a hole or a boss that is in the way.",
"help.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans (SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.",
"help.licences": "Licences",
"help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminium or ABS. Use it so that the mass and weight match the real part.",
"help.mit": "MIT licence",
"help.solid.draft": "Tilts the selected faces by the angle you set, measured from a neutral plane. Use it on moulded or cast parts so that they slide out of the mould.",
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
"help.solid.split_body": "Cuts a body into separate bodies with a plane, the face of another body or a sketch line. Use it to make parts that are printed, machined or coloured on their own.",
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
"help.third_party": "Third-party licences",
"home.new_mm_hint": "Millimetres · opens in Design",
"navbar.custom.customise": "Customise navigation bar…",
"navbar.custom.position_centre": "Centre",
"navbar.description.separator": "A thin line that groups neighbouring slots.",
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
"prefs.bottom_centre": "Bottom centre",
"prefs.logo_theme": "Theme's colours",
"prefs.max_file_size": "Largest file to open",
"prefs.max_file_size_design": "Design files",
"prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.",
"prefs.max_file_size_note": "Defaults: 512 MB and 256 MB; at most 16 GB. Opening a file takes several times its size in memory. A design that keeps a large import may need a higher design file limit to save and reopen.",
"prefs.max_file_size_step": "STEP and import files",
"prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.",
"prefs.megabytes": "MB",
"prefs.network_locations_note": "For organisations that keep designs on file servers. Off: only servers you allow.",
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour",
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.",
"prefs.sketch_construction_lines": "Extended construction line tools",
"prefs.sketch_recent_snap": "Auto snap to recent centres and midpoints",
"prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centres or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.",
"prefs.sketch_state_icons": "Sketch icons in the colour of their state",
"prefs.sketch_state_icons_note": "In History and the browser, a sketch's icon takes the colour of its state (under constrained, fully constrained or conflicting) and its tooltip names the state. Off: every sketch has the same icon.",
"prefs.sketch_tracking_guides": "Centre and midpoint alignment guides",
"prefs.sketch_tracking_guides_note": "Hover over a circle centre or straight-edge midpoint, then move horizontally or vertically to show a temporary dotted guide.",
"prefs.status_bar_note": "A thin bar along the window's bottom edge shows what is being edited, the open sketch's state in its colour and the design's units; a click on the units changes them. The sketch's state then no longer shows in the view's corner.",
"prefs.structure.appearance_note": "Colours, text and accessibility.",
"prefs.structure.tools_note": "How modelling tools behave.",
"prefs.theme_follow": "Follow a theme file",
"prefs.theme_follow_error": "Not applied: {error}. The last good theme stays on.",
"prefs.theme_follow_launch": "Followed for this run only (--theme-file); reloaded whenever another tool rewrites it.",
"prefs.theme_follow_note": "Reloaded whenever another tool rewrites it. Choosing a theme stops following it.",
"prefs.theme_follow_off": "Off",
"prefs.theme_follow_stop": "Stop following",
"prefs.theme_option_opacity_help": "Adjust floating panel opacity. A readability floor protects text over viewport colours.",
"prefs.theme_option_tab_colours": "Tab colours",
"prefs.theme_option_tab_colours_help": "Use this theme’s panel and viewport colours for each ribbon tab.",
"prefs.theme_option_tab_fade_ms_help": "Fade panel and viewport colours when switching tabs. Zero switches instantly.",
"prefs.trackpad_zoom_modifier.help": "Hold only this key to zoom at the pointer with vertical scroll. Off keeps the existing scroll behaviour.",
"prefs.view": "View behaviour",
"prefs.viewport_background_colour": "Colour",
"render.aging_add": "Add aging layer",
@@ -133,7 +79,6 @@
"render.aging_target_help": "A named target must still match its captured index and name. Global masks are world-anchored; they do not follow moving parts. This is neither cavity detection nor true edge wear. Filter the list to reach additional bodies.",
"render.aging_target_search": "Filter body names",
"render.aging_upward": "Upward-facing deposition",
"render.appearance_editor.base_colour": "Base colour",
"render.backdrop_first": "Start / centre colour",
"render.backdrop_second": "End / edge colour",
"render.backdrop_x": "Centre X",
@@ -150,31 +95,21 @@
"render.base_color": "Base colour · sRGB",
"render.bloom_help": "Creative output-only glow from positive linear highlights, spread with three normalised box passes. Radius is in output pixels. Added in linear display light before sRGB; not a lens, volumetric effect or convergence improvement. The raw EXR is unchanged.",
"render.chromatic_offset": "Chromatic edge offset",
"render.cpu_area_editor_help": "Full side vectors in millimeters; U cross V points towards the emitting side. This differs from GPU half-edge vectors.",
"render.cpu_light_help": "Up to eight directional lights. An empty custom list turns them off; selecting a preset restores its lights. Colours are linear RGB and intensity is relative, not watts.",
"render.cpu_linear_color": "Linear colour (R, G, B)",
"render.effects_gpu_only": "These effects require GPU path tracing. Changing the page never switches the renderer automatically.",
"render.effects_help": "Combine ordered colour looks and image-space lens effects. Rendering and raw-master regrading use the same saved settings. Raw EXR stays unchanged. These are original artistic controls, not calibrated lens or branded-film emulations.",
"render.effects_reset_lens": "Reset lens effects",
"render.effects_seed": "Pattern seed",
"render.env_colour": "Colour",
"render.environment.radial_only": "Centre and radius apply to Radial mode.",
"render.error_finish_gesture": "finish the modelling gesture before previewing",
"render.error_finish_operation": "finish the current modelling operation before entering Render",
"render.film_grain": "Photographic grain",
"render.flare_ghosts": "Ghost rings per source",
"render.flare_halo": "Flare halo",
"render.flare_streak": "Anamorphic-style streak",
"render.flare_strength": "Highlight flare strength",
"render.flare_threshold": "Scene-linear flare threshold",
"render.frame_centre_mark": "Centre mark",
"render.hdr_primaries_declared": "The source declares its colour primaries.",
"render.hdr_primaries_srgb": "Colour primaries: sRGB/D65",
"render.hdr_primaries_undeclared": "The source does not declare primaries; review the renderer colour requirements.",
"render.image_cancelled": "Render cancelled",
"render.image_cancelling": "Cancelling",
"render.image_scene_stale": "Finish the modelling gesture or refresh the scene before checking or starting an image.",
"render.inspector_colour": "Colour",
"render.layer_down": "Move down",
"render.layer_enabled": "Layer enabled",
"render.layer_remove": "Remove layer",
@@ -189,11 +124,6 @@
"render.library_rig_help": "Fit once at an explicit centre and physical extent. Complete rigs include passive cards; calibration blockers still refuse. Conservative emitter/card clearance is checked before saving.",
"render.library_source_preview_help": "Authored base-colour source map. This is not a rendered or measured material preview.",
"render.library_unfavorite": "Remove favourite",
"render.light_behaviour": "BEHAVIOUR",
"render.light_preview_help": "Live view is unshadowed; Area lights use a centre-sample approximation. Render uses the complete light model.",
"render.light_product_centre": "Product centre",
"render.light_temperature": "Colour temperature",
"render.lighting_colour": "Colour",
"render.look_add": "Add colour look",
"render.look_bleach": "Bleach-bypass-inspired",
"render.look_cool": "Cool",
@@ -217,48 +147,30 @@
"render.radial_distortion": "Radial distortion",
"render.ribbon_aging": "Aging",
"render.ribbon_effects": "Effects",
"render.ribbon_studios_help": "Browse implemented studio compositions and review their replacements before applying. Concept-only scenes remain labelled.",
"render.sharpening": "Detail sharpening",
"render.soft_focus": "Soft focus",
"render.stage_panels.centre": "Centre",
"render.stage_panels.source_note": "Authored base-colour source map. Not a measured material.",
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.",
"render.studio_center": "Centre X, Y, Z (mm)",
"render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.",
"sketch_text.align_center": "Centre",
"ui.3d_mouse_kind_cap": "Return to Centre",
"ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Centre device does.",
"ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's centre",
"ui.3d_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
"ui.3d_mouse_switch_cap": "Calibrate as Return to Centre",
"ui.agent_chat_minimize": "Minimise to a strip",
"ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
"ui.center": "Centre",
"ui.center_arc_follows_cursor": "Centre point arc follows the cursor's direction",
"ui.center_arc_follows_cursor_tip": "A centre point arc runs clockwise or counter-clockwise the way the cursor goes round its centre. Off: always counter-clockwise from the start.",
"ui.centerline": "Centreline",
"ui.colour_vision": "Colour vision",
"ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.",
"ui.colour_vision_off_tip": "The theme's own colours",
"ui.construction.ribbon_label": "X-Lines",
"ui.customizeToolbar": "Customise Toolbar…",
"ui.default_modelling_orientation": "Default modelling orientation",
"ui.design_properties.license": "Licence",
"ui.design_properties.organization": "Organisation",
"ui.details_curvature_caption": "Faces are coloured by their local radius of curvature.",
"ui.details_draft_caption": "Faces are coloured by their angle to the pull direction.",
"ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file",
"ui.instances": "Instances",
"ui.layout_compact": "Compact",
"ui.layout_floating": "Floating",
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
"ui.point_cloud_color_by": "Colour By",
"ui.point_cloud_file_colors": "File Colours",
"ui.point_cloud_no_file_colors": "The file has no colours",
"ui.point_cloud_single_color": "Single Colour",
"ui.properties.inertia_note": "About the centre of mass, in world axes (g·{unit}²)",
"ui.ribbon_labels": "Show text under ribbon icons",
"ui.view_centre": "View centre",
"ui.workspace_layout": "Workspace layout",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
+73 -748
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -683
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -683
View File
File diff suppressed because it is too large Load Diff
+5 -313
View File
@@ -407,7 +407,6 @@
"Recover unsaved design?": "저장되지 않은 디자인을 복구할까요?",
"Rectangular": "직사각형",
"Reference face": "참조 면",
"Reference plane": "참조 평면",
"Relief Depth": "릴리프 깊이",
"Relief Width": "릴리프 너비",
"Remove Tools": "공구 제거",
@@ -423,7 +422,6 @@
"Rename Feature": "피처 이름 바꾸기",
"Rename Folder": "폴더 이름 바꾸기",
"Render Stage": "렌더링 단계",
"Reopen the designs that were open when SolidCraft was last closed": "지난번에 열려 있던 디자인 다시 열기",
"Replace existing library file": "기존 라이브러리 파일 바꾸기",
"Report a bug — review a screenshot and open a GitHub draft": "버그 신고 — 스크린샷을 검토하고 GitHub 초안 열기",
"Report text copied. Paste it into GitHub and submit your report there.": "보고서 텍스트를 복사했습니다. GitHub에 붙여넣고 그곳에서 보고서를 제출하세요.",
@@ -478,7 +476,6 @@
"Second point": "두 번째 점",
"Section": "단면",
"Section position": "단면 위치",
"Sections": "단면",
"Sections (one sketch each)": "단면 (각각 스케치 하나)",
"Select Other": "다른 항목 선택",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "다른 항목 선택: 모델 위에서 왼쪽 버튼을 가만히 누르고 있거나(또는 오른쪽 클릭 › 다른 항목 선택…) 커서 아래의 모든 항목을 나열합니다. 같은 위치를 다시 클릭하면 다음 항목이 선택됩니다. 가운데 버튼을 더블 클릭하면 홈으로 이동합니다(환경설정 › 탐색).",
@@ -714,14 +711,11 @@
"cmd.appearance_pick_up": "모양 가져오기",
"cmd.appearance_replace": "모양 바꾸기",
"cmd.assembly_bom": "자재 명세서",
"cmd.assembly_mass_properties": "질량 특성",
"cmd.body.print_face": "출력 바닥면",
"cmd.body_rename": "바디 이름 변경",
"cmd.browser_group": "새 그룹",
"cmd.browser_move": "브라우저 항목 이동",
"cmd.browser_order": "브라우저 항목 정렬",
"cmd.browser_rename_group": "그룹 이름 변경",
"cmd.browser_reparent": "구성 요소로 이동",
"cmd.browser_ungroup": "그룹 해제",
"cmd.browser_visibility": "표시/숨기기",
"cmd.canvas_calibrate": "캔버스 보정",
@@ -744,7 +738,6 @@
"cmd.component_metadata": "부품 속성",
"cmd.component_metadata_set": "부품 속성 변경",
"cmd.component_move_bodies": "바디를 구성 요소로 이동",
"cmd.component_move_features": "특성을 구성 요소로 이동",
"cmd.component_move_sketches": "스케치를 구성 요소로 이동",
"cmd.component_paste_new": "새로 붙여넣기",
"cmd.component_properties": "구성 요소 물리 속성",
@@ -761,7 +754,6 @@
"cmd.config_rule": "구성 규칙",
"cmd.config_rules": "구성 규칙",
"cmd.config_table": "구성 표시",
"cmd.construct.plane.axis_point": "축과 점을 지나는 평면",
"cmd.construct_axis_edge": "모서리를 통과하는 축",
"cmd.construct_axis_normal_to_face": "면에 수직인 축",
"cmd.construct_axis_through_cylinder": "원기둥/원뿔/토러스를 통과하는 축",
@@ -908,9 +900,6 @@
"cmd.pointcloud_edit": "포인트 클라우드 편집",
"cmd.pointcloud_insert": "포인트 클라우드 삽입",
"cmd.pointcloud_list": "포인트 클라우드",
"cmd.render.compare.divider": "렌더 비교 구분선 이동",
"cmd.render.result.discard": "렌더 결과 버리기",
"cmd.render.result.select": "렌더 결과 선택",
"cmd.render_backends": "렌더링 백엔드",
"cmd.render_batch": "모든 샷 렌더링",
"cmd.render_camera_fit": "렌더 대상 화면 맞춤",
@@ -967,16 +956,6 @@
"cmd.sheet_refold": "다시 접기",
"cmd.sheet_rules": "판금 규칙 목록",
"cmd.sheet_unfold": "펼치기",
"cmd.sketch.construction_line": "보조 직선",
"cmd.sketch.construction_line.angle": "각도 지정 보조 직선",
"cmd.sketch.construction_line.bisector": "각의 이등분선",
"cmd.sketch.construction_line.horizontal": "수평 보조 직선",
"cmd.sketch.construction_line.parallel": "평행 보조 직선",
"cmd.sketch.construction_line.perpendicular": "직교 보조 직선",
"cmd.sketch.construction_line.tangent_at": "곡선 위 점에서의 접선",
"cmd.sketch.construction_line.tangent_external": "외부 점을 지나는 접선",
"cmd.sketch.construction_line.tangent_two": "공통 접선",
"cmd.sketch.construction_line.vertical": "수직 보조 직선",
"cmd.sketch_arc_center_point": "중심점 호",
"cmd.sketch_arc_tangent": "접선 호",
"cmd.sketch_arc_three_point": "3점 호",
@@ -1006,8 +985,6 @@
"cmd.sketch_draw_dimensioned": "치수가 있는 스케치 개체 그리기",
"cmd.sketch_edit": "스케치 편집",
"cmd.sketch_edit_text": "텍스트 편집",
"cmd.sketch_equation_curve": "방정식 곡선",
"cmd.sketch_equation_curve_edit": "방정식 곡선 편집",
"cmd.sketch_export_dxf": "DXF로 저장",
"cmd.sketch_finish_auto_constrain": "자동 구속으로 완료",
"cmd.sketch_fit_curves_to_section": "메시 단면에 곡선 맞추기",
@@ -1099,12 +1076,9 @@
"cmd.timeline_rollback": "타임라인 마커 이동",
"cmd.timeline_stop": "기록 재생 중지",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.ui.assistant": "도우미 표시 전환",
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
"cmd.view.render_focus": "렌더 집중 보기",
"cmd.view.status_details": "뷰포트 상태 상세 정보",
"cmd.view.status_items": "뷰포트 상태 항목",
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
"cmd.view_angle_readout": "뷰 각도",
"cmd.view_apply": "이름 있는 보기 복원",
@@ -1117,17 +1091,14 @@
"cmd.view_render_frame": "렌더 프레임 가이드",
"cmd.view_save": "새 이름 있는 보기",
"cmd.view_shots_strip": "샷 스트립",
"color.blue": "파랑",
"color.copy": "클릭하여 색상 값 복사",
"color.edit": "클릭하여 색상 편집",
"color.gamma": "색상 값을 0-255 감마 공간으로 표시 중",
"color.green": "초록",
"color.hex": "Hex",
"color.hex_help": "#rrggbb 또는 #rrggbbaa 입력. 6자리 값은 불투명도 유지",
"color.hex_hint": "#rrggbb",
"color.hue": "색조",
"color.linear": "색상 값을 0-1 선형 공간으로 표시 중",
"color.red": "빨강",
"color.saturation_value": "채도 및 명도",
"color.selected": "선택한 색상",
"config.column_filter_empty": "일치하는 열이 없습니다",
@@ -1174,14 +1145,9 @@
"dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입",
"dialog.dimension_name": "치수: {name}",
"dialog.edit_title": "{title} 편집",
"dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
"dialog.select": "↖ 선택",
"dialog.selected_count": "↖ {count}개 선택됨",
"dialog.selection_constraint": "구속 조건 {id}",
"dialog.selection_external_curve": "{sketch} · 곡선 {id}",
"dialog.selection_guidance": "변경할 입력을 선택하세요. 선택한 객체를 클릭하면 선택이 해제되며, Ctrl/Cmd를 누른 채 클릭해도 전환됩니다. 미리 보기를 확인한 다음 확인을 누르세요.",
"dialog.selection_midpoint": "중점 · {id}",
"dialog.selection_profile": "{sketch} · 프로파일 {number}",
"dialog.selection_step": "{step}/{total} 단계: {input} 선택",
"dialog.step": "단계",
"document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?",
@@ -1222,24 +1188,12 @@
"help.attributions": "저작자 표시 (ATTRIBUTION.md)",
"help.build": "버전 {version} · 커밋 {commit} · 빌드 날짜 {date}",
"help.component.finish_edit": "제자리에서 편집 중인 부품을 저장하고 어셈블리로 돌아가 그 부품으로 어셈블리를 업데이트합니다. 연결된 부품의 변경을 마쳤을 때 사용하세요.",
"help.construct.plane.axis_point": "축과 축 위에 있지 않은 점을 포함하는 구성 평면을 만듭니다. 원점 축, 구성 축, 직선 모서리 또는 스케치 직선을 선택한 다음 꼭짓점, 스케치 점 또는 구성점을 선택합니다. 축 위의 점으로는 평면을 하나로 결정할 수 없습니다.",
"help.font_details": "인터페이스는 egui의 내장 글꼴을 사용합니다(Ubuntu Font Licence 및 MIT/Bitstream Vera 라이선스의 Ubuntu Light와 Hack, OFL 및 MIT의 Noto Emoji와 이모지 아이콘 글꼴). 스케치 텍스트는 Liberation Sans(SIL OFL 1.1)를 사용합니다. The Crafting Apps의 선택적 글꼴 팩 craft-fonts(BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1)는 SolidCraft 빌드에 포함되지 않습니다.",
"help.fonts": "글꼴",
"help.licences": "라이선스",
"help.mit": "MIT 라이선스",
"help.notice": "NOTICE",
"help.pointcloud.insert": "PLY, XYZ 또는 PTS 스캔을 모델 옆에 포인트 클라우드로 삽입합니다. 측정 데이터를 참고하여 모델링할 때 사용합니다.",
"help.sketch.construction_line": "두 점을 지나는 무한 보조 직선을 만듭니다. 스케치 프로파일을 나누지 않고 작도할 수 있습니다.",
"help.sketch.construction_line.angle": "스케치 X축을 기준으로 지정한 각도의 무한 보조 직선을 만듭니다. 점을 선택한 뒤 각도를 입력하거나 방향을 선택합니다.",
"help.sketch.construction_line.bisector": "교차하는 두 스케치 직선을 선택한 뒤 두 각의 이등분선 중 하나를 선택합니다. 결과는 원본 직선의 편집을 따릅니다.",
"help.sketch.construction_line.horizontal": "한 점을 지나는 무한 수평 보조 직선을 만듭니다. 나중에 편집해도 수평 구속이 유지됩니다.",
"help.sketch.construction_line.parallel": "스케치 직선을 선택한 뒤 평행한 무한 보조 직선을 배치합니다. 다른 참조를 선택하려면 마우스 오른쪽 버튼을 클릭합니다.",
"help.sketch.construction_line.perpendicular": "스케치 직선을 선택한 뒤 직교하는 무한 보조 직선을 배치합니다. 다른 참조를 선택하려면 마우스 오른쪽 버튼을 클릭합니다.",
"help.sketch.construction_line.tangent_at": "원이나 원호 위의 점, 또는 열린 스플라인이나 원뿔 곡선의 끝점에 접선을 만듭니다. 곡선을 선택한 뒤 접점을 선택합니다.",
"help.sketch.construction_line.tangent_external": "원 또는 원호, 외부 점, 접선 해를 차례로 선택합니다. 미리보기에는 그려진 원호 위의 접점만 표시됩니다.",
"help.sketch.construction_line.tangent_two": "두 원 또는 원호를 선택한 뒤 미리보기에서 공통 접선을 선택합니다. 접점은 그려진 곡선 위에 있어야 합니다.",
"help.sketch.construction_line.vertical": "한 점을 지나는 무한 수직 보조 직선을 만듭니다. 나중에 편집해도 수직 구속이 유지됩니다.",
"help.sketch.equation_curve": "범위에 걸쳐 수식으로 곡선을 그립니다: x(t)와 y(t), 또는 y = f(x). 수식에 매개변수를 쓸 수 있으며 곡선이 그 값을 따라갑니다. 사인파, 나선, 기어 이의 인벌류트에 사용합니다.",
"help.solid.chamfer": "선택한 모서리를 지정한 거리만큼 평평하게 깎습니다. 날카로운 모서리를 다듬거나 핀 또는 나사가 쉽게 들어가도록 할 때 사용합니다.",
"help.solid.extrude": "스케치 프로파일을 지정한 거리만큼 3D로 밀어내 재료를 더하거나 제거합니다. 평면 윤곽을 블록, 보스 또는 포켓으로 만들 때 사용합니다.",
"help.solid.fillet": "선택한 모서리를 지정한 반지름의 매끄러운 원호로 둥글게 만듭니다. 강도, 편의성 또는 외관을 위해 날카로운 모서리를 부드럽게 할 때 사용합니다.",
@@ -1325,8 +1279,6 @@
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.",
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
"joint_move.conflict": "이 부품의 조인트가 다른 조인트와 충돌합니다: {joint}.",
"joint_move.conflict_hint": "충돌하는 조인트를 편집, 비활성화 또는 삭제하여 다른 조인트가 부품을 배치할 수 있게 하세요.",
"keymap.command_id": "명령 ID",
"keymap.empty": "일치하는 명령이 없습니다",
"keymap.use_anyway": "그래도 여기서 {key} 사용",
@@ -1367,8 +1319,6 @@
"motion.track_name": "트랙 {number}",
"nav.alias.help": "Shift+Alt+왼쪽 드래그는 회전, Shift+Alt+가운데 드래그는 이동, Shift+Alt+오른쪽 드래그는 확대/축소입니다. 가운데 드래그로도 이동하고 오른쪽 드래그로도 회전합니다",
"nav.blender.help": "가운데 드래그는 회전, Shift+가운데 드래그는 이동, Ctrl+가운데 드래그는 확대/축소입니다. 오른쪽 드래그는 카메라에 영향을 주지 않습니다",
"nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다",
"nav.creo.label": "Creo",
"nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.",
"nav.free_orbit.label": "자유 회전",
"nav.free_orbit.snap_help": "마우스와 3D 마우스로 양쪽 극점을 지나 회전할 수 있으며 롤 각도가 유지됩니다. 궤도 회전 드래그 중 Shift를 누르면 보기 방향과 롤 각도가 스냅됩니다.",
@@ -1549,14 +1499,10 @@
"prefs.bottom_centre": "하단 가운데",
"prefs.bottom_right": "오른쪽 아래",
"prefs.browser": "브라우저",
"prefs.browser_eye_left": "표시 아이콘을 접기 화살표 옆에 표시",
"prefs.browser_eye_left_note": "브라우저 행의 표시 아이콘을 행 오른쪽 끝의 열이 아니라 이전처럼 접기 화살표 옆에 표시합니다.",
"prefs.built_in": "기본 제공",
"prefs.button_left": "왼쪽 드래그",
"prefs.button_middle": "가운데 드래그",
"prefs.button_right": "오른쪽 드래그",
"prefs.carousel_next": "다음",
"prefs.carousel_previous": "이전",
"prefs.choose_font": "글꼴 파일 선택…",
"prefs.click": "클릭",
"prefs.contact_shading": "부드러운 접촉 음영",
@@ -1569,10 +1515,6 @@
"prefs.ctrl_cmd": "Ctrl / Cmd",
"prefs.curved_surfaces": "곡면",
"prefs.curves": "곡면",
"prefs.cut_preview_tint": "제거되는 재료를 빨간색으로 표시",
"prefs.cut_preview_tint_help": "미리 보기는 피처가 제거하는 재료와 대체되는 형상을 빨간색 반투명 고스트로 채웁니다. 끄면 이 채움이 완전히 투명해지고 윤곽선만 그려집니다.",
"prefs.dark_parts_shading": "어두운 부품의 형태 유지",
"prefs.dark_parts_shading_help": "선형 조명과 부드러운 하늘광으로 뷰를 음영 처리하여 어두운 면이 검게 뭉개지지 않고 그라데이션을 유지합니다",
"prefs.dead_zone_note": "빗금 친 영역이 데드존입니다. 그 안의 움직임은 무시됩니다.",
"prefs.default": "기본값",
"prefs.design": "설계 기본값",
@@ -1593,35 +1535,19 @@
"prefs.font_sample": "The quick brown fox · Ø12.50 mm · 45.0° · 0123456789",
"prefs.font_unreadable": "선택한 글꼴을 읽을 수 없습니다.",
"prefs.general": "일반",
"prefs.general.active": "사용 중",
"prefs.general.crash_reports": "충돌 보고서",
"prefs.general.edited": "수정됨",
"prefs.general.held": "포함 항목",
"prefs.general.import": "가져오기...",
"prefs.general.last_check": "마지막 확인: {time}",
"prefs.general.minutes": "분",
"prefs.general.name_setup": "이 설정의 이름",
"prefs.general.profile_name": "프로필 이름",
"prefs.general.save_new": "새로 저장",
"prefs.general.step_import": "STEP 및 가져오기",
"prefs.general.this_computer": "이 컴퓨터",
"prefs.general_note": "프로필, 언어, 파일 및 업데이트.",
"prefs.general_note": "언어, 저장 및 업데이트.",
"prefs.gesture_orbit": "회전",
"prefs.gesture_pan": "이동",
"prefs.gesture_zoom": "확대/축소",
"prefs.graphics": "그래픽",
"prefs.graphics_note": "속도와 부드러움 중에서 선택합니다. 각 선택의 효과가 샘플에 표시됩니다.",
"prefs.graphics_presets_note": "프리셋은 아래의 선택 항목을 한 번에 설정합니다.",
"prefs.hide_constraint_glyphs_zoomed_out": "축소 시 구속 조건 기호 숨기기",
"prefs.hide_constraint_glyphs_zoomed_out_note": "스케치를 충분히 축소하면 구속 조건 기호가 가려지지 않도록 숨깁니다. 끄기: 확대/축소와 관계없이 고정된 화면 크기를 유지합니다.",
"prefs.history": "이력",
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
"prefs.history_origin_bodies_note": "왼쪽 또는 브라우저 아래의 히스토리가 활성 구성 요소의 바디 폴더와 원점으로 시작하며, 브라우저처럼 표시 아이콘과 메뉴가 있습니다. 아래쪽 히스토리는 바뀌지 않습니다.",
"prefs.history_play_button": "기록 컨트롤에 기록 재생 버튼 표시",
"prefs.history_play_button_note": "기록 컨트롤의 한 단계 뒤로와 한 단계 앞으로 사이에 기록 재생을 추가합니다. 컨트롤을 마우스 오른쪽 버튼으로 클릭해 표시하거나 숨길 수도 있습니다.",
"prefs.hold_stop_snapping": "누르고 있으면 스냅 중지",
"prefs.home_fit_size": "홈·맞춤 크기",
"prefs.home_fit_size_note": "홈, 맞춤, 연 디자인 또는 뷰 탐색기의 뷰 변경으로 모델을 화면에 맞출 때 모델이 뷰에서 차지하는 비율. 아래 설정과 관계없이 적용됩니다. 100 %: 패널, 나침반, 내비게이션 바 사이의 뷰를 가득 채웁니다.",
"prefs.icons_only": "아이콘만",
"prefs.imported_file": "가져온 파일",
"prefs.input": "입력",
@@ -1630,22 +1556,17 @@
"prefs.interface_note": "SolidCraft의 모양과 각 부분의 위치입니다.",
"prefs.interval_minutes": "{n}분",
"prefs.labels": "레이블",
"prefs.language_system": "시스템",
"prefs.layout": "레이아웃",
"prefs.layout_note": "컴팩트는 패널을 창 가장자리에 고정합니다. 브라우저 반투명은 플로팅에서 사용할 수 있습니다. 어시스턴트 위치는 어시스턴트 페이지에서 바꿀 수 있습니다.",
"prefs.left": "왼쪽",
"prefs.left_click": "왼쪽 클릭",
"prefs.line_drag_draws_one": "누른 채 드래그하면 선 하나 그리기",
"prefs.line_drag_draws_one_note": "선 도구에서 누르고 드래그한 뒤 놓으면 누른 곳에서 놓은 곳까지 선 하나를 그리고 거기서 끝납니다. 클릭하면 이전처럼 이어지는 선을 그립니다. 끄면: 드래그는 클릭처럼 놓은 곳에 점을 둡니다.",
"prefs.line_hover_tangent_arc": "선의 마지막 점에 머무르면 접선 호로 전환",
"prefs.line_hover_tangent_arc_note": "선 도구에서 선분을 그린 뒤 체인의 마지막 점에서 벗어났다가 돌아와 포인터를 그 위에 멈추면, 다음 클릭으로 접선 호의 끝점을 지정합니다. 같은 동작으로 선으로 돌아갑니다. 끔: 머물러도 아무 일도 일어나지 않습니다.",
"prefs.live_input": "실시간 입력",
"prefs.live_preview": "실시간 미리보기",
"prefs.max_file_size": "열 수 있는 최대 파일 크기",
"prefs.max_file_size_design": "열 파일의 최대 크기: 디자인",
"prefs.max_file_size_design": "디자인 파일",
"prefs.max_file_size_flag": "이번 실행은 --max-file-size {size}로 시작했으며 SolidCraft를 닫을 때까지 적용됩니다. 이 설정은 해당 옵션 없이 시작할 때 적용됩니다.",
"prefs.max_file_size_note": "기본값: 512 MB 및 256 MB, 최대 16 GB. 파일을 열려면 파일 크기의 몇 배에 달하는 메모리가 필요합니다. 큰 가져오기 데이터를 보관하는 디자인은 저장하고 다시 열기 위해 디자인 파일 한도를 높여야 할 수 있습니다.",
"prefs.max_file_size_step": "열 파일의 최대 크기: STEP 및 가져오기",
"prefs.max_file_size_step": "STEP 및 가져오기 파일",
"prefs.max_file_size_web": "웹 앱은 STEP 및 가져오기 파일을 512 MB까지, 디자인을 256 MB까지 엽니다. 브라우저 탭은 더 큰 파일을 담을 수 없습니다. 데스크톱 앱에서는 이 한도를 높일 수 있습니다.",
"prefs.megabytes": "MB",
"prefs.minutes": "분",
@@ -1655,9 +1576,6 @@
"prefs.mouse_mapping": "마우스 매핑",
"prefs.navigation": "탐색",
"prefs.navigation_note": "마우스, 트랙패드, 3D 마우스로 뷰를 움직이는 방식입니다.",
"prefs.navigator_fit": "뷰 탐색기로 뷰를 바꾼 뒤 모델 맞춤",
"prefs.navigator_fit_note": "큐브의 면, 모서리, 화살표나 기즈모의 축을 클릭하면 어느 방향에서든 전체가 들어가는 한 거리(경계 구)에서 모델의 중심을 바라봅니다. 켜져 있는 동안 홈 뷰도 같은 확대/축소와 중심을 쓰므로 그 뒤에 고른 뷰는 방향만 바뀝니다. 끄면 뷰가 제자리에서 회전하며 대상과 확대/축소를 유지합니다.",
"prefs.network_locations_note": "파일 서버에 디자인을 보관하는 조직용입니다. 끄면 허용한 서버만 사용합니다.",
"prefs.not_on_gpu": "이 GPU에서는 사용할 수 없음",
"prefs.on": "켜짐",
"prefs.opaque": "불투명",
@@ -1666,16 +1584,7 @@
"prefs.origin_plane_names": "원점 평면 이름",
"prefs.origin_plane_names_note": "브라우저, 대화 상자, 목록에 표시되는 이름입니다. {top}은 XY, {front}는 XZ, {right}는 YZ이며 Z가 위쪽입니다. 명령, 스크립트, 저장된 디자인은 XY, XZ, YZ를 유지합니다.",
"prefs.overall_speed": "전체 속도",
"prefs.persistent_ribbon_pressed": "활성 리본 명령 강조 표시",
"prefs.persistent_ribbon_pressed_help": "효과가 켜져 있는 동안 리본 명령을 강조 표시합니다. 끄면 이전의 중립적인 모양과 명령 도움말을 유지합니다.",
"prefs.perspective_fov": "시야각",
"prefs.preview_choices": "선택 항목 미리보기",
"prefs.preview_tint_new_faces": "미리 보기에서 새 면에 색 입히기",
"prefs.preview_tint_new_faces_help": "바디를 변경하는 피처의 미리 보기에서 추가되는 면을 미리 보기 색으로 그릴 수 있습니다. 끄면 바디가 원래 색으로 그려집니다.",
"prefs.print_face_highlight": "출력 바닥면 강조",
"prefs.print_face_highlight_tip": "뷰에서 바디의 출력 바닥면을 색칠하고 윤곽을 그리는 강도입니다. 0 %이면 강조하지 않습니다.",
"prefs.profile_export": "내보내기",
"prefs.profile_imported": "프로필을 가져왔습니다.",
"prefs.project_options_dialog": "Project 옵션 대화상자",
"prefs.project_options_dialog_note": "켜기: 투영 전에 지오메트리 또는 바디와 투영 링크를 선택합니다. 끄기: 기존 직접 선택 작업 흐름을 유지합니다.",
"prefs.quality_default": "기본값",
@@ -1683,35 +1592,21 @@
"prefs.quality_slowest": "가장 느림",
"prefs.quality_smoother": "더 부드럽게",
"prefs.reports_saved": "{n}개 저장됨",
"prefs.restore_page_defaults": "페이지 기본값 복원",
"prefs.ribbon_click_ends_tool": "실행 중인 도구의 버튼을 클릭하면 도구 종료",
"prefs.ribbon_click_ends_tool_note": "사용 중인 스케치 도구의 강조된 리본 버튼을 클릭하면 도구가 종료되어 뷰에서 클릭하면 다시 선택됩니다. 끄기: 클릭하면 도구가 다시 시작됩니다.",
"prefs.right_click": "오른쪽 클릭",
"prefs.saving": "시작 및 저장",
"prefs.saving": "자동 저장 및 복구",
"prefs.scale_note": "창에서 Ctrl + / Ctrl −로도 조절합니다. 버튼은 100%에서 28 px입니다.",
"prefs.scale_text": "배율 및 텍스트",
"prefs.search": "설정 검색",
"prefs.search_empty": "일치하는 설정이 없습니다",
"prefs.select": "선택",
"prefs.select_one_item": "항목 하나 선택",
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
"prefs.selecting": "선택",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
"prefs.shortcuts": "단축키",
"prefs.single_surface_analysis": "표면 분석 하나만 사용",
"prefs.single_surface_analysis_help": "끄면 표면 음영 분석과 연속성 분석을 함께 유지할 수 있습니다. 켜면 마지막으로 시작하거나 변경한 분석만 유지됩니다.",
"prefs.sketch_align_points": "그리는 동안 다른 스케치 점에 맞추기",
"prefs.sketch_align_points_note": "그리는 동안 점이 한 스케치 점과 수평으로, 다른 점과 수직으로 동시에 맞춰지고 점선 안내선이 표시됩니다. 클릭하면 두 정렬이 모두 유지됩니다. 끄기: 이전 점에만, 한 번에 한 축씩 맞춥니다.",
"prefs.sketch_conflict_colour": "충돌하는 기하를 충돌 색상으로 표시",
"prefs.sketch_conflict_colour_note": "스케치의 구속 조건이 충돌하는 동안 실패한 구속 조건의 글리프나 치수뿐 아니라 그 구속 조건이 잡고 있는 곡선과 점도 충돌 색상으로 그립니다. 끄기: 평소 색상을 유지합니다.",
"prefs.sketch_construction_lines": "확장 보조 직선 도구",
"prefs.sketch_open_look_at": "스케치를 열 때 정면으로 돌리기",
"prefs.sketch_open_look_at_note": "끄기: 스케치를 만들거나 편집해도 뷰가 그대로입니다. 필요할 때 '바라보기'로 스케치를 정면으로 봅니다.",
"prefs.sketch_recent_snap": "최근 중심과 중점에 자동 스냅",
"prefs.sketch_recent_snap_note": "그리는 동안 최근에 사용한 두 원의 중심 또는 직선 모서리 중점의 안내선이 만나는 위치로 커서를 스냅합니다. 끄기: 안내선은 보기만 하고 커서는 움직이지 않습니다.",
"prefs.sketch_selection": "스케치 및 선택",
"prefs.sketch_selection_heading": "스케치 및 선택",
"prefs.sketch_selection_note": "스케치가 스냅되는 방식과 클릭으로 선택하는 방식입니다.",
@@ -1721,8 +1616,6 @@
"prefs.sketch_tab_always_note": "스케치가 열려 있지 않을 때도 스케치 탭이 리본에서 모델 바로 다음에 남아 있습니다. 그 그리기 도구는 선택한 평면이나 면, 또는 다음에 고르는 평면에서 스케치를 시작하고 그 안에서 도구를 바로 쓸 수 있게 합니다.",
"prefs.sketch_tracking_guides": "중심 및 중점 정렬 안내선",
"prefs.sketch_tracking_guides_note": "원 중심이나 직선 모서리의 중점에 마우스를 올린 다음 수평 또는 수직으로 이동하면 임시 점선 안내선이 표시됩니다.",
"prefs.sketch_tracking_points": "그리는 동안 끝점과 모서리에서도 정렬 안내선",
"prefs.sketch_tracking_points_note": "그리기 도구가 점을 기다리는 동안 선이나 호의 끝점, 직사각형 모서리, 자유 점에 마우스를 올리면 중심이나 중점과 같은 임시 점선 안내선이 표시됩니다. 끄면: 중심과 중점만.",
"prefs.sketching": "스케치",
"prefs.snap_drawing_note": "스케치 점을 그리거나 드래그하는 동안.",
"prefs.source": "소스",
@@ -1734,24 +1627,6 @@
"prefs.status_error": "오류",
"prefs.status_selected": "선택됨",
"prefs.status_warning": "경고",
"prefs.structure.appearance": "모양",
"prefs.structure.appearance_note": "색상, 텍스트 및 접근성.",
"prefs.structure.in_profile": "프로필에 포함",
"prefs.structure.layout": "레이아웃",
"prefs.structure.layout_note": "창 구성 요소의 위치.",
"prefs.structure.look": "보기",
"prefs.structure.measure_pointer": "측정 및 포인터",
"prefs.structure.mouse3d": "3D 마우스",
"prefs.structure.mouse3d_note": "SpaceMouse 및 SpaceNavigator.",
"prefs.structure.navigation_note": "마우스와 트랙패드. 3D 마우스는 별도 페이지에 있습니다.",
"prefs.structure.profile": "프로필",
"prefs.structure.profile_edited": "저장 후 변경됨",
"prefs.structure.profile_hover": "설정 프로필에 저장됨",
"prefs.structure.scale_percent": "{percent}%",
"prefs.structure.sketch": "스케치",
"prefs.structure.tools": "도구",
"prefs.structure.tools_note": "모델링 도구의 동작.",
"prefs.structure.work": "작업",
"prefs.supersampling_brief": "뷰를 더 크게 그린 뒤 줄여서 필터링합니다. 매우 선명하지만 더 무겁습니다.",
"prefs.tab_accent_note": "강조 색상이 활성 탭을 따라갑니다",
"prefs.theme_file": "테마 파일",
@@ -1761,16 +1636,6 @@
"prefs.theme_follow_note": "다른 도구가 파일을 다시 쓸 때마다 다시 불러옵니다. 테마를 선택하면 따르기를 중지합니다.",
"prefs.theme_follow_off": "끄기",
"prefs.theme_follow_stop": "따르기 중지",
"prefs.theme_option_opacity": "불투명도",
"prefs.theme_option_opacity_help": "플로팅 패널의 불투명도를 조정합니다. 뷰포트 색상 위에서도 텍스트를 읽을 수 있도록 최솟값이 적용됩니다.",
"prefs.theme_option_tab_colours": "탭 색상",
"prefs.theme_option_tab_colours_help": "각 리본 탭에 이 테마의 패널 및 뷰포트 색상을 사용합니다.",
"prefs.theme_option_tab_fade_ms": "탭 전환",
"prefs.theme_option_tab_fade_ms_help": "탭을 전환할 때 패널 및 뷰포트 색상을 서서히 변경합니다. 0이면 즉시 전환합니다.",
"prefs.theme_options_badge": "옵션",
"prefs.theme_options_more": "더 보기 · {count}",
"prefs.theme_options_reset": "초기화",
"prefs.theme_options_unavailable": "이 기기에서는 사용할 수 없음",
"prefs.timeline_half_hour": "−30분",
"prefs.timeline_now": "지금",
"prefs.timeline_quarter_hour": "−15",
@@ -1790,33 +1655,17 @@
"prefs.under_cursor": "커서 아래",
"prefs.units_precision": "단위 및 정밀도",
"prefs.updates": "업데이트",
"prefs.value_boxes_in_view": "뷰에서 핸들 옆에 값 표시",
"prefs.value_boxes_in_view_tip": "명령의 값(거리, 반경 등)을 모델 위 핸들 옆 상자에 표시합니다. 끄면 값은 명령 창에 입력하며, 선택 후 키보드 입력이 그 창으로 가고 모델을 가리는 것이 없습니다.",
"prefs.view": "뷰 동작",
"prefs.view_confirm_corner": "뷰 모서리에 확인 및 취소",
"prefs.view_confirm_corner_note": "대화 상자나 스케치가 열려 있는 동안 뷰 오른쪽 위의 체크와 X 표시가 대화 상자의 확인과 취소와 같은 동작을 하거나 스케치를 마치고 취소합니다. 뷰 탐색기는 그 아래로 내려갑니다.",
"prefs.viewport": "뷰포트",
"prefs.viewport_analysis_status": "뷰포트 해석 상태 표시",
"prefs.viewport_analysis_status_help": "활성 해석을 뷰포트 위에 표시하고 각 항목에서 범례를 엽니다. Esc로 최신 해석을 끕니다. 끄면 모서리 범례와 이전 Esc 동작으로 돌아갑니다.",
"prefs.viewport_section_status": "뷰포트 단면 상태 표시",
"prefs.viewport_section_status_help": "뷰포트에 단면 설정을 표시합니다. Escape 키와 활성 리본 버튼으로 단면을 끕니다. 비활성화하면 이전 단면 조작을 유지합니다.",
"prefs.viewport_status_details": "뷰포트 실시간 상세 정보",
"prefs.viewport_status_details_help": "표준 상세 카드에서 활성 분석 및 단면 설정을 편집합니다. 끄면 읽기 전용 분석 범례와 단면 대화상자로 돌아갑니다.",
"prefs.viewport_status_summary": "뷰포트 상태 항목 요약",
"prefs.viewport_status_summary_help": "상태 항목이 3개 이상이면 하나의 요약 배지로 표시합니다. 비활성화하면 모든 항목을 기존 행에 표시합니다.",
"prefs.wheel": "휠",
"prefs.wheel_trackpad": "휠 및 트랙패드",
"prefs.workspace": "작업 공간",
"prefs.zoom_stop_at_model": "모델에서 확대/축소 멈추기",
"prefs.zoom_stop_at_model_tip": "3D 마우스와 장치 앱은 뷰 중앙의 표면을 향해 확대/축소하고, 드래그 확대/축소는 드래그 시작점을 고정합니다. 끄면 모델을 통과할 수 있습니다. 휠과 핀치는 별도의 커서로 확대/축소 설정을 사용합니다",
"prefs.zoom_toward_cursor": "확대/축소, 커서 방향으로",
"properties.title": "속성: {name}",
"recovery.days_ago": "{count}일 전",
"recovery.detail": "{file} · 피처 {features}개 · {when} 자동 저장",
"recovery.discard_all": "모두 버리기",
"recovery.discard_all_detail": "이 작업은 되돌릴 수 없습니다.",
"recovery.discard_all_title": "자동 저장 {count}개를 버리시겠습니까?",
"recovery.discard_all_title_one": "자동 저장 1개를 버리시겠습니까?",
"recovery.hours_ago": "{count}시간 전",
"recovery.just_now": "방금",
"recovery.min_ago": "{count}분 전",
@@ -1865,10 +1714,6 @@
"render.appearances": "외관",
"render.assign_help": "클릭하여 선택 항목에 적용하거나 드래그하여 미리 보고 놓으세요. 선택이 없으면 칠하기를 시작합니다.",
"render.background": "장면 배경",
"render.batch_all_shots": "모든 샷 ({count})",
"render.batch_empty": "모든 샷을 렌더링하기 전에 샷을 저장하세요.",
"render.batch_limit": "한 번에 최대 32개의 샷을 렌더링할 수 있습니다.",
"render.batch_status": "{running}개 실행 중 · {waiting}개 대기 중",
"render.bloom_enable": "표시 블룸",
"render.bloom_help": "양의 선형 하이라이트를 세 번의 정규화 필터로 확산하는 출력 전용 효과입니다. 반경은 출력 픽셀 단위이며 sRGB 전에 더합니다. 렌즈·볼륨·수렴 개선이 아니며 원본 EXR은 변하지 않습니다.",
"render.bloom_opaque": "블룸에는 불투명 출력이 필요합니다. 투명 PNG를 몰래 합성하지 않고 거부합니다.",
@@ -1960,13 +1805,6 @@
"render.children.workstation": "워크스테이션",
"render.choose_look": "먼저 외관 라이브러리에서 외관을 선택하세요",
"render.clear_stage": "Remove saved stage",
"render.compare_divider": "비교 구분선",
"render.compare_live": "라이브",
"render.compare_live_preview": "라이브 미리보기",
"render.compare_render": "렌더 {number}",
"render.compare_side": "렌더 {number} · {shot}",
"render.compare_summary": "{shot} · {width} × {height} · PNG · {seconds}초 · {renderer} · 리비전 {revision}에서 캡처",
"render.compare_swap": "맞바꾸기",
"render.cpu_budget": "CPU 리소스 프로필",
"render.cpu_budget_bodies": "바디 수 상한",
"render.cpu_budget_compact": "컴팩트 (기존 제한)",
@@ -2379,9 +2217,7 @@
"render.output_ready": "렌더링 준비 완료",
"render.output_run_check": "검사 실행",
"render.output_size_limit": "{method}: 한 변 최대 {dimension} px, 총 {pixels} 픽셀. 가능한 최대 크기: {width} × {height}.",
"render.output_size_method_profile": "{width} × {height}을(를) 렌더링하려면 {method}와(과) {profile} CPU 리소스 프로필을 선택하거나 더 작은 크기를 선택하세요.",
"render.output_size_ok": "크기 정상 · {width} × {height} px",
"render.output_size_profile": "{width} × {height}을(를) 렌더링하려면 {profile} CPU 리소스 프로필이나 더 작은 크기를 선택하세요.",
"render.output_tools_inspector": "출력 검사기에서 크기와 형식을 편집하세요.",
"render.output_view_image": "이미지 보기",
"render.paint_label": "칠하기",
@@ -2435,10 +2271,6 @@
"render.result_actual": "100%",
"render.result_actual_size": "100%",
"render.result_reduced_display": "표시 미리보기를 뷰포트 장치 또는 캐시 제한에 맞춰 축소했습니다. 보관된 PNG와 내보내기는 전체 해상도를 유지합니다. 이 축소 보기에서는 원본 100% 검사가 불가능합니다.",
"render.results_choose": "보관된 결과",
"render.results_label": "렌더 {number} · {shot} · {seconds}초",
"render.results_stale_label": "{result} [이전 버전]",
"render.results_unnamed": "이미지",
"render.return_design": "설계로 돌아가기",
"render.ribbon_backgrounds": "배경",
"render.ribbon_check": "검사",
@@ -2517,7 +2349,6 @@
"render.size_custom": "사용자 지정…",
"render.size_full_hd": "풀 HD",
"render.size_hd": "HD",
"render.size_needs_profile": "{size} · {profile} 필요",
"render.size_presets": "크기 프리셋",
"render.size_presets_pending": "출력 편집기와 함께 제공 예정",
"render.size_preview": "미리보기",
@@ -2725,7 +2556,6 @@
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
"settings.double_click_components": "구성 요소를 두 번 클릭하여 활성화하거나 연결된 구성 요소를 제자리에서 편집",
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다.",
"settings.insert_lists_open_designs": "설계 삽입에서 다른 탭에 열린 설계를 먼저 표시",
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
@@ -2739,34 +2569,12 @@
"sheet.flange_type_base": "베이스",
"sheet.flange_type_contour": "윤곽",
"sheet.flange_type_edge": "모서리",
"sheet.personal_rules.confirm_delete": "삭제 확인",
"sheet.personal_rules.delete": "삭제",
"sheet.personal_rules.delete_prompt": "이 개인 규칙을 삭제할까요? 설계에 이미 있는 규칙은 바뀌지 않습니다.",
"sheet.personal_rules.full": "개인 라이브러리에 이미 규칙이 64개 있습니다.",
"sheet.personal_rules.invalid_name": "줄 바꿈이나 제어 문자가 없는 짧은 이름을 입력하세요.",
"sheet.personal_rules.invalid_values": "규칙 값을 모두 입력하세요. 각 값은 이 설계에서 계산할 수 있어야 하며, 두께는 양수, K 계수는 0에서 1 사이여야 합니다.",
"sheet.personal_rules.missing": "선택한 개인 규칙을 더 이상 사용할 수 없습니다.",
"sheet.personal_rules.name": "규칙 이름",
"sheet.personal_rules.name_exists": "같은 이름의 개인 규칙이 이미 있습니다. 다른 이름을 사용하세요.",
"sheet.personal_rules.name_hint": "고유한 이름",
"sheet.personal_rules.overwrite": "선택한 개인 규칙에 변경 사항 저장",
"sheet.personal_rules.rename": "이름 바꾸기",
"sheet.personal_rules.rule": "개인 규칙",
"sheet.personal_rules.save": "새로 저장",
"sheet.personal_rules.scope": "모든 설계에서 쓸 수 있도록 프로필에 저장됩니다.",
"sheet.personal_rules.select": "규칙 선택",
"sheet.personal_rules.title": "개인 규칙",
"sheet.personal_rules.unsupported": "이 규칙에는 이 버전에서 사용할 수 없는 값이 있습니다.",
"shortcut.json_error": "매개변수는 JSON 형식이어야 합니다: {error}",
"shortcut.no_command": "`{id}` 명령이 없습니다. JSON 앞의 텍스트는 명령 ID여야 합니다",
"shortcut.pin": "S 상자 맨 위에 고정",
"shortcut.unpin": "고정 해제",
"sketch is missing": "스케치가 없습니다",
"sketch.degrees_of_freedom": "자유도 {count}개",
"sketch.dimension_added_driven": "스케치가 이미 이 치수를 결정하므로 종속(참조) 치수로 추가했습니다",
"sketch.dimension_applied": "치수를 적용했습니다",
"sketch.dimension_applied_defined": "치수를 적용했습니다. 매개변수 `{name}`을(를) 정의했습니다",
"sketch.dimension_applied_renamed": "치수를 적용했습니다. 매개변수 이름을 `{name}`(으)로 지정했습니다",
"sketch.dof_left": "남은 자유도 {count}개",
"sketch.dof_left_one": "남은 자유도 {count}개",
"sketch.draw_curves": "{linetype}(으)로 그리기",
@@ -2901,10 +2709,6 @@
"ui.3d_mouse_offer": "3D 마우스를 보정하면 바뀌거나 반전된 축이 바로잡히고 속도와 조작감이 맞춰집니다.",
"ui.3d_mouse_offer_never": "다시 표시하지 않음",
"ui.3d_mouse_offer_swap": "Hidraw(직접 드라이버)로 전환",
"ui.3d_mouse_orbit": "궤도 회전",
"ui.3d_mouse_orbit_classic": "클래식",
"ui.3d_mouse_orbit_tip": "추적: 회전을 시작할 때 뷰 가운데에 있는 모델 부분을 중심으로 3D 마우스가 뷰를 회전하며, 그 점을 희미한 구로 표시합니다. 클래식: 뷰의 중심을 기준으로",
"ui.3d_mouse_orbit_traced": "추적",
"ui.3d_mouse_reading": "읽은 값",
"ui.3d_mouse_reset_axes": "축 지정 초기화",
"ui.3d_mouse_reset_view": "뷰 초기화",
@@ -2941,8 +2745,6 @@
"ui.3d_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.",
"ui.3d_mouse_switch_cap": "중앙 복귀형으로 보정",
"ui.3d_mouse_switch_trackball": "트랙볼로 보정",
"ui.3d_mouse_use_direct": "직접 드라이버 사용",
"ui.3d_mouse_use_direct_tip": "spacenavd를 거치지 않고 3D 마우스를 직접(hidraw) 읽습니다.",
"ui.3d_mouse_view_axis": "뷰 축",
"ui.3d_mouse_view_off": "끔",
"ui.3d_mouse_view_off_tip": "3D 마우스 드라이버 프로필이 이 축을 끄므로, 이 축의 동작으로는 뷰가 움직이지 않습니다. 여기서 다른 축의 동작을 고르면 둘이 바뀌고, 그 동작은 더 이상 뷰를 움직이지 않습니다.",
@@ -2979,15 +2781,12 @@
"ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:",
"ui.agent_chat_auth_title": "에이전트에 로그인",
"ui.agent_chat_cancel": "취소",
"ui.agent_chat_cancel_timeout": "에이전트가 중지 요청에 응답하지 않아 연결이 끊어졌습니다. 계속하려면 다시 연결하세요.",
"ui.agent_chat_cannot_start": "설치되어 있지만 시작되지 않습니다: {error}",
"ui.agent_chat_changes": "변경 {n}개, 실행 취소 한 번",
"ui.agent_chat_changes_of": "변경 {n}/{limit}",
"ui.agent_chat_close": "닫기",
"ui.agent_chat_command": "에이전트 명령",
"ui.agent_chat_connect": "연결",
"ui.agent_chat_copy": "복사",
"ui.agent_chat_custom_heading": "사용자 지정 모델",
"ui.agent_chat_custom_hint": "표준 입출력으로 Agent Client Protocol을 사용하는 모든 프로그램. 셸 없이 실행됩니다.",
"ui.agent_chat_denied": "거부함",
"ui.agent_chat_deny": "거부",
@@ -3029,15 +2828,7 @@
"ui.agent_chat_mode_all": "모두",
"ui.agent_chat_mode_ask": "확인",
"ui.agent_chat_mode_edits": "편집",
"ui.agent_chat_model_custom": "직접 입력 사용: {model}",
"ui.agent_chat_model_rejected": "에이전트가 {model}을(를) 받아들이지 않았습니다: {error}",
"ui.agent_chat_model_search": "모델 검색…",
"ui.agent_chat_model_tip": "에이전트가 사용하는 모델입니다. 선택은 이 에이전트에 기억됩니다.",
"ui.agent_chat_models_failed": "모델 목록을 가져올 수 없습니다: {error}",
"ui.agent_chat_models_heading": "사용 가능한 모델",
"ui.agent_chat_models_listing": "모델 목록을 가져오는 중…",
"ui.agent_chat_models_none": "엔드포인트에 나열된 모델이 없습니다",
"ui.agent_chat_models_not_a_list": "응답이 모델 목록이 아닙니다",
"ui.agent_chat_new": "새 채팅",
"ui.agent_chat_new_confirm": "새 채팅을 시작할까요? 현재 채팅은 지워집니다.",
"ui.agent_chat_next_picture": "다음 이미지",
@@ -3066,12 +2857,9 @@
"ui.agent_chat_queued_stopped": "대기 중 (중지됨, 보내지 않음)",
"ui.agent_chat_refresh": "새로 고침",
"ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}",
"ui.agent_chat_reinstall": "{agent}이(가) 설치되어 있지만({program}) SolidCraft가 확인할 때 시작되지 않았습니다: {error}\n설치가 완료되지 않았거나 다른 종류의 컴퓨터용일 수 있습니다. `{install}`(으)로 다시 설치하고, 터미널에서 버전이 출력되는지 확인한 뒤 새로 고침을 누르세요.",
"ui.agent_chat_remove": "대기열에서 제거",
"ui.agent_chat_remove_file": "이 메시지에서 빼기",
"ui.agent_chat_remove_imported": "이 메시지에서 빼기. 부품은 디자인에 남습니다 (실행 취소하면 제거됨)",
"ui.agent_chat_reply_cut_off": "응답이 끝나기 전에 길이 한도에 도달해 불완전할 수 있습니다. 에이전트에게 계속하거나 더 짧게 답하라고 요청하세요.",
"ui.agent_chat_reply_declined": "모델이 이 메시지에 대한 답변을 거절했습니다.",
"ui.agent_chat_right": "오른쪽",
"ui.agent_chat_save": "저장",
"ui.agent_chat_send": "보내기",
@@ -3125,17 +2913,12 @@
"ui.ai_copy_suffix": "복사본",
"ui.ai_custom_url": "사용자 지정 URL",
"ui.ai_default": "기본값",
"ui.ai_edit": "편집",
"ui.ai_edit_agent_title": "에이전트 편집",
"ui.ai_edit_gone": "이 에이전트는 제거되었으므로 저장할 내용이 없습니다.",
"ui.ai_edit_tip": "이 에이전트의 이름, 명령 또는 엔드포인트를 변경합니다. 저장된 키는 표시되지 않습니다.",
"ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요",
"ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요",
"ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요",
"ui.ai_http_too_large": "대화가 이 엔드포인트에 비해 너무 큽니다. 새 채팅을 시작하세요",
"ui.ai_key": "API 키",
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
"ui.ai_key_keep_hint": "저장된 키를 유지하려면 비워 두세요",
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
"ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
"ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}",
@@ -3162,7 +2945,6 @@
"ui.ai_preset": "프리셋",
"ui.ai_remove": "제거",
"ui.ai_reset": "재설정",
"ui.ai_save": "저장",
"ui.ai_settings": "AI 설정…",
"ui.ai_show_features": "AI 기능 표시",
"ui.ai_show_features_note": "에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.",
@@ -3226,7 +3008,6 @@
"ui.circle": "원",
"ui.circular_pattern": "원형 패턴",
"ui.clear": "지우기",
"ui.clear_print_face": "출력 바닥면 해제",
"ui.clear_recent": "최근 항목 지우기",
"ui.clear_the_selection": "선택 해제",
"ui.click_selects_whole_bodies": "클릭하면 바디 전체 선택",
@@ -3255,25 +3036,12 @@
"ui.coming_later": "추후 지원 예정",
"ui.command_done": "{command} 완료",
"ui.command_palette_hint": "명령 입력(예: {example}) — 또는 S 키를 눌러 검색",
"ui.components_from_bodies_hint": "활성 구성요소 아래에 선택한 각 바디와 같은 이름의 구성요소를 만듭니다.",
"ui.components_from_bodies_required": "바디를 하나 이상 선택하세요.",
"ui.concentric": "동심",
"ui.cone": "원뿔",
"ui.conic_curve": "원뿔 곡선",
"ui.constant": "상수",
"ui.constraints_conflict": "구속 조건 충돌",
"ui.construction": "구성선",
"ui.construction.angle_hint": "보조 직선: 점 클릭 후 각도 입력 또는 방향 선택",
"ui.construction.bisector_hint": "이등분선: 교차하는 두 직선 선택 후 원하는 해 선택",
"ui.construction.common_hint": "공통 접선: 두 원/원호 선택 후 원하는 해 선택",
"ui.construction.contact_hint": "접선: 원/원호 또는 열린 스플라인/원뿔 곡선 선택 후 접점 선택 (자유 곡선은 끝점만)",
"ui.construction.external_hint": "접선: 원 또는 원호, 외부 점, 원하는 해를 차례로 선택",
"ui.construction.free_hint": "보조 직선: 두 점 클릭; 마우스 오른쪽 버튼으로 다시 시작",
"ui.construction.normal_needs_length": "무한 직선은 항상 보조선입니다. 일반 선을 그리려면 길이를 입력하세요",
"ui.construction.point_hint": "보조 직선: 통과할 점 클릭",
"ui.construction.reference_hint": "보조 직선: 직선 선택 후 점 선택; 마우스 오른쪽 버튼으로 참조 변경",
"ui.construction.ribbon_label": "보조선",
"ui.construction.side_hint": "보조 직선: 선이 뻗어 나갈 쪽 클릭; 마우스 오른쪽 버튼으로 다시 시작",
"ui.context_menu": "컨텍스트 메뉴",
"ui.continuity_g0": "G0 꺾임",
"ui.continuity_g1": "G1 접선",
@@ -3299,7 +3067,6 @@
"ui.dark": "어둡게",
"ui.default_modelling_orientation": "기본 모델링 방향",
"ui.delete": "삭제",
"ui.delete_select_first": "먼저 삭제할 항목을 선택하세요: 피처, 바디, 컴포넌트 또는 스케치 형상",
"ui.depth": "깊이",
"ui.design": "디자인",
"ui.design_properties.author": "작성자",
@@ -3313,40 +3080,6 @@
"ui.design_properties.part_number": "부품 번호",
"ui.design_properties.project": "프로젝트",
"ui.design_properties.revision": "리비전",
"ui.details_access_caption": "선택한 방향에서 공구가 각 면에 도달할 수 있는지 표시합니다.",
"ui.details_angle": "각도",
"ui.details_continuity_caption": "선택한 이음부를 편집합니다. 다른 이음부는 각자의 설정을 유지합니다.",
"ui.details_continuity_stale": "이전 형상입니다. 이 이음부를 편집하거나 화면에 맞추려면 분석을 다시 실행하세요.",
"ui.details_copy_list": "목록 복사",
"ui.details_curvature": "곡률 공차",
"ui.details_curvature_caption": "각 면의 국소 곡률 반지름에 따라 색상이 표시됩니다.",
"ui.details_direction": "공구 방향",
"ui.details_draft_caption": "금형 분리 방향에 대한 각 면의 각도에 따라 색상이 표시됩니다.",
"ui.details_edit_seam": "이음부 편집",
"ui.details_fixture": "UI 구성 요소 예시 — 샘플 데이터만 표시",
"ui.details_fixture_caption": "설명용 값을 사용하는 UI 구성 요소 예시입니다. 모델의 분석 결과가 아닙니다.",
"ui.details_fixture_problem": "문제가 있는 결과 예시",
"ui.details_fixture_readout": "측정값 예시",
"ui.details_flip": "뒤집기",
"ui.details_gap": "간격",
"ui.details_hatch": "절단면 해칭",
"ui.details_jump": "곡률 불연속",
"ui.details_key": "범례",
"ui.details_offset": "오프셋",
"ui.details_plane": "평면",
"ui.details_pull": "금형 분리 방향",
"ui.details_radius": "반지름",
"ui.details_readout": "측정값",
"ui.details_samples": "샘플 수",
"ui.details_seam": "{body} · 모서리 {edge}",
"ui.details_settings": "설정",
"ui.details_sharp_gap": "날카로운 이음 또는 간격",
"ui.details_show_all": "전체 {count}개 표시",
"ui.details_smooth": "매끄러움",
"ui.details_stripes": "줄무늬",
"ui.details_target": "형상에 맞게 보기 조정",
"ui.details_worst_seams": "연속성이 가장 나쁜 이음부",
"ui.details_zebra_caption": "반사된 줄무늬는 면이 얼마나 매끄럽게 이어지는지 보여 줍니다.",
"ui.diameter": "지름",
"ui.dimension": "치수",
"ui.direct_interaction": "직접 조작",
@@ -3391,26 +3124,14 @@
"ui.edit_in_place_saved_elsewhere": "부품이 다른 파일에 저장되었습니다. 어셈블리는 여전히 {name}에 연결되어 있습니다",
"ui.edit_in_place_wait": "디자인에서 실행 중인 작업이 끝날 때까지 기다리세요",
"ui.edit_section_analysis": "단면 분석 편집",
"ui.edit_sketch_empty": "편집할 스케치가 없습니다",
"ui.ellipse": "타원",
"ui.emboss_wrap_note": "지원되는 원통에 접하는 직교 선 프로파일은 자동으로 감깁니다. 대상을 고르려면 벽을 선택하세요.",
"ui.end_radius": "끝 반지름",
"ui.end_scale": "끝 배율",
"ui.entities": "요소",
"ui.equal": "동일",
"ui.equation_end": "끝",
"ui.equation_explicit": "명시적",
"ui.equation_form": "형식",
"ui.equation_note": "변수는 단위가 없는 수입니다. sin(t)는 라디안으로 읽습니다. 단위가 없는 결과는 디자인 단위입니다.",
"ui.equation_note_range": "변수는 범위와 같이 길이입니다. 길이 매개변수와 함께 쓸 수 있으며, sin()에는 길이로 나누어 넣습니다. 단위가 없는 결과는 디자인 단위로 읽습니다.",
"ui.equation_parametric": "매개변수식",
"ui.equation_variable_unit": "변수에 범위의 단위 사용",
"ui.equation_x_of_t": "x(t)",
"ui.equation_y_of_t": "y(t)",
"ui.equation_y_of_x": "y(x)",
"ui.equivalent_radius": "등가 반지름",
"ui.expand": "펼치기",
"ui.explode_needs_two_components": "분해 보기는 구성 요소를 서로 떼어 놓으므로 설계에 구성 요소가 두 개 이상 있어야 합니다(새 구성 요소, 설계 삽입 또는 바디로 구성 요소 만들기)",
"ui.exportDxf": "DXF 내보내기…",
"ui.export_ellipsis_keymap": "내보내기…",
"ui.export_keymap_tip": "키, S 상자 즐겨찾기, 도구 모음을 파일로 내보내기",
@@ -3467,7 +3188,6 @@
"ui.heading_design": "디자인",
"ui.heading_draft": "구배",
"ui.heading_edit": "편집",
"ui.heading_equation_curve": "방정식 곡선",
"ui.heading_extrude": "돌출",
"ui.heading_fillet": "필렛",
"ui.heading_finish_editing": "편집 완료",
@@ -3555,12 +3275,7 @@
"ui.import_ellipsis": "가져오기…",
"ui.infinite": "무한",
"ui.insert": "삽입",
"ui.insert_design.browse": "찾아보기…",
"ui.insert_design.open_designs": "열린 설계",
"ui.insert_design.saved_file": "{name}에 저장되지 않은 변경 사항이 있어 저장된 파일을 삽입했습니다. 저장한 다음 링크 업데이트로 변경 사항을 가져오세요.",
"ui.insert_design.unsaved": "저장되지 않은 변경 사항",
"ui.inspect": "검사",
"ui.instances": "인스턴스",
"ui.intersect": "교차",
"ui.isolate": "분리 표시",
"ui.join": "접합",
@@ -3569,14 +3284,12 @@
"ui.joints": "조인트",
"ui.keyboard_shortcuts": "키보드 단축키",
"ui.language": "언어",
"ui.lasso_select": "올가미(자유롭게 둘러 그리기: 완전히 안쪽에 있는 것을 선택)",
"ui.layout_compact": "컴팩트",
"ui.layout_floating": "플로팅",
"ui.length": "길이",
"ui.length_decimals": "길이 소수점 자릿수",
"ui.light": "밝게",
"ui.line": "선",
"ui.line_arc_hint": "접선 호: 호의 끝점을 클릭합니다. 선으로 돌아가려면 마지막 점에 다시 머무르고, 끝내려면 오른쪽 클릭 또는 Esc를 누릅니다",
"ui.lock": "잠금",
"ui.loft": "로프트",
"ui.look_at": "바라보기",
@@ -3656,7 +3369,6 @@
"ui.part_size_shared": "부품을 다시 빌드하며 추가된 피처는 모두 대체됩니다. 이 구성요소의 모든 인스턴스가 업데이트됩니다.",
"ui.paste": "붙여넣기",
"ui.path": "경로",
"ui.pattern_object_type_tip": "바디: 뷰나 브라우저에서 선택하는 솔리드 형상이며, 각각 통째로 복사됩니다.\n피처: 돌출이나 구멍 같은 기록의 단계이며, 기록에서 또는 그 단계가 만든 면에서 선택합니다. 그 단계가 반복됩니다.\n스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
"ui.pattern_on_path": "경로 패턴",
"ui.perpendicular": "수직",
"ui.perspective_view": "원근 투영 뷰",
@@ -3688,11 +3400,6 @@
"ui.polygon": "다각형",
"ui.preferences": "환경 설정",
"ui.press_a_key": "키를 누르세요…",
"ui.print_face_body_not_sent": "이 면의 바디는 보내지지 않습니다. 먼저 바디에 추가하십시오",
"ui.print_face_cleared": "{body}: 출력 바닥면을 해제했습니다",
"ui.print_face_input": "출력 바닥면",
"ui.print_face_pick": "솔리드 바디의 평면 하나를 선택하면 출력 바닥면이 됩니다",
"ui.print_face_set": "{body}: 출력 바닥면을 설정했습니다. 슬라이서로 보내면 이 면이 아래로 놓입니다",
"ui.profile": "프로파일",
"ui.profile_plane": "프로파일 평면",
"ui.profiles": "프로파일",
@@ -3708,7 +3415,6 @@
"ui.properties.inertia_y": "Iyx, Iyy, Iyz",
"ui.properties.inertia_z": "Izx, Izy, Izz",
"ui.properties.measuring": "측정 중…",
"ui.properties.measuring_bodies": "측정 중… 바디 {total}개 중 {done}개",
"ui.properties.measuring_failed": "측정이 끝나기 전에 중단되었습니다.",
"ui.properties.principal_axis": "주축 {number}",
"ui.properties.principal_moments": "주관성 모멘트",
@@ -3769,8 +3475,6 @@
"ui.select": "선택",
"ui.selectOther": "기타 선택…",
"ui.selectTangentChain": "접선 체인 선택",
"ui.select_all": "모두 선택",
"ui.select_components_by_face": "구성요소 선택(면을 클릭하면 해당 구성요소 선택)",
"ui.selection": "선택",
"ui.selection_modifiers": "선택 보조 키",
"ui.selection_shift_add_only": "Shift+클릭은 추가만 함",
@@ -3813,7 +3517,6 @@
"ui.slice": "슬라이스",
"ui.slicer": "슬라이서",
"ui.slicer_delete_tip": "{program}\nDelete 키로 목록에서 제거",
"ui.slicer_single_instance_tip": "실행 중인 창에 파일을 불러오는 슬라이서용입니다.",
"ui.slot": "슬롯",
"ui.smooth": "부드럽게",
"ui.smooth_wheel_zoom": "부드러운 휠 확대/축소",
@@ -3900,19 +3603,10 @@
"ui.view_centre": "뷰 중심",
"ui.viewport_status_clear": "{name} 끄기",
"ui.viewport_status_clear_hint": "{name} 끄기 · Esc",
"ui.viewport_status_edges": "모서리 {count}개",
"ui.viewport_status_from": "{direction} 방향에서",
"ui.viewport_status_key": "{name} 범례 표시",
"ui.viewport_status_minimum_radius": "최소 반지름",
"ui.viewport_status_pairs": "{count}쌍",
"ui.viewport_status_section_hatch": "{setting} · 해칭",
"ui.viewport_status_section_plane": "현재 평면",
"ui.viewport_status_section_tilted": "기울어짐 · {offset}",
"ui.viewport_status_stripes": "줄무늬 {count}개",
"ui.viewport_status_summary_clear": "모두 끄기",
"ui.viewport_status_summary_heading": "이 뷰에서",
"ui.viewport_status_summary_label": "{name} 외 {count}개 항목",
"ui.viewport_status_summary_more": "+{count}",
"ui.viewport_theme_follow": "테마 따르기",
"ui.visual_shaded_hidden_edges": "음영 처리 및 숨겨진 모서리",
"ui.visual_shaded_with_edges": "음영 처리 및 모서리",
@@ -3943,10 +3637,8 @@
"update.available": "SolidCraft {version} 사용 가능",
"update.button": "업데이트",
"update.button_version": "{version}(으)로 업데이트",
"update.check_automatically": "시작 시 및 하루에 한 번 확인",
"update.check_for_updates": "업데이트 확인",
"update.check_automatically": "업데이트 자동 확인",
"update.check_now": "지금 확인",
"update.check_unavailable": "이 설치에서는 업데이트를 확인할 수 없습니다.",
"update.checking": "확인 중…",
"update.copy": "복사",
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1188
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -606,8 +606,6 @@
"cmd.appearance_library": "Biblioteca de aparências",
"cmd.appearance_list": "Listar aparências",
"cmd.assembly_bom": "Lista de materiais",
"cmd.assembly_mass_properties": "Propriedades de massa",
"cmd.body.print_face": "Face de impressão",
"cmd.body_rename": "Renomear corpo",
"cmd.browser_group": "Novo grupo",
"cmd.browser_move": "Mover itens do navegador",
@@ -817,16 +815,6 @@
"cmd.sheet_refold": "Redobrar",
"cmd.sheet_rules": "Listar regras de chapa metálica",
"cmd.sheet_unfold": "Desdobrar",
"cmd.sketch.construction_line": "Reta de construção",
"cmd.sketch.construction_line.angle": "Reta de construção em ângulo",
"cmd.sketch.construction_line.bisector": "Bissetriz",
"cmd.sketch.construction_line.horizontal": "Reta de construção horizontal",
"cmd.sketch.construction_line.parallel": "Reta de construção paralela",
"cmd.sketch.construction_line.perpendicular": "Reta de construção perpendicular",
"cmd.sketch.construction_line.tangent_at": "Tangente em um ponto da curva",
"cmd.sketch.construction_line.tangent_external": "Tangente por um ponto externo",
"cmd.sketch.construction_line.tangent_two": "Tangente comum",
"cmd.sketch.construction_line.vertical": "Reta de construção vertical",
"cmd.sketch_arc_center_point": "Arco por ponto central",
"cmd.sketch_arc_tangent": "Arco tangente",
"cmd.sketch_arc_three_point": "Arco por 3 pontos",
@@ -1294,8 +1282,6 @@
"prefs.orientation": "Orientação",
"prefs.overall_speed": "Velocidade geral",
"prefs.preview_choices": "Prévia das suas escolhas",
"prefs.print_face_highlight": "Destaque da face de impressão",
"prefs.print_face_highlight_tip": "Intensidade da cor e do contorno da face de impressão de um corpo na vista. 0 % não mostra destaque.",
"prefs.quality_default": "padrão",
"prefs.quality_fastest": "mais rápido",
"prefs.quality_slowest": "mais lento",
@@ -1575,8 +1561,6 @@
"ui.3d_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.",
"ui.3d_mouse_switch_cap": "Calibrar como retorno ao centro",
"ui.3d_mouse_switch_trackball": "Calibrar como trackball",
"ui.3d_mouse_use_direct": "Usar driver direto",
"ui.3d_mouse_use_direct_tip": "Ler o mouse 3D diretamente (hidraw) em vez de pelo spacenavd.",
"ui.3d_mouse_view_axis": "Eixo da vista",
"ui.3d_mouse_view_off": "Desligado",
"ui.3d_mouse_view_off_tip": "O perfil do driver do mouse 3D desliga este eixo, então o movimento dele não move a vista. Escolher aqui o movimento de outro eixo troca os dois, e esse movimento deixa de mover a vista.",
@@ -1846,7 +1830,6 @@
"ui.circle": "Círculo",
"ui.circular_pattern": "Padrão circular",
"ui.clear": "Limpar",
"ui.clear_print_face": "Limpar face de impressão",
"ui.clear_recent": "Limpar recentes",
"ui.clear_the_selection": "Limpar a seleção",
"ui.click_selects_whole_bodies": "O clique seleciona corpos inteiros",
@@ -1881,7 +1864,6 @@
"ui.constant": "Constante",
"ui.constraints_conflict": "Restrições em conflito",
"ui.construction": "Construção",
"ui.construction.ribbon_label": "Linhas aux.",
"ui.context_menu": "Menu de contexto",
"ui.continuity_g0": "G0 vinco",
"ui.continuity_g1": "G1 tangente",
@@ -2092,7 +2074,6 @@
"ui.infinite": "Infinita",
"ui.insert": "Inserir",
"ui.inspect": "Inspecionar",
"ui.instances": "Instâncias",
"ui.intersect": "Interseção",
"ui.isolate": "Isolar",
"ui.join": "Unir",
@@ -2206,11 +2187,6 @@
"ui.polygon": "Polígono",
"ui.preferences": "Preferências",
"ui.press_a_key": "pressione uma tecla…",
"ui.print_face_body_not_sent": "O corpo dessa face não está sendo enviado: adicione-o primeiro a Corpos",
"ui.print_face_cleared": "{body}: face de impressão removida",
"ui.print_face_input": "Face de impressão",
"ui.print_face_pick": "Selecione uma face plana de um corpo sólido para torná-la a face de impressão",
"ui.print_face_set": "{body}: face de impressão definida; Enviar ao fatiador apoia o corpo sobre ela",
"ui.profile": "Perfil",
"ui.profile_plane": "Plano do perfil",
"ui.profiles": "Perfis",
@@ -2268,7 +2244,6 @@
"ui.select": "Selecionar",
"ui.selectOther": "Selecionar outro…",
"ui.selectTangentChain": "Selecionar cadeia tangente",
"ui.select_all": "Selecionar tudo",
"ui.selection": "Seleção",
"ui.selection_modifiers": "Modificadores de seleção",
"ui.selection_shift_add_only": "Shift+clique adiciona sem remover",
@@ -2401,9 +2376,7 @@
"update.button": "Atualizar",
"update.button_version": "Atualizar para {version}",
"update.check_automatically": "Verificar atualizações automaticamente",
"update.check_for_updates": "Verificar atualizações",
"update.check_now": "Verificar agora",
"update.check_unavailable": "A verificação de atualizações não está disponível para esta instalação.",
"update.checking": "Verificando…",
"update.copy": "Copiar",
"update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o SolidCraft para Aplicativos, substituindo a cópia antiga, e reinicie o SolidCraft.",
File diff suppressed because it is too large Load Diff
+1 -1093
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -683
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -683
View File
File diff suppressed because it is too large Load Diff
+27 -241
View File
@@ -20,7 +20,6 @@ struct U {
sel: vec4<f32>, // xyz a whole selected body's colour before lighting
product_lights_meta: vec4<f32>, // explicit preview flag and bounded count
product_lights: array<vec4<f32>,84>, // position/kind, radiance, axis/cone, attenuation
sky: vec4<f32>, // xyz = world up; w = 1: shade in linear light with a sky fill (#7974)
};
fn reversed() -> bool {
@@ -35,7 +34,6 @@ 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_2d<u32>;
@group(2) @binding(1) var ao_map: texture_2d<f32>;
fn clipped(p: vec3<f32>) -> bool {
return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
@@ -63,7 +61,7 @@ fn out_color(c: vec4<f32>) -> vec4<f32> {
}
struct TOut {
@invariant @builtin(position) pos: vec4<f32>,
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) c: vec4<f32>,
@location(2) wp: vec3<f32>,
@@ -108,7 +106,7 @@ fn vs_tri_inst(
/// stores every vertex's outputs: more of them cost more than the triangles' shared vertices
/// save).
struct FOut {
@invariant @builtin(position) pos: vec4<f32>,
@builtin(position) pos: vec4<f32>,
@location(0) rp: vec3<f32>,
@location(1) c: vec4<f32>,
};
@@ -210,12 +208,6 @@ fn fs_ao_depth_flat(i: FOut, @builtin(front_facing) front: bool) -> AoDepth {
return opaque_depth(t, front);
}
@fragment
fn fs_ao_term_flat(i: FOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let t = flat_fragment(i, front);
return ao_term(t, front);
}
/// Opaque model surfaces: cut by the section plane/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
@fragment
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
@@ -251,44 +243,6 @@ fn pushed(z: f32) -> f32 {
return clamp(z - select(-push, push, reversed()), 0.0, 1.0);
}
/// A far wall seen through the cut where another body's face lying on it was drawn in front of
/// it (a shaft in its bore, #9407): its cap colour again, at its depth pulled toward the eye by
/// `touch_depth`, so it wins over that face, which is faceted another way.
@fragment
fn fs_solid_touch(i: TOut, @builtin(front_facing) front: bool) -> CutOut {
var o: CutOut;
// Derivatives (in `touch_depth`) first, before `solid` may discard.
o.depth = touch_depth(i.wp, i.pos.z);
o.c = solid(i, front);
return o;
}
@fragment
fn fs_solid_touch_flat(i: FOut, @builtin(front_facing) front: bool) -> CutOut {
let t = flat_fragment(i, front);
var o: CutOut;
o.depth = touch_depth(t.wp, t.pos.z);
o.c = solid(t, front);
return o;
}
/// The depth of `wp` (depth `z`) pulled toward the eye by what may part two faceted copies of
/// one surface: 3 pixels' width along the view ray (display refinement keeps a facet's sag about
/// a pixel) and 2 pixels of the face's own depth slope (more where it turns from the eye).
fn touch_depth(wp: vec3<f32>, z: f32) -> f32 {
let px = min(length(dpdx(wp)), length(dpdy(wp)));
let slope = max(abs(dpdx(z)), abs(dpdy(z)));
var dir = -u.back.xyz;
if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; }
let len = length(dir);
if (len < 1e-12) { return z; }
let c = u.vp * vec4<f32>(wp - dir * (3.0 * px / len), 1.0);
if (c.w <= 0.0) { return z; }
let pulled = c.z / c.w;
if (reversed()) { return clamp(max(pulled, z) + 2.0 * slope, 0.0, 1.0); }
return clamp(min(pulled, z) - 2.0 * slope, 0.0, 1.0);
}
/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut),
/// never behind the wall itself (`z`).
fn cap_depth(wp: vec3<f32>, z: f32) -> f32 {
@@ -353,89 +307,11 @@ fn opaque_depth(i: TOut, front: bool) -> AoDepth {
return AoDepth(depth, bitcast<u32>(depth));
}
/// The term for an opaque surface's fragment, on the prepass's depth. A section's cut face has none.
fn ao_term(i: TOut, front: bool) -> vec4<f32> {
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
if (!front && length(u.clip.xyz) > 0.5) { return vec4<f32>(1.0); }
return vec4<f32>(occlusion_term(i));
}
@fragment
fn fs_ao_term(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return ao_term(i, front);
}
@vertex
fn vs_ao_blur(@builtin(vertex_index) v: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((v << 1u) & 2u), f32(v & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
/// The depth map at `p`, reading 0 near and 1 far whichever way the buffer runs.
fn map_depth(p: vec2<i32>) -> f32 {
let at = clamp(p, vec2<i32>(0), vec2<i32>(textureDimensions(ao_depth)) - vec2<i32>(1));
let d = bitcast<f32>(textureLoad(ao_depth, at, 0).x);
return select(d, 1.0 - d, reversed());
}
/// The raw term (`ao_map` in this pass) averaged over a 4 x 4 window, which holds every turn of
/// [`ao_turn`]'s pattern once. A neighbour counts when it lies on this pixel's plane, the slope
/// taken from the nearer side across each axis, so no surface's shade crosses an edge.
@fragment
fn fs_ao_blur(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let c = vec2<i32>(pos.xy);
let d = map_depth(c);
if (d >= 1.0) { return vec4<f32>(1.0); }
let left = d - map_depth(c + vec2<i32>(-1, 0));
let right = map_depth(c + vec2<i32>(1, 0)) - d;
let above = d - map_depth(c + vec2<i32>(0, -1));
let below = map_depth(c + vec2<i32>(0, 1)) - d;
let dx = select(right, left, abs(left) < abs(right));
let dy = select(below, above, abs(above) < abs(below));
let tolerance = 1.5 * (abs(dx) + abs(dy)) + 1e-6;
let last = vec2<i32>(textureDimensions(ao_map)) - vec2<i32>(1);
var sum = 0.0;
var weight = 0.0;
for (var y = -2; y < 2; y = y + 1) {
for (var x = -2; x < 2; x = x + 1) {
let p = c + vec2<i32>(x, y);
let s = map_depth(p);
if (s < 1.0 && abs(s - (d + dx * f32(x) + dy * f32(y))) <= tolerance) {
sum = sum + textureLoad(ao_map, clamp(p, vec2<i32>(0), last), 0).x;
weight = weight + 1.0;
}
}
}
return vec4<f32>(sum / max(weight, 1.0));
}
/// A pixel's turn of the sample pattern: every 4 x 4 window holds each of 16 turns once.
fn ao_turn(pos: vec2<f32>) -> f32 {
let x = u32(pos.x) & 3u;
let y = u32(pos.y) & 3u;
// WGSL won't mix shifts and `|` without parentheses; naga takes it, browsers refuse it (#11080).
let v = (((x ^ y) & 1u) << 3u) | ((y & 1u) << 2u) | (((x ^ y) >> 1u) << 1u) | (y >> 1u);
return (f32(v) + 0.5) / 16.0;
}
/// The lit pass's occlusion: the blurred term where this point falls in the map, whatever the
/// window's viewport offset and supersampling.
fn occlusion(i: TOut) -> f32 {
let size = vec2<i32>(textureDimensions(ao_map));
// The maps are 1 × 1 stand-ins until a frame draws into them (the product preview draws no
// prepass, and AO off releases them): they tell nothing, so leave the surface unoccluded.
if (u.ao.x <= 0.0 || length(i.n) < 0.5 || size.x < 2 || size.y < 2) { return 1.0; }
let q = u.vp * vec4<f32>(i.wp, 1.0);
if (q.w <= 0.0) { return 1.0; }
let uv = vec2<f32>(q.x / q.w * 0.5 + 0.5, 0.5 - q.y / q.w * 0.5);
return textureLoad(ao_map, clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - vec2<i32>(1)), 0).x;
}
// Cosine-weighted hemisphere samples, the pattern turned by a different angle at each pixel.
// 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_term(i: TOut) -> f32 {
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);
@@ -449,9 +325,10 @@ fn occlusion_term(i: TOut) -> f32 {
if (size.x < 2 || size.y < 2) { return 1.0; }
// The same offsets at every pixel made each sample darken its own shifted copy of a nearby
// part's outline: faint ghosts of the part where the radius is large on screen (#4167),
// stepped bands along contacts where it is small (#3407). Turned, neighbouring pixels sample
// different points, the copies break up into grain, and the blur takes the grain out (#9327).
let turn = ao_turn(i.pos.xy);
// stepped bands along contacts where it is small (#3407). Turned by interleaved gradient
// noise (Jimenez 2014), neighbouring pixels sample different points and the copies break up
// into an even grain.
let turn = fract(52.9829189 * fract(dot(i.pos.xy, vec2<f32>(0.06711056, 0.00583715))));
let t = tangent * cos(turn * 6.28318531) + bitangent * sin(turn * 6.28318531);
let b = cross(n, t);
// One comparison for both depth directions: depths read as 0 near / 1 far (mirrored when
@@ -508,13 +385,8 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
let r = reflect(-v, n);
var side = cross(v, vec3<f32>(0.0, 0.0, 1.0));
if (length(side) < 1e-3) { side = vec3<f32>(1.0, 0.0, 0.0); }
let x = dot(r, normalize(side));
let s = sin(x * u.ana.y * 3.14159265);
// A face whose normal lies in the upright plane through the view (every horizontal
// face) reflects straight ahead: its sideways part is zero, on the edge between a black
// and a white stripe, and rounding picked one or the other from frame to frame while
// the view turned (#2153). Straight ahead is white.
let c = select(0.08, 0.95, s > 0.0 || abs(x) < 1e-4);
let s = sin(dot(r, normalize(side)) * u.ana.y * 3.14159265);
let c = select(0.08, 0.95, s > 0.0);
return out_color(vec4<f32>(vec3<f32>(c), 1.0));
}
if (u.ana.x > 4.5) {
@@ -635,31 +507,6 @@ fn lit(i: TOut, visibility: f32) -> vec4<f32> {
return lit_color(i, visibility, i.c);
}
/// sRGB to linear light, exact (the straight segment too, which `to_linear` leaves out).
fn srgb_decode(c: vec3<f32>) -> vec3<f32> {
return select(c / 12.92, pow((c + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4)), c > vec3<f32>(0.04045));
}
fn srgb_encode(c: vec3<f32>) -> vec3<f32> {
let l = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
return select(l * 12.92, 1.055 * pow(l, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055), l > vec3<f32>(0.0031308));
}
/// The studio light summed in linear light, encoded once (#7974): the key and the eye fill as in
/// [`lit_color`], over a sky fill that grows as the face turns up, and the 4% every dielectric
/// reflects of that sky (a little more toward grazing), so a near-black face keeps a gradient. The sky
/// and the fill are what ambient occlusion (`visibility`) takes away. sRGB out.
fn sky_lit(base: vec3<f32>, n: vec3<f32>, diff: f32, fill: f32, visibility: f32) -> vec3<f32> {
let up = dot(n, normalize(u.sky.xyz)) * 0.5 + 0.5;
let sky = mix(0.18, 0.42, up);
// Schlick's rise toward grazing, tempered so mid-tones only gain a soft rim.
let fresnel = 0.04 + 0.2 * pow(1.0 - fill, 5.0);
let albedo = srgb_decode(base);
// Scaled so light and mid-grey faces land near where the sRGB shading puts them.
let light = albedo * (0.41 * diff + (0.66 * sky + 0.2 * fill) * visibility) + vec3<f32>(fresnel * sky * visibility);
return srgb_encode(light);
}
/// [`lit`] in a colour of its own: a whole highlighted body is lit in its colour lifted by the
/// hover amount, or in the selection colour, from the model's own plan triangles.
fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
@@ -686,9 +533,6 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> 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;
if u.sky.w > 0.5 {
return out_color(vec4<f32>(min(sky_lit(base.rgb, n, diff, fill, visibility) + vec3<f32>(spec), vec3<f32>(1.0)), base.a));
}
let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility);
return out_color(vec4<f32>(min(base.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), base.a));
}
@@ -853,96 +697,38 @@ fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
// Point clouds (`view/clouds.rs`): each point is a round dot a few pixels across, facing the
// screen, depth-tested and written like the model's faces, so the model hides the points behind
// it and they hide it. The cloud's style colours them by their own colour, a ramp or a palette,
// and shows only those whose filtered values lie in its ranges or sets.
// Point clouds: each point is a round dot a few pixels across, facing the screen, depth-tested
// and written like the model's faces, so the model hides the points behind it and they hide it.
// The colour's alpha carries the dot's size (eighths of a pixel).
struct Cloud {
style: vec4<f32>, // x: colour (0 own, 1 ramp, 2 palette), y: dot size (px), z, w: ramp range
filters: array<vec4<f32>, 4>, // x: kind (0 off, 1 range y…z, 2 whole values y + i for the bits i of w)
};
@group(1) @binding(3) var<uniform> cloud: Cloud;
struct CIn {
@location(0) p: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec4<f32>,
};
struct COut {
struct POut {
@builtin(position) pos: vec4<f32>,
@location(0) wp: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec3<f32>,
@location(4) at: vec2<f32>, // the place on its disc
@location(5) @interpolate(flat) has: u32,
@location(0) c: vec4<f32>,
@location(1) wp: vec3<f32>,
@location(2) at: vec2<f32>,
};
@vertex
fn vs_cloud_dot(@builtin(vertex_index) vi: u32, i: CIn) -> COut {
fn vs_point(@builtin(vertex_index) vi: u32, @location(0) p: vec3<f32>, @location(1) c: vec4<f32>) -> POut {
var xs = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
var ys = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let at = vec2<f32>(xs[vi], ys[vi]);
let size = max(cloud.style.y, 1.0);
let centre = u.vp * vec4<f32>(i.p, 1.0);
var o: COut;
o.pos = vec4<f32>(centre.xy + at * size / u.screen.xy * centre.w, centre.z, centre.w);
let size = max(c.a * 255.0 / 8.0, 1.0);
let centre = u.vp * vec4<f32>(p, 1.0);
let half = u.screen.xy * 0.5;
var o: POut;
o.pos = vec4<f32>(centre.xy + at * size * 0.5 / half * centre.w, centre.z, centre.w);
// Behind the eye: nothing.
if (near_z(centre) < 0.0) { o.pos = vec4<f32>(0.0, 0.0, 0.0, -1.0); }
o.wp = i.p;
o.v = i.v;
o.f = i.f;
o.rgb = i.rgb.rgb;
o.has = u32(round(i.rgb.a * 255.0));
o.c = vec4<f32>(c.rgb, 1.0);
o.wp = p;
// Dots under two pixels are squares: a disc that small loses its centre pixel.
o.at = at * select(1.0, 0.0, size < 2.0);
return o;
}
/// Whether filter `f` keeps the value `v`.
fn cloud_keeps(f: vec4<f32>, v: f32) -> bool {
if (f.x < 0.5) { return true; }
if (f.x < 1.5) { return v >= f.y && v <= f.z; }
let k = i32(round(v)) - i32(round(f.y));
return k >= 0 && k < 32 && ((bitcast<u32>(f.w) >> u32(k)) & 1u) != 0u;
}
fn cloud_ramp(t: f32) -> vec3<f32> {
var stops = array<vec3<f32>, 5>(
vec3<f32>(40.0, 70.0, 200.0), vec3<f32>(30.0, 170.0, 230.0), vec3<f32>(60.0, 200.0, 90.0),
vec3<f32>(240.0, 220.0, 60.0), vec3<f32>(230.0, 70.0, 50.0));
let k = clamp(t, 0.0, 1.0) * 4.0;
let j = min(u32(k), 3u);
return mix(stops[j], stops[j + 1u], k - f32(j)) / 255.0;
}
fn cloud_palette(v: f32) -> vec3<f32> {
var colours = array<vec3<f32>, 10>(
vec3<f32>(240.0, 140.0, 40.0), vec3<f32>(245.0, 205.0, 65.0), vec3<f32>(230.0, 65.0, 65.0),
vec3<f32>(165.0, 75.0, 190.0), vec3<f32>(65.0, 190.0, 115.0), vec3<f32>(75.0, 150.0, 220.0),
vec3<f32>(240.0, 120.0, 180.0), vec3<f32>(140.0, 105.0, 75.0), vec3<f32>(120.0, 215.0, 215.0),
vec3<f32>(190.0, 190.0, 80.0));
if (v < -0.5) { return vec3<f32>(150.0, 160.0, 176.0) / 255.0; }
return colours[u32(round(v)) % 10u] / 255.0;
}
@fragment
fn fs_cloud(o: COut) -> @location(0) vec4<f32> {
var out = clipped(o.wp) || dot(o.at, o.at) > 1.0;
for (var k = 0u; k < 4u; k++) {
out = out || (((o.has >> k) & 1u) != 0u && !cloud_keeps(cloud.filters[k], o.f[k]));
}
if (out) { discard; }
var c = o.rgb;
let mode = cloud.style.x;
if ((o.has & 16u) != 0u && mode > 1.5) {
c = cloud_palette(o.v);
} else if ((o.has & 16u) != 0u && mode > 0.5) {
c = cloud_ramp((o.v - cloud.style.z) / max(cloud.style.w - cloud.style.z, 1e-12));
}
return out_color(vec4<f32>(c, 1.0));
fn fs_point(i: POut) -> @location(0) vec4<f32> {
if (clipped(i.wp) || dot(i.at, i.at) > 1.0) { discard; }
return out_color(i.c);
}
-47
View File
@@ -75,7 +75,6 @@ impl Tokens {
font_compass: 13.5,
font_subtitle: 15.0,
font_intro: 16.0,
font_page_heading: 20.0,
font_article_heading: 17.0,
font_title: 22.0,
font_display: 26.0,
@@ -227,31 +226,6 @@ impl Tokens {
dialog_label: Color32::from_rgb(58, 70, 86),
selected_ink: Color32::from_rgb(26, 80, 127),
selected_bg: Color32::from_rgb(232, 241, 250),
figure_ground: Color32::from_rgb(247, 248, 250),
figure_line: Color32::from_rgb(111, 124, 143),
figure_highlight: Color32::WHITE,
figure_shadow: Color32::BLACK,
figure_face_top: Color32::from_rgb(255, 255, 255),
figure_face_left: Color32::from_rgb(228, 233, 240),
figure_face_right: Color32::from_rgb(201, 210, 222),
figure_card: Color32::from_rgb(255, 255, 255),
figure_white: Color32::WHITE,
figure_black: Color32::BLACK,
figure_accent: Color32::from_rgb(35, 110, 173),
figure_reference: Color32::from_rgb(138, 83, 0),
figure_axis_x: Color32::from_rgb(214, 69, 69),
figure_axis_y: Color32::from_rgb(46, 158, 91),
figure_axis_z: Color32::from_rgb(47, 111, 208),
figure_ok: Color32::from_rgb(47, 125, 91),
figure_warn: Color32::from_rgb(201, 138, 0),
figure_conflict: Color32::from_rgb(184, 74, 0),
pref_well: Color32::from_rgb(238, 241, 245),
pref_note: Color32::from_rgb(183, 74, 0),
pref_note_fill: Color32::from_rgb(251, 235, 221),
pref_on_accent: Color32::from_rgb(255, 255, 255),
pref_ok: Color32::from_rgb(39, 107, 44),
pref_ok_fill: Color32::from_rgb(229, 242, 230),
pref_disabled: Color32::from_rgb(138, 148, 163),
timeline_item: Color32::WHITE,
timeline_item_error: Color32::from_rgb(250, 220, 220),
timeline_marker: Color32::from_rgb(70, 76, 88),
@@ -371,27 +345,6 @@ impl Tokens {
t.dialog_label = t.text;
t.selected_ink = Color32::from_rgb(156, 199, 245);
t.selected_bg = Color32::from_rgb(42, 70, 110);
t.figure_ground = Color32::from_rgb(28, 31, 38);
t.figure_line = Color32::from_rgb(163, 174, 194);
t.figure_face_top = Color32::from_rgb(107, 119, 144);
t.figure_face_left = Color32::from_rgb(81, 91, 114);
t.figure_face_right = Color32::from_rgb(57, 66, 86);
t.figure_card = Color32::from_rgb(47, 52, 62);
t.figure_accent = Color32::from_rgb(140, 190, 245);
t.figure_reference = Color32::from_rgb(232, 194, 74);
t.figure_axis_x = Color32::from_rgb(242, 122, 122);
t.figure_axis_y = Color32::from_rgb(98, 212, 142);
t.figure_axis_z = Color32::from_rgb(108, 168, 247);
t.figure_ok = Color32::from_rgb(127, 209, 174);
t.figure_warn = Color32::from_rgb(232, 194, 74);
t.figure_conflict = Color32::from_rgb(240, 162, 122);
t.pref_well = Color32::from_rgb(47, 52, 62);
t.pref_note = Color32::from_rgb(240, 162, 122);
t.pref_note_fill = Color32::from_rgb(74, 51, 38);
t.pref_on_accent = Color32::from_rgb(15, 22, 32);
t.pref_ok = Color32::from_rgb(127, 209, 174);
t.pref_ok_fill = Color32::from_rgb(31, 58, 49);
t.pref_disabled = Color32::from_rgb(138, 148, 163);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
t.error = Color32::from_rgb(240, 100, 96);
t.sketch_conflict = t.error;
@@ -0,0 +1,171 @@
{
"order": 20,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark Hard",
"base": "dark",
"colors": {
"app_bar": "#141617",
"toolbar": "#1d2021",
"panel": "#1d2021",
"panel_header": "#3c3836",
"border": "#504945",
"text": "#ebdbb2",
"text_dim": "#a89984",
"accent": "#83a598",
"accent_soft": "#414f4b",
"sketch_accent": "#a1a322",
"error": "#fb4934",
"warning": "#fabd2f",
"icon": "#d5c4a1",
"timeline": "#141617",
"hover": "#46403e",
"sel_face": "#458588",
"sel_edge": "#83a598",
"sel_edge_rim": "#aec4bc",
"sel_profile": "#076678",
"sel_profile_edge": "#458588",
"hover_profile": "#83a59830",
"hover_profile_edge": "#83a598",
"sel_vertex": "#83a598",
"origin_plane": "#fe801950",
"origin_plane_edge": "#d65d0ec8",
"origin_plane_hover": "#83a59896",
"origin_point": "#d5c4a1",
"axis_x": "#fb4934",
"axis_y": "#b8bb26",
"axis_z": "#83a598",
"construction_plane": "#fe801940",
"construction_geom": "#e97926",
"body": "#a89984",
"body_edge": "#141617",
"manipulator": "#83a598",
"preview_add": "#83a598",
"preview_new": "#fabd2f",
"preview_sketch": "#b8bb26",
"preview_cut": "#fb49346e",
"cube_face": "#ebdbb2",
"cube_border": "#665c54",
"cube_text": "#282828",
"cube_hover": "#83a598",
"viewport_top": "#2c2c2c",
"viewport_bottom": "#2c2c2c",
"grid": "#443f3c",
"grid_major": "#504945",
"body_selected": "#83a598",
"sketch": "#83a598",
"sketch_fixed": "#ebdbb2",
"sketch_halo": "#141617",
"sketch_locked": "#b8bb26",
"dimension": "#fabd2f",
"dimension_driven": "#b57614",
"sketch_point": "#ebdbb2",
"sketch_point_free": "#83a598",
"dialog_bg": "#3c3836",
"dialog_border": "#504945",
"chip": "#458588",
"section_cap": "#d65d0e",
"profile_fill": "#83a59828",
"glyph_bg": "#2c2c2c",
"glyph_edge": "#fe614c",
"glyph_text": "#fe614c",
"overlay": "#1d2021f0",
"rubber_band": "#83a598",
"app_bar_text": "#ebdbb2",
"app_bar_hover": "#ebdbb21c",
"timeline_item": "#3c3836",
"timeline_item_error": "#63221f",
"timeline_marker": "#bdae93",
"field": "#141617",
"box_window": "#83a598",
"box_crossing": "#b8bb26",
"sketch_projected": "#d3869b",
"sketch_lost": "#fb4934",
"sketch_construction": "#fe8019",
"sketch_centerline": "#d65d0e",
"bend_up": "#b8bb26",
"bend_down": "#fe8019",
"interference": "#fb4934",
"icons.agent": "#d3869b",
"surface_fill": "#fe9755",
"surface_soft": "#382c20",
"surface_ink": "#fe8019",
"surface_accent": "#e97926",
"mesh_fill": "#e1ab32",
"mesh_soft": "#383323",
"mesh_ink": "#fabd2f",
"mesh_accent": "#c39429",
"form_fill": "#ea9bb0",
"form_soft": "#332c30",
"form_tab_ink": "#d3869b",
"form_accent": "#cf8297",
"sheet_fill": "#93c681",
"sheet_soft": "#2b332c",
"sheet_ink": "#8ec07c",
"render_fill": "#fc9483",
"render_soft": "#382523",
"render_ink": "#fb5d4a",
"render_accent": "#fe614c",
"sketch_soft": "#303322",
"sketch_ink": "#b8bb26",
"form_ink": "#ebdbb2",
"form_dim": "#bdae93",
"form_primary": "#83a598",
"form_error": "#fb4934",
"form_error_fill": "#63221f",
"form_success": "#b8bb26",
"form_success_fill": "#343a28",
"dialog_body": "#1d2021",
"dialog_footer": "#3c3836",
"dialog_rule": "#504945",
"control_fill": "#141617",
"control_border": "#7c6f64",
"dialog_label": "#ebdbb2",
"selected_ink": "#ebdbb2",
"selected_bg": "#414f4b",
"on_accent": "#1d2021"
},
"icon_palette": {
"ink": "#141617",
"line": "#d5c4a1",
"passive-1": "#c5c0b8",
"passive-2": "#9a948d",
"passive-3": "#706b64",
"guide": "#a89984",
"solid": [
"#b8dbcd",
"#9bbeb1",
"#83a598"
],
"surface": [
"#fcc4a3",
"#fe9755",
"#e97926"
],
"mesh": [
"#fbca67",
"#e1ab32",
"#c39429"
],
"form": [
"#fbbfce",
"#ea9bb0",
"#cf8297"
],
"sketch": [
"#d7db50",
"#babd2a",
"#a1a322"
],
"constraint": [
"#fcc2b7",
"#fc9483",
"#fe614c"
],
"construction": [
"#fcc4a3",
"#fe9755",
"#e97926"
]
}
}
@@ -0,0 +1,172 @@
{
"order": 30,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark Soft",
"base": "dark",
"colors": {
"app_bar": "#1d2021",
"toolbar": "#32302f",
"panel": "#32302f",
"panel_header": "#3c3836",
"border": "#504945",
"text": "#ebdbb2",
"text_dim": "#a89984",
"accent": "#83a598",
"accent_soft": "#4e5954",
"selected_ink": "#ebdbb2",
"sketch_accent": "#a1a322",
"error": "#fb4934",
"warning": "#fabd2f",
"icon": "#d5c4a1",
"timeline": "#1d2021",
"hover": "#46403e",
"sel_face": "#458588",
"sel_edge": "#83a598",
"sel_edge_rim": "#aec4bc",
"sel_profile": "#076678",
"sel_profile_edge": "#458588",
"hover_profile": "#83a59830",
"hover_profile_edge": "#83a598",
"sel_vertex": "#83a598",
"origin_plane": "#fe801950",
"origin_plane_edge": "#d65d0ec8",
"origin_plane_hover": "#83a59896",
"origin_point": "#d5c4a1",
"axis_x": "#fb4934",
"axis_y": "#b8bb26",
"axis_z": "#83a598",
"construction_plane": "#fe801940",
"construction_geom": "#e97926",
"body": "#a89984",
"body_edge": "#1d2021",
"manipulator": "#83a598",
"preview_add": "#83a598",
"preview_new": "#fabd2f",
"preview_sketch": "#b8bb26",
"preview_cut": "#fb49346e",
"cube_face": "#ebdbb2",
"cube_border": "#665c54",
"cube_text": "#282828",
"cube_hover": "#83a598",
"viewport_top": "#373432",
"viewport_bottom": "#373432",
"grid": "#443f3c",
"grid_major": "#504945",
"body_selected": "#83a598",
"sketch": "#83a598",
"sketch_fixed": "#ebdbb2",
"sketch_halo": "#1d2021",
"sketch_locked": "#b8bb26",
"dimension": "#fabd2f",
"dimension_driven": "#b57614",
"sketch_point": "#ebdbb2",
"sketch_point_free": "#83a598",
"dialog_bg": "#3c3836",
"dialog_border": "#504945",
"chip": "#458588",
"section_cap": "#d65d0e",
"profile_fill": "#83a59828",
"glyph_bg": "#373432",
"glyph_edge": "#fe614c",
"glyph_text": "#fe614c",
"overlay": "#32302ff0",
"rubber_band": "#83a598",
"app_bar_text": "#ebdbb2",
"app_bar_hover": "#ebdbb21c",
"timeline_item": "#3c3836",
"timeline_item_error": "#702b28",
"timeline_marker": "#bdae93",
"field": "#1d2021",
"box_window": "#83a598",
"box_crossing": "#b8bb26",
"sketch_projected": "#d3869b",
"sketch_lost": "#fb4934",
"sketch_construction": "#fe8019",
"sketch_centerline": "#d65d0e",
"bend_up": "#b8bb26",
"bend_down": "#fe8019",
"interference": "#fb4934",
"icons.agent": "#d3869b",
"surface_fill": "#fe9755",
"surface_soft": "#4a3a2c",
"surface_ink": "#fe8b2e",
"surface_accent": "#e97926",
"mesh_fill": "#e1ab32",
"mesh_soft": "#4a412f",
"mesh_ink": "#fabd2f",
"mesh_accent": "#c39429",
"form_fill": "#ea9bb0",
"form_soft": "#453a3c",
"form_tab_ink": "#d996a8",
"form_accent": "#cf8297",
"sheet_fill": "#93c681",
"sheet_soft": "#3d4138",
"sheet_ink": "#8ec07c",
"render_fill": "#fc9483",
"render_soft": "#4a3330",
"render_ink": "#fc7e6f",
"render_accent": "#fe614c",
"sketch_soft": "#42412e",
"sketch_ink": "#b8bb26",
"form_ink": "#ebdbb2",
"form_dim": "#bdae93",
"form_primary": "#83a598",
"form_error": "#fb4934",
"form_error_fill": "#702b28",
"form_success": "#b8bb26",
"form_success_fill": "#343a28",
"dialog_body": "#32302f",
"dialog_footer": "#3c3836",
"dialog_rule": "#504945",
"control_fill": "#1d2021",
"control_border": "#7c6f64",
"dialog_label": "#ebdbb2",
"selected_ink": "#ebdbb2",
"selected_bg": "#4e5954",
"on_accent": "#1d2021"
},
"icon_palette": {
"ink": "#1d2021",
"line": "#d5c4a1",
"passive-1": "#c5c0b8",
"passive-2": "#9a948d",
"passive-3": "#706b64",
"guide": "#a89984",
"solid": [
"#b8dbcd",
"#9bbeb1",
"#83a598"
],
"surface": [
"#fcc4a3",
"#fe9755",
"#e97926"
],
"mesh": [
"#fbca67",
"#e1ab32",
"#c39429"
],
"form": [
"#fbbfce",
"#ea9bb0",
"#cf8297"
],
"sketch": [
"#d7db50",
"#babd2a",
"#a1a322"
],
"constraint": [
"#fcc2b7",
"#fc9483",
"#fe614c"
],
"construction": [
"#fcc4a3",
"#fe9755",
"#e97926"
]
}
}
+2 -120
View File
@@ -122,6 +122,7 @@
"control_fill": "#1d2021",
"control_border": "#7c6f64",
"dialog_label": "#ebdbb2",
"selected_ink": "#ebdbb2",
"selected_bg": "#48544f",
"on_accent": "#1d2021"
},
@@ -167,124 +168,5 @@
"#fe9755",
"#e97926"
]
},
"options": [
{
"id": "contrast",
"type": "choice",
"default": "standard",
"label": {
"de": "Kontrast",
"en": "Contrast",
"ja": "コントラスト",
"ko": "대비",
"zh": "对比度"
},
"help": {
"de": "Wähle die weiche, normale oder harte Gruvbox-Palette.",
"en": "Choose the soft, standard or hard Gruvbox palette.",
"ja": "Gruvboxのソフト・標準・ハードのパレットを選びます。",
"ko": "Gruvbox의 부드러운, 표준 또는 강한 팔레트를 선택합니다.",
"zh": "选择柔和、标准或强对比的 Gruvbox 配色。"
},
"values": [
{
"id": "soft",
"label": {
"de": "Weich",
"en": "Soft",
"ja": "ソフト",
"ko": "부드럽게",
"zh": "柔和"
}
},
{
"id": "standard",
"label": {
"de": "Standard",
"en": "Standard",
"ja": "標準",
"ko": "표준",
"zh": "标准"
}
},
{
"id": "hard",
"label": {
"de": "Hart",
"en": "Hard",
"ja": "ハード",
"ko": "강하게",
"zh": "强对比"
}
}
]
}
],
"overlays": [
{
"when": {
"opt": "contrast",
"eq": "soft"
},
"colors": {
"toolbar": "#32302f",
"panel": "#32302f",
"accent_soft": "#4e5954",
"viewport_top": "#373432",
"viewport_bottom": "#373432",
"glyph_bg": "#373432",
"overlay": "#32302ff0",
"timeline_item_error": "#702b28",
"surface_soft": "#4a3a2c",
"surface_ink": "#fe8b2e",
"mesh_soft": "#4a412f",
"form_soft": "#453a3c",
"form_tab_ink": "#d996a8",
"sheet_soft": "#3d4138",
"render_soft": "#4a3330",
"render_ink": "#fc7e6f",
"sketch_soft": "#42412e",
"form_error_fill": "#702b28",
"dialog_body": "#32302f",
"selected_bg": "#4e5954"
}
},
{
"when": {
"opt": "contrast",
"eq": "hard"
},
"colors": {
"app_bar": "#141617",
"toolbar": "#1d2021",
"panel": "#1d2021",
"accent_soft": "#414f4b",
"timeline": "#141617",
"body_edge": "#141617",
"viewport_top": "#2c2c2c",
"viewport_bottom": "#2c2c2c",
"sketch_halo": "#141617",
"glyph_bg": "#2c2c2c",
"overlay": "#1d2021f0",
"timeline_item_error": "#63221f",
"field": "#141617",
"surface_soft": "#382c20",
"mesh_soft": "#383323",
"form_soft": "#332c30",
"form_tab_ink": "#d3869b",
"sheet_soft": "#2b332c",
"render_soft": "#382523",
"render_ink": "#fb5d4a",
"sketch_soft": "#303322",
"form_error_fill": "#63221f",
"dialog_body": "#1d2021",
"control_fill": "#141617",
"selected_bg": "#414f4b"
},
"icon_palette": {
"ink": "#141617"
}
}
]
}
}
@@ -0,0 +1,171 @@
{
"order": 50,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light Hard",
"base": "light",
"colors": {
"app_bar": "#3c3836",
"toolbar": "#f9f5d7",
"panel": "#f9f5d7",
"panel_header": "#ebdbb2",
"border": "#d5c4a1",
"text": "#3c3836",
"text_dim": "#7c6f64",
"accent": "#076678",
"accent_soft": "#c9d8c4",
"sketch_accent": "#625e15",
"error": "#9d0006",
"warning": "#b57614",
"icon": "#504945",
"timeline": "#ebdbb2",
"hover": "#f1e5c1",
"sel_face": "#458588",
"sel_edge": "#076678",
"sel_edge_rim": "#83a598",
"sel_profile": "#076678",
"sel_profile_edge": "#458588",
"hover_profile": "#45858830",
"hover_profile_edge": "#458588",
"sel_vertex": "#076678",
"origin_plane": "#fe80195e",
"origin_plane_edge": "#d65d0ec8",
"origin_plane_hover": "#45858896",
"origin_point": "#d5c4a1",
"axis_x": "#cc241d",
"axis_y": "#98971a",
"axis_z": "#458588",
"construction_plane": "#fe801946",
"construction_geom": "#9b3407",
"body": "#bdae93",
"body_edge": "#3c3836",
"manipulator": "#076678",
"preview_add": "#458588",
"preview_new": "#d79921",
"preview_sketch": "#98971a",
"preview_cut": "#cc241d6e",
"cube_face": "#fbf1c7",
"cube_border": "#7c6f64",
"cube_text": "#3c3836",
"cube_hover": "#83a598",
"viewport_top": "#fbf8e3",
"viewport_bottom": "#ebdbb2",
"grid": "#d5c4a1",
"grid_major": "#bdae93",
"body_selected": "#83a598",
"sketch": "#076678",
"sketch_fixed": "#282828",
"sketch_halo": "#fbf8e3",
"sketch_locked": "#79740e",
"dimension": "#1f5c40",
"dimension_driven": "#4f7d64",
"sketch_point": "#282828",
"sketch_point_free": "#076678",
"dialog_bg": "#f9f5d7",
"dialog_border": "#d5c4a1",
"chip": "#458588",
"section_cap": "#d65d0e",
"profile_fill": "#fabd2f46",
"glyph_bg": "#fcfaeb",
"glyph_edge": "#a91714",
"glyph_text": "#a91714",
"overlay": "#f9f5d7eb",
"rubber_band": "#076678",
"app_bar_text": "#fbf1c7",
"app_bar_hover": "#fbf1c71c",
"timeline_item": "#f9f5d7",
"timeline_item_error": "#f0cbb2",
"timeline_marker": "#504945",
"field": "#fcfaeb",
"box_window": "#076678",
"box_crossing": "#79740e",
"sketch_projected": "#8f3f71",
"sketch_lost": "#9d0006",
"sketch_construction": "#af3a03",
"sketch_centerline": "#d65d0e",
"bend_up": "#79740e",
"bend_down": "#af3a03",
"interference": "#cc241d",
"icons.agent": "#8f3f71",
"surface_fill": "#df663c",
"surface_soft": "#f3e6c6",
"surface_ink": "#af3a03",
"surface_accent": "#9b3407",
"mesh_fill": "#c08024",
"mesh_soft": "#f4ebc7",
"mesh_ink": "#915e10",
"mesh_accent": "#7b5112",
"form_fill": "#c46fa2",
"form_soft": "#f1e6cf",
"form_tab_ink": "#8f3f71",
"form_accent": "#893a6c",
"sheet_fill": "#659f7a",
"sheet_soft": "#eaebcd",
"sheet_ink": "#3d7252",
"render_fill": "#e85a4d",
"render_soft": "#f2e1c6",
"render_ink": "#9d0006",
"render_accent": "#a91714",
"sketch_soft": "#efebc7",
"sketch_ink": "#6e6a0d",
"form_ink": "#3c3836",
"form_dim": "#665c54",
"form_primary": "#076678",
"form_error": "#9d0006",
"form_error_fill": "#f0cbb2",
"form_success": "#427b58",
"form_success_fill": "#dae3b6",
"dialog_body": "#f9f5d7",
"dialog_footer": "#ebdbb2",
"dialog_rule": "#d5c4a1",
"control_fill": "#fcfaeb",
"control_border": "#a89984",
"dialog_label": "#3c3836",
"selected_ink": "#3c3836",
"selected_bg": "#c9d8c4",
"on_accent": "#fbf1c7"
},
"icon_palette": {
"ink": "#3c3836",
"line": "#504945",
"passive-1": "#e9e3df",
"passive-2": "#b8b3af",
"passive-3": "#8a8581",
"guide": "#7c6f64",
"solid": [
"#87d5e9",
"#4e9caf",
"#076678"
],
"surface": [
"#feb299",
"#df663c",
"#9b3407"
],
"mesh": [
"#fcb864",
"#c08024",
"#7b5112"
],
"form": [
"#fbaad9",
"#c46fa2",
"#893a6c"
],
"sketch": [
"#d0cc73",
"#989439",
"#625e15"
],
"constraint": [
"#fab3a8",
"#e85a4d",
"#a91714"
],
"construction": [
"#feb299",
"#df663c",
"#9b3407"
]
}
}
@@ -0,0 +1,171 @@
{
"order": 60,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light Soft",
"base": "light",
"colors": {
"app_bar": "#3c3836",
"toolbar": "#f2e5bc",
"panel": "#f2e5bc",
"panel_header": "#ebdbb2",
"border": "#d5c4a1",
"text": "#3c3836",
"text_dim": "#7c6f64",
"accent": "#076678",
"accent_soft": "#c3ccae",
"sketch_accent": "#625e15",
"error": "#9d0006",
"warning": "#b57614",
"icon": "#504945",
"timeline": "#ebdbb2",
"hover": "#eedfb6",
"sel_face": "#458588",
"sel_edge": "#076678",
"sel_edge_rim": "#83a598",
"sel_profile": "#076678",
"sel_profile_edge": "#458588",
"hover_profile": "#45858830",
"hover_profile_edge": "#458588",
"sel_vertex": "#076678",
"origin_plane": "#fe80195e",
"origin_plane_edge": "#d65d0ec8",
"origin_plane_hover": "#45858896",
"origin_point": "#d5c4a1",
"axis_x": "#cc241d",
"axis_y": "#98971a",
"axis_z": "#458588",
"construction_plane": "#fe801946",
"construction_geom": "#9b3407",
"body": "#bdae93",
"body_edge": "#3c3836",
"manipulator": "#076678",
"preview_add": "#458588",
"preview_new": "#d79921",
"preview_sketch": "#98971a",
"preview_cut": "#cc241d6e",
"cube_face": "#fbf1c7",
"cube_border": "#7c6f64",
"cube_text": "#3c3836",
"cube_hover": "#83a598",
"viewport_top": "#f6edd0",
"viewport_bottom": "#ebdbb2",
"grid": "#d5c4a1",
"grid_major": "#bdae93",
"body_selected": "#83a598",
"sketch": "#076678",
"sketch_fixed": "#282828",
"sketch_halo": "#f6edd0",
"sketch_locked": "#79740e",
"dimension": "#1f5c40",
"dimension_driven": "#4f7d64",
"sketch_point": "#282828",
"sketch_point_free": "#076678",
"dialog_bg": "#f2e5bc",
"dialog_border": "#d5c4a1",
"chip": "#458588",
"section_cap": "#d65d0e",
"profile_fill": "#fabd2f46",
"glyph_bg": "#f8f2de",
"glyph_edge": "#a91714",
"glyph_text": "#a91714",
"overlay": "#f2e5bceb",
"rubber_band": "#076678",
"app_bar_text": "#fbf1c7",
"app_bar_hover": "#fbf1c71c",
"timeline_item": "#f2e5bc",
"timeline_item_error": "#eabe9c",
"timeline_marker": "#504945",
"field": "#f8f2de",
"box_window": "#076678",
"box_crossing": "#79740e",
"sketch_projected": "#8f3f71",
"sketch_lost": "#9d0006",
"sketch_construction": "#af3a03",
"sketch_centerline": "#d65d0e",
"bend_up": "#79740e",
"bend_down": "#af3a03",
"interference": "#cc241d",
"icons.agent": "#8f3f71",
"surface_fill": "#df663c",
"surface_soft": "#edd7ad",
"surface_ink": "#a83803",
"surface_accent": "#9b3407",
"mesh_fill": "#c08024",
"mesh_soft": "#eddcaf",
"mesh_ink": "#84560f",
"mesh_accent": "#7b5112",
"form_fill": "#c46fa2",
"form_soft": "#ead8b6",
"form_tab_ink": "#8f3f71",
"form_accent": "#893a6c",
"sheet_fill": "#659f7a",
"sheet_soft": "#e4ddb4",
"sheet_ink": "#38694b",
"render_fill": "#e85a4d",
"render_soft": "#ebd3ad",
"render_ink": "#9d0006",
"render_accent": "#a91714",
"sketch_soft": "#e8dcae",
"sketch_ink": "#66610c",
"form_ink": "#3c3836",
"form_dim": "#665c54",
"form_primary": "#076678",
"form_error": "#9d0006",
"form_error_fill": "#eabe9c",
"form_success": "#427b58",
"form_success_fill": "#dae3b6",
"dialog_body": "#f2e5bc",
"dialog_footer": "#ebdbb2",
"dialog_rule": "#d5c4a1",
"control_fill": "#f8f2de",
"control_border": "#a89984",
"dialog_label": "#3c3836",
"selected_ink": "#3c3836",
"selected_bg": "#c3ccae",
"on_accent": "#fbf1c7"
},
"icon_palette": {
"ink": "#3c3836",
"line": "#504945",
"passive-1": "#e9e3df",
"passive-2": "#b8b3af",
"passive-3": "#8a8581",
"guide": "#7c6f64",
"solid": [
"#87d5e9",
"#4e9caf",
"#076678"
],
"surface": [
"#feb299",
"#df663c",
"#9b3407"
],
"mesh": [
"#fcb864",
"#c08024",
"#7b5112"
],
"form": [
"#fbaad9",
"#c46fa2",
"#893a6c"
],
"sketch": [
"#d0cc73",
"#989439",
"#625e15"
],
"constraint": [
"#fab3a8",
"#e85a4d",
"#a91714"
],
"construction": [
"#feb299",
"#df663c",
"#9b3407"
]
}
}
+1 -123
View File
@@ -167,127 +167,5 @@
"#df663c",
"#9b3407"
]
},
"options": [
{
"id": "contrast",
"type": "choice",
"default": "standard",
"label": {
"de": "Kontrast",
"en": "Contrast",
"ja": "コントラスト",
"ko": "대비",
"zh": "对比度"
},
"help": {
"de": "Wähle die weiche, normale oder harte Gruvbox-Palette.",
"en": "Choose the soft, standard or hard Gruvbox palette.",
"ja": "Gruvboxのソフト・標準・ハードのパレットを選びます。",
"ko": "Gruvbox의 부드러운, 표준 또는 강한 팔레트를 선택합니다.",
"zh": "选择柔和、标准或强对比的 Gruvbox 配色。"
},
"values": [
{
"id": "soft",
"label": {
"de": "Weich",
"en": "Soft",
"ja": "ソフト",
"ko": "부드럽게",
"zh": "柔和"
}
},
{
"id": "standard",
"label": {
"de": "Standard",
"en": "Standard",
"ja": "標準",
"ko": "표준",
"zh": "标准"
}
},
{
"id": "hard",
"label": {
"de": "Hart",
"en": "Hard",
"ja": "ハード",
"ko": "강하게",
"zh": "强对比"
}
}
]
}
],
"overlays": [
{
"when": {
"opt": "contrast",
"eq": "soft"
},
"colors": {
"toolbar": "#f2e5bc",
"panel": "#f2e5bc",
"accent_soft": "#c3ccae",
"hover": "#eedfb6",
"viewport_top": "#f6edd0",
"sketch_halo": "#f6edd0",
"dialog_bg": "#f2e5bc",
"glyph_bg": "#f8f2de",
"overlay": "#f2e5bceb",
"timeline_item": "#f2e5bc",
"timeline_item_error": "#eabe9c",
"field": "#f8f2de",
"surface_soft": "#edd7ad",
"surface_ink": "#a83803",
"mesh_soft": "#eddcaf",
"mesh_ink": "#84560f",
"form_soft": "#ead8b6",
"sheet_soft": "#e4ddb4",
"sheet_ink": "#38694b",
"render_soft": "#ebd3ad",
"sketch_soft": "#e8dcae",
"sketch_ink": "#66610c",
"form_error_fill": "#eabe9c",
"dialog_body": "#f2e5bc",
"control_fill": "#f8f2de",
"selected_bg": "#c3ccae"
}
},
{
"when": {
"opt": "contrast",
"eq": "hard"
},
"colors": {
"toolbar": "#f9f5d7",
"panel": "#f9f5d7",
"accent_soft": "#c9d8c4",
"hover": "#f1e5c1",
"viewport_top": "#fbf8e3",
"sketch_halo": "#fbf8e3",
"dialog_bg": "#f9f5d7",
"glyph_bg": "#fcfaeb",
"overlay": "#f9f5d7eb",
"timeline_item": "#f9f5d7",
"timeline_item_error": "#f0cbb2",
"field": "#fcfaeb",
"surface_soft": "#f3e6c6",
"mesh_soft": "#f4ebc7",
"mesh_ink": "#915e10",
"form_soft": "#f1e6cf",
"sheet_soft": "#eaebcd",
"sheet_ink": "#3d7252",
"render_soft": "#f2e1c6",
"sketch_soft": "#efebc7",
"sketch_ink": "#6e6a0d",
"form_error_fill": "#f0cbb2",
"dialog_body": "#f9f5d7",
"control_fill": "#fcfaeb",
"selected_bg": "#c9d8c4"
}
}
]
}
}
@@ -0,0 +1,6 @@
| proposals/render/2026-10-30/original-long-scene/fixture-spec.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/geometry.commands.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/optional-glass-probe.commands.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/original-asymmetric-srgb.hdr | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/raster-comparison.request.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
| proposals/render/2026-10-30/original-long-scene/static-checks.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
+2 -26
View File
@@ -202,16 +202,12 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
}
// Named origin planes and construction planes are the component's own.
PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {}
PlaneRef::Tangent { face, at, reference, .. } => {
PlaneRef::Tangent { face, at } => {
*at = apply_point(m, *at);
map_geo_ref(m, face);
if let Some(r) = reference {
map_geo_ref(m, r);
}
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::AxisPoint { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
@@ -422,19 +418,6 @@ impl Document {
false
}
/// Is `c` (or one of its ancestors) marked out of the History bar (#10393)? The root (0)
/// never is: its own features always show.
pub fn component_history_hidden(&self, mut c: u64) -> bool {
for _ in 0..1000 {
match self.components.iter().find(|x| x.id == c) {
Some(x) if x.history_hidden => return true,
Some(x) if x.parent != 0 => c = x.parent,
_ => return false,
}
}
false
}
/// Would putting a placement of `component` into `parent` put it inside itself: is `parent`
/// the component, or inside it, created in it or placed in it at any depth? Placements can sit
/// apart from where their component was created ([`Self::component_within`] follows only
@@ -473,14 +456,7 @@ impl Document {
let id = self.next_id.max(1);
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default();
// The next number, or, when an occurrence already has that name (one before it was
// deleted, or of another component of that name), the first number none has (#9738).
let next = self.occurrences.iter().filter(|o| o.component == component).count() + 1;
let free = |k: &usize| {
let name = format!("{cname}:{k}");
!self.occurrences.iter().any(|o| o.name == name)
};
let k = std::iter::once(next).chain(1..).find(free).unwrap_or(next);
let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1;
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false });
Ok(id)
}
-182
View File
@@ -1,182 +0,0 @@
//! The browser's groups as a tree: how deep a group sits, who holds whom, and pruning what a
//! saved design says about the browser down to what still exists.
use crate::document::{BROWSER_FOLDERS, BrowserEntry, BrowserGroup, Document, MAX_GROUP_NESTING};
use std::collections::{BTreeMap, BTreeSet};
impl Document {
pub fn browser_group(&self, id: u64) -> Option<&BrowserGroup> {
self.browser_groups.iter().find(|g| g.id == id)
}
/// The level of a group: 1 for a top-level group, 2 for a child of one, and so on. A chain
/// that loops or runs past the limit counts as one level too deep.
pub fn group_level(&self, id: u64) -> usize {
let mut level = 0;
let mut at = Some(id);
while let Some(g) = at {
level += 1;
if level > MAX_GROUP_NESTING {
return level;
}
at = self.browser_group(g).and_then(|g| g.parent);
}
level
}
/// Whether `ancestor` is `id` or holds it, at any depth.
pub fn group_within(&self, id: u64, ancestor: u64) -> bool {
let mut at = Some(id);
for _ in 0..=MAX_GROUP_NESTING {
match at {
Some(g) if g == ancestor => return true,
Some(g) => at = self.browser_group(g).and_then(|g| g.parent),
None => return false,
}
}
false
}
/// How many levels the group and the groups under it take: 1 for a group with no child group.
pub fn group_height(&self, id: u64) -> usize {
fn height(d: &Document, id: u64, left: usize) -> usize {
if left == 0 {
return 1;
}
1 + d.browser_groups.iter().filter(|g| g.parent == Some(id)).map(|g| height(d, g.id, left - 1)).max().unwrap_or(0)
}
height(self, id, MAX_GROUP_NESTING + 1)
}
/// The entries of a holder's list in display order: a group's contents, or the folder's top
/// level when `holder` is `None`. Groups the list names come where it names them. The child
/// groups it does not name (in creation order) go just after the last group it names, or
/// first when it names none, so they sit above the items as they always have. Entries for
/// groups that are not the holder's children are dropped.
pub fn ordered_entries(&self, component: u64, folder: &str, holder: Option<u64>) -> Vec<BrowserEntry> {
let listed: &[BrowserEntry] = match holder {
Some(g) => self.browser_group(g).map_or(&[], |g| &g.items),
None => self.browser_order.get(&format!("{component}/{folder}")).map_or(&[], Vec::as_slice),
};
let mine: Vec<u64> =
self.browser_groups.iter().filter(|g| g.component == component && g.folder == folder && g.parent == holder).map(|g| g.id).collect();
let mut out: Vec<BrowserEntry> = Vec::with_capacity(listed.len() + mine.len());
for e in listed {
match e {
BrowserEntry::Group(id) if !mine.contains(id) || out.contains(e) => {}
_ => out.push(e.clone()),
}
}
let at = out.iter().rposition(|e| matches!(e, BrowserEntry::Group(_))).map_or(0, |i| i + 1);
let unlisted: Vec<BrowserEntry> = mine.iter().map(|id| BrowserEntry::Group(*id)).filter(|e| !out.contains(e)).collect();
out.splice(at..at, unlisted);
out
}
/// The items in a group and in the groups inside it.
pub fn group_item_keys(&self, id: u64) -> Vec<String> {
let inside = self.browser_groups.iter().filter(|g| self.group_within(g.id, id));
inside.flat_map(|g| g.item_keys().map(str::to_string)).collect()
}
/// The group holding the item directly, in a component's folder.
pub fn group_of_item(&self, component: u64, folder: &str, key: &str) -> Option<&BrowserGroup> {
self.browser_groups.iter().find(|g| g.component == component && g.folder == folder && g.has_item(key))
}
/// Only the browser's groups and order changed (a group made, nested, moved into, ordered):
/// nothing is evaluated and no placement moves. The same whole-Document proof as
/// [`Document::differs_only_in_render`]; `next_id` may differ, as a new group takes an id.
pub fn differs_only_in_browser(&self, previous: &Document) -> bool {
if self.browser_groups == previous.browser_groups && self.browser_order == previous.browser_order {
return false;
}
let mut normalized = previous.clone();
normalized.browser_groups.clone_from(&self.browser_groups);
normalized.browser_order.clone_from(&self.browser_order);
normalized.next_id = self.next_id;
*self == normalized
}
/// What [`Document::browser_pruned`] depends on, cheaply: it can only find something to drop
/// after a feature, placement, component or group was added or removed. Ids are never
/// reused, so a removal followed by an addition still moves `next_id`.
pub fn browser_stamp(&self) -> [u64; 6] {
let n = |len: usize| u64::try_from(len).unwrap_or(u64::MAX);
[
self.next_id,
n(self.features.len()),
n(self.occurrences.len()),
n(self.components.len()),
n(self.browser_groups.len()),
n(self.browser_order.len()),
]
}
/// The groups and order lists with what they name that is gone dropped, or `None` when
/// nothing is: groups of a missing component or folder, a parent that is missing, elsewhere
/// or loops, entries for groups and sketches, construction items and placements that no
/// longer exist, repeats, and order lists of missing components. Body names are left alone:
/// only an evaluated model knows them.
pub fn browser_pruned(&self) -> Option<(Vec<BrowserGroup>, BTreeMap<String, Vec<BrowserEntry>>)> {
if self.browser_groups.is_empty() && self.browser_order.is_empty() {
return None;
}
let components: BTreeSet<u64> = self.components.iter().map(|c| c.id).collect();
let features: BTreeSet<u64> = self.features.iter().map(|f| f.id).collect();
let placed_in: BTreeMap<u64, u64> = self.occurrences.iter().map(|o| (o.id, o.parent)).collect();
let component_exists = |c: u64| c == 0 || components.contains(&c);
let placed = |component: u64, key: &str| key.parse::<u64>().ok().and_then(|o| placed_in.get(&o)) == Some(&component);
// Body names cannot be checked here; the other folders hold feature ids.
let item_exists = |folder: &str, component: u64, key: &str| match folder {
"bodies" => true,
"components" => placed(component, key),
_ => key.parse::<u64>().is_ok_and(|id| features.contains(&id)),
};
let mut groups = self.browser_groups.clone();
let mut order = self.browser_order.clone();
groups.retain(|g| component_exists(g.component) && BROWSER_FOLDERS.contains(&g.folder.as_str()));
let known: BTreeMap<u64, (u64, String)> = groups.iter().map(|g| (g.id, (g.component, g.folder.clone()))).collect();
let parents: BTreeMap<u64, Option<u64>> = groups
.iter()
.map(|g| (g.id, g.parent.filter(|p| *p != g.id && known.get(p).is_some_and(|(c, f)| *c == g.component && *f == g.folder))))
.collect();
// A loop, or a chain too deep, is cut at the group that makes it.
let too_deep = |id: u64| std::iter::successors(Some(id), |g| parents.get(g).copied().flatten()).nth(MAX_GROUP_NESTING).is_some();
let kept: BTreeMap<u64, Option<u64>> = parents.iter().map(|(id, p)| (*id, if too_deep(*id) { None } else { *p })).collect();
for g in &mut groups {
g.parent = kept[&g.id];
}
let mut taken: BTreeSet<(u64, String, String)> = BTreeSet::new();
for g in &mut groups {
let (component, folder, id) = (g.component, g.folder.clone(), g.id);
let mut seen = BTreeSet::new();
g.items.retain(|e| match e {
BrowserEntry::Group(c) => kept.get(c) == Some(&Some(id)) && seen.insert(*c),
BrowserEntry::Item(k) => item_exists(&folder, component, k) && taken.insert((component, folder.clone(), k.clone())),
});
}
let top = |c: u64, folder: &str, id: u64| kept.get(&id) == Some(&None) && known.get(&id).is_some_and(|(kc, kf)| *kc == c && kf == folder);
order.retain(|key, list| {
let Some((c, folder)) = key.split_once('/') else { return false };
let Some(c) = c.parse::<u64>().ok().filter(|c| component_exists(*c)) else { return false };
if !BROWSER_FOLDERS.contains(&folder) {
return false;
}
let mut seen = BTreeSet::new();
list.retain(|e| match e {
BrowserEntry::Group(id) => top(c, folder, *id) && seen.insert(e.clone()),
BrowserEntry::Item(k) => item_exists(folder, c, k) && seen.insert(e.clone()),
});
!list.is_empty()
});
(groups != self.browser_groups || order != self.browser_order).then_some((groups, order))
}
/// Put placements of one component in the order the browser shows them: the saved
/// `components` order, then the rest as they were.
pub fn sort_placements(&self, parent: u64, ids: &mut [u64]) {
let Some(order) = self.browser_order.get(&format!("{parent}/components")) else { return };
let rank: BTreeMap<&str, usize> = order.iter().enumerate().filter_map(|(i, e)| Some((e.as_item()?, i))).collect();
ids.sort_by_cached_key(|id| rank.get(id.to_string().as_str()).copied().unwrap_or(usize::MAX));
}
}
+2 -4
View File
@@ -15,10 +15,8 @@ pub struct Canvas {
pub name: String,
/// `png` or `jpeg`.
pub format: String,
/// The image file, base64. Shared rather than owned: every edit clones the document (the undo
/// history keeps the old one), and an image of up to 16 MiB copied into each of the 200 undo
/// states would hold gigabytes. Serialized as the same JSON string as before.
pub data: std::sync::Arc<str>,
/// The image file, base64.
pub data: String,
/// Pixel size of the image.
pub pixels: [u32; 2],
pub plane: PlaneRef,
+2 -15
View File
@@ -176,8 +176,6 @@ pub(crate) fn remap_link(
remap_link(b, ids, names, bodies, depth + 1);
}
LinkSource::Plane { name } => mapped_name(name, names),
// A construction axis, by name (#9324); the origin axes are no feature's.
LinkSource::Axis { name } if !matches!(name.as_str(), "X" | "Y" | "Z") => mapped_name(name, names),
_ => {}
}
}
@@ -255,12 +253,6 @@ impl crate::Feature {
for e in sketch.text_exprs_mut() {
*e = clone_expression(e, parameters);
}
// An equation curve's variable is its own: a parameter of that name is not renamed in it.
for (variable, e) in sketch.equation_exprs_mut() {
let others: BTreeMap<String, String> =
parameters.iter().filter(|(old, _)| old.as_str() != variable).map(|(o, n)| (o.clone(), n.clone())).collect();
*e = clone_expression(e, &others);
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
@@ -316,7 +308,7 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
checked_geo(g, depth + 1)?;
}
}
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } | PlaneRef::AxisPoint { axis: a, point: b } => {
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
checked_geo(a, depth + 1)?;
checked_geo(b, depth + 1)?;
}
@@ -333,12 +325,7 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
checked_geo(b, depth + 1)?;
checked_geo(c, depth + 1)?;
}
PlaneRef::Tangent { face, reference, .. } => {
checked_geo(face, depth + 1)?;
if let Some(r) = reference {
checked_geo(r, depth + 1)?;
}
}
PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?,
PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?,
_ => {}
}
+5 -117
View File
@@ -116,11 +116,6 @@ pub struct Construct {
pub feature: u64,
pub name: String,
pub geom: ConstructGeom,
/// The bodies it was built from, when every pick it reads lies on a body
/// ([`source_bodies`]): a later Move of all of them carries it along (#10127). Empty when
/// it also reads something fixed (a point, a plane, a sketch), which keeps it in place.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bodies: Vec<String>,
}
/// Everything a reference needs to be resolved.
@@ -349,13 +344,10 @@ fn model_line(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> {
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
_ => Err(bad(MODEL_AXIS_REFS)),
_ => Err(bad("pick a straight or circular edge, or a cylinder, cone or torus face")),
}
}
/// What [`model_axis`] says about a reference that is neither an edge nor a face.
pub const MODEL_AXIS_REFS: &str = "pick a straight or circular edge, or a cylinder, cone or torus face";
/// The axis a feature turns about when an edge or face of the model is picked for it (a revolve,
/// a circular pattern, a plane at an angle), as [`model_line`] finds it. The direction is
/// oriented the same way however the edge or surface runs (its largest component positive), so
@@ -502,48 +494,6 @@ fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> {
}
}
/// A point over `s` where the face turns towards `towards` (a reference plane's normal): around
/// the axis of a cylinder, cone or torus, turned `angle` about it; straight at it on a sphere. Of
/// the two sides, the one `at` is on (#8822).
fn facing(s: FaceSurface, at: Vec3, towards: Vec3, angle: f64) -> Result<Vec3> {
let side = |d: Vec3, w: Vec3| if d.dot(w) < 0.0 { d * -1.0 } else { d };
let around = |axis: Vec3, w: Vec3| -> Result<(Vec3, f64)> {
let d = (towards - axis * towards.dot(axis))
.normalized()
.ok_or_else(|| bad("the reference is square to the face's axis: every plane tangent to the face meets it at a right angle"))?;
let d = d * angle.cos() + axis.cross(d) * angle.sin();
let r = w - axis * w.dot(axis);
Ok((side(d, r), r.len()))
};
if !angle.is_finite() {
return Err(bad("the angle is not a number"));
}
Ok(match s {
FaceSurface::Plane { .. } => at,
FaceSurface::Cylinder { origin, axis, radius } => {
let w = at - origin;
let (d, _) = around(axis.into(), w)?;
origin + axis * w.dot(axis) + d * radius
}
FaceSurface::Cone { apex, axis, .. } => {
let w = at - apex;
let (d, r) = around(axis.into(), w)?;
apex + axis * w.dot(axis) + d * r.max(1e-6)
}
// On the outer equator, where the tube faces away from the axis.
FaceSurface::Torus { center, axis, major, minor } => {
let (d, _) = around(axis.into(), at - center)?;
center + d * (major + minor)
}
FaceSurface::Sphere { center, radius } => {
if angle.abs() > 1e-12 {
return Err(bad("a sphere has no axis to turn the plane about: leave the angle out"));
}
center + side(towards, at - center) * radius
}
})
}
fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> {
match g {
GeoRef::Face { at, name } => {
@@ -585,46 +535,6 @@ fn line_line(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> Option<Vec3> {
Some(((p + u * s) + (q + v * t)) * 0.5)
}
/// The bodies a construction built from `refs` lies on: each face, edge and vertex pick's body,
/// and the bodies of the constructions it is built from. Empty when any pick is not on a body
/// (a fixed point, the origin, a plane, a world axis, a sketch) or is not found: such a
/// construction is held by something a move of the bodies leaves where it is, so it stays.
pub(crate) fn source_bodies(ctx: &Ctx, refs: &[&GeoRef]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for g in refs {
let found: Vec<String> = match g {
GeoRef::Face { at, name } => face_of(ctx.st, *at, name).map(|(b, ..)| vec![b.definition_name().to_string()]).unwrap_or_default(),
GeoRef::Edge { at, name } => edge_pick(ctx.st, *at, name).map(|(b, ..)| vec![b.definition_name().to_string()]).unwrap_or_default(),
GeoRef::Vertex { .. } => {
point(ctx, g).ok().and_then(|p| vertex_body(ctx.st, p)).map(|b| vec![b.definition_name().to_string()]).unwrap_or_default()
}
GeoRef::Axis { name } | GeoRef::ConstructPoint { name } => {
ctx.st.construct.iter().find(|c| c.name.as_str() == AsRef::<str>::as_ref(name)).map(|c| c.bodies.clone()).unwrap_or_default()
}
_ => Vec::new(),
};
if found.is_empty() {
return Vec::new();
}
for b in found {
if !out.contains(&b) {
out.push(b);
}
}
}
out
}
/// The body with a vertex (an end of one of its edges) at `p`.
fn vertex_body(st: &ModelState, p: Vec3) -> Option<&ModelBody> {
bodies(st).find(|b| {
let tol = (b.body.size() * 1e-3).max(1e-3);
b.body
.edges(tol)
.is_ok_and(|edges| edges.iter().flat_map(|e| [e.points.first(), e.points.last()]).flatten().any(|v| v.dist(p) <= 1e-6 * (1.0 + p.len())))
})
}
pub(crate) fn resolve_axis(ctx: &Ctx, d: &AxisDef) -> Result<(Vec3, Vec3)> {
match d {
AxisDef::ThroughFace { face } => line(ctx, face),
@@ -707,16 +617,6 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
};
Plane::new(o1, d1, n.cross(d1)).ok_or_else(degenerate)
}
PlaneRef::AxisPoint { axis, point: at } => {
let (origin, dir) = line(ctx, axis)?;
let dir = dir.normalized().ok_or_else(|| bad("the axis has no direction"))?;
let delta = point(ctx, at)? - origin;
let across = delta - dir * delta.dot(dir);
if !across.len().is_finite() || across.len() < 1e-9 {
return Err(bad("the point is on the axis: pick a point off the axis to define a unique plane"));
}
Plane::new(origin, dir, across).ok_or_else(degenerate)
}
PlaneRef::ThreePoints { a, b, c } => {
let (pa, pb, pc) = (point(ctx, a)?, point(ctx, b)?, point(ctx, c)?);
if (pb - pa).cross(pc - pa).len() < 1e-9 {
@@ -724,17 +624,9 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
}
Plane::new(pa, pb - pa, pc - pa).ok_or_else(degenerate)
}
PlaneRef::Tangent { face, at, reference, angle } => {
PlaneRef::Tangent { face, at } => {
let (s, _) = face_surface_at(ctx, face)?;
let at = match reference {
Some(r) => {
let towards = plane(ctx, r)?.normal().normalized().ok_or_else(degenerate)?;
let angle = angle.as_ref().map(|a| Document::eval_in(ctx.vals, a, Kind::Angle)).transpose()?.unwrap_or(0.0);
facing(s, *at, towards, angle)?
}
None => *at,
};
let (on, n) = surface_normal(s, at).ok_or_else(|| bad("the point is on the face's axis"))?;
let (on, n) = surface_normal(s, *at).ok_or_else(|| bad("the point is on the face's axis"))?;
Plane::from_normal(on, n).ok_or_else(degenerate)
}
PlaneRef::Perpendicular { plane: base, line: l } => {
@@ -873,14 +765,10 @@ impl PlaneRef {
/// The references of a plane built from model geometry.
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } | PlaneRef::AxisPoint { axis: a, point: b } => vec![a.as_mut(), b.as_mut()],
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => vec![a.as_mut(), b.as_mut()],
PlaneRef::Perpendicular { plane, line } => vec![plane.as_mut(), line.as_mut()],
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
PlaneRef::Tangent { face, reference, .. } => {
let mut v = vec![face.as_mut()];
v.extend(reference.as_deref_mut());
v
}
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
PlaneRef::ThroughPoint { base, point } => {
let mut refs = base.refs_mut();
+28 -376
View File
@@ -97,21 +97,10 @@ pub enum PlaneRef {
Midplane { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through two straight edges (lines) that lie in one plane.
TwoEdges { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through a line (axis) and a point not on that line.
AxisPoint { axis: Box<GeoRef>, point: Box<GeoRef> },
/// Through three points.
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it. With
/// a `reference` (a plane or planar face), it touches where the face turns towards the
/// reference instead, turned by `angle` about the face's axis, on the side of `at` (#8822).
Tangent {
face: Box<GeoRef>,
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
reference: Option<Box<GeoRef>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
angle: Option<Expr>,
},
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
Tangent { face: Box<GeoRef>, at: Vec3 },
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
AlongPath { path: Box<GeoRef>, t: Expr },
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
@@ -1094,11 +1083,6 @@ pub enum FeatureKind {
/// bodies follow. Replaces `to`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to_plane: Option<PlaneRef>,
/// Point-to-point alignment: the directions the offset moves along (the chosen world
/// X/Y/Z axes in the frame of the bodies being moved). `None`: all three axes, the
/// offset in full.
#[serde(default, skip_serializing_if = "Option::is_none")]
axes: Option<Vec<Dir>>,
},
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
@@ -1217,10 +1201,9 @@ pub enum FeatureKind {
body: Option<BodyName>,
},
/// A perforation field (#732): round holes of `diameter` on a staggered (60°) or square grid of `pitch` over the
/// whole flat sheet, or inside the closed profiles of `sketch` when it is given, kept `margin` (default: the
/// diameter) clear of its edges, cut-outs, bend zones and the outline. Stored like a cosmetic thread: the holes go
/// to the flat pattern, the DXF and the measured volume, not into the B-rep, so a panel can carry tens of thousands
/// of them.
/// whole flat sheet, kept `margin` (default: the diameter) clear of its edges, cut-outs and bend zones. Stored like
/// a cosmetic thread: the holes go to the flat pattern, the DXF and the measured volume, not into the B-rep, so a
/// panel can carry tens of thousands of them.
SheetPerforate {
#[walk(length = "Diameter")]
diameter: Expr,
@@ -1233,13 +1216,6 @@ pub enum FeatureKind {
pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
/// The field's outline: a sketch on a face of the sheet. None: the whole sheet.
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
/// Which of the outline sketch's profiles bound the field. None: all of them.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(profiles_of = sketch)]
profiles: Option<ProfileSel>,
},
/// Fold a sheet along a line on its base face: a sketch line (followed when the sketch
/// changes), else the points `a`, `b`.
@@ -2038,7 +2014,7 @@ fn fair_keep_edges() -> bool {
/// Triangles of one imported mesh body (millimetres). The arrays are shared by clones: every
/// edit keeps the design as it was for undo, and a scanned part's triangles (hundreds of
/// megabytes) are not copied for that.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
pub name: String,
/// x, y, z per vertex.
@@ -2049,20 +2025,6 @@ pub struct MeshData {
pub color: Option<[f32; 3]>,
}
/// The same mesh: shared arrays are the same without reading them, so the dirty check and
/// autosave after an edit elsewhere do not walk a scan's millions of coordinates. Arrays that
/// are not shared compare value by value, as before. Coordinates are finite (a design that
/// holds others does not load), so the shortcut only skips work.
impl PartialEq for MeshData {
fn eq(&self, other: &Self) -> bool {
use std::sync::Arc;
self.name == other.name
&& self.color == other.color
&& (Arc::ptr_eq(&self.triangles, &other.triangles) || self.triangles == other.triangles)
&& (Arc::ptr_eq(&self.positions, &other.positions) || self.positions == other.positions)
}
}
/// Points of a sketch, by id.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct SketchPoints {
@@ -2100,11 +2062,6 @@ pub struct Component {
/// Free text properties of the definition (material grade, finish, supplier…), by name.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub properties: std::collections::BTreeMap<String, String>,
/// Keeps this component's features (and its children's) out of the History bar (#10393).
/// The features still rebuild and the component still shows in the Browser; imported copies
/// whose features only exist to rebuild the copy can carry this flag.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub history_hidden: bool,
}
/// One loft section: profiles of a sketch.
@@ -2240,11 +2197,7 @@ pub enum PatternKind {
reversed2: bool,
},
Circular {
/// A point on the axis the copies turn about: numbers, or length expressions it follows
/// as the parameters change (#10852).
#[serde(with = "point_expr")]
#[walk(placement)]
origin: PointExpr,
origin: Position,
axis: Dir,
#[walk(unitless = "Quantity")]
count: Expr,
@@ -2454,167 +2407,6 @@ impl FeatureKind {
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
}
}
/// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`).
pub fn kind_key(&self) -> &'static str {
match self {
FeatureKind::BodyTransfer { .. } => "body_transfer",
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
FeatureKind::Revolve { .. } => "revolve",
FeatureKind::Fillet { .. } => "fillet",
FeatureKind::Chamfer { .. } => "chamfer",
FeatureKind::Box { .. } => "box",
FeatureKind::Cylinder { .. } => "cylinder",
FeatureKind::Sphere { .. } => "sphere",
FeatureKind::Torus { .. } => "torus",
FeatureKind::Cone { .. } => "cone",
FeatureKind::Combine { .. } => "combine",
FeatureKind::Pattern { .. } => "pattern",
FeatureKind::Mirror { .. } => "mirror",
FeatureKind::Hole { .. } => "hole",
FeatureKind::Thread { .. } => "thread",
FeatureKind::Loft { .. } => "loft",
FeatureKind::Sweep { .. } => "sweep",
FeatureKind::Shell { .. } => "shell",
FeatureKind::Draft { .. } => "draft",
FeatureKind::ConstructionPlane { .. } => "construction_plane",
FeatureKind::Split { .. } => "split",
FeatureKind::ConstructionAxis { .. } => "construction_axis",
FeatureKind::ConstructionPoint { .. } => "construction_point",
FeatureKind::SplitFace { .. } => "split_face",
FeatureKind::Scale { .. } => "scale",
FeatureKind::OffsetFace { .. } => "offset_face",
FeatureKind::DeleteFace { .. } => "delete_face",
FeatureKind::BoundingSolid { .. } => "bounding_solid",
FeatureKind::Pipe { .. } => "pipe",
FeatureKind::FrameMember { .. } => "frame_member",
FeatureKind::Emboss { .. } => "emboss",
FeatureKind::Rib { .. } => "rib",
FeatureKind::ReplaceFace { .. } => "replace_face",
FeatureKind::Align { .. } => "align",
FeatureKind::Coil { .. } => "coil",
FeatureKind::SheetBase { .. } => "sheet_base",
FeatureKind::SheetContour { .. } => "sheet_contour",
FeatureKind::SheetFlange { .. } => "sheet_flange",
FeatureKind::SheetHem { .. } => "sheet_hem",
FeatureKind::SheetPerforate { .. } => "sheet_perforate",
FeatureKind::SheetFold { .. } => "sheet_fold",
FeatureKind::SheetPath { .. } => "sheet_path",
FeatureKind::SheetUnfold { .. } => "sheet_unfold",
FeatureKind::SheetConvert { .. } => "sheet_convert",
FeatureKind::Boss { .. } => "boss",
FeatureKind::Lip { .. } => "lip",
FeatureKind::SnapFit { .. } => "snap_fit",
FeatureKind::Rest { .. } => "rest",
FeatureKind::BoundaryFill { .. } => "boundary_fill",
FeatureKind::Remove { .. } => "remove",
FeatureKind::Patch { .. } => "patch",
FeatureKind::Stitch { .. } => "stitch",
FeatureKind::Thicken { .. } => "thicken",
FeatureKind::SurfaceExtrude { .. } => "surface_extrude",
FeatureKind::Unstitch { .. } => "unstitch",
FeatureKind::ReverseNormal { .. } => "reverse_normal",
FeatureKind::SurfaceUntrim { .. } => "surface_untrim",
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
FeatureKind::SurfaceLoft { .. } => "surface_loft",
FeatureKind::SurfaceOffset { .. } => "surface_offset",
FeatureKind::SurfaceRuled { .. } => "surface_ruled",
FeatureKind::SurfaceExtend { .. } => "surface_extend",
FeatureKind::SurfaceBlend { .. } => "surface_blend",
FeatureKind::SurfaceBoundary { .. } => "surface_boundary",
FeatureKind::SurfaceNetwork { .. } => "surface_network",
FeatureKind::SurfaceMatch { .. } => "surface_match",
FeatureKind::SurfaceFair { .. } => "surface_fair",
FeatureKind::SurfaceTrim { .. } => "surface_trim",
FeatureKind::Move { .. } => "move",
FeatureKind::Import { .. } => "import",
FeatureKind::MeshConvert { .. } => "mesh_convert",
FeatureKind::MeshImport { .. } => "mesh_import",
}
}
/// Map a display or Rust type name (e.g. `"HoleFeature"`, `"EdgeFlangeFeature"`)
/// to the canonical design-file variant key (`"hole"`, `"sheet_flange"`).
pub fn type_name_to_kind_key(name: &str) -> Option<&'static str> {
Some(match name {
"BodyTransferFeature" => "body_transfer",
"Sketch" => "sketch",
"ExtrudeFeature" => "extrude",
"RevolveFeature" => "revolve",
"FilletFeature" => "fillet",
"ChamferFeature" => "chamfer",
"BoxFeature" => "box",
"CylinderFeature" => "cylinder",
"SphereFeature" => "sphere",
"TorusFeature" => "torus",
"ConeFeature" => "cone",
"CombineFeature" => "combine",
"RectangularPatternFeature" | "PathPatternFeature" | "CircularPatternFeature" => "pattern",
"MirrorFeature" => "mirror",
"ConstructionPlane" => "construction_plane",
"HoleFeature" => "hole",
"ThreadFeature" => "thread",
"LoftFeature" => "loft",
"SweepFeature" => "sweep",
"ShellFeature" => "shell",
"DraftFeature" => "draft",
"SplitBodyFeature" => "split",
"SplitFaceFeature" => "split_face",
"ConstructionAxis" => "construction_axis",
"ConstructionPoint" => "construction_point",
"MoveFeature" => "move",
"ScaleFeature" => "scale",
"OffsetFacesFeature" => "offset_face",
"DeleteFaceFeature" => "delete_face",
"OffsetSurfaceFeature" => "surface_offset",
"BoundingSolidFeature" => "bounding_solid",
"PipeFeature" => "pipe",
"FrameMemberFeature" => "frame_member",
"EmbossFeature" => "emboss",
"BossFeature" => "boss",
"LipFeature" | "GrooveFeature" => "lip",
"SnapFitFeature" => "snap_fit",
"RestFeature" => "rest",
"BoundaryFillFeature" => "boundary_fill",
"BaseFlangeFeature" => "sheet_base",
"ContourFlangeFeature" => "sheet_contour",
"EdgeFlangeFeature" => "sheet_flange",
"HemFeature" => "sheet_hem",
"PerforateFeature" => "sheet_perforate",
"FoldFeature" => "sheet_fold",
"PathFlangeFeature" => "sheet_path",
"UnfoldFeature" | "RefoldFeature" => "sheet_unfold",
"ConvertToSheetMetalFeature" => "sheet_convert",
"CoilFeature" => "coil",
"RibFeature" | "WebFeature" => "rib",
"ReplaceFaceFeature" => "replace_face",
"AlignFeature" => "align",
"RemoveFeature" => "remove",
"PatchFeature" => "patch",
"SurfaceExtrudeFeature" => "surface_extrude",
"SurfaceRevolveFeature" => "surface_revolve",
"SurfaceSweepFeature" => "surface_sweep",
"SurfaceLoftFeature" => "surface_loft",
"BlendFeature" => "surface_blend",
"BoundaryFeature" => "surface_boundary",
"MatchFeature" => "surface_match",
"FairFeature" => "surface_fair",
"StitchFeature" => "stitch",
"ThickenFeature" => "thicken",
"TrimFeature" => "surface_trim",
"ExtendFeature" => "surface_extend",
"RuledSurfaceFeature" => "surface_ruled",
"UnstitchFeature" => "unstitch",
"ReverseNormalFeature" => "reverse_normal",
"UntrimFeature" => "surface_untrim",
"BaseFeature" => "import",
"MeshFeature" => "mesh_import",
"MeshConvertFeature" => "mesh_convert",
_ => return None,
})
}
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
@@ -2836,19 +2628,6 @@ pub struct DesignLink {
/// local overrides and survive a source update. Older links start with no baseline.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inherited: Vec<LinkedBodyProperties>,
/// SHA-256 of the design file's bytes as this design last read them ([`Self::file_digest`]):
/// opened again, this design tells which linked files changed meanwhile (#9739). Links made
/// before it was kept have none and are taken as up to date.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_sha256: Option<String>,
}
impl DesignLink {
/// What [`Self::file_sha256`] keeps for a design file's `bytes`.
pub fn file_digest(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(bytes))
}
}
/// The values last inherited by a linked body, not another editable material library.
@@ -2957,9 +2736,6 @@ pub struct Document {
/// Physical material per body (by body name); others use the default.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub materials: std::collections::BTreeMap<String, String>,
/// The flat face per body (by body name) that a slicer export lays on the print bed.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub print_faces: std::collections::BTreeMap<String, crate::config::ConfigFaceTarget>,
/// Physical materials defined in this design (`material.create`), next to the built-in ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_materials: Vec<UserMaterial>,
@@ -2980,11 +2756,9 @@ pub struct Document {
/// order; feature ids remain the source of truth when features are renamed or reordered.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub timeline_groups: Vec<TimelineGroup>,
/// Browser order per component folder (`"<component>/<folder>"` → groups and item keys).
/// The `components` folder orders a component's sub-components (by placement id); it is not
/// drawn as a folder.
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub browser_order: std::collections::BTreeMap<String, Vec<BrowserEntry>>,
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
@@ -3073,69 +2847,8 @@ pub struct BrowserGroup {
#[serde(default)]
pub component: u64,
pub folder: String,
/// The group that holds this one; `None` is the top level of its folder.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<u64>,
/// The group's items and child groups in display order. A child group missing here still
/// belongs to it (its `parent` says so) and is listed after the ones that are here.
#[serde(default)]
pub items: Vec<BrowserEntry>,
}
/// One line of a browser order list: a group (a number in a saved design) or an item (a string:
/// an item key or body name, so a body named `"1"` never clashes with group 1).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(untagged)]
pub enum BrowserEntry {
Group(u64),
Item(String),
}
impl From<&str> for BrowserEntry {
fn from(key: &str) -> Self {
BrowserEntry::Item(key.to_string())
}
}
impl From<String> for BrowserEntry {
fn from(key: String) -> Self {
BrowserEntry::Item(key)
}
}
impl BrowserEntry {
pub fn item(key: &str) -> Self {
BrowserEntry::Item(key.to_string())
}
pub fn as_item(&self) -> Option<&str> {
match self {
BrowserEntry::Item(k) => Some(k),
BrowserEntry::Group(_) => None,
}
}
pub fn is_item(&self, key: &str) -> bool {
self.as_item() == Some(key)
}
}
/// How many groups may sit inside one another (a top-level group is level 1).
pub const MAX_GROUP_NESTING: usize = 8;
/// The folders a browser group or order list can be for. `components` only orders sub-components.
pub const BROWSER_FOLDERS: [&str; 4] = ["bodies", "sketches", "construction", "components"];
impl BrowserGroup {
/// The keys of the items directly in this group, in order.
pub fn item_keys(&self) -> impl Iterator<Item = &str> {
self.items.iter().filter_map(BrowserEntry::as_item)
}
/// Whether this group holds the item directly.
pub fn has_item(&self, key: &str) -> bool {
self.items.iter().any(|e| e.is_item(key))
}
pub items: Vec<String>,
}
/// A saved history folder. Folders may nest; each feature or joint belongs to at most one folder.
@@ -3269,7 +2982,6 @@ impl Document {
body_components: Default::default(),
body_offsets: Default::default(),
materials: Default::default(),
print_faces: Default::default(),
user_materials: Vec::new(),
appearances: Default::default(),
favorites: Default::default(),
@@ -3285,7 +2997,7 @@ impl Document {
}
/// Rename body `old` to `new` where the design keeps things by body name outside its
/// features: materials, print faces, appearances, plastic rules, configuration columns, offsets, components and the
/// features: materials, appearances, plastic rules, configuration columns, offsets, components and the
/// browser's groups and order. (The features are the rename command's to change.)
///
/// A name the design numbered (`Body1`) is not used again after the rename: the design's
@@ -3301,10 +3013,6 @@ impl Document {
if let Some(m) = self.materials.remove(old) {
self.materials.insert(new.to_string(), m);
}
if let Some(mut face) = self.print_faces.remove(old) {
face.body = new.to_string();
self.print_faces.insert(new.to_string(), face);
}
if let Some(rule) = self.plastic.assigned.remove(old) {
self.plastic.assigned.insert(new.to_string(), rule);
}
@@ -3331,23 +3039,22 @@ impl Document {
if let Some(c) = self.body_components.remove(old) {
self.body_components.insert(new.to_string(), c);
}
let rename = |e: &mut BrowserEntry| {
if e.is_item(old) {
*e = BrowserEntry::item(new);
}
};
for g in self.browser_groups.iter_mut().filter(|g| g.folder == "bodies") {
g.items.iter_mut().for_each(rename);
for k in g.items.iter_mut().filter(|k| *k == old) {
*k = new.to_string();
}
}
for (_, v) in self.browser_order.iter_mut().filter(|(k, _)| k.ends_with("/bodies")) {
v.iter_mut().for_each(rename);
for k in v.iter_mut().filter(|k| *k == old) {
*k = new.to_string();
}
}
}
/// The single body rename (old name, new name) that turns `previous` into this design,
/// when that is all that changed: the features' stored body names (`made` gives the names
/// each feature's bodies have now, by feature id), typed body references, participants and
/// what [`Document::rename_body_keys`] renames. `None` for any other change.
/// each feature's bodies have now, by feature id) and what [`Document::rename_body_keys`]
/// renames. `None` for any other change.
pub fn body_rename_from(&self, previous: &Document, made: impl Fn(u64) -> Vec<String>) -> Option<(String, String)> {
if self.features.len() != previous.features.len() {
return None;
@@ -3378,16 +3085,8 @@ impl Document {
}
let (old, new) = rename?;
let mut expected = previous.clone();
let bodies = std::collections::BTreeMap::from([(old.clone(), new.clone())]);
for (f, g) in expected.features.iter_mut().zip(&self.features) {
f.body_names.clone_from(&g.body_names);
// Updating references to the renamed body changes no feature input other than its
// name (#6287). Keep the full comparison below: geometry, expressions, sketch links
// and face anchors must still agree before the model may reuse its geometry.
f.kind.remap_bodies(&bodies);
for body in f.participants.iter_mut().filter(|body| **body == old) {
body.clone_from(&new);
}
}
expected.rename_body_keys(&old, &new);
(expected == *self).then_some((old, new))
@@ -3667,14 +3366,10 @@ impl Document {
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
pub fn from_json(s: &str) -> Result<Document> {
crate::render_resources::check_json_budget(s)?;
let mut d = crate::format::read(s)?;
let d = crate::format::read(s)?;
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
return Err(DocError::Invalid("document too large".into()));
}
// A design from elsewhere may nest groups too deep, loop, or name things that are gone.
if let Some((groups, order)) = d.browser_pruned() {
(d.browser_groups, d.browser_order) = (groups, order);
}
Ok(d)
}
pub fn to_json(&self) -> String {
@@ -3682,8 +3377,6 @@ impl Document {
}
pub fn feature(&self, id: u64) -> Option<&Feature> {
#[cfg(test)]
crate::eval::cost_tests::count_feature_search();
self.features.iter().find(|f| f.id == id)
}
pub fn feature_mut(&mut self, id: u64) -> Option<&mut Feature> {
@@ -3702,35 +3395,15 @@ impl Document {
self.features.iter().find(|f| f.name.eq_ignore_ascii_case(r))
}
/// Resolve a textual timeline reference. An exact name wins even when it looks like an id.
/// Older scripts also pass ids as strings or use a different case for a unique name.
pub fn resolve_feature_name(&self, name: &str) -> Result<&Feature> {
if let Some(feature) = self.features.iter().find(|f| f.name == name) {
return Ok(feature);
}
if let Ok(id) = name.parse::<u64>()
&& let Some(feature) = self.feature(id)
{
return Ok(feature);
}
let mut matches = self.features.iter().filter(|f| f.name.eq_ignore_ascii_case(name));
let feature = matches.next().ok_or_else(|| DocError::Invalid(format!("no feature `{name}`")))?;
if matches.next().is_some() {
return Err(DocError::Invalid(format!("ambiguous feature `{name}`. Use its exact name or numeric id")));
}
Ok(feature)
}
/// The feature a pattern or mirror seed names, resolved the way `replay()` resolved seed
/// names before seeds were keyed by id (#1134): an exact name match on a feature that is
/// not a sketch or a construction plane wins — a sketch and its extrude often share a
/// name, and the feature that makes geometry is the one to copy. Other references use
/// the timeline lookup.
/// name, and the feature that makes geometry is the one to copy — else [`find_feature`].
pub fn seed_feature(&self, name: &str) -> Option<&Feature> {
self.features
.iter()
.find(|x| x.name == name && !matches!(x.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }))
.or_else(|| self.resolve_feature_name(name).ok())
.or_else(|| self.find_feature(name))
}
fn unique_name(&self, base: &str) -> String {
@@ -3786,18 +3459,12 @@ impl Document {
} else {
0
};
let id = self.next_id.max(1);
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let name = match name {
Some(n) if !n.trim().is_empty() => n.trim().to_string(),
_ => self.unique_name(kind.base_name()),
};
// Plane references share the feature namespace. A duplicate would silently select
// the first feature. Other shared names (a sketch and its extrude) remain supported.
let is_plane = matches!(kind, FeatureKind::ConstructionPlane { .. });
if self.features.iter().any(|f| f.name == name && (is_plane || matches!(f.kind, FeatureKind::ConstructionPlane { .. }))) {
return Err(DocError::Invalid(format!("another feature is called `{name}`")));
}
let id = self.next_id.max(1);
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let f = Feature {
id,
name,
@@ -4038,7 +3705,7 @@ impl Document {
}
/// Add or change a parameter. Fails if the result would not evaluate. A new parameter
/// without a unit takes the unit of its value (a length in the design's units, deg or none).
/// without a unit takes the unit of its value (mm, deg or none).
pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
// A text parameter: one with the text unit, or an existing one when no unit is given.
match (unit.map(crate::text_params::is_text_unit), self.text_param(name).is_some()) {
@@ -4195,7 +3862,6 @@ impl Document {
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::AxisPoint { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| PlaneRef::AlongPath { .. }
@@ -4223,11 +3889,10 @@ impl Document {
}
}
/// Apply parameter values to every dimension in a sketch, to its points placed by parameters
/// and to its equation curves (in place).
/// Apply parameter values to every dimension in a sketch, and to its points placed by
/// parameters (in place).
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
self.apply_point_values(vals, sk)?;
self.apply_equation_values(vals, sk)?;
self.apply_pattern_values(vals, sk)?;
for c in &mut sk.constraints {
if let Some(p) = &c.param {
@@ -4348,7 +4013,7 @@ impl Document {
return Err(DocError::Invalid("a text's height must be positive".into()));
}
if let Some(e) = &exprs.text {
let words = self.eval_words(e, vals).map_err(|error| DocError::Expr(format!("text: {error}")))?;
let words = self.eval_words(e).map_err(|error| DocError::Expr(format!("text: {error}")))?;
if words.trim().is_empty() || words.chars().count() > solidcraft_sketch::MAX_TEXT_CHARS {
return Err(DocError::Invalid(format!("text: `{e}` gives no words, or too many")));
}
@@ -4393,11 +4058,6 @@ impl Document {
analysis: solidcraft_sketch::Analysis,
) -> Result<solidcraft_sketch::SolveReport> {
self.apply_dimension_values(vals, sk)?;
// A text built anew since it was stored (an expression gave it other words) has other
// outline entities: the stored sketch is taken with this sketch's, so the points still
// pair up for the side checks and the walk below.
let paired = moved_from.and_then(|from| sk.stored_with_rebuilt_texts(from));
let moved_from = paired.as_ref().or(moved_from);
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
// Offsets always require another solve after refresh. Only that last report is
@@ -4436,14 +4096,6 @@ impl Document {
} else {
solidcraft_sketch::solve_after_edit_with_analysis(sk, stored, was(sk).as_deref(), analysis)
};
// An attachment to the old offset can prevent the source from moving at all.
// Retry from a source-only seed, adopting it only if every constraint still holds.
if !report.ok()
&& report.oversized.is_none()
&& let Some(retried) = sk.solve_after_offset_edit(stored, was(sk).as_deref(), analysis)
{
report = retried;
}
}
Ok(report)
}
+74 -579
View File
File diff suppressed because it is too large Load Diff
+2 -699
View File
@@ -12,37 +12,20 @@ use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_kernel as kernel;
use solidcraft_sketch::Sketch;
use crate::eval::{PlacementReads, body_world, hash_placement, placement_digest};
use crate::*;
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces and bodies
/// measured, bodies searched for by name, features searched for by id, bodies a cut (or
/// intersect) ran its booleans on, and bodies searched for the cylinder a tap marking lies on.
/// measured.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Counts {
pub moved: usize,
pub digested: usize,
pub joint_faces: usize,
pub measures: usize,
pub body_searches: usize,
pub feature_searches: usize,
pub cut: usize,
pub cylinder_search: usize,
}
thread_local! {
static COUNTS: Cell<Counts> = const {
Cell::new(Counts {
moved: 0,
digested: 0,
joint_faces: 0,
measures: 0,
body_searches: 0,
feature_searches: 0,
cut: 0,
cylinder_search: 0,
})
};
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0, measures: 0 }) };
}
pub(super) fn count(f: impl FnOnce(&mut Counts)) {
@@ -53,33 +36,6 @@ pub(super) fn count(f: impl FnOnce(&mut Counts)) {
});
}
thread_local! {
static WRITTEN: Cell<usize> = const { Cell::new(0) };
}
/// A feature written out on this thread to be fingerprinted.
pub(super) fn count_written() {
WRITTEN.with(|c| c.set(c.get() + 1));
}
/// The features `f` writes out on this thread to fingerprint them.
fn written(f: impl FnOnce()) -> usize {
WRITTEN.with(|c| c.set(0));
f();
WRITTEN.with(Cell::get)
}
/// A search on this thread through all of a design's features for one.
pub(crate) fn count_feature_search() {
count(|c| c.feature_searches += 1);
}
/// The searches `f` makes on this thread through all features or all bodies.
fn searched(f: impl FnOnce()) -> usize {
let n = counted(f);
n.feature_searches + n.body_searches
}
/// The work `f` does on this thread.
fn counted(f: impl FnOnce()) -> Counts {
COUNTS.with(|c| c.set(Counts::default()));
@@ -187,25 +143,6 @@ fn joint_face_measurements_never_follow_a_replaced_body_or_stale_offset() {
assert_eq!(work.joint_faces, 0, "offsets are applied after the shared face measurements");
}
/// Every solve of a drag resolves its edge origins again: they share the joint faces' one
/// measurement, as a face origin does, instead of tessellating the whole body each time.
#[test]
fn an_edge_origin_measures_its_faces_once() {
use crate::joints::{JointOrigin, Snap};
let doc = Document::new("edge origin");
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(), 0));
let pick = Vec3::new(5.0, 0.0, 10.0);
let origin = JointOrigin { occurrence: 0, snap: Snap::Edge { pick, edge: None, face: None, corner: None, axis: None } };
let work = counted(|| {
for _ in 0..8 {
let frame = joints::resolve_origin(&doc, &state.clone(), &origin).unwrap();
assert!(apply_point(&frame, Vec3::ZERO).dist(pick) < 1e-9);
}
});
assert_eq!(work.joint_faces, 1, "a solve must not measure the faces again: {work:?}");
}
/// `parts` imported files of two boxes each, every one in its own component placed away from
/// the origin (as inserted parts are).
fn placed_imports(parts: usize) -> Document {
@@ -421,132 +358,6 @@ fn an_imported_file_is_fingerprinted_by_its_text() {
assert_eq!(model.state().bodies.len(), 3);
}
/// Pinning body names fingerprints the timeline again only from the first renamed feature on
/// (`Model::pinned_prefix`), and gives the fingerprints the full pass gives: random pins
/// on a timeline of boxes, cuts, patterns of features (one placed before its source, as one made
/// with the timeline rolled back is), a suppressed feature and a history marker, after an
/// evaluation and after earlier pins.
#[test]
fn pinning_body_names_fingerprints_as_the_full_pass_does() {
let kind = |v: serde_json::Value| serde_json::from_value::<FeatureKind>(v).unwrap();
let mut doc = Document::new("pins");
let mut ids = Vec::new();
for i in 0..10 {
let x = 20.0 * i as f64;
let b =
kind(serde_json::json!({"type": "box", "corner": [x, 0.0, 0.0], "length": "10", "width": "10", "height": "10", "operation": "new_body"}));
ids.push(doc.add_feature(b, None).unwrap());
if i % 3 == 1 {
let c = kind(
serde_json::json!({"type": "cylinder", "axis": [0.0, 0.0, 1.0], "base": [x + 5.0, 5.0, -1.0], "height": "12", "radius": "2", "operation": "cut"}),
);
ids.push(doc.add_feature(c, None).unwrap());
}
}
let circular = |features: Vec<u64>| {
kind(serde_json::json!({"type": "pattern", "features": features, "bodies": [],
"pattern": {"type": "circular", "angle": "360 deg", "axis": [0.0, 0.0, 1.0], "count": "3", "origin": [0.0, -40.0, 0.0]}}))
};
ids.push(doc.add_feature(circular(vec![ids[2]]), None).unwrap());
// A pattern of a later feature, placed early in the timeline.
let early = doc.add_feature(circular(vec![ids[9]]), None).unwrap();
let at = doc.features.iter().position(|f| f.id == early).unwrap();
let f = doc.features.remove(at);
doc.features.insert(3, f);
ids.push(early);
doc.feature_mut(ids[5]).unwrap().suppressed = true;
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: usize| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % n as u64) as usize
};
let fps = |m: &Model| m.results.iter().map(|r| (r.id, r.fingerprint)).collect::<Vec<_>>();
let mut old = Arc::new(doc);
let mut model = Model::new();
model.evaluate_shared(&old);
let (mut proven, mut kept) = (0, 0);
for round in 0..60 {
if round % 20 == 10 {
// From an evaluation's fingerprints again, with the marker in some rounds.
let mut d = (*old).clone();
d.marker = (round == 30).then_some(d.features.len() - 2);
old = Arc::new(d);
model.evaluate_shared(&old);
}
let mut new = (*old).clone();
let mut renamed = Vec::new();
for _ in 0..1 + next(3) {
let f = &mut new.features[next(old.features.len())];
let slot = next(3);
f.body_names.resize(f.body_names.len().max(slot + 1), String::new());
f.body_names[slot] = format!("Pinned {round} {slot}");
renamed.push(f.id);
}
new.reserved_body_names.insert(format!("Body{}", 100 + round));
let new = Arc::new(new);
let start = model.pinned_prefix(&old, &renamed);
let first = new.features.iter().position(|f| renamed.contains(&f.id)).unwrap();
assert!(start <= first, "round {round}: kept {start} past the first renamed feature {first}");
proven += usize::from(start > 0);
kept += start;
let mut partial = model.clone();
partial.refingerprint_shared(&new, start);
let mut full = model.clone();
full.refingerprint_shared(&new, 0);
assert_eq!(fps(&partial), fps(&full), "round {round}");
assert_ne!(fps(&partial), fps(&model), "round {round}: the names are fingerprinted");
// The pinned fingerprints prove themselves for the next pin, as the full pass's do.
assert_eq!(partial.fingerprinted.as_ref().map(|(_, d)| *d), full.fingerprinted.as_ref().map(|(_, d)| *d));
model = partial;
old = new;
}
assert!(proven > 40 && kept > 0, "the stored fingerprints were reused: {proven} rounds, {kept} results");
// No proof, no reuse: results changed since, another design, or the design about to change
// in place.
let last = [*ids.last().unwrap()];
assert!(model.pinned_prefix(&old, &last) > 0);
let mut changed = model.clone();
changed.results[0].fingerprint ^= 1;
assert_eq!(changed.pinned_prefix(&old, &last), 0);
assert_eq!(model.pinned_prefix(&Arc::new((*old).clone()), &last), 0);
let mut forgot = model.clone();
forgot.forget_fingerprints();
assert_eq!(forgot.pinned_prefix(&old, &last), 0);
}
/// A design evaluated ahead, on a worker thread of its own, fingerprints its features from the
/// texts the window's evaluation wrote: none is written out again there (the store was per
/// thread, and every ahead run started it empty), and the fingerprints are the same.
#[test]
fn an_evaluation_on_another_thread_reuses_the_written_features() {
let mut doc = Document::new("ahead");
// Ids no other test's design uses: the store is shared by the tests running beside this one.
doc.next_id = 0x0007_e57a_4ead_0000;
for i in 0..40 {
let x = 20.0 * i as f64;
let b = serde_json::json!({"type": "box", "corner": [x, 0.0, 0.0], "length": "10", "width": "10", "height": "10", "operation": "new_body"});
doc.add_feature(serde_json::from_value(b).unwrap(), None).unwrap();
}
let mut window = Model::new();
let first = written(|| window.evaluate(&doc));
assert!(first >= doc.features.len(), "{first} written");
let fps = |m: &Model| m.results.iter().map(|r| r.fingerprint).collect::<Vec<_>>();
let (doc2, mut ahead) = (doc.clone(), window.clone());
ahead.no_cache = true;
let (work, ahead_fps) = std::thread::spawn(move || {
let work = written(|| ahead.evaluate(&doc2));
(work, fps(&ahead))
})
.join()
.unwrap();
assert_eq!(work, 0, "features written out again");
assert_eq!(ahead_fps, fps(&window));
}
/// A 10 mm square plate per hole along X, 4 mm thick, in component `comp`, with `holes` square
/// holes cut through it by boxes drawn in component `tools` (the plate's own, usually). The
/// plate's body name.
@@ -836,511 +647,3 @@ fn shared_evaluation_refreshes_live_sketches_and_keeps_unknown_features_conserva
assert!(model.results.iter().all(|r| r.error.is_none()));
assert!(model.evaluate_shared(&doc), "an unclassified feature must keep the ordinary evaluation path");
}
/// A saved model writes each set of sketch keys once, as the keys it sets and drops relative to
/// the set written before it (every state repeating its whole set made a 5,484-feature design's
/// model too large for its file, which then kept only some states and evaluated the rest on
/// opening). Every state reads back the keys it was evaluated with, also in a model thinned to
/// fewer states; a set that names no set before it is refused.
#[test]
fn saved_states_read_back_their_sketch_keys() {
let mut doc = Document::new("Sketch keys");
for i in 0..4 {
doc.add_feature(line_sketch(PlaneRef::Origin { name: "XY".into() }, Vec2::new(f64::from(i) + 1.0, 1.0)), None).unwrap();
for j in 0..3 {
let corner = point_expr(Vec3::new(f64::from(i) * 20.0, f64::from(j) * 20.0, 0.0));
let kind = FeatureKind::Box { corner, length: "5".into(), width: "5".into(), height: "5".into(), operation: Operation::NewBody };
doc.add_feature(kind, None).unwrap();
}
}
let mut model = Model::new();
model.evaluate(&doc);
let keys = |m: &Model| m.results.iter().map(|r| r.state.sketch_keys.clone()).collect::<Vec<_>>();
assert_eq!(model.state().sketch_keys.len(), 4);
let saved = model.saved(&doc).unwrap();
assert_eq!(saved.records["sketch_key_sets"].as_array().unwrap().len(), 4, "one set per sketch");
assert_eq!(saved.records["states"].as_array().unwrap().len(), doc.features.len());
assert_eq!(keys(&Model::adopt(&doc, &saved).unwrap()), keys(&model));
// The final state alone (every body costs more than the budget): the sets are all kept.
let pieces: Vec<Vec<(usize, usize)>> = (0..saved.bodies.len()).map(|i| vec![(i, 1 << 20)]).collect();
let (thinned, _, _) = saved.thinned(&pieces, 0).unwrap();
assert_eq!(thinned.records["states"].as_array().unwrap().len(), 1);
assert_eq!(thinned.final_state().unwrap().sketch_keys, model.state().sketch_keys);
assert_eq!(keys(&Model::adopt(&doc, &thinned).unwrap()), keys(&model));
// Sets and drops read as written; a base that is not an earlier set is refused.
let with_sets = |sets: serde_json::Value| SavedModel {
records: {
let mut r = saved.records.clone();
r["sketch_key_sets"] = sets;
r
},
bodies: saved.bodies.clone(),
};
let first = *model.state().sketch_keys.keys().next().unwrap();
let dropped = with_sets(serde_json::json!([
{"set": [[first, "0000000000000001"], [7, "0000000000000002"]], "drop": []},
{"base": 0, "set": [], "drop": []},
{"base": 1, "set": [[8, "0000000000000003"]], "drop": [first]},
{"base": 0, "set": [[7, "00000000000000ff"]], "drop": []},
]));
let want: BTreeMap<u64, u64> = [(7, 0xff), (first, 1)].into();
assert_eq!(dropped.final_state().unwrap().sketch_keys, want);
let dropped = with_sets(serde_json::json!([
{"set": [[first, "0000000000000001"], [7, "0000000000000002"]], "drop": []},
{"base": 0, "set": [], "drop": []},
{"base": 0, "set": [], "drop": []},
{"base": 1, "set": [[8, "0000000000000003"]], "drop": [first]},
]));
assert_eq!(dropped.final_state().unwrap().sketch_keys, [(7, 2), (8, 3)].into());
for base in [3, 4] {
let sets =
serde_json::json!([{"set": [], "drop": []}, {"set": [], "drop": []}, {"set": [], "drop": []}, {"base": base, "set": [], "drop": []}]);
assert!(with_sets(sets).final_state().is_err(), "base {base}");
}
}
/// The dirty check and autosave compare the whole document after every edit; a scanned
/// part's shared triangles must not be read for that. A coordinate that equals nothing (not a
/// number) shows whether the shared arrays were read: only reading them makes a mesh differ
/// from its own copy.
#[test]
fn comparing_a_copied_mesh_does_not_read_its_shared_arrays() {
let mesh = MeshData {
name: "Scan".into(),
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., f64::NAN]),
triangles: Arc::from([0, 1, 2]),
color: None,
};
let copy = mesh.clone();
assert!(copy == mesh, "a copy shares the arrays and is the same mesh without reading them");
let mut renamed = mesh.clone();
renamed.name = "Other".into();
assert!(renamed != mesh);
let mut coloured = mesh.clone();
coloured.color = Some([0.2, 0.4, 0.6]);
assert!(coloured != mesh);
let unshared = MeshData { positions: Arc::from(mesh.positions.to_vec()), ..mesh.clone() };
assert!(unshared != mesh, "arrays that are not shared still compare value by value");
let finite = MeshData { positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., 0.]), ..mesh.clone() };
let equal = MeshData { positions: Arc::from(finite.positions.to_vec()), triangles: Arc::from(finite.triangles.to_vec()), ..finite.clone() };
assert!(equal == finite, "equal values in other arrays are the same mesh");
let flipped = MeshData { triangles: Arc::from([0, 2, 1]), ..finite.clone() };
assert!(flipped != finite);
}
/// `blocks` boxes, each its own body, in component `comp`, then a cut box without targets
/// through each of the first `cuts` (it cuts every body of its component).
fn blocks_with_cuts(doc: &mut Document, comp: u64, blocks: usize, cuts: usize) {
let y = 100.0 * comp as f64;
for k in 0..blocks {
let corner = point_expr(Vec3::new(20.0 * k as f64, y, 0.0));
let kind = FeatureKind::Box { corner, length: "10".into(), width: "10".into(), height: "4".into(), operation: Operation::NewBody };
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().component = comp;
}
for k in 0..cuts {
let corner = point_expr(Vec3::new(20.0 * k as f64 + 4.0, y + 4.0, -1.0));
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut };
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().component = comp;
}
}
#[test]
fn an_evaluation_from_the_cache_does_not_search_the_bodies_for_each_cut() {
// Each cut reads where every body of its component is placed; looking each one up by name
// made an evaluation quadratic in the bodies, even with every feature taken from the cache.
let searches = |blocks: usize| {
let mut doc = Document::new("blocks");
blocks_with_cuts(&mut doc, 0, blocks, 3);
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
let work = counted(|| model.evaluate(&doc));
assert_eq!(model.last_recomputed, 0);
work.body_searches
};
let (few, many) = (searches(4), searches(24));
assert_eq!(few, many, "searches grew with the bodies: {few} for 4 blocks, {many} for 24");
}
/// The bodies part of the placement digest as it was before the bodies were looked up once:
/// each body searched by name, placed through [`body_world`].
fn digest_searching_each_body(doc: &Document, f: &Feature, st: &ModelState) -> u64 {
use std::hash::{Hash, Hasher};
let mut reads = PlacementReads::default();
reads.drawn_targets(Some(st), f);
let inverse = mat_inverse(&doc.component_transform(f.component)).unwrap();
let mut h = std::collections::hash_map::DefaultHasher::new();
for name in &reads.bodies {
name.hash(&mut h);
match st.body(name) {
Some(b) => hash_placement(&mat_mul(&inverse, &body_world(doc, b)), &mut h),
None => u8::MAX.hash(&mut h),
}
}
h.finish() | 1
}
#[test]
fn looking_bodies_up_once_keeps_every_placement_digest() {
// Two parts placed apart, a body moved into the other part and one moved within its part:
// each cut's digest is the one searching each body gave, bit for bit, so saved caches stay
// valid.
let mut doc = Document::new("digests");
let a = doc.add_component(Some("A"), 0).unwrap();
let b = doc.add_component(Some("B"), 0).unwrap();
blocks_with_cuts(&mut doc, a, 5, 2);
blocks_with_cuts(&mut doc, b, 4, 2);
blocks_with_cuts(&mut doc, 0, 3, 1);
place(&mut doc, a, rigid(Vec3::new(3.0, -7.0, 1.5), Vec3::ZERO, Vec3::Z, 0.4));
place(&mut doc, b, rigid(Vec3::new(-20.0, 5.0, 0.0), Vec3::ZERO, Vec3::X, 0.25));
let mut model = Model::new();
model.evaluate(&doc);
let names: Vec<String> = model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 12);
doc.body_components.insert(names[1].clone(), b);
doc.body_offsets.insert(names[2].clone(), rigid(Vec3::new(0.0, 0.0, 2.0), Vec3::ZERO, Vec3::Y, 0.1));
doc.body_offsets.insert(names[6].clone(), rigid(Vec3::new(1.0, 1.0, 0.0), Vec3::ZERO, Vec3::Z, -0.3));
model.evaluate(&doc);
let st = model.state();
let cuts: Vec<&Feature> = doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::Box { operation: Operation::Cut, .. })).collect();
assert_eq!(cuts.len(), 5);
for f in cuts {
let digest = placement_digest(&doc, f, Some(&st));
assert_ne!(digest, 0);
assert_eq!(digest, digest_searching_each_body(&doc, f, &st), "cut {}", f.id);
}
}
/// `comps` parts, each with `bodies` boxes, `sketches` sketches and `taps` taps, placed `copies`
/// times: a design and its state, made without evaluating it.
fn placed_parts(comps: usize, bodies: usize, sketches: usize, taps: usize, copies: usize) -> (Document, ModelState) {
let mut doc = Document::new("placed parts");
let mut one = Document::new("one sketch");
one.add_feature(line_sketch(PlaneRef::Origin { name: "XY".into() }, Vec2::new(3.0, 4.0)), None).unwrap();
let mut m = Model::new();
m.evaluate(&one);
let solved = m.state().sketches.first().unwrap().clone();
let mut st = ModelState::default();
let solid = kernel::box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
for c in 0..comps {
let comp = doc.add_component(Some(&format!("C{c}")), 0).unwrap();
let o = doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap();
o.transform = rigid(Vec3::new(10.0 * c as f64, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.01 * c as f64);
for k in 1..copies {
doc.add_occurrence(comp, 0, rigid(Vec3::new(10.0 * c as f64, 20.0 * k as f64, 0.0), Vec3::ZERO, Vec3::Z, 0.02 * k as f64)).unwrap();
}
let mut names = Vec::new();
for b in 0..bodies {
let kind =
serde_json::from_value(serde_json::json!({"type": "box", "corner": [0, 0, 0], "length": "2", "width": "3", "height": "4"})).unwrap();
let name = format!("B{c}_{b}");
let id = doc.add_feature(kind, Some(&name)).unwrap();
doc.feature_mut(id).unwrap().component = comp;
st.bodies.push(ModelBody::new(name.clone(), solid.clone(), id));
names.push((name, id));
}
for s in 0..sketches {
let id = doc.add_feature(line_sketch(PlaneRef::Origin { name: "XY".into() }, Vec2::new(1.0 + s as f64, 2.0)), None).unwrap();
doc.feature_mut(id).unwrap().component = comp;
let mut s2 = SolvedSketch::clone(&solved);
s2.feature = id;
st.sketches.push(Arc::new(s2));
}
for t in 0..taps {
let (body, feature) = names[t % names.len()].clone();
st.threads.push(ThreadInfo {
feature,
body,
designation: "M6".into(),
major_diameter: 6.0,
radius: 3.0,
pitch: 1.0,
internal: true,
handedness: Default::default(),
axis_point: Vec3::new(1.0, 1.0, 0.0),
axis: solidcraft_geom::UnitVec3::Z,
start: 0.0,
end: 4.0,
});
}
}
(doc, st)
}
#[test]
fn placing_parts_looks_each_sketch_and_tap_up_once() {
// A large assembly searched all features for every sketch and construction, and all bodies
// twice for every tap and placement, each time its world state was made (every command).
let searches = |sketches: usize, taps: usize| {
let (doc, state) = placed_parts(4, 2, sketches, taps, 3);
let mut world = ModelState::default();
let n = searched(|| world = world_state(&doc, &state));
assert_eq!(world.bodies.len(), 4 * 2 * 3);
assert_eq!(world.sketches.len(), 4 * sketches);
assert_eq!(world.threads.len(), 4 * taps * 3);
n
};
assert_eq!(searches(8, 6), searches(1, 1));
}
#[test]
fn a_placed_tap_follows_its_body_offset() {
let (mut doc, state) = placed_parts(1, 1, 0, 1, 1);
let comp = doc.components[0].id;
doc.body_offsets.insert("B0_0".into(), rigid(Vec3::new(0.0, 0.0, 7.0), Vec3::ZERO, Vec3::Z, 0.0));
let world = world_state(&doc, &state);
let tap = world.threads.first().unwrap();
let expected =
apply_point(&mat_mul(&doc.component_transform(comp), &rigid(Vec3::new(0.0, 0.0, 7.0), Vec3::ZERO, Vec3::Z, 0.0)), Vec3::new(1.0, 1.0, 0.0));
assert!(tap.axis_point.dist(expected) < 1e-9, "{:?} {expected:?}", tap.axis_point);
}
/// What adding a feature of `kind` at the end of `doc` did to the bodies already there: how many
/// its cut ran booleans on, and which it left exactly as they were (the same geometry, its name,
/// key and face names carried over). A cut that cannot reach a body hands it back as it was, and
/// must not pay a whole boolean to find that out (#10225); the sheet metal hole (#10226) and the
/// cosmetic thread (#10227) check their own shortcuts with it too.
pub(super) struct CutWork {
pub cut: usize,
pub kept: Vec<String>,
pub model: Model,
}
pub(super) fn cut_work(doc: &mut Document, kind: FeatureKind) -> CutWork {
let mut model = Model::new();
model.evaluate(doc);
let id = doc.add_feature(kind, None).unwrap();
let work = counted(|| model.evaluate(doc));
let result = model.result(id).unwrap();
assert_eq!(result.error, None, "the feature builds");
let before = model.state_before(id);
let kept = result
.state
.bodies
.iter()
.filter(|b| {
before
.body(&b.name)
.is_some_and(|old| old.body.same(&b.body) && old.key == b.key && crate::naming::face_names(old) == crate::naming::face_names(b))
})
.map(|b| b.name.clone())
.collect();
CutWork { cut: work.cut, kept, model }
}
/// A box body named `name`, `size` long on each side from `corner`.
fn block(doc: &mut Document, name: &str, corner: Vec3, size: [f64; 3]) {
let id = doc
.add_feature(
FeatureKind::Box {
corner: point_expr(corner),
length: size[0].to_string().into(),
width: size[1].to_string().into(),
height: size[2].to_string().into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
}
/// A cylinder that cuts, standing on `base` along Z.
fn cutter(base: Vec3, radius: f64, height: f64) -> FeatureKind {
FeatureKind::Cylinder {
base: point_expr(base),
axis: Dir(Vec3::Z),
radius: radius.to_string().into(),
height: height.to_string().into(),
operation: Operation::Cut,
references: Vec::new(),
}
}
/// A cut leaves the bodies its tool cannot reach as they were, without a boolean on each: in a
/// design of many bodies those booleans were most of a hole's time (#10225).
#[test]
fn a_cut_leaves_bodies_it_cannot_reach_as_they_were() {
let mut doc = Document::new("far");
block(&mut doc, "Near", Vec3::ZERO, [50.0, 40.0, 30.0]);
// 0.5 mm clear of the tool, and far off.
block(&mut doc, "Beside", Vec3::new(13.5, 0.0, 0.0), [20.0, 40.0, 30.0]);
for i in 0..5 {
block(&mut doc, &format!("Far{i}"), Vec3::new(200.0 * (i + 1) as f64, 0.0, 0.0), [50.0, 40.0, 30.0]);
}
// Near and Beside overlap; the hole goes through Near only, beside Beside.
let work = cut_work(&mut doc, cutter(Vec3::new(10.0, 20.0, -5.0), 3.0, 40.0));
assert_eq!(work.cut, 1, "only the body the tool reaches goes through the boolean");
assert_eq!(work.kept, ["Beside", "Far0", "Far1", "Far2", "Far3", "Far4"]);
assert!(volume(&work.model, "Near") < 60_000.0 - 800.0, "and it is cut");
}
/// A cut still goes through the boolean wherever its tool reaches a body or comes close: on a
/// face, along a line, a hair beside it, into a dome above its edge circle. (Bodies its tool
/// cannot reach are left out of it: `a_cut_leaves_bodies_it_cannot_reach_as_they_were`.)
#[test]
fn a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face() {
// A cylinder whose side is tangent to the face x = 50, one a hair clear of it, one 0.1 mm in.
for (x, cuts) in [(53.0, false), (53.0005, false), (52.9, true)] {
let mut doc = Document::new("graze");
block(&mut doc, "A", Vec3::ZERO, [50.0, 40.0, 30.0]);
let work = cut_work(&mut doc, cutter(Vec3::new(x, 20.0, 0.0), 3.0, 30.0));
assert_eq!(work.cut, 1, "the tool at x = {x} goes through the boolean");
let v = volume(&work.model, "A");
assert_eq!(v < 60_000.0 - 1.0, cuts, "the tool at x = {x} cuts material: {v}");
assert!(!cuts || work.kept.is_empty(), "a body cut is a new body");
}
// A box meeting the block only along its top edge.
let mut doc = Document::new("edge");
block(&mut doc, "A", Vec3::ZERO, [50.0, 40.0, 30.0]);
let along = FeatureKind::Box {
corner: point_expr(Vec3::new(50.0, 0.0, 30.0)),
length: "10".into(),
width: "40".into(),
height: "10".into(),
operation: Operation::Cut,
};
assert_eq!(cut_work(&mut doc, along).cut, 1);
// A dome: a sphere's cap rising 10 mm above the circle it stands on. A thin tool drilled into
// its top near the apex, well above that circle, cuts it.
let mut doc = Document::new("dome");
doc.add_feature(FeatureKind::Sphere { center: point_expr(Vec3::new(0.0, 0.0, -10.0)), radius: "20".into(), operation: Operation::NewBody }, None)
.unwrap();
let below = FeatureKind::Box {
corner: point_expr(Vec3::new(-30.0, -30.0, -40.0)),
length: "60".into(),
width: "60".into(),
height: "40".into(),
operation: Operation::Cut,
};
doc.add_feature(below, None).unwrap();
let mut model = Model::new();
model.evaluate(&doc);
let dome = model.state().bodies[0].name.clone();
let whole = volume(&model, &dome);
let work = cut_work(&mut doc, cutter(Vec3::new(2.0, 1.0, 8.0), 1.0, 5.0));
assert_eq!(work.cut, 1);
assert!(volume(&work.model, &dome) < whole - 1.0, "the drill cuts into the dome");
assert!(work.kept.is_empty());
}
/// A cylinder body named `name` standing on `base` along Z.
fn shaft(doc: &mut Document, name: &str, base: Vec3, radius: f64, height: f64) {
let id = doc
.add_feature(
FeatureKind::Cylinder {
base: point_expr(base),
axis: Dir(Vec3::Z),
radius: radius.to_string().into(),
height: height.to_string().into(),
operation: Operation::NewBody,
references: Vec::new(),
},
None,
)
.unwrap();
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
}
/// The work of adding `kind` at the end of `doc`, and the tap markings the design then has.
fn marking_work(doc: &mut Document, kind: FeatureKind) -> (Counts, Vec<ThreadInfo>) {
let mut model = Model::new();
model.evaluate(doc);
let id = doc.add_feature(kind, None).unwrap();
let work = counted(|| model.evaluate(doc));
assert_eq!(model.result(id).unwrap().error, None, "the feature builds");
(work, model.state().threads.clone())
}
/// A cosmetic tap marking at `p`.
fn marking(p: Vec3) -> FeatureKind {
FeatureKind::Thread { face: FacePick(p), designation: Some("M6".into()), length: None, modeled: false, handedness: ThreadHandedness::Right }
}
/// A tap marking looks for its cylinder in the bodies near it only: each body searched was
/// tessellated again, so many markings in a design of many bodies took quadratic time (#10227).
#[test]
fn a_tap_marking_searches_only_the_bodies_near_it() {
let mut doc = Document::new("far");
shaft(&mut doc, "Shaft", Vec3::ZERO, 3.0, 6.0);
for i in 0..5 {
block(&mut doc, &format!("Far{i}"), Vec3::new(200.0 * (i + 1) as f64, 0.0, 0.0), [50.0, 40.0, 30.0]);
}
let (work, threads) = marking_work(&mut doc, marking(Vec3::new(3.0, 0.0, 3.0)));
assert_eq!(work.cylinder_search, 1, "only the shaft is searched");
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].body, "Shaft");
assert_eq!(threads[0].designation, "M6");
assert!((threads[0].radius - 3.0).abs() < 1e-6 && !threads[0].internal);
// A tapped hole in a plate among far bodies: only the plate is searched for its wall.
let mut doc = Document::new("tapped");
block(&mut doc, "Plate", Vec3::ZERO, [60.0, 60.0, 12.0]);
for i in 0..5 {
block(&mut doc, &format!("Far{i}"), Vec3::new(200.0 * (i + 1) as f64, 0.0, 0.0), [50.0, 40.0, 30.0]);
}
let hole = FeatureKind::Hole {
position: FacePick(Vec3::new(30.0, 30.0, 12.0)),
at: None,
direction: Dir(-Vec3::Z),
diameter: "5".into(),
depth: Some("10".into()),
hole: HoleKind::default(),
points: None,
thread: Some("M6".into()),
thread_depth: None,
handedness: ThreadHandedness::Right,
to: None,
references: Vec::new(),
};
let (work, threads) = marking_work(&mut doc, hole);
assert_eq!(work.cylinder_search, 1, "only the plate is searched");
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].body, "Plate");
assert!(threads[0].internal && (threads[0].radius - 2.5).abs() < 1e-6);
}
/// Bodies within reach of a tap marking are still searched: the shaft it lies on, a block 3 mm
/// off (within a twentieth of the block's size, where the cylinder search itself looks), and one
/// 3.6 mm off (just past it, but within the margin the boxes keep); a block 4.5 mm off is not.
/// The marking stays on the shaft.
#[test]
fn a_tap_marking_still_searches_the_bodies_within_its_reach() {
let mut doc = Document::new("near");
shaft(&mut doc, "Shaft", Vec3::ZERO, 3.0, 6.0);
// Each block is 50 x 40 x 30 (a twentieth of its size is 3.54 mm), its face x = 3 + gap.
for (name, gap) in [("Close", 3.0), ("Edge", 3.6), ("Clear", 4.5)] {
block(&mut doc, name, Vec3::new(3.0 + gap, -20.0, 0.0), [50.0, 40.0, 30.0]);
}
let (work, threads) = marking_work(&mut doc, marking(Vec3::new(3.0, 0.0, 3.0)));
assert_eq!(work.cylinder_search, 3, "the shaft, Close and Edge are searched");
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].body, "Shaft");
// A pin in a hole a hair wider: a marking on the pin, and one on the hole's wall.
for (p, body, internal) in [(Vec3::new(2.0, 0.0, 5.0), "Pin", false), (Vec3::new(2.5, 0.0, 5.0), "Plate", true)] {
let mut doc = Document::new("pin");
block(&mut doc, "Plate", Vec3::new(-20.0, -20.0, 0.0), [40.0, 40.0, 10.0]);
let bore = FeatureKind::Cylinder {
base: point_expr(Vec3::new(0.0, 0.0, -1.0)),
axis: Dir(Vec3::Z),
radius: "2.5".into(),
height: "12".into(),
operation: Operation::Cut,
references: Vec::new(),
};
doc.add_feature(bore, None).unwrap();
shaft(&mut doc, "Pin", Vec3::ZERO, 2.0, 10.0);
let mut kind = marking(p);
if let FeatureKind::Thread { designation, .. } = &mut kind {
*designation = None;
}
let (work, threads) = marking_work(&mut doc, kind);
assert_eq!(work.cylinder_search, 2, "both bodies are searched for {body}");
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].body, body);
assert_eq!(threads[0].internal, internal);
}
}
+171 -243
View File
@@ -97,7 +97,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane, axes } => {
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane } => {
// A plane on either side is worked out now, so the bodies follow it when it moves.
let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized()));
if let Some(p) = from_plane {
@@ -126,28 +126,6 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
(Some(_), Some(n)) => n * (to - from).dot(n),
_ => to - from,
};
// Point-to-point alignment can keep only the chosen world X/Y/Z directions,
// stored in the bodies' frame. A missing list means all three (the full offset).
let d = match axes {
None => d,
Some(dirs) if dirs.is_empty() => {
return Err(DocError::Invalid("the align axes enable none of X, Y and Z".into()));
}
Some(dirs) => {
let mut kept = Vec3::ZERO;
let mut used = false;
for a in dirs {
if let Some(a) = a.0.normalized() {
kept += a * d.dot(a);
used = true;
}
}
if !used {
return Err(DocError::Invalid("the align axes have no direction".into()));
}
kept
}
};
m[3][0] += d.x;
m[3][1] += d.y;
m[3][2] += d.z;
@@ -684,7 +662,7 @@ pub(super) fn sheet_eval(
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body, sketch, profiles } => {
FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body } => {
// Without a body name the field goes on the newest sheet, as the command's own body lookup does.
let i = match body {
Some(_) => sheet_for(st, body, None)?,
@@ -706,18 +684,8 @@ pub(super) fn sheet_eval(
"perforation needs diameter > 0, pitch > diameter and margin >= 0 (diameter {d}, pitch {pitch}, margin {margin})"
)));
}
// The outline sketch's profiles, where they lie on the sheet now.
let outline = match sketch {
Some(sk) => {
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?;
let regions = select_profiles(ss, profiles.as_ref().unwrap_or(&ProfileSel::All))?;
let sh = st.sheets.get(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
Some(sh.perforation_outline(&ss.plane, &regions)?)
}
None => None,
};
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false, outline });
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false });
Ok(())
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
@@ -812,10 +780,7 @@ pub(super) fn sheet_eval(
hit.point
}
Some((n, Err(_))) => {
append_warning(
warning,
format!("edge `{}` no longer exists; using the edge at the picked point", crate::name_lookup::quoted(n)),
);
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
p.0
}
None => p.0,
@@ -856,12 +821,12 @@ pub(super) fn sheet_eval(
// (its outer level, `hi`) with z into the material, as with bend edges, so the sheet
// takes the plate's place: its thickness runs from `hi` back to `lo`.
let plane = Plane::from_normal(face.0 + n * hi, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
let slice = FaceSlice { mesh: &mesh, plane, at: face.0, n, tol };
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
let recesses = slice.recesses(&mb.body, (lo, hi))?;
let recesses = plate_recesses(&mb.body, &mesh, &plane, face.0, n, (lo, hi), tol)?;
let mut walls = std::collections::HashMap::new();
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f));
let mut loops: Vec<Loop2> = slice.outline(recesses.level).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
let mut loops: Vec<Loop2> =
face_outline(&mesh, &plane, face.0, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw();
@@ -1569,13 +1534,13 @@ fn sheet_prism_cut(
/// A box around `body`, grown past its mesh's deflection from the exact faces (and a hair
/// more): bodies whose boxes are apart do not meet. None when it can't be worked out.
pub(super) fn body_box(body: &Body) -> Option<Aabb3> {
fn body_box(body: &Body) -> Option<Aabb3> {
let tol = (body.size() * 1e-3).max(1e-4);
padded_box(&body.tessellate(tol).ok()?, tol)
}
/// [`body_box`] of a model body, from the mesh it keeps.
pub(super) fn mesh_box(mb: &ModelBody) -> Option<Aabb3> {
fn mesh_box(mb: &ModelBody) -> Option<Aabb3> {
padded_box(&mb.mesh(), mb.display_tol())
}
@@ -1584,25 +1549,8 @@ fn padded_box(mesh: &solidcraft_geom::Mesh, tol: f64) -> Option<Aabb3> {
(!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| b.inflated(2.0 * tol + 1e-6))
}
/// Whether [`kernel::cylinder_face_at`] is known to find no face of `mb` at `p`. It takes a
/// face only when its own mesh of the body comes within a twentieth of the body's size of the
/// point. That mesh lies in the body's box ([`mesh_box`]) give or take its own deflection, which
/// the reach allows for, so a box farther away than that holds no face for it. Working that out from the mesh the body keeps
/// spares a tessellation of every body in the design for each tap marking (#10227). A mesh
/// body, and a body whose box is unknown, are searched as before.
pub(super) fn beyond_cylinder_reach(mb: &ModelBody, p: Vec3) -> bool {
if mb.body.is_mesh() {
return false;
}
let Some(b) = mesh_box(mb) else { return false };
let size = mb.body.size();
let reach = size * 0.05 + 2.0 * (size * 1e-3).max(1e-3);
let off = (b.min - p).max(p - b.max).max(Vec3::ZERO);
off.len() > reach
}
/// Whether two boxes are known to be apart (an unknown box is never apart).
pub(super) fn boxes_apart(a: &Option<Aabb3>, b: &Option<Aabb3>) -> bool {
fn boxes_apart(a: &Option<Aabb3>, b: &Option<Aabb3>) -> bool {
let (Some(a), Some(b)) = (a, b) else { return false };
a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z
}
@@ -1636,58 +1584,43 @@ fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::Shee
})
}
/// The face that [`FaceSlice::loops`], [`FaceSlice::outline`] and [`FaceSlice::recesses`] measure:
/// the mesh, the plane the outline's coordinates live in, the point `at` and normal `n` that levels
/// are measured from, and the tolerance. Callers build it once and pass it on unchanged.
/// (`n` stays a `Vec3` until #2675 lands `UnitVec3` upstream.)
struct FaceSlice<'a> {
mesh: &'a Mesh,
plane: Plane,
at: Vec3,
n: Vec3,
tol: f64,
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Ring<Vec2>> {
face_outline(mesh, plane, at, n, level, tol).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
}
impl FaceSlice<'_> {
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn loops(&self, level: f64) -> Vec<Ring<Vec2>> {
self.outline(level).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
}
/// [`FaceSlice::loops`] with each point's B-rep face across the outline from it to the next
/// point (the wall the outline runs along there): the face off the plane that meets both points.
fn outline(&self, level: f64) -> Vec<Vec<(Vec2, Option<u32>)>> {
let (mesh, plane, at, n, tol) = (self.mesh, &self.plane, self.at, self.n, self.tol);
let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol));
let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect();
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
// The faces off the plane at each point on it, by position to a millionth of a millimetre
// either way (a neighbouring face's mesh may round a shared point differently).
let mut touch: std::collections::HashMap<(i64, i64, i64), Vec<u32>> = Default::default();
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face).filter(|(t, _)| !on_plane(t)) {
for p in t.iter().map(|k| pos(*k)).filter(|p| ((*p - at).dot(n) - level).abs() < tol) {
let faces = touch.entry(q(p)).or_default();
if !faces.contains(f) {
faces.push(*f);
}
/// [`face_loops`] with each point's B-rep face across the outline from it to the next point (the
/// wall the outline runs along there): the face off the plane that meets both points.
fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<(Vec2, Option<u32>)>> {
let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol));
let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect();
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
// The faces off the plane at each point on it, by position to a millionth of a millimetre
// either way (a neighbouring face's mesh may round a shared point differently).
let mut touch: std::collections::HashMap<(i64, i64, i64), Vec<u32>> = Default::default();
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face).filter(|(t, _)| !on_plane(t)) {
for p in t.iter().map(|k| pos(*k)).filter(|p| ((*p - at).dot(n) - level).abs() < tol) {
let faces = touch.entry(q(p)).or_default();
if !faces.contains(f) {
faces.push(*f);
}
}
let near = |p: Vec3| {
let (x, y, z) = q(p);
let cells = (-1..=1).flat_map(|i| (-1..=1).flat_map(move |j| (-1..=1).map(move |k| (x + i, y + j, z + k))));
let mut faces: Vec<u32> = cells.filter_map(|c| touch.get(&c)).flatten().copied().collect();
faces.sort_unstable();
faces.dedup();
faces
};
let across = |a: Vec3, b: Vec3| {
let at_b = near(b);
near(a).into_iter().find(|f| at_b.contains(f))
};
chain_loops(mesh, plane, &on, across)
}
let near = |p: Vec3| {
let (x, y, z) = q(p);
let cells = (-1..=1).flat_map(|i| (-1..=1).flat_map(move |j| (-1..=1).map(move |k| (x + i, y + j, z + k))));
let mut faces: Vec<u32> = cells.filter_map(|c| touch.get(&c)).flatten().copied().collect();
faces.sort_unstable();
faces.dedup();
faces
};
let across = |a: Vec3, b: Vec3| {
let at_b = near(b);
near(a).into_iter().find(|f| at_b.contains(f))
};
chain_loops(mesh, plane, &on, across)
}
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh.
@@ -1777,142 +1710,138 @@ struct LevelFace {
tris: Vec<(Vec3, f64)>,
}
impl FaceSlice<'_> {
/// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`),
/// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its
/// thickness, lying in an opening of the face it is cut into, and its walls are square to the
/// plate (walls that are not show as material the plate's volume doesn't account for). A body
/// that is no plate once its recesses are filled has none, so the plate check refuses it as
/// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face
/// only, up to half the thickness deep, and no larger than the face they are cut into (a plate
/// raised in places is no plate of the raised thickness).
fn recesses(&self, body: &Body, (lo, hi): (f64, f64)) -> Result<Recesses> {
let (mesh, plane, at, n, tol) = (self.mesh, &self.plane, self.at, self.n, self.tol);
let t = hi - lo;
let eps = t * 1e-3;
let level = |p: Vec3| (p - at).dot(n);
let mut faces: BTreeMap<u32, Option<LevelFace>> = BTreeMap::new();
for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some([a, b, c]) = mesh.tri(tri) else { continue };
let cr = (b - a).cross(c - a);
let len = cr.len();
if !(len > 1e-12) {
continue;
}
let up = cr.dot(n) > 0.0;
let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() }));
match entry {
Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => {
for p in [a, b, c] {
face.lo = face.lo.min(level(p));
face.hi = face.hi.max(level(p));
}
face.area += len / 2.0;
face.tris.push(((a + b + c) / 3.0, len / 2.0));
/// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`),
/// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its
/// thickness, lying in an opening of the face it is cut into, and its walls are square to the
/// plate (walls that are not show as material the plate's volume doesn't account for). A body
/// that is no plate once its recesses are filled has none, so the plate check refuses it as
/// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face
/// only, up to half the thickness deep, and no larger than the face they are cut into (a plate
/// raised in places is no plate of the raised thickness).
fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (lo, hi): (f64, f64), tol: f64) -> Result<Recesses> {
let t = hi - lo;
let eps = t * 1e-3;
let level = |p: Vec3| (p - at).dot(n);
let mut faces: BTreeMap<u32, Option<LevelFace>> = BTreeMap::new();
for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some([a, b, c]) = mesh.tri(tri) else { continue };
let cr = (b - a).cross(c - a);
let len = cr.len();
if !(len > 1e-12) {
continue;
}
let up = cr.dot(n) > 0.0;
let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() }));
match entry {
Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => {
for p in [a, b, c] {
face.lo = face.lo.min(level(p));
face.hi = face.hi.max(level(p));
}
_ => *entry = None,
face.area += len / 2.0;
face.tris.push(((a + b + c) / 3.0, len / 2.0));
}
_ => *entry = None,
}
// The floors (face index, level, the face), and the areas of the plate's two faces (at `lo`
// and at `hi`).
let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new();
let (mut bottom, mut top) = (0.0, 0.0);
for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) {
let d = (face.lo + face.hi) / 2.0;
if face.hi - face.lo > eps {
continue;
} else if (d - hi).abs() <= eps && face.up {
top += face.area;
} else if (d - lo).abs() <= eps && !face.up {
bottom += face.area;
} else if d > lo + eps && d < hi - eps {
floors.push((f, d, face));
}
}
let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 };
if floors.is_empty() {
return Ok(none());
}
// A floor is a recess's where (most of) it lies in an opening of the face it is cut into:
// inside that face's outline and in a hole of it, not on an island in the hole. (Its
// triangles are those at its level within `eps`: `tol` may exceed a recess's depth.)
let probe = FaceSlice { mesh, plane: self.plane, at, n, tol: eps };
let loops_at = |level: f64| -> Vec<Loop2> { probe.loops(level).into_iter().map(|l| Loop2::polygon(&l)).collect() };
let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo));
floors.retain(|(_, _, face)| {
let loops = if face.up { &top_loops } else { &bottom_loops };
let open = |p: Vec2| {
let k = loops.iter().filter(|l| l.contains(p)).count();
k >= 2 && k.is_multiple_of(2)
};
face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::<f64>() > face.area / 2.0
});
let Some(&(_, _, first)) = floors.first() else { return Ok(none()) };
// Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their
// openings count, and its volume with theirs the outline times the thickness. Else (a bent
// part) they are none.
let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo };
let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::<f64>();
let outline = bottom + opened(false);
let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum();
let volume = kernel::measure(body)?.volume;
if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) {
return Ok(none());
}
let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
let up = first.up;
if let Some(other) = floors.iter().find(|f| f.2.up != up) {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets",
pt(first.at),
pt(other.2.at)
)));
}
let depth_of = |d: f64| depth_in(d, up);
if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) {
return Err(DocError::Invalid(format!(
"not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets",
pt(deep.2.at),
depth_of(deep.1),
t
)));
}
let face_area = if up { top } else { bottom };
if floors.iter().map(|f| f.2.area).sum::<f64>() > face_area {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)"
)));
}
// The floors' outlines, from a mesh fine enough to mark them by.
let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?;
floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1)));
// Floors at one depth are one pocket where they meet.
let mut groups: Vec<(f64, Vec<Region2>)> = Vec::new();
for (f, d, ..) in &floors {
let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect();
let mut loops = boundary_loops(&fine, plane, &on);
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let Some(outline) = it.next() else { continue };
let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
match groups.last_mut() {
Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region),
_ => groups.push((depth_of(*d), vec![region])),
}
}
// The sheet's z starts from the picked face when its plane faces away from that face.
let from_zero = up == (plane.normal().dot(n) < 0.0);
let mut work = sheet::FlatWork::new();
let (mut pockets, mut volume) = (Vec::new(), 0.0);
for (depth, regions) in groups {
let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) };
for region in sheet::material_union(regions, &mut work)? {
volume += region.area().abs() * depth;
pockets.push((region, z0, z1));
}
}
Ok(Recesses { level: if up { lo } else { hi }, pockets, volume })
}
// The floors (face index, level, the face), and the areas of the plate's two faces (at `lo`
// and at `hi`).
let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new();
let (mut bottom, mut top) = (0.0, 0.0);
for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) {
let d = (face.lo + face.hi) / 2.0;
if face.hi - face.lo > eps {
continue;
} else if (d - hi).abs() <= eps && face.up {
top += face.area;
} else if (d - lo).abs() <= eps && !face.up {
bottom += face.area;
} else if d > lo + eps && d < hi - eps {
floors.push((f, d, face));
}
}
let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 };
if floors.is_empty() {
return Ok(none());
}
// A floor is a recess's where (most of) it lies in an opening of the face it is cut into:
// inside that face's outline and in a hole of it, not on an island in the hole. (Its
// triangles are those at its level within `eps`: `tol` may exceed a recess's depth.)
let loops_at = |level: f64| -> Vec<Loop2> { face_loops(mesh, plane, at, n, level, eps).into_iter().map(|l| Loop2::polygon(&l)).collect() };
let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo));
floors.retain(|(_, _, face)| {
let loops = if face.up { &top_loops } else { &bottom_loops };
let open = |p: Vec2| {
let k = loops.iter().filter(|l| l.contains(p)).count();
k >= 2 && k.is_multiple_of(2)
};
face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::<f64>() > face.area / 2.0
});
let Some(&(_, _, first)) = floors.first() else { return Ok(none()) };
// Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their
// openings count, and its volume with theirs the outline times the thickness. Else (a bent
// part) they are none.
let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo };
let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::<f64>();
let outline = bottom + opened(false);
let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum();
let volume = kernel::measure(body)?.volume;
if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) {
return Ok(none());
}
let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
let up = first.up;
if let Some(other) = floors.iter().find(|f| f.2.up != up) {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets",
pt(first.at),
pt(other.2.at)
)));
}
let depth_of = |d: f64| depth_in(d, up);
if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) {
return Err(DocError::Invalid(format!(
"not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets",
pt(deep.2.at),
depth_of(deep.1),
t
)));
}
let face_area = if up { top } else { bottom };
if floors.iter().map(|f| f.2.area).sum::<f64>() > face_area {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)"
)));
}
// The floors' outlines, from a mesh fine enough to mark them by.
let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?;
floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1)));
// Floors at one depth are one pocket where they meet.
let mut groups: Vec<(f64, Vec<Region2>)> = Vec::new();
for (f, d, ..) in &floors {
let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect();
let mut loops = boundary_loops(&fine, plane, &on);
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let Some(outline) = it.next() else { continue };
let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
match groups.last_mut() {
Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region),
_ => groups.push((depth_of(*d), vec![region])),
}
}
// The sheet's z starts from the picked face when its plane faces away from that face.
let from_zero = up == (plane.normal().dot(n) < 0.0);
let mut work = sheet::FlatWork::new();
let (mut pockets, mut volume) = (Vec::new(), 0.0);
for (depth, regions) in groups {
let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) };
for region in sheet::material_union(regions, &mut work)? {
volume += region.area().abs() * depth;
pockets.push((region, z0, z1));
}
}
Ok(Recesses { level: if up { lo } else { hi }, pockets, volume })
}
/// The body a placed plastic feature joins: by name, else the one under the point.
@@ -2104,8 +2033,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let plane = Plane::from_normal(face.0, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
let tol = (mb.body.size() * 1e-3).max(1e-3);
let mesh = mb.body.tessellate(tol)?;
let slice = FaceSlice { mesh: &mesh, plane, at: face.0, n, tol };
let mut loops = slice.loops(0.0);
let mut loops = face_loops(&mesh, &plane, face.0, n, 0.0, tol);
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let ccw = |l: &Ring<Vec2>| if l.signed_area() < 0.0 { l.iter().rev().copied().collect::<Ring<_>>() } else { l.clone() };
// The inner edge is an opening (the cavity under a rim): not the foot of a boss or a
+24 -165
View File
@@ -161,12 +161,6 @@ pub const FUNCTIONS: &[(&str, &str)] = &[
("ceil", "ceil(x)"),
("round", "round(x)"),
("exp", "exp(x)"),
("sinh", "sinh(x)"),
("cosh", "cosh(x)"),
("tanh", "tanh(x)"),
("asinh", "asinh(x)"),
("acosh", "acosh(x), x ≥ 1"),
("atanh", "atanh(x), −1 < x < 1"),
("ln", "ln(x)"),
("log", "log(x) (base 10)"),
("log10", "log10(x)"),
@@ -304,28 +298,6 @@ pub fn value_in_unit(v: Value, unit: &str) -> f64 {
}
}
/// The number a reply reports for a value: millimetres for a length, degrees for an angle,
/// as-is for a unit-less or compound value. One meaning of `value` in every reply (#10747).
pub fn reported_value(v: Value) -> f64 {
match v.kind() {
Some(Kind::Angle) => v.v.to_degrees(),
_ => v.v,
}
}
/// The number a person would read for `v`: the shortest decimal that reads back after
/// rounding off the last few bits (12 significant digits), so `30 deg` stored as
/// 29.999999999999996 reports 30 and `0.1 + 0.2` reports 0.3. Only what is *reported*
/// is rounded; stored values and evaluation keep full precision (#10747).
pub fn round_reported(v: f64) -> f64 {
if !v.is_finite() {
return v;
}
// `:.11e` keeps 12 significant digits; parsing back gives the f64 nearest that decimal,
// which serializes as the shortest form that reads back to it.
format!("{v:.11e}").parse().unwrap_or(v)
}
fn unit(name: &str) -> Option<Value> {
UNITS.iter().find(|(n, _, _)| *n == name).map(|(_, k, s)| match k {
Kind::Angle => Value::angle(*s),
@@ -372,9 +344,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len());
let mut i = 0;
let mut out = Vec::new();
// Parentheses open at this point, innermost last, true for a function call's: a comma directly
// in a call separates its arguments, and anywhere else can only be a decimal comma (#9408).
let mut parens: Vec<bool> = Vec::new();
// Parentheses open at this point: a comma outside them can only be a decimal comma.
let mut depth = 0usize;
// A single leading `=` marks the text as a formula (`=d3`, #4681) and is not part of it.
if let Some(k) = cs.iter().position(|(_, c)| !c.is_whitespace())
&& cs.get(k).is_some_and(|(_, c)| *c == '=')
@@ -392,7 +363,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
while let Some(&(_, c)) = cs.get(i) {
if c.is_whitespace() {
i += 1;
} else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, in_call(&parens), out.last()) {
} else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, depth, out.last()) {
let st = i;
// A leading decimal comma (`,5` for 0,5, #2966) starts the number as `.5` would.
let leading_comma = c == ',';
@@ -403,17 +374,11 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
i += 1;
}
// A decimal comma (`2,5 mm`, as typed with a numeric keypad in many locales, #1347):
// a comma between digits, in a number with no point, not directly in a function call
// (there a comma separates arguments: `max(1,2)`).
// a comma between digits, in a number with no point, outside any parentheses (inside
// them a comma separates a function's arguments: `max(1,2)`).
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
if !in_call(&parens)
&& !leading_comma
&& !has_point
&& digit(i.wrapping_sub(1))
&& cs.get(i).is_some_and(|(_, c)| *c == ',')
&& digit(i + 1)
{
if depth == 0 && !leading_comma && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
i += 1;
while digit(i) {
i += 1;
@@ -435,7 +400,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
// A mixed number, as imperial sizes are written (`2 1/8"` is 2.125 in, #1802): a whole
// number, then a proper fraction of whole numbers, is one value, not `2 × 1/8`.
if t.chars().all(|c| c.is_ascii_digit())
&& let Some((fraction, end)) = mixed_fraction(&cs, i, in_call(&parens))
&& let Some((fraction, end)) = mixed_fraction(&cs, i, depth)
{
out.push((Tok::Num(v + fraction), at(st), at(end)));
i = end;
@@ -473,10 +438,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
i += 1;
} else if "+-*/^(),%".contains(c) {
match c {
'(' => parens.push(matches!(out.last(), Some((Tok::Id(_), _, _)))),
')' => {
parens.pop();
}
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ => {}
}
out.push((Tok::Op(c), at(i), at(i + 1)));
@@ -488,32 +451,27 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
Ok(out)
}
/// Whether the innermost open parenthesis is a function call's.
fn in_call(parens: &[bool]) -> bool {
parens.last() == Some(&true)
}
/// Whether the comma at `i` starts a number, as a leading decimal comma (`,5` for 0,5, #2966).
/// It must not be directly in a function call (`in_call`: there a comma separates arguments, as
/// for any decimal comma), be followed by a digit, and stand where an operand is expected: at the
/// start or right after `( + - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning
/// (`2 ,5` and `1,5,3` stay errors).
fn leading_decimal_comma(cs: &[(usize, char)], i: usize, in_call: bool, prev: Option<&(Tok, usize, usize)>) -> bool {
!in_call
/// It must be outside parentheses (inside them a comma separates arguments, as for any decimal
/// comma), be followed by a digit, and stand where an operand is expected: at the start or right
/// after `+ - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning (`2 ,5` and
/// `1,5,3` stay errors).
fn leading_decimal_comma(cs: &[(usize, char)], i: usize, depth: usize, prev: Option<&(Tok, usize, usize)>) -> bool {
depth == 0
&& cs.get(i).is_some_and(|(_, c)| *c == ',')
&& cs.get(i + 1).is_some_and(|(_, c)| c.is_ascii_digit())
&& match prev {
None => true,
Some((Tok::Op(op), _, _)) => "(+-*/^".contains(*op),
Some((Tok::Op(op), _, _)) => "+-*/^".contains(*op),
Some(_) => false,
}
}
/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`,
/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a
/// decimal comma: `2 1/8,5`). Its value and where it ends. `in_call`: directly in a function call
/// there (a comma separates arguments: `max(2 1/8,1)`).
fn mixed_fraction(cs: &[(usize, char)], mut i: usize, in_call: bool) -> Option<(f64, usize)> {
/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the
/// parentheses open there (inside them a comma separates arguments: `max(2 1/8,1)`).
fn mixed_fraction(cs: &[(usize, char)], mut i: usize, depth: usize) -> Option<(f64, usize)> {
let digits = |from: usize| cs.get(from..).map_or(0, |rest| rest.iter().take_while(|(_, c)| c.is_ascii_digit()).count());
let space = i;
while cs.get(i).is_some_and(|(_, c)| *c == ' ' || *c == '\t') {
@@ -527,7 +485,7 @@ fn mixed_fraction(cs: &[(usize, char)], mut i: usize, in_call: bool) -> Option<(
let end = i + numerator + 1 + denominator;
let next = |k: usize| cs.get(end + k).map(|(_, c)| *c);
let continues = matches!(next(0), Some('.'))
|| (!in_call && matches!(next(0), Some(',')) && next(1).is_some_and(|c| c.is_ascii_digit()))
|| (depth == 0 && matches!(next(0), Some(',')) && next(1).is_some_and(|c| c.is_ascii_digit()))
|| (matches!(next(0), Some('e' | 'E')) && next(1).is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '+'));
if denominator == 0 || continues {
return None;
@@ -631,11 +589,9 @@ pub fn is_literal(s: &str) -> bool {
}
}
/// Is the expression a bare number without a unit, like `20` or `-1.5`? A leading `=` only marks
/// a formula (#4681), so `=20` is the same bare number.
/// Is the expression a bare number without a unit, like `20` or `-1.5`?
pub fn is_bare_number(s: &str) -> bool {
let number = s.trim().strip_prefix('=').unwrap_or(s);
is_literal(s) && number.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
is_literal(s) && s.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
}
/// Is `s` shaped like a parameter name: a letter or `_`, then letters, digits and `_`? The shape
@@ -972,11 +928,7 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
let a = one()?;
unit_fn(a.v.abs(), a)
}
"sign" => {
let x = one()?.v;
// `f64::signum` gives 1 for +0 and -1 for -0; the sign of zero is 0.
Value::num(if x == 0.0 { 0.0 } else { x.signum() })
}
"sign" => Value::num(one()?.v.signum()),
"floor" | "ceil" | "round" | "trunc" => {
let a = one()?;
// Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter).
@@ -991,24 +943,6 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
unit_fn(r * if scale != 0.0 { scale } else { 1.0 }, a)
}
"exp" => Value::num(num(one()?)?.exp()),
"sinh" => Value::num(num(one()?)?.sinh()),
"cosh" => Value::num(num(one()?)?.cosh()),
"tanh" => Value::num(num(one()?)?.tanh()),
"asinh" => Value::num(num(one()?)?.asinh()),
"acosh" => {
let x = num(one()?)?;
if !(x >= 1.0) {
return Err(DocError::Expr("`acosh` needs a number of at least 1".into()));
}
Value::num(x.acosh())
}
"atanh" => {
let x = num(one()?)?;
if !(x > -1.0 && x < 1.0) {
return Err(DocError::Expr("`atanh` needs a number between -1 and 1, both excluded".into()));
}
Value::num(x.atanh())
}
"ln" | "log" | "log10" => {
let x = num(one()?)?;
if x <= 0.0 {
@@ -1280,13 +1214,7 @@ mod tests {
// Inside parentheses a comma still separates a function's arguments.
assert_eq!(ev("max(1,2)", Kind::Unitless).unwrap(), 2.0);
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
// Plain parentheses have no arguments to separate (#9408), also inside a call.
assert_eq!(ev("(2,5)", Kind::Unitless).unwrap(), 2.5);
assert_eq!(ev("-(width-0,5)/2", Kind::Length).unwrap(), -19.75);
assert_eq!(ev("(,5)", Kind::Unitless).unwrap(), 0.5);
assert_eq!(ev("(2 1/8,5)", Kind::Unitless).unwrap(), 2.0 / 8.5);
assert_eq!(ev("max((1,5), 1)", Kind::Unitless).unwrap(), 1.5);
assert_eq!(ev("(max(1,2))", Kind::Unitless).unwrap(), 2.0);
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
// Not between two digits, or a second comma: still an error.
for bad in ["2,", "2 ,5", "2, 5", "1,5,3", "1.5,3", ",5,3", ",5.3", "width ,5", "(1) ,5"] {
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
@@ -1400,15 +1328,6 @@ mod tests {
assert!((ev("atan2(1; 1)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
assert!((ev("exp(ln(5))", Kind::Unitless).unwrap() - 5.0).abs() < 1e-12);
assert!((ev("log(1000)", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12);
// Hyperbolic functions (#9332): their definitions in exp, identities and inverses.
assert!((ev("cosh(1.5) - (exp(1.5) + exp(-1.5)) / 2", Kind::Unitless).unwrap()).abs() < 1e-12);
assert!((ev("sinh(1.5) - (exp(1.5) - exp(-1.5)) / 2", Kind::Unitless).unwrap()).abs() < 1e-12);
assert!((ev("tanh(0.7) - sinh(0.7) / cosh(0.7)", Kind::Unitless).unwrap()).abs() < 1e-12);
assert!((ev("cosh(2) ^ 2 - sinh(2) ^ 2", Kind::Unitless).unwrap() - 1.0).abs() < 1e-12);
assert!((ev("asinh(sinh(1.25))", Kind::Unitless).unwrap() - 1.25).abs() < 1e-12);
assert!((ev("acosh(cosh(1.25))", Kind::Unitless).unwrap() - 1.25).abs() < 1e-12);
assert!((ev("atanh(tanh(0.5))", Kind::Unitless).unwrap() - 0.5).abs() < 1e-12);
assert_eq!(ev("cosh(0) + sinh(0) + tanh(0) + asinh(0) + acosh(1) + atanh(0)", Kind::Unitless).unwrap(), 1.0);
assert_eq!(ev("floor(2.7) + ceil(0.2) + round(1.5) + abs(-1)", Kind::Unitless).unwrap(), 6.0);
assert!((ev("1 rad", Kind::Angle).unwrap() - 1.0).abs() < 1e-12);
assert!((ev("acos(0)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
@@ -1418,32 +1337,6 @@ mod tests {
assert_eq!(ev("width^2 / width", Kind::Length).unwrap(), 40.0);
}
/// The hyperbolic functions (#9332) take a unit-less number, and the inverses refuse what lies
/// outside their domain, as `asin` and `ln` do.
#[test]
fn hyperbolic_functions_refuse_units_and_numbers_outside_their_domain() {
for (e, says) in [
("cosh(2 mm)", "`cosh` needs a unit-less number"),
("tanh(30 deg)", "`tanh` needs a unit-less number"),
("acosh(0.5)", "`acosh` needs a number of at least 1"),
("atanh(1)", "`atanh` needs a number between -1 and 1"),
("atanh(-1.5)", "`atanh` needs a number between -1 and 1"),
] {
let err = ev(e, Kind::Unitless).map(|v| v.to_string()).unwrap_or_else(|e| e.to_string());
assert!(err.contains(says), "{e}: {err}");
}
}
/// `sign(0)` is 0, not 1 (Rust's `signum` takes the sign bit, so it calls zero positive).
#[test]
fn sign_of_zero_is_zero() {
for (e, want) in
[("sign(0)", 0.0), ("sign(-0)", 0.0), ("sign(0 mm)", 0.0), ("sign(5)", 1.0), ("sign(-2 mm)", -1.0), ("sign(width - 40)", 0.0)]
{
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
}
}
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
#[test]
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {
@@ -1556,8 +1449,6 @@ mod tests {
assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg"));
assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width"));
assert!(is_bare_number(" -1.5 ") && is_bare_number("+2") && !is_bare_number("20 mm") && !is_bare_number("(2)"));
// A leading `=` only marks a formula (#4681): `=5` is the bare number `5`, not `==5`.
assert!(is_bare_number("=5") && is_bare_number(" = -1.5") && !is_bare_number("=5 mm") && !is_bare_number("==5") && !is_bare_number("=d3"));
assert_eq!((length_unit_or_mm(" in "), length_unit_or_mm("deg"), length_unit_or_mm("")), ("in", "mm", "mm"));
}
@@ -1673,36 +1564,4 @@ mod tests {
defs[2].expr = "bad +".into();
assert!(eval_params(&defs, 1.0).1.contains_key("c"));
}
/// #10747: reported numbers are the shortest decimals that read back after rounding off
/// the last few bits (12 significant digits); stored values keep full precision.
#[test]
fn round_reported_reads_as_a_person_would() {
for (noisy, read) in [
(30f64.to_radians().to_degrees(), 30.0),
(0.1 + 0.2, 0.3),
(3.0 * 25.4, 76.2),
(1.2 * 25.4, 30.48),
(29.999999999999996, 30.0),
(0.30000000000000004, 0.3),
(-0.1 - 0.2, -0.3),
] {
assert_eq!(round_reported(noisy), read, "{noisy}");
}
// A number with real digits of its own keeps them up to 12 significant digits.
assert_eq!(round_reported(1.234567890123), 1.23456789012);
assert_eq!(round_reported(1.0000000000001), 1.0);
// Zero and non-finite values pass through.
assert_eq!(round_reported(0.0), 0.0);
assert!(round_reported(f64::INFINITY).is_infinite());
assert!(round_reported(f64::NAN).is_nan());
}
/// #10747: `value` means millimetres and degrees in every reply.
#[test]
fn reported_value_is_mm_and_degrees() {
assert_eq!(reported_value(Value::length(50.8)), 50.8);
assert_eq!(round_reported(reported_value(Value::angle(30f64.to_radians()))), 30.0);
assert_eq!(reported_value(Value::num(4.0)), 4.0);
}
}

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