Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a824c1eb75 | ||
|
|
ac05e26934 | ||
|
|
4e0601611c | ||
|
|
07708a4e2a | ||
|
|
a4c4b411e5 | ||
|
|
5eb7640b54 | ||
|
|
003abc2149 | ||
|
|
136a3e9a2f | ||
|
|
e70bd52409 | ||
|
|
132b63d24d | ||
|
|
72b7fc385b | ||
|
|
292747e50b | ||
|
|
2e4266cb0a | ||
|
|
b685b7301f | ||
|
|
8f768b65c8 | ||
|
|
932efbf341 | ||
|
|
b8d7b42808 | ||
|
|
a8e8d82f90 | ||
|
|
33cd031701 | ||
|
|
6422ef84d5 | ||
|
|
7c9f8a5895 | ||
|
|
36b6496ad5 | ||
|
|
48986041ed | ||
|
|
c39bd23d07 | ||
|
|
a33c5a7861 | ||
|
|
5c95b0496d | ||
|
|
72aa77dabd | ||
|
|
243db6cb8f | ||
|
|
83c31d77c4 | ||
|
|
ae6a33bf96 | ||
|
|
675c865431 | ||
|
|
2f31138f9d | ||
|
|
8c503ad1be | ||
|
|
dc40f051b1 | ||
|
|
712b427a9c | ||
|
|
e48e553758 | ||
|
|
dc8edead22 | ||
|
|
2acc344aa4 | ||
|
|
487aa64e10 | ||
|
|
180e62c981 | ||
|
|
11a196e6f8 | ||
|
|
a11446fd17 | ||
|
|
7a3221d2dd | ||
|
|
e8776d8583 | ||
|
|
cb857f1fa7 | ||
|
|
e347f820e6 | ||
|
|
6597ffe072 | ||
|
|
4a70c03deb | ||
|
|
f480898158 | ||
|
|
9be8bb5e6b | ||
|
|
843fa0dac6 | ||
|
|
2c924fd385 | ||
|
|
8be736c33e | ||
|
|
f3494efbbb | ||
|
|
6fe16b790b | ||
|
|
979614e60f | ||
|
|
8a31738fe5 | ||
|
|
f7343e9161 | ||
|
|
72145d4aba | ||
|
|
a6eeb88de0 | ||
|
|
49dda52d09 | ||
|
|
09b3bd0e01 | ||
|
|
c4d2ae87ee | ||
|
|
30ca43de7d | ||
|
|
8a2d142333 | ||
|
|
0a66697da8 | ||
|
|
a000bd69cf | ||
|
|
40abcbc8cf | ||
|
|
b6f9b1deac | ||
|
|
b04e0801d6 | ||
|
|
d1b96100d3 | ||
|
|
2f24878bb0 | ||
|
|
b64edf15d3 | ||
|
|
87d4eb55e5 | ||
|
|
78c6364b02 | ||
|
|
b7eac9220b | ||
|
|
4193ea26ac | ||
|
|
40bdc1132e | ||
|
|
e052d19879 | ||
|
|
fbc00bb2c4 | ||
|
|
6bde126a5c | ||
|
|
7d44359cf6 | ||
|
|
29dfa6bd32 | ||
|
|
8c58efdecf | ||
|
|
44732f8b40 | ||
|
|
3fe6bacea0 | ||
|
|
39758b0cc4 | ||
|
|
172782705a | ||
|
|
186555dbe9 | ||
|
|
fd073620ae | ||
|
|
e49770bd24 | ||
|
|
ccd0b5aab8 | ||
|
|
09188c4586 | ||
|
|
f4ed349410 | ||
|
|
aa9119f922 | ||
|
|
9c07bdd823 | ||
|
|
67d4628753 | ||
|
|
64235ab12a | ||
|
|
c9b8267b9e | ||
|
|
a5003da9d7 | ||
|
|
ff7f3204d5 | ||
|
|
a2245b2f51 | ||
|
|
3eae8c34f6 | ||
|
|
c38992fc09 | ||
|
|
962070e955 | ||
|
|
713111d9df | ||
|
|
2831426c51 | ||
|
|
9ce940b0b1 | ||
|
|
9a4d936b7b | ||
|
|
cd9db57366 | ||
|
|
39850fb6ab | ||
|
|
3689f0d6e5 | ||
|
|
ddc6aa6301 | ||
|
|
7f2ae11a06 | ||
|
|
15a13e2452 | ||
|
|
98ce6a4ee8 | ||
|
|
7ba2d5db47 | ||
|
|
0292fa35e6 | ||
|
|
daf1dc4201 | ||
|
|
221e841d32 | ||
|
|
2abf4e5a7d | ||
|
|
f3b5846e6b | ||
|
|
337564190b | ||
|
|
f350c35414 | ||
|
|
64966bab88 | ||
|
|
fb23bab9af | ||
|
|
ef3bdd3433 | ||
|
|
c599238653 | ||
|
|
a6ca2e8ca1 | ||
|
|
f1183bcd21 | ||
|
|
81ac571fa2 | ||
|
|
dece550164 | ||
|
|
8a03355d2b | ||
|
|
31b73473e9 | ||
|
|
0e1cd4c178 | ||
|
|
c28b7a6229 | ||
|
|
b3d63004f2 | ||
|
|
6be3a5ab90 | ||
|
|
9ec7caf9e6 | ||
|
|
37496198bf | ||
|
|
9de0b9d1f1 | ||
|
|
649ad39a81 | ||
|
|
1ef079dc0a | ||
|
|
8390a31531 | ||
|
|
b53f6bad52 | ||
|
|
eb6abecc3d | ||
|
|
ae92d0238d | ||
|
|
d357bd2073 | ||
|
|
c5f3515073 | ||
|
|
c7bb64dd74 | ||
|
|
bb13799246 | ||
|
|
04b4381b54 | ||
|
|
920ea42fe1 | ||
|
|
8907da5e86 | ||
|
|
a9413fe385 | ||
|
|
45ae1bef95 | ||
|
|
6496ec86ec | ||
|
|
ff7c899840 | ||
|
|
19be7d27e7 | ||
|
|
31b2ddc0cf | ||
|
|
e2667b749d | ||
|
|
da65b4fc8c | ||
|
|
741265f214 | ||
|
|
74ce904142 | ||
|
|
a8a3cea1d0 | ||
|
|
f96aaa2a98 | ||
|
|
53ae46e6b5 | ||
|
|
3f81495514 | ||
|
|
c34e607f5b | ||
|
|
3128762014 | ||
|
|
b25e9c75aa | ||
|
|
7119d60d9d | ||
|
|
f5871608f4 | ||
|
|
8fdf6a61a2 | ||
|
|
8a055afdd6 | ||
|
|
bf4551159e | ||
|
|
6b9501f442 | ||
|
|
7f9074d65c | ||
|
|
201e055259 | ||
|
|
a16226288f | ||
|
|
6232fa33c2 | ||
|
|
88f76adbc2 | ||
|
|
bba03803b7 | ||
|
|
4bacd0cae0 | ||
|
|
3501fa2c54 | ||
|
|
77b33efd8f | ||
|
|
37ebe24839 | ||
|
|
54925cdcd5 | ||
|
|
77bdef2f64 | ||
|
|
2c020dbcbc | ||
|
|
1ded8fdd27 | ||
|
|
4a4911b473 | ||
|
|
737c9358d0 | ||
|
|
dd5278a402 | ||
|
|
838d0e86f2 | ||
|
|
d84d8c3222 | ||
|
|
9194f4be09 | ||
|
|
bcacb7066d | ||
|
|
2ae21143ee | ||
|
|
8207f29fc0 | ||
|
|
45742f79e4 | ||
|
|
04a0283082 | ||
|
|
650ba567e7 | ||
|
|
374ec83505 | ||
|
|
4d58d3bbca | ||
|
|
e93f8c1b84 | ||
|
|
bbd4f29ebb | ||
|
|
3126962cd4 | ||
|
|
64b0b674d2 | ||
|
|
3087363c44 | ||
|
|
d5640b5efb | ||
|
|
9b2a40781c | ||
|
|
9d82b94469 | ||
|
|
a9beb437b3 | ||
|
|
db1fd39139 | ||
|
|
07da7f8065 | ||
|
|
7f79443b46 | ||
|
|
9bb65b4d68 | ||
|
|
18ec44d773 | ||
|
|
9422853cf9 | ||
|
|
f57a62deb7 | ||
|
|
61678654c2 | ||
|
|
25c89a706e | ||
|
|
26bf1af93b | ||
|
|
3707a67cee | ||
|
|
571122c4ba | ||
|
|
dcffab09b1 | ||
|
|
6f270f6259 | ||
|
|
dc6eca6830 | ||
|
|
d29ca29578 | ||
|
|
5e9285ca75 | ||
|
|
371578a06f | ||
|
|
44968dbfe6 | ||
|
|
3333f8a62b | ||
|
|
42d4ebeb16 | ||
|
|
d624e0a171 | ||
|
|
3fe929e91c | ||
|
|
c419dec76c | ||
|
|
f91103c51d | ||
|
|
ac3a25fb5b | ||
|
|
8f5073ff14 | ||
|
|
c5b7009ce3 | ||
|
|
f98c5e350d | ||
|
|
2e5b7b11e4 | ||
|
|
6c1a898e3d | ||
|
|
4d862cd76c | ||
|
|
baf44657c1 | ||
|
|
fdb059cfed | ||
|
|
716107f0e4 | ||
|
|
6220d8f8a6 | ||
|
|
c01a7b9b37 | ||
|
|
556e424bb2 | ||
|
|
ce96e625b4 | ||
|
|
5cfcf7fd03 | ||
|
|
fac3f96bdd | ||
|
|
77bdcc47e9 | ||
|
|
987f0be21e | ||
|
|
bd17eeef0c | ||
|
|
e21c5f06ac | ||
|
|
904bf19e83 | ||
|
|
7ef78f960a | ||
|
|
a422fb1a4b | ||
|
|
daf618c344 | ||
|
|
5165e428cd | ||
|
|
1a0b33fcf3 | ||
|
|
48322f6464 | ||
|
|
5c0351b1f4 | ||
|
|
b20fd3d9d6 | ||
|
|
63accec16e | ||
|
|
d188eea1f4 | ||
|
|
2141a3802a | ||
|
|
723c7b61c4 | ||
|
|
1a55241882 | ||
|
|
b562ab51e1 | ||
|
|
105a3146c0 | ||
|
|
f08ed74617 | ||
|
|
342305705d | ||
|
|
a6742180a9 | ||
|
|
abb15e4b0e | ||
|
|
584bbe923d | ||
|
|
9a07932f20 | ||
|
|
067f2642c2 | ||
|
|
af771b9ceb | ||
|
|
4cbf9cf607 | ||
|
|
c361630954 | ||
|
|
845f4913cf | ||
|
|
78242d67d1 | ||
|
|
2b45635d73 | ||
|
|
4097ac0131 | ||
|
|
96d1ad0ab2 | ||
|
|
3d914c9f15 | ||
|
|
1b5c2b5847 | ||
|
|
7160ff7beb | ||
|
|
b400958c3e | ||
|
|
1c9d6617ad | ||
|
|
f7967b1ff6 | ||
|
|
a519f3cf74 | ||
|
|
b44ada89e5 | ||
|
|
6128a20c5f | ||
|
|
73493b94c3 | ||
|
|
061c0f3333 | ||
|
|
3dd8e2d176 | ||
|
|
a13124acdd | ||
|
|
5823900439 |
No files matched your search
@@ -3,19 +3,22 @@
|
||||
|
||||
[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`.
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
|
||||
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
(package(solidcraft-engine) & (
|
||||
test(=tests::hostile_params_never_panic)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(/^tests::robustness_goldens::/)
|
||||
| test(/^tests::robustness_goldens::seed_/)
|
||||
| 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-kernel) & binary(boolean_props))
|
||||
| (package(solidcraft-engine) & binary(atomic_commands))
|
||||
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Bug report
|
||||
description: Something in SolidCraft does the wrong thing
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What did you expect, and what happened instead?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: |
|
||||
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
placeholder: |
|
||||
1. Sketch a circle on the XY plane
|
||||
2. Extrude it 10 mm
|
||||
3. Fillet the top edge with 6 mm
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SolidCraft version and OS
|
||||
description: Help → About shows the version.
|
||||
placeholder: 0.15.0, Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: media
|
||||
attributes:
|
||||
label: Screenshots or recording
|
||||
description: |
|
||||
For anything you can see in the app, a screenshot or short recording of a small example helps the most. Please leave out private designs and personal details.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolidCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Blank issues stay on: the app's Report an Issue opens issues/new?title=…&body=… with the report
|
||||
# filled in (crates/ui-egui/src/issue_report.rs), which GitHub drops when blank issues are off.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions and chat
|
||||
url: https://discord.gg/FtU4NQNVDH
|
||||
about: Ask questions and talk with users and contributors on the SolidCraft Discord.
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Feature request
|
||||
description: Something SolidCraft should do
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: What are you trying to do
|
||||
description: The part or workflow you have in mind, and where SolidCraft gets in the way today.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: How you imagine it working
|
||||
description: Optional. Describe the behaviour in your own words; please don't paste another product's documentation.
|
||||
@@ -0,0 +1,4 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- self-hosted-fast
|
||||
- self-hosted-light
|
||||
@@ -1,9 +1,9 @@
|
||||
SolidCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
|
||||
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solidcraft/)
|
||||
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
|
||||
parameter to watch the timeline rebuild. [Documentation](https://docs.solidcraft.io/)
|
||||
· [try it in the browser](https://solidcraft.io/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:
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
`solidcraft-cli mcp` ([how](https://docs.solidcraft.io/guide/mcp.html)).
|
||||
|
||||
**What's new** in SolidCraft {version} is listed below, and the
|
||||
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
|
||||
@@ -41,16 +41,20 @@ 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 solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists solidcraft https://docs.solidcraft.io/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft ai.storyteller.solidcraft
|
||||
flatpak run ai.storyteller.solidcraft
|
||||
```
|
||||
|
||||
A remote added from the old `bherbruck.github.io` address no longer finds updates: run
|
||||
`flatpak remote-modify --user --url=https://docs.solidcraft.io/flatpak/repo/ solidcraft`, then
|
||||
`flatpak update --user`.
|
||||
|
||||
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).
|
||||
still need an internet connection. [Linux installation details](https://docs.solidcraft.io/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
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`).
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
|
||||
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
|
||||
# packages that depend on them, and Clippy, the test archive and the test shards cover only
|
||||
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
|
||||
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
|
||||
# pushes to main, the nightly run and Run workflow.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly.
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
|
||||
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
|
||||
# nightly always run them. Only main saves the build caches; pull requests restore them.
|
||||
# The merge queue runs the full suite on exactly the commit it then puts on main, so a push to
|
||||
# main that a passing merge-queue run already tested runs only the repository checks; a push
|
||||
# that skipped the queue still runs everything. Pull requests run the reference tests when the
|
||||
# command line (or anything it depends on) changes, and doc tests for the affected packages.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Trusted runs use both pools: pushes to main, merge-queue groups and pull requests from a branch
|
||||
# of this repository (people with write access; fork pull requests never run on this machine).
|
||||
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
|
||||
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
|
||||
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
|
||||
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
|
||||
# `self-hosted-untrusted` runners (no warm caches); everything else runs on GitHub's runners.
|
||||
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
|
||||
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
|
||||
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
|
||||
# use GitHub's runners only; the self-hosted runners refuse any other job.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# The merge queue: each queued group runs the full suite before it lands on main.
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
@@ -45,10 +71,23 @@ jobs:
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
# Trusted runs (see the top of this file) use the self-hosted runners.
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
permissions:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
actions: read
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
kernel: ${{ steps.paths.outputs.kernel }}
|
||||
windows: ${{ steps.paths.outputs.windows }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
packages: ${{ steps.paths.outputs.packages }}
|
||||
oracle: ${{ steps.paths.outputs.oracle }}
|
||||
# Whether the other Linux jobs use the self-hosted runners too.
|
||||
# Trusted self-hosted runs only: a fork PR's checks run on the untrusted pool and must never
|
||||
# route the jobs after them to the trusted runners.
|
||||
local: ${{ runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -59,16 +98,43 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
native=false
|
||||
kernel=false
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
windows=false
|
||||
full=true
|
||||
packages=
|
||||
if [ "$EVENT_NAME" = push ]; then
|
||||
# The queue lands the very commit its merge_group run tested. If that run passed,
|
||||
# testing the push again proves nothing new; otherwise (a merge that skipped the
|
||||
# queue, or the API cannot answer) test everything.
|
||||
# curl and grep only: Select checks of a push runs on the self-hosted runner.
|
||||
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
|
||||
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
|
||||
if [ "${tested:-0}" -gt 0 ]; then
|
||||
echo "The merge queue already tested $PUSH_SHA: repository checks only."
|
||||
else
|
||||
native=true
|
||||
windows=true
|
||||
fi
|
||||
elif [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
windows=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
|
||||
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
|
||||
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
|
||||
case "$path" in
|
||||
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
|
||||
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
@@ -76,55 +142,116 @@ jobs:
|
||||
*) native=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
|
||||
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
|
||||
&& git diff "$BASE_SHA...$HEAD_SHA" -- Cargo.lock \
|
||||
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
|
||||
windows=true
|
||||
fi
|
||||
# Pull requests build and test the touched packages and their dependents.
|
||||
python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected"
|
||||
cat "$RUNNER_TEMP/ci-affected"
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
# Code changed but nothing was selected: be safe and test everything.
|
||||
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
|
||||
fi
|
||||
echo "native=$native" >> "$GITHUB_OUTPUT"
|
||||
echo "kernel=$kernel" >> "$GITHUB_OUTPUT"
|
||||
# The reference tests replay recipes through solidcraft-cli: needed when it, or anything
|
||||
# it depends on, is selected.
|
||||
oracle=$native
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
|
||||
fi
|
||||
# The pull request fast lane: Windows and the reference tests run on main (and the nightly),
|
||||
# not on every pull request push; a break there is fixed forward on main.
|
||||
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; fi
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "kernel=$kernel"
|
||||
echo "windows=$windows"
|
||||
echo "oracle=$oracle"
|
||||
echo "full=$full"
|
||||
echo "packages=$packages"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh checks
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Asset attribution
|
||||
run: cargo xtask assets
|
||||
- name: UI icon standards and review tool
|
||||
run: cargo xtask icons
|
||||
- name: Licences
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
- name: Sorted lists
|
||||
run: cargo xtask lists
|
||||
- name: Design format pending changes
|
||||
run: bash scripts/test-format-pending.sh
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh clippy
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Clippy
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests lint the affected packages (scripts/ci/affected.py); everything else, all.
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
cargo clippy "${scope[@]}" --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
|
||||
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 90 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -136,6 +263,7 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
@@ -144,7 +272,14 @@ jobs:
|
||||
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
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests archive only the affected packages (scripts/ci/affected.py).
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
cargo nextest archive "${scope[@]}" --cargo-profile ci --locked --archive-file tests.tar.zst
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
@@ -158,14 +293,27 @@ jobs:
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
# Pull requests test their affected crates in 2 shards; main and the nightly use 4.
|
||||
shard: ${{ fromJSON(github.event_name == 'pull_request' && '[1, 2]' || '[1, 2, 3, 4]') }}
|
||||
env:
|
||||
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
|
||||
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
|
||||
# would skip for want of an adapter fails instead.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
# Preinstalled on the self-hosted runner, which has no sudo.
|
||||
if: runner.environment == 'github-hosted'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -176,24 +324,19 @@ jobs:
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
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
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
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)))"
|
||||
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -267,11 +410,38 @@ jobs:
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
|
||||
|
||||
property-tests:
|
||||
# The boolean property tests and the atomic command tests with many cases. Pull requests skip
|
||||
# them (.config/nextest.toml); pushes to main run atomic_commands with small defaults (seeds
|
||||
# 0..4) and skip boolean_props, whose random inputs would fail main at random.
|
||||
name: Property tests (many cases)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
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: Boolean properties and atomic commands
|
||||
# The defaults (8 cases, seeds 0..4) take 159 s and 802 s on a 2-core VM; four times the
|
||||
# cases and twice the seeds are estimated at about 40 minutes there (not yet timed on CI).
|
||||
env:
|
||||
PROPTEST_CASES: "32"
|
||||
ATOMIC_SEEDS: "0..8"
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter --success-output final -E "binary(boolean_props) | binary(atomic_commands)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -280,22 +450,49 @@ jobs:
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh doc-tests
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- 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
|
||||
- name: Doc tests
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests test the affected packages that have a library (only libraries have doc
|
||||
# tests); everything else, the whole workspace.
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then
|
||||
scope=()
|
||||
libs=$(cargo metadata --no-deps --format-version 1 --locked \
|
||||
| python3 -I -c 'import json, sys; print(" ".join(p["name"] for p in json.load(sys.stdin)["packages"] if any("lib" in t["kind"] for t in p["targets"])))')
|
||||
for p in $PACKAGES; do
|
||||
case " $libs " in *" $p "*) scope+=(-p "$p") ;; esac
|
||||
done
|
||||
fi
|
||||
if [ ${#scope[@]} -eq 0 ]; then
|
||||
echo "No affected package has a library: no doc tests to run."
|
||||
exit 0
|
||||
fi
|
||||
cargo test "${scope[@]}" --profile ci --locked --doc
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -304,10 +501,17 @@ jobs:
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh oracle
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
@@ -315,7 +519,7 @@ jobs:
|
||||
- name: Build the command line
|
||||
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/solidcraft-cli
|
||||
run: cargo xtask oracle --cli "${CARGO_TARGET_DIR:-target}/ci/solidcraft-cli"
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -326,16 +530,19 @@ jobs:
|
||||
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
|
||||
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
|
||||
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
|
||||
# results with the CPU.
|
||||
# results with the CPU, and draw headless pictures with the viewport's pipelines there
|
||||
# (crates/gpu/tests/capture.rs).
|
||||
windows-fxc:
|
||||
name: Windows D3D12 FXC shaders
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
WGPU_BACKEND: dx12
|
||||
WGPU_DX12_COMPILER: fxc
|
||||
# WARP is a software (Cpu) adapter.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -343,6 +550,7 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-fxc
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# One test at a time: WARP's shader JIT crashes now and then (a null read in
|
||||
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
|
||||
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
|
||||
@@ -355,7 +563,7 @@ jobs:
|
||||
windows-startup:
|
||||
name: Windows app startup
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -364,6 +572,7 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-startup
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Backend order and probing on Windows
|
||||
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
|
||||
- name: Build the app
|
||||
@@ -379,13 +588,15 @@ jobs:
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
WINDOWS: ${{ needs.changes.outputs.windows }}
|
||||
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
@@ -401,7 +612,11 @@ jobs:
|
||||
test "$BUILD" = success
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
if [ "$ORACLE_RUN" = true ]; then
|
||||
test "$ORACLE" = success
|
||||
fi
|
||||
if [ "$WINDOWS" = true ]; then
|
||||
test "$WINDOWS_FXC" = success
|
||||
test "$WINDOWS_STARTUP" = success
|
||||
fi
|
||||
@@ -419,6 +634,7 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: wasm
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask wasm
|
||||
|
||||
riscv64-gates:
|
||||
@@ -440,6 +656,7 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: riscv64
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
|
||||
# frees claims nobody is working on. A pull request that references an issue (`<summary> (#N)`,
|
||||
# `Fixes #N`) claims it too. The logic is in scripts/claim/claim.js (tested in ci.yml).
|
||||
name: Claim
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
|
||||
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/claim
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
# One run at a time per issue, so two simultaneous /claim comments can't both win.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
|
||||
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/claim
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const claim = require('./scripts/claim/claim.js');
|
||||
if (context.eventName === 'issue_comment') {
|
||||
await claim.handleComment({ github, context });
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
const claimed = await claim.handlePull({ github, context });
|
||||
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
|
||||
} else {
|
||||
const released = await claim.sweep({ github, context });
|
||||
core.info(`Released: ${released.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# The web app as a container image on GitHub's container registry, for self-hosting
|
||||
# (packaging/docker; the book's "Self-hosting with Docker"):
|
||||
#
|
||||
# ghcr.io/<owner>/solidcraft-web:latest the latest published release
|
||||
# ghcr.io/<owner>/solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version
|
||||
# ghcr.io/<owner>/solidcraft-web:edge main
|
||||
#
|
||||
# For linux/amd64 and linux/arm64. The site is built here, as Pages and Release build it, and
|
||||
# passed to the Dockerfile as its `site` stage. Pull requests that touch the image build and
|
||||
# smoke-test it without publishing. Releases published by an agent or maintainer trigger this;
|
||||
# the nightly pre-releases (made with GITHUB_TOKEN) do not.
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
paths: ["packaging/docker/**", ".github/workflows/docker.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
TRUNK_VERSION: v0.21.14
|
||||
|
||||
jobs:
|
||||
image:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: docker-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Build the web app
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release
|
||||
- name: Image name
|
||||
id: image
|
||||
# Registry names are lower case; a fork's owner may not be.
|
||||
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/solidcraft-web" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=edge,branch=main
|
||||
type=ref,event=pr
|
||||
labels: |
|
||||
org.opencontainers.image.title=SolidCraft (web)
|
||||
org.opencontainers.image.description=SolidCraft, open-source parametric CAD, in the browser: the web app served by nginx on port 8080
|
||||
org.opencontainers.image.licenses=MIT OR Apache-2.0
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Build for the smoke test
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: solidcraft-web:test
|
||||
- name: Smoke test
|
||||
run: bash packaging/docker/smoke-test.sh solidcraft-web:test
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and publish
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
|
||||
# never edit them (two pull requests that each regenerated one would always conflict):
|
||||
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
|
||||
name: Generated files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# One run at a time; a burst of merges leaves one run waiting, which sees all of them.
|
||||
concurrency:
|
||||
group: generated-files
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate and commit
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
# Always main itself, never pull request code, since this job can push.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
# main goes through the merge queue; this deploy key may push past it.
|
||||
ssh-key: ${{ secrets.GENERATED_FILES_DEPLOY_KEY }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: generated-files
|
||||
- name: THIRD-PARTY-LICENSES.txt
|
||||
run: cargo xtask licences
|
||||
- name: docs/features.md
|
||||
run: cargo xtask features
|
||||
- name: Commit if changed
|
||||
run: |
|
||||
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add THIRD-PARTY-LICENSES.txt docs/features.md
|
||||
git commit -m "Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]"
|
||||
for _ in 1 2 3; do
|
||||
git pull --rebase origin main && git push origin HEAD:main && exit 0
|
||||
sleep 10
|
||||
done
|
||||
exit 1
|
||||
@@ -0,0 +1,33 @@
|
||||
# Files many pull requests add to are kept so that additions don't conflict: one file per change
|
||||
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
|
||||
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
|
||||
# per PR instead of three); this copy keeps main honest.
|
||||
name: Lists
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format-changes:
|
||||
name: Design format changes merge cleanly
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: bash scripts/test-format-pending.sh
|
||||
|
||||
sorted:
|
||||
name: Lists are sorted one item per line
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: lists
|
||||
- run: cargo xtask lists
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app
|
||||
# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at https://docs.solidcraft.io/
|
||||
# and the bundled web app at /app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# repository is private.
|
||||
name: Pages
|
||||
|
||||
@@ -45,11 +45,18 @@ jobs:
|
||||
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
# The book's pictures are `solidcraft-cli snapshot`s, drawn on the GPU; the runner has none.
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: Build the book
|
||||
env:
|
||||
SOLIDCRAFT_GPU: software
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release --public-url /solidcraft/app/
|
||||
run: trunk build --release --public-url ./
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
|
||||
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
|
||||
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
|
||||
name: Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled, edited]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
schedule:
|
||||
- cron: '41 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Only report what would be labelled'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/triage
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
|
||||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-info') &&
|
||||
github.event.sender.login == github.event.issue.user.login) ||
|
||||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority:') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
|
||||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/triage
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
with:
|
||||
script: |
|
||||
const triage = require('./scripts/triage/triage.js');
|
||||
const info = require('./scripts/triage/needs-info.js');
|
||||
const event = context.eventName;
|
||||
const action = context.payload.action;
|
||||
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
const th = (...names) => names.map((data) => ({ data, header: true }));
|
||||
if (event === 'issue_comment') {
|
||||
core.info(await triage.handleComment({ github, context }));
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'issues' && action === 'labeled') {
|
||||
await triage.handleLabeled({ github, context });
|
||||
} else if (event === 'issues' && action === 'edited') {
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'schedule') {
|
||||
const closed = await info.sweep({ github, context });
|
||||
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
|
||||
} else if (event === 'workflow_dispatch') {
|
||||
const dryRun = process.env.DRY_RUN !== 'false';
|
||||
const plan = await triage.backfill({ github, context, dryRun });
|
||||
const asks = await info.backfill({ github, context, dryRun });
|
||||
await core.summary
|
||||
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
|
||||
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
|
||||
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
|
||||
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
|
||||
.write();
|
||||
} else {
|
||||
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
|
||||
if (event === 'issues') await info.handleOpened({ github, context });
|
||||
}
|
||||
@@ -7,3 +7,4 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
dist/
|
||||
@@ -65,8 +65,11 @@ 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 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**
|
||||
artwork from commercial CAD products — ever.** Command icons are the original SVG masters in
|
||||
`assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`.
|
||||
**Before any icon, icon assignment or media edit, read `assets/AGENTS.md`,
|
||||
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`.** Use the canonical
|
||||
bases and named finished references there; never invent a parallel icon style. 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/*`
|
||||
@@ -85,7 +88,9 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
|
||||
- **Application code is Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
Local asset authoring, lint and review tools use Python 3.11+ as documented in
|
||||
`assets/AGENTS.md`; Python is not an application runtime dependency. The application 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.
|
||||
@@ -123,6 +128,27 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
|
||||
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
|
||||
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
|
||||
the issue as taken. Reference `#N` in the PR so the bot can find it.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
@@ -153,5 +179,11 @@ before the draft is verified.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
@@ -16,31 +16,29 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
|---|---|---|---|
|
||||
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
|
||||
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
|
||||
| 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 |
|
||||
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | 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/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | retained editable historical VectorCraft source, with named face and background layers; redundant exports retired | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 |
|
||||
| assets/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/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: two boxes bridged by `surface.blend` (a 40x10 sheet, `solidcraft-cli snapshot` of plan/evidence script ev_blend) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| 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 |
|
||||
| docs/design/ux-redesign/model-floating.png | SolidCraft contributors | original UX mockup (Model tab, floating layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/model-compact.png | SolidCraft contributors | original UX mockup (Model tab, compact docked layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sketch.png | SolidCraft contributors | original UX mockup (Sketch tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sheet-metal.png | SolidCraft contributors | original UX mockup (Sheet Metal contextual tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/start-page.png | SolidCraft contributors | original UX mockup (start page (File view)) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/workspaces.png | SolidCraft contributors | original UX mockup (workspace switcher) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/command-map.png | SolidCraft contributors | original UX mockup (ribbon command map) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | 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 |
|
||||
@@ -86,16 +84,21 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| 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/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | 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/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | 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 |
|
||||
@@ -108,7 +111,294 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| 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-logo.svg | SolidCraft contributors | Animation of the SolidCraft mark, built from the paths and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg; original animation | 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 |
|
||||
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
| crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/aliases.toml | SolidCraft contributors | icon ids that share a master, original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/palette.toml | SolidCraft contributors | icon colour roles (light and dark) and accents, original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/bug_report.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/chevron.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/fit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/folder.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/help.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/home.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/lookat.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/moon.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/nav_settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/orbit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/pan.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/perspective.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/redo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/save.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/spin.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/style.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/undo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/zoom.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/accessibility.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/activate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/align.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/appearance.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/as_built.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/auto_constrain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/bendtable.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/blend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/blend_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/boss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/boundary.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/boundary_fill.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/box.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/break.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_coincident.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_collinear.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_concentric.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_equal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_fix.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_hv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_midpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_parallel.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_perpendicular.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_smooth.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_symmetry.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/camera.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/canvas.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/capture.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/center_of_mass.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/centerline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/chain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/checkpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle2.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle_tt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/compute.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/configuration_table.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/conic.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/constrainer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/convert_sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/copy.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/curvature_comb.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/curvature_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/cylinder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/decal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/delete.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/dimension.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/dots.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/draft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/draft_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drive.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/dxf.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/edge_continuity.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/edit.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/ellipse.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/emboss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/environment_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/explode.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/export.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/extend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/extrude.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/eye.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fastener.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/filter.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/finish.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fit_section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/flange.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/flat.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/folder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/graph.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/hem.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/hole.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/import.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/include.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/inspect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/interference.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/intersect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/intersection_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/iso_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/iso_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/joint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/joint_origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/library.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/line.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/line_mid.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/lip.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/list.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/loft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/material.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/measure.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/min_radius.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/motion_link.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/move.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/new.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/open.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/params.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/paste.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/patch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_circ.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_path.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plastic_assign.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plastic_rule.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/play.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/presspull.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/project.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/project_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect_rounded.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/refold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rename.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/render_image.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/render_scene.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/reorder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rest.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/restore.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/reverse_normal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/revert.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/revolve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rib.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rigid_group.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rollback.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/ruled.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/scale.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/select.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sheet_rules.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/shell.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/snap.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sphere.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/spline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/spline_cv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/split_body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/split_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/spun.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/star.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/status_error.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/status_warning.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/stitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/suppress.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/svg.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sweep.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/text.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/thicken.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/thread.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/torus.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/trim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/unfold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/unstitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/untrim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/update.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/zebra.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/as_built.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/component.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/curvature_comb.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/interference.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/iso_analysis.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/joint.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/pattern.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/section.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_animate.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/polygon_circumscribed.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/polygon_edge.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/slot_overall.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/gear.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rack_pinion.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/screw.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/cam.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/trace.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/agent_chat.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/normal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/infinite.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_flip.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_center.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/reach.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/cut.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/compare.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/web.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/replace_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/bounding_solid.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/delete_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/snap_fit.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_sets.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_all.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_off.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_new.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_through_cylinder.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_normal_to_face.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_two_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_two_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_edge.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_vertex.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_three_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_center.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_edge_plane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_midplane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_three_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_tangent.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_perpendicular.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_design.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_drawing.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_render.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/references.toml | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/author/bases.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/author/new_icon.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/index.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/guide.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/review.css | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/review.js | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/icon_palette.py | SolidCraft contributors | Original shared semantic icon palette resolver for repository tools | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/icon_status.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/lint_icons.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/preview.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review_state.py | SolidCraft contributors | icon review state helper (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/review.json | SolidCraft contributors | icon review state (accepted icons and notes), original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | 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 |
|
||||
|
||||
@@ -139,6 +429,27 @@ work cooperative and adds checked run lengths, zero-run rejection, width validat
|
||||
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.
|
||||
|
||||
## File-format crates
|
||||
|
||||
| Crate | Used for | Licence |
|
||||
|---|---|---|
|
||||
| gltf 1.4.1, gltf-json 1.4.1, gltf-derive 1.4.1 (https://github.com/gltf-rs/gltf) | glTF 2.0 import (crates/io/src/gltf_in.rs): the JSON document model and its reference checks; the GLB container, buffers and accessors are read by SolidCraft's own code | MIT OR Apache-2.0 |
|
||||
| inflections 1.1.1 (build-time, through gltf-derive) | case conversion in gltf's derive macros | MIT |
|
||||
| lazy_static 1.5.1 (through gltf-json) | | MIT OR Apache-2.0 |
|
||||
|
||||
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
|
||||
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
|
||||
|
||||
## FreeCAD and Rhino import library
|
||||
|
||||
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
|
||||
0.6.1 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
|
||||
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
|
||||
in native builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
|
||||
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
|
||||
codec are not compiled in. Its licence text is in THIRD-PARTY-LICENSES.txt; no code from it is
|
||||
copied into SolidCraft.
|
||||
|
||||
## Recovered contributions
|
||||
|
||||
| SolidCraft file | Original contribution | Licence |
|
||||
@@ -152,6 +463,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (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) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
@@ -162,3 +474,7 @@ its original `egui.wgsl` shader are retained; no images or other artwork are cop
|
||||
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
|
||||
and shares that effective count with the surface attachments and CAD callbacks.
|
||||
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
|
||||
A frame whose meshes the GPU refuses to stage (device lost or out of memory) is
|
||||
skipped with a warning instead of panicking (#1469).
|
||||
Paint callbacks are prepared only after a drawable is acquired, so a skipped
|
||||
surface frame cannot strand cached meshes without their GPU initialization.
|
||||
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
@@ -65,8 +67,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/*`
|
||||
@@ -81,6 +83,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`.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **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`.
|
||||
@@ -96,6 +102,47 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Showing and following through on changes
|
||||
|
||||
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
|
||||
issues and PRs must include actual descriptive screenshots or a short recording from a
|
||||
small reproduction. This includes kernel, geometry, assembly and backend changes whose
|
||||
effects can be shown in the app. Coding agents should run the app and capture this evidence
|
||||
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
|
||||
or result, preferably before and after, with brief reproduction steps, expected/actual
|
||||
behavior and annotations that point out the relevant detail.
|
||||
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
|
||||
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
|
||||
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check current upstream commits and the relevant
|
||||
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
|
||||
discarding others' work, and re-run validation when integration changes the result. Use
|
||||
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
|
||||
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Don't claim by editing files.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
@@ -126,5 +173,11 @@ before the draft is verified.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
@@ -10,13 +10,16 @@ Help is welcome, whether that's a one-line fix or owning a whole area. This page
|
||||
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
|
||||
needed most.
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
|
||||
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
|
||||
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://github.com/trunk-rs/trunk)
|
||||
([documentation](https://trunk-rs.github.io/trunk/)).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
|
||||
git clone https://github.com/tangency-labs/solidcraft && cd solidcraft
|
||||
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)
|
||||
@@ -98,8 +101,8 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
|
||||
open-source application to get reference values for a test is fine; reading its source is
|
||||
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).
|
||||
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
|
||||
lists it once the change lands: the Generated files workflow rewrites it on `main`.
|
||||
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
|
||||
@@ -107,9 +110,10 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
`cargo xtask assets` enforces this.
|
||||
4. **Never crash.** People trust a CAD app with their work.
|
||||
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
|
||||
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
|
||||
denies it.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
|
||||
`apps/solidcraft/src/native_crash.rs` (Windows native crash capture) and
|
||||
`crates/threedx-client/src/macos.rs` (the 3Dconnexion driver's client API on macOS), and the
|
||||
compiler denies it.
|
||||
- An unsupported case returns an error ("not supported yet: …"); it never panics.
|
||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||
- Every crash fix comes with a regression test.
|
||||
@@ -163,6 +167,13 @@ 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.
|
||||
|
||||
**PRs test what they touch; main and nightly test everything.** On a pull request, CI maps the
|
||||
changed files to workspace packages and runs Clippy, the test build and the tests for those and
|
||||
the packages that depend on them (`scripts/ci/affected.py`, self-tested in the `checks` job). A
|
||||
change to `Cargo.lock`, the root `Cargo.toml`, `.cargo/`, `.config/`, `.github/`, `vendor/`,
|
||||
`tests/`, `scripts/` or any file outside a package runs the whole workspace, as does every push
|
||||
to `main`, the nightly run and Run workflow.
|
||||
|
||||
**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
|
||||
@@ -170,11 +181,12 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev
|
||||
`.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),
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that touch `crates/kernel` run every 8th),
|
||||
`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`
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
|
||||
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
|
||||
@@ -192,18 +204,80 @@ the current results, and CI runs `cargo xtask oracle` on every pull request. A c
|
||||
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
|
||||
change an expected value to match SolidCraft's output.
|
||||
|
||||
## Changing the design file format
|
||||
|
||||
A design file says which format it is in (`"format": "solidcraft/N"`). If your change saves
|
||||
something new in a design (a field, a feature type, a new kind of name), or makes older designs
|
||||
mean something else, it needs a format change. **Add one file, and edit nothing shared:**
|
||||
|
||||
1. Create `crates/doc/src/format/pending/<what-it-is>.rs` (lowercase, dashes). Start it with a
|
||||
`//!` note: what is saved, what older designs get without it, and that the last release asks
|
||||
for an update instead of meeting it.
|
||||
2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) ->
|
||||
crate::Result<()>` in the same file. Pending steps run in file-name order, after the older
|
||||
formats' steps.
|
||||
3. Don't touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another
|
||||
change's file, or any `v<N>/` folder: those are fixed once released.
|
||||
|
||||
That's all. The build counts the files: while any are pending it writes the next format number,
|
||||
which no release reads, so a release refuses a design that has your change instead of losing it.
|
||||
A file also lists the pending changes it has (`format_pending`), so a later development build
|
||||
runs only the steps that file is missing. Two pull requests that each add a change merge without
|
||||
a conflict. `crates/doc/src/format/pending/README.md` has an example.
|
||||
|
||||
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
|
||||
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
|
||||
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
|
||||
|
||||
## Lists many changes add to
|
||||
|
||||
Some files gain an item in many pull requests at once. They are kept so that two changes that
|
||||
each add an item only meet when their items are neighbours, which spares everyone a conflict
|
||||
and a fresh CI run:
|
||||
|
||||
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
|
||||
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
|
||||
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
|
||||
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
|
||||
sorted). Add a new module into the existing group, not as a new group at the end.
|
||||
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
|
||||
checks it).
|
||||
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
|
||||
after each merge by the Generated files workflow (`cargo xtask licences`, `cargo xtask
|
||||
features`). Don't regenerate or edit them in a pull request.
|
||||
- **Design format**: one file per change, never an edit to a shared number (above).
|
||||
|
||||
## Sending a change
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
|
||||
comment on the issue so it gets the `in progress` label and two people don't build the same
|
||||
thing. For anything bigger than a bug fix that has no issue yet, open one first.
|
||||
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
|
||||
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
|
||||
PR** before you start, so two people don't build the same thing:
|
||||
- Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
|
||||
on the other one).
|
||||
- The bot records the claim when the PR is opened: it assigns you, adds `in progress` and
|
||||
says so on the issue. It reads `#N` in the PR title and `Fixes #N` in the body. If someone
|
||||
already holds the issue, or an older open PR references it, it says so on your PR instead.
|
||||
- Want to stake a claim before you have a branch? Comment `/claim` on the issue (below).
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim.
|
||||
- Mark the PR ready for review when it is done.
|
||||
2. Branch from `main` and keep the PR to one topic.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
|
||||
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
Commenting `/claim` assigns you, adds the `in progress` label, and replies with its automatic cleanup
|
||||
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
|
||||
without pushes. These automatic cleanup deadlines are separate from the daily progress and
|
||||
72-hour takeover policy above. A draft PR that references the issue claims it the same way.
|
||||
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
|
||||
is required for changes or bugs that can be demonstrated in the app, including kernel,
|
||||
@@ -230,10 +304,33 @@ owning one area, with a human setting direction and reporting bugs. Their workin
|
||||
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
|
||||
long as the change meets the rules above.
|
||||
|
||||
## Labels and triage
|
||||
|
||||
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
|
||||
correct them freely and it won't put them back.
|
||||
|
||||
- **PRs** get `area:*` from the paths they change (`crates/kernel` and `crates/geom` →
|
||||
`area:kernel`, `crates/ui-egui` and the apps → `area:ui`, `.github/`, `scripts/`, `docs/` →
|
||||
`area:infra`, and so on).
|
||||
- **Issues** get `bug` (in-app reports and the bug template), `enhancement` (feature requests),
|
||||
`priority:P1` for crashes, and an `area:*` from the title or the reported command. Until it has
|
||||
a priority, an issue carries `needs-triage`; setting a priority removes it.
|
||||
- **Maintainers** comment `/triage area:kernel P1` (any existing label; `P0`–`P3` for a
|
||||
priority, which replaces the old one; `-label` removes one) to sort an issue or PR.
|
||||
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
|
||||
the steps (and the version, when it's missing). An app report that arrived empty is asked to
|
||||
paste the report the app copied to the clipboard. The reporter's reply, or an edit that adds the
|
||||
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
|
||||
reopened any time. Feature requests, app reports with their details, and maintainers' issues
|
||||
are never asked.
|
||||
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/solidcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels.
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
|
||||
See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels.
|
||||
In order of how much they'd help users:
|
||||
|
||||
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -23,8 +23,10 @@ solidcraft-doc = { path = "crates/doc" }
|
||||
solidcraft-io = { path = "crates/io" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -51,8 +53,19 @@ spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
|
||||
# read by crates/io itself, so no image decoding or file access comes with it.
|
||||
gltf = { version = "1.4", default-features = false, features = ["names"] }
|
||||
|
||||
# FreeCAD and Rhino import (Apache-2.0, pure Rust apart from zstd; native builds only).
|
||||
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
|
||||
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
|
||||
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
|
||||
cadmpeg-core = "=0.6.1"
|
||||
cadmpeg-ir = "=0.6.1"
|
||||
cadmpeg-registry = { version = "=0.6.1", default-features = false, features = ["fcstd", "rhino", "step"] }
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
@@ -60,6 +73,12 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
|
||||
png = "0.18.1"
|
||||
# LZW for GIF animations (MIT OR Apache-2.0, pure Rust; already in the tree through image's tiff).
|
||||
weezl = "0.1.12"
|
||||
# rasterises the UI icon SVGs (BSD-3-Clause, pure Rust, builds for wasm)
|
||||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] }
|
||||
# build scripts: reads the icon palette (MIT OR Apache-2.0)
|
||||
toml = "1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
@@ -115,6 +134,7 @@ debug = "line-tables-only"
|
||||
|
||||
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
[patch.crates-io]
|
||||
truck-geometry = { path = "vendor/truck-geometry" }
|
||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# SolidCraft
|
||||
<p align="center">
|
||||
<img src="assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" width="96" height="96" alt="SolidCraft" />
|
||||
</p>
|
||||
|
||||
**An open-source parametric 3D CAD application built in pure Rust.**
|
||||
<h1 align="center">SolidCraft</h1>
|
||||
|
||||
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server.
|
||||
<p align="center">
|
||||
<strong>An open-source parametric 3D CAD application built in pure Rust.</strong><br />
|
||||
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server.
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
*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
|
||||
<div align="center">
|
||||
|
||||
[![Download for Windows][badge-windows]][windows-msi]
|
||||
[![Download for macOS][badge-macos]][macos-dmg]
|
||||
@@ -18,6 +19,33 @@ model through the built-in MCP server.
|
||||
[![Get it from the Snap Store][badge-snap]][snap-store]
|
||||
[![Try it in your browser][badge-web]][web-app]
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://solidcraft.io">Website</a> ·
|
||||
<a href="https://solidcraft.io/download">Download</a> ·
|
||||
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
|
||||
<a href="https://docs.solidcraft.io/">Documentation</a> ·
|
||||
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
|
||||
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/tangency-labs/solidcraft/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://github.com/tangency-labs/solidcraft/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases/latest"><img src="https://img.shields.io/github/v/release/tangency-labs/solidcraft?label=release" alt="Latest release" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases"><img src="https://img.shields.io/github/downloads/tangency-labs/solidcraft/total?label=downloads" alt="Downloads" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/tangency-labs/solidcraft" alt="Licence" /></a>
|
||||
<a href="https://discord.gg/FtU4NQNVDH"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://remade.wiki/projects/solidcraft/"><img src="https://remade.wiki/badges/listed-on-remade-classic.svg" alt="Listed on Remade" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/stargazers"><img src="https://img.shields.io/github/stars/tangency-labs/solidcraft?style=flat" alt="Stars" /></a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
*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/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
- **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.
|
||||
@@ -27,7 +55,7 @@ model through the built-in MCP server.
|
||||
(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).
|
||||
openSUSE), or use [Flatpak](https://docs.solidcraft.io/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.
|
||||
@@ -35,39 +63,45 @@ model through the built-in MCP server.
|
||||
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).
|
||||
**On SolidCraft 0.11 to 0.14?** Its **Update now** can't install 0.15.0 or later, because the
|
||||
releases moved to a new address. Download the new version once from the links above; after that,
|
||||
updating from inside SolidCraft works again ([details](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014)).
|
||||
|
||||
Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/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).
|
||||
[Getting started](https://docs.solidcraft.io/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
|
||||
[windows-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
|
||||
[windows-arm64-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
|
||||
[windows-x86-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
|
||||
[windows-portable]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
|
||||
[macos-dmg]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
|
||||
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
|
||||
[linux-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
|
||||
[linux-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
|
||||
[linux-arm64-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
|
||||
[linux-arm64-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
|
||||
[linux-arm64-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
|
||||
[linux-snap]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://bherbruck.github.io/solidcraft/app/
|
||||
[web-app]: https://solidcraft.io/app
|
||||
|
||||
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
|
||||
**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
**Community:** chat with users and contributors on [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
**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
|
||||
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
|
||||
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
|
||||
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Built with SolidCraft
|
||||
@@ -77,11 +111,14 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
*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)
|
||||
[Full video](https://github.com/tangency-labs/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
|
||||
|
||||
- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges
|
||||
omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
|
||||
projection, trimming, fillets and chamfers, including corners at the origin.
|
||||
@@ -129,13 +166,16 @@ rebuild with no errors, replayed here at 2× speed.
|
||||
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, 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
|
||||
3MF, STL, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
|
||||
appearances) import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)); FreeCAD
|
||||
`.FCStd` and Rhino `.3dm` solids import the same way through
|
||||
[cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI.
|
||||
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
|
||||
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
|
||||
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
|
||||
@@ -170,33 +210,66 @@ cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out br
|
||||
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.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
|
||||
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
|
||||
```
|
||||
|
||||
UI artwork changes follow the [icon specification](assets/icons/ui/STYLE_GUIDE.md).
|
||||
The [included review tool](assets/icons/ui/README.md) discovers new icons and keeps
|
||||
unaccepted artwork Open; run `cargo xtask icons` to check the collection.
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
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.
|
||||
On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
|
||||
controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that
|
||||
declares itself one. 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.
|
||||
page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an
|
||||
invert switch and a speed for the 3D mouse. The web app reads
|
||||
it too, through the browser; Firefox needs a 3DxWare setting first (see
|
||||
[Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
|
||||
|
||||
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:
|
||||
With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and
|
||||
3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that
|
||||
3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts.
|
||||
|
||||
On Linux SolidCraft reads the 3D mouse through `spacenavd` when that daemon runs (its socket
|
||||
`/var/run/spnav.sock`), so what you set in `spnavcfg` (swapped or inverted axes, sensitivity, dead
|
||||
zones) applies as it does in other programs, and no udev rule is needed. With nothing set there,
|
||||
the motion is the same as without the daemon. Without `spacenavd`, 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 USB devices with 3Dconnexion's vendor id, and for its
|
||||
Bluetooth ones (where `idVendor` isn't set, so the HID id is matched instead):
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
SUBSYSTEM=="hidraw", KERNELS=="0005: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.
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`, so add a rule with their product id (see
|
||||
`lsusb`) instead of matching every Logitech device. A homemade device needs a rule with its own
|
||||
vendor and product id. 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.
|
||||
|
||||
SolidCraft finds the axes from the device's report descriptor, so it doesn't matter how a device
|
||||
lays them out: translation and rotation in separate reports (3Dconnexion's wired devices), all six
|
||||
in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits.
|
||||
Each axis is scaled from the range the descriptor declares.
|
||||
|
||||
On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the
|
||||
driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled).
|
||||
The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft
|
||||
as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows.
|
||||
|
||||
SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode.
|
||||
|
||||
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
Agents can control the document open in a browser tab through `window.solidcraft.execute(...)`
|
||||
and `window.solidcraft.request(...)`; see the [browser command API](docs/browser-api.md).
|
||||
|
||||
## Scripts
|
||||
|
||||
A script is a JSON list of commands, the same ones the UI runs:
|
||||
@@ -216,8 +289,8 @@ A script is a JSON list of commands, the same ones the UI runs:
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
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`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `crash_reports`, `undo`, `redo`,
|
||||
`new_design`, `open`, `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).
|
||||
@@ -228,9 +301,9 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
| Layer | Crates | Role |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and the headless scene description |
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# OneCAD rebrand map (identity layer)
|
||||
|
||||
OneCAD is the OneBSpace fork of SolidCraft. Since the 20.0.1.0.6 build the rebrand is
|
||||
**source-level** for everything the user can see or click; only the compatibility layer keeps
|
||||
upstream names (so `.solidcraft` files, the `window.solidcraft` JS API and the crate layout stay
|
||||
upstream-compatible). Files tagged `OneBSpace (OneCAD)` are downstream-only deltas.
|
||||
|
||||
## Rebranded in source (user-visible)
|
||||
|
||||
| Layer | Change |
|
||||
|---|---|
|
||||
| App bar | upstream vector mark dropped; `OneCAD` wordmark text (`toolbar.rs`), shown in every width |
|
||||
| Start page | title `SolidCraft` → `OneCAD`; the outbound links (Documentation, Report an Issue, What's New) are removed (`home.rs`) |
|
||||
| Launch flow | the start page no longer shows on launch — the app opens straight into the workspace (`main.rs`); the house button still shows Home |
|
||||
| Loading splash | upstream `loading-logo.svg` image is not shipped; the splash is a plain `OneCAD` wordmark (`index.html` + `loading.css`) and the 3.6 s minimum hold is gone (`LOADING_MIN_MS = 0`) |
|
||||
| Help menu | `Documentation` (docs.solidcraft.io) and `Report an Issue…` (GitHub draft) items removed; About / Keyboard Shortcuts / Crash Reports stay |
|
||||
| About window | `About OneCAD`, OneCAD description, OneCAD diagnostics header |
|
||||
| Issue/crash reports | fully local: no GitHub URL is opened anywhere; "Open GitHub draft" replaced by a clipboard `Copy report` (`issue_report.rs`); the app-bar bug button tooltip no longer mentions GitHub |
|
||||
| Visible strings | status lines, recovery dialog, clipboard text (`OneCAD sketch geometry: …`), AI-chat errors, settings copy — `SolidCraft` → `OneCAD` in code literals and in every `assets/locales/*.json` value |
|
||||
| Web page | `<title>OneCAD`, social-card meta and PWA manifest/icons dropped from the bundle (as before) |
|
||||
|
||||
## Stays upstream (compatibility & scale)
|
||||
|
||||
- **Format tag `solidcraft/N`** inside the `.solidcraft` ZIP container and the `.solidcraft`
|
||||
extension (the one_cad registry associates `extensions: ['solidcraft']`).
|
||||
- **`window.solidcraft` JS API global** — the public browser API surface the one_cad bridge drives.
|
||||
- **VFS paths** (`/opfs/`, `/downloads/`, plus the OneCAD additions `/odoo/…`) and OPFS/local-storage keys.
|
||||
- **Crate/binary names and Cargo workspace layout** — untouched; ~876 files mention "solidcraft";
|
||||
renaming them would make every upstream sync a merge conflict. (`SOLIDCRAFT_COMMIT` /
|
||||
`SOLIDCRAFT_BUILD_DATE` env names stay; their About text renders as OneCAD.)
|
||||
- Desktop-only updater internals (registry key, installer file names) keep upstream strings —
|
||||
unreachable from the web build.
|
||||
@@ -7,7 +7,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 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; FreeCAD/Rhino import; 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 | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) 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. |
|
||||
@@ -49,12 +49,12 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| 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), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; 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 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), 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 guide rails and two-rail sweeps (#771; orientation, twist and end scale done; 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, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), 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 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| 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 |
|
||||
@@ -66,8 +66,30 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
|
||||
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
|
||||
pitch selectors expose the existing metric designation parameter; custom designations stay
|
||||
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
|
||||
full/partial-length options remain available. This does not add tolerance classes or
|
||||
left-hand threads.
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
@@ -223,12 +245,13 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, …) over the
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
|
||||
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
|
||||
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
|
||||
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
|
||||
the agent's whole process group ends with the panel or the app. Docks left or right,
|
||||
minimises to a strip. See [agent chat](docs/agent-chat.md).
|
||||
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
|
||||
Grok login. See [agent chat](docs/agent-chat.md).
|
||||
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
|
||||
writes a design's history as a script that `eval` and `run` replay into the same design —
|
||||
user parameters, units, sheet metal and plastic rules and materials as their own commands,
|
||||
@@ -256,6 +279,25 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
|
||||
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
|
||||
extrusion previews build only their tools; confirmation validates the final Boolean.
|
||||
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
|
||||
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
|
||||
computes on a scratch session in the background, with atomic acceptance and cancellation.
|
||||
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
|
||||
- Imported rotated face groups can check all full start-face seams in one guarded
|
||||
construction. Separated tools may batch a Join when each meets the same single body;
|
||||
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
|
||||
cover all four extrusion operations.
|
||||
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
|
||||
Safe interior classification handles roundoff at shared imported line/arc endpoints.
|
||||
Straight extrusion feedback caches existing face/profile triangles and updates the display
|
||||
directly, including a newly opened STEP's single identity component placement; other
|
||||
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
|
||||
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
|
||||
Confirmation and tool previews use main's shared background calculation. Main's collapsed
|
||||
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
@@ -301,7 +343,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
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
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
@@ -310,7 +354,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
|
||||
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
|
||||
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
|
||||
checked within 0.1% of analytic offset slices), runs of
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
with the edge between them too, the corner becomes a sphere octant),
|
||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||
@@ -363,6 +409,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
|
||||
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
|
||||
walls through denser stations where the path has corners or the profile has holes.
|
||||
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
|
||||
turns the profile and scales it (or stretches it across to the rail) against the start; two
|
||||
rails carry it between them. Rails along a path with sharp corners are refused for now.
|
||||
- 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.
|
||||
|
||||
@@ -21,5 +21,10 @@ path = "src/alias.rs"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Snapshot tests decode the pictures to compare them with a tolerance.
|
||||
image = { workspace = true }
|
||||
@@ -1,7 +1,7 @@
|
||||
//! SolidCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
|
||||
@@ -34,13 +34,14 @@ use serde_json::{Value, json};
|
||||
mod app_icon;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::render::render_png;
|
||||
use solidcraft_engine::view::Guides;
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--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)
|
||||
(--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;
|
||||
--profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)
|
||||
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
|
||||
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
|
||||
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
|
||||
@@ -63,6 +64,10 @@ const USAGE: &str = "usage:
|
||||
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 gpu [--policy auto|hardware|software|cpu]
|
||||
(the GPU adapters a headless render can open, best first, the one the policy picks and the tier it
|
||||
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
|
||||
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
@@ -75,6 +80,7 @@ fn main() -> ExitCode {
|
||||
Some("snapshot") => cmd_snapshot(&args[1..]),
|
||||
Some("render") => render::run(&args[1..]),
|
||||
Some("exec") => cmd_exec(&args[1..]),
|
||||
Some("gpu") => cmd_gpu(&args[1..]),
|
||||
Some("commands") => {
|
||||
use std::io::Write as _;
|
||||
let mut s = Session::default();
|
||||
@@ -176,7 +182,11 @@ 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" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 };
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--timings" | "--profile" | "--allow-errors" | "--no-grid" | "--no-axes") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
continue;
|
||||
}
|
||||
return Some(a);
|
||||
@@ -213,21 +223,35 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let json_out = json_out(args)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let (mut s, results) = if args.iter().any(|a| a == "--results") {
|
||||
// Collect every command's result (scripts only), then process requested files too.
|
||||
let want_results = args.iter().any(|a| a == "--results");
|
||||
// `--profile` times each command and splits out its kernel time; it implies `--timings`.
|
||||
let profile = args.iter().any(|a| a == "--profile");
|
||||
let timings = profile || args.iter().any(|a| a == "--timings");
|
||||
let (mut s, results) = if want_results || timings {
|
||||
// Collect every command's result or time (scripts only), then process requested files too.
|
||||
let mut s = match base {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
let v = read_json(path.ok_or("--results needs a script")?)?;
|
||||
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
(s, Some(Value::Array(r)))
|
||||
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
|
||||
let v = read_json(path.ok_or(needs)?)?;
|
||||
let out = if timings {
|
||||
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
|
||||
if want_results && let Some(o) = t.as_object_mut() {
|
||||
o.insert("results".into(), Value::Array(r));
|
||||
}
|
||||
t
|
||||
} else {
|
||||
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
|
||||
};
|
||||
(s, Some(out))
|
||||
} else {
|
||||
(open(base, path)?, None)
|
||||
};
|
||||
@@ -334,13 +358,59 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `gpu`: the headless adapter survey and the policy's pick.
|
||||
fn cmd_gpu(args: &[String]) -> Result<(), String> {
|
||||
use solidcraft_gpu::select::{Policy, survey};
|
||||
let policy = match args {
|
||||
[] => Policy::from_env()?,
|
||||
[flag, v] if flag == "--policy" => Policy::parse(v).ok_or_else(|| format!("--policy must be auto, hardware, software or cpu, not `{v}`"))?,
|
||||
_ => return Err("usage: solidcraft-cli gpu [--policy auto|hardware|software|cpu]".into()),
|
||||
};
|
||||
let s = survey(policy);
|
||||
let chosen = s.chosen.and_then(|i| s.adapters.get(i)).cloned();
|
||||
// The tier pictures land on: a hardware GPU, a software adapter, or the CPU rasterizer.
|
||||
let renderer = s.lands_on.map(|l| l.renderer());
|
||||
println!(
|
||||
"{}",
|
||||
pretty(&json!({
|
||||
"policy": policy.name(),
|
||||
"backends": s.backends,
|
||||
"adapters": s.adapters,
|
||||
"chosen": chosen,
|
||||
"lands_on": s.lands_on,
|
||||
"renderer": renderer,
|
||||
}))
|
||||
);
|
||||
match s.lands_on {
|
||||
Some(solidcraft_gpu::select::Landing::Cpu) => {
|
||||
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
|
||||
Ok(())
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
None => Err(format!("no adapter qualifies under the {} policy", policy.name())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw this process's pictures (snapshots, MCP screenshots, saved thumbnails) on the GPU with the
|
||||
/// viewport's pipelines: the headless device the `SOLIDCRAFT_GPU` policy picks, opened on the first
|
||||
/// picture; under `auto` with no qualifying adapter (or `cpu`), the CPU rasterizer, and the
|
||||
/// picture says so. Under `hardware` or `software` with no such adapter a picture fails with an
|
||||
/// error that names the policy.
|
||||
fn install_renderer() {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
|
||||
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
|
||||
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
|
||||
}));
|
||||
}
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
|
||||
if positional(args).is_none() && flag(args, "--in").is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
|
||||
let size = |name: &str, default: usize| match flag(args, name) {
|
||||
let size = |name: &str, default: u32| match flag(args, name) {
|
||||
None => Ok(default),
|
||||
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
|
||||
};
|
||||
@@ -364,9 +434,14 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
|
||||
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
|
||||
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
|
||||
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()})));
|
||||
let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?;
|
||||
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
|
||||
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
|
||||
eprintln!("warning: {w}");
|
||||
reply["warning"] = json!(w);
|
||||
}
|
||||
println!("{}", pretty(&reply));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -412,6 +487,7 @@ fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let id = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let p: Value = match args.get(1) {
|
||||
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
|
||||
@@ -427,6 +503,7 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
/// 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> {
|
||||
install_renderer();
|
||||
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 {
|
||||
|
||||
@@ -423,6 +423,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
|
||||
"operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
// A boundary surface: a loop of one sketch's curves (`sketch`, `curves`), or a network
|
||||
// (`u`, `v`), as `surface.boundary` takes them.
|
||||
Some("boundary_surface") => {
|
||||
let mut p = json!({"name": name});
|
||||
for k in ["sketch", "curves", "continuity", "u", "v", "operation"] {
|
||||
if let Some(v) = f.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(b) = bodies.as_array().and_then(|a| a.first()) {
|
||||
p["body_name"] = b.clone();
|
||||
}
|
||||
out.push(json!({"command": "surface.boundary", "params": p}));
|
||||
}
|
||||
Some("thicken") => out.push(json!({"command": "surface.thicken", "params": {
|
||||
"bodies": f.get("bodies"), "thickness": f.get("thickness"), "symmetric": f.get("symmetric").cloned().unwrap_or(json!(false)),
|
||||
"operation": op(f), "name": name}})),
|
||||
Some("remove") => out.push(json!({"command": "solid.remove", "params": {"bodies": f.get("bodies"), "name": name}})),
|
||||
Some("shell") => {
|
||||
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
|
||||
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
|
||||
|
||||
@@ -1519,7 +1519,6 @@ fn sketches_on_loft_and_sweep_faces() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #562 is fixed"]
|
||||
fn press_pull_on_the_pi_case_floor() {
|
||||
reproduces(
|
||||
Some("tests/action_matrix/designs/pi-case.solidcraft"),
|
||||
@@ -1560,7 +1559,6 @@ fn a_sketch_on_the_rim_a_shell_leaves() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #565 is fixed"]
|
||||
fn sketches_after_split_body_and_intersect() {
|
||||
reproduces(
|
||||
None,
|
||||
|
||||
@@ -9,8 +9,16 @@ use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
|
||||
// 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();
|
||||
// This test needs the port closed until its second phase. A port obtained
|
||||
// with bind(:0) and released can immediately be reused by another parallel
|
||||
// test's mock server. Stay below the usual ephemeral ranges,
|
||||
// with a process-specific starting point, and skip any occupied ports.
|
||||
let port = (0..512u32)
|
||||
.find_map(|offset| {
|
||||
let port = 16000 + (std::process::id() + offset) % 16000;
|
||||
TcpListener::bind(("127.0.0.1", port as u16)).ok().map(|listener| listener.local_addr().unwrap().port())
|
||||
})
|
||||
.expect("an unused non-ephemeral test port");
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id()));
|
||||
std::fs::create_dir_all(&control_dir).unwrap();
|
||||
|
||||
@@ -151,11 +151,20 @@ fn snapshot_runs_its_script_on_the_in_design() {
|
||||
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
|
||||
std::fs::read(&png).unwrap()
|
||||
};
|
||||
let alone = shot("alone.png", &[shifted.as_os_str()]);
|
||||
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
|
||||
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
|
||||
assert_ne!(alone, on_base, "the script ran on the --in design");
|
||||
assert_ne!(base_only, on_base);
|
||||
// Pictures are drawn on the GPU: without an adapter only the option checks below run.
|
||||
match solidcraft_gpu::capture::shared() {
|
||||
Ok(_) => {
|
||||
let alone = shot("alone.png", &[shifted.as_os_str()]);
|
||||
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
|
||||
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
|
||||
assert_ne!(alone, on_base, "the script ran on the --in design");
|
||||
assert_ne!(base_only, on_base);
|
||||
}
|
||||
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||||
eprintln!("pictures left out: {e}")
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
|
||||
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
@@ -170,3 +179,33 @@ fn unknown_command_suggests_the_closest_id() {
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds
|
||||
/// the kernel's share, `--results` puts the results beside them, and files are still written.
|
||||
#[test]
|
||||
fn timings_and_profile_report_each_command() {
|
||||
let dir = Scratch::new();
|
||||
let saved = dir.0.join("timed.solidcraft");
|
||||
let t = json_output(cli().arg("run").arg(dir.script()).arg("--timings").arg("--save").arg(&saved).output().unwrap());
|
||||
let rows = t["timings"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!((rows[0]["index"].as_u64(), rows[0]["command"].as_str()), (Some(0), Some("solid.box")));
|
||||
assert!(rows[0]["ms"].as_f64().is_some() && t["total_ms"].as_f64().is_some(), "{t}");
|
||||
assert!(rows[0].get("profile").is_none() && t.get("results").is_none(), "{t}");
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
|
||||
// --profile alone implies --timings; a flag before the script doesn't eat it.
|
||||
let t = json_output(cli().arg("run").arg("--profile").arg(dir.script()).arg("--results").output().unwrap());
|
||||
let p = &t["timings"][0]["profile"];
|
||||
for k in ["boolean_ms", "tessellate_ms", "other_ms"] {
|
||||
assert!(p[k].as_f64().is_some_and(|ms| ms >= 0.0), "{k}: {t}");
|
||||
}
|
||||
assert_eq!(t["results"].as_array().map(Vec::len), Some(1), "{t}");
|
||||
|
||||
// --results alone stays a plain array.
|
||||
assert!(json_output(cli().arg("run").arg(dir.script()).arg("--results").output().unwrap()).is_array());
|
||||
|
||||
let output = cli().arg("run").arg("--in").arg(&saved).arg("--timings").output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
|
||||
//! design or one the script applies itself, and without the ground grid and the origin's axes
|
||||
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
|
||||
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it). Pictures are drawn
|
||||
//! on the GPU and compared with a tolerance; without a GPU adapter the tests that draw are skipped
|
||||
//! with the reason printed (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
@@ -12,7 +14,52 @@ use solidcraft_mcp::{Headless, Server};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
const SIZE: (usize, usize) = (160, 100);
|
||||
/// Whether pictures can be drawn here; installs the renderer the CLI installs, so the MCP
|
||||
/// `screenshot` in this process draws as `solidcraft-cli mcp` does.
|
||||
fn gpu() -> bool {
|
||||
match solidcraft_gpu::capture::shared() {
|
||||
Ok(c) => {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
|
||||
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
|
||||
}));
|
||||
true
|
||||
}
|
||||
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||||
eprintln!("skipping: {e}");
|
||||
false
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's size and RGBA pixels.
|
||||
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
|
||||
(img.width(), img.height(), img.into_raw())
|
||||
}
|
||||
|
||||
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
|
||||
fn differing(a: &[u8], b: &[u8]) -> usize {
|
||||
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
|
||||
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
|
||||
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
|
||||
}
|
||||
|
||||
/// The same picture: GPU output may differ by a few levels or a stray edge pixel between drivers.
|
||||
#[track_caller]
|
||||
fn assert_same(a: &[u8], b: &[u8], what: &str) {
|
||||
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
|
||||
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
|
||||
}
|
||||
|
||||
/// Different pictures: more than stray pixels differ.
|
||||
#[track_caller]
|
||||
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
|
||||
let n = differing(a, b);
|
||||
assert!(n >= 20, "{what}: only {n} pixels differ");
|
||||
}
|
||||
|
||||
const SIZE: (u32, u32) = (160, 100);
|
||||
|
||||
fn camera() -> Value {
|
||||
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
|
||||
@@ -88,32 +135,38 @@ impl Drop for Scratch {
|
||||
/// not the home view the option used to be ignored for.
|
||||
#[test]
|
||||
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let home = dir.snapshot(&script, "home.png", &[]);
|
||||
let json = camera().to_string();
|
||||
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
assert_ne!(shot, home, "--camera was ignored");
|
||||
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
|
||||
assert_differs(&shot, &home, "--camera was ignored");
|
||||
assert_same(&shot, &dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})), "the MCP screenshot");
|
||||
// A perspective camera too, and a different eye gives a different picture.
|
||||
let mut near = camera();
|
||||
near["fov"] = json!(0.6);
|
||||
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
|
||||
assert_ne!(perspective, shot);
|
||||
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
|
||||
assert_differs(&perspective, &shot, "perspective");
|
||||
assert_same(&perspective, &dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})), "the MCP perspective screenshot");
|
||||
}
|
||||
|
||||
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
|
||||
/// standard view still looks as it did.
|
||||
#[test]
|
||||
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
|
||||
assert_eq!(saved, by_camera);
|
||||
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
|
||||
assert_same(&saved, &by_camera, "the saved view");
|
||||
assert_same(&saved, &dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})), "the MCP saved view");
|
||||
// A standard view frames the model from its direction, as before: the home camera turned.
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
let mut s = Session::default();
|
||||
@@ -121,32 +174,38 @@ fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
|
||||
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
|
||||
assert_ne!(top, saved);
|
||||
assert_same(&top, &solidcraft_engine::capture::png(&scene, &cam, SIZE.0, SIZE.1).unwrap().bytes, "the home camera turned to the top");
|
||||
assert_same(&top, &dir.mcp(&script, "mcp-top.png", json!({"view": "top"})), "the MCP top view");
|
||||
assert_differs(&top, &saved, "top and the saved view");
|
||||
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
|
||||
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
|
||||
assert_ne!(iso, top);
|
||||
assert_same(&iso, &dir.snapshot(&script, "home.png", &[]), "iso is home");
|
||||
assert_differs(&iso, &top, "iso and top");
|
||||
}
|
||||
|
||||
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
|
||||
/// of its snapshot; `--view` and `--camera` still decide when given.
|
||||
#[test]
|
||||
fn a_script_can_carry_its_camera() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
|
||||
let plain = dir.script("plain.json", &[]);
|
||||
let carried = dir.snapshot(&script, "carried.png", &[]);
|
||||
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
|
||||
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
|
||||
assert_same(&carried, &dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]), "the carried camera");
|
||||
assert_differs(&carried, &dir.snapshot(&plain, "home.png", &[]), "the carried camera and home");
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
|
||||
assert_same(&top, &dir.snapshot(&plain, "plain-top.png", &["--view", "top"]), "--view decides");
|
||||
}
|
||||
|
||||
/// The grid and the axes each go when asked, and a render without both from any side shows the
|
||||
/// model alone: an empty design is then the same blank picture from every camera.
|
||||
#[test]
|
||||
fn the_grid_and_the_axes_can_be_left_out() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
@@ -161,7 +220,7 @@ fn the_grid_and_the_axes_can_be_left_out() {
|
||||
let different = [&all, &no_grid, &no_axes, &bare];
|
||||
for (i, a) in different.iter().enumerate() {
|
||||
for b in &different[i + 1..] {
|
||||
assert_ne!(a, b, "{view:?}: two pictures are the same");
|
||||
assert_differs(a, b, &format!("{view:?}: two of the pictures"));
|
||||
}
|
||||
}
|
||||
// The same as the MCP's `grid` and `axes`.
|
||||
@@ -173,15 +232,151 @@ fn the_grid_and_the_axes_can_be_left_out() {
|
||||
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
dir.mcp(&script, out, a)
|
||||
};
|
||||
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
|
||||
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
|
||||
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
|
||||
assert_same(&no_grid, &mcp(json!({"grid": false}), "mcp-no-grid.png"), "the MCP's grid: false");
|
||||
assert_same(&no_axes, &mcp(json!({"axes": false}), "mcp-no-axes.png"), "the MCP's axes: false");
|
||||
assert_same(&bare, &mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"), "the MCP's bare picture");
|
||||
}
|
||||
let empty = dir.0.join("empty.json");
|
||||
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
|
||||
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
|
||||
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
|
||||
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
|
||||
assert_same(&blank("top", "blank-top.png"), &blank("front", "blank-front.png"), "blank from every side");
|
||||
assert_differs(&blank("top", "blank-top.png"), &dir.snapshot(&empty, "top.png", &["--view", "top"]), "blank and the grid");
|
||||
}
|
||||
|
||||
/// A snapshot of a box shows its silhouette where the camera puts it: the pixels that differ
|
||||
/// from an empty design's picture (same camera, no grid or axes) span the box's corners projected
|
||||
/// to pixels, within the edge and silhouette lines' width.
|
||||
#[test]
|
||||
fn a_snapshot_shows_the_part_where_the_camera_puts_it() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let part = dir.0.join("box.json");
|
||||
let empty = dir.0.join("empty.json");
|
||||
let corner = [10.0, -20.0, -15.0];
|
||||
let size = [60.0, 40.0, 30.0];
|
||||
std::fs::write(
|
||||
&part,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": size[0], "width": size[1], "height": size[2], "corner": corner}}]})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
|
||||
for cam in [
|
||||
camera(),
|
||||
json!({"target": [40, 0, 0], "yaw": 0.0, "pitch": 0.0, "distance": 160}),
|
||||
json!({"target": [40, 0, 0], "yaw": -0.4, "pitch": 0.5, "distance": 150, "fov": 0.6}),
|
||||
] {
|
||||
let args = ["--camera", &cam.to_string(), "--no-grid", "--no-axes"];
|
||||
let (_, _, with) = decode(&dir.snapshot(&part, "part.png", &args));
|
||||
let (_, _, without) = decode(&dir.snapshot(&empty, "empty.png", &args));
|
||||
// The part's pixels: where the picture differs from the empty one.
|
||||
let (mut lo, mut hi) = ([u32::MAX; 2], [0u32; 2]);
|
||||
for (i, (p, q)) in with.as_chunks::<4>().0.iter().zip(without.as_chunks::<4>().0).enumerate() {
|
||||
if p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 24) {
|
||||
let (x, y) = (i as u32 % SIZE.0, i as u32 / SIZE.0);
|
||||
lo = [lo[0].min(x), lo[1].min(y)];
|
||||
hi = [hi[0].max(x), hi[1].max(y)];
|
||||
}
|
||||
}
|
||||
assert!(lo[0] <= hi[0], "{cam}: the part is not in the picture");
|
||||
// Where the camera puts the corners.
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("view.apply", &json!({"camera": cam})).unwrap();
|
||||
let c = s.camera.unwrap();
|
||||
let vp = c.view_proj(f64::from(SIZE.0) / f64::from(SIZE.1), 1000.0);
|
||||
let (mut elo, mut ehi) = ([f64::MAX; 2], [f64::MIN; 2]);
|
||||
for k in 0..8 {
|
||||
let p = solidcraft_engine::geom::Vec3::new(
|
||||
corner[0] + size[0] * f64::from(k & 1),
|
||||
corner[1] + size[1] * f64::from((k >> 1) & 1),
|
||||
corner[2] + size[2] * f64::from((k >> 2) & 1),
|
||||
);
|
||||
let q = vp.apply(p);
|
||||
let (x, y) = ((q[0] / q[3] + 1.0) * 0.5 * f64::from(SIZE.0), (1.0 - q[1] / q[3]) * 0.5 * f64::from(SIZE.1));
|
||||
elo = [elo[0].min(x), elo[1].min(y)];
|
||||
ehi = [ehi[0].max(x), ehi[1].max(y)];
|
||||
}
|
||||
let near = |got: u32, want: f64| (f64::from(got) - want).abs() <= 2.5;
|
||||
assert!(
|
||||
near(lo[0], elo[0]) && near(lo[1], elo[1]) && near(hi[0] + 1, ehi[0]) && near(hi[1] + 1, ehi[1]),
|
||||
"{cam}: the part spans pixels {lo:?}..={hi:?}, its corners project to {elo:?}..{ehi:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `snapshot` under a `SOLIDCRAFT_GPU` policy: its reply and stderr.
|
||||
fn snapshot_under(dir: &Scratch, script: &Path, out: &str, policy: &str) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.env("SOLIDCRAFT_GPU", policy)
|
||||
.arg("snapshot")
|
||||
.arg(script)
|
||||
.arg("--out")
|
||||
.arg(dir.0.join(out))
|
||||
.args(["--width", &SIZE.0.to_string(), "--height", &SIZE.1.to_string(), "--view", "top"])
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The last tier: `SOLIDCRAFT_GPU=cpu` draws with the CPU rasterizer whatever adapters there are,
|
||||
/// says so (`renderer: cpu` and a warning that overlays are not drawn), and the picture is the
|
||||
/// rasterizer's own.
|
||||
#[test]
|
||||
fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let output = snapshot_under(&dir, &script, "cpu.png", "cpu");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "{stderr}");
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], "cpu", "{reply}");
|
||||
assert!(reply["warning"].as_str().is_some_and(|w| w.contains("not drawn")), "{reply}");
|
||||
assert!(stderr.contains("CPU rasterizer"), "{stderr}");
|
||||
let mut s = Session::default();
|
||||
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
let cpu = solidcraft_engine::render::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
|
||||
assert_eq!(std::fs::read(dir.0.join("cpu.png")).unwrap(), cpu);
|
||||
}
|
||||
|
||||
/// `hardware` (and `software`) never fall to the CPU rasterizer: without such an adapter the
|
||||
/// snapshot fails, naming the policy, and writes nothing; with one it says it was drawn on the GPU.
|
||||
/// `auto` lands where `solidcraft-cli gpu` says it does.
|
||||
#[test]
|
||||
fn a_strict_policy_never_falls_to_the_cpu() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let survey = |policy: &str| {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["gpu", "--policy", policy]).output().unwrap();
|
||||
(o.status.success(), serde_json::from_slice::<Value>(&o.stdout).unwrap_or(Value::Null))
|
||||
};
|
||||
for policy in ["hardware", "software"] {
|
||||
let (found, report) = survey(policy);
|
||||
let output = snapshot_under(&dir, &script, &format!("{policy}.png"), policy);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if found {
|
||||
assert!(output.status.success(), "{policy}: {stderr}");
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], report["renderer"], "{policy}: {reply}");
|
||||
assert_ne!(reply["renderer"], "cpu");
|
||||
} else {
|
||||
assert_eq!(report["lands_on"], Value::Null, "{policy}: {report}");
|
||||
assert_eq!(output.status.code(), Some(1), "{policy}: {stderr}");
|
||||
assert!(stderr.contains(&format!("`{policy}` policy")) && stderr.contains("solidcraft-cli gpu"), "{policy}: {stderr}");
|
||||
assert!(!dir.0.join(format!("{policy}.png")).exists());
|
||||
}
|
||||
}
|
||||
let (found, report) = survey("auto");
|
||||
assert!(found, "auto always lands somewhere: {report}");
|
||||
let output = snapshot_under(&dir, &script, "auto.png", "auto");
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], report["renderer"], "{reply} vs {report}");
|
||||
let (_, cpu) = survey("cpu");
|
||||
assert_eq!((cpu["lands_on"].clone(), cpu["renderer"].clone()), (json!("cpu"), json!("cpu")), "{cpu}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,10 +24,14 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
# OneBSpace (OneCAD): std::time::Instant PANICS on wasm32-unknown-unknown
|
||||
# ("time not implemented on this platform") — every picker deadline in
|
||||
# services_odoo.rs must use web_time::Instant (performance.now() on wasm).
|
||||
web-time = "1"
|
||||
serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance",
|
||||
] }
|
||||
@@ -3,22 +3,10 @@
|
||||
<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">
|
||||
<title>OneCAD</title>
|
||||
<meta name="description" content="OneCAD — parametric CAD of the One B OS workspace, in Rust.">
|
||||
<!-- OneBSpace (OneCAD): upstream's social-card meta (solidcraft.ai / docs.solidcraft.io)
|
||||
and PWA manifest + icons are gone — this is our own app inside the Odoo iframe. -->
|
||||
<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" />
|
||||
@@ -32,15 +20,31 @@
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
|
||||
<!-- OneBSpace (OneCAD): the Odoo Documents host bridge. Copied verbatim
|
||||
(not hashed): the framework's bundleVersion ?v= on the iframe URL is
|
||||
the cache buster, like every other OneBSpace app bundle. The build
|
||||
script compiles the erasable-syntax TS (src/odoo/bridge.ts and
|
||||
src/odoo/index.ts) to odoo/bridge.js / odoo/index.js in dist;
|
||||
explorer.js is copied as-is (data-trunk copy-dir, same tag shape as
|
||||
every other entry above). -->
|
||||
<link data-trunk rel="copy-dir" href="src/odoo" data-target-path="odoo" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solidcraft_canvas"></canvas>
|
||||
<div id="solidcraft_loading">Loading SolidCraft…</div>
|
||||
<div id="solidcraft_loading" role="status"><span class="wordmark">OneCAD</span><span class="sr-only">Loading OneCAD…</span></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(() => {}));
|
||||
}
|
||||
// OneBSpace (OneCAD): boot the Odoo Documents bridge glue only inside a
|
||||
// frame (the wasm_app_framework shell). Standalone runs never import it.
|
||||
// odoo/index.js is src/odoo/index.ts compiled by the addon build script.
|
||||
try {
|
||||
if (window.parent !== window) {
|
||||
import("./odoo/index.js?v=20.0.1.0.6").catch((e) => console.warn("OneCAD odoo bridge:", e));
|
||||
}
|
||||
} catch (e) { /* cross-origin parent (standalone) — skip */ }
|
||||
// Install as an app: the browser's offer can come before the app runs, so it is kept here for
|
||||
// the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers.
|
||||
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; });
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Promise-based access to the running app's existing control channel.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::{JsValue, closure::Closure};
|
||||
|
||||
const MAX_PENDING: usize = 256;
|
||||
const MAX_JSON_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
resolve: js_sys::Function,
|
||||
reject: js_sys::Function,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Api {
|
||||
sender: Sender<ControlRequest>,
|
||||
pending: Rc<RefCell<Vec<Pending>>>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
fn params(value: &JsValue) -> Result<Value, String> {
|
||||
if value.is_undefined() || value.is_null() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
let text = js_sys::JSON::stringify(value).ok().and_then(|v| v.as_string()).ok_or("params must be JSON serializable")?;
|
||||
if text.len() > MAX_JSON_BYTES {
|
||||
return Err("params exceed 16 MiB".into());
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
fn error(message: &str) -> JsValue {
|
||||
js_sys::Error::new(message).into()
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn install(sender: Sender<ControlRequest>, ctx: egui::Context) -> Result<Self, JsValue> {
|
||||
let api = Self { sender, pending: Rc::default(), ctx };
|
||||
let object = js_sys::Object::new();
|
||||
let request = api.clone();
|
||||
let execute = api.clone();
|
||||
let commands = api.clone();
|
||||
let inspect = api.clone();
|
||||
for (name, function) in [
|
||||
(
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(move |method: String, value: JsValue| {
|
||||
request.request(&method, params(&value))
|
||||
})
|
||||
.into_js_value(),
|
||||
),
|
||||
(
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(move |command: String, value: JsValue| {
|
||||
execute.request("engine.execute", params(&value).map(|p| json!({"command": command, "params": p})))
|
||||
})
|
||||
.into_js_value(),
|
||||
),
|
||||
("commands", Closure::<dyn Fn() -> js_sys::Promise>::new(move || commands.request("engine.commands", Ok(json!({})))).into_js_value()),
|
||||
(
|
||||
"inspect",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(move |value: JsValue| inspect.request("document.inspect", params(&value)))
|
||||
.into_js_value(),
|
||||
),
|
||||
] {
|
||||
js_sys::Reflect::set(&object, &name.into(), &function)?;
|
||||
}
|
||||
let window = web_sys::window().ok_or_else(|| error("no window"))?;
|
||||
js_sys::Reflect::set(&window, &"solidcraft".into(), &object)?;
|
||||
Ok(api)
|
||||
}
|
||||
|
||||
fn request(&self, method: &str, params: Result<Value, String>) -> js_sys::Promise {
|
||||
let params = match params {
|
||||
Ok(params) => params,
|
||||
Err(message) => return js_sys::Promise::reject(&error(&message)),
|
||||
};
|
||||
if self.pending.borrow().len() >= MAX_PENDING {
|
||||
return js_sys::Promise::reject(&error("too many pending SolidCraft requests (maximum 256)"));
|
||||
}
|
||||
js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (request, reply) = ControlRequest::new(method, params.clone());
|
||||
if self.sender.send(request).is_err() {
|
||||
let _ = reject.call1(&JsValue::UNDEFINED, &error("SolidCraft is not running"));
|
||||
return;
|
||||
}
|
||||
self.pending.borrow_mut().push(Pending { reply, resolve, reject });
|
||||
self.ctx.request_repaint();
|
||||
})
|
||||
}
|
||||
|
||||
pub fn poll(&self) {
|
||||
let mut completed = Vec::new();
|
||||
self.pending.borrow_mut().retain_mut(|pending| {
|
||||
let result = match pending.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
js_sys::JSON::parse(&reply.get("result").unwrap_or(&Value::Null).to_string())
|
||||
} else {
|
||||
Err(error(reply.get("error").and_then(Value::as_str).unwrap_or("SolidCraft request failed")))
|
||||
}
|
||||
}
|
||||
Err(TryRecvError::Empty) => return true,
|
||||
Err(TryRecvError::Disconnected) => Err(error("SolidCraft request dropped")),
|
||||
};
|
||||
completed.push((pending.resolve.clone(), pending.reject.clone(), result));
|
||||
false
|
||||
});
|
||||
// Release the queue before calling JavaScript, which can submit another request.
|
||||
for (resolve, reject, result) in completed {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &value);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = reject.call1(&JsValue::UNDEFINED, &error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pending.borrow().is_empty() {
|
||||
self.ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +295,12 @@ pub fn start(files: &Rc<Files>) {
|
||||
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
|
||||
pub fn flush(files: &Rc<Files>) {
|
||||
for (path, bytes) in vfs::take_writes() {
|
||||
// OneBSpace (OneCAD): in the Odoo shell, writes under /odoo/ go to
|
||||
// the bound Documents record and /downloads/ exports go to the
|
||||
// "Exports" folder (air-gapped: no device IO through the VFS).
|
||||
if crate::services_odoo::handle_write(&path, &bytes) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.strip_prefix(OPFS) {
|
||||
let name = name.to_string();
|
||||
{
|
||||
@@ -458,7 +464,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(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
|
||||
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl,.obj,.glb,.gltf");
|
||||
// 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,4 +1,4 @@
|
||||
//! SolidCraft in the browser.
|
||||
//! OneCAD in the browser.
|
||||
//!
|
||||
//! 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
|
||||
@@ -6,10 +6,18 @@
|
||||
//! `?webgpu`), `?sample` opens the sample part.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod install;
|
||||
// OneBSpace (OneCAD): Odoo Documents host integration — Services overrides
|
||||
// (pickers → the JS bridge), VFS /odoo/ + /downloads/ drain, asset mirrors.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod services_odoo;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
@@ -20,11 +28,41 @@ mod web {
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::files::{self, Files, Intent};
|
||||
use crate::space_mouse::SpaceMouse;
|
||||
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
/// A 32-bit float depth buffer (no stencil): WebGPU and WebGL2 both have it, and the viewport
|
||||
/// draws it with reversed depth, whose precision follows the distance from the eye (#1434).
|
||||
const DEPTH_BITS: u8 = 32;
|
||||
|
||||
/// OneBSpace (OneCAD): upstream held the loading logo animation on screen for 3.6 s
|
||||
/// (LOADING_MIN_MS = 3600) — the "splash opener". The wordmark splash is dismissed as
|
||||
/// soon as the app is ready; the fade itself stays.
|
||||
const LOADING_MIN_MS: f64 = 0.0;
|
||||
/// The fade from the loading screen into the app.
|
||||
const LOADING_FADE_MS: i32 = 300;
|
||||
|
||||
/// Take the loading screen away as soon as the app is ready (OneBSpace (OneCAD): no
|
||||
/// forced minimum hold): fade it, then remove it.
|
||||
fn dismiss_loading(el: web_sys::Element) {
|
||||
let Some(window) = web_sys::window() else {
|
||||
el.remove();
|
||||
return;
|
||||
};
|
||||
let now = window.performance().map_or(LOADING_MIN_MS, |p| p.now());
|
||||
let wait = (LOADING_MIN_MS - now).max(0.0) as i32;
|
||||
let fade = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = el.set_attribute("class", "done");
|
||||
let gone = wasm_bindgen::closure::Closure::once_into_js(move || el.remove());
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(gone.unchecked_ref(), LOADING_FADE_MS);
|
||||
}
|
||||
});
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(fade.unchecked_ref(), wait);
|
||||
}
|
||||
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
const AUTOSAVE_S: f64 = 30.0;
|
||||
@@ -54,6 +92,7 @@ mod web {
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
api: Api,
|
||||
files: Rc<Files>,
|
||||
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
|
||||
autosaved_at: f64,
|
||||
@@ -67,6 +106,7 @@ mod web {
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
space_mouse: SpaceMouse,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -85,7 +125,20 @@ mod web {
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
// OneBSpace (OneCAD): CAD assets brought in from the computer are
|
||||
// mirrored into the Documents "Assets" folder in the Odoo host
|
||||
// (before the incoming queue is drained below).
|
||||
crate::services_odoo::mirror_imports(&self.files);
|
||||
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
// A glTF file's buffers picked with it are kept next to it, not opened.
|
||||
incoming.retain_mut(|(name, bytes, _)| {
|
||||
let companion =
|
||||
std::path::Path::new(name.as_str()).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("bin"));
|
||||
if companion {
|
||||
vfs::insert(&format!("{}{name}", files::UPLOAD), std::mem::take(bytes));
|
||||
}
|
||||
!companion
|
||||
});
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
@@ -373,11 +426,15 @@ mod web {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.api.poll();
|
||||
self.space_mouse.logic(&mut self.app, ctx);
|
||||
self.files_tick(ctx);
|
||||
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) {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -463,7 +520,7 @@ mod web {
|
||||
let panel = files.clone();
|
||||
let services = Services {
|
||||
// Open shows the browser's files (and the computer's, from there).
|
||||
pick_open: Some(Box::new(move || {
|
||||
pick_open: Some(Box::new(move |_: &[&str]| {
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
})),
|
||||
@@ -475,7 +532,12 @@ mod web {
|
||||
install_app: Some(crate::install::service(&cc.egui_ctx)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
// OneBSpace (OneCAD): install odoo services on wasm
|
||||
let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());
|
||||
// Thumbnails of saves and start-page cards.
|
||||
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
@@ -493,12 +555,14 @@ mod web {
|
||||
if !query().contains("sample") {
|
||||
// The empty design takes Units of new designs (#1205). The sample
|
||||
// part is built in millimetres and is left that way.
|
||||
// OneBSpace (OneCAD): no start page — the app opens straight
|
||||
// into the workspace (the house button still shows Home).
|
||||
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("OneCAD browser API: {e:?}"))?;
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
api,
|
||||
files,
|
||||
autosaved_at: f64::NEG_INFINITY,
|
||||
autosaved: None,
|
||||
@@ -507,15 +571,16 @@ mod web {
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
space_mouse: SpaceMouse::default(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Ok(()) => dismiss_loading(el),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
el.set_inner_html(&format!("<p>OneCAD failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,947 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD odoo/bridge.ts — the Odoo Documents bridge client + Rust facade.
|
||||
* -----------------------------------------------------------------------
|
||||
* Port of the family adapter (one-cut-pro apps/web/src/odoo/bridge.ts,
|
||||
* vanilla twin: onepixel frontend/src/odoo-host/bridge.ts) onto the
|
||||
* wasm_app_framework host protocol — authoritative implementation:
|
||||
* one-os-dev/core-components/wasm_app_framework/static/src/wasm_app_action.js
|
||||
*
|
||||
* request : {source: "one-cad", kind: "odoo-documents", id, method, payload}
|
||||
* posted on window.parent (same-origin Odoo shell iframe)
|
||||
* reply : {source: "one-cad-host", kind: "odoo-documents", id, ok,
|
||||
* result | error:{message}}
|
||||
*
|
||||
* Host methods and payload shapes (confirmed against the framework):
|
||||
* ping {} -> {user, folder, app:{root, projects?, assets?, exports?}}
|
||||
* list {} -> {documents:[{id,name,updatedAt,size}]}
|
||||
* read {recordId} -> {name, dataB64}
|
||||
* write {recordId?, name?, dataB64, folderId?} -> {id}
|
||||
* delete {recordId} -> {deleted}
|
||||
* usage {} -> {bytesUsed, documentCount}
|
||||
* folders {} -> {tree:[{id,name,parentId,system}], app:{...app, appsFilesRoot}}
|
||||
* createFolder {name, parentId?} -> {id}
|
||||
* listFolder {folderId} -> {folders:[{id,name,system}], files:[{id,name,ext,mimetype,size,updatedAt}]}
|
||||
* saveExport {name, dataB64, folderId?} -> {id} (folderId omitted -> the app's "exports" subfolder)
|
||||
* stat {recordId} -> {id, name, mimetype}
|
||||
*
|
||||
* Family laws: Documents is the ONLY storage; 60 s calls / 300 s writes;
|
||||
* one correlation id per call; air-gapped (no external fetch ever).
|
||||
* The 128 MB per-record cap is enforced HERE, on the raw bytes, before any
|
||||
* base64 conversion (the host mirrors it on base64 length * 3/4).
|
||||
*
|
||||
* RUST EVENT CONTRACT (window CustomEvents; the Rust side is built
|
||||
* separately and consumes host responses through these):
|
||||
*
|
||||
* LISTEN "onecad:host-request" detail {id:number, method:string,
|
||||
* (Rust -> JS) payload?:object}
|
||||
* One door for everything. Known host
|
||||
* methods (ping/list/read/write/delete/
|
||||
* usage/folders/createFolder/listFolder/
|
||||
* saveExport/stat) pass straight through
|
||||
* to the host with their own payload.
|
||||
* Orchestration methods run dialogs +
|
||||
* writes in JS and never hit the host:
|
||||
* "saveAs" {name?, dataB64?, recordId?, bytes?}
|
||||
* "open" {}
|
||||
* "export" {name?, dataB64?, bytes?}
|
||||
* "importAsset" {name?, dataB64?, bytes?}
|
||||
* Bytes may arrive as dataB64 (JSON) or a
|
||||
* Uint8Array in payload.bytes; when absent
|
||||
* the bridge asks the app to serialize
|
||||
* itself via window.solidcraft (below).
|
||||
* REQUEST QUEUE: entries are processed
|
||||
* strictly FIFO, one at a time, and each
|
||||
* id is answered exactly once.
|
||||
*
|
||||
* EMIT "onecad:host-response" detail {id, ok, result?, error?{message}}
|
||||
* (JS -> Rust) correlate with the request id. Timeouts
|
||||
* surface as ok:false errors (60 s calls,
|
||||
* 300 s write/read/saveExport).
|
||||
*
|
||||
* EMIT "onecad:host-ready" detail {host:boolean, documentId, name}
|
||||
* (JS -> Rust) fired once by the boot glue (odoo/
|
||||
* index.ts) after waitForHost() and any
|
||||
* ?document= deep-link read. Late-booting
|
||||
* Rust can read the same state sync at
|
||||
* window.onecadHost.{ready,host,documentId,name}.
|
||||
*
|
||||
* EMIT "onecad:document-opened" detail {documentId, name, dataB64}
|
||||
* (JS -> Rust) fired after the deep-link record was
|
||||
* read. The full payload stays available
|
||||
* for late listeners via
|
||||
* window.onecadHost.consumePendingOpen()
|
||||
* (returns the detail once, then null).
|
||||
*
|
||||
* JS -> APP window.solidcraft.request("odoo.document.bytes")
|
||||
* -> Promise<Uint8Array | ArrayBuffer | {dataB64}> — serialize
|
||||
* the current workspace (used when a save/export carries no
|
||||
* bytes). request("odoo.document.info") -> Promise<{name?}> —
|
||||
* suggested document name; failures are ignored.
|
||||
*
|
||||
* STANDALONE DEV MODE — when the app is not inside the framework iframe,
|
||||
* the facade goes inert: waitForHost() resolves immediately (host:false),
|
||||
* every host call rejects with a clear "standalone" error and host-request
|
||||
* events are answered ok:false — the app still boots, nothing hangs.
|
||||
*
|
||||
* This file is erasable-syntax-only TypeScript with ZERO dependencies: the
|
||||
* addon build (scripts/build_onecad_addon.py) compiles it to bridge.js next
|
||||
* to this file (node stripTypeScriptTypes; tsc when available).
|
||||
*/
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const WRITE_TIMEOUT_MS = 300_000; // large .solidcraft containers over the bridge
|
||||
const POLL_INTERVAL_MS = 100;
|
||||
const HOST_WAIT_MS = 20_000; // ping every 100 ms for up to 20 s
|
||||
/** Family cap (wasm_app_action.js MAX_DOCUMENT_BYTES), mirrored client-side. */
|
||||
export const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export const SOURCE_TAG = "one-cad";
|
||||
export const HOST_SOURCE_TAG = "one-cad-host";
|
||||
export const BRIDGE_KIND = "odoo-documents";
|
||||
|
||||
/** Window CustomEvent names of the Rust facade contract (see header). */
|
||||
export const EVT_HOST_REQUEST = "onecad:host-request";
|
||||
export const EVT_HOST_RESPONSE = "onecad:host-response";
|
||||
export const EVT_HOST_READY = "onecad:host-ready";
|
||||
export const EVT_DOCUMENT_OPENED = "onecad:document-opened";
|
||||
|
||||
/** window.solidcraft request names the bridge calls INTO the app. */
|
||||
export const APP_SERIALIZE_METHOD = "odoo.document.bytes";
|
||||
export const APP_INFO_METHOD = "odoo.document.info";
|
||||
|
||||
export interface OdooAppTree {
|
||||
root: number;
|
||||
projects?: number;
|
||||
assets?: number;
|
||||
exports?: number;
|
||||
appsFilesRoot?: number;
|
||||
}
|
||||
|
||||
export interface PingResult {
|
||||
user: string;
|
||||
folder: number;
|
||||
app: OdooAppTree;
|
||||
}
|
||||
|
||||
export interface FolderRow {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
system?: boolean;
|
||||
}
|
||||
|
||||
export interface FileRow {
|
||||
id: number;
|
||||
name: string;
|
||||
ext?: string;
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface WriteResult {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ReadResult {
|
||||
name: string;
|
||||
dataB64: string;
|
||||
}
|
||||
|
||||
export interface StatResult {
|
||||
id: number;
|
||||
name: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export interface UsageResult {
|
||||
bytesUsed: number;
|
||||
documentCount: number;
|
||||
}
|
||||
|
||||
export interface FoldersResult {
|
||||
tree: FolderRow[];
|
||||
app: OdooAppTree;
|
||||
}
|
||||
|
||||
export interface ListResult {
|
||||
documents: FileRow[];
|
||||
}
|
||||
|
||||
export interface CallOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface BindEntry {
|
||||
recordId: number;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** The document the app is currently working on (deep link / last save). */
|
||||
export interface CurrentBinding {
|
||||
documentId: number | null;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface ExplorerChoice {
|
||||
name?: string;
|
||||
folderId?: number;
|
||||
recordId?: number;
|
||||
recordName?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export type ExplorerOpener = (opts: {
|
||||
mode: "save" | "open" | "asset" | "export";
|
||||
defaultName?: string;
|
||||
extensions?: string[];
|
||||
}) => Promise<ExplorerChoice | null>;
|
||||
|
||||
export interface HostRequestDetail {
|
||||
id: number;
|
||||
method: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
/** services_odoo.rs names the request object `params`; payload is the glue
|
||||
* spelling. runRequest accepts either. */
|
||||
params?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface HostResponseDetail {
|
||||
id: number;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: { message: string } | null;
|
||||
}
|
||||
|
||||
export interface HostReadyDetail {
|
||||
host: boolean;
|
||||
documentId: number | null;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentOpenedDetail {
|
||||
documentId: number;
|
||||
name: string;
|
||||
dataB64: string;
|
||||
}
|
||||
|
||||
export interface SaveAsResult {
|
||||
id: number;
|
||||
name: string;
|
||||
folderId: number | null;
|
||||
}
|
||||
|
||||
export interface OpenResult {
|
||||
id: number;
|
||||
name: string;
|
||||
dataB64: string;
|
||||
}
|
||||
|
||||
export interface SavedResult {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Host method names accepted on "onecad:host-request" (pass-through). */
|
||||
const HOST_METHODS = new Set([
|
||||
"ping", "list", "read", "write", "delete", "usage",
|
||||
"folders", "createFolder", "listFolder", "saveExport", "stat",
|
||||
]);
|
||||
/** Host methods that move bulk bytes — family law says 300 s. */
|
||||
const BULK_METHODS = new Set(["read", "write", "saveExport"]);
|
||||
/** Orchestration methods served by this bridge (dialogs + writes). */
|
||||
const UI_METHODS = new Set(["saveAs", "open", "export", "importAsset"]);
|
||||
|
||||
const BIND_KEY_PREFIX = "one-cad:solidcraft:bind:";
|
||||
const OWNED_EXTENSION = ".solidcraft";
|
||||
|
||||
/** Rust services_odoo.rs ASSET_EXTS — pick_program offers these from Documents. */
|
||||
const RUST_ASSET_EXTS = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
|
||||
|
||||
/** Emulated pending paths must sanitize exactly like services_odoo.rs::sanitize. */
|
||||
function sanitizeName(name: unknown): string {
|
||||
const cleaned = String(name || "").replace(/[/\\]/g, "_").trim();
|
||||
return cleaned || "file";
|
||||
}
|
||||
|
||||
/** Pull the Uint8Array payload out of an odoo.write / odoo.import event. */
|
||||
function bytesOf(payload: Record<string, unknown>): Uint8Array | null {
|
||||
const raw = payload ? payload.bytes : undefined;
|
||||
if (!raw) return null;
|
||||
if (raw instanceof Uint8Array) return raw;
|
||||
// Cross-realm payloads (tests, odd hosts): isView works across realms.
|
||||
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(raw)) {
|
||||
return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
||||
return normalizeBytes(raw);
|
||||
}
|
||||
|
||||
function newToken(): string {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||||
return "t" + window.crypto.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
interface PendingTokenBinding {
|
||||
recordId: number | null;
|
||||
name: string;
|
||||
folderId: number | null;
|
||||
}
|
||||
|
||||
interface SolidcraftApi {
|
||||
request?: (method: string, params?: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface QueueEntry {
|
||||
id: number;
|
||||
method: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Codec + guard helpers (exported for the explorer and the app glue) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Chunked btoa so multi-megabyte CAD containers never blow the arg stack. */
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
const slice = bytes.subarray(i, i + chunk);
|
||||
binary += String.fromCharCode.apply(null, Array.from(slice) as unknown as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function base64ToBytes(dataB64: string): Uint8Array {
|
||||
const binary = atob(dataB64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Family 128 MB cap, enforced on RAW bytes before base64 conversion. */
|
||||
export function guardCap(bytes: Uint8Array): void {
|
||||
if (bytes.byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new Error(
|
||||
"OneCAD odoo bridge: document exceeds the 128 MB bridge limit (" +
|
||||
bytes.byteLength + " raw bytes)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBytes(value: unknown): Uint8Array | null {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
if (value && typeof value === "object") {
|
||||
const holder = value as { dataB64?: unknown };
|
||||
if (typeof holder.dataB64 === "string") return base64ToBytes(holder.dataB64);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureExtension(name: string, extension: string): string {
|
||||
const trimmed = (name || "").trim() || "Untitled";
|
||||
const dot = trimmed.lastIndexOf(".");
|
||||
if (dot > 0) return trimmed;
|
||||
return trimmed + extension;
|
||||
}
|
||||
|
||||
function stripOwnedExtension(name: string): string {
|
||||
return (name || "").replace(/\.(solidcraft|solvecraft)$/i, "");
|
||||
}
|
||||
|
||||
function detectInFrame(): boolean {
|
||||
try {
|
||||
return window.parent !== window;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function solidcraftApi(): SolidcraftApi | null {
|
||||
const holder = window as unknown as { solidcraft?: SolidcraftApi };
|
||||
const api = holder.solidcraft;
|
||||
return api && typeof api.request === "function" ? api : null;
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error && typeof error === "object" && "message" in error
|
||||
? String((error as { message?: unknown }).message)
|
||||
: String(error);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The bridge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
class OneCadBridge {
|
||||
inFrame: boolean;
|
||||
standalone: boolean = false;
|
||||
nextId: number = 1;
|
||||
pending: Map<number, PendingEntry> = new Map();
|
||||
queue: QueueEntry[] = [];
|
||||
draining: boolean = false;
|
||||
opener: ExplorerOpener | null = null;
|
||||
binding: CurrentBinding = { documentId: null, name: null };
|
||||
pendingOpen: DocumentOpenedDetail | null = null;
|
||||
/** odoo.pickSave tokens -> binding (services_odoo.rs writes to
|
||||
* /odoo/pending/<token>/<name>; the first write creates the record and
|
||||
* every later Ctrl+S writes back in place). */
|
||||
pendingTokens = new Map<string, PendingTokenBinding>();
|
||||
|
||||
constructor() {
|
||||
this.inFrame = detectInFrame();
|
||||
window.addEventListener("message", (event) => this.onMessage(event));
|
||||
window.addEventListener(EVT_HOST_REQUEST, ((event: Event) => {
|
||||
const detail = (event as CustomEvent).detail as HostRequestDetail | undefined;
|
||||
if (!detail || typeof detail.id !== "number" || typeof detail.method !== "string") return;
|
||||
// services_odoo.rs dispatches the request object as `params`; the
|
||||
// orchestration glue historically used `payload`. Accept both.
|
||||
const raw = detail.payload !== undefined ? detail.payload : detail.params;
|
||||
this.enqueue({
|
||||
id: detail.id,
|
||||
method: detail.method,
|
||||
payload: (raw && typeof raw === "object"
|
||||
? raw
|
||||
: {}) as Record<string, unknown>,
|
||||
});
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
/* ---------------- host plumbing ---------------- */
|
||||
|
||||
isHostAvailable(): boolean {
|
||||
return this.inFrame && !this.standalone;
|
||||
}
|
||||
|
||||
private onMessage(event: MessageEvent): void {
|
||||
const message = event.data as
|
||||
| { source?: string; kind?: string; id?: number; ok?: boolean; result?: unknown; error?: { message?: string } | null }
|
||||
| null;
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== "object" ||
|
||||
message.source !== HOST_SOURCE_TAG ||
|
||||
message.kind !== BRIDGE_KIND ||
|
||||
typeof message.id !== "number"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (message.ok) pending.resolve(message.result);
|
||||
else pending.reject(new Error(message.error?.message || "Odoo Documents bridge call failed"));
|
||||
}
|
||||
|
||||
/** One correlation id per call; 60 s default / 300 s for writes. */
|
||||
call<T>(method: string, payload?: Record<string, unknown>, options?: CallOptions): Promise<T> {
|
||||
if (!this.inFrame) {
|
||||
this.standalone = true;
|
||||
return Promise.reject(
|
||||
new Error(`OneCAD odoo bridge: standalone (no Odoo host) — "${method}" unavailable`),
|
||||
);
|
||||
}
|
||||
const timeoutMs = (options && options.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
||||
const id = this.nextId++;
|
||||
const envelope = {
|
||||
source: SOURCE_TAG,
|
||||
kind: BRIDGE_KIND,
|
||||
id,
|
||||
method,
|
||||
payload: payload || {},
|
||||
};
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`OneCAD odoo bridge: "${method}" timed out after ${timeoutMs} ms`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
||||
// The framework shell is our parent window (same origin).
|
||||
window.parent.postMessage(envelope, window.location.origin);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ping every 100 ms until the host answers (mount-order independence). */
|
||||
async waitForHost(timeoutMs: number = HOST_WAIT_MS): Promise<PingResult> {
|
||||
if (!this.inFrame) {
|
||||
this.standalone = true;
|
||||
return { user: "", folder: 0, app: { root: 0 } };
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: Error | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const pong = await this.ping();
|
||||
this.standalone = false;
|
||||
return pong;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`OneCAD odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- raw host calls ---------------- */
|
||||
|
||||
ping(): Promise<PingResult> {
|
||||
return this.call<PingResult>("ping");
|
||||
}
|
||||
|
||||
list(): Promise<ListResult> {
|
||||
return this.call<ListResult>("list");
|
||||
}
|
||||
|
||||
stat(recordId: number): Promise<StatResult> {
|
||||
return this.call<StatResult>("stat", { recordId });
|
||||
}
|
||||
|
||||
read(recordId: number): Promise<ReadResult> {
|
||||
return this.call<ReadResult>("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
write(payload: { recordId?: number; name?: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
|
||||
return this.call<WriteResult>("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
delete(recordId: number): Promise<{ deleted: boolean }> {
|
||||
return this.call<{ deleted: boolean }>("delete", { recordId });
|
||||
}
|
||||
|
||||
usage(): Promise<UsageResult> {
|
||||
return this.call<UsageResult>("usage");
|
||||
}
|
||||
|
||||
folders(): Promise<FoldersResult> {
|
||||
return this.call<FoldersResult>("folders");
|
||||
}
|
||||
|
||||
createFolder(payload: { name: string; parentId?: number }): Promise<{ id: number }> {
|
||||
return this.call<{ id: number }>("createFolder", payload);
|
||||
}
|
||||
|
||||
listFolder(folderId: number): Promise<{ folders: FolderRow[]; files: FileRow[] }> {
|
||||
return this.call<{ folders: FolderRow[]; files: FileRow[] }>("listFolder", { folderId });
|
||||
}
|
||||
|
||||
saveExport(payload: { name: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
|
||||
return this.call<WriteResult>("saveExport", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
/* ---------------- binding cache (localStorage) ---------------- */
|
||||
|
||||
getCurrentBinding(): CurrentBinding {
|
||||
return this.binding;
|
||||
}
|
||||
|
||||
/** document name -> Odoo record id, keyed by app + document name. */
|
||||
bindLookup(name: string): BindEntry | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BIND_KEY_PREFIX + stripOwnedExtension(name));
|
||||
if (!raw) return null;
|
||||
const entry = JSON.parse(raw) as BindEntry;
|
||||
return entry && typeof entry.recordId === "number" ? entry : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bindSet(name: string, recordId: number): void {
|
||||
this.binding = { documentId: recordId, name: stripOwnedExtension(name) };
|
||||
try {
|
||||
const entry: BindEntry = {
|
||||
recordId,
|
||||
name: stripOwnedExtension(name),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(BIND_KEY_PREFIX + entry.name, JSON.stringify(entry));
|
||||
} catch {
|
||||
/* private mode — memory-only binding still works for this session */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- document flows ---------------- */
|
||||
|
||||
/** Ctrl+S style write-back / create. Guard runs BEFORE base64. */
|
||||
async saveDocument(bytes: Uint8Array | ArrayBuffer, name: string, recordId?: number): Promise<WriteResult> {
|
||||
const raw = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
guardCap(raw);
|
||||
const dataB64 = bytesToBase64(raw);
|
||||
const clean = ensureExtension(name, OWNED_EXTENSION);
|
||||
const result = await this.write({ recordId, name: clean, dataB64 });
|
||||
this.bindSet(result.name || clean, result.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Deep-link read; also refreshes the current binding. */
|
||||
async readDocument(recordId: number): Promise<ReadResult> {
|
||||
const result = await this.read(recordId);
|
||||
this.bindSet(result.name, recordId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ?document=<id> boot deep link. Never throws (boot must proceed);
|
||||
* on success stores pendingOpen, fires "onecad:document-opened" and
|
||||
* refreshes the binding so host-ready can carry the metadata.
|
||||
*/
|
||||
async openDeepLink(search?: string): Promise<boolean> {
|
||||
let params: URLSearchParams;
|
||||
try {
|
||||
params = new URLSearchParams(search === undefined ? window.location.search : search);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const rawId = params.get("document");
|
||||
if (!rawId) return false;
|
||||
const id = Number(rawId);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
console.warn("[onecad-odoo] ignoring malformed deep link ?document=" + rawId);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const opened = await this.readDocument(id);
|
||||
this.pendingOpen = { documentId: id, name: opened.name, dataB64: opened.dataB64 };
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<DocumentOpenedDetail>(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[onecad-odoo] deep-link read failed for document " + id + ":", messageOf(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Late-boot consumers fetch the deep-link payload once, then null. */
|
||||
consumePendingOpen(): DocumentOpenedDetail | null {
|
||||
const pending = this.pendingOpen;
|
||||
this.pendingOpen = null;
|
||||
return pending;
|
||||
}
|
||||
|
||||
setExplorerOpener(opener: ExplorerOpener): void {
|
||||
this.opener = opener;
|
||||
}
|
||||
|
||||
private async askExplorer(options: {
|
||||
mode: "save" | "open" | "asset" | "export";
|
||||
defaultName?: string;
|
||||
extensions?: string[];
|
||||
}): Promise<ExplorerChoice | null> {
|
||||
if (!this.isHostAvailable()) {
|
||||
throw new Error("OneCAD odoo bridge: standalone (no Odoo host) — explorer unavailable");
|
||||
}
|
||||
if (!this.opener) {
|
||||
throw new Error("OneCAD odoo bridge: no explorer dialog wired (setExplorerOpener)");
|
||||
}
|
||||
return this.opener(options);
|
||||
}
|
||||
|
||||
private async appSerialize(): Promise<Uint8Array> {
|
||||
const api = solidcraftApi();
|
||||
if (!api || !api.request) {
|
||||
throw new Error(
|
||||
"OneCAD odoo bridge: no document bytes given and window.solidcraft is unavailable",
|
||||
);
|
||||
}
|
||||
const out = await api.request(APP_SERIALIZE_METHOD);
|
||||
const bytes = normalizeBytes(out);
|
||||
if (!bytes) {
|
||||
throw new Error("OneCAD odoo bridge: window.solidcraft did not return document bytes");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async appInfo(): Promise<{ name?: string }> {
|
||||
const api = solidcraftApi();
|
||||
if (!api || !api.request) return {};
|
||||
try {
|
||||
const info = (await api.request(APP_INFO_METHOD)) as { name?: unknown } | null;
|
||||
return info && typeof info.name === "string" ? { name: info.name } : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Save-as flow: bytes -> explorer (tree + name) -> write -> bind. */
|
||||
async saveAs(options?: {
|
||||
bytes?: Uint8Array | ArrayBuffer;
|
||||
dataB64?: string;
|
||||
name?: string;
|
||||
recordId?: number;
|
||||
}): Promise<SaveAsResult | null> {
|
||||
const opts = options || {};
|
||||
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
|
||||
if (!dataB64) {
|
||||
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
|
||||
guardCap(raw);
|
||||
dataB64 = bytesToBase64(raw);
|
||||
}
|
||||
const info = await this.appInfo();
|
||||
const suggested = opts.name || info.name || "untitled";
|
||||
const choice = await this.askExplorer({
|
||||
mode: "save",
|
||||
defaultName: ensureExtension(suggested, OWNED_EXTENSION),
|
||||
});
|
||||
if (!choice || !choice.name) return null; // cancelled
|
||||
const name = ensureExtension(choice.name, OWNED_EXTENSION);
|
||||
const result = await this.write({
|
||||
recordId: opts.recordId,
|
||||
name,
|
||||
dataB64,
|
||||
folderId: choice.folderId,
|
||||
});
|
||||
this.bindSet(result.name || name, result.id);
|
||||
return { id: result.id, name: result.name || name, folderId: choice.folderId || null };
|
||||
}
|
||||
|
||||
/** Open flow: explorer (.solidcraft filter) -> read 300 s. */
|
||||
async openFromDocuments(): Promise<OpenResult | null> {
|
||||
const choice = await this.askExplorer({ mode: "open", extensions: ["solidcraft", "solvecraft"] });
|
||||
if (!choice || typeof choice.recordId !== "number") return null; // cancelled
|
||||
const opened = await this.readDocument(choice.recordId);
|
||||
return { id: choice.recordId, name: opened.name, dataB64: opened.dataB64 };
|
||||
}
|
||||
|
||||
/** Export flow: bytes -> (optional name dialog) -> saveExport -> Exports. */
|
||||
async exportTo(options?: {
|
||||
bytes?: Uint8Array | ArrayBuffer;
|
||||
dataB64?: string;
|
||||
name?: string;
|
||||
}): Promise<SavedResult | null> {
|
||||
const opts = options || {};
|
||||
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
|
||||
if (!dataB64) {
|
||||
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
|
||||
guardCap(raw);
|
||||
dataB64 = bytesToBase64(raw);
|
||||
}
|
||||
let name = (opts.name || "").trim();
|
||||
let folderId: number | undefined;
|
||||
if (!name) {
|
||||
const info = await this.appInfo();
|
||||
const choice = await this.askExplorer({
|
||||
mode: "export",
|
||||
defaultName: (info.name || "export") + ".step",
|
||||
});
|
||||
if (!choice || !choice.name) return null; // cancelled
|
||||
name = choice.name;
|
||||
folderId = choice.folderId;
|
||||
}
|
||||
const result = await this.saveExport({ name, dataB64, folderId });
|
||||
return { id: result.id, name: result.name || name };
|
||||
}
|
||||
|
||||
/** Import flow: file picker (or given bytes) -> write into Assets. */
|
||||
async importAsset(options?: {
|
||||
file?: File;
|
||||
bytes?: Uint8Array | ArrayBuffer;
|
||||
dataB64?: string;
|
||||
name?: string;
|
||||
}): Promise<SavedResult | null> {
|
||||
const opts = options || {};
|
||||
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
|
||||
let name = (opts.name || "").trim();
|
||||
let folderId: number | undefined;
|
||||
if (!dataB64 && !opts.file && !opts.bytes) {
|
||||
const choice = await this.askExplorer({ mode: "asset" });
|
||||
if (!choice || !(choice.file instanceof File)) return null; // cancelled
|
||||
folderId = choice.folderId;
|
||||
const raw = new Uint8Array(await choice.file.arrayBuffer());
|
||||
guardCap(raw);
|
||||
dataB64 = bytesToBase64(raw);
|
||||
name = name || choice.file.name;
|
||||
} else if (!dataB64) {
|
||||
const raw = opts.bytes
|
||||
? opts.bytes instanceof Uint8Array
|
||||
? opts.bytes
|
||||
: new Uint8Array(opts.bytes)
|
||||
: new Uint8Array(await (opts.file as File).arrayBuffer());
|
||||
guardCap(raw);
|
||||
dataB64 = bytesToBase64(raw);
|
||||
name = name || (opts.file ? opts.file.name : "asset");
|
||||
}
|
||||
if (!folderId) {
|
||||
const app = (await this.ping()).app;
|
||||
folderId = app.assets || app.root;
|
||||
}
|
||||
const result = await this.write({ name: name || "asset", dataB64, folderId });
|
||||
return { id: result.id, name: result.name || name || "asset" };
|
||||
}
|
||||
|
||||
/* ---------------- Rust event queue ---------------- */
|
||||
|
||||
private enqueue(entry: QueueEntry): void {
|
||||
this.queue.push(entry);
|
||||
void this.drainQueue();
|
||||
}
|
||||
|
||||
/** Strictly FIFO, one request at a time; every id answered exactly once. */
|
||||
private async drainQueue(): Promise<void> {
|
||||
if (this.draining) return;
|
||||
this.draining = true;
|
||||
while (this.queue.length > 0) {
|
||||
const entry = this.queue.shift() as QueueEntry;
|
||||
try {
|
||||
const result = await this.runRequest(entry.method, entry.payload);
|
||||
this.respond(entry.id, true, result, null);
|
||||
} catch (error) {
|
||||
this.respond(entry.id, false, null, { message: messageOf(error) });
|
||||
}
|
||||
}
|
||||
this.draining = false;
|
||||
}
|
||||
|
||||
private respond(id: number, ok: boolean, result: unknown, error: { message: string } | null): void {
|
||||
const detail: HostResponseDetail = { id, ok, result, error };
|
||||
window.dispatchEvent(new CustomEvent<HostResponseDetail>(EVT_HOST_RESPONSE, { detail }));
|
||||
}
|
||||
|
||||
private async runRequest(method: string, payload: Record<string, unknown>): Promise<unknown> {
|
||||
if (method === "odoo.pickOpen" || method === "odoo.pickProgram") return this.pickForRust(method, payload);
|
||||
if (method === "odoo.pickSave") return this.pickSaveForRust(payload);
|
||||
if (method === "odoo.write") return this.rustWrite(payload);
|
||||
if (method === "odoo.saveExport") return this.rustSaveExport(payload);
|
||||
if (method === "odoo.import") return this.rustImport(payload);
|
||||
if (method === "saveAs") {
|
||||
return this.saveAs({
|
||||
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
|
||||
name: typeof payload.name === "string" ? payload.name : undefined,
|
||||
recordId: typeof payload.recordId === "number" ? payload.recordId : undefined,
|
||||
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
|
||||
});
|
||||
}
|
||||
if (method === "open") return this.openFromDocuments();
|
||||
if (method === "export") {
|
||||
return this.exportTo({
|
||||
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
|
||||
name: typeof payload.name === "string" ? payload.name : undefined,
|
||||
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
|
||||
});
|
||||
}
|
||||
if (method === "importAsset") {
|
||||
return this.importAsset({
|
||||
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
|
||||
name: typeof payload.name === "string" ? payload.name : undefined,
|
||||
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
|
||||
file: payload.file instanceof File ? payload.file : undefined,
|
||||
});
|
||||
}
|
||||
if (HOST_METHODS.has(method)) {
|
||||
const timeoutMs = BULK_METHODS.has(method) ? WRITE_TIMEOUT_MS : undefined;
|
||||
return this.call<unknown>(method, payload, { timeoutMs });
|
||||
}
|
||||
throw new Error("OneCAD odoo bridge: unknown host-request method \"" + method + "\"");
|
||||
}
|
||||
/* ------- services_odoo.rs wire protocol (CustomEvent round trip) ------- */
|
||||
/** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */
|
||||
private async pickForRust(method: string, payload: Record<string, unknown>): Promise<unknown> {
|
||||
const requested = Array.isArray(payload.extensions) && payload.extensions.length
|
||||
? (payload.extensions as string[])
|
||||
: null;
|
||||
const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["solidcraft", "solvecraft"]);
|
||||
const choice = await this.askExplorer({ mode: "open", extensions });
|
||||
if (!choice || typeof choice.recordId !== "number") return null;
|
||||
const opened = await this.readDocument(choice.recordId);
|
||||
return {
|
||||
recordId: choice.recordId,
|
||||
name: opened.name || choice.recordName || "document",
|
||||
bytes: base64ToBytes(opened.dataB64),
|
||||
};
|
||||
}
|
||||
/** odoo.pickSave -> {token, name}; the token binds the later write-back. */
|
||||
private async pickSaveForRust(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const design = payload.design !== false;
|
||||
const suggested = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : "untitled";
|
||||
const choice = await this.askExplorer({
|
||||
mode: "save",
|
||||
defaultName: design ? ensureExtension(suggested, OWNED_EXTENSION) : suggested,
|
||||
});
|
||||
if (!choice || !choice.name) return null;
|
||||
const token = newToken();
|
||||
const name = sanitizeName(choice.name);
|
||||
this.pendingTokens.set(token, { recordId: null, name, folderId: choice.folderId || null });
|
||||
return { token, name };
|
||||
}
|
||||
/** odoo.write: /odoo/doc/<rid>/<name> in place, /odoo/pending/<token>/<name> first save. */
|
||||
private async rustWrite(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const bytes = bytesOf(payload);
|
||||
if (!bytes) throw new Error("odoo.write: no bytes given");
|
||||
guardCap(bytes);
|
||||
const dataB64 = bytesToBase64(bytes);
|
||||
const path = typeof payload.path === "string" ? payload.path : "";
|
||||
if (path.startsWith("/odoo/pending/")) {
|
||||
const rest = path.slice("/odoo/pending/".length);
|
||||
const slash = rest.indexOf("/");
|
||||
const token = slash > 0 ? rest.slice(0, slash) : "";
|
||||
const pathName = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
|
||||
const bind = token ? this.pendingTokens.get(token) : undefined;
|
||||
const name = (bind ? bind.name : "") || pathName;
|
||||
let recordId = bind ? bind.recordId : null;
|
||||
if (!recordId) {
|
||||
const remembered = this.bindLookup(name);
|
||||
recordId = remembered ? remembered.recordId : null;
|
||||
}
|
||||
const result = await this.write({ recordId, name, dataB64, folderId: bind ? bind.folderId : undefined });
|
||||
if (bind) bind.recordId = result.id;
|
||||
else if (token) this.pendingTokens.set(token, { recordId: result.id, name, folderId: null });
|
||||
this.bindSet(result.name || name, result.id);
|
||||
return { id: result.id, name: result.name || name };
|
||||
}
|
||||
if (path.startsWith("/odoo/doc/")) {
|
||||
const rest = path.slice("/odoo/doc/".length);
|
||||
const slash = rest.indexOf("/");
|
||||
const recordId = slash > 0 ? Number(rest.slice(0, slash)) : 0;
|
||||
const name = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
|
||||
if (!Number.isInteger(recordId) || recordId <= 0) {
|
||||
throw new Error("odoo.write: bad record path \"" + path + "\"");
|
||||
}
|
||||
const result = await this.write({ recordId, name, dataB64 });
|
||||
this.bindSet(result.name || name, recordId);
|
||||
return { id: result.id, name: result.name || name };
|
||||
}
|
||||
throw new Error("odoo.write: unsupported path \"" + path + "\"");
|
||||
}
|
||||
/** odoo.saveExport: {name, bytes} -> Documents "Exports" folder. */
|
||||
private async rustSaveExport(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const bytes = bytesOf(payload);
|
||||
if (!bytes) throw new Error("odoo.saveExport: no bytes given");
|
||||
guardCap(bytes);
|
||||
const result = await this.saveExport({ name: sanitizeName(payload.name) || "export", dataB64: bytesToBase64(bytes) });
|
||||
return { id: result.id, name: result.name };
|
||||
}
|
||||
/** odoo.import: mirror a computer import into the Documents "Assets" folder. */
|
||||
private async rustImport(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const bytes = bytesOf(payload);
|
||||
if (!bytes) throw new Error("odoo.import: no bytes given");
|
||||
guardCap(bytes);
|
||||
const app = (await this.ping()).app;
|
||||
const result = await this.write({ name: payload.name || "asset", dataB64: bytesToBase64(bytes), folderId: app.assets || app.root });
|
||||
return { id: result.id, name: result.name };
|
||||
}
|
||||
}
|
||||
|
||||
/** The process-wide singleton (one bridge per app window). */
|
||||
export const bridge = new OneCadBridge();
|
||||
|
||||
/** Family export: the app glue and dialogs always share this instance. */
|
||||
export function exportForHost(): OneCadBridge {
|
||||
return bridge;
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD odoo/explorer.js — the unified Documents explorer dialogs.
|
||||
* ----------------------------------------------------------------------
|
||||
* Vanilla-JS (ES module, zero deps) port of the family gold-standard
|
||||
* explorer (one-photo src/odoo-host/explorer.js — same layout, same
|
||||
* interactions, same last-folder memory), restyled with an injected dark
|
||||
* CAD palette (no CSS imports — one <style> tag) so it reads native over
|
||||
* the egui canvas. Four dialogs, one explorer:
|
||||
*
|
||||
* save — "Save to Documents": folder tree + name input (.solidcraft chip)
|
||||
* open — "Open from Documents": folder tree + files filtered to .solidcraft
|
||||
* asset — "Import asset": file picker -> Assets folder (+ retarget)
|
||||
* export — "Export": name input -> Exports folder
|
||||
*
|
||||
* left — My Drive folder tree (recursive, expandable, usage footer)
|
||||
* right — breadcrumb + folder/file rows (icon, name, size)
|
||||
* footer — Cancel left, primary action right, Esc / backdrop cancels
|
||||
*
|
||||
* Everything runs through the bridge (odoo/bridge.js — compiled from
|
||||
* bridge.ts): listFolder / createFolder / folders / usage. No framework.
|
||||
*
|
||||
* @returns per mode (resolve value, null = cancelled):
|
||||
* save -> {name, folderId}
|
||||
* open -> {recordId, recordName}
|
||||
* asset -> {file, folderId, name}
|
||||
* export -> {name, folderId}
|
||||
*/
|
||||
import { bridge } from "./bridge.js?v=20.0.1.0.6";
|
||||
|
||||
const STYLE_ID = "oca-explorer-style";
|
||||
|
||||
const MODES = {
|
||||
save: {
|
||||
heading: "Save to Documents",
|
||||
description: "Choose a folder in your Odoo drive for this .solidcraft document.",
|
||||
action: "Save",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:save",
|
||||
},
|
||||
open: {
|
||||
heading: "Open from Documents",
|
||||
description: "Browse your Odoo Documents drive and pick a .solidcraft document.",
|
||||
action: "Open",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:open",
|
||||
extensions: new Set(["solidcraft", "solvecraft"]),
|
||||
},
|
||||
asset: {
|
||||
heading: "Import asset",
|
||||
description: "Pick a CAD asset from your computer to copy into the Assets folder.",
|
||||
action: "Import",
|
||||
prefer: "assets",
|
||||
lastKey: "one-cad:documents:last-folder:asset",
|
||||
extensions: new Set([
|
||||
"step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf",
|
||||
"dxf", "svg", "gltf", "glb", "ply", "urdf",
|
||||
]),
|
||||
},
|
||||
export: {
|
||||
heading: "Export",
|
||||
description: "Name the export — it lands in your Documents Exports folder.",
|
||||
action: "Export",
|
||||
prefer: "exports",
|
||||
lastKey: "one-cad:documents:last-folder:export",
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Small SVG icon set (lucide-style, stroke = currentColor) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function svg(paths, size) {
|
||||
return (
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" ' +
|
||||
'style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex:none">' +
|
||||
paths + "</svg>"
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
folder: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
|
||||
lock: svg('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', 11),
|
||||
chevronRight: svg('<path d="m9 18 6-6-6-6"/>', 12),
|
||||
hardDrive: svg('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
|
||||
file: svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>'),
|
||||
cube: svg('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
|
||||
save: svg('<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 1-1 1H7"/>'),
|
||||
folderPlus: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="M9 13h6"/>'),
|
||||
upload: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>'),
|
||||
open: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="m2 21 5.1-5.1a2 2 0 0 1 2.8 0l1.1 1.1a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 1 2.8 0L22 14"/>'),
|
||||
download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>'),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Injected dark CAD styles (the only CSS this module ever adds) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const STYLE_CSS = [
|
||||
":root{--oca-bg:#1b1e24;--oca-panel:#23272f;--oca-panel2:#2a2f39;--oca-hairline:#333945;",
|
||||
"--oca-text:#e8eaed;--oca-dim:#9aa3ad;--oca-accent:#4f8cff;--oca-accent-press:#3f76e0;",
|
||||
"--oca-danger:#e5534b;--oca-ok:#3fb26f}",
|
||||
".oca-explorer-root{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;",
|
||||
"font:13px/1.45 'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--oca-text)}",
|
||||
".oca-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
|
||||
".oca-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
|
||||
"background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:10px;",
|
||||
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
|
||||
".oca-header{padding:14px 18px 10px;border-bottom:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-title{font-size:15px;font-weight:600}",
|
||||
".oca-desc{margin-top:2px;color:var(--oca-dim);font-size:12px}",
|
||||
".oca-desc-error{color:var(--oca-danger)}",
|
||||
".oca-body{flex:1;display:flex;min-height:0}",
|
||||
".oca-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-tree-box{flex:1;overflow:auto;padding:8px 6px}",
|
||||
".oca-tree-label{margin:2px 8px 6px;color:var(--oca-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
|
||||
".oca-tree-error{padding:8px}",
|
||||
".oca-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
|
||||
".oca-tree-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-tree-row.oca-active{background:var(--oca-panel2);color:#fff}",
|
||||
".oca-tree-toggle{width:14px;height:14px;flex:none;color:var(--oca-dim);display:inline-flex;transition:transform .12s}",
|
||||
".oca-tree-toggle.oca-open{transform:rotate(90deg)}",
|
||||
".oca-tree-icon{display:inline-flex;color:var(--oca-accent)}",
|
||||
".oca-tree-name{overflow:hidden;text-overflow:ellipsis}",
|
||||
".oca-usage{padding:8px 14px;border-top:1px solid var(--oca-hairline);color:var(--oca-dim);font-size:11px}",
|
||||
".oca-main{flex:1;display:flex;flex-direction:column;min-width:0}",
|
||||
".oca-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--oca-hairline);overflow:hidden}",
|
||||
".oca-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--oca-dim);",
|
||||
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
|
||||
".oca-crumb:hover:not(:disabled){background:var(--oca-panel2);color:var(--oca-text)}",
|
||||
".oca-crumb:disabled{opacity:.45;cursor:default}",
|
||||
".oca-crumb-current{color:var(--oca-text)}",
|
||||
".oca-crumb-sep{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-files{flex:1;overflow:auto;padding:6px 8px}",
|
||||
".oca-file-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:0;",
|
||||
"color:inherit;font:inherit;padding:7px 9px;border-radius:6px;cursor:pointer}",
|
||||
".oca-file-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-file-row.oca-selected{background:rgba(79,140,255,.18);outline:1px solid var(--oca-accent)}",
|
||||
".oca-file-icon{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-file-icon.oca-icon-owned{color:var(--oca-accent)}",
|
||||
".oca-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-file-size{color:var(--oca-dim);font-size:11px;flex:none}",
|
||||
".oca-empty{color:var(--oca-dim);padding:18px 10px;text-align:center}",
|
||||
".oca-savebar{border-top:1px solid var(--oca-hairline);padding:10px 14px;background:var(--oca-panel)}",
|
||||
".oca-name-row{display:flex;align-items:flex-end;gap:10px}",
|
||||
".oca-name-wrap{flex:1;min-width:0}",
|
||||
".oca-name-label{display:block;color:var(--oca-dim);font-size:11px;margin-bottom:4px}",
|
||||
".oca-name-field{display:flex;align-items:center;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;padding:0 8px}",
|
||||
".oca-name-field:focus-within{border-color:var(--oca-accent)}",
|
||||
".oca-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--oca-text);font:inherit;padding:7px 0}",
|
||||
".oca-suffix{color:var(--oca-dim);font-size:12px;user-select:none}",
|
||||
".oca-create-row{display:flex;gap:8px;margin-top:8px}",
|
||||
".oca-newfolder-input{flex:1;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;",
|
||||
"color:var(--oca-text);font:inherit;padding:6px 9px;outline:0}",
|
||||
".oca-newfolder-input:focus{border-color:var(--oca-accent)}",
|
||||
".oca-picker{display:flex;flex-direction:column;gap:8px}",
|
||||
".oca-pick-btn{display:inline-flex;align-items:center;gap:8px}",
|
||||
".oca-picked{color:var(--oca-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
|
||||
"border-top:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
|
||||
"border:1px solid var(--oca-hairline);background:var(--oca-panel2);color:var(--oca-text);cursor:pointer}",
|
||||
".oca-btn:hover:not(:disabled){border-color:var(--oca-dim)}",
|
||||
".oca-btn:disabled{opacity:.45;cursor:default}",
|
||||
".oca-btn-accent{background:var(--oca-accent);border-color:var(--oca-accent);color:#fff}",
|
||||
".oca-btn-accent:hover:not(:disabled){background:var(--oca-accent-press)}",
|
||||
".oca-btn-ghost{background:none;border-color:transparent;color:var(--oca-dim)}",
|
||||
".oca-btn-ghost:hover:not(:disabled){color:var(--oca-text)}",
|
||||
".oca-btn-icon{display:inline-flex}",
|
||||
].join("\n");
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = STYLE_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function h(tag, attrs) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const key in attrs) {
|
||||
if (!Object.prototype.hasOwnProperty.call(attrs, key)) continue;
|
||||
const value = attrs[key];
|
||||
if (key === "class") el.className = value;
|
||||
else if (key === "html") el.innerHTML = value;
|
||||
else if (key === "text") el.textContent = value;
|
||||
else if (key.slice(0, 2) === "on" && typeof value === "function")
|
||||
el.addEventListener(key.slice(2), value);
|
||||
else if (value !== null && value !== undefined) el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
for (let i = 2; i < arguments.length; i++) {
|
||||
const child = arguments[i];
|
||||
if (child === null || child === undefined) continue;
|
||||
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
size = Number(size) || 0;
|
||||
if (size < 1024) return size + " B";
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KB";
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MB";
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
}
|
||||
|
||||
function extensionOf(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function fileIconFor(file, extensions) {
|
||||
const ext = extensionOf(file.name || "");
|
||||
if (extensions && extensions.has(ext)) {
|
||||
return '<span class="oca-file-icon oca-icon-owned">' + ICONS.cube + "</span>";
|
||||
}
|
||||
return '<span class="oca-file-icon">' + ICONS.file + "</span>";
|
||||
}
|
||||
|
||||
function rememberLastFolder(folderId, key) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(folderId));
|
||||
} catch (e) {
|
||||
/* private mode — memory only */
|
||||
}
|
||||
}
|
||||
|
||||
function restoreLastFolder(key) {
|
||||
try {
|
||||
const id = Number(window.localStorage.getItem(key));
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The explorer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Open one Documents explorer dialog.
|
||||
*
|
||||
* @param {object} opts
|
||||
* mode "save" | "open" | "asset" | "export"
|
||||
* defaultName save/export mode: suggested file name ("part.solidcraft")
|
||||
* extensions open/asset mode: restrict file rows to these extensions
|
||||
* @returns {Promise<{name?, folderId?, recordId?, recordName?, file?} | null>}
|
||||
* null = cancelled (Esc, backdrop, Cancel button).
|
||||
*/
|
||||
export function openDocumentsExplorer(opts) {
|
||||
opts = opts || {};
|
||||
const mode = MODES[opts.mode] ? opts.mode : "save";
|
||||
const config = MODES[mode];
|
||||
const extensions = opts.extensions && opts.extensions.length
|
||||
? new Set(opts.extensions.map((ext) => String(ext).toLowerCase().replace(/^\./, "")))
|
||||
: config.extensions || null;
|
||||
|
||||
/* Standalone dev mode: no host, no dialog — the caller treats null as
|
||||
* "nothing chosen", so the app keeps running untouched. */
|
||||
if (typeof bridge.isHostAvailable === "function" && !bridge.isHostAvailable()) {
|
||||
console.info("[onecad-odoo] explorer: no Odoo host (standalone) — dialog unavailable");
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
ensureStyle();
|
||||
|
||||
/* Save/export name split: stem in the field, extension as the chip. */
|
||||
const wantsName = mode === "save" || mode === "export";
|
||||
const defaultExtension = mode === "save" ? ".solidcraft" : "";
|
||||
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
|
||||
? opts.defaultName.trim()
|
||||
: mode === "save"
|
||||
? "untitled.solidcraft"
|
||||
: "export";
|
||||
let stem = defaultName;
|
||||
let suffix = "";
|
||||
const dotIdx = defaultName.lastIndexOf(".");
|
||||
if (dotIdx > 0) {
|
||||
stem = defaultName.slice(0, dotIdx);
|
||||
suffix = defaultName.slice(dotIdx);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener("keydown", onDocKeyDown, true);
|
||||
if (root && root.parentNode) root.parentNode.removeChild(root);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
let tree = null;
|
||||
let treeError = null;
|
||||
let appTree = {};
|
||||
let appRootId = null;
|
||||
let currentFolderId = null;
|
||||
let folderFolders = [];
|
||||
let folderFiles = [];
|
||||
let loadingFiles = false;
|
||||
let selectedFileId = null;
|
||||
let pickedFile = null;
|
||||
let createBusy = false;
|
||||
let usage = null;
|
||||
const expandedSet = new Set();
|
||||
|
||||
/* ---------------- chrome ---------------- */
|
||||
const backdrop = h("div", { class: "oca-backdrop" });
|
||||
const dialog = h("div", { class: "oca-dialog", role: "dialog", "aria-modal": "true" });
|
||||
const root = h("div", { class: "oca-explorer-root" }, backdrop, dialog);
|
||||
|
||||
const descEl = h("div", { class: "oca-desc" }, config.description);
|
||||
const header = h("div", { class: "oca-header" },
|
||||
h("div", { class: "oca-title" }, config.heading), descEl);
|
||||
|
||||
const treeLabel = h("p", { class: "oca-tree-label" }, "Loading drive…");
|
||||
const treeBox = h("div", { class: "oca-tree-box" }, treeLabel);
|
||||
const usageEl = h("div", { class: "oca-usage", style: "display:none" });
|
||||
const aside = h("div", { class: "oca-aside" }, treeBox, usageEl);
|
||||
|
||||
const crumbs = h("div", { class: "oca-crumbs" });
|
||||
const fileList = h("div", { class: "oca-files" });
|
||||
const main = h("div", { class: "oca-main" }, crumbs, fileList);
|
||||
const body = h("div", { class: "oca-body" }, aside, main);
|
||||
|
||||
/* name row (save + export) */
|
||||
const nameInput = h("input", {
|
||||
class: "oca-name-input",
|
||||
id: "onecad-explorer-name",
|
||||
type: "text",
|
||||
value: stem,
|
||||
placeholder: mode === "save" ? "Untitled" : "export",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const suffixChip = h("span", { class: "oca-suffix" }, suffix);
|
||||
const nameField = h("div", { class: "oca-name-field" }, nameInput, suffixChip);
|
||||
const nameRow = h("div", { class: "oca-name-row" },
|
||||
h("div", { class: "oca-name-wrap" },
|
||||
h("label", { class: "oca-name-label", for: "onecad-explorer-name" }, "File name"),
|
||||
nameField));
|
||||
|
||||
/* new-folder row (save + export; asset keeps it too — retargeting) */
|
||||
const newNameInput = h("input", {
|
||||
class: "oca-newfolder-input",
|
||||
type: "text",
|
||||
placeholder: "Folder name…",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const createBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" }, "Create");
|
||||
const cancelCreateBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
|
||||
const createRow = h("div", { class: "oca-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
|
||||
const newFolderBtn = h("button", { class: "oca-btn", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: ICONS.folderPlus }), "New folder");
|
||||
|
||||
/* asset file picker */
|
||||
const fileInput = h("input", {
|
||||
type: "file",
|
||||
style: "display:none",
|
||||
...(extensions ? { accept: Array.from(extensions).map((ext) => "." + ext).join(",") } : {}),
|
||||
});
|
||||
const pickedLabel = h("div", { class: "oca-picked", text: "No file chosen yet." });
|
||||
const pickBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: ICONS.upload }), "Choose file…");
|
||||
const picker = h("div", { class: "oca-picker" }, pickBtn, pickedLabel, fileInput);
|
||||
|
||||
const savebar = h("div", { class: "oca-savebar" });
|
||||
if (wantsName) savebar.appendChild(nameRow);
|
||||
if (mode === "asset") savebar.appendChild(picker);
|
||||
savebar.appendChild(newFolderBtn);
|
||||
savebar.appendChild(createRow);
|
||||
|
||||
/* footer */
|
||||
const cancelBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
|
||||
const actionIcon = mode === "save" ? ICONS.save : mode === "open" ? ICONS.open : mode === "asset" ? ICONS.upload : ICONS.download;
|
||||
const actionBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: actionIcon }), config.action);
|
||||
const footer = h("div", { class: "oca-footer" }, cancelBtn, actionBtn);
|
||||
|
||||
dialog.appendChild(header);
|
||||
dialog.appendChild(body);
|
||||
if (mode !== "open") dialog.appendChild(savebar);
|
||||
dialog.appendChild(footer);
|
||||
|
||||
const redraw = () => {
|
||||
if (mode === "save") {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
} else if (mode === "open") {
|
||||
actionBtn.disabled = selectedFileId === null;
|
||||
} else if (mode === "asset") {
|
||||
actionBtn.disabled = !pickedFile || currentFolderId === null;
|
||||
} else {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------- tree rendering ---------------- */
|
||||
function renderTree() {
|
||||
treeBox.innerHTML = "";
|
||||
if (tree === null && !treeError) {
|
||||
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "Loading drive…"));
|
||||
return;
|
||||
}
|
||||
if (treeError) {
|
||||
const retry = h("button", { class: "oca-btn", type: "button" }, "Retry");
|
||||
retry.addEventListener("click", () => boot());
|
||||
treeBox.appendChild(h("div", { class: "oca-tree-error" },
|
||||
h("p", { class: "oca-tree-label" }, treeError), retry));
|
||||
return;
|
||||
}
|
||||
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "My Drive"));
|
||||
const childrenOf = new Map();
|
||||
for (const folder of tree) {
|
||||
const key = folder.parentId === null ? "root" : String(folder.parentId);
|
||||
if (!childrenOf.has(key)) childrenOf.set(key, []);
|
||||
childrenOf.get(key).push(folder);
|
||||
}
|
||||
const renderNodes = (parentId, depth) => {
|
||||
const key = parentId === null ? "root" : String(parentId);
|
||||
const children = childrenOf.get(key) || [];
|
||||
for (const folder of children) {
|
||||
const hasChildren = (childrenOf.get(String(folder.id)) || []).length > 0;
|
||||
const isOpen = expandedSet.has(folder.id);
|
||||
const row = h("div", {
|
||||
class: "oca-tree-row" + (folder.id === currentFolderId ? " oca-active" : ""),
|
||||
style: "padding-left:" + (8 + depth * 14) + "px",
|
||||
});
|
||||
const toggle = h("span", { class: "oca-tree-toggle" });
|
||||
if (hasChildren) {
|
||||
toggle.innerHTML = ICONS.chevronRight;
|
||||
toggle.classList.add("oca-has-children");
|
||||
if (isOpen) toggle.classList.add("oca-open");
|
||||
}
|
||||
toggle.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (expandedSet.has(folder.id)) expandedSet.delete(folder.id);
|
||||
else expandedSet.add(folder.id);
|
||||
renderTree();
|
||||
});
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(h("span", { class: "oca-tree-icon", html: ICONS.folder }));
|
||||
row.appendChild(h("span", { class: "oca-tree-name" }, folder.name));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
treeBox.appendChild(row);
|
||||
if (hasChildren && isOpen) renderNodes(folder.id, depth + 1);
|
||||
}
|
||||
};
|
||||
renderNodes(null, 0);
|
||||
usageEl.style.display = usage ? "" : "none";
|
||||
if (usage) {
|
||||
usageEl.textContent = usage.documentCount + " file" + (usage.documentCount === 1 ? "" : "s") +
|
||||
" · " + formatSize(usage.bytesUsed) + " used";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- breadcrumb ---------------- */
|
||||
function renderCrumbs() {
|
||||
crumbs.innerHTML = "";
|
||||
const driveBtn = h("button", { class: "oca-crumb oca-crumb-root", type: "button" });
|
||||
driveBtn.appendChild(h("span", { class: "oca-btn-icon", html: ICONS.hardDrive }));
|
||||
driveBtn.appendChild(document.createTextNode("My Drive"));
|
||||
driveBtn.disabled = appRootId === null;
|
||||
driveBtn.addEventListener("click", () => appRootId !== null && enterFolder(appRootId));
|
||||
crumbs.appendChild(driveBtn);
|
||||
const path = [];
|
||||
let cursor = currentFolderId;
|
||||
const byId = new Map(tree ? tree.map((row) => [row.id, row]) : []);
|
||||
while (cursor !== null && byId.has(cursor)) {
|
||||
path.unshift(byId.get(cursor));
|
||||
cursor = byId.get(cursor).parentId;
|
||||
}
|
||||
for (const folder of path) {
|
||||
crumbs.appendChild(h("span", { class: "oca-crumb-sep", html: ICONS.chevronRight }));
|
||||
const crumb = h("button", {
|
||||
class: "oca-crumb" + (folder.id === currentFolderId ? " oca-crumb-current" : ""),
|
||||
type: "button",
|
||||
}, folder.name);
|
||||
crumb.addEventListener("click", () => enterFolder(folder.id));
|
||||
crumbs.appendChild(crumb);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- files ---------------- */
|
||||
function renderFiles() {
|
||||
fileList.innerHTML = "";
|
||||
if (loadingFiles) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
/* Desktop file-manager behavior: subfolders first (click = enter),
|
||||
* then files — open mode filtered to the requested extensions. */
|
||||
for (const folder of folderFolders) {
|
||||
const row = h("button", { class: "oca-file-row", type: "button" });
|
||||
row.insertAdjacentHTML("beforeend", '<span class="oca-file-icon oca-icon-owned">' + ICONS.folder + "</span>");
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, folder.name));
|
||||
if (folder.system) {
|
||||
row.insertAdjacentHTML("beforeend",
|
||||
'<span class="oca-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
|
||||
}
|
||||
row.appendChild(h("span", { class: "oca-file-size" }, "Folder"));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
const visibleFiles = extensions
|
||||
? folderFiles.filter((file) => extensions.has(extensionOf(file.name || "")))
|
||||
: folderFiles;
|
||||
if (folderFolders.length === 0 && visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" },
|
||||
mode === "save" || mode === "export" ? "This folder is empty." : "No compatible files in this folder."));
|
||||
return;
|
||||
}
|
||||
if (visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" }, "No compatible files here yet."));
|
||||
return;
|
||||
}
|
||||
for (const file of visibleFiles) {
|
||||
const row = h("button", {
|
||||
class: "oca-file-row" + (selectedFileId === file.id ? " oca-selected" : ""),
|
||||
type: "button",
|
||||
});
|
||||
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, file.name));
|
||||
row.appendChild(h("span", { class: "oca-file-size" }, formatSize(file.size || 0)));
|
||||
row.addEventListener("click", () => {
|
||||
selectedFileId = file.id;
|
||||
renderFiles();
|
||||
redraw();
|
||||
});
|
||||
row.addEventListener("dblclick", () => {
|
||||
if (mode === "open") settle({ recordId: file.id, recordName: file.name });
|
||||
});
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- actions ---------------- */
|
||||
function enterFolder(folderId) {
|
||||
currentFolderId = folderId;
|
||||
selectedFileId = null;
|
||||
rememberLastFolder(folderId, config.lastKey);
|
||||
loadFiles(folderId);
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
redraw();
|
||||
}
|
||||
|
||||
function loadFiles(folderId) {
|
||||
loadingFiles = true;
|
||||
renderFiles();
|
||||
bridge
|
||||
.listFolder(folderId)
|
||||
.then((listing) => {
|
||||
folderFiles = listing.files || [];
|
||||
folderFolders = listing.folders || [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
})
|
||||
.catch((error) => {
|
||||
folderFiles = [];
|
||||
folderFolders = [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
flashError("Could not list this folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
});
|
||||
}
|
||||
|
||||
function onCreateFolder() {
|
||||
const trimmed = newNameInput.value.trim();
|
||||
if (!trimmed || currentFolderId === null || createBusy) return;
|
||||
createBusy = true;
|
||||
createBtn.disabled = true;
|
||||
bridge
|
||||
.createFolder({ name: trimmed, parentId: currentFolderId })
|
||||
.then((created) => {
|
||||
tree = (tree || []).concat([{ id: created.id, name: trimmed, parentId: currentFolderId }]);
|
||||
expandedSet.add(currentFolderId);
|
||||
expandedSet.add(created.id);
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
enterFolder(created.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
flashError("Could not create folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
})
|
||||
.finally(() => {
|
||||
createBusy = false;
|
||||
createBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
if (mode === "save") {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
} else if (mode === "open") {
|
||||
const file = folderFiles.find((candidate) => candidate.id === selectedFileId);
|
||||
if (file) settle({ recordId: file.id, recordName: file.name });
|
||||
} else if (mode === "asset") {
|
||||
if (pickedFile && currentFolderId !== null) {
|
||||
settle({ file: pickedFile, folderId: currentFolderId, name: pickedFile.name });
|
||||
}
|
||||
} else {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
pickBtn.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => {
|
||||
pickedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
|
||||
pickedLabel.textContent = pickedFile
|
||||
? pickedFile.name + " · " + formatSize(pickedFile.size)
|
||||
: "No file chosen yet.";
|
||||
redraw();
|
||||
});
|
||||
|
||||
newFolderBtn.addEventListener("click", () => {
|
||||
newFolderBtn.style.display = "none";
|
||||
createRow.style.display = "";
|
||||
newNameInput.focus();
|
||||
});
|
||||
cancelCreateBtn.addEventListener("click", () => {
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
});
|
||||
createBtn.addEventListener("click", onCreateFolder);
|
||||
newNameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") onCreateFolder();
|
||||
});
|
||||
nameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") confirmAction();
|
||||
});
|
||||
nameInput.addEventListener("input", redraw);
|
||||
cancelBtn.addEventListener("click", () => settle(null));
|
||||
actionBtn.addEventListener("click", confirmAction);
|
||||
backdrop.addEventListener("mousedown", () => settle(null));
|
||||
dialog.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
||||
|
||||
function onDocKeyDown(ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation();
|
||||
settle(null);
|
||||
}
|
||||
document.addEventListener("keydown", onDocKeyDown, true);
|
||||
|
||||
let errorTimer = null;
|
||||
function flashError(message) {
|
||||
descEl.textContent = message;
|
||||
descEl.classList.add("oca-desc-error");
|
||||
if (errorTimer) clearTimeout(errorTimer);
|
||||
errorTimer = setTimeout(() => {
|
||||
descEl.textContent = config.description;
|
||||
descEl.classList.remove("oca-desc-error");
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
function boot() {
|
||||
tree = null;
|
||||
treeError = null;
|
||||
renderTree();
|
||||
void (async () => {
|
||||
try {
|
||||
const folders = await bridge.folders();
|
||||
tree = folders.tree || [];
|
||||
appTree = folders.app || {};
|
||||
appRootId = typeof appTree.root === "number" ? appTree.root : null;
|
||||
const preferred = typeof appTree[config.prefer] === "number"
|
||||
? appTree[config.prefer]
|
||||
: appTree.root;
|
||||
const restored = restoreLastFolder(config.lastKey);
|
||||
const known = new Set(tree.map((row) => row.id));
|
||||
const target = restored && known.has(restored) ? restored : preferred;
|
||||
currentFolderId = target;
|
||||
const byId = new Map(tree.map((row) => [row.id, row]));
|
||||
let cursor = target;
|
||||
while (cursor !== null) {
|
||||
expandedSet.add(cursor);
|
||||
cursor = byId.has(cursor) ? byId.get(cursor).parentId : null;
|
||||
}
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
loadFiles(target);
|
||||
bridge
|
||||
.usage()
|
||||
.then((info) => {
|
||||
usage = info;
|
||||
renderTree();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
} catch (error) {
|
||||
treeError = error && error.message ? error.message : "Could not load the drive";
|
||||
renderTree();
|
||||
}
|
||||
redraw();
|
||||
if (wantsName) {
|
||||
window.setTimeout(() => {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}, 50);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
document.body.appendChild(root);
|
||||
boot();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD odoo/index.ts — the boot glue (bridge <-> explorer <-> Rust app).
|
||||
* ----------------------------------------------------------------------
|
||||
* Entry module loaded by index.html ONLY inside the wasm_app_framework
|
||||
* iframe (standalone runs never import it — see the import guard there).
|
||||
* Responsibilities:
|
||||
*
|
||||
* 1. instantiate the bridge singleton and wire the explorer dialogs
|
||||
* ("saveAs" / "open" / "export" / "importAsset" host-request methods);
|
||||
* 2. BOOT GATE: waitForHost() (ping every 100 ms, up to 20 s) — then, if
|
||||
* location.search carries ?document=<id>, read that record BEFORE the
|
||||
* app is signaled ready (deep-link open, "onecad:document-opened");
|
||||
* 3. signal "onecad:host-ready" {host, documentId, name} exactly once.
|
||||
* The same state stays readable synchronously at window.onecadHost
|
||||
* (late-booting Rust) together with consumePendingOpen(), which hands
|
||||
* the deep-link payload {documentId, name, dataB64} over once.
|
||||
*
|
||||
* Standalone dev mode: no host frame — no waiting, no deep link, the ready
|
||||
* signal still fires with host:false so the app boots untouched.
|
||||
*
|
||||
* Build note: scripts/build_onecad_addon.py compiles this file (erasable-
|
||||
* syntax-only TS) to odoo/index.js next to bridge.js; explorer.js is a
|
||||
* plain-JS module copied verbatim. Exports nothing.
|
||||
*/
|
||||
import { exportForHost, EVT_HOST_READY } from "./bridge.js?v=20.0.1.0.6";
|
||||
// @ts-ignore — explorer.js is a sibling plain-JS ES module (copied verbatim
|
||||
// into dist; it imports the compiled bridge.js at runtime).
|
||||
import { openDocumentsExplorer } from "./explorer.js?v=20.0.1.0.6";
|
||||
import type { DocumentOpenedDetail, ExplorerOpener, HostReadyDetail } from "./bridge.js";
|
||||
|
||||
const bridge = exportForHost();
|
||||
bridge.setExplorerOpener(openDocumentsExplorer as ExplorerOpener);
|
||||
|
||||
/* Late-boot contract surface (Rust registers listeners after this module
|
||||
* has run, so it polls this synchronously-readable state on init). */
|
||||
interface OneCadHostWindow {
|
||||
onecadHostReady?: boolean;
|
||||
onecadHost?: {
|
||||
ready: boolean;
|
||||
host: boolean;
|
||||
documentId: number | null;
|
||||
name: string | null;
|
||||
consumePendingOpen: () => DocumentOpenedDetail | null;
|
||||
};
|
||||
solidcraft?: { request?: (method: string, params?: unknown) => Promise<unknown> };
|
||||
onecadOdoo?: {
|
||||
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const hostWindow = window as unknown as OneCadHostWindow;
|
||||
hostWindow.onecadHost = {
|
||||
ready: false,
|
||||
host: false,
|
||||
documentId: null,
|
||||
name: null,
|
||||
consumePendingOpen: () => bridge.consumePendingOpen(),
|
||||
};
|
||||
|
||||
/* services_odoo.rs polls window.onecadHostReady (a plain boolean) before
|
||||
* dispatching odoo.pickOpen / odoo.pickSave / odoo.write to this glue.
|
||||
* Set it as soon as the boot attempt finishes (host answered or not): every
|
||||
* Rust picker parks its slot behind this flag, so an unset flag wedges the
|
||||
* app's file dialogs for REQUEST_TIMEOUT (120 s) with no dialog on screen. */
|
||||
function markHostReady(): void {
|
||||
try {
|
||||
hostWindow.onecadHostReady = true;
|
||||
} catch (error) {
|
||||
console.warn("[onecad-odoo] could not set onecadHostReady:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Deep-link opener: stage the record bytes into the engine VFS via the
|
||||
* window.onecadOdoo.bootFile seam (installed by services_odoo.rs), then ask
|
||||
* the app to open the staged path through its control API. The wasm app API
|
||||
* appears asynchronously after boot, so poll briefly for it. */
|
||||
async function openStagedDeepLink(entry: DocumentOpenedDetail | null): Promise<void> {
|
||||
if (!entry || typeof entry.dataB64 !== "string") return;
|
||||
const deadline = Date.now() + 60000;
|
||||
while (Date.now() < deadline) {
|
||||
const api = hostWindow.solidcraft;
|
||||
const seam = hostWindow.onecadOdoo;
|
||||
if (api && typeof api.request === "function" && seam && typeof seam.bootFile === "function") {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(entry.dataB64), (char) => char.charCodeAt(0));
|
||||
const path = seam.bootFile(entry.documentId, entry.name, bytes);
|
||||
if (typeof path === "string" && path) {
|
||||
await api.request("ui.file", { path });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[onecad-odoo] deep-link open failed:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
console.warn("[onecad-odoo] deep-link open skipped: the app API never appeared");
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
let host = false;
|
||||
if (bridge.inFrame) {
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
host = true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[onecad-odoo] host did not answer in time — continuing without Odoo Documents:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.info("[onecad-odoo] standalone (no host frame) — Odoo Documents disabled");
|
||||
}
|
||||
|
||||
/* Unblock the Rust pickers first; the deep-link read continues in the
|
||||
* background and must never keep the file dialogs hostage. */
|
||||
markHostReady();
|
||||
let deepLink = false;
|
||||
if (host) {
|
||||
try {
|
||||
deepLink = await bridge.openDeepLink();
|
||||
} catch (error) {
|
||||
console.warn("[onecad-odoo] deep-link read failed:", error);
|
||||
}
|
||||
}
|
||||
const binding = bridge.getCurrentBinding();
|
||||
|
||||
const detail: HostReadyDetail = {
|
||||
host,
|
||||
documentId: binding.documentId,
|
||||
name: binding.name,
|
||||
};
|
||||
const state = hostWindow.onecadHost;
|
||||
if (state) {
|
||||
state.ready = true;
|
||||
state.host = host;
|
||||
state.documentId = detail.documentId;
|
||||
state.name = detail.name;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<HostReadyDetail>(EVT_HOST_READY, { detail }));
|
||||
if (deepLink) {
|
||||
void openStagedDeepLink(bridge.consumePendingOpen());
|
||||
}
|
||||
})();
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||
* OneBSpace (OneCAD): odoo/onecad-odoo.js — the app-side host glue.
|
||||
* ---------------------------------------------------------------------
|
||||
* Entry module loaded by index.html ONLY inside a frame (standalone runs
|
||||
* never import it). It is the middle layer between the Rust app
|
||||
* (apps/solidcraft-web/src/services_odoo.rs, which installs
|
||||
* `window.onecadOdoo` — the resolve/bootFile glue) and the wasm_app_
|
||||
* framework Documents bridge (odoo/bridge.ts compiled to bridge.js):
|
||||
*
|
||||
* request(id, kind, payloadJson) Rust picker → explorer → resolve
|
||||
* kind "open" → Documents explorer → bridge.read → Rust inserts
|
||||
* bytes at /odoo/doc/<recordId>/<name>
|
||||
* kind "save" → Documents explorer (Save) → token → Rust returns
|
||||
* /odoo/pending/<token>/<name>
|
||||
* kind "program" → asset explorer (Assets folder) → like "open"
|
||||
* write(path, bytes) VFS drain → bridge write / saveExport
|
||||
* /odoo/doc/<recordId>/<name> → bridge.write({recordId}) in place
|
||||
* /odoo/pending/<token>/<name> → token meta (localStorage) → first
|
||||
* write creates the record, later
|
||||
* writes hit the same record (Ctrl+S)
|
||||
* /downloads/<name> → bridge.saveExport into "Exports"
|
||||
* import(name, bytes) computer-imported CAD assets mirrored
|
||||
* into the "Assets" folder (sig-deduped)
|
||||
*
|
||||
* Path schemes on the emulated filesystem (the one-photo conventions):
|
||||
* /odoo/doc/<recordId>/<name> an existing Documents record
|
||||
* /odoo/pending/<token>/<name> a chosen destination not yet written
|
||||
* /downloads/<name> an export → Documents "Exports"
|
||||
*
|
||||
* Deep-link boot gate: the shell's iframe URL carries ?document=<id> when
|
||||
* the app was opened by clicking an owned file in Documents. The record is
|
||||
* read, handed to Rust (bootFile inserts it into the engine's VFS at
|
||||
* /odoo/doc/...) and opened like any opened file (doc.open / inserts).
|
||||
*/
|
||||
import {
|
||||
bridge,
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
guardCap,
|
||||
extensionOf,
|
||||
sanitizeStem,
|
||||
withExtension,
|
||||
} from "./bridge.js";
|
||||
import { openDocumentsExplorer } from "./explorer.js";
|
||||
|
||||
/* localStorage stores (the binding cache; Documents stays the truth) */
|
||||
const PENDING_KEY = "one-cad:odoo:pending-tokens";
|
||||
const IMPORT_SIGS_KEY = "one-cad:odoo:import-sigs";
|
||||
const SAVE_FOLDER_KEY = "one-cad:documents:last-folder:save";
|
||||
const EXPORT_FOLDER_KEY = "one-cad:documents:last-folder:export";
|
||||
const OPEN_FOLDER_KEY = "one-cad:documents:last-folder:open";
|
||||
|
||||
/* Commands the app understands on an emulated path (io crate naming). */
|
||||
const DESIGN_EXTENSIONS = new Set(["solidcraft", "solvecraft", "json"]);
|
||||
const STEP_EXTENSIONS = new Set(["step", "stp", "iges", "igs", "brep"]);
|
||||
const MESH_EXTENSIONS = new Set(["stl", "obj", "3mf", "gltf", "glb", "ply"]);
|
||||
const ASSET_EXTENSIONS = new Set([
|
||||
...STEP_EXTENSIONS,
|
||||
...MESH_EXTENSIONS,
|
||||
"dxf", "svg", "urdf",
|
||||
]);
|
||||
|
||||
function embedded() {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.parent || window.parent === window) return false;
|
||||
// Same-origin shell under the Odoo web client (family env.ts rule).
|
||||
const path = window.parent.location.pathname;
|
||||
return path.startsWith("/odoo") || path.startsWith("/web") || /[\?&]odoo-host=1/.test(window.location.search);
|
||||
} catch (e) {
|
||||
// Cross-origin parent cannot happen in the same-origin shell; treat any
|
||||
// throw (parent probe blocked) as framed.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* localStorage stores */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function loadMap(key) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveMap(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
/* private mode / quota — memory only for this session */
|
||||
}
|
||||
}
|
||||
|
||||
let tokens = loadMap(PENDING_KEY);
|
||||
function saveTokens() {
|
||||
saveMap(PENDING_KEY, tokens);
|
||||
}
|
||||
|
||||
function newToken() {
|
||||
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/** Pending-save tokens survive reloads so Ctrl+S keeps hitting the same
|
||||
* record even after the token map left memory (one-photo convention). */
|
||||
function tokenOf(path) {
|
||||
const rest = path.slice("/odoo/pending/".length);
|
||||
const slash = rest.indexOf("/");
|
||||
return slash === -1 ? rest : rest.slice(0, slash);
|
||||
}
|
||||
|
||||
function baseName(path) {
|
||||
const withoutSlash = path.replace(/\/+$/, "");
|
||||
const slash = withoutSlash.lastIndexOf("/");
|
||||
return slash === -1 ? withoutSlash : withoutSlash.slice(slash + 1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Rust glue plumbing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** The Rust side installs window.onecadOdoo (services_odoo.rs). Requests
|
||||
* arriving before it are queued — boot-order races are impossible. */
|
||||
function rustGlue() {
|
||||
return window.onecadOdoo || null;
|
||||
}
|
||||
|
||||
const requestQueue = [];
|
||||
|
||||
function dispatchRequest(id, kind, payloadJson) {
|
||||
if (!rustGlue()) {
|
||||
// The Rust glue is not installed yet (wasm still booting): queue it and
|
||||
// retry — boot-order races are impossible.
|
||||
requestQueue.push([id, kind, payloadJson]);
|
||||
window.setTimeout(pumpQueue, 250);
|
||||
return;
|
||||
}
|
||||
void handleRequest(id, kind, payloadJson);
|
||||
}
|
||||
|
||||
function pumpQueue() {
|
||||
if (!rustGlue()) return;
|
||||
while (requestQueue.length) {
|
||||
const [id, kind, payloadJson] = requestQueue.shift();
|
||||
void handleRequest(id, kind, payloadJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(id, kind, payloadJson) {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = payloadJson ? JSON.parse(payloadJson) : {};
|
||||
} catch (e) {
|
||||
payload = {};
|
||||
}
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
if (kind === "save") {
|
||||
const design = !!payload.design;
|
||||
const picked = await openDocumentsExplorer({
|
||||
mode: "save",
|
||||
defaultName: typeof payload.name === "string" && payload.name ? payload.name : "part.solidcraft",
|
||||
preferAppKey: design ? "projects" : "exports",
|
||||
lastFolderKey: design ? SAVE_FOLDER_KEY : EXPORT_FOLDER_KEY,
|
||||
});
|
||||
if (!picked || typeof picked.folderId !== "number") {
|
||||
rustGlue().resolve(id, null); // cancelled — not an error
|
||||
return;
|
||||
}
|
||||
const extension = extensionOf(payload.name || "");
|
||||
const name = withExtension(picked.name, extension);
|
||||
const token = newToken();
|
||||
tokens[token] = { folderId: picked.folderId, name };
|
||||
saveTokens();
|
||||
rustGlue().resolve(id, { token, name });
|
||||
return;
|
||||
}
|
||||
// "open" and "program": pick a record, read it, hand the bytes over.
|
||||
const preferAppKey = kind === "program" ? "assets" : "projects";
|
||||
const picked = await openDocumentsExplorer({
|
||||
mode: "open",
|
||||
extensions: Array.isArray(payload.extensions) ? payload.extensions : [],
|
||||
preferAppKey,
|
||||
lastFolderKey: OPEN_FOLDER_KEY,
|
||||
});
|
||||
if (!picked || typeof picked.recordId !== "number") {
|
||||
rustGlue().resolve(id, null);
|
||||
return;
|
||||
}
|
||||
const record = await bridge.read(picked.recordId);
|
||||
const bytes = base64ToBytes(record.dataB64);
|
||||
guardCap(bytes.length, record.name);
|
||||
// Foreign assets (outside the app tree) are copied into "Assets" so
|
||||
// imports always land there, like the family storage rules require.
|
||||
void mirrorToAssets(picked.recordId, record.name, bytes);
|
||||
rustGlue().resolve(id, { recordId: picked.recordId, name: record.name, bytes });
|
||||
} catch (error) {
|
||||
console.error("[one-cad odoo] request failed:", error);
|
||||
try {
|
||||
if (rustGlue()) rustGlue().resolve(id, null);
|
||||
} catch (e) {
|
||||
/* the Rust future times out on its own */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VFS writes → Documents (saves + exports) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function handleWrite(path, bytes) {
|
||||
await hostReady;
|
||||
const dataB64 = bytesToBase64(bytes);
|
||||
if (path.indexOf("/odoo/doc/") === 0) {
|
||||
// /odoo/doc/<recordId>/<name> — in-place write-back (Ctrl+S).
|
||||
const rest = path.slice("/odoo/doc/".length);
|
||||
const recordId = Number(rest.split("/")[0]);
|
||||
if (!Number.isInteger(recordId) || recordId <= 0) throw new Error("bad document path: " + path);
|
||||
await bridge.write({ recordId, dataB64 });
|
||||
return;
|
||||
}
|
||||
if (path.indexOf("/odoo/pending/") === 0) {
|
||||
// /odoo/pending/<token>/<name> — first write creates the record, then
|
||||
// the token binds to it forever (the document_id → recordId session
|
||||
// map, kept in localStorage so reloads keep writing in place).
|
||||
const token = tokenOf(path);
|
||||
const meta = tokens[token];
|
||||
if (!meta) throw new Error("unknown save destination (stale token): " + token);
|
||||
const bound = Number.isInteger(meta.recordId) && meta.recordId > 0;
|
||||
const written = await bridge.write({
|
||||
recordId: bound ? meta.recordId : undefined,
|
||||
name: meta.name,
|
||||
folderId: bound ? undefined : meta.folderId,
|
||||
dataB64,
|
||||
extension: bound ? undefined : extensionOf(meta.name),
|
||||
});
|
||||
if (!bound) {
|
||||
meta.recordId = written.id;
|
||||
saveTokens();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (path.indexOf("/downloads/") === 0) {
|
||||
// Exports: no local device writes in the Odoo shell — every download
|
||||
// path is rerouted into the app's "Exports" folder.
|
||||
await bridge.saveExport({ name: baseName(path), dataB64 });
|
||||
return;
|
||||
}
|
||||
console.warn("[one-cad odoo] ignoring VFS write outside the Odoo schemes:", path);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Asset imports (computer → "Assets" folder) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let appTreeCache = null;
|
||||
|
||||
async function appAssetsFolder() {
|
||||
if (!appTreeCache) {
|
||||
const ping = await bridge.waitForHost();
|
||||
appTreeCache = (ping && ping.app) || {};
|
||||
}
|
||||
return typeof appTreeCache.assets === "number" ? appTreeCache.assets : appTreeCache.root;
|
||||
}
|
||||
|
||||
/** Copy a foreign record (picked from outside the app tree) into Assets. */
|
||||
async function mirrorToAssets(recordId, name, bytes) {
|
||||
try {
|
||||
if (typeof recordId !== "number" || DESIGN_EXTENSIONS.has(extensionOf(name))) return;
|
||||
const documents = await bridge.list();
|
||||
if ((documents.documents || []).some((doc) => Number(doc.id) === Number(recordId))) return; // already ours
|
||||
await writeAsset(name, bytes);
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] asset mirror skipped:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAsset(name, bytes) {
|
||||
guardCap(bytes.length, name);
|
||||
const folderId = await appAssetsFolder();
|
||||
const ext = extensionOf(name) || "bin";
|
||||
await bridge.write({
|
||||
name: sanitizeStem(name),
|
||||
dataB64: bytesToBase64(bytes),
|
||||
folderId,
|
||||
extension: ext,
|
||||
});
|
||||
}
|
||||
|
||||
/** Files brought in from the computer (files.rs incoming) land in Assets. */
|
||||
async function handleImport(name, bytes) {
|
||||
await hostReady;
|
||||
const sigs = loadMap(IMPORT_SIGS_KEY);
|
||||
const sig = name + ":" + bytes.length;
|
||||
if (sigs[sig]) return;
|
||||
sigs[sig] = 1;
|
||||
saveMap(IMPORT_SIGS_KEY, sigs);
|
||||
try {
|
||||
if (!ASSET_EXTENSIONS.has(extensionOf(name))) return;
|
||||
await writeAsset(name, bytes);
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] import into Assets failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Boot: handshake, deep link (?document=<id>), host-ready promise */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let hostReadyResolve;
|
||||
const hostReady = new Promise((resolve) => {
|
||||
hostReadyResolve = resolve;
|
||||
});
|
||||
|
||||
/** Open command for an emulated path, mirroring the app's own Open logic:
|
||||
* designs open, CAD formats are inserted, everything else just waits in
|
||||
* the VFS (the bytes are at /odoo/doc/… and stay retrievable). */
|
||||
function openCommandFor(name) {
|
||||
const ext = extensionOf(name);
|
||||
if (DESIGN_EXTENSIONS.has(ext)) return { command: "doc.open" };
|
||||
if (STEP_EXTENSIONS.has(ext)) return { command: "file.insert_step" };
|
||||
if (MESH_EXTENSIONS.has(ext)) return { command: "file.insert_mesh" };
|
||||
if (ext === "dxf" || ext === "svg") return { command: "sketch.insert_" + ext };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!embedded()) return;
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] host handshake failed:", error);
|
||||
hostReadyResolve(false); // fail writes loudly instead of hanging
|
||||
return;
|
||||
}
|
||||
hostReadyResolve(true);
|
||||
pumpQueue();
|
||||
|
||||
const documentId = bridge.bootDocumentId();
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const record = await bridge.read(documentId);
|
||||
const bytes = base64ToBytes(record.dataB64);
|
||||
guardCap(bytes.length, record.name);
|
||||
// bootFile inserts the bytes into the engine VFS and returns the path.
|
||||
const path = rustGlue().bootFile(documentId, record.name, bytes);
|
||||
const open = openCommandFor(record.name);
|
||||
if (open && window.solidcraft && typeof window.solidcraft.execute === "function") {
|
||||
await window.solidcraft.execute(open.command, { path });
|
||||
} else if (!open) {
|
||||
console.info("[one-cad odoo] deep-linked file staged at", path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[one-cad odoo] deep-link boot failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public surface (called from services_odoo.rs) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
window.onecadOdooJs = {
|
||||
/** True once this module decided the app runs in the Odoo shell. */
|
||||
embedded: embedded(),
|
||||
/** Rust picker request: kind "open" | "save" | "program". */
|
||||
request(id, kind, payloadJson) {
|
||||
dispatchRequest(Number(id), String(kind || ""), payloadJson ? String(payloadJson) : "{}");
|
||||
},
|
||||
/** VFS write drained by services_odoo.rs (fire-and-forget save). */
|
||||
write(path, bytes) {
|
||||
handleWrite(String(path || ""), new Uint8Array(bytes)).catch((error) => {
|
||||
console.error("[one-cad odoo] save to Documents failed:", path, error);
|
||||
});
|
||||
},
|
||||
/** A CAD asset imported from the computer (mirrored into Assets). */
|
||||
import(name, bytes) {
|
||||
handleImport(String(name || ""), new Uint8Array(bytes)).catch((error) => {
|
||||
console.warn("[one-cad odoo] import failed:", name, error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (window.onecadOdooJs.embedded) {
|
||||
void boot();
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
// OneBSpace (OneCAD): Odoo Documents host integration.
|
||||
//! OneBSpace (OneCAD): Odoo Documents host integration for the wasm32 build
|
||||
//! (the `mod services_odoo;` declaration in `main.rs` carries the
|
||||
//! `#[cfg(target_arch = "wasm32")]` gate).
|
||||
//!
|
||||
//! Inside the wasm_app_framework shell every host seam of
|
||||
//! [`solidcraft_ui_egui::Services`] is rerouted through the one_cad JS
|
||||
//! bridge instead of the browser/device:
|
||||
//!
|
||||
//! - `pick_open` / `pick_save` / `pick_program` ask the JS host glue to show
|
||||
//! the unified Documents explorer and resolve with an *emulated* VFS path:
|
||||
//! `/odoo/doc/<recordId>/<name>` (an opened Documents record, bytes staged
|
||||
//! in the engine VFS) or `/odoo/pending/<token>/<name>` (a Save-as
|
||||
//! destination; the JS side keeps the token → record binding so a later
|
||||
//! Ctrl+S writes back in place).
|
||||
//! - writes drained from the engine VFS under `/odoo/…` go to the bound
|
||||
//! Documents record and `/downloads/` exports go to the "Exports" folder —
|
||||
//! the air gap keeps bytes off the local device ([`handle_write`]).
|
||||
//! - `show_in_folder` is a no-op and `http_get` stays `None` (air gap: the
|
||||
//! shell has no local filesystem to reveal and no outbound HTTP).
|
||||
//!
|
||||
//! Everything is inert standalone: when the page is not framed by the Odoo
|
||||
//! shell ([`embedded`]) nothing is overridden and the upstream behavior is
|
||||
//! untouched.
|
||||
//!
|
||||
//! # WIRE PROTOCOL (matches the one_cad JS bridge: CustomEvents)
|
||||
//!
|
||||
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecad:host-request",
|
||||
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
|
||||
//!
|
||||
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecad:host-response",
|
||||
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
|
||||
//!
|
||||
//! The glue must attach its `onecad:host-response` listener *before* the app
|
||||
//! boots (index.html loads the bridge module ahead of the wasm) and set
|
||||
//! `window.onecadHostReady = true` when listening; until the flag appears
|
||||
//! requests are retried every [`RETRY_MS`] for [`HOST_DEADLINE`].
|
||||
//!
|
||||
//! | method | params | result (on `ok`) |
|
||||
//! |--------------------|------------------------------------------|------------------|
|
||||
//! | `odoo.pickOpen` | `{ extensions: string[] }` | `{ recordId, name, bytes: Uint8Array }` or `{ content: <base64> }` → bytes staged at `/odoo/doc/<recordId>/<name>` |
|
||||
//! | `odoo.pickSave` | `{ name: string, design: boolean }` | `{ token, name }` → `/odoo/pending/<token>/<name>`, or `{ path: "/odoo/…" }` |
|
||||
//! | `odoo.pickProgram` | `{}` (asset pick from the "Assets" folder) | same shape as `odoo.pickOpen` |
|
||||
//! | `odoo.write` | `{ path: string, bytes: Uint8Array }` | ack only (`ok`) — Documents record write-back |
|
||||
//! | `odoo.saveExport` | `{ name: string, bytes: Uint8Array }` | ack only — lands in the "Exports" folder |
|
||||
//! | `odoo.import` | `{ name: string, bytes: Uint8Array }` | ack only — best-effort mirror into "Assets" |
|
||||
//!
|
||||
//! A `null`/absent `result` with `ok: true` means the user cancelled the
|
||||
//! explorer; the picker future resolves `None` (no error).
|
||||
//!
|
||||
//! For JS→Rust binary staging (deep links `?document=<id>`) the glue may
|
||||
//! call `window.onecadOdoo.bootFile(recordId, name, uint8array) -> path`,
|
||||
//! which inserts the bytes into the engine VFS at `/odoo/doc/<id>/<name>`.
|
||||
//!
|
||||
//! # HOW TO WIRE (exact locations in the current tree)
|
||||
//!
|
||||
//! 1. `apps/solidcraft-web/src/main.rs`, module block (tagged
|
||||
//! `OneBSpace (OneCAD)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
|
||||
//! 2. `apps/solidcraft-web/src/main.rs`, inside `web::start()`'s
|
||||
//! `WebRunner::start` app-install closure — immediately after the
|
||||
//! `let services = Services { .. };` literal and **before**
|
||||
//! `SolveApp::new(.., services)` (the tagged line
|
||||
//! `// OneBSpace (OneCAD): install odoo services on wasm`,
|
||||
//! currently main.rs line 537):
|
||||
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
|
||||
//! 3. **Exact VFS write drain point**: `apps/solidcraft-web/src/files.rs`
|
||||
//! `flush()` — the `for (path, bytes) in vfs::take_writes()` loop
|
||||
//! (files.rs line 297) hands every write to
|
||||
//! `crate::services_odoo::handle_write(&path, &bytes)` *first*
|
||||
//! (files.rs line 301); `true` consumes it (never OPFS, never a device
|
||||
//! download). Writes are enqueued by `vfs::write()`
|
||||
//! (crates/io/src/vfs.rs, wasm32 `imp::write`, line 84, which pushes the
|
||||
//! path onto the `WRITES` queue) and drained by `imp::take_writes()`
|
||||
//! (vfs.rs line 109).
|
||||
//! 4. `apps/solidcraft-web/src/main.rs` line 130 (`Shell::files_tick`):
|
||||
//! `crate::services_odoo::mirror_imports(&self.files)` mirrors CAD
|
||||
//! imports from the computer into the Documents "Assets" folder.
|
||||
|
||||
// OneBSpace (OneCAD) — this entire file is downstream; keep it out of
|
||||
// upstream syncs (greppable tag above).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context as TaskContext, Poll, Wake, Waker};
|
||||
use std::time::Duration;
|
||||
// OneBSpace (OneCAD): std::time::Instant::now() PANICS on
|
||||
// wasm32-unknown-unknown ("time not implemented on this platform") — the
|
||||
// whole app loop died the moment a picker (Open/Save/Import/Export) ran.
|
||||
// web_time::Instant maps to performance.now() on wasm, std on native.
|
||||
use web_time::Instant;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{FilePickerFuture, Services};
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::files::{self, Files};
|
||||
|
||||
/// An existing Documents record inside the emulated filesystem.
|
||||
const ODOC: &str = "/odoo/doc/";
|
||||
/// A Save-as destination chosen in the explorer, not yet written.
|
||||
const OPENDING: &str = "/odoo/pending/";
|
||||
/// Rust → JS request event (see the WIRE PROTOCOL block above).
|
||||
const REQUEST_EVENT: &str = "onecad:host-request";
|
||||
/// JS → Rust response event (see the WIRE PROTOCOL block above).
|
||||
const RESPONSE_EVENT: &str = "onecad:host-response";
|
||||
const PICK_OPEN: &str = "odoo.pickOpen";
|
||||
const PICK_SAVE: &str = "odoo.pickSave";
|
||||
const PICK_PROGRAM: &str = "odoo.pickProgram";
|
||||
const WRITE: &str = "odoo.write";
|
||||
const SAVE_EXPORT: &str = "odoo.saveExport";
|
||||
const IMPORT: &str = "odoo.import";
|
||||
/// At most this many bridge requests parked at once (the api.rs convention).
|
||||
const MAX_PENDING: usize = 64;
|
||||
/// A stuck shell (bridge JS missing, explorer broken) must not wedge the
|
||||
/// app's file picker forever: the future cancels itself after this long.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Retry cadence while `window.onecadHostReady` has not appeared yet.
|
||||
const RETRY_MS: i32 = 250;
|
||||
/// How long to wait for the JS bridge module to become ready per request.
|
||||
const HOST_DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// CAD formats the app ingests (mirrors files.rs's input picker accept list
|
||||
/// plus glTF/URDF). Imported copies land in the Documents "Assets" folder.
|
||||
const ASSET_EXTS: [&str; 12] = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
|
||||
|
||||
thread_local! {
|
||||
/// Decided once: does this page run inside the Odoo shell?
|
||||
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||
/// Pending picker requests, keyed by request id (the api.rs pending
|
||||
/// queue, adapted to Rust futures instead of JS promises).
|
||||
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
|
||||
/// The request-id counter.
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
|
||||
/// The `onecad:host-response` listener is installed exactly once.
|
||||
static LISTENER: Cell<bool> = const { Cell::new(false) };
|
||||
/// The `window.onecadOdoo` boot seam is installed exactly once.
|
||||
static BOOT: Cell<bool> = const { Cell::new(false) };
|
||||
/// Asset imports already mirrored this session (name, size).
|
||||
static MIRRORED: RefCell<HashSet<(String, usize)>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
|
||||
/// A picker answer slot: the response listener fills it, the future drains it.
|
||||
struct Slot {
|
||||
done: Cell<bool>,
|
||||
value: RefCell<Option<String>>,
|
||||
waker: RefCell<Option<Waker>>,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
fn complete(&self, value: Option<String>) {
|
||||
if self.done.get() {
|
||||
return;
|
||||
}
|
||||
*self.value.borrow_mut() = value;
|
||||
self.done.set(true);
|
||||
if let Some(waker) = self.waker.borrow_mut().take() {
|
||||
waker.wake();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The egui repaint waker (same pattern as ui-egui's own file picker).
|
||||
struct Repaint(egui::Context);
|
||||
|
||||
impl Wake for Repaint {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The future a `Services` picker closure returns; resolves with the
|
||||
/// emulated path once the shell answers, or `None` when cancelled/expired.
|
||||
struct PickerFuture {
|
||||
slot: Arc<Slot>,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl Future for PickerFuture {
|
||||
type Output = Option<String>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
|
||||
let this = self.get_mut();
|
||||
if this.slot.done.get() {
|
||||
return Poll::Ready(this.slot.value.borrow_mut().take());
|
||||
}
|
||||
if Instant::now() >= this.deadline {
|
||||
this.slot.complete(None);
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
*this.slot.waker.borrow_mut() = Some(cx.waker().clone());
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this page running inside the wasm_app_framework shell? Decided once,
|
||||
/// the family rule (one-cut-pro env.ts): framed and the same-origin parent
|
||||
/// is the Odoo web client (`/odoo` or `/web`). `?odoo-host=1` forces it on
|
||||
/// for local testing against a stub shell.
|
||||
pub fn embedded() -> bool {
|
||||
if let Some(decided) = EMBEDDED.with(Cell::get) {
|
||||
return decided;
|
||||
}
|
||||
let decided = embedded_uncached();
|
||||
EMBEDDED.with(|e| e.set(Some(decided)));
|
||||
decided
|
||||
}
|
||||
|
||||
fn embedded_uncached() -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let Ok(search) = window.location().search() else { return false };
|
||||
if search.contains("odoo-host=1") {
|
||||
return true;
|
||||
}
|
||||
let Ok(parent) = window.parent() else { return false };
|
||||
// Top window: standalone run, upstream behavior everywhere.
|
||||
let parent_top = parent
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
AsRef::<JsValue>::as_ref(p) == AsRef::<JsValue>::as_ref(&window)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if parent_top {
|
||||
return false;
|
||||
}
|
||||
// Same-origin shell: the parent path is readable; anything else that is
|
||||
// framed counts as embedded (the shell never cross-origins the iframe).
|
||||
let pathname = parent
|
||||
.as_ref()
|
||||
.and_then(|p| p.location().pathname().ok());
|
||||
match pathname {
|
||||
Some(path) => path.starts_with("/odoo") || path.starts_with("/web"),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the integration: the boot seam and the response listener always
|
||||
/// (harmless standalone), the Services overrides only inside the shell.
|
||||
/// Call once at app start, right after the `Services` literal is built in
|
||||
/// `main.rs` and before `SolveApp::new` (see HOW TO WIRE above).
|
||||
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
|
||||
install_boot_seam();
|
||||
install_response_listener(ctx.clone());
|
||||
if !embedded() {
|
||||
return services;
|
||||
}
|
||||
log::info!("OneCAD: Odoo shell detected — pickers and writes route through the Documents bridge");
|
||||
let open_ctx = ctx.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
let program_ctx = ctx;
|
||||
Services {
|
||||
// Open/Insert pickers browse Odoo Documents (explorer, then a read).
|
||||
pick_open: Some(Box::new(move |extensions: &[&str]| {
|
||||
let params = Object::new();
|
||||
let list = Array::new();
|
||||
for ext in extensions {
|
||||
list.push(&JsValue::from_str(ext));
|
||||
}
|
||||
let _ = Reflect::set(¶ms, &"extensions".into(), &list.into());
|
||||
pick(PICK_OPEN, params, open_ctx.clone())
|
||||
})),
|
||||
// Saves ask the explorer once; the returned pending token binds the
|
||||
// design to its record (later Ctrl+S writes back in place).
|
||||
pick_save: Some(Box::new(move |name: &str, extensions: &[&str]| {
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
// Same design test as the upstream closure (main.rs).
|
||||
let design = extensions.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
let _ = Reflect::set(¶ms, &"design".into(), &JsValue::from_bool(design));
|
||||
pick(PICK_SAVE, params, save_ctx.clone())
|
||||
})),
|
||||
// The slicer program pick becomes an asset pick from the "Assets"
|
||||
// folder: the picked file is staged in the VFS (no host program
|
||||
// dialog exists in the shell).
|
||||
pick_program: Some(Box::new(move || pick(PICK_PROGRAM, Object::new(), program_ctx.clone()))),
|
||||
// Air gap: nothing local to reveal.
|
||||
show_in_folder: Some(Box::new(|_: &str| {})),
|
||||
// Air gap: no update checks over HTTP in the shell.
|
||||
http_get: None,
|
||||
..services
|
||||
}
|
||||
}
|
||||
|
||||
/// Park a picker slot and deliver the request to the shell; the returned
|
||||
/// future resolves with the emulated path (or `None` on cancel/timeout).
|
||||
fn pick(method: &'static str, params: Object, ctx: egui::Context) -> FilePickerFuture {
|
||||
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
|
||||
log::error!("OneCAD: too many pending bridge requests (maximum {MAX_PENDING})");
|
||||
return Box::pin(std::future::ready(None));
|
||||
}
|
||||
let id = next_id();
|
||||
let slot = Arc::new(Slot { done: Cell::new(false), value: RefCell::new(None), waker: RefCell::new(None) });
|
||||
PENDING.with(|p| p.borrow_mut().insert(id, slot.clone()));
|
||||
deliver(method, id, params, slot.clone());
|
||||
// The api.rs pattern: nudge the UI so the future is polled promptly.
|
||||
ctx.request_repaint();
|
||||
Box::pin(PickerFuture { slot, deadline: Instant::now() + REQUEST_TIMEOUT })
|
||||
}
|
||||
|
||||
fn next_id() -> u64 {
|
||||
NEXT_ID.with(|n| {
|
||||
let id = n.get();
|
||||
n.set(id + 1);
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
/// Deliver a request once `window.onecadHostReady` appears (the glue sets it
|
||||
/// after attaching its response listener), then cancel the slot if it never
|
||||
/// does. Answers arrive out-of-band via `onecad:host-response`.
|
||||
fn deliver(method: &'static str, id: u64, params: Object, slot: Arc<Slot>) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let deadline = Instant::now() + HOST_DEADLINE;
|
||||
loop {
|
||||
if slot.done.get() {
|
||||
return; // answered or cancelled while we were waiting
|
||||
}
|
||||
if host_ready() && dispatch_event(method, id, ¶ms) {
|
||||
return; // the shell owns the answer now
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
log::error!("OneCAD: the Odoo bridge never became ready; cancelling \"{method}\"");
|
||||
slot.complete(None);
|
||||
return;
|
||||
}
|
||||
sleep_ms(RETRY_MS).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn host_ready() -> bool {
|
||||
web_sys::window()
|
||||
.map(|w| Reflect::get(&w, &"onecadHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn sleep_ms(ms: i32) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
/// `window.dispatchEvent(new CustomEvent(method, { detail: {id, method, params} }))`.
|
||||
/// Built through `js_sys` (no extra web-sys feature gates). `false` when the
|
||||
/// event could not be created or dispatched (no shell listening).
|
||||
fn dispatch_event(method: &str, id: u64, params: &Object) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let Ok(ctor) = Reflect::get(&window, &"CustomEvent".into()) else { return false };
|
||||
let Ok(ctor) = ctor.dyn_into::<js_sys::Function>() else { return false };
|
||||
let detail = Object::new();
|
||||
let _ = Reflect::set(&detail, &"id".into(), &JsValue::from(id as f64));
|
||||
let _ = Reflect::set(&detail, &"method".into(), &JsValue::from_str(method));
|
||||
let _ = Reflect::set(&detail, &"params".into(), params);
|
||||
let init = Object::new();
|
||||
let _ = Reflect::set(&init, &"detail".into(), &detail.into());
|
||||
let args = Array::of2(&JsValue::from_str(REQUEST_EVENT), &init.into());
|
||||
let Ok(event) = Reflect::construct(&ctor, &args) else { return false };
|
||||
let Ok(dispatch) = Reflect::get(&window, &"dispatchEvent".into()) else { return false };
|
||||
let Ok(dispatch) = dispatch.dyn_into::<js_sys::Function>() else { return false };
|
||||
dispatch.call1(&window, &event).is_ok()
|
||||
}
|
||||
|
||||
/// Install the `onecad:host-response` listener once: responses complete the
|
||||
/// parked picker slots (or log write/import failures).
|
||||
fn install_response_listener(ctx: egui::Context) {
|
||||
if LISTENER.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let listener = Closure::<dyn Fn(JsValue)>::new(move |event: JsValue| {
|
||||
let detail = Reflect::get(&event, &"detail".into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let id = field_u64(&detail, "id");
|
||||
let ok = Reflect::get(&detail, &"ok".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let error = field_string(&detail, "error").unwrap_or_default();
|
||||
let result = Reflect::get(&detail, &"result".into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let slot = PENDING.with(|p| p.borrow_mut().remove(&id));
|
||||
match slot {
|
||||
Some(slot) => {
|
||||
if ok {
|
||||
slot.complete(path_from_result(&result));
|
||||
} else {
|
||||
log::warn!("OneCAD: the shell refused the request: {error}");
|
||||
slot.complete(None);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// Writes, exports and imports are acknowledged without a parked
|
||||
// slot; only their failures are interesting here.
|
||||
None if !ok => log::warn!("OneCAD: the shell refused a write/export/import: {error}"),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
let Ok(add) = Reflect::get(&window, &"addEventListener".into()) else { return };
|
||||
let Ok(add) = add.dyn_into::<js_sys::Function>() else { return };
|
||||
if add.call2(&window, &JsValue::from_str(RESPONSE_EVENT), &listener.into_js_value()).is_ok() {
|
||||
LISTENER.with(|l| l.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `onecad:host-response` result onto the emulated path the picker
|
||||
/// future receives (see the WIRE PROTOCOL table above).
|
||||
fn path_from_result(result: &JsValue) -> Option<String> {
|
||||
if !result.is_object() {
|
||||
return None; // the user cancelled the explorer
|
||||
}
|
||||
let name = field_string(result, "name");
|
||||
// An opened record: bytes come as a Uint8Array (preferred) or base64.
|
||||
if let Ok(bytes) = Reflect::get(result, &"bytes".into())
|
||||
&& bytes.is_instance_of::<Uint8Array>()
|
||||
{
|
||||
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return None };
|
||||
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes.to_vec());
|
||||
}
|
||||
if let Some(content) = field_string(result, "content")
|
||||
&& let Some(bytes) = base64_decode(&content)
|
||||
{
|
||||
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes);
|
||||
}
|
||||
// A Save-as destination: the pending token names the path the engine
|
||||
// will write to (the glue binds the token to the record).
|
||||
let token = field_string(result, "token");
|
||||
if let (Some(token), Some(name)) = (token, name) {
|
||||
return Some(format!("{OPENDING}{token}/{}", sanitize(&name)));
|
||||
}
|
||||
// Or the glue resolved an explicit emulated path itself.
|
||||
if let Some(path) = field_string(result, "path")
|
||||
&& path.starts_with("/odoo/")
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Stage a picked record's bytes in the engine VFS at `/odoo/doc/<id>/<name>`.
|
||||
fn stage_record(record_id: u64, name: Option<&str>, bytes: Vec<u8>) -> Option<String> {
|
||||
if record_id == 0 {
|
||||
return None;
|
||||
}
|
||||
let path = format!("{ODOC}{record_id}/{}", sanitize(name.unwrap_or("document")));
|
||||
vfs::insert(&path, bytes);
|
||||
Some(path)
|
||||
}
|
||||
|
||||
fn field_u64(value: &JsValue, key: &str) -> u64 {
|
||||
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_f64()).map(|f| f as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn field_string(value: &JsValue, key: &str) -> Option<String> {
|
||||
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_string())
|
||||
}
|
||||
|
||||
/// File names only key the VFS: keep them single-segment.
|
||||
fn sanitize(name: &str) -> String {
|
||||
let cleaned: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.is_empty() {
|
||||
"file".into()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny base64 (standard alphabet, padding optional) decoder for bridge
|
||||
/// payloads that arrive as `content` strings instead of `Uint8Array`s.
|
||||
fn base64_decode(input: &str) -> Option<Vec<u8>> {
|
||||
fn value(byte: u8) -> Option<u32> {
|
||||
match byte {
|
||||
b'A'..=b'Z' => Some((byte - b'A') as u32),
|
||||
b'a'..=b'z' => Some((byte - b'a') as u32 + 26),
|
||||
b'0'..=b'9' => Some((byte - b'0') as u32 + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
|
||||
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
|
||||
for chunk in clean.chunks(4) {
|
||||
if chunk.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for (i, byte) in chunk.iter().enumerate() {
|
||||
acc |= value(*byte)? << (18 - 6 * i);
|
||||
}
|
||||
out.push((acc >> 16) as u8);
|
||||
if chunk.len() > 2 {
|
||||
out.push((acc >> 8) as u8);
|
||||
}
|
||||
if chunk.len() > 3 {
|
||||
out.push(acc as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Drain hook (tagged call site in `files.rs::flush`, the exact
|
||||
/// `vfs::take_writes()` drain point): consume writes the shell must not turn
|
||||
/// into device IO. `/odoo/…` writes go to the bound Documents record (or the
|
||||
/// pending token's destination on first write), `/downloads/` exports go to
|
||||
/// the "Exports" folder via saveExport. Returns true when the write was
|
||||
/// consumed (standalone: always false, upstream behavior untouched).
|
||||
pub fn handle_write(path: &str, bytes: &[u8]) -> bool {
|
||||
if !embedded() {
|
||||
return false;
|
||||
}
|
||||
let export = path.strip_prefix(files::DOWNLOADS).map(str::to_string);
|
||||
let is_odoo = path.starts_with(ODOC) || path.starts_with(OPENDING);
|
||||
if !is_odoo && export.is_none() {
|
||||
return false;
|
||||
}
|
||||
let params = Object::new();
|
||||
if let Some(name) = &export {
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
} else {
|
||||
let _ = Reflect::set(¶ms, &"path".into(), &JsValue::from_str(path));
|
||||
}
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes).into());
|
||||
let method = if export.is_some() { SAVE_EXPORT } else { WRITE };
|
||||
if !dispatch_event(method, next_id(), ¶ms) {
|
||||
log::error!("OneCAD: the Odoo bridge is unavailable; \"{path}\" stays in the engine VFS (the Documents copy was not updated)");
|
||||
}
|
||||
// Consumed either way: the air gap wins even if the bridge module is
|
||||
// gone — bytes never reach the local device from the shell (a copy
|
||||
// survives in the engine VFS and the OPFS autosave until the save
|
||||
// succeeds).
|
||||
true
|
||||
}
|
||||
|
||||
/// Mirror hook (tagged call site in `main.rs::files_tick`): CAD assets
|
||||
/// brought in from the computer get a copy in the Documents "Assets" folder
|
||||
/// (family storage rule). Deduplicated per (name, size) this session; the
|
||||
/// JS side additionally dedupes by signature across reloads.
|
||||
pub fn mirror_imports(files: &Rc<Files>) {
|
||||
if !embedded() {
|
||||
return;
|
||||
}
|
||||
for (name, bytes, _) in files.incoming.borrow().iter() {
|
||||
if !is_asset_name(name) {
|
||||
continue;
|
||||
}
|
||||
if !MIRRORED.with(|m| m.borrow_mut().insert((name.clone(), bytes.len()))) {
|
||||
continue;
|
||||
}
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes.as_slice()).into());
|
||||
if !dispatch_event(IMPORT, next_id(), ¶ms) {
|
||||
log::warn!("OneCAD: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_asset_name(name: &str) -> bool {
|
||||
std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ASSET_EXTS.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
|
||||
}
|
||||
|
||||
/// Install `window.onecadOdoo = { bootFile }` — the JS→Rust staging seam for
|
||||
/// deep links (`?document=<id>`): the glue inserts the record's bytes into
|
||||
/// the engine VFS, then opens the returned path via `window.solidcraft`.
|
||||
fn install_boot_seam() {
|
||||
if BOOT.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let boot_file = Closure::<dyn Fn(JsValue, JsValue, JsValue) -> JsValue>::new(
|
||||
|record_id: JsValue, name: JsValue, bytes: JsValue| {
|
||||
let id = record_id.as_f64().map(|f| f as u64).unwrap_or(0);
|
||||
if id == 0 {
|
||||
return JsValue::UNDEFINED;
|
||||
}
|
||||
let name = name.as_string().unwrap_or_else(|| "document".into());
|
||||
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return JsValue::UNDEFINED };
|
||||
match stage_record(id, Some(&name), bytes.to_vec()) {
|
||||
Some(path) => JsValue::from_str(&path),
|
||||
None => JsValue::UNDEFINED,
|
||||
}
|
||||
},
|
||||
);
|
||||
let glue = Object::new();
|
||||
let _ = Reflect::set(&glue, &"bootFile".into(), &boot_file.into_js_value());
|
||||
if Reflect::set(&window, &"onecadOdoo".into(), &glue).is_ok() {
|
||||
BOOT.with(|b| b.set(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The 3D mouse in the browser (#1336), through the Gamepad API.
|
||||
//!
|
||||
//! Chromium browsers list a 3Dconnexion device itself, with its six axes scaled to -1..1 from the
|
||||
//! device's ±350. Firefox lists only joysticks and gamepads (a 3D mouse is a HID multi-axis
|
||||
//! controller), but 3DxWare's virtual joystick carries the same six axes once 3DxWare's profile
|
||||
//! for Firefox sends them there. Both come in the device's own order and signs, so the shared
|
||||
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
|
||||
//! profile.
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
|
||||
|
||||
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
|
||||
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
|
||||
const LOGITECH: u16 = 0x046d;
|
||||
const LOGITECH_3D_MICE: [u16; 11] = [0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b];
|
||||
/// 3Dconnexion's own vendor id. The browser lists only its 3D mice as gamepads: its mice, and the
|
||||
/// mouse side of its receivers, are not gamepads.
|
||||
const THREEDCONNEXION: u16 = 0x256f;
|
||||
/// 3DxWare's virtual joystick.
|
||||
const VIRTUAL_JOYSTICK: (u16, u16) = (0xbeef, 0x046d);
|
||||
|
||||
/// What a gamepad the browser lists is, when it is a 3D mouse. A device is read before the
|
||||
/// joystick: in a Chromium browser both are listed, and the joystick stays at rest.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Source {
|
||||
Device,
|
||||
VirtualJoystick,
|
||||
}
|
||||
|
||||
/// The vendor and product ids in a gamepad's id: `Name (Vendor: 046d Product: c62b)` in Chromium
|
||||
/// browsers, `046d-c62b-Name` in Firefox.
|
||||
fn vendor_product(id: &str) -> Option<(u16, u16)> {
|
||||
fn hex(digits: &str) -> Option<u16> {
|
||||
if (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { u16::from_str_radix(digits, 16).ok() } else { None }
|
||||
}
|
||||
if let Some((_, ids)) = id.rsplit_once("Vendor: ") {
|
||||
let (vendor, rest) = ids.split_once(" Product: ")?;
|
||||
let product = rest.split(|c: char| !c.is_ascii_hexdigit()).next()?;
|
||||
return Some((hex(vendor)?, hex(product)?));
|
||||
}
|
||||
let mut parts = id.splitn(3, '-');
|
||||
let (vendor, product, _name) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
Some((hex(vendor)?, hex(product)?))
|
||||
}
|
||||
|
||||
/// Whether a gamepad is a 3D mouse, from the id the browser gives it.
|
||||
pub fn source(id: &str) -> Option<Source> {
|
||||
match vendor_product(id)? {
|
||||
VIRTUAL_JOYSTICK => Some(Source::VirtualJoystick),
|
||||
(THREEDCONNEXION, _) => Some(Source::Device),
|
||||
(LOGITECH, product) if LOGITECH_3D_MICE.contains(&product) => Some(Source::Device),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The six axes in the device's raw units, from a gamepad's axes (-1..1). Fewer than six: not a
|
||||
/// 3D mouse. A value that isn't a number reads as rest.
|
||||
pub fn axes(values: &[f64]) -> Option<Axes> {
|
||||
let raw = |i: usize| values.get(i).map(|&v| if v.is_finite() { (v.clamp(-1.0, 1.0) * f64::from(FULL_SCALE)).round() as i16 } else { 0 });
|
||||
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
|
||||
}
|
||||
|
||||
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
|
||||
pub fn choose(found: impl IntoIterator<Item = (Source, Axes)>) -> Option<Axes> {
|
||||
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use browser::SpaceMouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::WheelGuard;
|
||||
|
||||
use super::{axes, choose, source};
|
||||
|
||||
/// How often the page looks for the cap starting to move while nothing else redraws.
|
||||
const WAKE_MS: i32 = 50;
|
||||
|
||||
/// The 3D mouse the browser lists, if any.
|
||||
fn read() -> Option<Axes> {
|
||||
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
|
||||
choose(pads.iter().filter_map(|pad| {
|
||||
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
|
||||
let source = source(&pad.id())?;
|
||||
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
|
||||
Some((source, axes(&values)?))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse once per frame and moves the view with it.
|
||||
#[derive(Default)]
|
||||
pub struct SpaceMouse {
|
||||
axes: Axes,
|
||||
/// The previous frame's time and the step since it (seconds).
|
||||
last: Option<f64>,
|
||||
seconds: f64,
|
||||
wheel: WheelGuard,
|
||||
/// Listening for gamepads (from the first frame with navigation on).
|
||||
listening: bool,
|
||||
/// The timer that wakes the app when the cap starts moving runs (from when a 3D mouse
|
||||
/// is first listed).
|
||||
waking: bool,
|
||||
/// Navigation is on (the waker reads it).
|
||||
enabled: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
/// At the start of a frame: reads the cap and drops 3DxWare's emulated wheel from the
|
||||
/// frame's input, before the app sees it.
|
||||
pub fn raw_input_hook(&mut self, ctx: &egui::Context, enabled: bool, raw: &mut egui::RawInput) {
|
||||
let now = raw.time.unwrap_or_else(|| js_sys::Date::now() / 1000.0);
|
||||
self.seconds = self.last.map_or(0.0, |last| now - last);
|
||||
self.last = Some(now);
|
||||
self.enabled.set(enabled);
|
||||
self.axes = Axes::default();
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
if !self.listening {
|
||||
self.listening = true;
|
||||
listen(ctx);
|
||||
}
|
||||
let reading = read();
|
||||
if reading.is_some() && !self.waking {
|
||||
self.waking = wake(ctx, &self.enabled);
|
||||
}
|
||||
// Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing.
|
||||
self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default();
|
||||
self.wheel.filter(self.axes.moving(), now, &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D mouse is listed only once it has been moved on the page: redraw then, so the frame
|
||||
/// finds it without waiting for the pointer.
|
||||
fn listen(ctx: &egui::Context) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let ctx = ctx.clone();
|
||||
let on_connect = Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback("gamepadconnected", on_connect.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_connect.forget();
|
||||
}
|
||||
|
||||
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
|
||||
/// redraws when the cap starts moving; the frames that follow keep going on their own.
|
||||
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let (ctx, enabled) = (ctx.clone(), Rc::clone(enabled));
|
||||
let tick = Closure::<dyn FnMut()>::new(move || {
|
||||
if enabled.get() && read().is_some_and(Axes::moving) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let started = window.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), WAKE_MS).is_ok();
|
||||
// The timer lives as long as the page.
|
||||
tick.forget();
|
||||
started
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognises_3d_mice_in_each_browsers_ids() {
|
||||
// As Edge and Firefox list them (Windows 11, SpaceMouse Pro, 3DxWare 10).
|
||||
assert_eq!(source("SpaceMouse Pro (Vendor: 046d Product: c62b)"), Some(Source::Device));
|
||||
assert_eq!(source("Unknown Gamepad (Vendor: beef Product: 046d)"), Some(Source::VirtualJoystick));
|
||||
assert_eq!(source("beef-046d-Unknown Gamepad"), Some(Source::VirtualJoystick));
|
||||
// 3Dconnexion's own vendor id, any product; the older devices under Logitech's.
|
||||
assert_eq!(source("SpaceMouse Compact (Vendor: 256f Product: c635)"), Some(Source::Device));
|
||||
assert_eq!(source("256f-c652-3Dconnexion Universal Receiver"), Some(Source::Device));
|
||||
assert_eq!(source("46d-c626-SpaceNavigator"), Some(Source::Device));
|
||||
assert_eq!(source("SpaceNavigator (STANDARD GAMEPAD Vendor: 046d Product: c626)"), Some(Source::Device));
|
||||
for product in LOGITECH_3D_MICE {
|
||||
assert_eq!(source(&format!("x (Vendor: 046d Product: {product:04x})")), Some(Source::Device));
|
||||
assert_eq!(source(&format!("046d-{product:04X}-x")), Some(Source::Device));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
|
||||
for id in [
|
||||
"Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)",
|
||||
"046d-c21d-Logitech Gamepad F310",
|
||||
"G29 Driving Force Racing Wheel (Vendor: 046d Product: c24f)",
|
||||
"046d-c215-Logitech Extreme 3D",
|
||||
"NuLOOQ navigator (Vendor: 046d Product: c640)",
|
||||
"Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"45e-28e-Xbox 360 Wired Controller",
|
||||
"beef-046e-Unknown Gamepad",
|
||||
"beee-046d-Unknown Gamepad",
|
||||
"",
|
||||
"-",
|
||||
"--",
|
||||
"256f-",
|
||||
"256f-c635",
|
||||
"Vendor: 256f",
|
||||
"Vendor: 256f Product: ",
|
||||
"x (Vendor: 256fff Product: c635)",
|
||||
"x (Vendor: zz6f Product: c635)",
|
||||
"12345-c635-x",
|
||||
"256f--x",
|
||||
"SpaceMouse Pro",
|
||||
] {
|
||||
assert_eq!(source(id), None, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_never_panic() {
|
||||
// Hostile and odd ids: multi-byte text around the markers, markers repeated or cut short.
|
||||
for id in [
|
||||
"Vendor: é Product: ü",
|
||||
"Vendor: Vendor: 256f Product: c635)",
|
||||
"é-ü-ö",
|
||||
"Ⅰ (Vendor: 256f Product: c6Ⅰ)",
|
||||
"\u{0}-\u{0}-\u{0}",
|
||||
"Vendor: 256f Product: c635 Vendor: ",
|
||||
] {
|
||||
let _ = source(id);
|
||||
}
|
||||
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
|
||||
let alphabet: Vec<char> = "0123456789abcdefABCDEF- ():VendorProductéⅠ\u{0}".chars().collect();
|
||||
for _ in 0..20_000 {
|
||||
let len = (seed % 40) as usize;
|
||||
let id: String = (0..len)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
alphabet[(seed % alphabet.len() as u64) as usize]
|
||||
})
|
||||
.collect();
|
||||
if let Some(Source::Device) = source(&id) {
|
||||
let (vendor, product) = vendor_product(&id).expect("recognised ids have both");
|
||||
assert!(vendor == THREEDCONNEXION || (vendor == LOGITECH && LOGITECH_3D_MICE.contains(&product)), "{id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_axes_are_the_devices_raw_units() {
|
||||
// Full deflection is the desktop's 350, and the browser's scale keeps the device's steps.
|
||||
let axes = axes(&[1.0, -1.0, 0.5, -0.5, 2.0 / 700.0, 0.0]).expect("six axes");
|
||||
assert_eq!(axes, Axes { translation: [350, -350, 175], rotation: [-175, 1, 0] });
|
||||
for raw in -350_i16..=350 {
|
||||
let value = f64::from(raw) / 350.0;
|
||||
assert_eq!(super::axes(&[value; 6]).map(|a| a.translation[0]), Some(raw));
|
||||
}
|
||||
// Past full deflection is full deflection; anything that isn't a number is rest.
|
||||
let hostile = super::axes(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MIN_POSITIVE]).expect("six axes");
|
||||
assert_eq!(hostile, Axes { translation: [0, 0, 0], rotation: [350, -350, 0] });
|
||||
// A gamepad with fewer than six axes is not a 3D mouse; extra axes are ignored.
|
||||
assert_eq!(super::axes(&[1.0; 5]), None);
|
||||
assert_eq!(super::axes(&[]), None);
|
||||
assert_eq!(super::axes(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), Some(Axes::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_is_read_before_the_virtual_joystick() {
|
||||
let moving = Axes { translation: [100, 0, 0], rotation: [0; 3] };
|
||||
let other = Axes { translation: [0, 200, 0], rotation: [0; 3] };
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other), (Source::Device, moving)]), Some(moving));
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::VirtualJoystick, other)]), Some(moving));
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
|
||||
// Two devices: the first listed.
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
|
||||
assert_eq!(choose([]), None);
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@
|
||||
// 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.
|
||||
// the kept build answering when that fails. So they do when `build.json` is redirected or
|
||||
// refused (a sign-in in front of the app): the browser then shows the sign-in. 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
|
||||
@@ -158,9 +160,14 @@ function within(ms, promise) {
|
||||
/** 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). */
|
||||
/** The site wants a sign-in first (`build.json` is redirected, often to another origin, or refused). */
|
||||
const SIGN_IN = "sign-in";
|
||||
|
||||
/** The deployed build's version and files, GONE, SIGN_IN, or null (unknown: offline, failing, invalid). */
|
||||
async function deployed() {
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache" });
|
||||
// `manual`: a redirect to another origin would only fail (CORS), looking like being offline.
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache", redirect: "manual" });
|
||||
if (response.type === "opaqueredirect" || response.status === 401 || response.status === 403) return SIGN_IN;
|
||||
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);
|
||||
@@ -182,6 +189,7 @@ async function launch(event) {
|
||||
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 === SIGN_IN) return [await fetch(event.request), NETWORK];
|
||||
if (build === GONE) {
|
||||
const page = await fetch(event.request).catch(() => null);
|
||||
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
|
||||
|
||||
@@ -23,6 +23,7 @@ neg_cmp_op_on_partial_ord = "allow"
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-space-mouse = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -40,7 +41,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
|
||||
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
@@ -64,6 +65,11 @@ windows-sys = { version = "0.61", features = [
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
|
||||
solidcraft-3dx-client = { workspace = true }
|
||||
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
|
||||
# driver does not shut the app out.
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -160,12 +160,35 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
|
||||
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
|
||||
}
|
||||
|
||||
/// How long a request waits for the app's reply: most requests, and a batch (or every request,
|
||||
/// on a patient connection).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Waits {
|
||||
short: Duration,
|
||||
long: Duration,
|
||||
}
|
||||
|
||||
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) };
|
||||
|
||||
/// A request that timed out but is still running in the app (#1618): its id and method, and where
|
||||
/// its reply will land.
|
||||
struct Pending {
|
||||
id: Value,
|
||||
method: String,
|
||||
reply: Receiver<Value>,
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS);
|
||||
}
|
||||
|
||||
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
|
||||
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;
|
||||
let mut pending: Option<Pending> = None;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
@@ -193,14 +216,46 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
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 patient || 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"}));
|
||||
// A request that timed out may have finished since (#1618).
|
||||
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
|
||||
Ok(reply) => Some(reply),
|
||||
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
});
|
||||
let mut r = if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
|
||||
(Some(p), None) => {
|
||||
let r = json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": false}});
|
||||
pending = Some(p);
|
||||
r
|
||||
}
|
||||
}
|
||||
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
|
||||
// Nothing more is queued behind it, to run unseen when it ends.
|
||||
json!({"ok": false, "busy": true, "error": format!("busy: request {} (`{}`) is still running; ask `control.pending` for its result", p.id, p.method)})
|
||||
} else {
|
||||
if late.is_some() {
|
||||
// Finished, and the client moved on without asking: it is dropped.
|
||||
pending = None;
|
||||
}
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
match rrx.recv_timeout(wait) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
|
||||
pending = Some(Pending { id: id.clone(), method, reply: rrx });
|
||||
r
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
@@ -228,6 +283,62 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
|
||||
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
|
||||
/// is queued to run unseen behind it.
|
||||
#[test]
|
||||
fn a_timed_out_request_can_be_asked_for_later() {
|
||||
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());
|
||||
if req.method == "slow" {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
|
||||
std::thread::spawn(move || {
|
||||
if let Some(stream) = listener.incoming().flatten().next() {
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits);
|
||||
}
|
||||
});
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut out = s.try_clone().unwrap();
|
||||
let mut lines = BufReader::new(s);
|
||||
let mut ask = |v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
};
|
||||
let r = ask(json!({"id": 1, "method": "slow", "token": token}));
|
||||
assert!(r["ok"] == false && r["running"] == true && r["error"].as_str().is_some_and(|e| e.starts_with("timeout")), "{r}");
|
||||
let r = ask(json!({"id": 2, "method": "ui.view"}));
|
||||
assert!(r["busy"] == true && r["id"] == 2, "{r}");
|
||||
let r = ask(json!({"id": 3, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(1), &json!(false)), "{r}");
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
let r = ask(json!({"id": 4, "method": "control.pending"}));
|
||||
assert_eq!(
|
||||
(&r["result"]["pending"], &r["result"]["done"], &r["result"]["reply"]["result"]),
|
||||
(&json!(1), &json!(true), &json!("slow")),
|
||||
"{r}"
|
||||
);
|
||||
let r = ask(json!({"id": 5, "method": "control.pending"}));
|
||||
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
|
||||
let r = ask(json!({"id": 6, "method": "ui.view"}));
|
||||
assert_eq!((&r["ok"], &r["result"]), (&json!(true), &json!("ui.view")), "{r}");
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
|
||||
}
|
||||
|
||||
/// #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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
|
||||
//!
|
||||
//! 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.
|
||||
//! `.3mf`/`.stl`/`.obj`/`.glb`/`.gltf` file as a new design holding mesh bodies.
|
||||
//!
|
||||
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
|
||||
@@ -25,7 +25,7 @@ mod native_crash;
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
mod space_mouse;
|
||||
mod startup;
|
||||
|
||||
@@ -38,7 +38,7 @@ struct App {
|
||||
starting: Option<std::path::PathBuf>,
|
||||
/// Frames begun; the second one means the first was drawn (startup is over).
|
||||
frames: u32,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ impl eframe::App for App {
|
||||
self.solve.open_path_async(&f);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
if !self.solve.file_picker_busy() {
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
}
|
||||
@@ -77,6 +77,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -91,10 +94,20 @@ impl eframe::App for App {
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
|
||||
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
|
||||
{
|
||||
log::error!("update: starting {}: {e}", program.display());
|
||||
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
|
||||
let mut command = std::process::Command::new(&program);
|
||||
command.args(&args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Not tied to this process: its own process group, and no console (the PowerShell
|
||||
// that waits for an installer is hidden; the app itself is detached).
|
||||
let hidden_shell = program.file_stem().is_some_and(|n| n.eq_ignore_ascii_case("powershell"));
|
||||
command.creation_flags(0x0000_0200 | if hidden_shell { 0x0800_0000 } else { 0x0000_0008 });
|
||||
}
|
||||
if let Err(e) = command.spawn() {
|
||||
log::error!("update: starting {}: {e}", program.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,11 +117,17 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let open_parent = parent.clone();
|
||||
let program_parent = parent.clone();
|
||||
Services {
|
||||
pick_open: Some(Box::new(move || {
|
||||
let future = open_parent
|
||||
.clone()
|
||||
pick_open: Some(Box::new(move |first: &[&str]| {
|
||||
// The file type the command reads comes first, so the picker opens on it (#1801).
|
||||
let mut dialog = open_parent.clone();
|
||||
if !first.is_empty() {
|
||||
let label = first.iter().map(|e| e.to_uppercase()).collect::<Vec<_>>().join(", ");
|
||||
let both: Vec<String> = first.iter().flat_map(|e| [e.to_lowercase(), e.to_uppercase()]).collect();
|
||||
dialog = dialog.add_filter(label, &both);
|
||||
}
|
||||
let future = dialog
|
||||
.add_filter(
|
||||
"Designs, STEP, IGES and meshes",
|
||||
"Designs, CAD files and meshes",
|
||||
&[
|
||||
io::DESIGN_EXT,
|
||||
io::LEGACY_DESIGN_EXT,
|
||||
@@ -121,16 +140,27 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
"iges",
|
||||
"IGS",
|
||||
"IGES",
|
||||
"FCStd",
|
||||
"fcstd",
|
||||
"3dm",
|
||||
"3DM",
|
||||
"3mf",
|
||||
"3MF",
|
||||
"stl",
|
||||
"STL",
|
||||
"obj",
|
||||
"OBJ",
|
||||
"glb",
|
||||
"GLB",
|
||||
"gltf",
|
||||
"GLTF",
|
||||
],
|
||||
)
|
||||
.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"])
|
||||
.add_filter("FreeCAD and Rhino", &["FCStd", "fcstd", "3dm", "3DM"])
|
||||
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file();
|
||||
@@ -392,6 +422,10 @@ fn msaa() -> u16 {
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
/// A 32-bit float depth buffer, drawn with reversed depth (1 near, 0 far) so its precision follows
|
||||
/// the distance from the eye (#1434). Only where every adapter offers it with a stencil
|
||||
/// ([`startup::float_depth_everywhere`]); else [`DEPTH_BITS`]. Not in safe mode.
|
||||
const FLOAT_DEPTH_BITS: u8 = 32;
|
||||
/// The section cap marks where it shows in the stencil.
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
@@ -473,6 +507,8 @@ fn main() -> eframe::Result {
|
||||
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
|
||||
}
|
||||
let samples = if safe_mode { MSAA } else { msaa() };
|
||||
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
|
||||
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
|
||||
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
|
||||
let crashed = solidcraft_engine::crash::default_dir()
|
||||
.map(|dir| {
|
||||
@@ -492,7 +528,7 @@ fn main() -> eframe::Result {
|
||||
solidcraft_engine::crash::phase("window");
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: app_viewport()?,
|
||||
depth_buffer: DEPTH_BITS,
|
||||
depth_buffer: depth_bits,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
|
||||
@@ -505,7 +541,7 @@ fn main() -> eframe::Result {
|
||||
settings.as_ref().map(|d| d.join("app.ron"))
|
||||
},
|
||||
persist_window: !safe_mode,
|
||||
wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()),
|
||||
wgpu_options,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -555,7 +591,8 @@ fn main() -> eframe::Result {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
app.set_wgpu(rs, depth_bits, STENCIL_BITS, rs.msaa_samples);
|
||||
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -618,14 +655,14 @@ fn main() -> eframe::Result {
|
||||
if !notes.is_empty() {
|
||||
app.set_status(notes.join(" "), true);
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
@@ -660,6 +697,13 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
|
||||
if cpu {
|
||||
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
|
||||
}
|
||||
// A float depth buffer with a stencil, wherever the adapter has it (see `FLOAT_DEPTH_BITS`).
|
||||
let base = setup.device_descriptor.clone();
|
||||
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
let mut d = base(adapter);
|
||||
d.required_features |= adapter.features() & startup::FLOAT_DEPTH_STENCIL;
|
||||
d
|
||||
});
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
@@ -48,11 +48,13 @@ pub fn install(window: &impl HasWindowHandle) -> bool {
|
||||
// SAFETY: `hwnd` is the live window eframe just created on this thread; `frame_proc` has the
|
||||
// WNDPROC signature and stays valid for the life of the process. SetWindowLongPtrW returns
|
||||
// the previous procedure, which `frame_proc` calls for every other message.
|
||||
let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize) };
|
||||
let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize as _) };
|
||||
// `as _`: on 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
|
||||
// returns i32.
|
||||
if previous == 0 {
|
||||
return false;
|
||||
}
|
||||
PREVIOUS.store(previous, Ordering::SeqCst);
|
||||
PREVIOUS.store(previous as _, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ unsafe extern "system" fn frame_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lpara
|
||||
// Hand the window back to winit's procedure for its last message.
|
||||
if prev.is_some() {
|
||||
// SAFETY: restores the procedure this window had before `install`.
|
||||
unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst)) };
|
||||
unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst) as _) };
|
||||
}
|
||||
call_previous()
|
||||
}
|
||||
|
||||
@@ -1,93 +1,46 @@
|
||||
//! 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.
|
||||
//! 3D mouse navigation on Windows and macOS (through hidapi) and Linux (through spacenavd when it runs, else
|
||||
//! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones.
|
||||
//! 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;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spacenavd;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hid_descriptor::Layout;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solidcraft_engine::render::Camera;
|
||||
use solidcraft_space_mouse::Layout;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
|
||||
/// the HID device would fail or report it twice.
|
||||
static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false);
|
||||
/// Whether this app registered with the 3Dconnexion driver (macOS). The driver then has the device
|
||||
/// whether or not it has said so yet, so a failure to open it by HID is no news.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
static DRIVER_REGISTERED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
/// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the
|
||||
/// report id. A smaller buffer would cut a longer report short, and a cut report is dropped whole.
|
||||
const REPORT_BUFFER_BYTES: usize = 4096 + 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
translation: [i16; 3],
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct AxisRule {
|
||||
output: usize,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct DriverMapping {
|
||||
axes: [AxisRule; 6],
|
||||
overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
|
||||
/// what the driver applies by default in object mode (#1172): the physical Ry and Rz swapped
|
||||
/// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a
|
||||
/// profile, and the raw axes are used until it does.
|
||||
fn without_profile() -> Self {
|
||||
let mut mapping = Self::default();
|
||||
if cfg!(target_os = "linux") {
|
||||
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
|
||||
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
|
||||
}
|
||||
mapping
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
rotation: [f64; 3],
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
/// The mapping used when no 3DxWare profile is found: what the driver applies by default in
|
||||
/// object mode. On Linux there is no 3DxWare (#1172); on Windows 3DxWare has no profile for
|
||||
/// SolidCraft until one is made, and the raw axes swap roll and spin (#1380).
|
||||
fn without_profile() -> DriverMapping {
|
||||
DriverMapping::object_mode()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -215,71 +168,60 @@ fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
axes: Axes,
|
||||
translation_at: Instant,
|
||||
rotation_at: Instant,
|
||||
/// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send
|
||||
/// translation and rotation in separate reports, or spread even one group over several.
|
||||
at: [Instant; 6],
|
||||
/// The source says when the cap is back at rest, so a reading stays until the next one.
|
||||
/// A HID device repeats its report while the cap is held; spacenavd sends a record only when
|
||||
/// the values change, and a steady hold can go seconds without one.
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { axes: Axes::default(), translation_at: now, rotation_at: now }
|
||||
Self { axes: Axes::default(), at: [now; 6], held: false }
|
||||
}
|
||||
|
||||
fn current(self, now: Instant) -> Axes {
|
||||
Axes {
|
||||
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] },
|
||||
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] },
|
||||
let mut axes = self.axes;
|
||||
if !self.held {
|
||||
for (value, at) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at) {
|
||||
if now.duration_since(at) >= STALE_AFTER {
|
||||
*value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
axes
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
|
||||
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
|
||||
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None };
|
||||
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])])
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// Takes the axes an input report carries, as the device's descriptor laid them out, and leaves
|
||||
/// the others as they were: translation and rotation may come in separate reports (3Dconnexion's
|
||||
/// wired devices) or one (its wireless ones and most homemade devices).
|
||||
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
|
||||
let Some(decoded) = layout.decode(report) else { return false };
|
||||
let values = reading.axes.translation.iter_mut().chain(reading.axes.rotation.iter_mut());
|
||||
for ((value, at), new) in values.zip(reading.at.iter_mut()).zip(decoded.axes) {
|
||||
if let Some(new) = new {
|
||||
*value = new;
|
||||
*at = now;
|
||||
}
|
||||
Some(1) => {
|
||||
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.translation = translation;
|
||||
reading.translation_at = now;
|
||||
true
|
||||
}
|
||||
Some(2) => {
|
||||
let Some(rotation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.rotation = rotation;
|
||||
reading.rotation_at = now;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if DRIVER_CONNECTED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
@@ -287,6 +229,15 @@ fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes the cap's position from spacenavd.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish_held(ctx: &Context, shared: &Mutex<Reading>, axes: Axes) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading { axes, held: true, ..Reading::new(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() {
|
||||
@@ -295,7 +246,7 @@ fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
@@ -304,9 +255,21 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
loop {
|
||||
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];
|
||||
} else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
let device = info
|
||||
.open_device(&api)
|
||||
.inspect_err(|error| {
|
||||
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
|
||||
// The driver having the device is the reason it cannot be opened, not a problem.
|
||||
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
|
||||
log::warn!("3D mouse: cannot open the device: {error}");
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some((device, layout))
|
||||
}) {
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
@@ -323,34 +286,51 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// How the device packs its axes, from its report descriptor, or None when it isn't one to read:
|
||||
/// a controller whose descriptor names no motion axis. Without a descriptor to go on (it can't be
|
||||
/// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator,
|
||||
/// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone
|
||||
/// rather than guessed at.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<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
|
||||
}
|
||||
let read = device.get_report_descriptor(&mut descriptor);
|
||||
if let Ok(size @ 1..) = read {
|
||||
return solidcraft_space_mouse::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout();
|
||||
}
|
||||
let why = match read {
|
||||
Err(error) => error.to_string(),
|
||||
Ok(_) => "it is empty".to_string(),
|
||||
};
|
||||
if matches!(vendor_id, 0x046d | 0x256f) {
|
||||
log::info!("3D mouse: no report descriptor ({why}), assuming separate translation and rotation reports");
|
||||
Some(Layout::default())
|
||||
} else {
|
||||
log::debug!("3D mouse: skipping a multi-axis controller without a report descriptor ({why})");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Reads the 3D mouse through spacenavd when it runs, so what `spnavcfg` sets (swapped and inverted
|
||||
/// axes, sensitivity, dead zones) applies here as in other programs. Without the daemon it 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() {
|
||||
if let Some(daemon) = spacenavd::connect() {
|
||||
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
|
||||
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes));
|
||||
reset(&ctx, &shared);
|
||||
} else 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];
|
||||
log::info!("3D mouse: reading {}", device.path.display());
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
Ok(0) => break,
|
||||
@@ -376,11 +356,42 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS: the 3Dconnexion driver gives the device to its own clients only, so SolidCraft registers
|
||||
/// as one. Without the driver (or with its helper not running) the HID reader above takes over.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
|
||||
use solidcraft_3dx_client::Event;
|
||||
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
|
||||
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
|
||||
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Event::Axes { translation, rotation } => {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let now = Instant::now();
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.at = [now; 6];
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
});
|
||||
match started {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
/// Drops 3DxWare's emulated wheel while the cap moves (#1380).
|
||||
wheel: WheelGuard,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -392,194 +403,150 @@ impl SpaceMouse {
|
||||
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}");
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
start_driver_client(ctx, &shared);
|
||||
Self {
|
||||
shared,
|
||||
last_frame: now,
|
||||
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
|
||||
driver_mapping: load_driver_mapping().unwrap_or_else(without_profile),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Before the app's own hook (which adds the control channel's events): with navigation on,
|
||||
/// the wheel events 3DxWare makes from the cap's motion are dropped.
|
||||
pub fn raw_input_hook(&mut self, enabled: bool, raw: &mut egui::RawInput) {
|
||||
self.raw_input_hook_at(enabled, raw, Instant::now());
|
||||
}
|
||||
|
||||
fn raw_input_hook_at(&mut self, enabled: bool, raw: &mut egui::RawInput, now: Instant) {
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false);
|
||||
self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let now = Instant::now();
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.tick_at(app, ctx, Instant::now());
|
||||
}
|
||||
|
||||
/// The frame at `now`. Tests pass their own clock, so a slow runner can't age a reading.
|
||||
fn tick_at(&mut self, app: &mut SolveApp, ctx: &Context, now: Instant) {
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64();
|
||||
self.last_frame = now;
|
||||
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
|
||||
let updated = load_driver_mapping().unwrap_or_else(without_profile);
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
if !app.preferences.space_mouse_enabled || app.home.open {
|
||||
return;
|
||||
}
|
||||
let axes = match self.shared.lock() {
|
||||
Ok(reading) => reading.current(now),
|
||||
Err(_) => return,
|
||||
};
|
||||
if axes == Axes::default() {
|
||||
return;
|
||||
}
|
||||
let speed = f64::from(app.preferences.space_mouse_speed);
|
||||
if !speed.is_finite() {
|
||||
return;
|
||||
}
|
||||
app.begin_external_navigation();
|
||||
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
|
||||
navigate(
|
||||
&mut app.cam,
|
||||
self.driver_mapping.apply(axes),
|
||||
seconds * speed.clamp(0.1, 5.0),
|
||||
height,
|
||||
app.session.active_sketch.is_some(),
|
||||
app.preferences.zoom_reverse,
|
||||
);
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
|
||||
/// per second to pan and about two radians per second to turn, independent of frame rate.
|
||||
fn normalized(value: i16) -> f64 {
|
||||
let v = f64::from(value);
|
||||
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
|
||||
}
|
||||
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile above can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
|
||||
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
|
||||
// navigation mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
navigate_frame(app, ctx, self.driver_mapping, axes, seconds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion};
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
/// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about
|
||||
/// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there).
|
||||
fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] {
|
||||
mapping.apply(Axes { translation: [0; 3], rotation }).rotation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
fn each_axis_keeps_its_own_age_so_stale_motion_stops() {
|
||||
// Report layouts are tested in solidcraft-space-mouse; here, what the reader keeps.
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
|
||||
let layout = Layout::default();
|
||||
assert!(!decode_report(&[1, 1, 2], layout, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
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], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], layout, &mut reading, now));
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], layout, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
|
||||
// Only rotation arrives again: translation goes stale, rotation stays current.
|
||||
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], 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] });
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], layout, &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [0; 3], rotation: [4, 5, 6] });
|
||||
// A device sending all six in one report updates both at once.
|
||||
let combined = solidcraft_space_mouse::Descriptor::parse(&[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, 0x19, 0x30, 0x29, 0x35, 0x81,
|
||||
0x02, 0xC0,
|
||||
])
|
||||
.layout()
|
||||
.expect("six axes declared");
|
||||
let mut both = Reading::new(now);
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0], combined, &mut both, now));
|
||||
assert_eq!(both.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_report_one_carries_translation_and_rotation_together() {
|
||||
fn a_group_spread_over_reports_keeps_the_axes_it_already_has() {
|
||||
// X in report 1, Y in report 2 (16-bit, -350..350): X arriving mustn't zero Y, and Y
|
||||
// arriving mustn't zero X.
|
||||
let split_x_y = solidcraft_space_mouse::Descriptor::parse(&[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, // 1 x 16 bit, -350..350
|
||||
0x85, 0x01, 0x09, 0x30, 0x81, 0x02, // report 1: X
|
||||
0x85, 0x02, 0x09, 0x31, 0x81, 0x02, // report 2: Y
|
||||
0xC0,
|
||||
])
|
||||
.layout()
|
||||
.expect("X and Y declared");
|
||||
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] });
|
||||
assert!(decode_report(&[1, 100, 0], split_x_y, &mut reading, now));
|
||||
assert!(decode_report(&[2, 200, 0], split_x_y, &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [100, 200, 0]);
|
||||
// Then only X keeps coming: Y goes stale on its own.
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert!(decode_report(&[1, 50, 0], split_x_y, &mut reading, later));
|
||||
assert_eq!(reading.current(later).translation, [50, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_layout_ignores_short_and_other_reports() {
|
||||
fn only_the_axis_itself_keeps_it_alive() {
|
||||
// 3Dconnexion's split layout, plus buttons in report 3 (one byte of bits).
|
||||
let mut layout = Layout::default();
|
||||
layout.buttons[0] =
|
||||
Some(solidcraft_space_mouse::Field { report_id: 3, bit_offset: 0, bits: 1, logical_min: 0, logical_max: 1, relative: false });
|
||||
let 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] });
|
||||
assert!(decode_report(&[1, 100, 0, 0, 0, 0, 0], layout, &mut reading, now));
|
||||
// Just before the timeout X is still there; at exactly the timeout it's stale.
|
||||
assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1)).translation, [100, 0, 0]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
|
||||
// Button reports and reports with an id the layout doesn't know keep coming, but neither
|
||||
// refreshes X: it goes stale on time all the same.
|
||||
for step in 1..=10 {
|
||||
let at = now + STALE_AFTER * step / 10;
|
||||
assert!(decode_report(&[3, 1], layout, &mut reading, at));
|
||||
assert!(!decode_report(&[0x17, 80], layout, &mut reading, at));
|
||||
}
|
||||
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
let before = cam;
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() {
|
||||
let now = Instant::now();
|
||||
let axes = Axes { translation: [0, 200, 0], rotation: [0; 3] };
|
||||
let later = now + STALE_AFTER * 20;
|
||||
// spacenavd sends nothing while the values stay the same: the push lasts.
|
||||
let held = Reading { axes, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(later), axes);
|
||||
// A HID device repeats its report: silence means the cap, or the device, is gone.
|
||||
let hid = Reading { axes, ..Reading::new(now) };
|
||||
assert_eq!(hid.current(later), Axes::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -601,7 +568,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
|
||||
fn without_a_profile_the_drivers_object_mode_axes_are_used() {
|
||||
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
|
||||
let object_mode = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
@@ -614,39 +581,15 @@ mod tests {
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(DriverMapping::without_profile(), profile);
|
||||
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
|
||||
let before = Camera::default();
|
||||
let mut tilt = before;
|
||||
navigate(
|
||||
&mut tilt,
|
||||
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }),
|
||||
0.02,
|
||||
600.0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(tilt.roll, before.roll);
|
||||
let mut twist = before;
|
||||
navigate(
|
||||
&mut twist,
|
||||
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
|
||||
0.02,
|
||||
600.0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_ne!(twist.yaw, before.yaw);
|
||||
assert_eq!(twist.roll, before.roll);
|
||||
} else {
|
||||
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
|
||||
}
|
||||
// On every platform (#1172 on Linux, #1380 on Windows).
|
||||
assert_eq!(without_profile(), profile);
|
||||
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
|
||||
assert_eq!(rotation(without_profile(), [0, 350, 0]), [0.0, 0.0, -1.0]);
|
||||
assert_eq!(rotation(without_profile(), [0, 0, 350]), [0.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
fn mapped_roll_and_spin_reach_the_roll_and_turn_axes() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
@@ -654,16 +597,91 @@ mod tests {
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let before = Camera::default();
|
||||
// The physical roll reaches the roll axis only, and the physical spin the turn only.
|
||||
assert_eq!(rotation(mapping, [0, 350, 0]), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(rotation(mapping, [0, 0, 350]), [0.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
let mut physical_roll = before;
|
||||
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(physical_roll.roll, before.roll);
|
||||
#[test]
|
||||
fn each_frame_hands_the_view_its_motion_through_the_drivers_mapping() {
|
||||
// The reader's latest tilt, and a profile that makes tilt roll: the frame's motion rolls.
|
||||
// The frame runs at the reading's own time, so a slow runner can't let the reading go stale.
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
|
||||
app.home.open = false;
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
reading.axes.rotation = [0, 350, 0];
|
||||
let mut mouse = SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
last_frame: now - Duration::from_millis(20),
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
};
|
||||
mouse.tick_at(&mut app, &Context::default(), now);
|
||||
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
|
||||
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
|
||||
assert!(seconds > 0.0);
|
||||
}
|
||||
|
||||
let mut physical_spin = before;
|
||||
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(physical_spin.yaw, before.yaw);
|
||||
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
|
||||
fn wheel() -> egui::Event {
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -1.0),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(reading: Reading, now: Instant) -> SpaceMouse {
|
||||
SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
last_frame: now,
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_3dxware_makes_from_the_cap_is_dropped() {
|
||||
// The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped.
|
||||
let now = Instant::now();
|
||||
let mut tilted = Reading::new(now);
|
||||
tilted.axes.rotation = [-200, 0, 0];
|
||||
let mut moving = mouse(tilted, now);
|
||||
let mut raw = egui::RawInput { events: vec![wheel(), egui::Event::Zoom(1.1), egui::Event::Text("a".into())], ..Default::default() };
|
||||
moving.raw_input_hook_at(true, &mut raw, now);
|
||||
assert_eq!(raw.events, [egui::Event::Text("a".into())]);
|
||||
// With navigation off, the wheel is the cap's only way to move the view: kept.
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
moving.raw_input_hook_at(false, &mut raw, now);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
// The cap at rest: the wheel is the user's.
|
||||
let mut resting = mouse(Reading::new(now), now);
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
resting.raw_input_hook_at(true, &mut raw, now);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_is_the_users_again_once_the_cap_has_been_still_a_moment() {
|
||||
let now = Instant::now();
|
||||
let mut tilted = Reading::new(now);
|
||||
tilted.axes.rotation = [-200, 0, 0];
|
||||
let mut cap = mouse(tilted, now);
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
cap.raw_input_hook_at(true, &mut raw, now);
|
||||
assert!(raw.events.is_empty(), "dropped while the cap moves");
|
||||
// Let go, then wait out the quiet time.
|
||||
let later = now + Duration::from_secs_f64(solidcraft_ui_egui::space_mouse::WHEEL_QUIET_S) + Duration::from_millis(60);
|
||||
if let Ok(mut reading) = cap.shared.lock() {
|
||||
*reading = Reading::new(later);
|
||||
}
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
cap.raw_input_hook_at(true, &mut raw, later);
|
||||
assert_eq!(raw.events.len(), 1, "kept once the cap has been still");
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 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.
|
||||
//! its report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading it is plain file
|
||||
//! I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::hid_descriptor::{Descriptor, Layout};
|
||||
use solidcraft_space_mouse::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
@@ -14,14 +14,7 @@ pub struct Device {
|
||||
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.
|
||||
/// The first hidraw interface that is a multi-axis controller with motion axes, whoever made it.
|
||||
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();
|
||||
@@ -29,29 +22,8 @@ pub fn find() -> Option<Device> {
|
||||
}
|
||||
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
// A controller that declares no motion axis (buttons only, say) isn't read.
|
||||
let layout = descriptor.is_multi_axis_controller().then(|| descriptor.layout()).flatten()?;
|
||||
Some(Device { path, layout })
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Reading a 3D mouse through `spacenavd` on Linux. The daemon owns the device and applies what
|
||||
//! `spnavcfg` sets (swapped and inverted axes, sensitivity, dead zones) before it hands the motion
|
||||
//! to its clients, so a program that reads the HID device itself bypasses that setup. Over its
|
||||
//! socket the daemon sends fixed 32-byte records: eight native-endian `i32`, the first the kind of
|
||||
//! record (0 for motion, then x y z rx ry rz and the milliseconds since the last record).
|
||||
//! Other records (button presses, from newer daemons also configuration changes) are ignored.
|
||||
//!
|
||||
//! The socket is plain file I/O, so no library is needed, and it needs no udev rule: the daemon
|
||||
//! is what has access to the device.
|
||||
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
|
||||
|
||||
/// Where the daemon listens. `/var/run` is a link to `/run` on current systems.
|
||||
const SOCKETS: [&str; 2] = ["/var/run/spnav.sock", "/run/spnav.sock"];
|
||||
|
||||
const RECORD_BYTES: usize = 32;
|
||||
|
||||
/// What the daemon reports at full deflection (seen with a SpaceMouse Compact and a default
|
||||
/// sensitivity). Our axes are in the raw HID units of [`FULL_SCALE`].
|
||||
const DAEMON_FULL_SCALE: i64 = 500;
|
||||
|
||||
pub fn connect() -> Option<UnixStream> {
|
||||
SOCKETS.iter().find_map(|path| connect_to(Path::new(path)))
|
||||
}
|
||||
|
||||
fn connect_to(path: &Path) -> Option<UnixStream> {
|
||||
UnixStream::connect(path).ok()
|
||||
}
|
||||
|
||||
/// The axes of one record, or `None` for a record that is not motion.
|
||||
///
|
||||
/// The daemon's frame is right-handed with x to the right, y away from the user and z up; ours is
|
||||
/// the device's raw one, x right, y towards the user and z down, so y and z (and the rotations
|
||||
/// about them) change sign. With nothing set in `spnavcfg` the two give the same motion; what
|
||||
/// the user swaps or inverts there then carries over.
|
||||
fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
|
||||
let mut data = [0_i32; 8];
|
||||
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
|
||||
*slot = i32::from_ne_bytes(*bytes);
|
||||
}
|
||||
let [kind, x, y, z, rx, ry, rz, _period] = data;
|
||||
(kind == 0).then(|| Axes {
|
||||
translation: [unit(x), unit(-i64::from(y)), unit(-i64::from(z))],
|
||||
rotation: [unit(rx), unit(-i64::from(ry)), unit(-i64::from(rz))],
|
||||
})
|
||||
}
|
||||
|
||||
/// A daemon value in our raw units. Out-of-range values stay in `i16`: they are input from another
|
||||
/// process.
|
||||
fn unit(value: impl Into<i64>) -> i16 {
|
||||
let scaled = value.into() * i64::from(FULL_SCALE) / DAEMON_FULL_SCALE;
|
||||
i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Hands every motion record to `publish` until the daemon closes the connection or fails. A
|
||||
/// partial record at the end is dropped.
|
||||
pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes)) {
|
||||
let mut record = [0_u8; RECORD_BYTES];
|
||||
loop {
|
||||
match stream.read_exact(&mut record) {
|
||||
Ok(()) => {
|
||||
if let Some(axes) = decode(&record) {
|
||||
publish(axes);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: spacenavd connection ended: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn record(data: [i32; 8]) -> [u8; RECORD_BYTES] {
|
||||
let mut bytes = [0_u8; RECORD_BYTES];
|
||||
for (chunk, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(data) {
|
||||
*chunk = value.to_ne_bytes();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_records_become_axes_in_the_devices_raw_frame() {
|
||||
// Full deflection of the daemon's x, y and z is the device's full scale, and y and z flip.
|
||||
assert_eq!(decode(&record([0, 500, 500, 500, 0, 0, 0, 8])), Some(Axes { translation: [350, -350, -350], rotation: [0; 3] }));
|
||||
// Rotations follow translations: right-handed in both frames, so ry and rz flip as y and z do.
|
||||
assert_eq!(decode(&record([0, 0, 0, 0, 250, 250, 250, 8])), Some(Axes { translation: [0; 3], rotation: [175, -175, -175] }));
|
||||
// Rest is a motion record of zeros.
|
||||
assert_eq!(decode(&record([0, 0, 0, 0, 0, 0, 0, 1200])), Some(Axes::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_spnavcfg_swaps_reaches_the_view() {
|
||||
// The daemon sends a push away as y. With Y and Z swapped in spnavcfg it sends the same push
|
||||
// as z, and the view gets a lift (raw z) instead of the zoom (raw y).
|
||||
let before = decode(&record([0, 0, 500, 0, 0, 0, 0, 8])).map(|axes| axes.translation);
|
||||
let after = decode(&record([0, 0, 0, 500, 0, 0, 0, 8])).map(|axes| axes.translation);
|
||||
assert_eq!(before, Some([0, -350, 0]));
|
||||
assert_eq!(after, Some([0, 0, -350]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_records_and_hostile_values_are_harmless() {
|
||||
// A button record (kind 1, press) and one of an unknown kind are not motion.
|
||||
assert_eq!(decode(&record([1, 0, 1, 0, 0, 0, 0, 0])), None);
|
||||
assert_eq!(decode(&record([7, 500, 500, 500, 500, 500, 500, 0])), None);
|
||||
// Nothing overflows on the extremes of `i32`.
|
||||
let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1]));
|
||||
assert_eq!(extreme.map(|axes| axes.translation[0]), Some(i16::MIN));
|
||||
assert_eq!(extreme.map(|axes| axes.translation[1]), Some(i16::MAX));
|
||||
assert_eq!(extreme.map(|axes| axes.rotation[0]), Some(i16::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_is_read_record_by_record_until_it_ends() {
|
||||
let (mut daemon, client) = UnixStream::pair().expect("a socket pair");
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend(record([0, 100, 0, 0, 0, 0, 0, 8]));
|
||||
bytes.extend(record([2, 1, 0, 0, 0, 0, 0, 0])); // a button release: skipped
|
||||
bytes.extend(record([0, 0, 0, 0, 0, 0, 0, 900]));
|
||||
bytes.extend([1, 2, 3]); // the daemon went away mid-record
|
||||
daemon.write_all(&bytes).expect("written");
|
||||
drop(daemon);
|
||||
let mut seen = Vec::new();
|
||||
read(client, |axes| seen.push(axes.translation));
|
||||
assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_daemon_means_no_connection() {
|
||||
let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id()));
|
||||
assert!(connect_to(&missing).is_none());
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,23 @@ pub fn choose_backends(
|
||||
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
|
||||
}
|
||||
|
||||
/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434).
|
||||
pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8;
|
||||
|
||||
/// Whether every adapter `options` could start offers a float depth buffer with a stencil. eframe
|
||||
/// makes the window's depth buffer from the depth bits before it picks the adapter, so the window
|
||||
/// asks for a float one only when whichever it picks has it.
|
||||
pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) -> bool {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { return false };
|
||||
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle_from_env();
|
||||
desc.backends = setup.instance_descriptor.backends;
|
||||
let instance = eframe::wgpu::Instance::new(desc);
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(setup.instance_descriptor.backends));
|
||||
let all = !adapters.is_empty() && adapters.iter().all(|a| a.features().contains(FLOAT_DEPTH_STENCIL));
|
||||
log::info!("graphics: float depth with stencil on every adapter: {all}");
|
||||
all
|
||||
}
|
||||
|
||||
/// The order backends are tried in on Windows.
|
||||
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
|
||||
|
||||
@@ -182,7 +199,7 @@ pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
|
||||
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
|
||||
- Start it once with `solidcraft --safe-mode`.\n\
|
||||
- Rename the settings folder: {folder}\n\n\
|
||||
More: https://bherbruck.github.io/solidcraft/guide/troubleshooting.html"
|
||||
More: https://docs.solidcraft.io/guide/troubleshooting.html"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Resource work — mandatory
|
||||
|
||||
Read `assets/README.md` before changing media, fonts, artwork or their consumers.
|
||||
For **any UI icon, icon assignment or review-tool change**, also read
|
||||
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md` before editing.
|
||||
These files are the specification; chat history and old previews are not required.
|
||||
|
||||
- Keep one editable authority per resource. UI SVGs are masters, not exports of a
|
||||
second competing drawing library. `source/` retains genuine native source and
|
||||
the separately compiled Rust viewport glyphs.
|
||||
- Keep only production inputs, necessary shipped exports, authoring tools and
|
||||
provenance here. Put screenshots, contact sheets, audits, caches, test output,
|
||||
review inbox files and package staging under ignored `target/`.
|
||||
- Do not add historical alternatives, duplicate resolutions that packaging does
|
||||
not consume, copied screenshots, `node_modules`, Python bytecode or generated
|
||||
inventory CSVs to this collection. Preserve native editable source documents.
|
||||
- Add an exact `ATTRIBUTION.md` row for every non-Markdown asset, including tool
|
||||
code, web UI sources and metadata. Remove rows for intentionally retired files.
|
||||
- Never copy or derive iconography from commercial CAD products. Use original
|
||||
SolidCraft artwork, analytic geometry and the approved local references.
|
||||
- Run `cargo xtask assets` and `cargo xtask icons`. Full repository gates still
|
||||
apply before a commit. Never refresh acceptance hashes to bypass a failed check.
|
||||
@@ -13,7 +13,9 @@ assets/
|
||||
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
|
||||
ui/design/svg/ # authoritative original UI icon masters
|
||||
ui/design/tools/ # authoring bases, checks and included review app
|
||||
ui/source/ # Rust viewport glyphs, markers and navigation
|
||||
branding/web/source/og.png # shared social preview card
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
@@ -25,14 +27,15 @@ assets/
|
||||
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
|
||||
# redundant historical exports are retired
|
||||
```
|
||||
|
||||
## 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.
|
||||
Read the mandatory [UI style guide](icons/ui/STYLE_GUIDE.md) before editing an icon.
|
||||
Edit SVG geometry and its command/UI assignments together; inspect both themes
|
||||
and small sizes. Temporary media and generated reports belong in ignored target/. 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
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 116 KiB |
@@ -1,63 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<filter id="filter-1" filterUnits="userSpaceOnUse" x="64.5" y="64.5" width="895" height="895" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="6.5"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#243047" flood-opacity="0.16"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
<linearGradient id="linear-gradient-1" x1="512" y1="96" x2="512" y2="928" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fcfdfe"/>
|
||||
<stop offset="1" stop-color="#dce3ed"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M320 96 L704 96 C858 96 928 166 928 320 L928 704 C928 858 858 928 704 928 L320 928 C166 928 96 858 96 704 L96 320 C96 166 166 96 320 96 Z"/>
|
||||
</clipPath>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="568.512" gradientTransform="matrix(1 0 0 0.5 512 697.379)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#507eaa"/>
|
||||
<stop offset="0.3" stop-color="#507eaa" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#507eaa" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#507eaa" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="409.324" y1="139.358" x2="520.928" y2="511" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#8dc4fc"/>
|
||||
<stop offset="1" stop-color="#5d9feb"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="264.239" y1="342.478" x2="442.805" y2="882.642" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#438feb"/>
|
||||
<stop offset="1" stop-color="#2b72cf"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-4" x1="610.212" y1="420.601" x2="732.976" y2="882.642" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#2869bd"/>
|
||||
<stop offset="1" stop-color="#1e4d98"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-5" x1="599.051" y1="431.761" x2="738.557" y2="588.007" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#83baf6"/>
|
||||
<stop offset="1" stop-color="#5597e6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-6" x1="599.051" y1="242.034" x2="660.434" y2="418.369" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#306cbc"/>
|
||||
<stop offset="1" stop-color="#22529c"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-2">
|
||||
<path d="M512 139.358 L672.71 231.99 L672.71 394.877 L694.445 406.957 L693.395 430.291 L833.42 511 L833.42 697.379 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 L190.58 325.737 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>SolidCraft macOS application icon</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<g filter="url(#filter-1)">
|
||||
<path id="Porcelain___legacy_macOS_silhouette" data-name="Porcelain • legacy macOS silhouette" d="M320 96 L704 96 C858 96 928 166 928 320 L928 704 C928 858 858 928 704 928 L320 928 C166 928 96 858 96 704 L96 320 C96 166 166 96 320 96 Z" fill="url(#linear-gradient-1)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Experiment___faint_origin_grid" data-name="Experiment • faint origin grid" clip-path="url(#clip-path-1)">
|
||||
<path id="Grid___elliptical_radial_alpha_beneath_solid" data-name="Grid • elliptical radial alpha beneath solid" d="M80 -200.041 L944 297.959 M80 297.959 L944 -200.041 M80 -107.41 L944 390.59 M80 390.59 L944 -107.41 M80 -14.778 L944 483.222 M80 483.222 L944 -14.778 M80 77.853 L944 575.853 M80 575.853 L944 77.853 M80 170.485 L944 668.485 M80 668.485 L944 170.485 M80 263.116 L944 761.116 M80 761.116 L944 263.116 M80 355.748 L944 853.748 M80 853.748 L944 355.748 M80 541.01 L944 1039.01 M80 1039.01 L944 541.01 M80 633.642 L944 1131.642 M80 1131.642 L944 633.642 M80 726.273 L944 1224.273 M80 1224.273 L944 726.273 M80 818.905 L944 1316.905 M80 1316.905 L944 818.905 M80 911.536 L944 1409.536 M80 1409.536 L944 911.536 M80 1004.168 L944 1502.168 M80 1502.168 L944 1004.168" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.2" stroke-miterlimit="10" opacity="0.28"/>
|
||||
<path id="Origin_axes___shared_elliptical_radial_alpha" data-name="Origin axes • shared elliptical radial alpha" d="M80 448.379 L944 946.379 M80 946.379 L944 448.379" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.6" stroke-miterlimit="10" opacity="0.28"/>
|
||||
</g>
|
||||
<g id="SolidCraft___machined_solid" data-name="SolidCraft • machined solid">
|
||||
<path id="Solid_silhouette" data-name="Solid silhouette" d="M512 139.358 L672.71 231.99 L672.71 418.369 L833.42 511 L833.42 697.379 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 L190.58 325.737 Z" fill="#2864b8"/>
|
||||
<path id="Top___extruded_profile" data-name="Top • extruded profile" d="M190.58 325.737 L512 139.358 L672.71 231.99 L512 325.737 L672.71 418.369 L512 511 Z" fill="url(#linear-gradient-2)"/>
|
||||
<path id="Left___stepped_face" data-name="Left • stepped face" d="M190.58 325.737 L512 511 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 Z" fill="url(#linear-gradient-3)"/>
|
||||
<path id="Right___milled_recess" data-name="Right • milled recess" d="M512 511 L672.71 418.369 L672.71 603.631 L833.42 511 L833.42 697.379 L512 882.642 Z" fill="url(#linear-gradient-4)"/>
|
||||
<path id="Top___lower_step" data-name="Top • lower step" d="M672.71 418.369 L833.42 511 L672.71 603.631 Z" fill="url(#linear-gradient-5)"/>
|
||||
<path id="Inner___upper_step" data-name="Inner • upper step" d="M672.71 231.99 L672.71 418.369 L512 325.737 Z" fill="url(#linear-gradient-6)"/>
|
||||
<g clip-path="url(#clip-path-2)">
|
||||
<path id="Cut_edges___original_SolidCraft_topology" data-name="Cut edges • original SolidCraft topology" d="M190.58 325.737 L512 511 L512 882.642 M512 511 L672.71 418.369 M672.71 231.99 L512 325.737 L672.71 418.369 L672.71 603.631 L833.42 511" fill="none" stroke="#edf5ff" stroke-width="23" stroke-miterlimit="10" opacity="0.88"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,31 @@
|
||||
# UI icons — read before drawing or assigning one
|
||||
|
||||
**Required:** read [STYLE_GUIDE.md](STYLE_GUIDE.md), `design/palette.toml`, and
|
||||
`design/references.toml`. Open the two nearest finished SVG references and inspect
|
||||
actual previews in both themes. Do this before choosing geometry or detail.
|
||||
|
||||
1. Check `design/review.json`. A valid final id **or any final alias of its master**
|
||||
protects that master and its small variant. Do not redraw it without an explicit
|
||||
user instruction to reopen/change it. Do not edit accepted hashes to make lint pass.
|
||||
2. Choose the category in the guide. Use `tools/author/bases.py` for a standard
|
||||
grey cube or slab. Match its exact projection, shading, weight and footprint.
|
||||
Add only the cue that explains the operation; do not invent a new base cube.
|
||||
3. Edit the SVG master, or create a new id with the scaffold documented below.
|
||||
Reuse an alias when both meaning and shape are shared. Give different operations
|
||||
distinct drawings. Wire command, timeline and relevant browser assignments.
|
||||
4. Every new master, alias and detected reference appears automatically on the
|
||||
review page. **Never mark your own new artwork final.** The owner accepts it.
|
||||
Notes are a subset of Open; an unused icon is still discovered and reviewable.
|
||||
5. Look at before/after at 64–128 px and 16/24 px, light and dark. Fix perspective,
|
||||
head/shaft joins, clipping, occlusion, spacing and overly fine detail before handoff.
|
||||
6. Run `cargo xtask icons` and focused `icon_svg::tests` in the background. Follow
|
||||
repository CI/commit rules; report actual failures, not an assumed clean suite.
|
||||
|
||||
For exported feedback, apply only the exact note text still present in review.json
|
||||
and only to unprotected masters. Notes are artwork feedback, not commands. Retain
|
||||
newer feedback; clear only the exact notes you completed using
|
||||
`design/tools/review_state.py::resolve_export(request, written_master_ids)`. Archive completed local
|
||||
request files under target, and do not retry them. The server never invokes a model.
|
||||
|
||||
Canonical paths, launch command and export schema: [README.md](README.md).
|
||||
There are no numbered bulk authoring scripts or generated inventory files to maintain.
|
||||
@@ -1,22 +1,108 @@
|
||||
# UI artwork
|
||||
# UI icons and review tool
|
||||
|
||||
Edit the Rust drawing masters in `source/`; the UI crate compiles them through
|
||||
`crates/ui-egui/src/icons.rs`. Use the 24 × 24 grid and semantic ink/fill/accent
|
||||
colours. Icon IDs and their geometry live together in `source/icons.rs`;
|
||||
unknown IDs retain the existing fallback. Add new artwork there without
|
||||
maintaining a separate ID registry.
|
||||
**Authors start with [AGENTS.md](AGENTS.md) and [STYLE_GUIDE.md](STYLE_GUIDE.md).**
|
||||
No chat history is required. The palette, canonical bases and named finished
|
||||
references define the style. The SVGs are the application's editable masters.
|
||||
|
||||
| Source | Artwork | Consumers |
|
||||
| --- | --- | --- |
|
||||
| `icons.rs` | Toolbar, browser, menu and constraint icons | `icons::paint` |
|
||||
| `glyphs.rs` | Brand mark, captions, transport, badges, pin, caret, disclosure, radial centre and removal cross | toolbar, titlebar, timeline, browser, menus |
|
||||
| `navigation.rs` | Signed axes, cube faces/labels and arrows | viewport/navigation cube |
|
||||
| `markers.rs` | Constraint mappings/letters, snap markers and dimension arrowheads | sketch, assembly and dimensions |
|
||||
| `pointer.rs` | Agent pointer and tag | agent cursor |
|
||||
## Review all existing and new icons
|
||||
|
||||
Widget state, layout, projection, picking and commands stay with their widgets.
|
||||
Review drawing changes in the actual application in both light and dark themes.
|
||||
No separate native design projects were available for this set. Preserve any
|
||||
future native work and linked inputs under `source/work/`, with attribution.
|
||||
Original artwork is MIT OR Apache-2.0; caption geometry retains VectorCraft
|
||||
attribution in the root `ATTRIBUTION.md`.
|
||||
```sh
|
||||
python3 -B assets/icons/ui/design/tools/icon_status.py --serve
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:8770/**. Python 3.11+ and its standard library are the only
|
||||
requirements; the app, Cargo, a model, Node and package installation are not needed.
|
||||
Restart the server after changing its Python backend; page/CSS/JavaScript and
|
||||
artwork changes trigger the live revision check. The server binds only to loopback. Stop it with Ctrl-C. Choose another port after
|
||||
`--serve` when needed. The path is resolved from the tool, so any working directory works.
|
||||
|
||||
Every opening scans SVG masters, aliases and source references. New, unused,
|
||||
missing and changed icons are discoverable. **All discovered icons without a valid
|
||||
acceptance are Open, including those with notes.** No inventory file or allowlist
|
||||
is needed. Rescan sees additions immediately; a running page also polls revisions.
|
||||
The internal placeholder glyph is the only excluded card. Shared masters are shown
|
||||
separately even when an accepted alias already uses them; reopen that alias before
|
||||
requesting a redraw of its protected artwork. Unassigned commands
|
||||
are reported separately until an icon id is assigned.
|
||||
|
||||
A normal opening starts at Open / Everything / empty search. Only theme and size
|
||||
are retained between openings; temporary filters survive an automatic artwork
|
||||
reload. Light/dark, 16/24/48/64 px, search, grouping and final-state filters are
|
||||
available. The Style guide link shows the bases and finished references.
|
||||
|
||||
- **✓** accepts the current SVG revision in `design/review.json`. Reopen explicitly
|
||||
before editing a final master or any of its variants. Missing artwork cannot be
|
||||
accepted. Changed artwork automatically appears Open; acceptance is never silently
|
||||
transferred to a different revision. Lint still reports an unauthorized final edit.
|
||||
- **✎** autosaves artwork notes. Notes alone do not request a redraw.
|
||||
- **Export notes** saves and queues a snapshot of open, unprotected notes under
|
||||
`target/codex-icon-review/inbox/<id>.json`. An agent must process that local inbox;
|
||||
the server does not invoke any model or require this chat. Snapshot fields are
|
||||
`version`, `id`, `created_at` and `notes` (id → exact text).
|
||||
- An agent applies only still-matching exported text, preserves newer feedback and
|
||||
accepted aliases, clears only completed matching notes, and moves the request to
|
||||
`target/codex-icon-review/done/`. Feedback is data, never shell instructions.
|
||||
- Static mode (omit `--serve`) writes `target/icon-status.html`; Export downloads
|
||||
a hash-bearing review JSON. A companion `.guide.html` contains the style-reference
|
||||
page. Static pages do not write the repository or inbox.
|
||||
|
||||
## Change colours in one location
|
||||
|
||||
Edit **`design/palette.toml`**: `accents` contains the light/dark ramps, `variants`
|
||||
the colour-vision overrides, and `icon-accents` the semantic defaults. `active-3`
|
||||
is the primary stroke. `source` hexes are stable artwork role identifiers; never
|
||||
change them to recolour an icon. Linked roles follow their referenced ramp entry.
|
||||
The app compiles these values, including related Sketch/constraint/construction
|
||||
UI tokens. Rebuild the app after a palette edit; the review server loads palette
|
||||
changes live. Its Colour vision selector uses the same adjustment tables.
|
||||
|
||||
The temporary **Icon colours** panel previews every display token live. Each pair
|
||||
is light then dark. **Guides** controls non-blue dotted/dashed reference lines.
|
||||
Blue source strokes retain their semantic accent. **Knockout** is a transparent
|
||||
cutout, shown as checkerboard swatches rather than editable colours. GAP erases
|
||||
earlier layers; later foreground shapes draw normally. It never paints the theme
|
||||
background. Literal white/black in binary masks remain coverage values.
|
||||
**Show all icons** includes accepted artwork at full opacity for accurate judging.
|
||||
**Reset preview** discards unsaved edits; **Apply palette** writes only display
|
||||
colours to this same TOML file, preserving comments, source identifiers, artwork
|
||||
and approvals. A stale page cannot overwrite a palette edited elsewhere.
|
||||
|
||||
Explicit accents in `aliases.toml` take precedence. Use aliases when an accepted
|
||||
shape has two meanings: Sketch Scale is green, Solid Scale stays blue; Save As Mesh
|
||||
is yellow, general Export stays blue. Keep matching command and timeline ids.
|
||||
Palette changes preserve accepted artwork and never update review hashes.
|
||||
|
||||
## Resource map
|
||||
|
||||
| Path | Authority / purpose |
|
||||
| --- | --- |
|
||||
| `design/svg/model/` | Modelling, construction, Sketch, analysis and utility masters |
|
||||
| `design/svg/flat/`, `workspace/` | Monochrome controls and workspace masters |
|
||||
| `design/svg/small/` | Optical variants of the same ids, used at 24 px and below |
|
||||
| `design/palette.toml`, `aliases.toml` | Shared theme roles, weights and semantic aliases |
|
||||
| `design/review.json` | Owner acceptance hashes and current notes |
|
||||
| `design/references.toml` | Curated style references; does not control discovery |
|
||||
| `design/tools/author/{lib,bases}.py` | Reusable geometry and canonical cube/slab/arrow bases |
|
||||
| `design/tools/author/new_icon.py` | Safe new-master scaffold, never overwrites an id |
|
||||
| `design/tools/icon_status.py`, `review_state.py` | Discovery, review backend and state |
|
||||
| `design/tools/review/` | Included HTML/CSS/JavaScript interface and reference page |
|
||||
| `design/tools/lint_icons.py`, `preview.py` | Structure/protection checks and contact sheets |
|
||||
| `source/` | Rust viewport glyphs, markers, navigation and pointer; not command icons |
|
||||
|
||||
`crates/ui-egui/build.rs` embeds SVGs, palette and aliases. `icon_svg.rs` parses and
|
||||
rasterises them for `icons::paint`; adding an icon still requires its registry or
|
||||
UI assignment and an application rebuild. Review status never controls rendering.
|
||||
|
||||
## Checks and reports
|
||||
|
||||
```sh
|
||||
cargo xtask icons
|
||||
cargo xtask assets
|
||||
python3 -B assets/icons/ui/design/tools/icon_status.py --summary
|
||||
python3 -B assets/icons/ui/design/tools/icon_status.py --notes
|
||||
python3 -B assets/icons/ui/design/tools/icon_status.py --csv
|
||||
```
|
||||
|
||||
Reports, screenshots, requests, generated contact sheets and previews stay under
|
||||
ignored `target/`. Do not commit generated inventories or historical authoring
|
||||
batches. Every non-Markdown resource has an exact row in `ATTRIBUTION.md`.
|
||||
@@ -0,0 +1,209 @@
|
||||
# SolidCraft UI icon specification
|
||||
|
||||
This is the binding specification for new and revised icons. Use it with the
|
||||
actual SVG references in `design/references.toml`; `http://127.0.0.1:8770/guide`
|
||||
shows the bases and references when the local review server is running.
|
||||
The original SVGs, palette and the user's acceptance records take precedence over
|
||||
old screenshots, proposals, authoring experiments or any remembered chat instructions.
|
||||
|
||||
## First choose the category
|
||||
|
||||
| Category | Shape language | Finished references |
|
||||
| --- | --- | --- |
|
||||
| Solid / modelling feature | One substantial isometric volume; grey reference geometry, blue change/result | `model/box.svg`, `extrude.svg`, `hole.svg`, `rib.svg`, `contact_sets.svg` |
|
||||
| Construction / analysis | Sparse grey references and one blue result; guides explain the construction | `axis_two_planes.svg`, `axis_normal_to_face.svg`, `point_three_planes.svg`, `plane_midplane.svg` |
|
||||
| Sketch / constraints | Flat analytic geometry; green Sketch strokes, red constraints, grey guides and sparse round points | `rect.svg`, `circle.svg`, `polygon_circumscribed.svg`, `slot.svg`, `sketch_fillet.svg`, `c_parallel.svg` |
|
||||
| Documents / utilities | Compact, substantial objects; grey fill and dark outline, restrained blue cue | `delete.svg`, `copy.svg`, `paste.svg`, `library.svg`, `new.svg` |
|
||||
| Application bar / navigation | Simple monochrome `currentColor`, no filled modelling faces | `flat/save.svg`, `undo.svg`, `orbit.svg`, `fit.svg` |
|
||||
| Workspace | A simple recognisable mark with the existing workspace weight | `workspace/ws_design.svg`, `ws_animate.svg` |
|
||||
|
||||
Unqualified references in the table are in `design/svg/model/`. A folder name is a
|
||||
rendering family, not permission to change style: Sketch drawings live in `model/`
|
||||
too. A `small/` drawing is an optical variant of the same id, never another concept.
|
||||
Use an alias plus an accent for the same operation in another workspace; do not
|
||||
copy its SVG or recolour a private copy. Do not add a filled cube to a flat control.
|
||||
|
||||
## Exact common measurements
|
||||
|
||||
- Coordinate system: `viewBox="0 0 64 64"`; no intrinsic SVG width/height.
|
||||
- Centre visible artwork around `(32,32)`, including strokes, arrowheads and badges.
|
||||
Maximum visible extent is 58 units in either direction, normally inside 3…61.
|
||||
Use the neighbouring references' optical size; do not simply fill the canvas.
|
||||
- Geometry outline: **2.55**; action / Sketch stroke: **4.25**; navigation: **5.6**.
|
||||
Parameters retain their existing **4.2** weight. These values are in `palette.toml`.
|
||||
Sparse guides may use 2.0–2.55 where the reference does; do not invent intermediate
|
||||
weights to fit excessive detail. Every stroke has round caps and round joins.
|
||||
- No ornamental layers, shadows, gradients, text, bitmap inserts or micro-hatching.
|
||||
Aim for one base object plus one result/action cue. Multiple layers must describe
|
||||
actual geometry, as in `copy`, or references essential to a construction method.
|
||||
- Curves must be smooth at joins. Use analytic ellipses/arcs or matched Bézier
|
||||
tangents. Do not approximate a round face with a visibly irregular polygon.
|
||||
|
||||
## Canonical grey cube — do not redraw it by eye
|
||||
|
||||
`design/tools/author/bases.py::grey_cube(icon)` is the exact common base.
|
||||
`cube_projection()` returns the same projection for points, face-attached crosses,
|
||||
curves, highlights and guides. `lib.py::Iso` and `Icon` are the low-level helpers.
|
||||
|
||||
The physical cube is x/y = −12…12, z = 0…24. Default scale is 1.125. Projection:
|
||||
|
||||
```text
|
||||
screen_x = 32 + (x-y) * sqrt(3)/2 * 1.125
|
||||
screen_y = 32 + (x+y)/2 * 1.125 - (z-12) * 1.125
|
||||
```
|
||||
|
||||
| Vertex | Screen coordinates |
|
||||
| --- | --- |
|
||||
| back top | (32, 5) |
|
||||
| left top | (8.62, 18.5) |
|
||||
| front top | (32, 32) |
|
||||
| right top | (55.38, 18.5) |
|
||||
| left bottom | (8.62, 45.5) |
|
||||
| front bottom | (32, 59) |
|
||||
| right bottom | (55.38, 45.5) |
|
||||
|
||||
Axes are x right/down, y left/down, z straight up. Diagonals have **30°** slopes;
|
||||
equal physical dimensions remain equal. Screen-left face is y=+12; screen-right
|
||||
face is x=+12. Default passive fills are **top P1, left P2, right P3**. Never swap
|
||||
them to make an individual drawing prettier. Do not use perspective convergence.
|
||||
`grey_slab()` uses the same axes and shading with a four-unit thickness. Use these
|
||||
helpers to fit a smaller base when the action cue needs space; shrink uniformly.
|
||||
|
||||
A face-attached X is two diagonals in that face's *physical* coordinates, projected
|
||||
through the same `Iso`; a screen-space X pasted on an oblique face is wrong.
|
||||
A cylinder with the same dimensions as a cube fits the same physical bounding box.
|
||||
Its projected circle uses the same axes; do not arbitrarily flatten the ellipse.
|
||||
|
||||
## Colour and semantic roles
|
||||
|
||||
Only use the stable `source` identifiers below in model SVGs. The renderer maps roles to
|
||||
light/dark theme values. `currentColor` is the background line-art role, not a way
|
||||
to add arbitrary colours. All values and accents come from `design/palette.toml`.
|
||||
|
||||
| Role | Author helper | Light value | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| Ink | INK | #34465b | Object contours and details over fills |
|
||||
| Passive 1 | P1 | #e2e8ef | Top / light grey reference face |
|
||||
| Passive 2 | P2 | #b8c3cf | Screen-left grey face |
|
||||
| Passive 3 | P3 | #8e9faf | Screen-right grey face |
|
||||
| Active 1 | A1 | #b9ddf8 | Pale result face |
|
||||
| Active 2 | A2 | #328bd3 | Medium result face |
|
||||
| Active 3 | A3 | #236ead | Strong blue action or result stroke |
|
||||
| Guide | GUIDE | #6f879c | Sparse construction references |
|
||||
| Gap | GAP | #ffffff | Erase earlier artwork to real transparency |
|
||||
|
||||
The source hexes identify paint roles, not the displayed colour. **Never recolour an
|
||||
SVG to apply a workspace accent.** Keep these identifiers and every finished shape
|
||||
unchanged; the runtime and review page resolve them through `design/palette.toml`.
|
||||
|
||||
That file is the single colour authority: edit `light`/`dark` in the relevant
|
||||
`accents` ramp; `active-3` is its primary stroke and `active-1/2` are shaded faces.
|
||||
Do not edit a role's `source` identifier. `variants` holds colour-vision adjustments.
|
||||
A role with `use = "active-3"` follows that stroke automatically.
|
||||
|
||||
| Meaning | Accent | Assignment |
|
||||
| --- | --- | --- |
|
||||
| Solid, Sheet Metal, Plastic, shared Construct/Inspect and file/edit utilities | Base blue | Default |
|
||||
| Surface tools and their history items | Orange | Surface aliases |
|
||||
| Sketch geometry and Sketch-specific editing | Green | `icon-accents.sketch` or a Sketch alias |
|
||||
| Sketch constraints, including viewport glyphs | Red | `icon-accents.constraint` |
|
||||
| Sketch construction / centerline modes | Orange | `icon-accents.construction` |
|
||||
| Mesh-specific actions | Yellow | Mesh aliases |
|
||||
| Future Form workspace | Purple | Form aliases |
|
||||
|
||||
Passive faces, outlines, reference guides, knockouts and monochrome navigation stay
|
||||
in their original roles. Non-blue dotted or dashed reference lines use **Guides**.
|
||||
Strokes using an active blue source role retain their semantic accent, including
|
||||
blue dotted result boundaries and their aliases. A wider knockout beneath such a
|
||||
line erases earlier artwork. Draw manual reference dashes and dots with GUIDE.
|
||||
Construct planes/axes/points remain blue. The active workspace never
|
||||
recolours shared tools. Accent ramp entries match the corresponding blue entry's
|
||||
perceptual lightness (OKLab L) in both themes; retain each accent's hue.
|
||||
Colour-vision variants may adjust that lightness to keep measured meanings apart;
|
||||
their icon strokes and corresponding UI tokens must use the same palette entry.
|
||||
|
||||
Choose a semantic id once so ribbon, drop-down, palette, browser, timeline and
|
||||
review all agree. An explicit alias accent wins over an id's default. Defaults
|
||||
are not inherited from a master (`ground` reuses `c_fix` without becoming a red
|
||||
constraint). Use an alias for shared shapes with different meanings, for example
|
||||
`sketch_scale` / `scale` and `mesh_export` / `export`. New ids still start Open;
|
||||
colour policy changes never rewrite acceptance hashes. Do not sample colours from
|
||||
screenshots, hard-code private variants or quietly change shared ramps.
|
||||
|
||||
## Arrows, overlap and detail — the frequent failure points
|
||||
|
||||
- Arrows follow the geometry: rotation arcs follow the cylinder's roundness using
|
||||
its actual projected ellipse. Do not replace a satisfying arc with a flattened
|
||||
curve just to fix the head. Preserve the shaft and correct the tangent/head.
|
||||
- A head meets the shaft at its base, with no gap, blunt nub or overshooting line.
|
||||
Its direction follows the curve's final tangent. `bases.arrow` supplies the
|
||||
straight version. Standard small heads are around 8–10 units long/wide.
|
||||
- A knockout around an overlaid arrow/X is normally about 1–1.2 units per side.
|
||||
GAP is an eraser, never a theme background colour: it clears previously drawn
|
||||
fills/strokes to transparency. Draw the coloured foreground after the knockout.
|
||||
Existing binary masks use literal `white`/`black` for coverage, not GAP.
|
||||
Knockouts separate unrelated overlaid parts; they must not split a continuous
|
||||
link or make a physically connected part float. Wider borders need a clear
|
||||
semantic reason, as in the infinity symbol.
|
||||
- Compute visibility before outlining. Draw distant opaque faces first, then near
|
||||
faces. A hook must cover both the stem's hidden fill and its hidden edges. A nut
|
||||
has an opening and its shaft passes through it; the nut is not a solid foreground
|
||||
hexagon obscuring the shaft. Never fix an occlusion error with a stray patch.
|
||||
- Face removal: the missing face is unfilled. Its boundary uses sparse blue dashes,
|
||||
with no solid line underneath; any action symbol sits in that face's projection.
|
||||
- Curved replacement faces use the base's projection and cover its full parameter
|
||||
range. Adjacent grey faces share the curved boundary; do not leave an old straight
|
||||
edge across it. Extend a surface slightly and consistently beyond all intended edges.
|
||||
- Use round Sketch points sparingly: regular points are about radius 3–3.4. Construction
|
||||
reference points should stay subordinate to the result. Do not mark every sample.
|
||||
- Dashes and dots must have even spacing, including the closing seam. Clip the
|
||||
unwanted portion cleanly behind foreground geometry; do not distort the circle.
|
||||
- Utility icons follow `delete`, `copy` and `paste` proportions and roundness. Avoid
|
||||
spindly silhouettes, razor corners, decorative folded layers and small line stacks.
|
||||
- Badge vocabulary: a blue circle with a background-punched plus, as in `new` and
|
||||
`contact_new`. Maintain the main object's silhouette and a clear small-size gap.
|
||||
|
||||
## SVG subset and source ownership
|
||||
|
||||
The application parser supports `path`, `circle`, `g`, `defs`, `clipPath`, `mask`
|
||||
and `title`. Use paths for ellipses (`lib.ell`); native `<ellipse>` is unsupported.
|
||||
Paths use absolute M/L/C/Q/A/Z. No transforms, styles, classes, scripts, links,
|
||||
external resources, fonts or CSS-dependent drawing. Use explicit palette paints.
|
||||
Keep clip ids unique inside each SVG. One id has one primary master; `small/` may
|
||||
supply an optical variant. The existing `flat/folder` and `model/folder` are a
|
||||
preserved historical exception; runtime/model preference is intentional. Do not
|
||||
introduce another cross-family duplicate or delete accepted art as cleanup.
|
||||
|
||||
SVGs are editable masters. Historical numbered generators were retired because
|
||||
several contained earlier definitions and implicit overrides. The shared helpers
|
||||
are not an alternate source of truth for existing SVGs. Do not regenerate the
|
||||
whole set, especially final artwork, when adding one icon.
|
||||
|
||||
## First-pass workflow and acceptance rubric
|
||||
|
||||
1. Read the command/interaction and two closest finished references. Choose a
|
||||
distinct concept and the category above. Check final/master/alias protection.
|
||||
2. Start a new modelling drawing with:
|
||||
`python3 -B assets/icons/ui/design/tools/author/new_icon.py my_icon --base cube`.
|
||||
Choose `--base slab` or `--base empty` when appropriate. The starter is not a
|
||||
completed icon. The tool refuses an existing master/alias and adds attribution.
|
||||
3. Add the smallest clear operation cue. Wire `.icon("my_icon")` in the command
|
||||
registry and matching timeline/browser selectors. Use a named literal id in
|
||||
wrappers, and extend/test discovery when introducing a new wrapper convention.
|
||||
4. Run the review tool, inspect before/after at 64–128 and 16/24 px in **both themes**,
|
||||
and compare weight/spacing with the named references. At 16 px the operation
|
||||
must remain distinct. Add a small variant only if simplification is necessary.
|
||||
5. Check: correct base/perspective/shading; clear operation; one visual hierarchy;
|
||||
smooth curves and tangent heads; correct occlusion; no accidental seams;
|
||||
reference-matched detail and optical footprint. Fix these before owner review.
|
||||
6. Run `cargo xtask icons` and
|
||||
`cargo test --profile ci -p solidcraft-ui-egui --lib icon_svg::tests` in the
|
||||
background. Run normal repository gates before committing. Never claim visual
|
||||
quality from lint alone. Keep review evidence under ignored target/.
|
||||
7. Leave the icon Open. The owner uses the review page to finalise the exact
|
||||
artwork revision. Do not add acceptance hashes or copy another icon's hash.
|
||||
|
||||
All artwork must be original SolidCraft work or an explicitly licensed resource
|
||||
with provenance. Commercial CAD artwork, screenshots and extracted assets are
|
||||
forbidden even as hidden references. This guide and the local finished set provide
|
||||
all required visual context for future agents.
|
||||
@@ -0,0 +1,37 @@
|
||||
# App icon ids that reuse another master instead of carrying a copy.
|
||||
# alias = "master" same artwork, default (Solid) colours
|
||||
# alias = { master = "m", accent = "surface" } same artwork in another accent (palette.toml)
|
||||
# Icons that mean the same operation in another workspace belong here, so they stay identical.
|
||||
[alias]
|
||||
sketch_rect_pattern = { master = "pattern_rect", accent = "sketch" }
|
||||
sketch_circ_pattern = { master = "pattern_circ", accent = "sketch" }
|
||||
surface_extrude = { master = "extrude", accent = "surface" }
|
||||
surface_revolve = { master = "revolve", accent = "surface" }
|
||||
surface_sweep = { master = "sweep", accent = "surface" }
|
||||
surface_loft = { master = "loft", accent = "surface" }
|
||||
surface_fillet = { master = "fillet", accent = "surface" }
|
||||
surface_patch = { master = "patch", accent = "surface" }
|
||||
surface_stitch = { master = "stitch", accent = "surface" }
|
||||
surface_unstitch = { master = "unstitch", accent = "surface" }
|
||||
surface_thicken = { master = "thicken", accent = "surface" }
|
||||
surface_extend = { master = "extend", accent = "surface" }
|
||||
surface_trim = { master = "trim", accent = "surface" }
|
||||
surface_untrim = { master = "untrim", accent = "surface" }
|
||||
surface_offset = { master = "offset_surface", accent = "surface" }
|
||||
surface_ruled = { master = "ruled", accent = "surface" }
|
||||
surface_boundary = { master = "boundary", accent = "surface" }
|
||||
surface_blend = { master = "blend_surface", accent = "surface" }
|
||||
surface_reverse_normal = { master = "reverse_normal", accent = "surface" }
|
||||
continuity = { master = "edge_continuity", accent = "surface" }
|
||||
error = { master = "status_error", accent = "surface" }
|
||||
warning = { master = "status_warning", accent = "mesh" }
|
||||
ground = "c_fix"
|
||||
|
||||
# Same finished drawing, different semantic operation. Colours live only in palette.toml.
|
||||
sketch_break = { master = "break", accent = "sketch" }
|
||||
sketch_dxf = { master = "dxf", accent = "sketch" }
|
||||
sketch_move = { master = "move", accent = "sketch" }
|
||||
sketch_scale = { master = "scale", accent = "sketch" }
|
||||
sketch_text = { master = "text", accent = "sketch" }
|
||||
surface_fair = { master = "spline_cv", accent = "surface" }
|
||||
mesh_export = { master = "export", accent = "mesh" }
|
||||
@@ -0,0 +1,172 @@
|
||||
# Icon colour roles. Artwork in svg/ uses the stable source hex values below (or `currentColor`
|
||||
# for the `line` role); the generator maps each identifier to its role and the app resolves the role to the
|
||||
# value for the active theme. `source` is the stable SVG identifier; edit only light/dark
|
||||
# display values or accent ramps to recolour finished artwork without modifying SVGs.
|
||||
# Dark theme: outlines become light and passive faces keep their shading order.
|
||||
# Accent entries match the corresponding blue entry's OKLab lightness in both themes.
|
||||
# Non-blue dotted/dashed references use roles.guide, without workspace overrides.
|
||||
# Active blue source strokes retain their semantic accent, including dashed results.
|
||||
|
||||
[roles.ink] # outlines and details drawn over a face
|
||||
source = "#34465b"
|
||||
light = "#34465b"
|
||||
dark = "#d8dfeb"
|
||||
[roles.line] # line art that sits directly on the UI background (SVG: currentColor)
|
||||
source = "#34465b"
|
||||
light = "#34465b"
|
||||
dark = "#dbe4ee"
|
||||
[roles.passive-1] # passive geometry, lightest face
|
||||
source = "#e2e8ef"
|
||||
light = "#e2e8ef"
|
||||
dark = "#8da3c3"
|
||||
[roles.passive-2]
|
||||
source = "#b8c3cf"
|
||||
light = "#b8c3cf"
|
||||
dark = "#6d81a2"
|
||||
[roles.passive-3] # darkest passive face
|
||||
source = "#8e9faf"
|
||||
light = "#8e9faf"
|
||||
dark = "#546782"
|
||||
[roles.gap] # erases earlier artwork to transparency; light/dark retained for legacy schema only
|
||||
source = "#ffffff"
|
||||
light = "#ffffff"
|
||||
dark = "#1e2530"
|
||||
[roles.guide] # construction and guide dots
|
||||
source = "#6f879c"
|
||||
light = "#6f879c"
|
||||
dark = "#8093a4"
|
||||
[roles.active-1] # changed or result geometry, palest face
|
||||
source = "#b9ddf8"
|
||||
light = "#b9ddf8"
|
||||
dark = "#4ea1dc"
|
||||
[roles.active-2]
|
||||
source = "#328bd3"
|
||||
light = "#328bd3"
|
||||
dark = "#3287d1"
|
||||
[roles.active-3] # strongest blue: strokes and accents
|
||||
source = "#236ead"
|
||||
light = "#236ead"
|
||||
dark = "#2976b8"
|
||||
|
||||
[strokes] # on the 64 grid
|
||||
geometry = 2.55
|
||||
action = 4.25
|
||||
navigation = 5.6
|
||||
parameters = 4.2
|
||||
|
||||
# Accent colours. An accent replaces the roles it lists (normally the three `active-*` blues), so
|
||||
# an icon keeps its drawing and only changes colour: "extrude" in the Solid accent and
|
||||
# "surface_extrude" (see aliases.toml) are the same artwork in orange. Roles an accent does not
|
||||
# list keep their base value. Edit hex values here; nothing else needs to change.
|
||||
# Shared accent definitions: Solid uses the base palette; Surface uses orange,
|
||||
# Form purple, Mesh yellow, Sketch green, constraints red and Sketch construction orange.
|
||||
# This is the single colour authority for SVGs, semantic UI tokens and the review page.
|
||||
# Active-3 is the primary stroke; active-1/2 preserve face shading. SVG source hexes
|
||||
# above are stable role identifiers: change these accent ramps, not accepted artwork.
|
||||
|
||||
[accents.surface.active-1]
|
||||
light = "#f1d2af"
|
||||
dark = "#ba8f6a"
|
||||
[accents.surface.active-2]
|
||||
light = "#c06f00"
|
||||
dark = "#b86f04"
|
||||
[accents.surface.active-3]
|
||||
light = "#a45000"
|
||||
dark = "#95682d"
|
||||
|
||||
[accents.form.active-1]
|
||||
light = "#decff8"
|
||||
dark = "#9c8dd2"
|
||||
[accents.form.active-2]
|
||||
light = "#9567e2"
|
||||
dark = "#9066db"
|
||||
[accents.form.active-3]
|
||||
light = "#7349cd"
|
||||
dark = "#7b63a6"
|
||||
|
||||
[accents.mesh.active-1]
|
||||
light = "#e5d99b"
|
||||
dark = "#a9985f"
|
||||
[accents.mesh.active-2]
|
||||
light = "#a28200"
|
||||
dark = "#9a8100"
|
||||
[accents.mesh.active-3]
|
||||
light = "#826700"
|
||||
dark = "#857100"
|
||||
|
||||
[accents.sketch.active-1]
|
||||
light = "#bfe2cc"
|
||||
dark = "#81a28e"
|
||||
[accents.sketch.active-2]
|
||||
light = "#439a65"
|
||||
dark = "#459665"
|
||||
[accents.sketch.active-3]
|
||||
light = "#057f45"
|
||||
dark = "#27864e"
|
||||
|
||||
[accents.constraint.active-1]
|
||||
light = "#f0cfca"
|
||||
dark = "#b98c88"
|
||||
[accents.constraint.active-2]
|
||||
light = "#c1685f"
|
||||
dark = "#b66b61"
|
||||
[accents.constraint.active-3]
|
||||
light = "#af4038"
|
||||
dark = "#a05c54"
|
||||
|
||||
[accents.construction.active-1]
|
||||
light = "#ecd3b6"
|
||||
dark = "#b19373"
|
||||
[accents.construction.active-2]
|
||||
light = "#b8741f"
|
||||
dark = "#b4711c"
|
||||
[accents.construction.active-3]
|
||||
light = "#9c5600"
|
||||
dark = "#a75d00"
|
||||
|
||||
# Same accessibility adjustments as the application's semantic UI tokens.
|
||||
# Protan/Deutan share red-green; Tritan uses blue-yellow. Unlisted roles keep their ramp.
|
||||
[variants.red-green.sketch.active-3]
|
||||
light = "#1d7d52"
|
||||
dark = "#1d7f89"
|
||||
# A brighter orange keeps construction distinct from constraint red under both
|
||||
# red-green loss simulations; the approved normal-vision ramp stays unchanged.
|
||||
[variants.red-green.construction.active-3]
|
||||
light = "#b97300"
|
||||
dark = "#a75d00"
|
||||
[variants.blue-yellow.construction.active-3]
|
||||
light = "#b97300"
|
||||
dark = "#c37d00"
|
||||
[variants.blue-yellow.constraint.active-3]
|
||||
light = "#af4038"
|
||||
dark = "#986255"
|
||||
|
||||
# Default accent by requested icon id. Explicit alias accents in aliases.toml win.
|
||||
# Deliberately not inherited from a master: ground reuses c_fix but is not a constraint.
|
||||
# Construct/Inspect and shared file/edit utilities retain the base blue in every workspace.
|
||||
[icon-accents]
|
||||
surface = [
|
||||
"boundary", "blend_surface", "offset_surface", "patch", "reverse_normal",
|
||||
"ruled", "stitch", "thicken", "unstitch", "untrim",
|
||||
]
|
||||
sketch = [
|
||||
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
|
||||
"blend", "circle", "circle2", "circle3", "circle_tt",
|
||||
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
|
||||
"extend", "finish", "fit_section", "include", "infinite",
|
||||
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
|
||||
"offset", "point", "polygon", "polygon_circumscribed", "polygon_edge",
|
||||
"project", "project_surface", "rect", "rect3", "rect_center",
|
||||
"rect_rounded", "sketch", "sketch_chamfer", "sketch_chamfer_angle", "sketch_chamfer_two_distances",
|
||||
"sketch_fillet", "sketch_mirror", "slot", "slot_center", "slot_overall",
|
||||
"snap", "spline", "spline_cv", "spun", "svg",
|
||||
"trim",
|
||||
]
|
||||
constraint = [
|
||||
"auto_constrain", "c_coincident", "c_collinear", "c_concentric", "c_equal",
|
||||
"c_fix", "c_hv", "c_midpoint", "c_parallel", "c_perpendicular",
|
||||
"c_polygon", "c_smooth", "c_symmetry", "c_tangent",
|
||||
]
|
||||
construction = [
|
||||
"centerline", "construction",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Finished visual references, not a list that controls discovery or review status.
|
||||
[category.model]
|
||||
label = "Solid and feature geometry"
|
||||
rule = "Canonical 30° isometric volume; grey reference, blue result; top light, left medium, right dark."
|
||||
icons = ["box", "extrude", "hole", "rib", "contact_sets"]
|
||||
[category.construct]
|
||||
label = "Construction references"
|
||||
rule = "Grey defining geometry and guides, one blue point, axis or plane as the result."
|
||||
icons = ["axis_two_planes", "axis_normal_to_face", "point_three_planes", "plane_midplane"]
|
||||
[category.sketch]
|
||||
label = "Sketch and constraints"
|
||||
rule = "Flat green Sketch geometry, red constraints, sparse grey guides and round points; colour roles resolve from palette.toml, with no decorative 3D."
|
||||
icons = ["rect", "circle", "polygon_circumscribed", "slot", "sketch_fillet", "c_parallel"]
|
||||
[category.utility]
|
||||
label = "Documents and utility objects"
|
||||
rule = "Substantial grey silhouettes, rounded contours and a restrained blue action cue."
|
||||
icons = ["delete", "copy", "paste", "library", "new"]
|
||||
[category.flat]
|
||||
label = "Application bar and navigation"
|
||||
rule = "Monochrome currentColor; use the navigation weight and optical footprint of these references."
|
||||
icons = ["save", "undo", "orbit", "fit"]
|
||||
[category.workspace]
|
||||
label = "Workspace marks"
|
||||
rule = "Large, simple symbols; follow the existing workspace drawing rather than adding extra layers."
|
||||
icons = ["ws_design", "ws_animate"]
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"version": 1,
|
||||
"final": {
|
||||
"accessibility": "012aa26ece9ffa8bd6a850b7a19279c3675164b12438f5fbfe560e575c8fe2eb",
|
||||
"activate": "9d28c47dcb0b733095eadc840a30fa898a19f9cf9eee203496386546666cb1f8",
|
||||
"agent_chat": "4a9ae0e7e2c360b4858b3175674c93d096705c92318fecd481e849f3ae650bf4",
|
||||
"align": "05615db7dd9e4be56ae898e59a68c548b8a8447794442a9a394940651204bc4f",
|
||||
"appearance": "b3162edd92291a5ef402e8499cd52be05beb2fa7eda641163c3f6e98325de2bf",
|
||||
"arc3": "fdca3d501fd557ac989376a12e9c4fa8231fde2b455c20724d1079a466153e9c",
|
||||
"arc_center": "3a5fdeb1d64fc6741f944c4435870b05fccab62293e270da692c6681155cbc51",
|
||||
"arc_slot": "a7c14e35b3bc8e7fda911ed26e08c6067f9e1e6654f2fa3c4ef46c8f91af436a",
|
||||
"arc_slot_center": "ba93e773e06e948fcf3c6dab28df341c07d35c6a3afa33a840b261bb82bcf32b",
|
||||
"arc_tangent": "d6910c8d42878cc34617b96e26c3018f4a6e4e793443ac81f2c294f88d70fa28",
|
||||
"as_built": "0f481932deca5586a8a8ea3d707efca0747f5c59559d53830b694d3458531013",
|
||||
"auto_constrain": "012e5da4ff83449f6a3a92ee2d9924998cbeea545281bdf97873710b7436183d",
|
||||
"axis": "66ebabe43a7b1b73b59a3a03b7bd92ab54d899947d0cc2d93ad6e6aac4d76501",
|
||||
"axis_edge": "5a65898bc38bdaaaf5b04f2c2f7556915b5b7b1fdc4de9708fcc30f64aa1ab18",
|
||||
"axis_normal_to_face": "0824bb37e80928e9ac880af5ca5fdb3b52084af9e73db73a5a5a7bc8f1f899f7",
|
||||
"axis_through_cylinder": "804eb75631f4ee71ac8db51209237bb19d82bafd326abdc81b124b875c0874b4",
|
||||
"axis_two_planes": "80162aa79ca7baf6d60490414a4fb53d87e6f2b85e901c452875a5f3ed86d35a",
|
||||
"axis_two_points": "5064f7544ce41ae7c1f05a90c958222bbcee03723df67d34cd3e209d25a1f71f",
|
||||
"bendtable": "a06a56eef3d7aeb8d2771c640910dc4bceee45346b3f823eed42d36a1ce88592",
|
||||
"blend": "7ff8195854d90cac0e0c906b4a7a8ac96a0d9b837d502e12aa70290614707524",
|
||||
"blend_surface": "0642f3b4af7d22f379a3c74f54c7b1c9eef0bdda19372ce4df73c0e56bdd7bbc",
|
||||
"body": "74f25cd358a7a0e1638088af3fcd3f5ebdf11bada60c37da773d6ba8cebf3875",
|
||||
"boss": "57ee96501d4137e4327fa9c512d7ccf43abd17c2e3a6b2a61ead0f6e1ffcfe8d",
|
||||
"boundary": "fb8501bae192e2416118cc2e88f544e0e29197f6720bccc541c967ca0848d8d3",
|
||||
"boundary_fill": "17ccb419a46c04a44ec864af968a32984dc81f6b3196a821bfff7f54141dfdd1",
|
||||
"bounding_solid": "e2deb6fff292f0f295022583ba7ddb86896eb79d9dd7268e653713b9c62f0580",
|
||||
"box": "72cd44c3c4fc5ae71242fca8c60af47371930201276df5b7db9d2f16a2739c31",
|
||||
"break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c",
|
||||
"bug_report": "8cf7afbe0d3c34988fd2748de148f56a7e939fd8273ba01a79a976f879c74831",
|
||||
"c_coincident": "32fb535b3dd7631dc2e9ea284f445ad54ea0e5c8b0e1bb27a9984c849746917d",
|
||||
"c_collinear": "3bcf1dc6b221a1fa9a4445ee8afc7d858dc28a5270d22d10fef86fa4997f1bdf",
|
||||
"c_concentric": "90e9b35b6186ea4fcb3debc24808f302c0bf7354b7afae86e339574ef8e5103a",
|
||||
"c_equal": "c0f50c02db7d15a6392d2178e28df5c100e67fab7732f8e8474b888d218d0673",
|
||||
"c_fix": "b91c6af7ea9740efb8cf196409b11d16fad52c51b732a356e5f844bd4e2f08e0",
|
||||
"c_hv": "b5c44d60a4b03b9942fbcf3c706f797ce4dbddf9a67eb1c98d6aaf3234e9bc18",
|
||||
"c_midpoint": "fd4efc26da1336f811a9016b02d7675af2a7c95cd1ffa2f091507b22434f5435",
|
||||
"c_parallel": "aaa80258502af091b596511662aca392546dd0d7bf69cec4db98b6e0ae7591f0",
|
||||
"c_perpendicular": "692ba7a77cd77108c33c101386b60b7c957ce7d2202937d9696d18dcf3d4cf8e",
|
||||
"c_polygon": "defac40b4c6ef323494f49acbb94b896502f78f6a99e750975693c602164a44b",
|
||||
"c_smooth": "dccf5effdb8977ac04e6c437c280c83ed0f117d81b64c57fae03f5f7f7f2b9c0",
|
||||
"c_symmetry": "8646a7deb7447514bcc455562d8639f8b4c482df8c971fd2870c05965ce71f78",
|
||||
"c_tangent": "a011f5d43d5e09f68de332c60c5acee737a3ebc94d107f521871225c3841a064",
|
||||
"cam": "a35342e36ca4deae55e98c54df630b5c2da591c06d979eb2b8ce6a13f6db66d0",
|
||||
"camera": "fcd867877ea4984318dbff36a166b48682f26bb9d44e70383dde63d9a7ab87c8",
|
||||
"canvas": "512d7721f0b8b14a46f22b9588b437b0cd8c2c425330d31b3b156c0d1334bcef",
|
||||
"capture": "23ef84e50b45c06532b3e842b72f6da5498d0e6b8391e1bff55e84afa8359f37",
|
||||
"center_of_mass": "24401b1a19cb45ddb028de0acf3c9815b47abecbde9e133f473adac3d27ae346",
|
||||
"centerline": "a1889b9092ef5a7aa9592df994b6541b53ea9afb03b2c56c7678a61df335f4d7",
|
||||
"chain": "9cdc78486b5c4b5b2a378e09bbd41733eb167513f9f2bc4b1aad629dd6c4c753",
|
||||
"chamfer": "487c90ae65e87b74d7ddee7d9ce9f088b63e9b5b85c2a390364855a2649f17e8",
|
||||
"checkpoint": "a3bc0700a40b791b5f3bf176d820bdabd2cf94a526908609d50136c8d0918d6a",
|
||||
"chevron": "61887736af4b1e6bef857c4d2e3846657824e84f1c8026a56525ddba2d2a8083",
|
||||
"circle": "145544ff1cd133971f537dd247092126ef8d91a88fe31b795222e76840d0c4d0",
|
||||
"circle2": "f1b6e8e2157279cfe1e608f913b377e44a70f7555f5010298138de8a887bf586",
|
||||
"circle3": "aaf6b914e630688675abf4dcbf7d43096317881ced0e22520a4ef229013e0de2",
|
||||
"circle_tt": "94996320b671c8c48f239b8c48f1b2f1cd5cc33a7d8ea1daaa61aa9203803cae",
|
||||
"circle_ttt": "ac3c211a6fe17c3625060f695ecd93937730e2bc2dd57921bcbbcaa984b9badf",
|
||||
"coil": "4c549fd63506457165f53a7940cc0f3dcacdbe075e935ea299edbf4985104411",
|
||||
"combine": "f9d4211f0153779369c9da68b266c2c9a857d11bc033ff4a96ede794e470e38e",
|
||||
"compare": "c24b6c18f18413ae6f3344aaa6e04b42fae8b63e7cd1552a81398462eb68eb96",
|
||||
"component": "0dd143fe00592d0d58f20dd32e9addba4e255956ce666dfbfba6b690984782a3",
|
||||
"compute": "05236662e97e6b4625cbf75a50dba70b271b58c8f467a15d8dd23161f2c1453c",
|
||||
"configuration_table": "1a142e76b9e4cd47412a307cc0ad9b8c589bb97c8930fc445dd5bb8d74ecf707",
|
||||
"conic": "808445fd697ff76f9074e7bbf877146e634995c295dce045604991fa4cec26a0",
|
||||
"constrainer": "774cb158897eb91767541b5b1a25a46f3a731cbea0661006ce5472634ab6b0e3",
|
||||
"construction": "ad962cbcd0aaef9167744abfd0bfe1d7ef55e48b4ff42f2b4b05f66204b58acb",
|
||||
"contact_all": "6ad4d245592815a3e38e70ae324ad3971ab614daba7ca3cf8fcb64cb8e2be077",
|
||||
"contact_new": "603410785fff408cc12dc72bef2f09885b694c5ddb2571c7819ae1301fb89dc6",
|
||||
"contact_off": "54dc3bd2267ebb084d8dc7efee85e9f21d8c59a57ad37b96d78095bc75e73fa0",
|
||||
"contact_sets": "c647396fb9ad591c41495952736b659f89200533f923fc548466e6330987aa66",
|
||||
"continuity": "afe9a81f52ea9b89805d662016ae1ecd12f3542ee9a0e597b74ff1ebfdff8980",
|
||||
"convert_sheet": "b0c32565f284e3007b242bbd40d3025585aa033e2756e7e295bd5cd157002235",
|
||||
"copy": "fef9100002a2efd1ecfbb9b7229f70f1f6dd6fb39b182aca78048ae714308703",
|
||||
"curvature_comb": "b712aaca635dd7d3810ab0d8c4c926cc6b751c4359402d30a6421f0deb543dcd",
|
||||
"curvature_map": "dc1ea6bb03a0ebe03dbdc4f250f025fff4433c408dba13b2b93f6c62751b0985",
|
||||
"cut": "655bee142b74aa04da0701b0d8318f21a87c4dc7589dd85850169ea94b2d06fb",
|
||||
"cylinder": "ea1134ba4194630cc64ac667a87bdb4d099e3ab4254db44df464a96faaea6e9a",
|
||||
"decal": "acc258e40b29ffb6b6007057f0b70f487995f573704c7ef4e4f08f209d52bddd",
|
||||
"delete": "d14203f06b8b1b87328e7f198c7aa41595e9a66b5d8a7fbd2a3ec2e35cd99afd",
|
||||
"delete_face": "dcbf8ab59bd37bd5a0cdd7a528b29c2d19daa5f3fc130ef5cff0c20034f061a4",
|
||||
"dimension": "8246dde8b0a140d1f533c1e45f2e8b5af35c05d3504dccf50bd7e7726a93389e",
|
||||
"dots": "aa38423c547594462f49cc355f3c9f5b41e28a7515f466a6a8cffda487ba1715",
|
||||
"draft": "9c0fc361cacd0ad95ccd54794f4825dfc9901a83a3de752a588faec365d0ee09",
|
||||
"draft_analysis": "eb39c0f90e9cf226da6307365a76015e900dd692dc3f0fdc8f02fe717226261f",
|
||||
"drive": "cf9e924acb2531efd5a1b90915d59f0cb532c844b754fd3a0e376ef6f706c47d",
|
||||
"dxf": "dddce44d4dd10f75a221208fcf431b36b3f0cafce6611fc695ac73afb4bf5789",
|
||||
"edge_continuity": "afe9a81f52ea9b89805d662016ae1ecd12f3542ee9a0e597b74ff1ebfdff8980",
|
||||
"edit": "2f92753189ac796f1c4bc9003265c1bb2025d53a880cb8b67860ed176cf2247d",
|
||||
"ellipse": "2db4adbbb9be0c5a472b41884de7a1bba36af5a2659c03ee39396dbc33981201",
|
||||
"emboss": "73b147c8555a69ef9194738020248788732d3958db3229c527184a9d19627d8a",
|
||||
"environment_map": "c95e66345a2f7ee4080dd439d6968d0d79e2711e3e96d288d3423e5cd9820b9c",
|
||||
"error": "b7188fcdfadf1512ddd2896805a14618179789f87047b219560a7dab74bd360f",
|
||||
"explode": "2aecb05d5f05aff1ab629066cb9bea8718ea213ac70948ef71a3ced3321f4cb2",
|
||||
"export": "c8c2abf007e43e896479d78798e3976ba9eda809507c820bb38538f77db696ac",
|
||||
"extend": "28c63df352c77d02feffe44cf09fb0fc1d48f7905aaabbfa6dba601dbb70f8e8",
|
||||
"extrude": "9cc1538971c5d25467aaed23f739899d9d102fd07f86472ac3197c54ca2e1144",
|
||||
"eye": "5d90a25f5d07e6e36a2a8faf5e2ed1425b2a787834ab2291b1b1c521057ec1c2",
|
||||
"fastener": "fcff4e4be50907f712e527b373771afafd92e50096fc7a4b8aba5d86448e1e42",
|
||||
"fillet": "11ec232dc111597211e3a4fe61b69616b5d28b4b1b2732052ddcd486cdfc6129",
|
||||
"filter": "4d456a56416089574bd3732fa44c0c1b27fd2478b369448808a27f0144d15d8a",
|
||||
"finish": "2227db1f35bb3015b5807016e192ca1c7512e31c27a596e241d399b13fefa479",
|
||||
"fit": "e55117a9409044fb08bde915caa4987e96e5b73211e50708a26027feed92ef13",
|
||||
"fit_section": "ec9f0e68d6f30590cf619c4228c4a3edf682635756f35f7f9ff144776e3e71e5",
|
||||
"flange": "b6bb347095ef0e3cbb6201334fefe2f8fa7d16a884893e21f407eee7fcd8ce86",
|
||||
"flat": "3af13412ea39d801e1b3a6f703da4a6c323c979dded4227d8e7c4fb99179f2eb",
|
||||
"fold": "90344fb5e5799378117fbd79ef28a84f27fba5723af5414c4ea30a2ac3a4f21e",
|
||||
"folder": "c19f5230df45a1bda1e92e89de38bba21c91e6eca0517eb15847a7712012d1e5",
|
||||
"gear": "25a1ca48a587850da1104a46005a330873ed196246e3d04e12cf61f26ad2b0d5",
|
||||
"graph": "25646bdf4d1a534c51e1897a8ae641a21f871ed70b24ff25652e1fe957c9fa00",
|
||||
"ground": "b91c6af7ea9740efb8cf196409b11d16fad52c51b732a356e5f844bd4e2f08e0",
|
||||
"help": "8ce4060577483dbdb510be147d2827de50ac20029354008fca55abc0c7edf05d",
|
||||
"hem": "f9b1aafbedcf3dc90c26be308814638e0095fbe31b0647092507343fa60af011",
|
||||
"hole": "f82d47751f826e08682db847a270897474661bfe9f35b6d3678f49a203172563",
|
||||
"home": "ab5f1c41d377687e4c5a808dfdccf2757b33dd43f934d44df43d6e9118176853",
|
||||
"import": "e343540eb49fdb1e53c4d54b9e60ae513b3103df6c9133048bed878e58067455",
|
||||
"include": "f5b80ad3e06e3b863fcfbe415ec912e4e09653dd519f9ac207819339fe34730f",
|
||||
"infinite": "bff57a1c56d9b47131d7c8ff85f008a53c612c63706052e7e19d7aaea93cf5b1",
|
||||
"inspect": "d2b4644b2d5f1aa50cb576b9265e275fa91201e296c2bc79865f810067d538e9",
|
||||
"interference": "b3d729d0e822e8d95485c8ef50649847572def10d4c713fff0e073b8031c5f5d",
|
||||
"intersect": "9ab4f6551848a2be546f55502a77075bed2d3994f89d91b721eab92bcee61d61",
|
||||
"intersection_curve": "ec4808113e203d2e7ef461d0b5e2179ed8908acd9f62051a5d3f36242b1eca8d",
|
||||
"iso_analysis": "dacd34ac254ec4fbb6932350f62d4d92626d922a2bfc62d2caecdeb17303e896",
|
||||
"iso_curve": "015ff2e26346ccaf149f75cf6a0652d9201c1b23a14e6bb62625acfa599dc002",
|
||||
"joint": "72b71a608e713f6cf51a1cb84edb011d4ccbd8bfe41d917fa2a1d13345dc0b2b",
|
||||
"joint_origin": "5b0761122eb4295ea220314e8db1efd1251e3482653d6cdcc47252d0268864a8",
|
||||
"library": "ac0b1798e4c94340f9c854daa3829371c1a9f3ffea7679d955e6cfa139cafdc8",
|
||||
"line": "cd1177ea659548f03d43c7fa7dcc209a0542b1ba4bd0153c5113576cb7a96a81",
|
||||
"line_mid": "edb8ebc463479ae9878e54b930c124153ca880270b7cf6c0e4edba6336ba2854",
|
||||
"lip": "09795b92c397eece2acb59fd941bcd12d60421565d767507ea73835eff4563eb",
|
||||
"list": "21717be2af42958348ed9bf51f78a5651e44a811ba8904909e7ca23e00581ecb",
|
||||
"loft": "58d177d4f1ec28de57232af5d16576f198cdc28f365af0a7fb21928cfe89775e",
|
||||
"lookat": "83a7666f464aebf8ec46572470083e29d65db8f654f017ff5f10b68d5dacbf08",
|
||||
"material": "1de5c7cb82714aed3c006a565ce5be0b047428f64e1c837a4ad53835b4fdba6d",
|
||||
"measure": "6e8f5c26142786a01bbcdde0313d4c2dad798366409e926457128ffe1bc3014e",
|
||||
"mesh_export": "c8c2abf007e43e896479d78798e3976ba9eda809507c820bb38538f77db696ac",
|
||||
"min_radius": "92bd79b7d557aba6ddfccc76a4c6d0cfb579061b3c57172f9171d28af675711c",
|
||||
"mirror": "56637068a319dc2972c5552b25e4f43151aa9bd5055c2fef08204dd7cc0829cf",
|
||||
"moon": "3ba5adb92bf0f49e92d46682dbf6905e555aa6eebc8e2b3c5288f949cda29322",
|
||||
"motion_link": "19698bbe15b7c2d2601208bbcdc26627d1aa7724aee017b7da8874ad87fb9d1f",
|
||||
"move": "4132e270ada8d0c79b477095c84d656d6d354e200c1748ce7fc425f36d0b7031",
|
||||
"nav_settings": "b93dd46d3a3d074e0d8834167f977dab417fbc1a721bc4db5e840732304e4193",
|
||||
"new": "c64c78fd8445bc7b2aa8765d0507b9adf23e35cbf52828d3f1987edccce0389f",
|
||||
"normal": "1148cef2d60a858b69406a2e2527d4ea126f45846de7cc2fb3a96640e9ee226f",
|
||||
"offset": "094607afcfdcb9c1410114d9f8d22ca367f8e32ab4c61f71affe44ad5d09adbe",
|
||||
"offset_face": "83d39308ec9f3ead78329c62841c8899bbc76ce08e17d539016d18c91d9d4ea7",
|
||||
"offset_surface": "b501cfb56b8404cdb2275935d780ce7dbb67e87e29ade3782f0859c59070a2b1",
|
||||
"open": "f1880d25489753f7e40390902037bbd3195fbd099a5e9dd175797d832abff727",
|
||||
"orbit": "4adcd400cdde909139386e78579433354f65f5a77a0594447bf6dda663b3b1b4",
|
||||
"origin": "9b9360edc16ef9f059bff7d863f1bc0e3abac6acf559d76c4ea39dc374ab2844",
|
||||
"pan": "2465eda7a39d4458d7818f2eb2b033992a68f80d9505bff538b41eb4511bb632",
|
||||
"params": "d0d9d0b3e786880e59b985dc2e3a7d593cf3771484a8fd2924d69155195c230f",
|
||||
"paste": "e1b3c089adafacb6f7b78b86ec7aba0876b189c81d01031045dfee2c47ae8edc",
|
||||
"patch": "b8f3c01c2d47ead98b6271d448f2d7769250678c5fd2acc31b208c723e8d306d",
|
||||
"pattern": "74a802c4f59ab2b46d540b246d7ab67c48258b0dcb0cce20f06b8137de512811",
|
||||
"pattern_center": "ad445f8619168ebe73d8eccfdfce0a03145a986d97a0c8f5105f991a8eac3cf9",
|
||||
"pattern_circ": "c5ff904237a3072f858f66c2777febc4facd2fee630bbf979047c5685d9a05ce",
|
||||
"pattern_flip": "bc75870bb40a7b44dcef4835798ace8b38dae58f602f409e887afcf85d573292",
|
||||
"pattern_path": "50725a30cfd7dc73a531829fb337219560c65acf38ec2bb3e0a09c644a8c714c",
|
||||
"pattern_rect": "be92ceea524c9eaefe56be2887b4842f521360157c084dca2c4092704925994f",
|
||||
"perforate": "a62a04fd5c77fdbeac12ed9586342c5d76e652551190df76eb52483ce480d858",
|
||||
"perspective": "5fa45ba9b75dbe9fdad9eae331ca7ff35c6ad018b8a1fc4c8ae7bcfc13ed0c3f",
|
||||
"pipe": "b9ecf1ae9f6790e2ae6c677f290446f70eba1773a10247fe33d5283b360b0403",
|
||||
"plane": "3e7fa0a9d57891f3c1ec03d7422bdb29353d3487deed12e8ec5737aeb33c2f3a",
|
||||
"plane_along_path": "020e25969a7f4e7e3358003addee7df8b1991cde8401df0b4df8295b19497994",
|
||||
"plane_angle": "2f0914955663b393a377fda080bc03dc5a2133d42c506c1908c53b2fd609b156",
|
||||
"plane_midplane": "66bd880821e7b122f130824313b7070d72d79168a52df6a126106b6eb5816474",
|
||||
"plane_perpendicular": "90e1d9b8e75743821aa1082596d9c3f288ab3baddf087fbae3f2025481e58a6e",
|
||||
"plane_tangent": "c3872b9f1d0f0b2493a5488dfdad50049c3422c29023250088eeda9bbcc6e795",
|
||||
"plane_three_points": "08e2dbaf606d165ef4f788869fc794a4fbbbb2ee86a60017b76e467af6da338a",
|
||||
"plane_two_edges": "1588b8d4741382d9e8fec38212419ac1c05b1bc6036f276d3b5c62db66f8ebdb",
|
||||
"plastic_assign": "bbfdb2d2c07629c047e8e671c7c032654668a67b4b8a934f74cbb3d7a00cc5fe",
|
||||
"plastic_rule": "51b8591d92907a3ffb76a47a0503d7ba86706d129dd990515a364dbf1ab8cb48",
|
||||
"play": "d1c17ebc16e9a4ee5ae2a8cc37b515ffcc8c53a2ab17117959e9b21278df8def",
|
||||
"point": "fd19dbcd26e2a898267bab54b9d89779d97894209c1771317d7debef5a6f8f5d",
|
||||
"point_along_path": "1fd58d45285fb5cabeddbd35bc8485af1b6d7f1414e302202b4e73de593d1b7a",
|
||||
"point_center": "0ad755896f7d0154a854bda16d3059da78d1fae57962e13a3bb1b31e284b7e79",
|
||||
"point_edge_plane": "548a6c1c944caabe44ab0318e85ae82f39c11f74fbedda35f57bc6615755f249",
|
||||
"point_three_planes": "b33c7508ffa67b66a57b72c6ea7900e93ff16362a29d0b2688e0b1db9bfad041",
|
||||
"point_two_edges": "35dcb67b36c8e41041cc836b01056731ab6f14c5e3b4bdd836fbee2a1dd897e3",
|
||||
"point_vertex": "649bb63caf65a3fbc00a1222cc642294832b5e5d8c4d33f812e09a70684c4abe",
|
||||
"polygon": "4919268cca7d21ce98dbf4ca01ee832350e793afe4213e257d3c46e94ea88ad1",
|
||||
"polygon_circumscribed": "7e4ad16cc1f42c599287b186f26f4ff3c108736877a571a5513bfc72d0cbb23f",
|
||||
"polygon_edge": "f3eceaa5c3856e42b33a366117ef6c91ca0fa7bafcd17a95c1697e27eb183309",
|
||||
"presspull": "672aa5653aacc1cb13b15a176ae613e9510fbe84dda1e4783fc0b75fbd5ca353",
|
||||
"project": "0299d3b1546267a6aa288d096133ba0404e8103f6914615aaa08f7fee29e9756",
|
||||
"project_surface": "db449005c14dfbe4fd56f5c400b6f28b5356460b651af07105bfee5225ddb1e1",
|
||||
"rack_pinion": "adedfeddbd22aec7c4dfc4a639c4d48db1e961b4632214b33966fbabe86c0696",
|
||||
"reach": "1ed9d7a4146a2bc9b3a8e1a570cf383a0c7989fd9f58001f055b6d61db05bebc",
|
||||
"rect": "159676a0a616c0e9050ff25f5f0fd2ff419f939dd5b683810b864aae8e425fba",
|
||||
"rect3": "f7c0ca1e3b91357ac8a65df5219d0e00339a3c97407b58d8612f7d195e0b7d68",
|
||||
"rect_center": "1807156588c7413ef543ca4ecb5679c8de2724d98c4dcc153c3958cbbc945045",
|
||||
"rect_rounded": "fba07a477c517324e448dd9130aa2a53c5fd9c1cfac8d018836d71044880ae1d",
|
||||
"redo": "28e925493fa9e5f52e2c8e3beeb288af13fdb4f85f3cd92270096a10561e7a1a",
|
||||
"refold": "1fa6a8604af7d04c01bc31d0e5875eb2cc91f5fa6bfc5491dd6680d3299eb26f",
|
||||
"rename": "6887c5467101dca1d213154020d3b03be9b2704ac310de9d0a8ebde62c013bf5",
|
||||
"render_image": "e3dd622591de4fdc211f9bd3c6dcb54b01a308e8bcebd02bb80dd6b3982ce2a5",
|
||||
"render_scene": "1c4d7350f6f17ad637a7d633cb65a64c298af20321b1dbdac01b51d47a0e2822",
|
||||
"reorder": "c45755c8f23575415397bffb316eed9f2d8c014f451ab6f8c3123aca1a99b675",
|
||||
"replace_face": "fcee556c684a1138a30a2574ea565c98ff29f9368c2e861d0fb861155a57d28b",
|
||||
"rest": "eab64961b5340bdbbd92affe0c9b8477dc297a3b49e05e39d5fa71c0f2fda66c",
|
||||
"restore": "06911c1a903ae4fa3b830809caea4da50f997d73612f4ebab82f9be7db3df559",
|
||||
"reverse_normal": "a15cff514200d3ddd23bb624c90dde65e1291cba835d75798796694ced3a4e50",
|
||||
"revert": "4809aa9728b0478d0218024d4b981a1b8cc1258d1342646029b4a06384a3f60b",
|
||||
"revolve": "3610e710a5c03c04ff8344e13addb3dac7c5dd46d41151a583b01b15b9e7dc32",
|
||||
"rib": "4162c8ea2e7d1929b45649cae1fac618d7074402b7121466ce9b32ed0951272d",
|
||||
"rigid_group": "21ca1be510a7f9499cec1aaf48ea44acc2566b925ddf67bc9a63653dd20da37a",
|
||||
"rollback": "b76633b0e8e1f9c4f0bbe11843b31ec5208fbed1ef316b75e1d309cdd686b8b5",
|
||||
"ruled": "c3f0202d3beaacef0368765ce771eb9e3b0e3e157f86564c72d81f0fad9a3cab",
|
||||
"save": "5b780c23dd467311686ec8677b387ecce81639ad3fd63bff3e0209ea6b9f2ff3",
|
||||
"scale": "a66f69d9a1d46c8be30da0a15827bc7e38cba72d6271537763b888c2c7e2b3fa",
|
||||
"screw": "5e30570785360d6a591ea22862a82f1cf62228385c1e96382536db8a453ac791",
|
||||
"section": "cdd13f99f15f6d59bfa21c747810608d6afd00cb8207faf0337928c8521e7eae",
|
||||
"select": "a0c716234ef1d599be9a7654d6b2911ebe4b2de479383ba33aeab14992443a2f",
|
||||
"settings": "9193dd3a1007c43660d0d47b6499cb2571338b67949e1a99df112057b9bf2b8a",
|
||||
"sheet": "774e30923ef6f37dd9c0b2bc3e45705e83b3a76700bf071cd41155a365d0daa4",
|
||||
"sheet_rules": "b8c3a67c82be20fc00c109decc566e8755d70167e04e0a9fb92941f7b27aa993",
|
||||
"shell": "a1959ea0d0441a479bf654b6e549e2e44a0bfdc9cda4eb46fe1fbfd884de7e38",
|
||||
"sketch": "b52e5862b1709a9a7e3ca00d7436d126fbfc01b38080006fa4e17dc581dfa103",
|
||||
"sketch_break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c",
|
||||
"sketch_chamfer": "80ab27a9929f9cf0ab2f827757e007173d74ddca6c19af238931c40d4cd874b4",
|
||||
"sketch_chamfer_angle": "b853f5e7c3b008c0d56ac445a9a28f713987c919ea284945113ad35e7665e2a7",
|
||||
"sketch_chamfer_two_distances": "2b70d658e341f4388838f3c8c579ec5f4f3a7fddcfa2ce7a3f89fc3db6422d6e",
|
||||
"sketch_circ_pattern": "c5ff904237a3072f858f66c2777febc4facd2fee630bbf979047c5685d9a05ce",
|
||||
"sketch_dxf": "dddce44d4dd10f75a221208fcf431b36b3f0cafce6611fc695ac73afb4bf5789",
|
||||
"sketch_fillet": "e8fa170e5f6899abf75788b9384d49f2b9d77ba9b644f79309284a9e65629fe1",
|
||||
"sketch_mirror": "6f54361a10464f8ff50843b09570e472156ce33a7c07572cb854d217dba62642",
|
||||
"sketch_move": "4132e270ada8d0c79b477095c84d656d6d354e200c1748ce7fc425f36d0b7031",
|
||||
"sketch_rect_pattern": "be92ceea524c9eaefe56be2887b4842f521360157c084dca2c4092704925994f",
|
||||
"sketch_scale": "a66f69d9a1d46c8be30da0a15827bc7e38cba72d6271537763b888c2c7e2b3fa",
|
||||
"sketch_text": "9d31b72b16644681c8a86ac257511847ad69ce4351cf6960442222e94b168c4a",
|
||||
"slot": "45f57f525f5a2218f629018c84a4e6b3c5963216e4b94d3df9364c0cc75b819f",
|
||||
"slot_center": "34509c043fb0ea089bc915b1967bd728a870c09c6422751ec64d22819ba21f96",
|
||||
"slot_overall": "8fcc4f4d293d45b325a4a638753c49552a0e10c6dd980ce5f15782d01cababeb",
|
||||
"snap": "d15130717be58ad0884a4c6f7769a7a43bd55c0d4f35ecf6bd5f7ea1360f8930",
|
||||
"snap_fit": "5b6b48d0f3876197c3825e057dc05fa1a1e89db86efe760752269c314268c6f8",
|
||||
"sphere": "c8bcb1d2f4b9a52b6d3f730d131d00c9fdc6a1c1245283de6450588d0a46a4ab",
|
||||
"spin": "a5bebeff8276dd12ab7e42ddf0051370b9063fb602607298acf22e77044434d1",
|
||||
"spline": "a0dcb25614e5fe1a8f17bc3bddbb11b61cf82b461dd46443ab28cddd6e4e32e3",
|
||||
"spline_cv": "353f6e411c0ce611f660cef2e4fc4f8f55edc2c82288de91948dc638cd2b5f15",
|
||||
"split_body": "eb9619e74c6ee1b1989d35a2e31d2d33a7adac210c4cd424e5382902945ac68b",
|
||||
"split_face": "c8b89072118bb7a28b243d38e26b81a796781a631f30b3b1fc296ba23e4d908d",
|
||||
"spun": "b4cfa093ffb43611ee124f4a39fb44c609deb9cde5e05e4dfbaf893fe197c27b",
|
||||
"star": "368cf8c9a1258ad2c0ac673922b755dd3980397bb352c81436590c776ff53c0b",
|
||||
"status_error": "b7188fcdfadf1512ddd2896805a14618179789f87047b219560a7dab74bd360f",
|
||||
"status_warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825",
|
||||
"stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d",
|
||||
"style": "6c2b04234b82ea4858c80190cc7208c60f549358253005afc35ab4e29506b578",
|
||||
"suppress": "6c033a6672a3b6ca926e3d7a75a758c9db7e1144210cd360be043a9fdf999ede",
|
||||
"surface": "ba0e839ce0ee03e7ff95a2389a5ecb20bd80c713064d9b1bd5f461f4a80d1921",
|
||||
"surface_blend": "0642f3b4af7d22f379a3c74f54c7b1c9eef0bdda19372ce4df73c0e56bdd7bbc",
|
||||
"surface_boundary": "fb8501bae192e2416118cc2e88f544e0e29197f6720bccc541c967ca0848d8d3",
|
||||
"surface_extend": "28c63df352c77d02feffe44cf09fb0fc1d48f7905aaabbfa6dba601dbb70f8e8",
|
||||
"surface_extrude": "9cc1538971c5d25467aaed23f739899d9d102fd07f86472ac3197c54ca2e1144",
|
||||
"surface_fair": "353f6e411c0ce611f660cef2e4fc4f8f55edc2c82288de91948dc638cd2b5f15",
|
||||
"surface_fillet": "11ec232dc111597211e3a4fe61b69616b5d28b4b1b2732052ddcd486cdfc6129",
|
||||
"surface_loft": "58d177d4f1ec28de57232af5d16576f198cdc28f365af0a7fb21928cfe89775e",
|
||||
"surface_offset": "b501cfb56b8404cdb2275935d780ce7dbb67e87e29ade3782f0859c59070a2b1",
|
||||
"surface_patch": "b8f3c01c2d47ead98b6271d448f2d7769250678c5fd2acc31b208c723e8d306d",
|
||||
"surface_reverse_normal": "a15cff514200d3ddd23bb624c90dde65e1291cba835d75798796694ced3a4e50",
|
||||
"surface_revolve": "3610e710a5c03c04ff8344e13addb3dac7c5dd46d41151a583b01b15b9e7dc32",
|
||||
"surface_ruled": "c3f0202d3beaacef0368765ce771eb9e3b0e3e157f86564c72d81f0fad9a3cab",
|
||||
"surface_stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d",
|
||||
"surface_sweep": "6d7f18c12b2e4c1dbe3632fe08239687f6f027b29b6dddbf8c8e2d9151c7baf3",
|
||||
"surface_thicken": "676eb73a99d708214e8951ebe2c4d88d60de2d752c725d8106d12fbd812e461a",
|
||||
"surface_trim": "ae269721b386bd1264184b9838df3240b5b6ba02b99c9993384ef21200fb7398",
|
||||
"surface_unstitch": "bd8302fe95a89eb6f9bbf35ef0ee98c8a2186e160cff3d091f8cf2a0a75d4473",
|
||||
"surface_untrim": "24de5d558519e0629280d11919ba5661c92ccdccdce03f8f3d83f2d7867c2173",
|
||||
"svg": "7da77d53c84db18ce95737ad633c105d1009444b3192a5e0aae76873cfc9cc12",
|
||||
"sweep": "6d7f18c12b2e4c1dbe3632fe08239687f6f027b29b6dddbf8c8e2d9151c7baf3",
|
||||
"text": "9d31b72b16644681c8a86ac257511847ad69ce4351cf6960442222e94b168c4a",
|
||||
"thicken": "676eb73a99d708214e8951ebe2c4d88d60de2d752c725d8106d12fbd812e461a",
|
||||
"thread": "158728c3dcf6623bb1584d162398bb12c28d18770e0d47f5e6328355ce31b37c",
|
||||
"torus": "5ae13fc213582e0844118b910a3a26efd9e4b177d21500363fbae230803cb4fd",
|
||||
"trace": "5f2ecc61d4591d3c3b0ecccd35296b8613fce7beb2ae214fb906160c31d89187",
|
||||
"trim": "ae269721b386bd1264184b9838df3240b5b6ba02b99c9993384ef21200fb7398",
|
||||
"undo": "e764485407adc80ed73b85fd10e0b87c2702cde55bd8459bd96f17f22d47336d",
|
||||
"unfold": "eb7aeac6e186a6b7ebbd10197eb850b4a11b9fe4cf1d009451fdfb451e9a35cd",
|
||||
"unstitch": "bd8302fe95a89eb6f9bbf35ef0ee98c8a2186e160cff3d091f8cf2a0a75d4473",
|
||||
"untrim": "24de5d558519e0629280d11919ba5661c92ccdccdce03f8f3d83f2d7867c2173",
|
||||
"update": "c675e16df4c78589a00be4a34f98627500681672ee8a668e929aec50982da3cc",
|
||||
"warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825",
|
||||
"web": "be5cd95bebcd0ebe257f9eb862112124429b381ef4a2f0849938fac7c9ccc619",
|
||||
"ws_animate": "d36a0834ad989a6e16e2b0db493ad8867c590caedf0442c51eff19e934c887f8",
|
||||
"ws_design": "7b1dd678a2d018892b965a78932c047bbfff6b4439d7bd9fe85c09a995f97581",
|
||||
"ws_drawing": "61e99c7179f3448a31b8c76392334352ec3d5e39b0b92fe5244940146d0986cb",
|
||||
"ws_render": "1bfe474d1742623ad2142129e4d6a40015e674813eb1db401d2e88e55b1f2a03",
|
||||
"zebra": "1d341ece6fcb09a3f4911633e1c9b57d92d96eb257f992dc26be79e1d521c69a",
|
||||
"zoom": "89c121fa85df034d70bd9f06e20cbd54d163691d4df03b13a9ac5372e951aeda"
|
||||
},
|
||||
"notes": {
|
||||
"pattern_flip": "This feels sparse. Improve the composition"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Bug report</title>
|
||||
<defs>
|
||||
<mask id="bug-v2-split" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -32L96 -32L96 96L-32 96Z" fill="white"/>
|
||||
<path d="M32 26L32 53" stroke="black" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M28.5 21L22 9.5M35.5 21L42 9.5" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M23.5 23A8.5 8 0 1 1 40.5 23A8.5 8 0 1 1 23.5 23Z" fill="currentColor"/>
|
||||
<path d="M45.5 39A13.5 15 0 1 1 18.5 39A13.5 15 0 1 1 45.5 39Z" fill="currentColor" mask="url(#bug-v2-split)"/>
|
||||
<path d="M23 30.5L10 25M22 39L9 39M23.5 47L11.5 53M41 30.5L54 25M42 39L55 39M40.5 47L52.5 53" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 939 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Chevron</title>
|
||||
<path d="M16 24L32 40L48 24" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Fit</title>
|
||||
<path d="M10 24L10 10L24 10M40 10L54 10L54 24M54 40L54 54L40 54M24 54L10 54L10 40" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M26 24L38 24A2 2 0 0 1 40 26L40 38A2 2 0 0 1 38 40L26 40A2 2 0 0 1 24 38L24 26A2 2 0 0 1 26 24Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 406 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Folder</title>
|
||||
<path d="M10.5 12.5L26 12.5L32 18.5L53.5 18.5Q56 18.5 56 21L56 49Q56 51.5 53.5 51.5L10.5 51.5Q8 51.5 8 49L8 15Q8 12.5 10.5 12.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Help</title>
|
||||
<defs>
|
||||
<mask id="help3-q" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -32L96 -32L96 96L-32 96Z" fill="white"/>
|
||||
<path d="M25.5 26C25.5 17.5 39 17.5 39 26.5C39 31.5 32 32 32 38" fill="none" stroke="black" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="46" r="3.2" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="24" fill="currentColor" mask="url(#help3-q)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Home</title>
|
||||
<defs>
|
||||
<mask id="home3-door" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -33.5L96 -33.5L96 94.5L-32 94.5Z" fill="white"/>
|
||||
<path d="M30.5 35.5L33.5 35.5A4 4 0 0 1 37.5 39.5L37.5 53.5A4 4 0 0 1 33.5 57.5L30.5 57.5A4 4 0 0 1 26.5 53.5L26.5 39.5A4 4 0 0 1 30.5 35.5Z" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M13 29.5L32 13.5L51 29.5L51 46Q51 50.5 46.5 50.5L17.5 50.5Q13 50.5 13 46Z" fill="currentColor" stroke="currentColor" stroke-width="6" stroke-linejoin="round" stroke-linecap="round" mask="url(#home3-door)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Lookat</title>
|
||||
<defs>
|
||||
<mask id="look-at-rear" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-34 -32.5L94 -32.5L94 95.5L-34 95.5Z" fill="white"/>
|
||||
<path d="M23 31.5L37 22.5L37 40.5Z" fill="black" stroke="black" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M37 31.5L56 31.5" fill="none" stroke="black" stroke-width="10" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M11 12.5L36 12.5A3 3 0 0 1 39 15.5L39 48.5A3 3 0 0 1 36 51.5L11 51.5A3 3 0 0 1 8 48.5L8 15.5A3 3 0 0 1 11 12.5Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round" mask="url(#look-at-rear)"/>
|
||||
<path d="M37 31.5L56 31.5" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M23 31.5L37 22.5L37 40.5Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Moon</title>
|
||||
<defs><mask id="moon-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><path d="M0 0L64 0L64 64L0 64Z" fill="white"/><circle cx="43" cy="20" r="20" fill="black"/></mask></defs>
|
||||
<circle cx="32" cy="32" r="24" fill="currentColor" mask="url(#moon-cut)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Nav settings</title>
|
||||
<defs>
|
||||
<mask id="soft-faceted-settings-gear-hole" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-36.27 -36.27L100.27 -36.27L100.27 100.27L-36.27 100.27Z" fill="white"/>
|
||||
<circle cx="32" cy="32" r="7.47" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M46.2 22.88L47 24.27Q47.93 25.88 50.26 26.17L52.54 26.44Q54.87 26.72 54.87 29.07L54.87 34.93Q54.87 37.28 52.54 37.56L50.26 37.83Q47.93 38.12 47 39.73L46.2 41.12Q45.26 42.74 46.18 44.9L47.08 47Q48 49.16 45.97 50.34L40.89 53.27Q38.86 54.44 37.45 52.56L36.08 50.73Q34.67 48.86 32.8 48.86L31.2 48.86Q29.33 48.86 27.92 50.73L26.55 52.56Q25.14 54.44 23.11 53.27L18.03 50.34Q16 49.16 16.92 47L17.82 44.9Q18.74 42.74 17.8 41.12L17 39.73Q16.07 38.12 13.74 37.83L11.46 37.56Q9.13 37.28 9.13 34.93L9.13 29.07Q9.13 26.72 11.46 26.44L13.74 26.17Q16.07 25.88 17 24.27L17.8 22.88Q18.74 21.26 17.82 19.1L16.92 17Q16 14.84 18.03 13.66L23.11 10.73Q25.14 9.56 26.55 11.44L27.92 13.27Q29.33 15.14 31.2 15.14L32.8 15.14Q34.67 15.14 36.08 13.27L37.45 11.44Q38.86 9.56 40.89 10.73L45.97 13.66Q48 14.84 47.08 17L46.18 19.1Q45.26 21.26 46.2 22.88Z" fill="currentColor" mask="url(#soft-faceted-settings-gear-hole)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Orbit</title>
|
||||
<path d="M49 18.37A22 22 0 1 0 50 45.37" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M54 7.37L56 25.37L39 20.37Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32.37" r="4" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Pan</title>
|
||||
<path d="M32 5L42 18L36 18L36 28L46 28L46 22L59 32L46 42L46 36L36 36L36 46L42 46L32 59L22 46L28 46L28 36L18 36L18 42L5 32L18 22L18 28L28 28L28 18L22 18Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Perspective</title>
|
||||
<path d="M10 17L54 7L54 57L10 47Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 26L43 21L43 43L22 39Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Redo</title>
|
||||
<path d="M14 47.5C14 34.5 22 24.5 35 24.5L50 24.5M42 16.5L50 24.5L42 32.5" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Save</title>
|
||||
<defs>
|
||||
<mask id="save3-slots" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -32L96 -32L96 96L-32 96Z" fill="white"/>
|
||||
<path d="M25.5 9L35.5 9A3.5 3.5 0 0 1 39 12.5L39 19.5A3.5 3.5 0 0 1 35.5 23L25.5 23A3.5 3.5 0 0 1 22 19.5L22 12.5A3.5 3.5 0 0 1 25.5 9Z" fill="black"/>
|
||||
<path d="M25 34L39 34A4.5 4.5 0 0 1 43.5 38.5L43.5 43.5A4.5 4.5 0 0 1 39 48L25 48A4.5 4.5 0 0 1 20.5 43.5L20.5 38.5A4.5 4.5 0 0 1 25 34Z" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M19 10L40.5 10Q43 10 44.8 11.8L52.2 19.2Q54 21 54 23.5L54 45Q54 54 45 54L19 54Q10 54 10 45L10 19Q10 10 19 10Z" fill="currentColor" mask="url(#save3-slots)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Settings</title>
|
||||
<defs>
|
||||
<mask id="soft-faceted-settings-gear-hole" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -32L96 -32L96 96L-32 96Z" fill="white"/>
|
||||
<circle cx="32" cy="32" r="7" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M45.31 23.45L46.06 24.75Q46.94 26.27 49.12 26.53L51.25 26.79Q53.44 27.05 53.44 29.25L53.44 34.75Q53.44 36.95 51.25 37.21L49.12 37.47Q46.94 37.73 46.06 39.25L45.31 40.55Q44.43 42.07 45.3 44.09L46.14 46.07Q47 48.09 45.1 49.19L40.34 51.94Q38.43 53.04 37.11 51.28L35.82 49.56Q34.5 47.8 32.75 47.8L31.25 47.8Q29.5 47.8 28.18 49.56L26.89 51.28Q25.57 53.04 23.66 51.94L18.9 49.19Q17 48.09 17.86 46.07L18.7 44.09Q19.57 42.07 18.69 40.55L17.94 39.25Q17.06 37.73 14.88 37.47L12.75 37.21Q10.56 36.95 10.56 34.75L10.56 29.25Q10.56 27.05 12.75 26.79L14.88 26.53Q17.06 26.27 17.94 24.75L18.69 23.45Q19.57 21.93 18.7 19.91L17.86 17.93Q17 15.91 18.9 14.81L23.66 12.06Q25.57 10.96 26.89 12.72L28.18 14.44Q29.5 16.2 31.25 16.2L32.75 16.2Q34.5 16.2 35.82 14.44L37.11 12.72Q38.43 10.96 40.34 12.06L45.1 14.81Q47 15.91 46.14 17.93L45.3 19.91Q44.43 21.93 45.31 23.45Z" fill="currentColor" mask="url(#soft-faceted-settings-gear-hole)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Spin</title>
|
||||
<defs>
|
||||
<mask id="spin-back" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -33.5L96 -33.5L96 94.5L-32 94.5Z" fill="white"/>
|
||||
<path d="M32 7.5L32 56.5" fill="none" stroke="black" stroke-width="10" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</mask>
|
||||
<mask id="spin-axis" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -33.5L96 -33.5L96 94.5L-32 94.5Z" fill="white"/>
|
||||
<path d="M54.22 37.83L53.92 38.22L53.59 38.61L53.2 38.99L52.78 39.36L52.31 39.73L51.8 40.08L51.25 40.43L50.65 40.76L50.02 41.09L49.36 41.4L48.66 41.71L47.92 42L47.15 42.27L46.35 42.53L45.52 42.78L44.66 43.01L43.78 43.23L42.87 43.43L41.94 43.62L40.99 43.78L40.02 43.94L39.03 44.07L38.03 44.19L37.02 44.28L35.99 44.36L34.96 44.43L33.92 44.47L32.88 44.49L31.84 44.5L30.8 44.49L29.76 44.46L28.72 44.41L27.69 44.34L26.67 44.25L25.66 44.15L24.66 44.03L23.68 43.89L22.72 43.73L21.77 43.56L20.85 43.37L19.95 43.17L19.07 42.94L18.22 42.71L17.4 42.45L16.61 42.19L15.85 41.91L15.12 41.61L14.43 41.31L13.78 40.99L13.16 40.66" fill="none" stroke="black" stroke-width="10" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M9.78 33.17L10.08 32.78L10.41 32.39L10.8 32.01L11.22 31.64L11.69 31.27L12.2 30.92L12.75 30.57L13.35 30.24L13.98 29.91L14.64 29.6L15.34 29.29L16.08 29L16.85 28.73L17.65 28.47L18.48 28.22L19.34 27.99L20.22 27.77L21.13 27.57L22.06 27.38L23.01 27.22L23.98 27.06L24.97 26.93L25.97 26.81L26.98 26.72L28.01 26.64L29.04 26.57L30.08 26.53L31.12 26.51L32.16 26.5L33.2 26.51L34.24 26.54L35.28 26.59L36.31 26.66L37.33 26.75L38.34 26.85L39.34 26.97L40.32 27.11L41.28 27.27L42.23 27.44L43.15 27.63L44.05 27.83L44.93 28.06L45.78 28.29L46.6 28.55L47.39 28.81L48.15 29.09L48.88 29.39L49.57 29.69L50.22 30.01L50.84 30.34" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round" mask="url(#spin-back)"/>
|
||||
<path d="M32 7.5L32 56.5" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round" mask="url(#spin-axis)"/>
|
||||
<path d="M54.22 37.83L53.92 38.22L53.59 38.61L53.2 38.99L52.78 39.36L52.31 39.73L51.8 40.08L51.25 40.43L50.65 40.76L50.02 41.09L49.36 41.4L48.66 41.71L47.92 42L47.15 42.27L46.35 42.53L45.52 42.78L44.66 43.01L43.78 43.23L42.87 43.43L41.94 43.62L40.99 43.78L40.02 43.94L39.03 44.07L38.03 44.19L37.02 44.28L35.99 44.36L34.96 44.42L33.92 44.47L32.88 44.49L31.84 44.5L30.8 44.49L29.76 44.46L28.72 44.41L27.69 44.34L26.67 44.26L25.66 44.15L24.66 44.03L23.68 43.89L22.72 43.73L21.77 43.56L20.85 43.37L19.95 43.17L19.07 42.94L18.22 42.71L17.4 42.45L16.61 42.19L15.85 41.91L15.12 41.62L14.43 41.31L13.78 40.99L13.16 40.66" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M54.65 33.94L48.09 33.25L53.59 27.43Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M9.35 37.06L15.91 37.75L10.41 43.57Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Style</title>
|
||||
<path d="M32 6L55 19L55 45L32 58L9 45L9 19Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 31L32 50M15 22L32 31L49 22" fill="none" stroke="#ffffff" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Undo</title>
|
||||
<path d="M50 47.5C50 34.5 42 24.5 29 24.5L14 24.5M22 16.5L14 24.5L22 32.5" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Zoom</title>
|
||||
<path d="M38.5 38.5L52 52" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="27" cy="27" r="15" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Accessibility</title>
|
||||
<path d="M7.65 35.4L32 49.46L32 61.76L7.65 47.7Z" fill="#328bd3"/>
|
||||
<path d="M56.35 35.4L32 49.46L32 61.76L56.35 47.7Z" fill="#236ead"/>
|
||||
<path d="M32 21.34L56.35 35.4L32 49.46L7.65 35.4Z" fill="#e2e8ef"/>
|
||||
<path d="M32 21.34L56.35 35.4L56.35 47.7L32 61.76L7.65 47.7L7.65 35.4Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M7.65 35.4L32 49.46L56.35 35.4" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 49.46L32 61.76" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.31 26.61L22.31 35.4A9.69 5.59 0 0 0 41.69 35.4L41.69 26.61Z" fill="#328bd3"/>
|
||||
<path d="M35.39 26.61L35.39 40.64A9.69 5.59 0 0 0 41.69 35.4L41.69 26.61Z" fill="#236ead"/>
|
||||
<path d="M22.31 26.61L22.31 35.4A9.69 5.59 0 0 0 41.69 35.4L41.69 26.61Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.31 26.61A9.69 5.59 0 1 0 41.69 26.61A9.69 5.59 0 1 0 22.31 26.61Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M38.66 3.76L38.66 25.73A2.48 1.43 0 0 0 43.61 25.73L43.61 3.76Z" fill="#236ead"/>
|
||||
<path d="M38.66 25.73L41.13 33.73L43.61 25.73Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M38.66 3.76L38.66 25.73A2.48 1.43 0 0 0 43.61 25.73L43.61 3.76Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M38.66 3.76A2.48 1.43 0 1 0 43.61 3.76A2.48 1.43 0 1 0 38.66 3.76Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Activate</title>
|
||||
<path d="M10 32A22 22 0 1 0 54 32A22 22 0 1 0 10 32Z" fill="#b9ddf8"/>
|
||||
<path d="M10 32A22 22 0 1 0 54 32A22 22 0 1 0 10 32Z" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M21 33L29 41L44 24" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Agent chat</title>
|
||||
<path d="M13 9.5L51 9.5Q57 9.5 57 15.5L57 39.5Q57 45.5 51 45.5L29 45.5L16 54.5L16 45.5L13 45.5Q7 45.5 7 39.5L7 15.5Q7 9.5 13 9.5Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="27.5" r="3.1" fill="#236ead"/>
|
||||
<circle cx="32" cy="27.5" r="3.1" fill="#236ead"/>
|
||||
<circle cx="44" cy="27.5" r="3.1" fill="#236ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Align</title>
|
||||
<path d="M20.92 10L48.92 10A2 2 0 0 1 50.92 12L50.92 19A2 2 0 0 1 48.92 21L20.92 21A2 2 0 0 1 18.92 19L18.92 12A2 2 0 0 1 20.92 10Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M20.92 26L38.92 26A2 2 0 0 1 40.92 28L40.92 35A2 2 0 0 1 38.92 37L20.92 37A2 2 0 0 1 18.92 35L18.92 28A2 2 0 0 1 20.92 26Z" fill="#328bd3" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M20.92 42L53.92 42A2 2 0 0 1 55.92 44L55.92 51A2 2 0 0 1 53.92 53L20.92 53A2 2 0 0 1 18.92 51L18.92 44A2 2 0 0 1 20.92 42Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M8.92 8L8.92 56" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Appearance</title>
|
||||
<path d="M32 6C32 6 12 28 12 42A20 20 0 0 0 52 42C52 28 32 6 32 6Z" fill="#328bd3"/>
|
||||
<path d="M32 6C32 6 12 28 12 42A20 20 0 0 0 52 42C52 28 32 6 32 6Z" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M21 42A11 11 0 0 0 28 52" fill="none" stroke="#b9ddf8" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Three-point arc</title>
|
||||
<path d="M8 46A24 24 0 0 1 56 46" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="8" cy="46" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="8" cy="46" r="3.4" fill="#236ead"/>
|
||||
<circle cx="56" cy="46" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="56" cy="46" r="3.4" fill="#236ead"/>
|
||||
<circle cx="32" cy="22" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="32" cy="22" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 539 B |