Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
626fe6fdee | ||
|
|
01f2c78cd1 | ||
|
|
9ca7d307a4 | ||
|
|
2f91338d3d | ||
|
|
dfbf7dd6a8 | ||
|
|
cf9d7772f1 | ||
|
|
6e84c03946 | ||
|
|
3669d4b3d0 | ||
|
|
345c0882cd | ||
|
|
18347b3d0d | ||
|
|
ac84491638 | ||
|
|
6a67cc8f1e | ||
|
|
6cb3025b72 | ||
|
|
b34d45233d | ||
|
|
47a7f29485 | ||
|
|
9c51f9d433 | ||
|
|
4687e3b073 | ||
|
|
1ff16cc72b | ||
|
|
813bee8914 | ||
|
|
482abb4562 | ||
|
|
7bed28752e | ||
|
|
ea23401710 | ||
|
|
9f76cca8b4 | ||
|
|
d5ff5ea1a5 | ||
|
|
ce902be66c | ||
|
|
63095fedb3 | ||
|
|
00fdcf3e72 | ||
|
|
774a8b1d9a | ||
|
|
97ff7578c2 | ||
|
|
19bf854dc9 | ||
|
|
f204042bf2 | ||
|
|
4bea4c7785 | ||
|
|
09b5da3e9f | ||
|
|
a2c7396160 | ||
|
|
45f92277cb | ||
|
|
587cf865b8 | ||
|
|
c1c813e572 | ||
|
|
7498e3b367 | ||
|
|
f037c92370 | ||
|
|
2b70ed7b72 | ||
|
|
15b5b51bf5 | ||
|
|
abddd67d22 | ||
|
|
78d9aa0153 | ||
|
|
17db109c5d | ||
|
|
d2e409f40c | ||
|
|
8746376ee9 | ||
|
|
5808bcc0f0 | ||
|
|
d23593d3eb | ||
|
|
897b7d93b5 | ||
|
|
7c85970bfc | ||
|
|
8fb5742706 | ||
|
|
aba57f1ca2 | ||
|
|
57d738eac4 | ||
|
|
3f1c023563 | ||
|
|
7da7bbb335 | ||
|
|
2eb292dd4a | ||
|
|
b41ad23e3f | ||
|
|
2dcfea8df7 | ||
|
|
7135407268 | ||
|
|
5f7ce80a93 | ||
|
|
fdaf57daa3 | ||
|
|
7eac1de9ee | ||
|
|
fe07f79387 | ||
|
|
b52d4390a0 | ||
|
|
c616f50bd0 | ||
|
|
6c61442bb2 | ||
|
|
c333889fc0 | ||
|
|
648f0ff768 | ||
|
|
eaab205302 | ||
|
|
eea49922bd | ||
|
|
dd7790a43f | ||
|
|
fe3717f2e4 | ||
|
|
d9237d004e | ||
|
|
d8d54ded0d | ||
|
|
95d394dcab | ||
|
|
7ee351dc91 | ||
|
|
9297f81501 | ||
|
|
2afc10fbd5 | ||
|
|
5c98c1e24a | ||
|
|
0040ce6bf2 | ||
|
|
1f7f37d1ef | ||
|
|
96f6424aec | ||
|
|
3d3f9b626f | ||
|
|
c3ebe13991 | ||
|
|
b69cd2d0b7 | ||
|
|
753b0a2eab | ||
|
|
b702f0efde | ||
|
|
152418b624 | ||
|
|
e99b3bd4e8 | ||
|
|
8319c35a32 | ||
|
|
c3e0ea498a | ||
|
|
b6e3a6c366 | ||
|
|
4d8c80a86d | ||
|
|
ec5d7f9789 | ||
|
|
69050c81d9 | ||
|
|
53238fba5f | ||
|
|
5d661fa2af | ||
|
|
ff7a347013 | ||
|
|
534393ed5a | ||
|
|
200a83a629 | ||
|
|
d302aa1a00 | ||
|
|
9184994721 | ||
|
|
d2676da789 | ||
|
|
6a6a7e2865 | ||
|
|
3ea8562c5e | ||
|
|
e540832626 | ||
|
|
8dd583930e | ||
|
|
9bb18842e9 | ||
|
|
9cabc3532d | ||
|
|
bdcfe3ff1d | ||
|
|
dbfb689f6c | ||
|
|
17b4b9a32c | ||
|
|
c8d668108a | ||
|
|
dd084ec9e1 | ||
|
|
bfbf5f9432 | ||
|
|
facff1050e | ||
|
|
de1479029d | ||
|
|
16766bdbc7 | ||
|
|
d4fd760b0d | ||
|
|
d437c21368 | ||
|
|
a8b3b21fd0 | ||
|
|
be55a49b04 | ||
|
|
293d660d6c | ||
|
|
beca7eb126 | ||
|
|
1a0100cdd9 | ||
|
|
5267c96ab2 | ||
|
|
159f9913c1 | ||
|
|
dfa9e094cd | ||
|
|
8cdefd4cd5 | ||
|
|
7b0a1b296c | ||
|
|
25a3dc501b | ||
|
|
a3dfbaa0b0 | ||
|
|
58af8accfa | ||
|
|
4a989f6e2d | ||
|
|
aae70053ee | ||
|
|
9b5e39844a | ||
|
|
1d30e4e1d2 | ||
|
|
1a5e961362 | ||
|
|
ff609bc320 | ||
|
|
62a7f903f2 | ||
|
|
11c03fb0ab | ||
|
|
6f4a9d96fb | ||
|
|
4112189551 | ||
|
|
2bb24d1d2a | ||
|
|
a1cb8d4d36 | ||
|
|
a513304917 | ||
|
|
d7f5b2435e | ||
|
|
ce5762d873 | ||
|
|
831fa34752 | ||
|
|
11084a5963 | ||
|
|
2c4d197bf4 | ||
|
|
4ff8f3e14d | ||
|
|
eac4d66adc | ||
|
|
cb071bb93b | ||
|
|
0e500cacd3 | ||
|
|
1e4387f3ea | ||
|
|
9ba72e118b | ||
|
|
add4a18961 | ||
|
|
17e42e7b94 | ||
|
|
812669e861 | ||
|
|
c166c64b8e | ||
|
|
0b1e61504d | ||
|
|
7249623855 | ||
|
|
7b34d57b25 | ||
|
|
aa24d9f071 | ||
|
|
319d360c85 | ||
|
|
e10b0b7d22 | ||
|
|
d1a773a684 | ||
|
|
fe1e5f1b95 | ||
|
|
1e18ca9a46 | ||
|
|
5ef1dcba8e | ||
|
|
e30f3c9ebe | ||
|
|
738d532ce6 | ||
|
|
434fbc2d73 | ||
|
|
302dd7e343 | ||
|
|
d2086835c1 | ||
|
|
f5ae72057d | ||
|
|
29258767ce | ||
|
|
392fae1af4 | ||
|
|
8be0f64f5a | ||
|
|
9f6fb735dd | ||
|
|
46e21127dd | ||
|
|
90869732a3 | ||
|
|
24cff445ad | ||
|
|
bb60a95289 | ||
|
|
8d58e3b9f9 | ||
|
|
f0d80a1d7e | ||
|
|
0f0bb09858 | ||
|
|
6d5df4b9b3 | ||
|
|
27f20f97cb | ||
|
|
e67cc0c747 | ||
|
|
bcbfa50eac | ||
|
|
24974e6f37 | ||
|
|
bf962a9989 | ||
|
|
04cfb74c5e | ||
|
|
14635e5d3a | ||
|
|
a2f31f683d | ||
|
|
9089edfb71 | ||
|
|
a4ca1b68db | ||
|
|
94ab54d1f5 | ||
|
|
9e2a1eb993 | ||
|
|
abff7a49bb | ||
|
|
651e8e0853 | ||
|
|
4a86ad5a7c | ||
|
|
5ad8b3966f | ||
|
|
dc060f8b86 | ||
|
|
9c94e1b96d | ||
|
|
2aff1c5303 | ||
|
|
135a63f549 | ||
|
|
2a434d59c3 | ||
|
|
083ddf7722 | ||
|
|
db02a18627 | ||
|
|
c727e2d0c2 | ||
|
|
f9c19ade16 | ||
|
|
711e5508b5 | ||
|
|
913583bfee | ||
|
|
1abd989a67 | ||
|
|
b1740aeee5 | ||
|
|
736d9277b8 | ||
|
|
9283d2b5bb | ||
|
|
c1cd4cf194 | ||
|
|
1a6ff0954e | ||
|
|
88e9661457 | ||
|
|
7a8754bef7 | ||
|
|
6441621b8b | ||
|
|
d7797c3d38 | ||
|
|
b11841b0bc | ||
|
|
a8121215bc | ||
|
|
a69c2928f0 | ||
|
|
7d00df0a3f | ||
|
|
69e54f85d2 | ||
|
|
bf2a9f5541 | ||
|
|
d6af554c6d | ||
|
|
c2a9c71436 | ||
|
|
f93577ad70 | ||
|
|
9f88537927 | ||
|
|
3159fc0dd0 | ||
|
|
340ac0313a | ||
|
|
bb431f7ac2 | ||
|
|
2f7046efac | ||
|
|
aaeb7ceea7 | ||
|
|
10c6b865c7 | ||
|
|
a9879fa668 | ||
|
|
45bc22fc30 | ||
|
|
4aaa5457b3 | ||
|
|
1fd4a2f782 | ||
|
|
00baf1d883 | ||
|
|
91de39d3fe | ||
|
|
219816d282 | ||
|
|
2880525e6b | ||
|
|
aa156f4a9f | ||
|
|
ccc36dc1f9 | ||
|
|
0fe032438f | ||
|
|
72c5d974b8 | ||
|
|
201e9d5f9f | ||
|
|
af0440aaab | ||
|
|
49956c2797 | ||
|
|
b24e478bc1 | ||
|
|
9ab25cfd4a | ||
|
|
1ae6e9beb4 | ||
|
|
fc1d70cc3b | ||
|
|
dd67472252 | ||
|
|
9062c739d8 | ||
|
|
fb4c1d74b9 | ||
|
|
0dee9deec1 | ||
|
|
722bd007c8 | ||
|
|
9e60080b10 | ||
|
|
455ca86a6c | ||
|
|
6fde5ff55b | ||
|
|
7f92093cc8 | ||
|
|
ddd528abaf | ||
|
|
5c7149e57d | ||
|
|
809ecf781e | ||
|
|
01bf0e4eaf | ||
|
|
0d995c6a78 | ||
|
|
006a23a767 | ||
|
|
8e0c3d2794 | ||
|
|
f34ac5881f | ||
|
|
f290382498 | ||
|
|
deb96318a1 | ||
|
|
11ecb64059 | ||
|
|
1d7450b04c | ||
|
|
968a22897c | ||
|
|
0154572580 | ||
|
|
0bb5b7eb09 | ||
|
|
b447adafaa | ||
|
|
3938e267bb | ||
|
|
daed2686bc | ||
|
|
cddbfedb28 | ||
|
|
e56e055fa1 | ||
|
|
296683229d | ||
|
|
27415e4dd7 | ||
|
|
55f2f29fff | ||
|
|
d381d2b7f3 | ||
|
|
c62879b00d | ||
|
|
3c94f59d43 | ||
|
|
829912c6c5 | ||
|
|
6090e89b3f | ||
|
|
f06b76e14f | ||
|
|
4b8872e4d0 | ||
|
|
e7b5b2618e | ||
|
|
ef9bb0db09 | ||
|
|
f538eeb5fa | ||
|
|
d470bfc52a | ||
|
|
f82b6beedb | ||
|
|
80672460ed | ||
|
|
57e6396fbb | ||
|
|
a5cc1243e3 | ||
|
|
afcec4d0bd | ||
|
|
6efbd6ff14 | ||
|
|
c51735c885 | ||
|
|
4eaadf3099 | ||
|
|
882ef0e9c6 | ||
|
|
d15869ab85 | ||
|
|
511d105dcb | ||
|
|
2ebceb6918 | ||
|
|
7db66bb824 | ||
|
|
a7e47fdae6 | ||
|
|
ce226a88eb | ||
|
|
0b122e6d4a | ||
|
|
b2fc6d1fac | ||
|
|
e6a95615a0 | ||
|
|
6dab0fb2c1 | ||
|
|
a04ef4ca59 | ||
|
|
8e87a37511 | ||
|
|
fe4d171e22 | ||
|
|
75a5609b23 | ||
|
|
fc8a5b21cf |
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
(package(solidcraft-engine) & (
|
||||
test(=tests::hostile_params_never_panic)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(/^tests::robustness_goldens::/)
|
||||
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
|
||||
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
# Every test's time in the log, so the slow ones are easy to find.
|
||||
status-level = "pass"
|
||||
final-status-level = "slow"
|
||||
failure-output = "immediate-final"
|
||||
slow-timeout = { period = "60s" }
|
||||
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [bherbruck]
|
||||
ko_fi: bherbruck
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolveCraft problem
|
||||
about: Help reproduce and fix a SolidCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
@@ -12,7 +12,7 @@ What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolveCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
|
||||
+118
-19
@@ -3,41 +3,140 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
|
||||
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
|
||||
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
|
||||
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
|
||||
**Why 0.9:** this is a minor bump because the design file format moved to version 17, and
|
||||
because it adds many new features.
|
||||
|
||||
**What's new**
|
||||
- **Face actions work on about 90% of flat faces.** Sketches, offset planes and press-pull now
|
||||
attach to sheet metal rims and holes, plastic features (Boss, Lip, Rest, Snap Fit), lofts,
|
||||
revolved faces, pieces Offset Face moved and walls a Join merged
|
||||
([#677](https://github.com/bherbruck/solidcraft/pull/677), fixing
|
||||
[#681](https://github.com/bherbruck/solidcraft/issues/681)). Across the test models: sketch
|
||||
75% → 90%, offset plane 75% → 90%, press-pull 64% → 85%.
|
||||
- **Joins:** a logo-style part with pieces extruded up and down from one sketch now joins
|
||||
([#679](https://github.com/bherbruck/solidcraft/issues/679)), faces that only partly overlap
|
||||
fuse correctly ([#704](https://github.com/bherbruck/solidcraft/pull/704), fixing
|
||||
[#674](https://github.com/bherbruck/solidcraft/issues/674)), and booleans build the same body
|
||||
every time ([#748](https://github.com/bherbruck/solidcraft/pull/748)), so a design rebuilds
|
||||
identically on every run.
|
||||
- **Sketch:** type a dimension right after picking (D, click, type;
|
||||
[#695](https://github.com/bherbruck/solidcraft/pull/695)); redundant but consistent
|
||||
constraints are accepted, such as a rectangle clover
|
||||
([#699](https://github.com/bherbruck/solidcraft/pull/699)); tool windows for Scale, Offset,
|
||||
Mirror, Move/Copy, Fillet and Chamfer and the rectangular pattern (thanks @wdunn001), shown
|
||||
next to the Sketch Palette, which now lists the running tool's options; a typed Rho for the
|
||||
conic; a Construction Geometries row in the Sketch Palette that hides construction geometry
|
||||
([#804](https://github.com/bherbruck/solidcraft/pull/804)); sketch tool shortcuts open a sketch on the selected plane; Edit Sketch from the viewport
|
||||
menu.
|
||||
- **Features:** feature history on the left side with folders (opt in under Preferences;
|
||||
[#449](https://github.com/bherbruck/solidcraft/pull/449)); Convert to Sheet Metal from a solid
|
||||
or shelled body by its bend edges ([#725](https://github.com/bherbruck/solidcraft/pull/725));
|
||||
a tear corner relief ([#809](https://github.com/bherbruck/solidcraft/pull/809)); tapered and
|
||||
variable fillet radii ([#675](https://github.com/bherbruck/solidcraft/pull/675),
|
||||
[#749](https://github.com/bherbruck/solidcraft/pull/749)) with a radius spline you drag on the
|
||||
edge ([#752](https://github.com/bherbruck/solidcraft/pull/752)); free-form cut-outs stay exact
|
||||
in sheet-metal flat patterns ([#692](https://github.com/bherbruck/solidcraft/pull/692)) and edge
|
||||
flanges run along what cut-outs leave of an edge
|
||||
([#702](https://github.com/bherbruck/solidcraft/pull/702)); and Surface Extrude, sheets from
|
||||
open curves and profile walls ([#815](https://github.com/bherbruck/solidcraft/pull/815)).
|
||||
- **View:** Home and Fit frame only what is shown, and a middle double-click goes Home
|
||||
([#701](https://github.com/bherbruck/solidcraft/pull/701)); a cube navigator option
|
||||
(thanks @Karl-VonRichter, [#664](https://github.com/bherbruck/solidcraft/pull/664)); the window
|
||||
stays responsive and draggable while a design opens, with a Cancel button that stays put
|
||||
([#643](https://github.com/bherbruck/solidcraft/pull/643),
|
||||
[#686](https://github.com/bherbruck/solidcraft/pull/686)).
|
||||
- **Quality:** cuts no longer split walls they don't touch
|
||||
([#670](https://github.com/bherbruck/solidcraft/pull/670)); booleans are faster and use less
|
||||
memory ([#714](https://github.com/bherbruck/solidcraft/pull/714)); STL export welds shared
|
||||
vertices ([#816](https://github.com/bherbruck/solidcraft/pull/816)); complete Japanese
|
||||
translation ([#753](https://github.com/bherbruck/solidcraft/pull/753)).
|
||||
- **Fixes:** pasted features act on the pasted bodies (#613); a refused OK's error clears when
|
||||
you change the inputs (#641); dialogs show the units of their sizes (#630); the wheel over a
|
||||
dialog's list scrolls only the list (#678); a Single hole picked at a circle's centre lands on
|
||||
the centre (#657); axis or tangent plane on a drill tip works (#567); sketch text edits from
|
||||
letter interiors and moves as a whole; a projected edge stays the edge you picked; a size from
|
||||
a bare diameter keeps its unit (#716); Convert Mesh says how big a mesh is and what converts;
|
||||
the Save changes prompt takes arrow keys and Enter.
|
||||
- **MCP:** agent skills for modeling and editing over MCP
|
||||
([#728](https://github.com/bherbruck/solidcraft/pull/728)).
|
||||
- A new README demo and the SELENE rover showcase.
|
||||
|
||||
**File format 17.** Designs are now format 17 (`solidcraft/17`). SolidCraft 0.8 and older can't
|
||||
open designs saved by 0.9, as a container or as JSON; they ask you to update instead. Keep a
|
||||
copy of a file you may still open in 0.8. Files from every earlier version still open in 0.9.
|
||||
|
||||
**Upgrading from 0.8.** Nothing to do: open your designs and save (they're saved as format 17
|
||||
from then on). Feature-history folders on
|
||||
the left are off by default; turn them on in Preferences.
|
||||
|
||||
**Known limitations**
|
||||
- Some through-all holes along a wall or beside curved faces still fail
|
||||
([#559](https://github.com/bherbruck/solidcraft/issues/559)), as does a hole crossing an
|
||||
equal-radius hole ([#788](https://github.com/bherbruck/solidcraft/issues/788)).
|
||||
- Some fillets are still refused, such as along an outline of lines and arcs
|
||||
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
|
||||
- Some faces still can't take an attached sketch: a revolve's flat faces before a later feature
|
||||
([#726](https://github.com/bherbruck/solidcraft/issues/726)) and faces after Split Body or
|
||||
Combine Intersect ([#565](https://github.com/bherbruck/solidcraft/issues/565)).
|
||||
- Fillet and chamfer accept an edge pick far from any edge and change the nearest one
|
||||
([#783](https://github.com/bherbruck/solidcraft/issues/783)); Press Pull fails on faces beside
|
||||
rounds ([#562](https://github.com/bherbruck/solidcraft/issues/562)).
|
||||
- `sheet.fold` refuses a fixed point exactly on the sheet face
|
||||
([#685](https://github.com/bherbruck/solidcraft/issues/685)), and some tabs on L-shaped or
|
||||
collinear blanks can't be folded ([#638](https://github.com/bherbruck/solidcraft/issues/638),
|
||||
[#639](https://github.com/bherbruck/solidcraft/issues/639)); an edge flange with different
|
||||
per-edge angles leaves gaps at the corners
|
||||
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
|
||||
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
**Upgrading from 0.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
|
||||
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
|
||||
menu entry.
|
||||
|
||||
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
|
||||
container the default save format. `.solvecraft` designs, old settings folders and
|
||||
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
|
||||
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
|
||||
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
|
||||
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
|
||||
|
||||
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
`-portable.zip` (x64, x86 and arm64).
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
|
||||
The CLI is in `solidcraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
|
||||
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
|
||||
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
|
||||
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), the `.snap`
|
||||
(`sudo snap install --dangerous <file>`, or `sudo snap install solidcraft` from the Snap Store), or the `.tar.gz`.
|
||||
- **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
- Files without a version in the name (`solidcraft-windows-x64.msi`, `solidcraft-linux-x86_64.AppImage`, …)
|
||||
are the same packages under fixed names, for download links that always fetch the latest release.
|
||||
|
||||
For Flatpak feed installation and updates, add Flathub for the required runtime before
|
||||
installing SolidCraft; keep both remotes in the same user installation:
|
||||
|
||||
```sh
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install --user solvecraft ai.storyteller.solvecraft
|
||||
flatpak run ai.storyteller.solvecraft
|
||||
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft ai.storyteller.solidcraft
|
||||
flatpak run ai.storyteller.solidcraft
|
||||
```
|
||||
|
||||
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
|
||||
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
|
||||
|
||||
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
|
||||
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
|
||||
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`.
|
||||
|
||||
+151
-15
@@ -1,9 +1,15 @@
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 6 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cross_compile:
|
||||
@@ -15,18 +21,34 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
|
||||
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install advisory scanner
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
- name: Check locked dependencies
|
||||
run: cargo deny --locked check --warn unmaintained advisories bans sources
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
kernel: ${{ steps.paths.outputs.kernel }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -39,12 +61,14 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
native=false
|
||||
if [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
kernel=false
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
else
|
||||
# Compare both old and new paths of renames; unknown paths run native checks.
|
||||
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
|
||||
case "$path" in
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
@@ -54,6 +78,7 @@ jobs:
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
fi
|
||||
echo "native=$native" >> "$GITHUB_OUTPUT"
|
||||
echo "kernel=$kernel" >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
@@ -75,6 +100,8 @@ jobs:
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
run: cargo xtask web-offline-test
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
@@ -92,12 +119,12 @@ jobs:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -113,15 +140,120 @@ jobs:
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
# Every test binary at once (cargo test runs them one after another).
|
||||
run: cargo xtask test --locked
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: Compile tests into a nextest archive
|
||||
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
path: tests.tar.zst
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
|
||||
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
|
||||
# the build job's path, which is the same checkout path here.
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
|
||||
- name: Robustness goldens sample (pull requests that touch the kernel)
|
||||
# Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and
|
||||
# the nightly run every seed in the step above.
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true'
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter \
|
||||
-E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))"
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
|
||||
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
|
||||
# the whole matrix runs nightly (or by hand), a quarter per job.
|
||||
name: Action matrix (${{ matrix.part }}/4)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
part: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run a quarter of the matrix
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# nextest does not run doc tests.
|
||||
- run: cargo test --workspace --profile ci --locked --doc
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
@@ -144,9 +276,9 @@ jobs:
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Build the command line
|
||||
run: cargo build -p solvecraft-cli --profile ci --locked
|
||||
run: cargo build -p solidcraft-cli --profile ci --locked
|
||||
- name: Replay tests/oracle and compare with expected.json
|
||||
run: cargo xtask oracle --cli target/ci/solvecraft-cli
|
||||
run: cargo xtask oracle --cli target/ci/solidcraft-cli
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -156,7 +288,7 @@ jobs:
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, tests, oracle]
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -166,14 +298,18 @@ jobs:
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$BUILD" = success
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = success
|
||||
test "$ORACLE" = success
|
||||
fi
|
||||
|
||||
@@ -237,4 +373,4 @@ jobs:
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
@@ -6,9 +6,9 @@ name: Packaging lint
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -52,8 +52,14 @@ jobs:
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
|
||||
xmllint --noout assets/platforms/windows/solidcraft.wxs assets/platforms/windows/solidcraft.en-us.wxl assets/platforms/macos/*.plist* assets/platforms/linux/*.xml*
|
||||
desktop-file-validate assets/platforms/linux/ai.storyteller.solidcraft.desktop
|
||||
# The window's X11 class is its app id; the old id stays only as the rename shims.
|
||||
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' assets/platforms/linux/ai.storyteller.solidcraft.desktop
|
||||
grep -q '<id>ai.storyteller.solvecraft</id>' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
|
||||
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
|
||||
python3 -m py_compile packaging/linux/snap-smoke.py
|
||||
@@ -48,8 +48,10 @@ jobs:
|
||||
- name: Build the book
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solvecraft-web
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release --public-url /solidcraft/app/
|
||||
- name: Test the web app offline and across an update (headless Chrome)
|
||||
run: cargo xtask web-offline-test --site dist/web
|
||||
- name: Assemble the site
|
||||
run: |
|
||||
mkdir -p site
|
||||
|
||||
+134
-25
@@ -6,12 +6,16 @@
|
||||
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
# macOS universal .dmg, and the CLI as a .zip packaging/macos
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
# The main installers are also attached without the version in their names
|
||||
# (solidcraft-windows-x64.msi, solidcraft-linux-x86_64.AppImage, ...) so that
|
||||
# releases/latest/download/<name> links in the README keep working across releases.
|
||||
#
|
||||
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
|
||||
name: Release
|
||||
|
||||
@@ -79,7 +83,7 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
@@ -91,10 +95,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solvecraft-cli
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solvecraft-cli icons
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
@@ -114,8 +118,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -135,17 +139,22 @@ jobs:
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
|
||||
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
|
||||
rpm -qip dist/release/*.rpm
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solvecraft
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solidcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
# Legacy AppImage update alias (docs/compatibility.md).
|
||||
ls dist/release/solvecraft-*-linux-*.AppImage.zsync
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
@@ -166,8 +175,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -181,7 +190,7 @@ jobs:
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
@@ -190,6 +199,59 @@ jobs:
|
||||
|
||||
|
||||
|
||||
snap:
|
||||
name: Snap ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is
|
||||
# Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install snapcraft and the smoke-test tools
|
||||
run: |
|
||||
sudo snap install snapcraft --classic
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends xvfb xauth weston dbus-x11
|
||||
- name: Build the snap
|
||||
run: |
|
||||
sudo --preserve-env=SOLIDCRAFT_VERSION,SOLIDCRAFT_BUILD_DATE env "PATH=$PATH" \
|
||||
packaging/linux/snap-pack.sh --destructive-mode "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
sudo chown -R "$(id -u):$(id -g)" dist target
|
||||
- name: Install it and start the app on headless X11 and Wayland
|
||||
run: |
|
||||
snap_file="dist/release/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.snap"
|
||||
sudo snap install --dangerous "$snap_file"
|
||||
snap info --verbose "$snap_file" | head -n 12
|
||||
snap connections solidcraft
|
||||
solidcraft.cli --version
|
||||
# Snaps expect the login session's runtime directory, which runners don't create.
|
||||
sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "/run/user/$(id -u)"
|
||||
python3 packaging/linux/snap-smoke.py --out snap-smoke
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: snap-smoke-${{ matrix.arch }}
|
||||
path: snap-smoke/*.png
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-snap-${{ matrix.arch }}
|
||||
path: dist/release/*.snap
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
@@ -198,8 +260,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
@@ -211,7 +273,7 @@ jobs:
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
@@ -221,10 +283,10 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
@@ -237,8 +299,8 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -269,7 +331,7 @@ jobs:
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -290,7 +352,7 @@ jobs:
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
@@ -304,7 +366,7 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -328,7 +390,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, freebsd, macos, windows, web]
|
||||
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -345,6 +407,24 @@ jobs:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Version-less copies for permanent download links
|
||||
# The README links to releases/latest/download/<name>, which must not change between
|
||||
# releases: solidcraft-<version>-windows-x64.msi is also uploaded as
|
||||
# solidcraft-windows-x64.msi, and so on for the main installers.
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for f in "solidcraft-$VERSION-"*; do
|
||||
case "$f" in
|
||||
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
|
||||
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
|
||||
esac
|
||||
done
|
||||
for f in solidcraft-windows-x64.msi solidcraft-macos-universal.dmg \
|
||||
solidcraft-linux-x86_64.AppImage solidcraft-linux-x86_64.deb solidcraft-linux-x86_64.rpm; do
|
||||
test -s "$f" || { echo "::error::missing version-less copy $f"; exit 1; }
|
||||
done
|
||||
ls -l solidcraft-[!0-9]*
|
||||
- name: Checksums and notes
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
@@ -398,6 +478,35 @@ jobs:
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Snap Store: nightlies go to the edge channel and release drafts to candidate, only after
|
||||
# every package built and the GitHub release was staged. Publishing the GitHub release
|
||||
# promotes candidate to stable (snap-store.yml). Dry runs never upload. Skipped until the
|
||||
# owner adds the SNAPCRAFT_STORE_CREDENTIALS secret (docs/releasing.md).
|
||||
- id: snapstore
|
||||
name: Check for Snap Store credentials
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
run: |
|
||||
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
|
||||
echo "configured=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: skipping the Snap Store upload."
|
||||
fi
|
||||
- name: Upload the snaps to the Snap Store
|
||||
if: >-
|
||||
steps.snapstore.outputs.configured == 'true' && (github.event_name == 'schedule' ||
|
||||
(github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)))
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sudo snap install snapcraft --classic
|
||||
channel=candidate
|
||||
if [ "$GITHUB_EVENT_NAME" = schedule ]; then channel=edge; fi
|
||||
for arch in x86_64 aarch64; do
|
||||
snapcraft upload --release="$channel" "solidcraft-$VERSION-linux-$arch.snap"
|
||||
done
|
||||
echo "Uploaded the snaps to the Snap Store's $channel channel." >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Keep the files (dry run)
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
run: cp notes.md artifacts/release-notes.md
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Publishing a GitHub release (not a prerelease) promotes the snaps that release.yml uploaded to
|
||||
# the Snap Store's candidate channel to stable, so `sudo snap install solidcraft` gets it.
|
||||
# Skipped until the owner adds the SNAPCRAFT_STORE_CREDENTIALS secret; see docs/releasing.md.
|
||||
name: Snap Store
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: snap-store
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: creds
|
||||
name: Check for Snap Store credentials
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
run: |
|
||||
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
|
||||
echo "configured=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: nothing to promote."
|
||||
fi
|
||||
- name: Promote candidate to stable
|
||||
if: steps.creds.outputs.configured == 'true'
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
run: |
|
||||
sudo snap install snapcraft --classic
|
||||
snapcraft status solidcraft
|
||||
snapcraft promote solidcraft --from-channel=latest/candidate --to-channel=latest/stable --yes
|
||||
snapcraft status solidcraft
|
||||
@@ -6,13 +6,13 @@ kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
## Product name
|
||||
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
|
||||
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
|
||||
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
|
||||
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
|
||||
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
|
||||
`/solidcraft/`; do not add permanent old-name Pages aliases.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
@@ -33,10 +33,15 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
|
||||
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
|
||||
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
@@ -54,8 +59,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — ever.** Every icon is drawn in code
|
||||
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code
|
||||
(`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
@@ -70,7 +75,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
@@ -115,20 +123,23 @@ version bumps solely from commit prefixes, overwrite published versions, or push
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
|
||||
request on a connection carries the launch's token as `"token"`: read it from
|
||||
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
|
||||
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
|
||||
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
|
||||
`ui.render` or `solvecraft-cli snapshot`.
|
||||
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
|
||||
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
|
||||
`ui.render` or `solidcraft-cli snapshot`.
|
||||
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
|
||||
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
|
||||
+90
-22
@@ -4,29 +4,48 @@ Every non-code asset in this repository (images, icons, fonts, example parts) is
|
||||
its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset
|
||||
file is missing from this table.
|
||||
|
||||
**Policy (mandatory):** SolveCraft contains **no Autodesk iconography, images, artwork, fonts,
|
||||
templates, materials or help text.** Every asset is original work by SolveCraft contributors or
|
||||
**Policy (mandatory):** SolidCraft contains **no Autodesk iconography, images, artwork, fonts,
|
||||
templates, materials or help text.** Every asset is original work by SolidCraft contributors or
|
||||
third-party material under an open licence. Screenshots of Autodesk software are never committed.
|
||||
|
||||
Generated-in-code assets are original and have no file to list:
|
||||
- the UI icon set (`crates/ui-egui/src/icons.rs`);
|
||||
- the axis view, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
`crates/ui-egui/src/timeline.rs`).
|
||||
Editable drawing code, native projects and shipped exports are collected under `assets/`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solvecraft-web/fonts/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/archive/icons/macos-v1/exports/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/archive/icons/macos-v1/exports/icon.svg from VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
|
||||
| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.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 |
|
||||
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
@@ -48,33 +67,82 @@ Generated-in-code assets are original and have no file to list:
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
|
||||
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | 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/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/glyphs.rs | SolidCraft / VectorCraft contributors | Original glyphs; caption geometry adapted from VectorCraft titlebar.rs (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/icons.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/macos/Info.plist.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| 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/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
|
||||
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
| SolveCraft file | Adapted from | Licence |
|
||||
| SolidCraft file | Adapted from | Licence |
|
||||
|---|---|---|
|
||||
| xtask/src/layers.rs | CADCraft `xtask/src/layers.rs` (dependency layering rules and tests) | MIT OR Apache-2.0 |
|
||||
| apps/solvecraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
|
||||
| apps/solidcraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/lib.rs (screenshot queue, synthetic input) | CADCraft `crates/ui-egui/src/lib.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/control.rs (method dispatch and screenshot saving) | CADCraft `crates/ui-egui/src/control.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs (paint-callback structure) | CADCraft `crates/ui-egui/src/gpu.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solidcraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
||||
|
||||
## Incremental HDR codec adapter
|
||||
|
||||
`crates/render/src/lighting/hdr.rs` adapts the scanline/component/old-RLE decoding
|
||||
state machines from the Radiance decoder in `image` 0.25.10
|
||||
([source](https://docs.rs/crate/image/0.25.10/source/src/codecs/hdr/decoder.rs)),
|
||||
MIT OR Apache-2.0. Metadata parsing still uses that library. The adapter makes row
|
||||
work cooperative and adds checked run lengths, zero-run rejection, width validation,
|
||||
header/input/radiance bounds and cancellation. Original licence texts are retained
|
||||
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||
|
||||
## Recovered contributions
|
||||
|
||||
| SolveCraft file | Original contribution | Licence |
|
||||
| SolidCraft file | Original contribution | Licence |
|
||||
|---|---|---|
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solidcraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solidcraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solidcraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
| Directory | Source | Licence | Changes |
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (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 (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
@@ -6,13 +6,13 @@ kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
## Product name
|
||||
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
|
||||
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
|
||||
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
|
||||
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
|
||||
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
|
||||
`/solidcraft/`; do not add permanent old-name Pages aliases.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
@@ -33,10 +33,15 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
|
||||
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
|
||||
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
@@ -70,7 +75,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
@@ -92,20 +100,23 @@ version bumps solely from commit prefixes, overwrite published versions, or push
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
|
||||
request on a connection carries the launch's token as `"token"`: read it from
|
||||
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
|
||||
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
|
||||
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
|
||||
`ui.render` or `solvecraft-cli snapshot`.
|
||||
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
|
||||
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
|
||||
`ui.render` or `solidcraft-cli snapshot`.
|
||||
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
|
||||
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
|
||||
+35
-14
@@ -17,23 +17,23 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
|
||||
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo xtask test # unit, engine and UI-scenario tests
|
||||
cargo xtask ci # everything a PR must pass (see below)
|
||||
```
|
||||
|
||||
SolidCraft is the product and repository name. Cargo packages and executables retain
|
||||
their `solvecraft` names for compatibility with existing scripts and MCP clients.
|
||||
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
|
||||
`cargo build --release -p solvecraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
|
||||
after building). There is no Cargo package named `solidcraft`.
|
||||
SolidCraft is the product, repository, Cargo package and executable name.
|
||||
Build the desktop app with `cargo build --release -p solidcraft`, or the CLI with
|
||||
`cargo build --release -p solidcraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solidcraft-cli -- commands` (or `target/release/solidcraft-cli commands`
|
||||
after building). The old name appears only in the compatibility shims listed in
|
||||
[docs/compatibility.md](docs/compatibility.md); don't add new uses of it.
|
||||
|
||||
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
|
||||
Web: `cd apps/solidcraft-web && trunk serve --release`, then open the printed URL (`?sample`
|
||||
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
|
||||
HTTPS or localhost, because it uses the Origin Private File System.
|
||||
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
### NixOS: `nix develop`
|
||||
|
||||
@@ -43,7 +43,7 @@ loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-d
|
||||
through (a dialog then never appears and the pick returns no path):
|
||||
|
||||
```
|
||||
ERROR solvecraft: winit EventLoopError: ... The wayland library could not be loaded
|
||||
ERROR solidcraft: winit EventLoopError: ... The wayland library could not be loaded
|
||||
ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to create surface for any enabled backend: {}
|
||||
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
|
||||
```
|
||||
@@ -59,8 +59,8 @@ The default development shell retains the Rust/C toolchain already on `PATH`;
|
||||
To launch, use `nix run .#solidcraft -- --sample`. To install the app's terminal
|
||||
commands, desktop entry and generated icons, use `nix profile install .#solidcraft`.
|
||||
`nix build` alone creates a build result; it does not install a launcher in your
|
||||
desktop menu. The installed `solidcraft` command and compatible `solvecraft`
|
||||
command run the same wrapped app; `solvecraft-cli` is included as well. See
|
||||
desktop menu. The installed `solidcraft` command runs the wrapped app; `solidcraft-cli` is included as
|
||||
well. See
|
||||
[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands,
|
||||
desktop integration and how to distinguish an old executable on `PATH`.
|
||||
|
||||
@@ -82,7 +82,7 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
commands.
|
||||
- `crates/ui-egui`: the desktop/web front end (egui + wgpu). It's swappable; nothing below it
|
||||
depends on egui.
|
||||
- `crates/mcp`, `apps/solvecraft`, `apps/solvecraft-cli`, `apps/solvecraft-web`.
|
||||
- `crates/mcp`, `apps/solidcraft`, `apps/solidcraft-cli`, `apps/solidcraft-web`.
|
||||
|
||||
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
|
||||
|
||||
@@ -100,6 +100,8 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
not.) Permissive
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
|
||||
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
@@ -107,7 +109,7 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
|
||||
There's no `unsafe`, and clippy denies it.
|
||||
- An unsupported case returns an error ("not supported yet: …"); it never panics.
|
||||
- Every call into truck goes through `solvecraft_kernel::guard`.
|
||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||
- Every crash fix comes with a regression test.
|
||||
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
|
||||
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
|
||||
@@ -153,6 +155,25 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
|
||||
quarter each, so every test runs in its own process: don't rely on state left by another test.
|
||||
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
|
||||
`main` and nightly; check the `main` run after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
`tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part),
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
|
||||
corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/action-matrix.md)).
|
||||
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
|
||||
one that makes a passing action fail fails the sample every CI run checks.
|
||||
|
||||
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
|
||||
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
|
||||
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
|
||||
|
||||
Generated
+74
-60
@@ -2765,15 +2765,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -3289,89 +3280,109 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"getrandom 0.3.4",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-cli"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-mcp",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-mcp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-doc"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-kernel",
|
||||
"solvecraft-sketch",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-engine"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-doc",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-io",
|
||||
"solvecraft-kernel",
|
||||
"solvecraft-render",
|
||||
"solvecraft-sketch",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-geom"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-kernel",
|
||||
"log",
|
||||
"pollster 0.4.0",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
"solvecraft-geom",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"spade",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
@@ -3381,60 +3392,62 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solidcraft-engine",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-render"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-geom",
|
||||
"solidcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-geom",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"pollster 0.4.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"ttf-parser",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-web"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -4177,7 +4190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -4849,9 +4862,10 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+24
-14
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -14,15 +14,16 @@ repository = "https://github.com/bherbruck/solidcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
solvecraft-geom = { path = "crates/geom" }
|
||||
solvecraft-sketch = { path = "crates/sketch" }
|
||||
solvecraft-kernel = { path = "crates/kernel" }
|
||||
solvecraft-render = { path = "crates/render" }
|
||||
solvecraft-doc = { path = "crates/doc" }
|
||||
solvecraft-io = { path = "crates/io" }
|
||||
solvecraft-engine = { path = "crates/engine" }
|
||||
solvecraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solvecraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-geom = { path = "crates/geom" }
|
||||
solidcraft-sketch = { path = "crates/sketch" }
|
||||
solidcraft-kernel = { path = "crates/kernel" }
|
||||
solidcraft-render = { path = "crates/render" }
|
||||
solidcraft-gpu = { path = "crates/gpu" }
|
||||
solidcraft-doc = { path = "crates/doc" }
|
||||
solidcraft-io = { path = "crates/io" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -37,16 +38,20 @@ truck-shapeops = { version = "0.4", default-features = false }
|
||||
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
|
||||
truck-stepio = { version = "0.3", default-features = false }
|
||||
truck-topology = "0.6"
|
||||
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0); already in the
|
||||
# tree through truck-meshalgo.
|
||||
spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.38"
|
||||
quick-xml = "0.41"
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
@@ -54,6 +59,9 @@ eframe = { version = "0.36", default-features = false, features = ["persistence"
|
||||
# Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown).
|
||||
web-time = "1"
|
||||
egui-wgpu = "0.36"
|
||||
# the wgpu egui-wgpu uses, for compute (crates/gpu); backends come from the apps' features
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
|
||||
pollster = "0.4"
|
||||
rfd = "0.17"
|
||||
|
||||
[workspace.lints.rust]
|
||||
@@ -92,9 +100,11 @@ opt-level = 2
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
# Line tables only: crash reports name file:line in their backtraces (no runtime cost; a larger
|
||||
# binary). See docs/crash-reports.md.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# truck-shapeops with SolveCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
[patch.crates-io]
|
||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 SolveCraft contributors
|
||||
Copyright (c) 2026 SolidCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
SolveCraft
|
||||
Copyright (c) 2026 SolveCraft contributors.
|
||||
SolidCraft
|
||||
Copyright (c) 2026 SolidCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
@@ -7,10 +7,10 @@ Third-party material
|
||||
|
||||
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
|
||||
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
|
||||
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
|
||||
`solidcraft-cli licences`). The geometry kernel builds on the truck crates
|
||||
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
|
||||
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolidCraft
|
||||
modifications (listed in vendor/README.md and marked in the source); its licence is in
|
||||
vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
|
||||
|
||||
@@ -19,10 +19,15 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
|
||||
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
|
||||
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
|
||||
|
||||
The incremental Radiance RGBE row adapter in crates/render/src/lighting/hdr.rs adapts
|
||||
decoding state machines from image 0.25.10 (https://github.com/image-rs/image),
|
||||
MIT OR Apache-2.0. ATTRIBUTION.md describes the bounded/cancellable changes; the
|
||||
original licence texts are included in THIRD-PARTY-LICENSES.txt.
|
||||
|
||||
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
|
||||
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
|
||||
per-file list is in ATTRIBUTION.md.
|
||||
|
||||
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
SolidCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
|
||||
describe compatibility goals.
|
||||
@@ -6,16 +6,59 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server.
|
||||
|
||||

|
||||

|
||||
|
||||
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
[![Download for Windows][badge-windows]][windows-msi]
|
||||
[![Download for macOS][badge-macos]][macos-dmg]
|
||||
[![Download for Linux][badge-linux]][linux-appimage]
|
||||
[![Get it from the Snap Store][badge-snap]][snap-store]
|
||||
[![Try it in your browser][badge-web]][web-app]
|
||||
|
||||
- **Windows:** download and run the [installer][windows-msi].
|
||||
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
|
||||
[portable zip][windows-portable] that runs without installing.
|
||||
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
|
||||
(Apple silicon and Intel).
|
||||
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
|
||||
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
|
||||
openSUSE), or use [Flatpak](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
|
||||
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
|
||||
- **Web:** [try it in your browser][web-app], no install.
|
||||
|
||||
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
|
||||
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
|
||||
|
||||
Older versions, checksums and other formats are on the [releases page](https://github.com/bherbruck/solidcraft/releases).
|
||||
Install details, upgrading from earlier builds and building from source are in
|
||||
[Getting started](https://bherbruck.github.io/solidcraft/guide/getting-started.html).
|
||||
|
||||
[badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B
|
||||
[badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white
|
||||
[badge-linux]: https://img.shields.io/badge/Linux-AppImage-E95420?style=for-the-badge&logo=linux&logoColor=white
|
||||
[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white
|
||||
[badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D
|
||||
[windows-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
|
||||
[windows-arm64-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
|
||||
[windows-x86-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
|
||||
[windows-portable]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
|
||||
[macos-dmg]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
|
||||
[linux-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
|
||||
[linux-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
|
||||
[linux-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
|
||||
[linux-arm64-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
|
||||
[linux-arm64-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
|
||||
[linux-arm64-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
|
||||
[linux-snap]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://bherbruck.github.io/solidcraft/app/
|
||||
|
||||
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
||||
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
|
||||
|
||||
Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the
|
||||
SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
**Status: early but usable for simple parts.** 75 of 76 public reference parts
|
||||
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
|
||||
@@ -25,16 +68,15 @@ are listed by area in [docs/features.md](docs/features.md); status and plans are
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Name and compatibility
|
||||
## Built with SolidCraft
|
||||
|
||||
SolidCraft was previously called SolveCraft. The product display name and repository are
|
||||
changing; existing identifiers remain compatible: `.solvecraft` designs, `solvecraft-cli`,
|
||||
Cargo package names, settings/recovery locations and package IDs keep their existing names.
|
||||
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
|
||||
and filenames.
|
||||
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
|
||||
|
||||
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
|
||||
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
|
||||
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
|
||||
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
|
||||
rebuild with no errors, replayed here at 2× speed.
|
||||
[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
|
||||
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
|
||||
|
||||
## What works
|
||||
|
||||
@@ -49,13 +91,17 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
the boundary and keeps existing extrusions usable.
|
||||
Curve drawing and picking adapt together to zoom and display scale, including small circles
|
||||
at Fit and sketches in placed components.
|
||||
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
|
||||
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
|
||||
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
|
||||
rejected; fully constrained geometry turns black.
|
||||
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
|
||||
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
|
||||
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
|
||||
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
|
||||
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
|
||||
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
|
||||
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
|
||||
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
|
||||
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
|
||||
step. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
Newly captured complete adaptive boundaries retain their outer and hole source identities
|
||||
when spline span counts change. Partial or ambiguous boundaries do not qualify.
|
||||
@@ -74,9 +120,13 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
|
||||
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
|
||||
separately from through-hole outlines in the flat preview and DXF.
|
||||
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
|
||||
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
|
||||
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
|
||||
provisional picture from warm geometry caches while rebuilding before editing; caches never
|
||||
replace the parametric design. Export to STEP (AP242 with body names, colours and
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
|
||||
assemblies with joints as URDF for robot simulators (docs/urdf.md);
|
||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
@@ -89,31 +139,45 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
control channel so agents and tests can drive it.
|
||||
Feature errors and Help can open an editable **Create Issue** draft with relevant context
|
||||
and an optional reviewed screenshot; opening GitHub does not submit the report.
|
||||
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
- **Headless CLI** `solidcraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
|
||||
- **MCP server** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
- **MCP server** `solidcraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
in the running app (see below).
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solvecraft-cli -- commands # the command registry
|
||||
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
page in Preferences has an on/off switch and a speed control for the 3D mouse.
|
||||
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
|
||||
device. A udev rule that adds the `uaccess` tag does that, for example
|
||||
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
```
|
||||
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
|
||||
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
|
||||
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
|
||||
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
|
||||
report descriptor, so they work as well.
|
||||
|
||||
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
## Scripts
|
||||
@@ -131,12 +195,14 @@ A script is a JSON list of commands, the same ones the UI runs:
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solvecraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
|
||||
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
started with `solvecraft --control PORT`, so you can watch the agent model. Tools:
|
||||
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
|
||||
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
|
||||
`save`. Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. Details, client
|
||||
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
|
||||
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
|
||||
configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Architecture
|
||||
@@ -150,7 +216,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
|
||||
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
<!-- ANCHOR_END: architecture -->
|
||||
|
||||
## Contributing
|
||||
|
||||
+126
-14
@@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | **75 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR; 75-sm-bracket-holes fails (#296). |
|
||||
| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
@@ -36,7 +36,7 @@ In scope, in priority order:
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
@@ -44,32 +44,83 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
thermal data, per-property sources/conditions and isotropic/orthotropic stability
|
||||
checks through read-only `material.properties.validate`. It does not assign or
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
appearances, editable wood mapping, and parameterized component/assembly families.
|
||||
Stable identity/revision, copied design definitions, provenance and resource limits
|
||||
apply from the first implementation. Existing material, appearance and standard-part
|
||||
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
|
||||
for source/license review and acceptance gates. This is planning only: pack loading,
|
||||
texture rendering and expanded component families are not implemented by this entry.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
|
||||
it does not yet enable HDRI/PBR rendering or a document import command. See
|
||||
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
|
||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||
Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR,
|
||||
render jobs and optional engines remain future work; see [the contract](docs/render-backends.md)
|
||||
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
|
||||
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
|
||||
Polygon and Spline. Each open part remembers its last interactive choice per family
|
||||
across sketch sessions and document-tab switches; closing the part resets these UI choices.
|
||||
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
|
||||
placement, including orientation and the selected side count.
|
||||
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
|
||||
dimension values, so line type can change while a shape is in progress.
|
||||
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
|
||||
committed geometry; centerlines retain their dash-dot pattern.
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
|
||||
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||
Interior selection requires every profile boundary to belong to the same text source;
|
||||
the editor highlights the whole source without replacing profile selections.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
Dragging a text outline moves the entire source in one undo step; Escape restores the
|
||||
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
|
||||
about its baseline; copies keep an independent editable text source.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
@@ -96,6 +147,14 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
- The header exposes **Report a bug**, including an accessible compact control on
|
||||
narrow windows. It opens the same editable report and screenshot review without
|
||||
changing the active model or a pending dimension.
|
||||
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
|
||||
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
|
||||
journal) leaves a local report with the backtrace, version and commit, the command and the
|
||||
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
|
||||
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
|
||||
Crash Reports…) with a reviewed issue draft; agents read them through
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
@@ -137,6 +196,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
@@ -144,8 +208,16 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
|
||||
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
|
||||
patches require separate upstream review because advisory databases cannot version-match
|
||||
their path dependencies. Pull requests retain the existing licence checks.
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one perpendicular
|
||||
planar end face at each end. The UI and `solid.fillet` expose `curvature` (alias `g2`); the size
|
||||
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
|
||||
@@ -163,7 +235,14 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs).
|
||||
between two overlapping extruded arcs); knife edges, where the two faces have opposite
|
||||
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
|
||||
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
|
||||
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
|
||||
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
|
||||
other, except a round after a round of the edge between the wall and a flat face that it
|
||||
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
|
||||
piece of sphere.
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
@@ -199,8 +278,12 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs. Singular,
|
||||
self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
|
||||
speeds without changing stored nodes or relaxing the distance tolerance (#971).
|
||||
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
|
||||
editable source updates the derived boundary live. Break Association restores point editing.
|
||||
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
@@ -275,6 +358,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
|
||||
button drops keep insert-before ordering.
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
@@ -299,12 +384,20 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling.
|
||||
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.
|
||||
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
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
|
||||
clear of the flange beside it, and the returns of one feature are mitred toward each other
|
||||
with the rule's gap where their flanges meet at a corner; returns of separate features, and
|
||||
flanges on returns, are not closed toward each other.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
@@ -335,6 +428,25 @@ and reference-object placement remain separate work, as do loft rails and center
|
||||
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
|
||||
import times; single-solid assembly parts are not scheduled across nodes. See
|
||||
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
|
||||
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
|
||||
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
|
||||
format. Generated-mesh storage measurements reduce serialization and decoding work;
|
||||
a seekable reader defers original blobs until after provisional display.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
|
||||
and geometry records, with a separate truck adapter and bounded binary reader. Exact
|
||||
face-source certificates and operation history survive codec round trips. A provisional
|
||||
picture can be generated before original blobs are read; editing and measurement wait
|
||||
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
|
||||
the same document despite concurrent file writes. Default saves omit this cache and never force cold
|
||||
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
|
||||
Keys, resource budgets and document/revision guards reject incompatible, hostile or
|
||||
stale results. A second modelling kernel still requires its own tested adapter.
|
||||
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
|
||||
Home reads it without rebuilding the design when its local picture is missing;
|
||||
mismatched or malformed thumbnails fall back to normal generation. The image is
|
||||
kernel-independent and saving never forces cold meshing to produce it.
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Security policy
|
||||
|
||||
## 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/bherbruck/solidcraft/releases/latest) before
|
||||
reporting, and check whether the problem is still on `main`.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest release | ✅ |
|
||||
| Nightly builds | ✅ (fixes land here first) |
|
||||
| Older releases | ❌ |
|
||||
|
||||
## 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/bherbruck/solidcraft/security) and choose
|
||||
**Report a vulnerability**
|
||||
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
|
||||
|
||||
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.
|
||||
|
||||
Areas where reports are especially useful:
|
||||
|
||||
- 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)
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solvecraft-cli and the web app) is built from the 454 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 453 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.
|
||||
|
||||
@@ -262,7 +262,6 @@ proc-macro2 1.0.107 (MIT OR Apache-2.0)
|
||||
profiling 1.0.18 (MIT OR Apache-2.0)
|
||||
pxfm 0.1.30 (BSD-3-Clause OR Apache-2.0)
|
||||
quick-error 2.0.1 (MIT/Apache-2.0)
|
||||
quick-xml 0.38.4 (MIT)
|
||||
quick-xml 0.41.0 (MIT)
|
||||
quote 1.0.47 (MIT OR Apache-2.0)
|
||||
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
|
||||
@@ -8709,7 +8708,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
quick-xml 0.38.4, quick-xml 0.41.0
|
||||
quick-xml 0.41.0
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
@@ -13350,11 +13349,11 @@ truck-topology 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
Bundled fonts (SIL Open Font License, Version 1.1)
|
||||
================================================================================
|
||||
|
||||
- LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)
|
||||
- LiberationSans-Regular.ttf (assets/fonts/liberation-sans/LiberationSans-Regular.ttf)
|
||||
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)
|
||||
Source: https://github.com/liberationfonts
|
||||
|
||||
- BIZUDPGothic-Regular.ttf (apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf)
|
||||
- BIZUDPGothic-Regular.ttf (assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf)
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "solidcraft-cli"
|
||||
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
default-run = "solidcraft-cli"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solidcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
|
||||
# note on stderr. Shipped in every package for at least one release.
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
path = "src/alias.rs"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
|
||||
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
|
||||
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
|
||||
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
|
||||
//! stays clean for MCP's JSON-RPC), and exits with its status.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const TARGET: &str = "solidcraft-cli";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
|
||||
let sibling = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
|
||||
.filter(|p| p.is_file());
|
||||
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
|
||||
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
|
||||
Ok(status) => match status.code() {
|
||||
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
|
||||
None => ExitCode::FAILURE,
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `solidcraft-cli crashes`: the crash reports the desktop app wrote on this computer
|
||||
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::crash;
|
||||
|
||||
const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let dir = crash::default_dir().ok_or("no crash folder on this system")?;
|
||||
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let reports = crash::list(&dir);
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
println!("{}", super::pretty(&json!({ "dir": dir.to_string_lossy(), "reports": reports })));
|
||||
return Ok(());
|
||||
}
|
||||
println!("crash reports in {}", dir.display());
|
||||
if reports.is_empty() {
|
||||
println!(" (none)");
|
||||
}
|
||||
for r in reports {
|
||||
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
|
||||
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
|
||||
let replay = if r.replayable { " [replayable]" } else { "" };
|
||||
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some("show") => {
|
||||
println!("{}", super::pretty(&crash::read(&dir, id()?)?));
|
||||
Ok(())
|
||||
}
|
||||
Some("replay") => replay(&crash::read(&dir, id()?)?),
|
||||
Some("dir") => {
|
||||
println!("{}", dir.display());
|
||||
Ok(())
|
||||
}
|
||||
Some("clear") => {
|
||||
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics seen while replaying: (location, message).
|
||||
static PANICS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Run a report's commands on its design and say whether the crash happens again.
|
||||
fn replay(report: &Value) -> Result<(), String> {
|
||||
let id = report["id"].as_str().unwrap_or_default();
|
||||
let replay_path = report["replay_path"].as_str().ok_or_else(|| format!("report {id} cannot be replayed (see its notes)"))?;
|
||||
let script = super::read_json(replay_path)?;
|
||||
let commands = script["commands"].as_array().cloned().unwrap_or_default();
|
||||
let mut s = match report["design_path"].as_str() {
|
||||
Some(d) => super::load(d)?,
|
||||
None => solidcraft_engine::Session::default(),
|
||||
};
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
|
||||
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
|
||||
if let Ok(mut p) = PANICS.lock() {
|
||||
p.push((loc, msg));
|
||||
}
|
||||
}));
|
||||
if report["kind"] == "unclean_exit" {
|
||||
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
|
||||
}
|
||||
println!(
|
||||
"replaying {} command(s) on {}",
|
||||
commands.len(),
|
||||
report["design_path"].as_str().map_or_else(|| "a new design".to_string(), |d| d.to_string())
|
||||
);
|
||||
for (i, c) in commands.iter().enumerate() {
|
||||
let cmd = c["command"].as_str().unwrap_or_default();
|
||||
let r = s.execute(cmd, c.get("params").unwrap_or(&Value::Null));
|
||||
match r {
|
||||
Ok(_) => println!(" {:>3}. {cmd}: ok", i + 1),
|
||||
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
|
||||
}
|
||||
}
|
||||
let _ = std::panic::take_hook();
|
||||
let seen = PANICS.lock().map(|p| p.clone()).unwrap_or_default();
|
||||
let want = report["signature"].as_str().unwrap_or_default();
|
||||
let same = seen.iter().any(|(l, m)| format!("{:016x}", crash::signature(l, m)) == want);
|
||||
if same {
|
||||
println!(
|
||||
"reproduced: the same internal error ({}) at {}",
|
||||
report["message"].as_str().unwrap_or_default(),
|
||||
report["location"].as_str().unwrap_or_default()
|
||||
);
|
||||
} else if let Some((l, m)) = seen.first() {
|
||||
println!("an internal error happened, but not the reported one: {m} at {l}");
|
||||
println!("(the report says: {} at {})", report["message"].as_str().unwrap_or_default(), report["location"].as_str().unwrap_or_default());
|
||||
} else {
|
||||
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//!
|
||||
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
|
||||
//! is recorded like any other failure.
|
||||
@@ -9,8 +9,8 @@ use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::fuzz;
|
||||
use solvecraft_engine::fuzz::boolean_case;
|
||||
use solidcraft_engine::fuzz;
|
||||
use solidcraft_engine::fuzz::boolean_case;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
@@ -45,7 +45,7 @@ pub fn one(args: &[String]) -> Result<(), String> {
|
||||
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
|
||||
}
|
||||
}
|
||||
let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed);
|
||||
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
|
||||
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
|
||||
let b = boolean_case(seed);
|
||||
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -1,21 +1,23 @@
|
||||
//! SolveCraft headless command line.
|
||||
//! SolidCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solvecraft-cli commands the command registry as JSON
|
||||
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
//! MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
@@ -24,26 +26,28 @@ mod spatial;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
#[path = "../../app_icon.rs"]
|
||||
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
||||
mod app_icon;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::render::{StandardView, render_png};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
solvecraft-cli commands
|
||||
solvecraft-cli recipe <recipe.json>
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solvecraft-cli licences
|
||||
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors]
|
||||
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
|
||||
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
|
||||
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
solidcraft-cli recipe <recipe.json>
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -74,11 +78,12 @@ fn main() -> ExitCode {
|
||||
Some("fuzz") => fuzz::run(&args[1..]),
|
||||
Some("fuzz-one") => fuzz::one(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
Some("crashes") => crashes::run(&args[1..]),
|
||||
Some("icon") => cmd_icon(&args[1..]),
|
||||
Some("licences" | "licenses") => {
|
||||
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
|
||||
let _ = std::io::stdout().lock().write_all(solidcraft_engine::licences::all().as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||
@@ -87,7 +92,7 @@ fn main() -> ExitCode {
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
@@ -108,19 +113,26 @@ fn pretty(v: &Value) -> String {
|
||||
pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
// Through the engine's file access, which refuses anything that is not a regular file and
|
||||
// reads no more than the cap: a character device or a FIFO would otherwise never end.
|
||||
let s = solvecraft_engine::io::vfs::read_to_string(path, solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
|
||||
pub fn load(path: &str) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
if solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path) {
|
||||
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
let v = read_json(path)?;
|
||||
if v.get("format").and_then(Value::as_str).is_some_and(|f| f.starts_with("solvecraft")) {
|
||||
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
if solidcraft_engine::io::is_design_container(&bytes) {
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut session = Session::new(doc);
|
||||
session.path = Some(path.to_owned());
|
||||
return Ok(session);
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
} else if v.get("features").is_some() && v.get("commands").is_none() {
|
||||
let script = recipe::to_script(&v)?;
|
||||
@@ -144,7 +156,7 @@ fn positional(args: &[String]) -> Option<&String> {
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
if a.starts_with("--") {
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results") { 1 } else { 2 };
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
|
||||
continue;
|
||||
}
|
||||
return Some(a);
|
||||
@@ -179,6 +191,12 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
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
|
||||
// the wrong part with success, so fail the run unless asked to write it anyway.
|
||||
if !args.iter().any(|a| a == "--allow-errors") {
|
||||
timeline_ok(&s)?;
|
||||
}
|
||||
for out in flags(args, "--out") {
|
||||
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -194,6 +212,19 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
|
||||
fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
|
||||
if failed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"{} timeline feature(s) failed, so nothing was written (--allow-errors writes the bodies anyway):\n{}",
|
||||
failed.len(),
|
||||
failed.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
|
||||
let mut s = load(path)?;
|
||||
@@ -217,11 +248,11 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
|
||||
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
|
||||
let s = load(path)?;
|
||||
let mut cam = solvecraft_engine::view::home_camera(&s);
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
if let Some(v) = flag(args, "--view") {
|
||||
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
|
||||
}
|
||||
let scene = solvecraft_engine::view::scene(&s, &cam);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
|
||||
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
|
||||
@@ -246,7 +277,7 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
solvecraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
solidcraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
}
|
||||
|
||||
/// A Windows icon file holding PNG images (Vista and later read these).
|
||||
@@ -282,33 +313,49 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
|
||||
/// a running app's control port. stdout carries only protocol messages.
|
||||
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
|
||||
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
|
||||
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
let backend: Box<dyn solvecraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
|
||||
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
|
||||
if let Some(s) = &scope {
|
||||
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
|
||||
eprintln!(
|
||||
"solidcraft mcp: files limited to {} and the open design's folder (--allow-path DIR adds one, --allow-any-path lifts the limit)",
|
||||
roots.join(", ")
|
||||
);
|
||||
let design_dir = flag(args, "--in")
|
||||
.and_then(|p| std::path::Path::new(p).parent())
|
||||
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d });
|
||||
if let Some(why) = design_dir.and_then(solidcraft_engine::io::vfs::design_folder_refusal) {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
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) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| {
|
||||
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect_with_token(addr, flag(args, "--token")).map_err(|e| {
|
||||
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
|
||||
format!(
|
||||
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
|
||||
`solvecraft --control {port}` (or SOLVECRAFT_CONTROL_PORT={port}) and retry"
|
||||
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
|
||||
)
|
||||
})?),
|
||||
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
solidcraft_mcp::Server::new(backend).with_path_scope(scope).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
|
||||
/// parameter `save_round` set to the round number each time.
|
||||
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
|
||||
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
|
||||
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
||||
let filler = (0..3000).map(|i| solidcraft_engine::doc::Param {
|
||||
name: format!("p{i}"),
|
||||
expr: format!("{i} mm + 0.5 mm"),
|
||||
unit: "mm".into(),
|
||||
@@ -327,7 +374,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
|
||||
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
|
||||
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
return Err("usage: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
};
|
||||
let v = read_json(path)?;
|
||||
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
|
||||
@@ -355,21 +402,24 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solvecraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
||||
let bytes = solvecraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
||||
let step = solidcraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
|
||||
let bytes = solidcraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
||||
let t = std::time::Instant::now();
|
||||
// The file as read, held until the timeline has evaluated it (as the import command does).
|
||||
let mut read = None;
|
||||
let doc = if step {
|
||||
let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solvecraft_engine::doc::Document::new(&imported.name);
|
||||
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
|
||||
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
||||
read = Some(imported.import);
|
||||
doc
|
||||
} else {
|
||||
solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
};
|
||||
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
||||
@@ -378,12 +428,15 @@ fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::new(doc);
|
||||
drop(read);
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
// As the desktop app prepares a design it opened.
|
||||
st.warm_meshes(|| true);
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
Ok(())
|
||||
@@ -5,7 +5,7 @@
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::spatial::{self, Tri};
|
||||
use crate::{read_json, recipe};
|
||||
@@ -88,7 +88,7 @@ fn check_case(dir: &str) -> Value {
|
||||
Ok(s) => s,
|
||||
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
|
||||
};
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
if let Err(e) = s.run_script(&script) {
|
||||
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
|
||||
}
|
||||
@@ -148,27 +148,27 @@ fn check_case(dir: &str) -> Value {
|
||||
}
|
||||
|
||||
/// Export the session's bodies as STEP, validate the file and import it again.
|
||||
fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, String) {
|
||||
fn step_export_check(s: &solidcraft_engine::Session, volume: f64) -> (bool, String) {
|
||||
let st = s.model.state();
|
||||
let bytes = match solvecraft_engine::io::export(&st, &[], solvecraft_engine::io::Format::Step, "oracle") {
|
||||
let bytes = match solidcraft_engine::io::export(&st, &[], solidcraft_engine::io::Format::Step, "oracle") {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (false, format!("export: {e}")),
|
||||
};
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
if let Err(e) = solvecraft_engine::kernel::step_validate(&text) {
|
||||
if let Err(e) = solidcraft_engine::kernel::step_validate(&text) {
|
||||
return (false, format!("invalid: {e}"));
|
||||
}
|
||||
match solvecraft_engine::kernel::step_orientation_errors(&text) {
|
||||
match solidcraft_engine::kernel::step_orientation_errors(&text) {
|
||||
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
|
||||
Err(e) => return (false, format!("orientation check: {e}")),
|
||||
Ok(_) => {}
|
||||
}
|
||||
match solvecraft_engine::kernel::step_import(&text) {
|
||||
match solidcraft_engine::kernel::step_import(&text) {
|
||||
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
|
||||
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
|
||||
Ok(imp) => {
|
||||
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let v: f64 = m.iter().map(|x| x.volume).sum();
|
||||
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
|
||||
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
|
||||
@@ -184,7 +184,7 @@ fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, Stri
|
||||
}
|
||||
|
||||
/// Our part as a triangle soup (curved faces refined onto the surface).
|
||||
fn our_mesh(s: &solvecraft_engine::Session) -> Result<Vec<Tri>, String> {
|
||||
fn our_mesh(s: &solidcraft_engine::Session) -> Result<Vec<Tri>, String> {
|
||||
let mut ours: Vec<Tri> = Vec::new();
|
||||
for b in &s.world_state().bodies {
|
||||
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
|
||||
@@ -201,7 +201,7 @@ fn our_mesh(s: &solvecraft_engine::Session) -> Result<Vec<Tri>, String> {
|
||||
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
|
||||
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
|
||||
/// sits in space).
|
||||
fn reference_checks(s: &solvecraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
|
||||
fn reference_checks(s: &solidcraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
|
||||
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
|
||||
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
|
||||
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
|
||||
@@ -514,7 +514,23 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
out.push(json!({"command": "solid.mirror", "params": p}));
|
||||
}
|
||||
Some("fillet") => {
|
||||
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
|
||||
let mut p = json!({"edges": edge_points(f), "radius": f.get("radius"), "name": name});
|
||||
// Constant radius (the default), chord length, or a radius varying along each edge
|
||||
// (`radius` at the end nearest `start`, `radius2` at the other, `mid` between,
|
||||
// `smooth`), or a B-spline along each edge (`radius` its first control radius,
|
||||
// `radii` the rest, `knots`); `fit` for a radius too large for the geometry.
|
||||
match f.get("type").and_then(Value::as_str) {
|
||||
Some("chord_length") => p["type"] = json!("chord"),
|
||||
Some("variable_radius") => p["type"] = json!("variable"),
|
||||
Some("spline_radius") => p["type"] = json!("spline"),
|
||||
_ => {}
|
||||
}
|
||||
for key in ["radius2", "start", "mid", "smooth", "fit", "radii", "knots"] {
|
||||
if let Some(v) = f.get(key) {
|
||||
p[key] = v.clone();
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "solid.fillet", "params": p}))
|
||||
}
|
||||
Some("chamfer") => {
|
||||
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
|
||||
File renamed without changes.
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -4,7 +4,7 @@ use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn registry_output_is_complete_json() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let commands = registry.as_array().unwrap();
|
||||
@@ -16,7 +16,7 @@ fn registry_output_is_complete_json() {
|
||||
|
||||
#[test]
|
||||
fn closed_registry_pipe_exits_without_panicking() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
// Close our read end before waiting. No sleeps or timing threshold are needed:
|
||||
// the registry is larger than the pipe buffer, so it cannot all be written first.
|
||||
drop(child.stdout.take());
|
||||
@@ -0,0 +1,28 @@
|
||||
# Design file corpus
|
||||
|
||||
Designs saved by older SolidCraft builds. `tests/design_corpus.rs` opens each one with the
|
||||
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
|
||||
it is `solidcraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
|
||||
world bounding boxes), that it is upgraded to the current file format, and that damaged and
|
||||
hostile variants of it never panic.
|
||||
|
||||
| File | Saved by | Era |
|
||||
|---|---|---|
|
||||
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
|
||||
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
|
||||
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
|
||||
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
|
||||
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
|
||||
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
|
||||
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
|
||||
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
|
||||
| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) |
|
||||
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
|
||||
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
|
||||
| `issue-616-lid-ribs` | v0.7.0 (a9879fa), CLI | a rounded lid with a skirt ring, a window and a slot cut through, then four ribs joined on the plate over the skirt, flush with the window's sides; v0.7.0 displaced the ribs and left a slit through the skirt and plate (issue #616; script `examples/regressions/lid_ribs_flush_with_window.json`, expected values from the fix) |
|
||||
|
||||
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
|
||||
app and has none; `issue-616-lid-ribs` is made by its regression script). Every flat face of every final body must also take an attached sketch and an
|
||||
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
|
||||
|
||||
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 25751.999999999945,
|
||||
"bbox": {
|
||||
"max": [
|
||||
35.0,
|
||||
42.0,
|
||||
48.00000000000001
|
||||
],
|
||||
"min": [
|
||||
-35.0,
|
||||
-42.000000000000014,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
5.524909928104648e-14,
|
||||
-1.0945382096961234e-12,
|
||||
22.366336633663238
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 24,
|
||||
"face_types": {
|
||||
"plane": 10
|
||||
},
|
||||
"faces": 10,
|
||||
"kernel": {
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"shells": 1,
|
||||
"vertices": 16
|
||||
},
|
||||
"mass_g": 328.33079999999734,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm",
|
||||
"value": 70.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 6,
|
||||
"dof": 0,
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 10,
|
||||
"dof": 1,
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"profiles": 3,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.131943,
|
||||
"name": "Sketch1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 1.0812709999999999,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.450002,
|
||||
"name": "Sketch2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 5.191514000000001,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 25751.999999999945,
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"mass_g": 328.33079999999734,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"plane": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"p": 5,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 5,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 1,
|
||||
"param": "d1",
|
||||
"type": "length",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 0,
|
||||
"param": "d2",
|
||||
"type": "length",
|
||||
"value": 70.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 6,
|
||||
"p": 5
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 1,
|
||||
"b": 3,
|
||||
"construction": true,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": true,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "30"
|
||||
},
|
||||
"id": 2,
|
||||
"name": "Extrude1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d3"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 1,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"plane": {
|
||||
"anchor": {
|
||||
"author_path": [],
|
||||
"component": 0,
|
||||
"coordinates": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"path": [],
|
||||
"target": {
|
||||
"body": "Body1",
|
||||
"certificate": {
|
||||
"high": true,
|
||||
"holes": [],
|
||||
"kind": "extrude_cap",
|
||||
"outer": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
],
|
||||
"sketch": 1
|
||||
},
|
||||
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"origin_feature": 2,
|
||||
"point": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
]
|
||||
},
|
||||
"x_axis": [
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"at": [
|
||||
2.012802880253332,
|
||||
7.4635742926698185,
|
||||
30.000000000000114
|
||||
],
|
||||
"name": "F2:top",
|
||||
"plane": {
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"x_dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"y_dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"type": "face"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 8,
|
||||
"p": 9,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 9,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"p": 7,
|
||||
"param": "d4",
|
||||
"q": 6,
|
||||
"text": [
|
||||
-0.2,
|
||||
9.200000000000003
|
||||
],
|
||||
"type": "distance",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"j": 1,
|
||||
"k": 7,
|
||||
"l": 10,
|
||||
"p": 9
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 7,
|
||||
"construction": true,
|
||||
"id": "l9",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 8,
|
||||
"construction": true,
|
||||
"id": "l10",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"auto_face": true,
|
||||
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"id": "j1",
|
||||
"kind": "project",
|
||||
"source": {
|
||||
"at": [
|
||||
11.666666666666666,
|
||||
-13.999999999999995,
|
||||
30.0
|
||||
],
|
||||
"body": "Body1",
|
||||
"type": "face"
|
||||
}
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p1",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p2",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p3",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p4",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-31.000000000002185
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
35.0,
|
||||
-31.00000000000349
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
35.0,
|
||||
30.99999999999603
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
-35.0,
|
||||
30.999999999995318
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p9",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "18"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l5",
|
||||
"l4",
|
||||
"l7"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/14",
|
||||
"name": "Untitled",
|
||||
"next_id": 6,
|
||||
"params": [
|
||||
{
|
||||
"expr": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"units": "mm"
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 8430.29544246263,
|
||||
"bbox": {
|
||||
"max": [
|
||||
176.8000000000002,
|
||||
89.30000000000001,
|
||||
14.100000000000001
|
||||
],
|
||||
"min": [
|
||||
99.99999999999991,
|
||||
-1.820765760385257e-14,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
138.3999999716193,
|
||||
51.04392591016203,
|
||||
2.0990634391586322
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 156,
|
||||
"face_types": {
|
||||
"cylinder": 4,
|
||||
"plane": 50
|
||||
},
|
||||
"faces": 54,
|
||||
"kernel": {
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"shells": 1,
|
||||
"vertices": 104
|
||||
},
|
||||
"mass_g": 9.101726874183207,
|
||||
"material": "Default",
|
||||
"name": "Lid",
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 8,
|
||||
"dof": 8,
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"profiles": 2,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 1.178957,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 83.24342399999999,
|
||||
"name": "Fillet1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.150889,
|
||||
"name": "Skirt",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 94.465481,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 50.426736,
|
||||
"name": "Box2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 40.383005,
|
||||
"name": "Box3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 59.041461000000005,
|
||||
"name": "Box4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [
|
||||
{
|
||||
"face_shift_mm": 0.0,
|
||||
"rotation_rad": 0.0,
|
||||
"strategy": "planar_fallback",
|
||||
"tolerance_mm": 0.0,
|
||||
"translation_mm": 0.0
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 8,
|
||||
"ms": 562.5477970000001,
|
||||
"name": "Rib1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": "Boolean retry: exact planar fallback (no displacement)"
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 9,
|
||||
"ms": 89.56076,
|
||||
"name": "Rib2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 10,
|
||||
"ms": 65.330634,
|
||||
"name": "Rib3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 11,
|
||||
"ms": 97.229403,
|
||||
"name": "Rib4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 8430.29544246263,
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"mass_g": 9.101726874183207,
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"body_names": [
|
||||
"Lid"
|
||||
],
|
||||
"corner": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"height": "2.4",
|
||||
"id": 1,
|
||||
"length": "76.8",
|
||||
"name": "Box1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "89.3"
|
||||
},
|
||||
{
|
||||
"edge_names": [
|
||||
"F1:-x|F1:-y",
|
||||
"F1:+x|F1:-y",
|
||||
"F1:+y|F1:-x",
|
||||
"F1:+x|F1:+y"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
-1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
100.0,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
100.0,
|
||||
89.3,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
89.3,
|
||||
1.2
|
||||
]
|
||||
],
|
||||
"id": 2,
|
||||
"name": "Fillet1",
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"radius": "3",
|
||||
"suppressed": false,
|
||||
"type": "fillet"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"plane": {
|
||||
"base": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"distance": "2.4",
|
||||
"type": "offset"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 8,
|
||||
"p": 8
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
102.65,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
174.15,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
174.15,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
102.65,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
104.15,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
172.65,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
172.65,
|
||||
85.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
104.15,
|
||||
85.15
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "3"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d6"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"targets": [
|
||||
"Lid"
|
||||
],
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
104.4,
|
||||
5.4,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 5,
|
||||
"length": "68",
|
||||
"name": "Box2",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d7",
|
||||
"d8",
|
||||
"d9"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "61"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
116.4,
|
||||
72.0,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 6,
|
||||
"length": "44",
|
||||
"name": "Box3",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d10",
|
||||
"d11",
|
||||
"d12"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "10"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
127.4,
|
||||
84.0,
|
||||
2.4
|
||||
],
|
||||
"height": "4",
|
||||
"id": 7,
|
||||
"length": "22",
|
||||
"name": "Box4",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d13",
|
||||
"d14",
|
||||
"d15"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 8,
|
||||
"length": "1.6",
|
||||
"name": "Rib1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d16",
|
||||
"d17",
|
||||
"d18"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 9,
|
||||
"length": "1.6",
|
||||
"name": "Rib2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d19",
|
||||
"d20",
|
||||
"d21"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 10,
|
||||
"length": "1.6",
|
||||
"name": "Rib3",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d22",
|
||||
"d23",
|
||||
"d24"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 11,
|
||||
"length": "1.6",
|
||||
"name": "Rib4",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d25",
|
||||
"d26",
|
||||
"d27"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/15",
|
||||
"name": "issue-616-lid-ribs",
|
||||
"next_id": 12,
|
||||
"params": [],
|
||||
"units": "mm"
|
||||
}
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 2400.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
50.0,
|
||||
20.0,
|
||||
20.0
|
||||
],
|
||||
"min": [
|
||||
30.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
40.0,
|
||||
10.0,
|
||||
10.0
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 10.32,
|
||||
"material": "Default",
|
||||
"name": "Block",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 8000.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 600.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
3.552713678800501e-15,
|
||||
90.0,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
-9.999999999999998,
|
||||
80.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
-4.999999999999999,
|
||||
85.0,
|
||||
5.000000000000001
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 1.29,
|
||||
"material": "Default",
|
||||
"name": "Peg",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 1000.0
|
||||
}
|
||||
],
|
||||
"body_count": 2,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "Peg",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.204585,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 2.318662,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.109196,
|
||||
"name": "Peg",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 0.338859,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 0.312259,
|
||||
"name": "Move1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 0.23144699999999999,
|
||||
"name": "Move2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 3000.0,
|
||||
"edges": 24,
|
||||
"faces": 12,
|
||||
"mass_g": 11.61,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 9000.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"}
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/13",
|
||||
"format": "solidcraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}}
|
||||
]
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/13",
|
||||
"format": "solidcraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
+145
-11
@@ -1,15 +1,15 @@
|
||||
//! Designs saved by older SolveCraft builds (tests/corpus, see its README) open and evaluate as
|
||||
//! Designs saved by older SolidCraft builds (tests/corpus, see its README) open and evaluate as
|
||||
//! they did when they were saved; damaged and hostile design files never panic.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Session;
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
let mut v: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solvecraft")).collect();
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
|
||||
v.sort();
|
||||
assert!(v.len() >= 7, "corpus files in {}", dir.display());
|
||||
v
|
||||
@@ -56,7 +56,7 @@ fn old_designs_open_and_evaluate_as_saved() {
|
||||
problems.push(format!("{name}: feature {}: {e}", r.id));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded");
|
||||
assert_eq!(s.doc.format, solidcraft_engine::doc::format::format_string(solidcraft_engine::doc::format::FORMAT), "{name}: upgraded");
|
||||
let got = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
|
||||
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
|
||||
@@ -84,8 +84,11 @@ fn old_designs_open_and_evaluate_as_saved() {
|
||||
}
|
||||
}
|
||||
// Saved again, it reads back the same.
|
||||
let again = solvecraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(again, *s.doc, "{name}: round trip");
|
||||
let package = solidcraft_engine::io::write_design_container(&s.doc).unwrap();
|
||||
let packaged = solidcraft_engine::io::read_design(&package).unwrap();
|
||||
assert_eq!(packaged, *s.doc, "{name}: container round trip after migration");
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
@@ -95,7 +98,7 @@ fn old_designs_open_and_evaluate_as_saved() {
|
||||
/// they are found as the build that saved them found them, without new notes.
|
||||
#[test]
|
||||
fn names_saved_before_exact_history_find_the_faces_they_named() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solvecraft");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solidcraft");
|
||||
let s = open(&path);
|
||||
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
|
||||
@@ -106,7 +109,7 @@ fn names_saved_before_exact_history_find_the_faces_they_named() {
|
||||
}
|
||||
// The raised piece is the left one, as saved (the stored point is on it).
|
||||
let state = s.model.state();
|
||||
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
|
||||
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
|
||||
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
|
||||
}
|
||||
|
||||
@@ -115,7 +118,7 @@ fn names_saved_before_exact_history_find_the_faces_they_named() {
|
||||
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
|
||||
#[test]
|
||||
fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solvecraft");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solidcraft");
|
||||
let s = open(&path);
|
||||
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
|
||||
for r in &s.model.results {
|
||||
@@ -123,11 +126,142 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
}
|
||||
let state = s.model.state();
|
||||
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
|
||||
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
|
||||
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
|
||||
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
|
||||
}
|
||||
|
||||
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
|
||||
/// planes as outward normal and distance from the origin, or every flat face of the body,
|
||||
/// issue). Face indices are not used: they can change between builds. Each issue has an
|
||||
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
|
||||
/// check until it is removed from here.
|
||||
type Planes = &'static [([f64; 3], f64)];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 1] = [("issue-616-lid-ribs", "Lid", None, 628)];
|
||||
|
||||
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
|
||||
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
|
||||
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
|
||||
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
|
||||
if *d != design || *b != body {
|
||||
return None;
|
||||
}
|
||||
match planes {
|
||||
None => Some((*issue, false)),
|
||||
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn undo_to(s: &mut Session, features: usize) {
|
||||
for _ in 0..4 {
|
||||
if s.doc.features.len() <= features {
|
||||
return;
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
|
||||
}
|
||||
|
||||
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
|
||||
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
|
||||
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
|
||||
/// (design, body, face, issue it is known under, what went wrong or None).
|
||||
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
|
||||
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
|
||||
let mut out = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let mut s = open(&p);
|
||||
let state = s.model.state();
|
||||
let placed = s.placed_document();
|
||||
let mut picks = Vec::new();
|
||||
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
|
||||
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
|
||||
continue;
|
||||
};
|
||||
let mesh = body.body.tessellate(0.01).unwrap();
|
||||
for index in 0..body.body.face_count() {
|
||||
if body.body.planar_face(index).is_none() {
|
||||
continue;
|
||||
}
|
||||
// The middle of the face's largest triangle: well inside the face.
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
|
||||
let Some(point) = point else { continue };
|
||||
let point = apply_point(&frame, point);
|
||||
let normal =
|
||||
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
|
||||
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
|
||||
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
|
||||
}
|
||||
}
|
||||
let before = s.doc.features.len();
|
||||
for (body, index, (normal, distance), plane) in picks {
|
||||
let mut problem = None;
|
||||
match s.execute("sketch.create", &json!({"plane": plane})) {
|
||||
Ok(created) => {
|
||||
let id = created["sketch"].as_u64().unwrap_or(0);
|
||||
match s.placed_document().sketch_face_attachment(id, &s.model) {
|
||||
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
|
||||
other => problem = Some(format!("sketch attachment {other:?}")),
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
undo_to(&mut s, before);
|
||||
}
|
||||
Err(e) => problem = Some(format!("sketch.create: {e}")),
|
||||
}
|
||||
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
|
||||
Ok(_) => undo_to(&mut s, before),
|
||||
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
|
||||
}
|
||||
let known = known_without_attachment(&name, &body, normal, distance);
|
||||
out.push((name.clone(), body, index, known, problem));
|
||||
}
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
|
||||
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
|
||||
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
|
||||
#[test]
|
||||
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
|
||||
let all = flat_face_attachments();
|
||||
assert!(all.len() > 50, "only {} faces checked", all.len());
|
||||
let problems: Vec<String> = all
|
||||
.iter()
|
||||
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
|
||||
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
|
||||
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
|
||||
}
|
||||
|
||||
fn known_faces_attach(issue: u32) {
|
||||
let problems: Vec<String> = flat_face_attachments()
|
||||
.into_iter()
|
||||
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
|
||||
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #628 is fixed"]
|
||||
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
|
||||
known_faces_attach(628);
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
@@ -181,7 +315,7 @@ fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
|
||||
|
||||
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
|
||||
fn try_bytes(bytes: &[u8]) {
|
||||
if let Ok(doc) = solvecraft_engine::io::read_design(bytes) {
|
||||
if let Ok(doc) = solidcraft_engine::io::read_design(bytes) {
|
||||
let mut s = Session::new(doc);
|
||||
let _ = s.execute("inspect.measure", &json!({}));
|
||||
let _ = s.execute("document.inspect", &json!({}));
|
||||
@@ -197,7 +331,7 @@ fn generated() -> Vec<(String, Vec<u8>)> {
|
||||
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
|
||||
};
|
||||
let design = |name: &str, features: Value| {
|
||||
json!({"format": "solvecraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
|
||||
json!({"format": "solidcraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
|
||||
};
|
||||
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
|
||||
// by the point count and abort on the allocation.
|
||||
@@ -5,7 +5,7 @@ use std::process::Command;
|
||||
#[test]
|
||||
fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
|
||||
@@ -15,14 +15,14 @@ fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
#[test]
|
||||
fn empty_seed_range_at_maximum_is_valid() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_seed_runs_once_without_wrapping() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
+5
-5
@@ -6,17 +6,17 @@ use std::time::Duration;
|
||||
|
||||
fn round_of(path: &std::path::Path) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let doc = solvecraft_engine::io::read_design(&bytes).ok()?;
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).ok()?;
|
||||
doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killed_mid_save_the_old_file_survives() {
|
||||
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
|
||||
let exe = env!("CARGO_BIN_EXE_solidcraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-kill-mid-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("design.solvecraft");
|
||||
let path = dir.join("design.solidcraft");
|
||||
let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap();
|
||||
assert!(ok.success());
|
||||
assert_eq!(round_of(&path).as_deref(), Some("0"));
|
||||
@@ -44,7 +44,7 @@ fn killed_mid_save_the_old_file_survives() {
|
||||
let round = round_of(&path);
|
||||
assert!(round.is_some(), "after kill {i} the design file is torn or missing");
|
||||
seen.insert(round);
|
||||
let bak = solvecraft_engine::io::backup_path(&path);
|
||||
let bak = solidcraft_engine::io::backup_path(&path);
|
||||
if bak.exists() {
|
||||
assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn");
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_forwards_with_a_deprecation_note() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_passes_the_exit_status_through() {
|
||||
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
|
||||
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
|
||||
assert_eq!(alias.status.code(), real.status.code());
|
||||
assert_eq!(alias.stdout, real.stdout);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
||||
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
|
||||
// A port that was free a moment ago: nothing listens on it.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
@@ -0,0 +1,203 @@
|
||||
//! A parameter change gives the same topology as building at the new value from scratch, and
|
||||
//! changing it back gives the original topology: no stale splits, no faces added to regions
|
||||
//! nothing touched. Over the design corpus (`tests/corpus`, `tests/action_matrix/designs`), and
|
||||
//! the README demo's Raspberry Pi case pinned step by step.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
|
||||
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// A folder of its own for test `name` (tests run side by side).
|
||||
fn scratch_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-rebuild-topology-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A body's topology: its face and edge counts, and how many faces lie in each plane.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Topology {
|
||||
faces: usize,
|
||||
edges: usize,
|
||||
planes: BTreeMap<[i64; 4], usize>,
|
||||
}
|
||||
|
||||
/// Every solid body's topology, by name.
|
||||
fn topology(s: &Session) -> BTreeMap<String, Topology> {
|
||||
let st = s.world_state();
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.map(|b| {
|
||||
let mut planes = BTreeMap::new();
|
||||
for i in 0..b.body.face_count() {
|
||||
if let Some(FaceSurface::Plane { normal, point }) = face_surface(&b.body, i) {
|
||||
let key = [normal.x, normal.y, normal.z, normal.dot(point) / 100.].map(|x| (x * 1e4).round() as i64);
|
||||
*planes.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
(b.name.clone(), Topology { faces: b.body.face_count(), edges: b.body.edge_count(), planes })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// The design built from scratch: saved as it is (timeline only, no cached geometry) and
|
||||
/// opened again.
|
||||
fn rebuilt(s: &Session, path: &Path) -> Session {
|
||||
let mut t = s.scratch();
|
||||
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
|
||||
open(path)
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<u64> {
|
||||
s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect()
|
||||
}
|
||||
|
||||
/// The corpus's designs, as sessions.
|
||||
fn designs() -> Vec<(String, Session)> {
|
||||
let root = root();
|
||||
let mut out = Vec::new();
|
||||
for dir in ["apps/solidcraft-cli/tests/corpus", "tests/action_matrix/designs"] {
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for p in files {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
|
||||
open(&p)
|
||||
} else {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
|
||||
s.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
if s.active_sketch.is_some() {
|
||||
let _ = s.execute("sketch.finish", &json!({}));
|
||||
}
|
||||
s
|
||||
};
|
||||
out.push((name, s));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each design and its first length parameter that edits cleanly (the user's first): the parameter
|
||||
/// scaled by 1.1 gives what a fresh build at that value gives, and setting it back gives the
|
||||
/// original topology.
|
||||
#[test]
|
||||
fn a_parameter_edit_rebuilds_like_a_fresh_build_and_reverts_exactly() {
|
||||
let dir = scratch_dir("corpus");
|
||||
let mut problems = Vec::new();
|
||||
let mut checked = 0;
|
||||
for (name, s) in designs() {
|
||||
let original = topology(&s);
|
||||
let before = errors(&s);
|
||||
let mut params: Vec<_> = s.doc.params.iter().filter(|p| p.unit == "mm").cloned().collect();
|
||||
params.sort_by_key(|p| p.model);
|
||||
let mut tried = 0;
|
||||
for p in params {
|
||||
if tried == 1 {
|
||||
break;
|
||||
}
|
||||
let mut t = s.scratch();
|
||||
let expr = format!("({}) * 1.1", p.expr);
|
||||
if t.execute("parameters.change", &json!({"name": p.name, "expression": expr})).is_err() || errors(&t) != before {
|
||||
continue;
|
||||
}
|
||||
tried += 1;
|
||||
checked += 1;
|
||||
let edited = topology(&t);
|
||||
let fresh = topology(&rebuilt(&t, &dir.join(format!("{name}-{}.solidcraft", p.name))));
|
||||
if edited != fresh {
|
||||
problems.push(format!("{name}: {} = {expr}: edited {edited:?}\n fresh {fresh:?}", p.name));
|
||||
}
|
||||
t.execute("parameters.change", &json!({"name": p.name, "expression": p.expr})).unwrap();
|
||||
let back = topology(&t);
|
||||
if back != original {
|
||||
problems.push(format!("{name}: {} back to {}: {back:?}\n original {original:?}", p.name, p.expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(checked >= 8, "only {checked} parameter edits checked");
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// The README demo's Raspberry Pi case (`examples/demo/enclosure_ui_mcp.py --story pi`): its
|
||||
/// face count after each feature, and each outer side (the four walls and the bottom) one face
|
||||
/// throughout: (a) built at height 24, (b) built at 24 and changed to 32 in Parameters, (c)
|
||||
/// built at 32. The port cut-outs and the floor vents used to split the long wall (or the end
|
||||
/// wall at 24) into strips along the openings' tops and bottoms.
|
||||
#[test]
|
||||
fn pi_case_faces_per_step_at_both_heights() {
|
||||
// Sketch1 (1) .. RectangularPattern2 (14): the faces once features 1..=k are built.
|
||||
const FACES: [(usize, usize); 13] =
|
||||
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
|
||||
let dir = scratch_dir("pi");
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
|
||||
let at = |height: &str, file: &str| {
|
||||
let mut d = design.clone();
|
||||
for p in d["params"].as_array_mut().unwrap() {
|
||||
if p["name"] == "height" {
|
||||
p["expr"] = json!(height);
|
||||
}
|
||||
}
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
open(&path)
|
||||
};
|
||||
let a = at("24 mm", "pi-24.solidcraft");
|
||||
let mut b = at("24 mm", "pi-24-to-32.solidcraft");
|
||||
b.execute("parameters.change", &json!({"name": "height", "expression": "32 mm"})).unwrap();
|
||||
let c = at("32 mm", "pi-32.solidcraft");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut per_case = Vec::new();
|
||||
for (case, s, h) in [("(a) at 24", a, 24.), ("(b) 24 changed to 32", b, 32.), ("(c) at 32", c, 32.)] {
|
||||
assert!(errors(&s).is_empty(), "{case}: features fail: {:?}", errors(&s));
|
||||
let mut steps = Vec::new();
|
||||
for (k, want) in FACES {
|
||||
let mut t = s.scratch();
|
||||
t.execute("timeline.rollback", &json!({"position": k})).unwrap();
|
||||
let st = t.world_state();
|
||||
let body = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
|
||||
assert_eq!(body.body.face_count(), want, "{case}: faces after feature {k}");
|
||||
// Each outer side: the faces in its plane.
|
||||
for (n, d) in [([0., -1., 0.], 33.), ([0., 1., 0.], 33.), ([-1., 0., 0.], 47.5), ([1., 0., 0.], 47.5), ([0., 0., -1.], 0.)] {
|
||||
let on = (0..body.body.face_count())
|
||||
.filter(|&i| match face_surface(&body.body, i) {
|
||||
Some(FaceSurface::Plane { normal, point }) => {
|
||||
(normal.x - n[0]).abs() + (normal.y - n[1]).abs() + (normal.z - n[2]).abs() < 1e-6 && (normal.dot(point) - d).abs() < 1e-6
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.count();
|
||||
assert_eq!(on, 1, "{case}: after feature {k} the outer side facing {n:?} is {on} faces");
|
||||
}
|
||||
let bbox_top = body.mesh().bounds().max.z;
|
||||
assert!((bbox_top - h).abs() < 1e-6, "{case}: height {bbox_top}");
|
||||
steps.push(topology(&t));
|
||||
}
|
||||
per_case.push((case, steps));
|
||||
}
|
||||
// (b) and (c): the same topology after every feature.
|
||||
let (b, c) = (&per_case[1].1, &per_case[2].1);
|
||||
for (k, (x, y)) in b.iter().zip(c).enumerate() {
|
||||
assert_eq!(x, y, "after feature {}: (b) differs from (c)", k + 2);
|
||||
}
|
||||
}
|
||||
+44
-7
@@ -12,7 +12,7 @@ struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ impl Drop for Scratch {
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
fn json_output(output: Output) -> Value {
|
||||
@@ -43,10 +43,27 @@ fn body_count(path: &Path) -> Value {
|
||||
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_loads_both_design_encodings_by_contents() {
|
||||
let dir = Scratch::new();
|
||||
for extension in ["solidcraft", "solvecraft", "json"] {
|
||||
let saved = dir.0.join(format!("box.{extension}"));
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
|
||||
let bytes = std::fs::read(&saved).unwrap();
|
||||
assert_eq!(solidcraft_engine::io::is_design_container(&bytes), extension != "json");
|
||||
let renamed = dir.0.join(format!("{extension}.unusual"));
|
||||
std::fs::write(&renamed, bytes).unwrap();
|
||||
let measured = json_output(cli().arg("eval").arg(&renamed).output().unwrap());
|
||||
assert_eq!(measured["body_count"], 1);
|
||||
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 24.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_still_save_and_export_the_script_once() {
|
||||
let dir = Scratch::new();
|
||||
let saved = dir.0.join("box.solvecraft");
|
||||
// JSON is explicit here because the fixture edits its saved format tag.
|
||||
let saved = dir.0.join("box.json");
|
||||
let stl = dir.0.join("box.stl");
|
||||
let obj = dir.0.join("box.obj");
|
||||
let result = json_output(
|
||||
@@ -64,8 +81,8 @@ fn results_still_save_and_export_the_script_once() {
|
||||
fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let base = dir.0.join("base.solvecraft");
|
||||
let saved = dir.0.join("combined.solvecraft");
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
let saved = dir.0.join("combined.solidcraft");
|
||||
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
|
||||
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
@@ -77,7 +94,7 @@ fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
fn results_propagate_output_errors() {
|
||||
let dir = Scratch::new();
|
||||
for flag in ["--save", "--out"] {
|
||||
let path = dir.0.join("missing").join(if flag == "--save" { "box.solvecraft" } else { "box.stl" });
|
||||
let path = dir.0.join("missing").join(if flag == "--save" { "box.solidcraft" } else { "box.stl" });
|
||||
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(!output.stderr.is_empty());
|
||||
@@ -89,9 +106,29 @@ fn results_propagate_output_errors() {
|
||||
#[test]
|
||||
fn results_without_script_still_fail() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solvecraft");
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
|
||||
}
|
||||
|
||||
/// Compatibility shim (docs/compatibility.md): a design saved before the rename, with the
|
||||
/// legacy extension and format tag, still opens; re-saving writes the current tag.
|
||||
#[test]
|
||||
fn legacy_extension_and_format_tag_still_open() {
|
||||
let dir = Scratch::new();
|
||||
// JSON is explicit here because the fixture edits its saved format tag.
|
||||
let saved = dir.0.join("box.json");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
|
||||
let text = std::fs::read_to_string(&saved).unwrap();
|
||||
assert!(text.contains("\"solidcraft/"), "new designs carry the current tag");
|
||||
let legacy = dir.0.join("old.solvecraft");
|
||||
std::fs::write(&legacy, text.replace("\"solidcraft/", "\"solvecraft/")).unwrap();
|
||||
assert_eq!(body_count(&legacy), 1);
|
||||
let resaved = dir.0.join("resaved.solidcraft");
|
||||
json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap());
|
||||
assert_eq!(body_count(&resaved), 2);
|
||||
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
|
||||
assert!(reopened.to_json().contains("\"solidcraft/"));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! A timeline feature that fails fails `run`, and nothing is written: the bodies lack what the
|
||||
//! feature should have made, and handing them out with exit 0 hides the failure.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-run-strict-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A filleted box whose fillet a later parameter change makes impossible: every command
|
||||
/// succeeds, the timeline ends with a failing feature.
|
||||
fn broken_script(&self) -> PathBuf {
|
||||
let path = self.0.join("broken.json");
|
||||
let script = json!({"commands": [
|
||||
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
|
||||
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
|
||||
]});
|
||||
std::fs::write(&path, script.to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_feature_fails_the_run_and_writes_nothing() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.broken_script();
|
||||
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
|
||||
for extra in [&[][..], &["--results"][..], &["--quiet"][..]] {
|
||||
let output = cli().arg("run").arg(&script).args(extra).arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("Fillet1") && stderr.contains("nothing was written"), "{stderr}");
|
||||
assert!(output.stdout.is_empty(), "{extra:?}");
|
||||
assert!(!step.exists() && !saved.exists(), "{extra:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_errors_writes_anyway_and_a_saved_design_keeps_failing() {
|
||||
let dir = Scratch::new();
|
||||
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
|
||||
let output = cli().arg("run").arg(dir.broken_script()).arg("--allow-errors").arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let measured: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(measured["total"]["volume_mm3"], json!(4000.0), "the box without its fillet");
|
||||
assert!(std::fs::metadata(&step).unwrap().len() > 0);
|
||||
// The saved design still holds the failing feature: running it fails too, `eval` reports it.
|
||||
let again = dir.0.join("again.step");
|
||||
let output = cli().arg("run").arg(&saved).arg("--out").arg(&again).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(!again.exists());
|
||||
let output = cli().arg("eval").arg(&saved).output().unwrap();
|
||||
assert!(output.status.success());
|
||||
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert!(report["timeline"].as_array().unwrap().iter().any(|f| f["error"].is_string()), "{}", report["timeline"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_timeline_still_writes() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.0.join("box.json");
|
||||
std::fs::write(&script, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
|
||||
let step = dir.0.join("box.step");
|
||||
let output = cli().arg("run").arg(&script).arg("--out").arg(&step).arg("--quiet").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(std::fs::metadata(&step).unwrap().len() > 0);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "solvecraft-web"
|
||||
name = "solidcraft-web"
|
||||
description = "SolidCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
@@ -10,12 +10,12 @@ publish = false
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-web"
|
||||
name = "solidcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Build with: trunk build --release (from apps/solidcraft-web)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8781
|
||||
|
||||
# Offline use and updates: release builds get sw.js listing this build's files (xtask/src/web_offline.rs).
|
||||
[[hooks]]
|
||||
stage = "post_build"
|
||||
command = "cargo"
|
||||
command_arguments = ["xtask", "web-offline"]
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolidCraft</title>
|
||||
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="SolidCraft">
|
||||
<meta property="og:title" content="SolidCraft: try it in your browser">
|
||||
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
|
||||
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SolidCraft: try it in your browser">
|
||||
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<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="css" href="../../assets/styles/web/loading.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solidcraft_canvas"></canvas>
|
||||
<div id="solidcraft_loading">Loading SolidCraft…</div>
|
||||
<script>
|
||||
// Offline use and updates (sw.js; written into release builds by `cargo xtask web-offline`).
|
||||
if ("serviceWorker" in navigator) {
|
||||
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
|
||||
//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a
|
||||
//! the engine's in-memory files (`solidcraft_engine::io::vfs`) at start, written back whenever a
|
||||
//! command writes one. Files come in from the computer through a file picker (the File System
|
||||
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
|
||||
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
|
||||
@@ -9,7 +9,7 @@ use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
|
||||
use solvecraft_engine::io::vfs;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
|
||||
@@ -20,8 +20,11 @@ pub const OPFS: &str = "/opfs/";
|
||||
pub const DOWNLOADS: &str = "/downloads/";
|
||||
/// Files brought in from the computer.
|
||||
pub const UPLOAD: &str = "/upload/";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`).
|
||||
const RECOVERY: &str = "recovery.solvecraft";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
|
||||
const RECOVERY: &str = "recovery.solidcraft";
|
||||
/// The autosave earlier releases kept (compatibility shim, docs/compatibility.md): recovered
|
||||
/// when there is no current one.
|
||||
const LEGACY_RECOVERY: &str = "recovery.solvecraft";
|
||||
const RECOVERY_META: &str = "recovery.json";
|
||||
|
||||
/// What to do with a file brought in from the computer.
|
||||
@@ -87,7 +90,10 @@ thread_local! {
|
||||
}
|
||||
|
||||
/// Local storage keys of stored files.
|
||||
const LOCAL_PREFIX: &str = "solvecraft.file.";
|
||||
const LOCAL_PREFIX: &str = "solidcraft.file.";
|
||||
/// Local-storage files kept by earlier releases (compatibility shim, docs/compatibility.md):
|
||||
/// still listed and opened; removing one removes either key.
|
||||
const LEGACY_LOCAL_PREFIX: &str = "solvecraft.file.";
|
||||
|
||||
fn local() -> Result<web_sys::Storage, String> {
|
||||
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string())
|
||||
@@ -177,7 +183,9 @@ async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
|
||||
async fn remove_file(name: &str) -> Result<(), String> {
|
||||
if local_only() {
|
||||
return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
|
||||
let st = local()?;
|
||||
st.remove_item(&format!("{LEGACY_LOCAL_PREFIX}{name}")).map_err(err)?;
|
||||
return st.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
|
||||
}
|
||||
let dir = root().await?;
|
||||
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
|
||||
@@ -190,7 +198,7 @@ fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..st.length().unwrap_or(0).min(10_000) {
|
||||
let Some(k) = st.key(i).ok().flatten() else { continue };
|
||||
let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue };
|
||||
let Some(name) = k.strip_prefix(LOCAL_PREFIX).or_else(|| k.strip_prefix(LEGACY_LOCAL_PREFIX)) else { continue };
|
||||
if let Some(v) = st.get_item(&k).ok().flatten() {
|
||||
out.push((name.to_string(), decode(&v), 0.0));
|
||||
}
|
||||
@@ -251,7 +259,14 @@ pub fn start(files: &Rc<Files>) {
|
||||
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
|
||||
for (name, bytes, modified) in all {
|
||||
if name == LEGACY_RECOVERY {
|
||||
if !has_recovery {
|
||||
vfs::insert(&format!("{OPFS}{RECOVERY}"), bytes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if name == RECOVERY_META {
|
||||
let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
|
||||
let n = meta["name"].as_str().unwrap_or("Untitled").to_string();
|
||||
@@ -349,7 +364,7 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
|
||||
if files.entries.borrow().iter().any(|e| e.name == to) {
|
||||
return Err(format!("there is already a file named {to}"));
|
||||
}
|
||||
let bytes = vfs::read(&format!("{OPFS}{from}"), solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
let bytes = vfs::read(&format!("{OPFS}{from}"), solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
|
||||
delete(files, from);
|
||||
flush(files);
|
||||
@@ -357,11 +372,17 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Keep (or drop) the autosave of the open design.
|
||||
pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) {
|
||||
pub fn autosave(files: &Rc<Files>, doc: Option<(&solidcraft_engine::doc::Document, Option<&str>)>) {
|
||||
let files = files.clone();
|
||||
match doc {
|
||||
Some((d, path)) => {
|
||||
let bytes = solvecraft_engine::io::write_design(d);
|
||||
let bytes = match solidcraft_engine::io::write_design_container(d) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
files.say(format!("autosave: {e}"), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
@@ -375,6 +396,7 @@ pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Documen
|
||||
}
|
||||
None => spawn_local(async move {
|
||||
let _ = remove_file(RECOVERY).await;
|
||||
let _ = remove_file(LEGACY_RECOVERY).await;
|
||||
let _ = remove_file(RECOVERY_META).await;
|
||||
}),
|
||||
}
|
||||
@@ -436,7 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".solvecraft,.step,.stp,.3mf,.stl");
|
||||
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
let _ = input.set_attribute("style", "display:none");
|
||||
if let Some(body) = doc.body() {
|
||||
@@ -1,6 +1,6 @@
|
||||
//! SolveCraft in the browser.
|
||||
//! SolidCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`solvecraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
|
||||
//! sample part.
|
||||
@@ -13,15 +13,15 @@ mod files;
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::io::vfs;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::files::{self, Files, Intent};
|
||||
|
||||
const CANVAS_ID: &str = "solvecraft_canvas";
|
||||
const LOADING_ID: &str = "solvecraft_loading";
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
@@ -63,7 +63,7 @@ mod web {
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solvecraft_ui_egui::fonts::CjkFontLoader,
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ mod web {
|
||||
}
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
// A design from the computer is kept in the browser (a copy), so Save works.
|
||||
let path = files::free_name(&name);
|
||||
@@ -110,7 +110,7 @@ mod web {
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
if changed && wait <= 0.0 {
|
||||
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
|
||||
files::autosave(&self.files, Some((&s.design(), s.path.as_deref())));
|
||||
self.autosaved = Some(s.revision);
|
||||
self.autosaved_at = now;
|
||||
} else if changed {
|
||||
@@ -133,13 +133,13 @@ mod web {
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let recent = &mut self.app.home.recent;
|
||||
recent.retain(|p| vfs::exists(p));
|
||||
for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
|
||||
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
|
||||
let p = format!("{}{}", files::OPFS, e.name);
|
||||
if !recent.contains(&p) {
|
||||
recent.push(p);
|
||||
}
|
||||
}
|
||||
recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
|
||||
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
@@ -206,7 +206,7 @@ mod web {
|
||||
if ui.small_button("Open").clicked() {
|
||||
act = Some(("open", e.name.clone()));
|
||||
}
|
||||
let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
|
||||
act = Some(("insert", e.name.clone()));
|
||||
}
|
||||
@@ -241,7 +241,7 @@ mod web {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"download" => match vfs::read(&path, solvecraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
Ok(b) => {
|
||||
if let Err(e) = files::download(&name, &b) {
|
||||
self.app.set_status(e, true);
|
||||
@@ -251,8 +251,13 @@ mod web {
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let bytes = solvecraft_engine::io::write_design(d);
|
||||
if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) {
|
||||
let result = self
|
||||
.app
|
||||
.session
|
||||
.design_container(solidcraft_engine::io::DesignCompression::Fast, false, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
|
||||
if let Err(e) = result {
|
||||
self.app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
@@ -318,7 +323,7 @@ mod web {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solvecraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
@@ -326,22 +331,34 @@ mod web {
|
||||
};
|
||||
if installed {
|
||||
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
|
||||
} else {
|
||||
} 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);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
let slot = self.font_result.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
|
||||
*slot.borrow_mut() = Some(res);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
self.fetch_font(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_font(&self, ctx: &egui::Context) {
|
||||
let slot = self.font_result.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
|
||||
*slot.borrow_mut() = Some(res);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
@@ -349,7 +366,7 @@ mod web {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
@@ -360,19 +377,17 @@ mod web {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.file_panel(&ctx);
|
||||
self.recovery_prompt(&ctx);
|
||||
self.watch_text_for_font(&ctx);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
// 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(PREFS_KEY, self.app.prefs());
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the preferences (theme) are kept (the browser's local storage).
|
||||
const PREFS_KEY: &str = "solvecraft.prefs";
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -411,7 +426,7 @@ mod web {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let files = Rc::new(Files::default());
|
||||
files::start(&files);
|
||||
let panel = files.clone();
|
||||
@@ -423,13 +438,13 @@ mod web {
|
||||
})),
|
||||
// Designs are kept in the browser; everything else is downloaded.
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT);
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
@@ -437,7 +452,7 @@ mod web {
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
@@ -480,5 +495,5 @@ fn main() {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("solvecraft-web only runs in the browser: build it with `trunk build --release` in apps/solvecraft-web");
|
||||
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// SolidCraft's service worker: the web app keeps working offline and updates itself.
|
||||
//
|
||||
// `cargo xtask web-offline` (Trunk's post_build hook) writes the build into the line below: the
|
||||
// version (workspace version + a hash of every built file) and the files to keep, each with its
|
||||
// SHA-256, and the same as `build.json` next to it. Files the app only fetches when it needs
|
||||
// them (the CJK font) aren't kept: they come from the network. Every download is checked
|
||||
// against its SHA-256: a deployment that lands mid-download (another build's `index.html`) fails
|
||||
// the download instead of mixing into the kept build. A new build changes this file, so the
|
||||
// browser installs the new worker, which keeps the whole new build before taking over.
|
||||
//
|
||||
// A launch only ever starts a build whose every file is kept, so a half-downloaded update can't
|
||||
// break it. It asks for `build.json` (waiting at most NAVIGATE_TIMEOUT_MS): offline, slow or
|
||||
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
|
||||
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
|
||||
// started. When the site has no `build.json` (the app was removed), launches go to the network,
|
||||
// the kept build answering when that fails. Each page then gets its files from the build it
|
||||
// started (remembered in PAGES), never from another build with a file of the same name.
|
||||
//
|
||||
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
|
||||
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
|
||||
// an open page uses the build it started. Then unfinished downloads are dropped, and so are
|
||||
// builds replaced by a newer worker (RETIRED). The origin is shared with other apps (github.io)
|
||||
// and other copies of this one (the release zip works from any path): only caches named after
|
||||
// this worker's path (CACHE_PREFIX…, PAGES) are ever touched.
|
||||
//
|
||||
// Unbuilt (VERSION "dev", as debug builds get it), the worker retires: it drops what a release
|
||||
// build kept and unregisters, so `trunk serve` after `trunk serve --release --no-autoreload`
|
||||
// shows the development build. Deploying this file unbuilt switches the worker off for everyone.
|
||||
/* @build */ const VERSION = "dev", FILES = [];
|
||||
const RETIRE = VERSION === "dev";
|
||||
|
||||
const PATH = new URL(self.registration.scope).pathname;
|
||||
const CACHE_PREFIX = `solidcraft-app ${PATH} `;
|
||||
const CACHE = CACHE_PREFIX + VERSION;
|
||||
/** Which build each open page started: page id → cache name (or NETWORK). */
|
||||
const PAGES = `solidcraft-app-pages ${PATH}`;
|
||||
/** A page that came from the network gets its files from there too. */
|
||||
const NETWORK = "network";
|
||||
const NAVIGATE_TIMEOUT_MS = 4000;
|
||||
const UPDATE_TIMEOUT_MS = 20000;
|
||||
/** The entry that marks a cache as holding its whole build. */
|
||||
const COMPLETE = "build-complete";
|
||||
/** The entry that marks a build replaced by a newer worker. */
|
||||
const RETIRED = "build-retired";
|
||||
/** A page remembered this recently may still be loading (not yet listed among the clients). */
|
||||
const PAGE_LOADING_MS = 30 * 1000;
|
||||
|
||||
const url = (file) => new URL(file, self.registration.scope).href;
|
||||
const has = (name, entry) => caches.match(url(entry), { cacheName: name }).then(Boolean);
|
||||
|
||||
/** Run `fn` while using the cache `name`: it isn't deleted meanwhile. */
|
||||
const using = (name, fn) => navigator.locks.request(name, { mode: "shared" }, fn);
|
||||
|
||||
/** Delete the cache `name` if nothing is using it and `unwanted()` (asked once no one can start to) agrees. */
|
||||
const drop = (name, unwanted) =>
|
||||
navigator.locks.request(name, { ifAvailable: true }, async (free) => free && (await unwanted()) && caches.delete(name));
|
||||
|
||||
const hex = (buffer) => Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
/**
|
||||
* Keep every file of a build (an earlier partial download is resumed); resolves to its cache's
|
||||
* name. The first file that fails cancels the others.
|
||||
*/
|
||||
function keep(version, files) {
|
||||
const name = CACHE_PREFIX + version;
|
||||
return using(name, async () => {
|
||||
const cache = await caches.open(name);
|
||||
await cache.delete(url(RETIRED)); // Back in use (a rollback).
|
||||
if (await has(name, COMPLETE)) return name;
|
||||
const cancel = new AbortController();
|
||||
const results = await Promise.allSettled(
|
||||
files.map(async ({ file, sha256 }) => {
|
||||
try {
|
||||
if (await cache.match(url(file))) return;
|
||||
// `no-cache`: revalidate with the server, so a stale HTTP-cached page never mixes builds.
|
||||
const response = await fetch(new Request(url(file), { cache: "no-cache" }), { signal: cancel.signal });
|
||||
if (!response.ok) throw new Error(`${file}: HTTP ${response.status}`);
|
||||
const body = await response.arrayBuffer();
|
||||
if (hex(await crypto.subtle.digest("SHA-256", body)) !== sha256) throw new Error(`${file}: not this build's`);
|
||||
await cache.put(url(file), new Response(body, { headers: response.headers }));
|
||||
} catch (error) {
|
||||
cancel.abort();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const failed = results.find((r) => r.status === "rejected");
|
||||
if (failed) throw failed.reason;
|
||||
await cache.put(url(COMPLETE), new Response(version));
|
||||
return name;
|
||||
});
|
||||
}
|
||||
|
||||
const pageEntry = (id) => url(`~page/${encodeURIComponent(id)}`);
|
||||
|
||||
/** Remember which build a page started. */
|
||||
async function started(id, build) {
|
||||
if (!id) return;
|
||||
const record = JSON.stringify({ build, at: Date.now() });
|
||||
await (await caches.open(PAGES)).put(pageEntry(id), new Response(record));
|
||||
}
|
||||
|
||||
/** The build a page started; a page started before it was remembered gets this worker's. */
|
||||
async function startedBy(id) {
|
||||
const response = id ? await caches.match(pageEntry(id), { cacheName: PAGES }) : null;
|
||||
return response ? (await response.json()).build : CACHE;
|
||||
}
|
||||
|
||||
/** The builds open pages use (pages closed since are forgotten). */
|
||||
async function inUse() {
|
||||
const open = new Set((await self.clients.matchAll({ includeUncontrolled: true })).map((c) => c.id));
|
||||
const pages = await caches.open(PAGES);
|
||||
const builds = new Set();
|
||||
for (const request of await pages.keys()) {
|
||||
const id = decodeURIComponent(request.url.split("~page/")[1] ?? "");
|
||||
const record = await pages.match(request).then((r) => r?.json()).catch(() => null);
|
||||
if (record && (open.has(id) || Date.now() - record.at < PAGE_LOADING_MS)) builds.add(record.build);
|
||||
else await pages.delete(request);
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
|
||||
/** Drop unfinished downloads nothing is using, and replaced builds no open page uses. */
|
||||
async function sweep() {
|
||||
for (const name of await caches.keys()) {
|
||||
if (!name.startsWith(CACHE_PREFIX) || name === CACHE) continue;
|
||||
await drop(name, async () => !(await has(name, COMPLETE)) || ((await has(name, RETIRED)) && !(await inUse()).has(name)));
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Clean up first: a page open for days sees updates (and failed ones) without any launch.
|
||||
const install = () => sweep().catch(() => {}).then(() => keep(VERSION, FILES));
|
||||
event.waitUntil(RETIRE ? self.skipWaiting() : install().then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
if (RETIRE) {
|
||||
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
|
||||
return self.registration.unregister();
|
||||
}
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
}
|
||||
await sweep();
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
/** The promise's value, or null when it fails or takes longer than `ms`. */
|
||||
function within(ms, promise) {
|
||||
return Promise.race([promise.catch(() => null), new Promise((resolve) => setTimeout(resolve, ms, null))]);
|
||||
}
|
||||
|
||||
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
|
||||
const GONE = "gone";
|
||||
|
||||
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
|
||||
async function deployed() {
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache" });
|
||||
if (response.status === 404 || response.status === 410) return GONE;
|
||||
const build = response.ok ? await response.json() : null;
|
||||
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
|
||||
return valid && build.files.every((f) => typeof f.file === "string" && typeof f.sha256 === "string") ? build : null;
|
||||
}
|
||||
|
||||
/** Keep the worker alive for `promise` (allowed while the launch is being answered). */
|
||||
function extend(event, promise) {
|
||||
try {
|
||||
event.waitUntil(promise.catch(() => {}));
|
||||
} catch {
|
||||
// The event has ended; the work goes on while the worker runs.
|
||||
}
|
||||
}
|
||||
|
||||
/** A launch: the start page of a complete build (whatever query the URL has: `?sample`), and that build. */
|
||||
async function launch(event) {
|
||||
const kept = await caches.match(url("index.html"), { cacheName: CACHE });
|
||||
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
|
||||
extend(event, sweep());
|
||||
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
|
||||
if (build === GONE) {
|
||||
const page = await fetch(event.request).catch(() => null);
|
||||
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
|
||||
}
|
||||
if (!build || build.version === VERSION) return [kept, CACHE];
|
||||
const download = keep(build.version, build.files);
|
||||
extend(event, download);
|
||||
const name = await within(UPDATE_TIMEOUT_MS, download);
|
||||
const page = name && (await caches.match(url("index.html"), { cacheName: name }));
|
||||
return page ? [page, name] : [kept, CACHE];
|
||||
}
|
||||
|
||||
/**
|
||||
* A launch that fails unexpectedly still starts the kept build (or asks the network). A newer
|
||||
* worker only takes over once the launch has ended, by when its page is remembered (in use).
|
||||
*/
|
||||
async function launchOrKept(event) {
|
||||
let page, build;
|
||||
try {
|
||||
[page, build] = await launch(event);
|
||||
} catch {
|
||||
page = await caches.match(url("index.html"), { cacheName: CACHE }).catch(() => null);
|
||||
build = page ? CACHE : NETWORK;
|
||||
page = page || (await fetch(event.request));
|
||||
}
|
||||
await started(event.resultingClientId, build).catch(() => {});
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A page's files come from the build it started, else the network. */
|
||||
async function asset(event) {
|
||||
const build = await startedBy(event.clientId).catch(() => CACHE);
|
||||
const kept = build === NETWORK ? null : await caches.match(event.request, { cacheName: build, ignoreSearch: true });
|
||||
return kept || fetch(event.request);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (RETIRE || request.method !== "GET" || new URL(request.url).origin !== self.location.origin) return;
|
||||
const path = request.url.split(/[?#]/)[0];
|
||||
const start = request.mode === "navigate" && (path === url("") || path === url("index.html"));
|
||||
event.respondWith(start ? launchOrKept(event) : asset(event));
|
||||
});
|
||||
@@ -1,27 +1,30 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
name = "solidcraft"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solvecraft"
|
||||
default-run = "solidcraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
getrandom = "0.3"
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
@@ -0,0 +1,17 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons/app/windows/exports/solidcraft-executable.ico");
|
||||
println!("cargo:rerun-if-changed={}", icon.display());
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
|
||||
return Ok(());
|
||||
}
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
|
||||
let copy = out.join("solidcraft.ico");
|
||||
std::fs::copy(icon, ©)?;
|
||||
let rc = out.join("solidcraft.rc");
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/")))?;
|
||||
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_engine::control_auth;
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The token file this launch wrote, removed when the app ends.
|
||||
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// A new random token: 32 bytes from the operating system, as hex.
|
||||
fn new_token() -> Option<String> {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).ok()?;
|
||||
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// Remove this launch's token file (at exit).
|
||||
pub fn remove_token_file() {
|
||||
if let Some(p) = TOKEN_FILE.get() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// Every connection must show the token before anything runs (docs/control-protocol.md).
|
||||
let Some(token) = control_auth::env_token().or_else(new_token) else {
|
||||
eprintln!("solidcraft: control server not started: no random token available");
|
||||
return rx;
|
||||
};
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
match control_auth::token_path(port) {
|
||||
Some(path) => match control_auth::write_token_file(&path, &token) {
|
||||
Ok(()) => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
let _ = TOKEN_FILE.set(path);
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
|
||||
clients need SOLIDCRAFT_CONTROL_TOKEN",
|
||||
path.display()
|
||||
),
|
||||
},
|
||||
None => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN")
|
||||
}
|
||||
}
|
||||
let token = std::sync::Arc::new(token);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &token, tx, ctx);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
||||
/// killed, so the line is refused as soon as it passes `max`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let (done, used) = {
|
||||
let available = r.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
match available.iter().position(|b| *b == b'\n') {
|
||||
Some(i) => {
|
||||
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
||||
(true, i + 1)
|
||||
}
|
||||
None => {
|
||||
line.extend_from_slice(available);
|
||||
(false, available.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
r.consume(used);
|
||||
if done {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if line.len() > max {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to a connection that didn't show the token; the connection is then closed.
|
||||
fn unauthorized() -> Value {
|
||||
json!({"ok": false, "error": "unauthorized: send the control token as \"token\" (see docs/control-protocol.md)"})
|
||||
}
|
||||
|
||||
/// 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<TcpStream>) {
|
||||
use std::io::Read;
|
||||
let _ = writeln!(out, "{}", unauthorized());
|
||||
let _ = out.shutdown(std::net::Shutdown::Write);
|
||||
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());
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
let mut authenticated = false;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
// The first request must carry the token; later ones on the same connection may.
|
||||
if !authenticated {
|
||||
match msg.get("token").and_then(Value::as_str) {
|
||||
Some(t) if control_auth::tokens_match(t, token) => authenticated = true,
|
||||
_ => {
|
||||
refuse(&mut out, &mut reader);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 });
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(wait).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(_) if !authenticated => {
|
||||
refuse(&mut out, &mut reader);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
if bad >= MAX_BAD_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
|
||||
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
|
||||
/// need not be repeated on that connection.
|
||||
#[test]
|
||||
fn requests_run_only_after_the_token() {
|
||||
use std::io::Read;
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = ran.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
let _ = req.reply.send(json!({"ok": true, "result": null}));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
|
||||
}
|
||||
});
|
||||
let session = |lines: &[String]| {
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
for l in lines {
|
||||
let _ = writeln!(s, "{l}");
|
||||
}
|
||||
let _ = s.shutdown(std::net::Shutdown::Write);
|
||||
let mut text = String::new();
|
||||
let _ = s.read_to_string(&mut text);
|
||||
text.lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect::<Vec<_>>()
|
||||
};
|
||||
for first in [
|
||||
json!({"id": 1, "method": "app.quit", "params": {"force": true}}).to_string(),
|
||||
json!({"id": 1, "method": "app.quit", "token": "0123456789abcdef0123456789abcdee"}).to_string(),
|
||||
json!({"id": 1, "method": "app.quit", "token": "0123"}).to_string(),
|
||||
json!({"id": 1, "method": "app.quit", "token": 7}).to_string(),
|
||||
"not json".to_string(),
|
||||
] {
|
||||
let replies = session(&[first.clone(), json!({"id": 2, "method": "app.quit", "token": token}).to_string()]);
|
||||
assert_eq!(replies.len(), 1, "{first}: closed after refusing: {replies:?}");
|
||||
assert!(replies[0]["error"].as_str().is_some_and(|e| e.starts_with("unauthorized")), "{first}: {replies:?}");
|
||||
}
|
||||
assert!(ran.lock().unwrap().is_empty(), "nothing ran without the token");
|
||||
let replies = session(&[
|
||||
json!({"id": 1, "method": "ui.inspect", "token": token}).to_string(),
|
||||
json!({"id": 2, "method": "engine.commands"}).to_string(),
|
||||
]);
|
||||
assert_eq!(replies.len(), 2, "{replies:?}");
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
assert_eq!(*ran.lock().unwrap(), ["ui.inspect", "engine.commands"]);
|
||||
}
|
||||
|
||||
/// A request longer than the cap is refused instead of allocated.
|
||||
#[test]
|
||||
fn lines_past_the_cap_are_refused() {
|
||||
let flood = vec![b'A'; MAX_LINE + 2];
|
||||
let mut r = BufReader::new(flood.as_slice());
|
||||
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(e.to_string().contains("request too large"), "{e}");
|
||||
}
|
||||
|
||||
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
|
||||
#[test]
|
||||
fn lines_split_on_newlines() {
|
||||
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
||||
let mut r = BufReader::new(text.as_slice());
|
||||
let mut got = Vec::new();
|
||||
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
||||
got.push(String::from_utf8(line).unwrap());
|
||||
}
|
||||
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A line that spans several reads of the underlying stream still arrives whole.
|
||||
#[test]
|
||||
fn lines_longer_than_one_buffer_arrive_whole() {
|
||||
let long = "x".repeat(200_000);
|
||||
let text = format!("{long}\n{long}\n").into_bytes();
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
// The same line against a smaller cap is refused.
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert!(read_line(&mut r, 1000).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,45 @@
|
||||
//! SolveCraft desktop app.
|
||||
//! SolidCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft | part.step]`
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
|
||||
//!
|
||||
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
|
||||
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
|
||||
//!
|
||||
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
|
||||
//! See `solvecraft_ui_egui::control` and docs/control-protocol.md.
|
||||
//! See `solidcraft_ui_egui::control` and docs/control-protocol.md.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[path = "../../app_icon.rs"]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod space_mouse;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
use solidcraft_engine::{Session, io};
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// The design on screen is the one whose commands a crash report replays.
|
||||
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
// designs first; the window closes once they are answered (`quit_requested`).
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -49,25 +52,39 @@ impl eframe::App for App {
|
||||
self.solve.ui(ui);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.solve.prefs());
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the preferences (theme) are kept in eframe's storage.
|
||||
const PREFS_KEY: &str = "solvecraft.prefs";
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
"Designs, STEP, IGES and meshes",
|
||||
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
|
||||
&[
|
||||
io::DESIGN_EXT,
|
||||
io::LEGACY_DESIGN_EXT,
|
||||
"json",
|
||||
"step",
|
||||
"stp",
|
||||
"STEP",
|
||||
"STP",
|
||||
"igs",
|
||||
"iges",
|
||||
"IGS",
|
||||
"IGES",
|
||||
"3mf",
|
||||
"3MF",
|
||||
"stl",
|
||||
"STL",
|
||||
],
|
||||
)
|
||||
.add_filter("SolidCraft design", &["solvecraft"])
|
||||
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||
@@ -77,7 +94,17 @@ fn services() -> Services {
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
let dialog = rfd::FileDialog::new().set_file_name(name);
|
||||
let dialog = if exts == [io::DESIGN_EXT, "json"] {
|
||||
if name.ends_with(".json") {
|
||||
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
|
||||
} else {
|
||||
dialog.add_filter("SolidCraft container", &[io::DESIGN_EXT]).add_filter("Plain JSON design", &["json"])
|
||||
}
|
||||
} else {
|
||||
dialog.add_filter("File", exts)
|
||||
};
|
||||
dialog.save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
@@ -100,10 +127,51 @@ fn services() -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
|
||||
fn settings_dir() -> Option<std::path::PathBuf> {
|
||||
eframe::storage_dir("SolidCraft")
|
||||
}
|
||||
|
||||
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
|
||||
/// window opens).
|
||||
fn graphics_path() -> Option<std::path::PathBuf> {
|
||||
eframe::storage_dir("SolveCraft").map(|d| d.join("graphics.json"))
|
||||
settings_dir().map(|d| d.join("graphics.json"))
|
||||
}
|
||||
|
||||
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
|
||||
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
|
||||
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
|
||||
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
|
||||
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
|
||||
/// are never changed.
|
||||
fn adopt_legacy_settings() {
|
||||
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
|
||||
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
|
||||
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(¤t);
|
||||
if let Err(e) = adopt_settings(¤t, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
|
||||
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn adopt_settings(
|
||||
current: &std::path::Path,
|
||||
old_flatpak: Option<&std::path::Path>,
|
||||
legacy: &std::path::Path,
|
||||
legacy_eframe: Option<&std::path::Path>,
|
||||
) -> std::io::Result<()> {
|
||||
let fresh = !current.exists();
|
||||
if let Some(old) = old_flatpak {
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
|
||||
}
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
|
||||
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
|
||||
std::fs::create_dir_all(current)?;
|
||||
let new = current.join("app.ron");
|
||||
if !new.exists() {
|
||||
std::fs::copy(old, new)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
@@ -118,7 +186,7 @@ fn msaa() -> u16 {
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the application bar is the title bar
|
||||
/// (`solvecraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
|
||||
/// (`solidcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
@@ -127,13 +195,15 @@ const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
// Let macOS use the bundle's layered icon; eframe's default bitmap would replace it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let icon = egui::IconData::default();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let icon =
|
||||
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(egui::IconData {
|
||||
rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?,
|
||||
width: 64,
|
||||
height: 64,
|
||||
})
|
||||
.with_icon(icon)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
@@ -141,12 +211,16 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"))
|
||||
.with_app_id(APP_ID))
|
||||
}
|
||||
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
adopt_legacy_settings();
|
||||
let samples = msaa();
|
||||
let mut control_port: Option<u16> = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -155,21 +229,47 @@ fn main() -> eframe::Result {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--crash-log" => {
|
||||
match solidcraft_engine::crash::default_dir() {
|
||||
Some(dir) => {
|
||||
println!("crash reports: {}", dir.display());
|
||||
for r in solidcraft_engine::crash::list(&dir).iter().take(5) {
|
||||
println!(" {} {} {}", r.id, r.kind, r.message);
|
||||
}
|
||||
println!("more: solidcraft-cli crashes");
|
||||
}
|
||||
None => println!("no crash folder on this system"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [design.solidcraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
|
||||
let crashed = solidcraft_engine::crash::default_dir()
|
||||
.map(|dir| {
|
||||
let build = solidcraft_engine::crash::Build {
|
||||
version: solidcraft_ui_egui::help::VERSION,
|
||||
commit: solidcraft_ui_egui::help::COMMIT,
|
||||
built: solidcraft_ui_egui::help::BUILD_DATE,
|
||||
};
|
||||
solidcraft_engine::crash::install(dir, build)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: app_viewport()?,
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
// Kept with the other settings, not in a folder named after the app id.
|
||||
persistence_path: settings_dir().map(|d| d.join("app.ron")),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -179,34 +279,41 @@ fn main() -> eframe::Result {
|
||||
std::env::var_os("DISPLAY").is_some(),
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
||||
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
|
||||
std::env::var("SOLVECRAFT_WAYLAND").ok().as_deref(),
|
||||
solidcraft_engine::geom::env::var("WAYLAND").as_deref(),
|
||||
) {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
let result = eframe::run_native(
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
let info = rs.adapter.get_info();
|
||||
solidcraft_engine::crash::set_environment(&format!(
|
||||
"Renderer: {:?} {} ({}), MSAA {}x",
|
||||
info.backend, info.name, info.driver, rs.msaa_samples
|
||||
));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if sample {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
@@ -223,26 +330,30 @@ fn main() -> eframe::Result {
|
||||
app.home.show(rev);
|
||||
}
|
||||
// Autosave, and offer what crashed sessions left behind.
|
||||
if let Some(dir) = solvecraft_engine::recovery::default_dir() {
|
||||
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
// Tell the user about crashes since the last start (after the recovery offer is set up).
|
||||
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
);
|
||||
control_server::remove_token_file();
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
||||
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
|
||||
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
|
||||
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
|
||||
/// `SOLVECRAFT_WAYLAND=1` keeps native Wayland, `SOLVECRAFT_WAYLAND=0` forces XWayland.
|
||||
/// `SOLIDCRAFT_WAYLAND=1` keeps native Wayland, `SOLIDCRAFT_WAYLAND=0` forces XWayland.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
|
||||
match wayland_override.map(str::trim) {
|
||||
@@ -269,7 +380,7 @@ mod backend_tests {
|
||||
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
|
||||
// niri without a Wayland socket (nested X session) still uses X11.
|
||||
assert!(prefer_x11(true, false, Some("niri"), None));
|
||||
// SOLVECRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
|
||||
// SOLIDCRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
|
||||
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
|
||||
@@ -279,18 +390,49 @@ mod backend_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_preserves_the_bundle_icon_instead_of_setting_a_runtime_bitmap() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
assert_eq!(viewport.icon.as_deref(), Some(&eframe::egui::IconData::default()));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
let icon = viewport.icon.expect("running window must have an icon");
|
||||
assert_eq!((icon.width, icon.height), (64, 64));
|
||||
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some("ai.storyteller.solvecraft"));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
#[test]
|
||||
fn first_launch_adopts_the_previous_release_settings() {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let (current, legacy, legacy_eframe) = (d.join("solidcraft"), d.join("solvecraft"), d.join("ai.storyteller.solvecraft"));
|
||||
std::fs::create_dir_all(&legacy).unwrap();
|
||||
std::fs::create_dir_all(&legacy_eframe).unwrap();
|
||||
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
|
||||
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
|
||||
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
|
||||
// Later launches leave the current settings alone.
|
||||
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
|
||||
std::fs::remove_file(current.join("graphics.json")).unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
|
||||
assert!(!current.join("graphics.json").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_multisampling_uses_the_selected_backend_policy() {
|
||||
use eframe::wgpu::Backend;
|
||||
@@ -1,15 +1,22 @@
|
||||
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
|
||||
//! it publishes the latest axes, and the egui thread applies them once per frame.
|
||||
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
|
||||
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
|
||||
//! applies them once per frame.
|
||||
|
||||
mod hid_descriptor;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod hidraw;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hid_descriptor::Layout;
|
||||
#[cfg(target_os = "windows")]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_ui_egui::SolveApp;
|
||||
use solidcraft_engine::render::Camera;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
@@ -178,7 +185,10 @@ fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
|
||||
}
|
||||
|
||||
fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
|
||||
let cfg = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg");
|
||||
// The driver names the profile after the executable; one made for the pre-rename executable
|
||||
// still applies until the driver writes a new one (compatibility shim).
|
||||
let profile = ["solidcraft-KMJ.xml", "solvecraft-KMJ.xml"].into_iter().map(|n| cfg.join(n)).find(|p| p.is_file())?;
|
||||
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
|
||||
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
|
||||
return None;
|
||||
@@ -208,9 +218,14 @@ impl Reading {
|
||||
}
|
||||
|
||||
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
|
||||
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
|
||||
matches!(vendor_id, 0x046d | 0x256f)
|
||||
}
|
||||
|
||||
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
|
||||
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
@@ -220,8 +235,18 @@ fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
|
||||
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
|
||||
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
|
||||
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
|
||||
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
|
||||
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
|
||||
match report.first().copied() {
|
||||
Some(1) if layout == Layout::Combined => {
|
||||
let Some(body) = report.get(1..) else { return false };
|
||||
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.translation_at = now;
|
||||
reading.rotation_at = now;
|
||||
true
|
||||
}
|
||||
Some(1) => {
|
||||
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.translation = translation;
|
||||
@@ -238,6 +263,24 @@ fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The device is gone: stop reporting its last position.
|
||||
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
@@ -247,27 +290,72 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
|
||||
let layout = device_layout(&device);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(&report[..size], &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
/// How the device packs its axes, from its report descriptor; the usual split layout if the
|
||||
/// descriptor cannot be read.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn device_layout(device: &hidapi::HidDevice) -> Layout {
|
||||
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
||||
match device.get_report_descriptor(&mut descriptor) {
|
||||
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
|
||||
Layout::Split
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
|
||||
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
|
||||
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
use std::io::Read;
|
||||
let mut denied = None;
|
||||
loop {
|
||||
if let Some(device) = hidraw::find() {
|
||||
match std::fs::File::open(&device.path) {
|
||||
Ok(mut file) => {
|
||||
denied = None;
|
||||
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
// Say so once per device node, not every retry.
|
||||
Err(error) if denied.as_ref() != Some(&device.path) => {
|
||||
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
|
||||
denied = Some(device.path);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
@@ -286,7 +374,7 @@ impl SpaceMouse {
|
||||
let shared = Arc::new(Mutex::new(Reading::new(now)));
|
||||
let worker_state = Arc::clone(&shared);
|
||||
let worker_ctx = ctx.clone();
|
||||
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
|
||||
@@ -371,19 +459,46 @@ mod tests {
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
|
||||
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert_eq!(reading.current(later), Axes::default());
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_report_one_carries_translation_and_rotation_together() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
|
||||
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
|
||||
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
|
||||
// The same bytes on a split device are only the translation report; its padding is not rotation.
|
||||
let mut split = Reading::new(now);
|
||||
assert!(decode_report(&report, Layout::Split, &mut split, now));
|
||||
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_layout_ignores_short_and_other_reports() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
// Extra padding after the six values is fine.
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
|
||||
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
|
||||
//! it simply yields less information.
|
||||
|
||||
/// Generic Desktop usage page, and the usages in it that matter here.
|
||||
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
|
||||
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
|
||||
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
|
||||
const AXIS_COUNT: u32 = 6;
|
||||
|
||||
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
|
||||
const MOTION_REPORT_ID: u8 = 1;
|
||||
|
||||
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
|
||||
const MAX_DESCRIPTOR_BYTES: usize = 4096;
|
||||
/// Bound on the usages one main item can declare through a usage range.
|
||||
const MAX_USAGES_PER_ITEM: u32 = 256;
|
||||
|
||||
/// How a device spreads its six motion axes over input reports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
|
||||
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
|
||||
#[default]
|
||||
Split,
|
||||
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
|
||||
Combined,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Item {
|
||||
/// 0 main, 1 global, 2 local (3 is reserved).
|
||||
kind: u8,
|
||||
tag: u8,
|
||||
value: u32,
|
||||
}
|
||||
|
||||
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
|
||||
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
|
||||
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
|
||||
let mut pos = 0_usize;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let prefix = *descriptor.get(pos)?;
|
||||
pos += 1;
|
||||
if prefix == 0xFE {
|
||||
// Long item: a data length byte, a tag byte, then the data.
|
||||
let length = usize::from(*descriptor.get(pos)?);
|
||||
pos = pos.checked_add(2 + length)?;
|
||||
continue;
|
||||
}
|
||||
let size = match prefix & 0b11 {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
_ => 4,
|
||||
};
|
||||
let data = descriptor.get(pos..pos + size)?;
|
||||
pos += size;
|
||||
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
|
||||
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Descriptor {
|
||||
multi_axis_controller: bool,
|
||||
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
|
||||
motion_report_axes: u8,
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
pub fn parse(descriptor: &[u8]) -> Self {
|
||||
let mut summary = Self::default();
|
||||
let mut page = 0_u32;
|
||||
let mut report_id = 0_u8;
|
||||
let mut depth = 0_u32;
|
||||
// Local items belong to the next main item only.
|
||||
let mut usages: Vec<u32> = Vec::new();
|
||||
let mut usage_min = None;
|
||||
for item in items(descriptor) {
|
||||
match (item.kind, item.tag) {
|
||||
// Global: usage page, report id.
|
||||
(1, 0) => page = item.value,
|
||||
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
|
||||
// Local: usage, usage minimum, usage maximum.
|
||||
(2, 0) => usages.push(Self::full_usage(page, item)),
|
||||
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
|
||||
(2, 2) => {
|
||||
if let Some(min) = usage_min.take() {
|
||||
let max = Self::full_usage(page, item);
|
||||
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
|
||||
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
|
||||
}
|
||||
}
|
||||
// Main: input, collection, end collection.
|
||||
(0, 8) => {
|
||||
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
|
||||
summary.note_axes(&usages);
|
||||
}
|
||||
}
|
||||
(0, 0xA) => {
|
||||
// An application collection (1) at the top level names the device.
|
||||
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
|
||||
summary.multi_axis_controller = true;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
(0, 0xC) => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if item.kind == 0 {
|
||||
usages.clear();
|
||||
usage_min = None;
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
|
||||
fn full_usage(page: u32, item: Item) -> u32 {
|
||||
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
|
||||
}
|
||||
|
||||
fn note_axes(&mut self, usages: &[u32]) {
|
||||
for usage in usages {
|
||||
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
|
||||
if axis < AXIS_COUNT {
|
||||
self.motion_report_axes |= 1 << axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
|
||||
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn is_multi_axis_controller(&self) -> bool {
|
||||
self.multi_axis_controller
|
||||
}
|
||||
|
||||
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
|
||||
pub fn layout(&self) -> Layout {
|
||||
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
|
||||
const SPLIT: &[u8] = &[
|
||||
0x05, 0x01, // usage page: generic desktop
|
||||
0x09, 0x08, // usage: multi-axis controller
|
||||
0xA1, 0x01, // collection: application
|
||||
0x85, 0x01, // report id 1
|
||||
0x16, 0xA2, 0xFE, // logical minimum -350
|
||||
0x26, 0x5E, 0x01, // logical maximum 350
|
||||
0x75, 0x10, // report size 16
|
||||
0x95, 0x03, // report count 3
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
|
||||
0x81, 0x06, // input: data, variable, relative
|
||||
0x85, 0x02, // report id 2
|
||||
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
|
||||
0x81, 0x06, // input
|
||||
0xC0, // end collection
|
||||
];
|
||||
|
||||
/// Report 1 = X Y Z Rx Ry Rz.
|
||||
const COMBINED: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
|
||||
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
|
||||
const COMBINED_RANGE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
|
||||
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
|
||||
const KEYBOARD: &[u8] =
|
||||
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
|
||||
|
||||
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
|
||||
const MOUSE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
|
||||
0x06, 0xC0, 0xC0,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
|
||||
let descriptor = Descriptor::parse(SPLIT);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0b000_111);
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_in_report_one_are_a_combined_layout() {
|
||||
for bytes in [COMBINED, COMBINED_RANGE] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Combined);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_interfaces_are_not_3d_mice() {
|
||||
for bytes in [KEYBOARD, MOUSE, &[][..]] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(!descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_byte_usages_carry_their_own_page() {
|
||||
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
|
||||
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_hostile_descriptors_never_panic() {
|
||||
for end in 0..=COMBINED.len() {
|
||||
let _ = Descriptor::parse(&COMBINED[..end]);
|
||||
}
|
||||
// Long item with a length running past the end, huge usage ranges, deep nesting.
|
||||
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
|
||||
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
|
||||
let _ = Descriptor::parse(&[0xC0; 64]);
|
||||
let _ = Descriptor::parse(&[0xA1; 64]);
|
||||
let mut state = 0x2545_F491_4F6C_DD1D_u64;
|
||||
for _ in 0..200 {
|
||||
let bytes: Vec<u8> = (0..64)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state.to_le_bytes()[0]
|
||||
})
|
||||
.collect();
|
||||
let _ = Descriptor::parse(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
|
||||
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
|
||||
//! plain file I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::hid_descriptor::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
}
|
||||
|
||||
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
|
||||
fn vendor_id(uevent: &str) -> Option<u16> {
|
||||
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
|
||||
let vendor = id.split(':').nth(1)?;
|
||||
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
|
||||
}
|
||||
|
||||
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
|
||||
pub fn find() -> Option<Device> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
|
||||
names.sort();
|
||||
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
|
||||
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
|
||||
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_vendor_from_the_hid_id() {
|
||||
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
|
||||
assert_eq!(vendor_id(uevent), Some(0x256f));
|
||||
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ids_are_rejected() {
|
||||
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
|
||||
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -1,22 +0,0 @@
|
||||
# Design file corpus
|
||||
|
||||
Designs saved by older SolveCraft builds. `tests/design_corpus.rs` opens each one with the
|
||||
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
|
||||
it is `solvecraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
|
||||
world bounding boxes), that it is upgraded to the current file format, and that damaged and
|
||||
hostile variants of it never panic.
|
||||
|
||||
| File | Saved by | Era |
|
||||
|---|---|---|
|
||||
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
|
||||
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
|
||||
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
|
||||
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
|
||||
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
|
||||
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
|
||||
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
|
||||
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
|
||||
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
|
||||
|
||||
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
|
||||
point is that it was written by the old one. Add new files for new eras instead.
|
||||
@@ -1,10 +0,0 @@
|
||||
# Build with: trunk build --release (from apps/solvecraft-web)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8781
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolidCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="copy-file" href="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
|
||||
#solvecraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#solvecraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solvecraft_canvas"></canvas>
|
||||
<div id="solvecraft_loading">Loading SolidCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,170 +0,0 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::MAX_LINE;
|
||||
use solvecraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once. The channel has no authentication beyond being bound to
|
||||
/// loopback, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, tx, ctx);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
||||
/// killed, so the line is refused as soon as it passes `max`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let (done, used) = {
|
||||
let available = r.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
match available.iter().position(|b| *b == b'\n') {
|
||||
Some(i) => {
|
||||
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
||||
(true, i + 1)
|
||||
}
|
||||
None => {
|
||||
line.extend_from_slice(available);
|
||||
(false, available.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
r.consume(used);
|
||||
if done {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if line.len() > max {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
if bad >= MAX_BAD_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A request longer than the cap is refused instead of allocated.
|
||||
#[test]
|
||||
fn lines_past_the_cap_are_refused() {
|
||||
let flood = vec![b'A'; MAX_LINE + 2];
|
||||
let mut r = BufReader::new(flood.as_slice());
|
||||
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(e.to_string().contains("request too large"), "{e}");
|
||||
}
|
||||
|
||||
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
|
||||
#[test]
|
||||
fn lines_split_on_newlines() {
|
||||
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
||||
let mut r = BufReader::new(text.as_slice());
|
||||
let mut got = Vec::new();
|
||||
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
||||
got.push(String::from_utf8(line).unwrap());
|
||||
}
|
||||
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A line that spans several reads of the underlying stream still arrives whole.
|
||||
#[test]
|
||||
fn lines_longer_than_one_buffer_arrive_whole() {
|
||||
let long = "x".repeat(200_000);
|
||||
let text = format!("{long}\n{long}\n").into_bytes();
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
// The same line against a smaller cap is refused.
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert!(read_line(&mut r, 1000).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Application resources
|
||||
|
||||
`assets/` is the design resource home for SolidCraft.
|
||||
[ATTRIBUTION.md](../ATTRIBUTION.md) is the file inventory and provenance record.
|
||||
Keep one editable master per resource and place shipped derivatives in `exports/`.
|
||||
|
||||
```text
|
||||
assets/
|
||||
icons/
|
||||
app/
|
||||
shared/source/ # original Rust mark and executable ICO encoder
|
||||
macos/source/SolidCraft.icon/ # approved Composer project and linked SVGs
|
||||
macos/exports/ # paired Assets.car and icon.icns
|
||||
windows/exports/ # executable DIB ICO and installer PNG ICO
|
||||
linux/exports/hicolor/ # Linux/FreeBSD package images, 16–512 px
|
||||
ui/source/ # original drawing library and other UI glyphs
|
||||
branding/web/source/og.png # shared social preview card
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
appearances/library.rs # built-in visual presets
|
||||
materials/library.rs # physical defaults and colours
|
||||
shaders/viewport.wgsl # original viewport shader
|
||||
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
|
||||
locales/{en,de,ja,ko}.json # shared offline catalogs
|
||||
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
|
||||
archive/icons/macos-v1/
|
||||
source/icon.vectorcraft # earlier editable work, retained
|
||||
exports/ # all historical SVG, PNG and iconset exports
|
||||
```
|
||||
|
||||
## Editing and regeneration
|
||||
|
||||
[UI artwork](icons/ui/README.md) identifies the drawing masters and their consumers.
|
||||
Edit geometry and IDs together in the original Rust source and review changes in
|
||||
both application themes. Palettes and model presets are also editable Rust data.
|
||||
|
||||
The current macOS master is `icons/app/macos/source/SolidCraft.icon/`, including
|
||||
its linked SVG layers and appearance settings. The earlier VectorCraft document
|
||||
is preserved in the archive. No other native design projects were available for
|
||||
the current UI set or preview card. Keep any future `.vectorcraft`, `.ai` or other
|
||||
native work and linked inputs under that resource's `source/work/`, with an
|
||||
attribution row; retain the editable document alongside its exports.
|
||||
|
||||
Regenerate all exports of the shared Windows/Linux/FreeBSD mark together:
|
||||
|
||||
```sh
|
||||
cargo xtask app-icons
|
||||
```
|
||||
|
||||
For macOS, use Xcode with Icon Composer support:
|
||||
|
||||
```sh
|
||||
mkdir -p target/resources/macos-icon
|
||||
xcrun actool assets/icons/app/macos/source/SolidCraft.icon \
|
||||
--compile target/resources/macos-icon --platform macosx \
|
||||
--minimum-deployment-target 11.0 --app-icon SolidCraft \
|
||||
--output-partial-info-plist target/resources/macos-icon/icon-info.plist \
|
||||
--output-format human-readable-text
|
||||
cp target/resources/macos-icon/Assets.car assets/icons/app/macos/exports/Assets.car
|
||||
cp target/resources/macos-icon/SolidCraft.icns assets/icons/app/macos/exports/icon.icns
|
||||
```
|
||||
|
||||
Review and commit source changes and required exports together. Temporary renders,
|
||||
tool caches and package staging belong under ignored `target/` or `dist/`.
|
||||
Normal builds use the committed exports and require no design applications.
|
||||
The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
|
||||
|
||||
## Platform bindings
|
||||
|
||||
| Platform | Resources and output paths |
|
||||
| --- | --- |
|
||||
| macOS | Composer master and paired exports; bundle installs `Assets.car` and `SolidCraft.icns` |
|
||||
| Windows executable | `solidcraft-executable.ico`: original DIB/mask representations at 16/24/32/48/64/128/256 px |
|
||||
| Windows installer | `solidcraft.ico`: PNG representations at 16/20/24/32/40/48/64/128/256 px; `SOLIDCRAFT_ICONS` override retained |
|
||||
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
|
||||
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
|
||||
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
|
||||
| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists |
|
||||
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
|
||||
|
||||
## Code and provenance
|
||||
|
||||
Resources remain compiled by their owning layer through existing APIs:
|
||||
`ui-egui::icons::paint` and `Tokens` at L5, model presets in L2 `doc`, and sketch
|
||||
fonts at L1. State, projection, interaction, commands and GPU bindings stay in
|
||||
their current modules. Embedded data uses `CARGO_MANIFEST_DIR`; application code
|
||||
needs no runtime resource registry or repository lookup.
|
||||
|
||||
`cargo xtask assets` requires an exact attribution row for every collected file
|
||||
except Markdown documentation, and continues checking file assets elsewhere in
|
||||
the repository. This includes native work, Rust masters, catalogs, shaders and
|
||||
platform templates. Packaging selects explicit current files and fails when
|
||||
required inputs are absent. Archived artwork has no production consumers.
|
||||
|
||||
Dependency-owned egui fonts and vendored renderer shaders stay with their
|
||||
libraries; their licences remain in `THIRD-PARTY-LICENSES.txt` and `ATTRIBUTION.md`.
|
||||
System fonts, optional packs, user fonts/canvases/decals/materials and native OS
|
||||
controls retain external ownership. Root licence/notice texts stay canonical.
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Built-in model appearances: names, sRGB colours and metalness.
|
||||
pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
|
||||
("Aluminum - Satin", [204, 208, 214], 1.0),
|
||||
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
|
||||
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
|
||||
("Steel - Satin", [158, 164, 172], 1.0),
|
||||
("Stainless Steel - Brushed", [186, 191, 198], 1.0),
|
||||
("Cast Iron", [104, 104, 110], 1.0),
|
||||
("Brass - Polished", [208, 172, 88], 1.0),
|
||||
("Copper", [204, 122, 80], 1.0),
|
||||
("Titanium", [150, 148, 158], 1.0),
|
||||
("ABS - White", [236, 236, 230], 1.0),
|
||||
("ABS - Black", [36, 36, 38], 1.0),
|
||||
("ABS - Red", [196, 40, 40], 1.0),
|
||||
("ABS - Blue", [40, 90, 186], 1.0),
|
||||
("ABS - Yellow", [236, 196, 40], 1.0),
|
||||
("ABS - Green", [52, 150, 80], 1.0),
|
||||
("Rubber - Black", [28, 28, 28], 1.0),
|
||||
("Glass - Clear", [190, 220, 228], 0.25),
|
||||
("Glass - Smoked", [90, 96, 104], 0.45),
|
||||
("Wood - Oak", [190, 146, 92], 1.0),
|
||||
("Paint - Gloss White", [246, 246, 244], 1.0),
|
||||
];
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Loaded 100 of 840 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user