Compare commits

..

3 Commits

Author SHA1 Message Date
0xlillith 1b55174743 Render: isolate lens saves and retain bounded previews
Save only lens parameters on existing CPU/GPU jobs while keeping unrelated
draft settings dirty. Retain pinned checkpoints through preview and status,
and release host-only snapshots outside the status lock.

Use the admitted PNG bound for registered-executor checkpoint slots. Preserve
executor work credits, final publication and regrade ownership. Add regression
coverage and align help, translations and capability documentation.
2026-11-05 04:22:03 +01:00
0xlillith 5821ca2b5e Render: register existing live and coat command tests 2026-11-05 02:08:15 +01:00
0xlillith 8829c1c3c5 Render: restore saved shots, progressive images and exports 2026-11-05 02:08:14 +01:00
9997 changed files with 85302 additions and 9337267 deletions
+5 -178
View File
@@ -1,193 +1,20 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
profile: chill
poem: false
sequence_diagrams: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
in_progress_fortune: false
auto_review:
# Review when a PR is opened or marked ready, or on `@coderabbitai review`; not on every push.
drafts: false
auto_incremental_review: false
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
path_instructions:
- path: "**"
instructions: |
Project rule: don't change anyone's existing UX defaults unless the change is purely additive and belongs in the application.
A changed default is when something people already rely on in the UI changes for everyone who didn't opt in: an existing control, interaction or setting value. Examples the owner declined or reverted: right click becoming select while left click orbits (make it opt-in instead); a PR that removed all camera animation when only orbit lag was to be fixed. Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut.
A changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default; don't flag it as one, and expect `Changes a default: no` for it.
Not defaults: bug fixes and more correct results, kernel or geometry result changes, new options that are off by default, and CI or tooling config. Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
# Where code goes, SolidCraft or SolidCore: solidcore/docs/layers.md.
- path: "{apps,crates}/**"
instructions: |
Layers (solidcore/docs/layers.md): SolidCraft is a consumer of solidcore. It holds state,
selection, commands, settings, history and UI, and computes no geometry.
- Flag any new geometric computation here: a dot or cross product, normalising, sqrt or trig
on coordinates, a solve, a derivative, an intersection, a fit, a distance or area. Say which
layer it belongs in: a kernel primitive in solidcore/crates/solidcore if it computes
geometry, or a recipe in solidcore/crates/solidcore-ops if it only sequences kernel calls.
- Flag a new dependency on any solidcore crate other than solidcore-ops, solidcore-tess and
solidcore-record.
- Flag a new line in xtask/mesh-decisions.txt; that list only shrinks.
- Flag a second implementation of something solidcore already has (STEP, meshing, SSI,
tangent chains, frames).
# The vendored SolidCore kernel: its own review rules (from solidcore/.coderabbit.yaml, which
# CodeRabbit reads only at the repository root), with its paths under solidcore/.
- path: "solidcore/**"
instructions: |
The vendored SolidCore kernel (solidcore/). Paths in these instructions (AGENTS.md, docs/,
crates/) are under solidcore/.
Review for design quality as well as correctness. solidcore's rules are in AGENTS.md;
read the reference doc for the area a change touches before judging it.
- Flag code smells: logic duplicated from elsewhere in the kernel, special cases
bolted onto a general routine, tolerances or constants with no derivation, flags
or switches that keep an old path alive, fallbacks that hide a failure, and error
paths that lose the reason.
- Flag patches that make a symptom go away without fixing its cause: a widened
tolerance, a retry, an early return that skips the hard case, a test changed to
match new output. Ask what the root cause is and whether the change removes it.
- When a change reads like a patch, look at the other open pull requests and recent
changes in the same area. If several fixes keep touching the same code, say so,
and ask for the bigger picture: the shared cause, and the design that removes it.
- It is fine to recommend stopping and narrowing the scope. A smaller change that is
right, or an issue that states the real problem, is better than a larger change
that adds code which will have to be undone. Say so when that is the better path.
- Each fix needs a test that fails without it, and tests should check values a
wrong result would change. Tests gate on counts and values, never on timings.
The contract every construction keeps (docs/definitions-and-certificates.md):
- A face's surface is its definition S; its B-spline net B is an exact copy or a
certified approximation within a stored, proved epsilon. Every face of a result is
exact or certified. An operation that cannot build that refuses with a typed error
naming its cause and, where one exists, the size that builds. A correct refusal is a
correct result; a body built on a bare fitted face, an estimated bound or a widened
tolerance is not.
- A final answer (an intersection, a projection, a check, a continuity grade) reads S,
or B only inside a `NetScope` that asks for at least epsilon. A bound is enclosed,
never sampled or estimated, and rounding is counted.
- Layers (docs/layers.md): the kernel computes; ops decides and sequences and never
computes a geometric quantity; the applications compute nothing.
- No compatibility flags: a fix changes the default result, listing the cases it moves
and why each is right; no new `UpstreamFixes` field, no old path kept behind a switch.
- Show the problem first (AGENTS.md): a change to a case's result shows it before and
after from the same view, a VectorCraft schematic where the problem is a mechanism,
and the literature it follows. Ask for them when they are missing.
- Library code: no `unwrap`, `expect`, `panic!` or unchecked indexing; deterministic
results; input-dependent recursion bounded. Licence: only the upstream paths AGENTS.md
lists; never GPL/LGPL code.
The layer split (solidcore/docs/layers.md): solidcore is shared by every client, so flag
anything specific to one application: UI, command ids, application defaults or settings,
document state (selection, timeline, history). In solidcore/crates/solidcore-ops, also flag
any geometric quantity computed directly (a dot or cross product, normalizing, a square
root or trigonometry on coordinates, a linear solve, a derivative formula) instead of
through a kernel call: per solidcore/docs/layers.md it becomes a kernel primitive with its
own tests, and ops calls it.
- path: "solidcore/crates/solidcore/src/blend/**"
instructions: |
Fillets, chamfers, corners and setbacks: docs/generic-blend.md. They are one rolling-ball
construction (spine, rails, section, ends, corners, fold check, overflow); a failing case
means one of those parts is wrong for it, never a reason for a special case. Each blend
face carries its definition (`procedural_blend()`), or has an entry in the definition
ledger (crates/solidcore/tests/data/blend_definition_ledger/) and a test asserting which.
Cases are events decided from the geometry (a tangent end, a knife edge, a fold, a corner
type), not one construction tried and then the next. A round never ends at a seam's vertex
with a step: a picked edge continues across a seam into the rest of the same curve between
the same two faces, or the blend refuses. A size that does not fit is refused naming the
size that does.
- path: "solidcore/crates/solidcore/src/boolean/**"
instructions: |
Booleans: docs/booleans.md. The result is the regularized set operation decided on the
exact surfaces, or a typed refusal. Every decision point (a ray hit, a step-off point, an
imprinted vertex) is a root on S; no step reads a display mesh. A face set takes its class
(inside, outside, on) from a sector at a common edge, a ray or a coincident pair, and
nothing is kept or dropped for any other reason. A trimmed or copied face keeps its
surface's definition. Angle cut-offs and sampled coincidence are open work in the doc;
flag new ones standing in for a proof.
- path: "solidcore/crates/solidcore/src/offset/**"
instructions: |
Offset, shell and thicken: docs/offset-and-shell.md. A face's offset is the offset of its
definition S, never of its net B: exact for the analytic kinds, certified otherwise. Where
the distance reaches the least radius of curvature on the displaced side (towards the
centre of curvature) the offset collapses or folds; that is refused naming the largest
wall that builds, never built and repaired. A wall on the other side never folds from
curvature alone. The offset body's tolerance
is the input body's. The five-stage construction (face offsets, mitres with the extension
reach, arrangement, winding-number cavity rule, extraction) is the design; flag work that
extends the old executive instead of it.
- path: "solidcore/crates/solidcore/src/sweep/**"
instructions: |
Swept surfaces and threads: docs/procedural-sweep.md. A sweep is its definition (profile,
path, frame); its net is exact where the doc says so and certified otherwise. Check a
change against the doc's invariants and its exact-net cases.
- path: "solidcore/crates/solidcore/src/intersect/**"
instructions: &ssi |
Surface/surface intersection: docs/ssi.md. Curves come from closed forms where they exist
(line, circle, ellipse) and are traced otherwise; tangent contacts and branch points are
events with their own handling, not a retry with a smaller step. Every reported point is a
root on both definitions within its stated tolerance.
- path: "solidcore/crates/ssi/**"
instructions: *ssi
- path: "solidcore/crates/solidcore-ssi/**"
instructions: *ssi
- path: "solidcore/crates/solidcore/src/topology/**"
instructions: &validity |
B-rep validity, tolerances and healing: docs/validity-and-tolerances.md. A tolerance may
grow only to a bound: for imported data, to an enclosed (not sampled) gap, recorded; for a
construction, to its certificates' delta + max epsilon. A measured gap larger than that
means the construction is wrong: refuse, or recompute the curve from its surfaces'
intersection. No tube or ball may reach an entity it does not bound. A seam is an edge; a
face crossing its surface's seam carries it.
- path: "solidcore/crates/solidcore/src/heal/**"
instructions: *validity
- path: "solidcore/crates/solidcore-tess/**"
instructions: |
Tessellation is for display and export only (docs/layers.md): it decides nothing about the
model. Flag any model decision made from a mesh.
- path: "solidcore/crates/solidcore-ops/**"
instructions: |
Ops is the modelling API (docs/layers.md): it chooses entities, parameters and defaults and
sequences kernel calls; it never computes a geometric quantity itself (distances,
curvatures, intersections, fits). Results go through `finish`, which refuses a body that is
not a valid outward solid where a solid was asked for.
- path: "solidcore/docs/**"
instructions: |
A doc points to code, tests, records and the literature, never to a pull request or an
issue: no `#NNNN` and no PR or issue links, so a page reads the same whatever happens to the
tracker. A new page under docs/ needs the maintainers' agreement on its issue; one the
maintainers asked for in the pull request is fine. Figures are drawn in VectorCraft with
live text, their `.vectorcraft` source beside them, palette in docs/agent-style.md.
- path: "solidcore/crates/solidcore/tests/kernel_cases/**"
instructions: |
A kernel case's outcome changes in the case itself: its JSON expectation (and its
`gallery_note` if needed), its line in `known_failing.json` and its render beside it, never
a doc (the site's case gallery is generated from them). An expectation is graded on closed
forms or invariants of the geometry, not on another kernel's measured result; a refusal is
graded on the size it names.
pre_merge_checks:
custom_checks:
- name: "Existing defaults unchanged"
mode: "warning"
instructions: |
Pass if the pull request changes no UX default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts), or if its changes are purely additive. UX defaults only: a changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default and passes.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes a UX default.
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
+3 -35
View File
@@ -33,12 +33,6 @@ slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
# Bound the whole test process, including Vulkan host initialization and teardown.
# Cold runs can warm multiple pipelines before the first image starts rendering.
[[profile.ci.overrides]]
filter = "package(=solidcraft-ui-egui) & test(=render_workspace::tests::progressive_image_controls_route_to_the_registered_host)"
slow-timeout = { period = "60s", terminate-after = 20 }
# The action matrix sample runs its designs in parallel workers (ACTION_MATRIX_JOBS, set to match in
# ci.yml). Its workers reserve that many of nextest's threads, so the cores are not oversubscribed
# (a pi-case hole that takes 8 s alone went past the matrix's 90 s action limit when they were),
@@ -50,7 +44,7 @@ priority = 100
# The slowest tests start first, so a shard does not end with one of them running alone on a
# core while the others sit idle. Medians from the CI test timings (the junit artifacts), slowest
# first; a test over ~100 s belongs here (the medians of 24 full runs added the last ones, #14362).
# first; a test over ~100 s belongs here.
[[profile.ci.overrides]]
filter = """
(package(solidcraft-engine) & (
@@ -62,11 +56,6 @@ filter = """
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
| test(=tests::join_onto_a_finely_faceted_import)
| test(=tests::chord_and_variable_fillets)
| test(=cmd::joints::tests::motion_sweep::find_limits_sees_arms_meet_behind_pins_already_in_each_other)
| test(=thread_tests::a_multi_start_thread_takes_the_volume_of_one_start)
| test(=thread_tests::a_thread_reaches_a_flat_shoulder_at_every_angle_and_leaves_it_whole)
))
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
| (package(solidcraft-kernel) & (
@@ -75,23 +64,9 @@ filter = """
| test(=tspline::tests::hostile_tsplines_never_panic)
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
| test(/^issue_788_tests::equal_bores_crossing_/)
| test(=blend_variable::tests::variable_radius_on_a_box_edge)
| test(=tests::measure_of_a_body_with_many_faces)
| test(=modify_history_tests::fillet_variable_and_chord)
| test(=sweep_rails_tests::a_parallel_rail_along_a_curved_path_keeps_the_section)
| test(=corner_g2::tests::a_box_with_every_edge_rounded_has_eight_g2_corners)
| test(=corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box)
| test(=blend_corner::tests::issue1557_connected_rounds_keep_their_radii)
))
| (package(solidcraft-cli) & binary(naming_corpus) & test(=corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone))
| (package(solidcraft-ui-egui) & (
test(=render_workspace::tests::progressive_image_controls_route_to_the_registered_host)
| test(=preselect_tests::variable_fillet_round_trip)
| test(=dialogs::dense::tests::a_saved_smooth_patch_reopens_with_its_setback)
| test(=dialogs::dense::tests::no_reachable_fillet_option_state_is_refused_for_its_options)
))
"""
priority = 99
priority = 100
# Each nextest test has its own process/device. Concurrent cold lavapipe path
# compiles contend for CPU and can exhaust the 600 s pipeline setup budget.
@@ -147,12 +122,5 @@ max-threads = 1
[[profile.local-ci.overrides]]
platform = "cfg(windows)"
# Viewport tests also open real devices when their names do not contain "gpu".
filter = """
package(solidcraft-gpu) | package(solidcraft) | (package(solidcraft-ui-egui) & (
test(/(offscreen|gpu)/)
| test(/^viewport::tests::/)
| test(=render_workspace::tests::progressive_image_controls_route_to_the_registered_host)
))
"""
filter = "package(solidcraft-gpu) | package(solidcraft) | (package(solidcraft-ui-egui) & test(/(offscreen|gpu)/))"
test-group = "windows-gpu"
+1 -7
View File
@@ -1,12 +1,6 @@
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
.gitattributes text eol=lf
/crates/look/icons/design/requested_icons.txt text eol=lf
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
*.wgsl text eol=lf
/assets/icons/ui/design/requested_icons.txt text eol=lf
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
*.sc -linguist-detectable
# The kernel is vendored with git subtree, keeping its own history and licence: it must not look
# like SolidCraft source in GitHub's language statistics.
solidcore/** linguist-vendored
-8
View File
@@ -1,8 +0,0 @@
# Code owners: a pull request that changes these paths needs an approving review from an owner
# listed here when the branch ruleset requires review from Code Owners. Later lines take
# precedence over earlier ones.
# The vendored SolidCore kernel, owned as in SolidCore's own CODEOWNERS
# (solidcore/.github/CODEOWNERS, which GitHub reads only at a repository's root).
# The team (jyviko and bherbruck) assigns one of them per pull request, so either one approves.
/solidcore/ @tangency-labs/solidcore-owners
+1 -1
View File
@@ -6,7 +6,7 @@ Start with what this is, in plain words someone new to the project would underst
Changes a default: no
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour; a UX default only, a kernel result change is 'no'):
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
+20 -343
View File
@@ -2,7 +2,7 @@
# 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. Test data under tests/, vendored code under vendor/ and solidcore/ and root files a crate embeds
# those. Test data under tests/, vendored crates under vendor/ and root files a crate embeds
# select the packages that read them. Changes to the lockfile, root manifest,
# toolchain/cargo/nextest config, this workflow, scripts/ or any file no package owns select the
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
@@ -32,11 +32,6 @@
# are all skipped; it gets its own concurrency group (below) so it never cancels the real run. Its
# `ci` job still runs and reports the commit's real result (see the `ci` job): the merge queue
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
# SolidCore checks run SolidCore's own required checks (its fmt, Clippy, panic-site inventory, tests,
# kernel rig, goldens and wasm compile, as solidcore/.github/workflows/ci.yml defines them) from
# solidcore/, on a pull request (once approved), merge group or push to main that changes
# solidcore/** (main saves their caches), with SolidCore's cargo-deny (licences, bans, sources,
# advisories): solidcore/ is outside the workspace, so the workspace's cargo-deny never sees it.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# The release gate (release-gate.yml) calls this workflow (workflow_call) as a Run workflow with
# cross-compilation on.
@@ -135,6 +130,7 @@ jobs:
actions: read
outputs:
native: ${{ steps.paths.outputs.native }}
kernel: ${{ steps.paths.outputs.kernel }}
windows: ${{ steps.paths.outputs.windows }}
# The GPU path tests run (see the rule where it is computed). The job's `if` and the `ci` job's check both read this, so they cannot disagree.
gpu: ${{ steps.paths.outputs.gpu }}
@@ -148,8 +144,6 @@ jobs:
deps: ${{ steps.paths.outputs.deps }}
# A merge group reaches the wasm build: Compile WebAssembly.
wasm: ${{ steps.paths.outputs.wasm }}
# solidcore/** changed (approved pull requests, merge groups, pushes to main): SolidCore checks.
solidcore: ${{ steps.paths.outputs.solidcore }}
# The passing run a merge of main carries forward (its URL), when this push is only that.
carried: ${{ steps.paths.outputs.carried }}
steps:
@@ -163,7 +157,6 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
BEFORE_SHA: ${{ github.event.before }}
GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
GROUP_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -175,11 +168,11 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
native=false
kernel=false
windows=false
gpu_paths=false
deps=false
wasm=false
solidcore=false
full=true
packages=
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
@@ -193,12 +186,12 @@ jobs:
}
# Whether the NUL-separated paths in $1 (the diff $2) reach 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 and kernel, their scripts, or a lockfile change that moves
# the graphics stack (wgpu, naga, egui, winit, windows). Pull requests and merge groups share it.
# glue, the vendored egui-wgpu, their scripts, or a lockfile change that moves the
# graphics stack (wgpu, naga, egui, winit, windows). Pull requests and merge groups share it.
windows_paths() {
while IFS= read -r -d '' path; do
case "$path" in
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*|solidcore/*) windows=true ;;
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
done < "$1"
@@ -231,15 +224,10 @@ jobs:
Cargo.lock|Cargo.toml|*/Cargo.toml) deps=true ;;
esac
case "$path" in
crates/*|apps/solidcraft-web/*|vendor/*|solidcore/*|Cargo.lock|Cargo.toml|rust-toolchain*|.cargo/*|.github/workflows/ci.yml) wasm=true ;;
crates/*|apps/solidcraft-web/*|vendor/*|Cargo.lock|Cargo.toml|rust-toolchain*|.cargo/*|.github/workflows/ci.yml) wasm=true ;;
esac
done < "$1"
}
# Whether the NUL-separated paths in $1 change the vendored kernel (solidcore/**): SolidCore
# checks run its own required checks. Pull requests, merge groups and pushes to main share it.
solidcore_paths() {
if grep -qz '^solidcore/' "$1"; then solidcore=true; fi
}
# Opt-in for a big render change: a pull request labelled render-tests, or a merge group
# holding one (its pull requests are the (#N) of its squash commits), runs them whatever
# the paths say.
@@ -252,17 +240,6 @@ jobs:
return 1
}
if [ "$EVENT_NAME" = push ]; then
# SolidCore checks for a push to main that changes solidcore/**, whatever the queue
# tested: main saves their caches, which every pull request restores. A push that
# can't be compared (new branch, force push) runs them.
if [ "$GITHUB_REF_NAME" = main ]; then
if git diff --no-renames --name-only -z "$BEFORE_SHA" "$PUSH_SHA" > "$RUNNER_TEMP/ci-solidcore-paths" 2>/dev/null; then
solidcore_paths "$RUNNER_TEMP/ci-solidcore-paths"
else
solidcore=true
fi
echo "solidcore=$solidcore for $BEFORE_SHA..$PUSH_SHA"
fi
# 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.
@@ -297,7 +274,6 @@ jobs:
windows_paths "$RUNNER_TEMP/ci-paths" "$GROUP_BASE_SHA...$GROUP_HEAD_SHA"
gpu_paths "$RUNNER_TEMP/ci-paths"
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
solidcore_paths "$RUNNER_TEMP/ci-paths"
if [ "$gpu_paths" = false ] && group_has_render_label "$GROUP_BASE_SHA..$GROUP_HEAD_SHA"; then
gpu_paths=true
fi
@@ -306,9 +282,8 @@ jobs:
gpu_paths=true
deps=true
wasm=true
solidcore=true
fi
echo "windows=$windows gpu_paths=$gpu_paths deps=$deps wasm=$wasm solidcore=$solidcore for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
echo "windows=$windows gpu_paths=$gpu_paths deps=$deps wasm=$wasm for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
elif [ "$EVENT_NAME" != pull_request ]; then
# The nightly run and Run workflow: everything, Windows included.
native=true
@@ -317,13 +292,15 @@ jobs:
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
done < "$RUNNER_TEMP/ci-paths"
# Computed as for a merge group, then turned off below (the fast lane).
windows_paths "$RUNNER_TEMP/ci-paths" "$BASE_SHA...$HEAD_SHA"
# The advisories run on the pull request itself (it builds nothing of ours: about two minutes, most of it installing cargo-deny);
# the wasm compile waits for the merge queue.
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
wasm=false
solidcore_paths "$RUNNER_TEMP/ci-paths"
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
@@ -348,7 +325,6 @@ jobs:
quick=$native
native=false
oracle=false
solidcore=false
fi
# A push that only merges main into the change (Merge branch 'main', Update branch) when
# the change itself already passed: nothing new to test here, and main tests the merged
@@ -361,7 +337,6 @@ jobs:
if [ -n "$carried" ]; then
echo "Only a merge of main into a change that passed in $carried: repository checks only."
native=false
solidcore=false
fi
fi
# The GPU path tests (their own job, about 56 runner-minutes) are for render changes only:
@@ -386,12 +361,12 @@ jobs:
{
echo "native=$native"
echo "gpu=$gpu"
echo "kernel=$kernel"
echo "windows=$windows"
echo "oracle=$oracle"
echo "doctests=$doctests"
echo "deps=$deps"
echo "wasm=$wasm"
echo "solidcore=$solidcore"
echo "carried=$carried"
echo "full=$full"
echo "quick=$quick"
@@ -402,7 +377,7 @@ jobs:
name: Format and repository checks
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -430,11 +405,8 @@ jobs:
- name: Sorted lists
run: cargo xtask lists
# New UI text must reach every complete catalog; report it here, before review (#8542).
# Catalog coverage only: the command labels need the app's command table, which this job
# never builds (a release build of the CLI took 9-15 minutes). The `tests` job checks them
# against the CLI it builds anyway.
- name: Translations in the complete catalogs
run: cargo xtask i18n --check --no-registry
run: cargo xtask i18n --check
- name: Model decisions use exact geometry, not meshes
run: |
cargo xtask mesh-decisions
@@ -451,8 +423,8 @@ jobs:
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim, triage and PR-check bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/pr-checks/*.test.js
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
@@ -533,15 +505,9 @@ jobs:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -562,7 +528,7 @@ jobs:
compression-level: 0
- name: Compiler cache statistics
if: always()
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
run: sccache --show-stats
tests:
name: Native Linux tests
@@ -580,9 +546,6 @@ jobs:
# would skip for want of an adapter fails instead.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# The same for the shared viewport crate's own GPU tests, which read no SolidCraft variable.
VIEWPORT_TEST_GPU: software
VIEWPORT_TEST_REQUIRE_GPU: "1"
ACTION_MATRIX_JOBS: "12"
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -613,15 +576,9 @@ jobs:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -660,23 +617,9 @@ jobs:
--ignore-default-filter -E "($base) & not (package(=solidcraft-gpu) & test(/^path::/))" || status=$?
if [ "$status" -ne 0 ] && [ "$status" -ne 100 ]; then exit "$status"; fi
cargo xtask known-failing --junit "$junit"
# Every command label needs an English catalog entry (#8542). The label table is the CLI's
# command registry, so this builds the CLI in the profile the tests just used (the shared
# crates are cached) and reads it. Skipped when no selected package can change the table.
- name: Command labels in the translation catalogs
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
case "$FULL $PACKAGES " in
true*|*" solidcraft-cli "*|*" solidcraft-ui-egui "*|*" xtask "*) ;;
*) echo "No package that feeds the command table changed."; exit 0 ;;
esac
cargo build -p solidcraft-cli --profile ci --locked
cargo xtask i18n --check --cli "${CARGO_TARGET_DIR:-target}/ci/solidcraft-cli"
- name: Compiler cache statistics
if: always()
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
run: sccache --show-stats
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -706,9 +649,6 @@ jobs:
# As in Native Linux tests: lavapipe draws, and a test without an adapter fails.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# The same for the shared viewport crate's own GPU tests, which read no SolidCraft variable.
VIEWPORT_TEST_GPU: software
VIEWPORT_TEST_REQUIRE_GPU: "1"
# lavapipe's own shader cache (below).
MESA_SHADER_CACHE_DIR: /home/runner/.cache/mesa-gpu
CARGO_INCREMENTAL: "0"
@@ -752,15 +692,9 @@ jobs:
key: native-gpu
save-if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && matrix.shard == 1 }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -781,7 +715,7 @@ jobs:
cargo xtask known-failing --junit "$junit"
- name: Compiler cache statistics
if: always()
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
run: sccache --show-stats
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -914,15 +848,9 @@ jobs:
key: doc-tests
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
# nextest does not run doc tests.
- name: Doc tests
env:
@@ -978,15 +906,9 @@ jobs:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the command line
run: cargo build -p solidcraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
@@ -1015,9 +937,6 @@ jobs:
# WARP is a software (Cpu) adapter.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# The same for the shared viewport crate's own GPU tests, which read no SolidCraft variable.
VIEWPORT_TEST_GPU: software
VIEWPORT_TEST_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -1066,7 +985,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm, solidcore, solidcore-standalone]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -1124,9 +1043,6 @@ jobs:
ADVISORIES: ${{ needs.dependency-advisories.result }}
WASM_RUN: ${{ needs.changes.outputs.wasm }}
WASM: ${{ needs.wasm.result }}
SOLIDCORE_RUN: ${{ needs.changes.outputs.solidcore }}
SOLIDCORE: ${{ needs.solidcore.result }}
SOLIDCORE_STANDALONE: ${{ needs.solidcore-standalone.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -1164,11 +1080,6 @@ jobs:
if [ "$WASM_RUN" = true ]; then
test "$WASM" = success
fi
# solidcore/** changed on an approved pull request, in a merge group or on main.
if [ "$SOLIDCORE_RUN" = true ]; then
test "$SOLIDCORE" = success
test "$SOLIDCORE_STANDALONE" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
env:
@@ -1196,240 +1107,6 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- run: cargo xtask wasm
# SolidCore's own checks (its required fmt, clippy, test, kernel-rig, goldens and wasm, and its
# deny and publish-dry-run, in solidcore/.github/workflows/ci.yml, which GitHub does not run from
# a subfolder), with its commands, its toolchain (solidcore/rust-toolchain.toml) and solidcore/ as
# the working directory. For a pull request, merge group or push to main that changes
# solidcore/** (Select checks' `solidcore`), where `ci` requires it; skipped otherwise. Its build,
# kernel-rig and goldens jobs are one entry here, so the rig and goldens binaries are built once
# without an artifact between jobs.
# solidcore/ exported alone (as the mirror to tangency-labs/solidcore carries it) must still check: the
# `layers` rule reads the manifests, this builds them without the rest of the repository around.
solidcore-standalone:
name: SolidCore builds on its own
needs: changes
if: needs.changes.outputs.solidcore == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
env:
CARGO_INCREMENTAL: 0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Export solidcore/ alone
run: |
mkdir -p "$RUNNER_TEMP/sc"
git archive HEAD solidcore | tar -x -C "$RUNNER_TEMP/sc"
- name: Toolchain (solidcore/rust-toolchain.toml)
working-directory: ${{ runner.temp }}/sc/solidcore
run: rustup show active-toolchain || rustup toolchain install
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: "${{ runner.temp }}/sc/solidcore -> bin"
key: solidcore-standalone
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Check the exported tree
working-directory: ${{ runner.temp }}/sc/solidcore
run: cargo check --workspace --all-targets --locked
solidcore:
name: SolidCore checks (${{ matrix.check }})
needs: changes
if: needs.changes.outputs.solidcore == 'true'
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- check: fmt and clippy
key: lint
runner: blacksmith-8vcpu-ubuntu-2404
- check: tests
key: tests
runner: blacksmith-16vcpu-ubuntu-2404
- check: kernel rig and goldens
key: rig
runner: blacksmith-16vcpu-ubuntu-2404
- check: wasm
key: wasm
runner: blacksmith-4vcpu-ubuntu-2404
- check: deny
key: deny
runner: blacksmith-4vcpu-ubuntu-2404
- check: publish dry run
key: publish
runner: blacksmith-4vcpu-ubuntu-2404
defaults:
run:
working-directory: solidcore
env:
CARGO_INCREMENTAL: 0
# Tests that read upstream/TestFiles fail instead of skipping when it is missing.
SOLIDCORE_REQUIRE_UPSTREAM: "1"
BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Toolchain (solidcore/rust-toolchain.toml)
run: rustup show active-toolchain || rustup toolchain install
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: "solidcore -> bin"
key: solidcore-${{ matrix.key }}
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
# .github/actions/upstream, run from solidcore/ (a composite action's steps would run from the
# repository root): usd-brep at the pin in scripts/upstream-pin.env, for upstream/TestFiles.
- name: Upstream sources (TestFiles)
if: matrix.check == 'tests' || matrix.check == 'kernel rig and goldens'
run: |
while IFS='=' read -r k v; do
case "$k" in
UPSTREAM_REPO|UPSTREAM_VERSION|UPSTREAM_COMMIT) export "$k=$v" ;;
esac
done < scripts/upstream-pin.env
git init upstream
git -C upstream remote add origin "$UPSTREAM_REPO"
git -C upstream fetch --depth 1 origin "$UPSTREAM_COMMIT" \
|| git -C upstream fetch origin
git -C upstream checkout --detach "$UPSTREAM_COMMIT"
# fmt (and SolidCore's ci-scripts job, which is cheap enough to ride along).
- name: Format
if: matrix.check == 'fmt and clippy'
run: cargo fmt --all --check
# scripts/ci/check-frozen-archives.sh reads HEAD:<path> from the repository root, which here
# is SolidCraft's, so the same check runs on the solidcore/ paths.
- name: No new entries in frozen archives (one file per finding / upstream issue)
if: matrix.check == 'fmt and clippy'
run: |
fail=0
for f in plan/findings.md plan/upstream-issues.md crates/goldens/known_mismatches.toml; do
if git cat-file -e "HEAD:solidcore/$f" 2>/dev/null; then
echo "::error file=solidcore/$f::solidcore/$f is retired and must not come back (see scripts/ci/check-frozen-archives.sh)."
fail=1
fi
done
exit $fail
- name: CI script tests
if: matrix.check == 'fmt and clippy'
run: |
python3 -m unittest discover -s scripts/ci -p 'test_*.py' -v
scripts/ci/test-wasm-digest.sh
python3 -I scripts/ci/check_record_ids.py
- name: Clippy
if: matrix.check == 'fmt and clippy'
run: cargo clippy --workspace --all-targets -- -D warnings
# Panic-prone calls the deny lints don't see, compared with plan/panic-sites.tsv, which may
# only shrink. The checker reads <base>:plan/panic-sites.tsv from the repository root, so it
# gets a commit whose tree is the base's solidcore/ (none before solidcore/ existed: the
# ratchet then starts with this list, as the checker reports).
- name: Panic-site inventory
if: matrix.check == 'fmt and clippy'
run: |
if [ -n "$BASE" ]; then
git cat-file -e "$BASE^{commit}" 2>/dev/null || git fetch -q --no-tags --depth=1 origin "$BASE"
fi
if [ -n "$BASE" ] && tree=$(git rev-parse -q --verify "$BASE:solidcore"); then
base=$(GIT_AUTHOR_NAME=ci GIT_AUTHOR_EMAIL=ci@invalid GIT_COMMITTER_NAME=ci GIT_COMMITTER_EMAIL=ci@invalid \
git commit-tree "$tree" -m "solidcore/ at $BASE")
python3 -I scripts/ci/check_panic_sites.py --base "$base"
else
python3 -I scripts/ci/check_panic_sites.py
fi
# test: every test once in the goldens profile, then the doctests, then the solids report
# (exits 1 while any primitive fails, so only a crash or a missing JSON fails).
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
if: matrix.check == 'tests'
with:
tool: cargo-nextest@0.9.150
- name: Workspace tests (goldens profile)
if: matrix.check == 'tests'
run: cargo nextest run --workspace --cargo-profile goldens --no-fail-fast
- name: Doctests (goldens profile)
if: matrix.check == 'tests'
run: cargo test --workspace --profile goldens --doc
- name: Solids report (primitives example)
if: matrix.check == 'tests'
run: |
set +e
cargo run --profile goldens -p solidcore --example primitives -- --json reports/solids/solids.json
rc=$?
set -e
[ "$rc" -le 1 ] && test -s reports/solids/solids.json
# main's solids report, for SolidCore's site (solidcore-pages.yml, scripts/fetch-solids.sh).
- name: Upload solids report
if: matrix.check == 'tests' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: solids-report
path: solidcore/reports/solids/solids.json
if-no-files-found: error
retention-days: 90
# build, kernel-rig and goldens: the rig's test binary and the goldens binary (goldens
# profile), the rig run from its crate's directory, then every unit against its golden.
- name: Build the rig and goldens binaries (goldens profile)
id: binaries
if: matrix.check == 'kernel rig and goldens'
run: |
mkdir -p dist
rig=$(cargo test --profile goldens -p solidcore-ops --test kernel_cases --no-run \
--message-format=json \
| jq -r 'select(.reason == "compiler-artifact" and .target.name == "kernel_cases"
and .profile.test) | .executable' | tail -n 1)
test -x "$rig"
cp "$rig" dist/kernel_cases
cargo build --profile goldens -p goldens
cp bin/goldens/goldens dist/goldens
- name: Kernel rig
if: matrix.check == 'kernel rig and goldens'
env:
KERNEL_CASES_REPORT: ${{ github.workspace }}/solidcore/reports/kernel-rig/table.md
# each case in a process of its own, one per vCPU of the 16-vCPU, 64 GB runner, with a
# CPU-time hang limit (#14277)
KERNEL_CASES_JOBS: "16"
run: |
mkdir -p reports/kernel-rig
cd crates/solidcore-ops
"$GITHUB_WORKSPACE/solidcore/dist/kernel_cases" --ignored --nocapture
- name: Upload the kernel rig table
if: always() && matrix.check == 'kernel rig and goldens'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: solidcore-kernel-rig
path: solidcore/reports/kernel-rig/
if-no-files-found: ignore
retention-days: 30
# Also when the rig failed: SolidCore runs the two as separate jobs.
- name: Every unit against its golden
if: ${{ !cancelled() && matrix.check == 'kernel rig and goldens' && steps.binaries.outcome == 'success' }}
run: dist/goldens --all --goldens --jobs 0 --no-report
- name: Compile solidcore for wasm32
if: matrix.check == 'wasm'
run: |
rustup target add wasm32-unknown-unknown
cargo check -p solidcore --target wasm32-unknown-unknown
# SolidCore's deny job (licences, bans, sources) with its deny.toml, plus the advisories, as
# the workspace's Dependency advisories check them (unmaintained crates reported, not fatal).
- name: Install cargo-deny
if: matrix.check == 'deny'
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Licences, bans and sources (solidcore/deny.toml)
if: matrix.check == 'deny'
run: cargo deny --locked check licenses bans sources
- name: Advisories
if: matrix.check == 'deny'
run: cargo deny --locked check --warn unmaintained advisories
# The packaging check SolidCore's release workflow runs before publishing (no secrets).
- name: Publish dry run
if: matrix.check == 'publish dry run'
run: cargo publish -p solidcore --locked --dry-run
riscv64-gates:
name: Compile RISC-V (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
-6
View File
@@ -85,15 +85,9 @@ jobs:
key: fillet-rig
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the CLI (release)
run: cargo build --release --locked -p solidcraft-cli
- name: Run the fillet cases against the known-failing list
+17 -5
View File
@@ -1,4 +1,4 @@
# Files generated from the code are rewritten here, on main, once a night, so pull requests
# 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`).
# Pending files (assets/locales/pending, attribution/pending) are folded into the catalogs and
@@ -6,10 +6,22 @@
name: Generated files
on:
# Nightly, outside merge hours. A push straight to main resets every merge-queue batch, so the
# regeneration no longer runs on each push.
schedule:
- cron: "17 8 * * *"
push:
branches: [main]
# THIRD-PARTY-LICENSES.txt follows the manifests, the lockfile, the vendored crates' licence
# files and the fonts' licences; docs/features.md follows the command registry (crates/engine),
# read through solidcraft-cli. Other pushes regenerate nothing.
paths:
- "Cargo.lock"
- "**/Cargo.toml"
- "vendor/**"
- "assets/fonts/**"
- "crates/engine/**"
- "apps/solidcraft-cli/**"
- "xtask/**"
- "assets/locales/pending/*.json"
- "attribution/pending/*.md"
- ".github/workflows/generated.yml"
workflow_dispatch:
permissions:
+43
View File
@@ -0,0 +1,43 @@
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
# in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: write # to list a PR's changed paths and add its label
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+21 -22
View File
@@ -1,8 +1,8 @@
# The kernel rig and the complexity ladder as a regression gate. A plan job maps a pull request's
# changed files to the rig areas they can affect (scripts/ci/kernel_rig_plan.py): an area runs
# when a file its tests/kernel-cases/<area>/PATHS lists changes, or one of its cases; every area
# runs when the shared core in tests/kernel-cases/PATHS-ALL changes, including the vendored
# SolidCore source in solidcore; the ladder runs whenever any
# runs when the shared core in tests/kernel-cases/PATHS-ALL changes, and when the solidcore pin in
# the root Cargo.toml and Cargo.lock moves (the plan reads their diff); the ladder runs whenever any
# area does. One job then builds solidcraft-cli once (release), checks that every
# registry command has a case or an exemption (tests/kernel-cases/EXEMPT), and hands the CLI to a
# matrix of shards as an artifact, one per selected area plus the ladder, in parallel, so the
@@ -17,8 +17,8 @@
# The trigger paths are whole folders that hold every area's PATHS (a self-test,
# scripts/ci/test_kernel_rig_plan.py, checks they do), so a pull request that touches none of
# them (the UI, docs, packaging) starts no runner at all, and a new area needs no edit here: the
# plan finds the areas from the folders. Merge groups run the areas their batch's diff reaches, like a
# pull request. Pushes to main and develop on the same paths, the release gate (release-gate.yml, workflow_call) and a run by hand run
# plan finds the areas from the folders. Pushes to main and develop on the same paths, merge groups
# (once the queue is on), the release gate (release-gate.yml, workflow_call) and a run by hand run
# every area; those pushes also commit the compiler cache (a Blacksmith sticky disk) that every
# other run mounts read-only.
name: Kernel rig
@@ -30,12 +30,14 @@ on:
- "crates/kernel/**"
- "crates/geom/**"
- "crates/smlib/**"
- "crates/step/**"
- "crates/io/src/**"
- "crates/doc/src/**"
- "crates/engine/src/**"
- "tests/kernel-cases/**"
# The vendored kernel: a change here runs every area (tests/kernel-cases/PATHS-ALL).
- "solidcore/**"
# The solidcore pin (the plan runs every area only when its lines change).
- "Cargo.toml"
- "Cargo.lock"
# The rig itself: the grading, the runner, the selection and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
@@ -50,12 +52,14 @@ on:
- "crates/kernel/**"
- "crates/geom/**"
- "crates/smlib/**"
- "crates/step/**"
- "crates/io/src/**"
- "crates/doc/src/**"
- "crates/engine/src/**"
- "tests/kernel-cases/**"
# The vendored kernel: a change here runs every area (tests/kernel-cases/PATHS-ALL).
- "solidcore/**"
# The solidcore pin (the plan runs every area only when its lines change).
- "Cargo.toml"
- "Cargo.lock"
# The rig itself: the grading, the runner, the selection and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
@@ -94,21 +98,22 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# A pull request's diff needs its base's history.
fetch-depth: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && '0' || '1' }}
fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}
- name: Selection self-test
run: python3 -I scripts/ci/test_kernel_rig_plan.py
- id: plan
name: Map the changed files to areas
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Pull requests and merge groups run the areas their diff reaches (a batch that touches no
# kernel path runs none); pushes to main, the release gate and a run by hand run every area.
if [ "$EVENT_NAME" = pull_request ] || [ "$EVENT_NAME" = merge_group ]; then
# Pull requests run the areas their diff reaches; pushes, merge groups, the release
# gate and a run by hand run every area.
if [ "$EVENT_NAME" = pull_request ]; then
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/rig-paths"
python3 -I scripts/ci/kernel_rig_plan.py --paths "$RUNNER_TEMP/rig-paths" \
git diff --no-renames "$BASE_SHA...$HEAD_SHA" -- Cargo.toml Cargo.lock > "$RUNNER_TEMP/rig-pin.diff"
python3 -I scripts/ci/kernel_rig_plan.py --paths "$RUNNER_TEMP/rig-paths" --pin-diff "$RUNNER_TEMP/rig-pin.diff" \
--github-output "$GITHUB_OUTPUT"
else
python3 -I scripts/ci/kernel_rig_plan.py --all --github-output "$GITHUB_OUTPUT"
@@ -144,15 +149,9 @@ jobs:
key: kernel-rig
save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }}
- name: Set up compiler cache
id: sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
continue-on-error: true
- name: Build without the compiler cache if it could not be set up
if: steps.sccache.outcome == 'failure'
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
- name: Build the CLI (release) and the rig's runner
run: |
cargo build --release --locked -p solidcraft-cli
@@ -175,7 +174,7 @@ jobs:
retention-days: 1
- name: Compiler cache statistics
if: always()
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
run: sccache --show-stats
shard:
name: Kernel rig (${{ matrix.shard }})
+2 -13
View File
@@ -725,16 +725,10 @@ jobs:
run: |
tag="v$VERSION"
sha256sum --check SHA256SUMS.txt
# Generated notes start at the newest published release, pre-releases included. Left
# to GitHub they start at the Latest stable one, and the list outgrows the 125,000
# characters a release body may have.
start=()
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
# Stage all packages before publishing. Published tags are never replaced.
gh release create "$tag" --draft --prerelease --latest=false \
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes "${start[@]}" -- *
--notes-file ../notes.md --generate-notes -- *
gh release edit "$tag" --draft=false --prerelease --latest=false
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
- name: Create or update the draft release
@@ -766,13 +760,8 @@ jobs:
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
# As for nightlies: start the generated notes at the newest published release.
start=()
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${start[@]}" \
"${pre[@]}" -- *
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
-142
View File
@@ -1,142 +0,0 @@
# Mirror SolidCraft's vendored kernel to its own repository.
#
# `solidcore` is the SolidCore kernel, vendored with `git subtree`, so `main` keeps
# SolidCore's history. This workflow pushes the commits under `solidcore` back to
# tangency-labs/solidcore, so the same history can be read from either repository.
#
# It mirrors incrementally (scripts/ci/solidcore-mirror.sh): one mirror commit per new main
# commit that changes solidcore/, a merged branch's commits included, each with exactly that
# commit's solidcore/ tree, its author, dates and message, and a `solidcraft: <sha>` trailer. The
# last one records the main commit it reached (`solidcraft-main:`), where the next run starts; the
# first run starts from the vendoring merge. Never `git subtree split`: it replays main's whole
# history (about 100 minutes) and overflows dash's recursion limit.
#
# The push is always a fast-forward. A force push would rewrite SolidCore's history, so the
# workflow stops instead when the two have diverged, or when a mirror commit would not be the
# kernel alone.
#
# SolidCore's `main` is protected. The deploy key must have write access to that repository and
# must bypass its `protect-main` ruleset (see the pull request that added this file).
name: Mirror to SolidCore
on:
push:
branches: [main]
# Only a change to the vendored kernel can move SolidCore. Editing this file rehearses it.
paths:
- "solidcore/**"
- ".github/workflows/solidcore-mirror.yml"
- "scripts/ci/solidcore-mirror.sh"
workflow_dispatch:
permissions:
contents: read
# One run at a time, never cancelled: two pushes to SolidCore must not race.
concurrency:
group: solidcore-mirror
cancel-in-progress: false
env:
# The directory in SolidCraft that is the whole of SolidCore.
PREFIX: solidcore
SOLIDCORE_URL: git@github.com:tangency-labs/solidcore.git
SOLIDCORE_BRANCH: main
jobs:
mirror:
name: Mirror main's kernel commits
runs-on: ubuntu-24.04
# Incremental: seconds per run.
timeout-minutes: 15
defaults:
run:
shell: bash
steps:
# Always the real main, and its whole history: the mirror walks main's ancestry since the
# last mirrored commit, which a shallow clone would not have.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
persist-credentials: false
# The private half of a deploy key with write access to tangency-labs/solidcore. Fail here,
# with a readable message, instead of somewhere inside git.
- name: Set up the SolidCore deploy key
env:
SOLIDCORE_DEPLOY_KEY: ${{ secrets.SOLIDCORE_DEPLOY_KEY }}
run: |
if [ -z "$SOLIDCORE_DEPLOY_KEY" ]; then
echo "::error::The SOLIDCORE_DEPLOY_KEY secret is not set. Add the private half of a deploy key that has write access to tangency-labs/solidcore under Settings > Secrets and variables > Actions."
exit 1
fi
install -d -m 700 "$RUNNER_TEMP/ssh"
(umask 077 && printf '%s\n' "$SOLIDCORE_DEPLOY_KEY" > "$RUNNER_TEMP/ssh/solidcore")
# The fetch and the push inherit this, so both use the same key.
printf '%s\n' "GIT_SSH_COMMAND=ssh -i $RUNNER_TEMP/ssh/solidcore -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new" >> "$GITHUB_ENV"
# Decide, before touching SolidCore, whether there is anything to mirror and whether it is a
# fast-forward. There are three states: in sync, SolidCore behind (push), diverged (fail).
- name: Check what needs mirroring
id: check
run: |
set -euo pipefail
# The remote tip, so the ancestry tests below compare against what SolidCore has.
if ! git fetch --no-tags "$SOLIDCORE_URL" "$SOLIDCORE_BRANCH"; then
echo "::error::Could not fetch $SOLIDCORE_URL $SOLIDCORE_BRANCH. Check that SOLIDCORE_DEPLOY_KEY is the private half of a solidcore deploy key with write access, and that the branch exists."
exit 1
fi
REMOTE=$(git rev-parse FETCH_HEAD)
# Mirror commits for main's solidcore/ commits since the last run, built incrementally on
# SolidCore's tip (never `git subtree split`, which replays main's whole history). The
# script fails on divergence or anything that is not a fast-forward of SolidCore's tip.
bash scripts/ci/solidcore-mirror.sh "$REMOTE" HEAD > "$RUNNER_TEMP/mirror.out"
cat "$RUNNER_TEMP/mirror.out"
STATE=$(sed -n 's/^state=//p' "$RUNNER_TEMP/mirror.out")
SPLIT=$(sed -n 's/^tip=//p' "$RUNNER_TEMP/mirror.out")
COUNT=$(sed -n 's/^count=//p' "$RUNNER_TEMP/mirror.out")
{
echo "state=$STATE"
echo "split=$SPLIT"
echo "remote=$REMOTE"
} >> "$GITHUB_OUTPUT"
{
echo "### Mirror to SolidCore"
echo
echo "| | commit |"
echo "| --- | --- |"
echo "| SolidCraft \`$PREFIX\` mirror ($COUNT new) | \`$SPLIT\` |"
echo "| solidcore \`$SOLIDCORE_BRANCH\` | \`$REMOTE\` |"
echo
case "$STATE" in
in-sync) echo "**In sync.** Nothing to mirror." ;;
fast-forward) echo "**Fast-forward push needed.** SolidCore is behind." ;;
esac
} >> "$GITHUB_STEP_SUMMARY"
# Push the checked commit itself. A plain push is also the safety net: git refuses a
# non-fast-forward.
- name: Push the mirror commit
if: steps.check.outputs.state == 'fast-forward'
env:
SPLIT: ${{ steps.check.outputs.split }}
run: |
set -euo pipefail
if ! git push "$SOLIDCORE_URL" "$SPLIT:refs/heads/$SOLIDCORE_BRANCH"; then
echo "::error::Push to solidcore failed. If git names a protected branch or GH006, SOLIDCORE_DEPLOY_KEY has no write access or does not bypass solidcore's protect-main ruleset. If git names a non-fast-forward, solidcore moved during this run; re-run."
exit 1
fi
# Confirm SolidCore now has exactly the commit this run checked.
PUSHED=$(git ls-remote "$SOLIDCORE_URL" "refs/heads/$SOLIDCORE_BRANCH" | cut -f1)
if [ "$PUSHED" != "$SPLIT" ]; then
echo "::error::solidcore $SOLIDCORE_BRANCH is $PUSHED after the push, expected $SPLIT."
exit 1
fi
{ echo; echo "**Mirrored.** solidcore \`$SOLIDCORE_BRANCH\` is now \`$SPLIT\`."; } >> "$GITHUB_STEP_SUMMARY"
-69
View File
@@ -1,69 +0,0 @@
# SolidCore's slow checks, too expensive for every pull request: SolidCore's own nightly
# (solidcore/.github/workflows/nightly.yml), run from solidcore/, since GitHub does not run a
# subfolder's workflows.
name: SolidCore nightly
on:
schedule:
- cron: "41 3 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: solidcore-nightly
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
slow:
name: SolidCore slow checks
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 240
defaults:
run:
working-directory: solidcore
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Toolchain (solidcore/rust-toolchain.toml)
run: rustup show active-toolchain || rustup toolchain install
# solidcore/.github/actions/upstream, run from solidcore/ (a composite action's steps would
# run from the repository root): usd-brep at the pin in scripts/upstream-pin.env.
- name: Upstream sources (TestFiles)
run: |
while IFS='=' read -r k v; do
case "$k" in
UPSTREAM_REPO|UPSTREAM_VERSION|UPSTREAM_COMMIT) export "$k=$v" ;;
esac
done < scripts/upstream-pin.env
git init upstream
git -C upstream remote add origin "$UPSTREAM_REPO"
git -C upstream fetch --depth 1 origin "$UPSTREAM_COMMIT" \
|| git -C upstream fetch origin
git -C upstream checkout --detach "$UPSTREAM_COMMIT"
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: "solidcore -> bin"
key: solidcore-nightly
# Owner-approved (2026-10-26): a regression too slow for every PR (~3 min): the
# tiny-scale torus booleans finish under MAX_SOLVE_BRANCHES (geom::global_solver).
- name: Slow regressions
if: ${{ !cancelled() }}
run: cargo test -p solidcore --release --test max_solve_branches_tiny_torus -- --ignored
# Owner-approved (2026-10-27): the SolidCraft whole-corpus harness checks (every
# case reaches a verdict; every deferral names a plan unit) take minutes even
# optimised, so they run here instead of on every pull request.
- name: SolidCraft corpus harness checks
if: ${{ !cancelled() }}
run: >-
cargo test --profile goldens -p goldens --lib -- --ignored --exact
solidcraft::tests::every_case_reaches_a_verdict_without_a_harness_error
solidcraft::tests::deferrals_name_a_plan_unit
-88
View File
@@ -1,88 +0,0 @@
# SolidCore's progress site (solidcore/site/build.py) and unit checklist
# (solidcore/harness/plan/progress.py, published as PROGRESS.md), at the same address as before:
# https://tangency-labs.github.io/solidcore/. SolidCore's own Pages workflow
# (solidcore/.github/workflows/pages.yml), ported: GitHub does not run a subfolder's workflows.
#
# This repository's Pages site is SolidCraft's book and web app (pages.yml), and a repository has
# one Pages site, so this one is pushed to tangency-labs/solidcore's gh-pages branch with the
# mirror's deploy key (SOLIDCORE_DEPLOY_KEY, solidcore-mirror.yml). That repository's Settings ->
# Pages -> Source must be "Deploy from a branch", gh-pages, / (root).
#
# The site reads SolidCore's history (its progress timeline) and paths from its repository root,
# so it is built from a checkout of the mirror, tangency-labs/solidcore, whose main is exactly
# this repository's solidcore/ once the mirror has run.
#
# Triggers:
# - workflow_run of "Mirror to SolidCore" on main: every mirrored change.
# - schedule (hourly): claims from open "port: <unit id>" pull requests (now in this repository),
# and main's newest solids report (SolidCore checks upload it). Not after every CI run on main,
# as SolidCore's did: SolidCraft's main runs CI far more often.
# - workflow_dispatch: manual rebuild.
name: SolidCore pages
on:
workflow_run:
workflows: ["Mirror to SolidCore"]
types: [completed]
branches: [main]
schedule:
- cron: "17 * * * *"
workflow_dispatch:
permissions:
contents: read
actions: read # download main's solids report
pull-requests: read # open "port:" PRs for the checklist's claims
concurrency:
group: solidcore-pages
cancel-in-progress: false
jobs:
build:
name: Build and publish SolidCore's site
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: tangency-labs/solidcore
ref: main
fetch-depth: 0 # full history for the progress timeline
persist-credentials: false
# Main's solids report comes from this repository's CI now (SolidCore checks, tests).
- name: Fetch main's solids report
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: scripts/fetch-solids.sh || echo "no solids report; the Solids page will say so"
- name: Test checklist generator
run: python3 -m unittest discover -s harness/plan -p 'test_*.py'
- name: Generate checklist
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: python3 harness/plan/progress.py --prs
# The site links and its own address are SolidCore's.
- name: Build site
run: GITHUB_REPOSITORY=tangency-labs/solidcore python3 site/build.py --out _site
- name: Publish to tangency-labs/solidcore's gh-pages
env:
SOLIDCORE_DEPLOY_KEY: ${{ secrets.SOLIDCORE_DEPLOY_KEY }}
SHA: ${{ github.sha }}
run: |
if [ -z "$SOLIDCORE_DEPLOY_KEY" ]; then
echo "::error::The SOLIDCORE_DEPLOY_KEY secret is not set (see solidcore-mirror.yml)."
exit 1
fi
install -d -m 700 "$RUNNER_TEMP/ssh"
(umask 077 && printf '%s\n' "$SOLIDCORE_DEPLOY_KEY" > "$RUNNER_TEMP/ssh/solidcore")
export GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/ssh/solidcore -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
touch _site/.nojekyll
cd _site
git init -q -b gh-pages
git add -A
git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
commit -q -m "Site for solidcore $(git -C .. rev-parse --short HEAD) (SolidCraft $SHA)"
# The branch holds only the latest site: replacing it is the deploy.
git push -q --force git@github.com:tangency-labs/solidcore.git gh-pages
-54
View File
@@ -1,54 +0,0 @@
# Publishes the vendored SolidCore kernel's `solidcore` crate to crates.io when a tag
# "solidcore-v<version>" is pushed, e.g. `git tag solidcore-v0.0.1 && git push origin solidcore-v0.0.1`.
# SolidCore's own release workflow (solidcore/.github/workflows/release.yml), run from solidcore/:
# GitHub does not run a subfolder's workflows. SolidCraft's releases use no tag trigger, so the
# solidcore- prefix keeps the two apart.
# Needs the repository secret CRATES_IO_TOKEN (crates.io token, scope publish-update), in the
# crates-io environment.
name: Release SolidCore
on:
push:
tags: ["solidcore-v*"]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: crates-io
defaults:
run:
working-directory: solidcore
steps:
# Pinned by SHA and no third-party actions: this job holds the crates.io token.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0 # full history, for the on-main check below
- name: Tagged commit is on main
run: |
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::$GITHUB_REF_NAME ($GITHUB_SHA) is not an ancestor of origin/main"
exit 1
fi
- name: Toolchain (solidcore/rust-toolchain.toml)
run: rustup show active-toolchain || rustup toolchain install
- name: Tag matches crate version
run: |
want="${GITHUB_REF_NAME#solidcore-v}"
have="$(cargo metadata --no-deps --format-version 1 \
| python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"]=="solidcore"))')"
echo "tag $want, solidcore/crates/solidcore/Cargo.toml $have"
test "$want" = "$have"
- run: cargo test -p solidcore --locked
- run: rustup target add wasm32-unknown-unknown && cargo check -p solidcore --locked --target wasm32-unknown-unknown
# Package and verify without the token first; only the real publish sees it.
- name: Publish (dry run)
run: cargo publish -p solidcore --locked --dry-run
- name: Publish
run: cargo publish -p solidcore --locked
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
+8 -10
View File
@@ -3,30 +3,30 @@
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
## Start here
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`.
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
## Hard rules (what enforces each)
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- New kernel cases go to solidcore: `cargo xtask lists`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`; see `docs/layers.md`.
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives in `docs/compatibility.md`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
- Sorted lists (message catalogs, `mod` lines, languages) stay sorted: `cargo xtask lists`. Never edit generated files (`docs/features.md`, `THIRD-PARTY-LICENSES.txt`) in a PR.
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default (existing UI, not kernel results) keeps the old behaviour behind a preference; fill the PR's `Changes a default:` line.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
- Code, comments, commit messages and docs are in English; independent work runs in parallel, dependent work stays sequential: `skills/solidcraft-contributing/SKILL.md` ("Working as an agent").
- Show observable changes with real screenshots or a recording, captured yourself.
- Run `cargo xtask ci` before every commit. `cargo xtask skills` keeps this file and `skills/` honest.
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
## Working on X: read the skill first
| Working on | Read |
@@ -35,9 +35,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
| SolidCraft or solidcore, kernel, geometry | `skills/solidcraft-layers/SKILL.md` |
| A dialog, panel, camera, icon, colour | `skills/solidcraft-ui/SKILL.md` |
| Sheet metal | `skills/solidcraft-sheetmetal/SKILL.md` |
| Labels, messages, translations | `skills/solidcraft-ui-text/SKILL.md` |
| Saving something new in a design, import, export | `skills/solidcraft-file-formats/SKILL.md` |
| Preferences, a changed default | `skills/solidcraft-preferences/SKILL.md` |
+458 -503
View File
File diff suppressed because it is too large Load Diff
+30 -63
View File
@@ -17,29 +17,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work goes in solidcore/
## Kernel work is paused
Kernel and geometry work is welcome: booleans, fillets, chamfers, shell, offsets, sweeps and lofts,
threads and holes, STEP/IGES import and export, tessellation. It goes in `solidcore/`, the vendored
solidcore kernel, following [`solidcore/docs/layers.md`](solidcore/docs/layers.md) and
`skills/solidcraft-layers/SKILL.md`. SolidCraft's own `crates/kernel`, `crates/smlib` and
`crates/geom` only shrink toward the seam (ops, tess, record); please don't grow them.
A kernel pull request targets `main` like any other; a change that touches the kernel and the app is one
pull request. A big feature in several steps goes as stacked pull requests into `main`, each one landing in order
on top of the one before.
Crashes and data loss are still taken first.
### Updating a PR that was moved from solidcore
solidcore's open PRs were re-opened here on `moved/solidcore-N` branches in tangency-labs, so
contributors without write access can't push to them. To update one:
1. `git fetch https://github.com/tangency-labs/solidcraft moved/solidcore-N:moved/solidcore-N`, then
`git switch moved/solidcore-N`.
2. Commit your changes on it and push it to your fork: `git push <your-fork> moved/solidcore-N`.
3. Open a PR against `main` that says "Replaces #M" (M is the moved PR's number). The moved one
gets closed.
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
change `crates/kernel/`, `crates/geom/` or `vendor/truck*`, or issues and discussions about kernel
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
including the dialogs and tools for these features.
## Get it running (about 10 minutes on a fast machine)
@@ -104,11 +90,10 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
- `crates/sketch`: our own constraint solver, plus profile finding.
- `crates/dynamics`: multibody dynamics: rigid bodies, joint constraints, gravity and springs,
integrated in time (RFC #9719). It depends only on `geom`.
- `crates/kernel`: the B-rep kernel boundary: solids, extrude/revolve, booleans, fillets,
meshes, measures and STEP, behind our own API (implemented with `solidcore`). `crates/smlib` is
the safe wrapper around `solidcore`, and `solidcore` is vendored in this repository at
`solidcore/`; `.github/workflows/solidcore-mirror.yml` mirrors changes to it back to
[tangency-labs/solidcore](https://github.com/tangency-labs/solidcore).
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
copies of three truck crates live in `vendor/` and are excluded from our fmt/clippy rules.
- `crates/doc`: the document. It holds parameters and expressions, the feature timeline and its
incremental rebuild, components and occurrences, joints, appearances, and persistent naming.
- `crates/io`: design files and mesh formats (3MF, STL, OBJ).
@@ -121,12 +106,6 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
Whether a change belongs in SolidCraft or in SolidCore is decided by
[`solidcore/docs/layers.md`](solidcore/docs/layers.md). Its tests: the **kernel** (`solidcore`)
computes geometry any client could use; **ops** (`solidcore-ops`) is a recipe of kernel calls for a
workflow, with no geometric quantity of its own; the **app** (SolidCraft) holds state and maps input
to commands, with no geometry math.
## Rules that aren't negotiable
1. **Independent.** SolidCraft is independent. Behaviour is designed from public
@@ -154,7 +133,7 @@ to commands, with no geometry math.
`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 the vendored kernel goes through `solidcraft_kernel::guard`.
- Every call into truck goes through `solidcraft_kernel::guard`.
- Every crash fix comes with a regression test.
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/` with input and output schemas (see
@@ -189,15 +168,14 @@ The reference for what the kernel builds is the kernel-cases ladder (`tests/kern
**Kernel cases go to solidcore.** A case that checks a kernel operation (a boolean, a blend, a
shell, a sweep, an import's geometry) is a new entry in solidcore's corpus,
`solidcore/crates/solidcore/tests/kernel_cases/<area>/` in this repository, with an answer
in closed form or exact. Draft it as a rig case here, export it with
`crates/solidcore/tests/kernel_cases/<area>/` in
[tangency-labs/solidcore](https://github.com/tangency-labs/solidcore), with an answer in closed
form or exact. Draft it as a rig case here, export it with
`cargo xtask kernel-cases --area <area> --export DIR`, add `DIR/<area>/<case>.json` and its
`.bodies.json.gz` under `solidcore`, and delete the draft; or write the entry by hand as
that folder's README describes. The mirror workflow pushes the change to
[tangency-labs/solidcore](https://github.com/tangency-labs/solidcore). `tests/kernel-cases/` takes
no new cases: its `CASES` list only shrinks (`cargo xtask lists`). App-level cases (what a command
does, the UI, product rules) stay in SolidCraft, as tests in their area's existing test file or UI
scenarios.
`.bodies.json.gz` there, and delete the draft; or write the entry by hand as that folder's README
describes. `tests/kernel-cases/` takes no new cases: its `CASES` list only shrinks
(`cargo xtask lists`). App-level cases (what a command does, the UI, product rules) stay in
SolidCraft, as tests in their area's existing test file or UI scenarios.
The naming corpus checks persistent names: every name a corpus design stores still finds a face or
an edge, and two builds name its faces and edges alike
@@ -227,7 +205,7 @@ Either include the fix in the same PR, or mark the test `#[ignore = "fails until
(it can then merge right away), and remove that line in the PR that fixes the bug.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off. It
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
@@ -301,7 +279,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing
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 warns about a design that has your change and keeps what it can't read of it.
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.
@@ -321,7 +299,7 @@ and a fresh CI run:
- **Growing Rust lists**: use slices (`const ITEMS: &[Item] = &[...];`) without a written
length. `cargo xtask lists` rejects non-test const/static arrays with more than ten lines
of entries, except fixed standard or algorithm tables listed with reasons in `xtask/src/lists.rs`.
Built-in themes need only a JSON file in `crates/look/themes/`, with a unique nonnegative
Built-in themes need only a JSON file in `assets/styles/ui/themes/`, with a unique nonnegative
integer `order` (the picker order) and its own `provenance` line; keep existing order values.
- **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`
@@ -374,10 +352,7 @@ and a fresh CI run:
quick compile (`cargo check`) when you push. Once a maintainer has reviewed it they add the
`approved-for-ci` label when the PR is ready to merge, which runs the full CI (build, tests,
goldens, Clippy); every push after that runs the full set again. Maintainers merge approved
PRs in groups by area, so PRs that touch the same code meet a conflict at most once. The one
exception is the pull request that first vendored SolidCore: it must land with a real merge
commit, because a squash merge drops the subtree parent and the mirror can no longer
fast-forward SolidCore (`.github/workflows/solidcore-mirror.yml`).
PRs in groups by area, so PRs that touch the same code meet a conflict at most once.
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).
5. Write the PR title and first sentence for someone who hasn't seen your chat, the issue or the
@@ -443,13 +418,9 @@ 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.
If you run an agent, keep one long-running session rather than restarting it, don't `--resume` one
whose prompt cache has gone cold (it re-reads the whole history at full price), and act on CodeRabbit
comments only when they're about crashes, wrong results, data loss or security.
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
fails when a lower-numbered open PR by someone else already closes the same issue (the label
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt). A default here is a UX default only: a control, setting, preference or tool behaviour a user sees by default; a changed kernel or geometry result is `Changes a default: no`.
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
## Labels and triage
@@ -474,13 +445,13 @@ correct them freely and it won't put them back.
## Where help is wanted most
**Kernel speed (slow fillets, booleans, STEP):** report slow cases with a file under the `kernel-perf` label, and put kernel effort into the vendored kernel, [solidcore](https://github.com/tangency-labs/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
**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 the vendored kernel's code (`solidcore`).
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
Constant, variable-radius and chord fillets work, and so do corners where fillets of
different radii meet, including three rounds meeting at a vertex. Next are setback corners, fillets along curved edges between curved
faces, and fillets that consume neighbouring faces.
@@ -509,15 +480,11 @@ The user guide's screenshots and short clips (`docs/book/src/media/`) are captur
app by `examples/demo/doc_shots.py`, one function per figure. Each run starts a fresh app with its
own settings, so your preferences never show. When a change makes a figure out of date, run
`python3 examples/demo/doc_shots.py <page or shot>` (it needs Xvfb, ffmpeg, Pillow and a release
build) and commit the new file. Stills come in both themes: `--light` makes `<name>.light.png`,
and the page shows the one that matches the book's theme (an `<img class="dark-ui">` and an
`<img class="light-ui">`). A new figure is a new function there, an ATTRIBUTION row in
`attribution/pending/`, and a `<video>` (clips) or the image pair on the page. Keep a still under
build) and commit the new file. A new figure is a new function there, an ATTRIBUTION row in
`attribution/pending/`, and a `<video>` (clips) or image link on the page. Keep a still under
about 250 KB and a clip under about 1 MB.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
of SolidCraft. A contribution to the brand and interface (`crates/ui-kit`, `crates/look`,
`crates/theme`, `assets/branding`, `assets/icons/app`) is licensed under
[LICENSE-BRAND](LICENSE-BRAND), which lets the maintainers also license it as MIT OR Apache-2.0. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).
of SolidCraft. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).
Generated
+211 -643
View File
File diff suppressed because it is too large Load Diff
+18 -38
View File
@@ -1,12 +1,11 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
# Third-party code kept with local fixes (vendor/), and the vendored SolidCore kernel (solidcore/);
# neither is linted as ours. See CONTRIBUTING.md, "How the code is laid out".
exclude = ["solidcore", "vendor"]
# Third-party code kept with local fixes (see vendor/README.md); not linted as ours.
exclude = ["vendor"]
[workspace.package]
version = "0.40.0-rc.3"
version = "0.40.0-rc.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,6 +20,7 @@ solidcraft-profile = { path = "crates/profile" }
solidcraft-sketch = { path = "crates/sketch" }
solidcraft-kernel = { path = "crates/kernel" }
solidcraft-smlib = { path = "crates/smlib" }
solidcraft-step = { path = "crates/step" }
solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
@@ -28,38 +28,18 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-ui-kit = { path = "crates/ui-kit" }
solidcraft-look = { path = "crates/look" }
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-color = { path = "crates/color" }
solidcraft-ui-text = { path = "crates/ui-text" }
solidcraft-theme = { path = "crates/theme" }
solidcraft-viewport = { path = "crates/viewport" }
solidcraft-viewport-egui = { path = "crates/viewport-egui" }
solidcraft-viewport-gpu = { path = "crates/viewport-gpu" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-mcp-server = { path = "crates/mcp-server" }
solidcraft-control = { path = "crates/control" }
solidcraft-control-egui = { path = "crates/control-egui" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Vendored with
# `git subtree` at solidcore: that tree is the source of truth, and
# .github/workflows/solidcore-mirror.yml pushes changes in it back to its own repository (#13153).
solidcore = { path = "solidcore/crates/solidcore" }
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
# move the rev on its own commit. For a
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "8ddce901671f1cebd9214a31d21895d725e886c8" }
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
# and the native body file. Vendored with solidcore, in the same subtree.
solidcore-record = { path = "solidcore/crates/solidcore-record" }
# The modelling layer (solidcore's layers §1, "Ops"): the record-level calls SolidCraft makes into the
# kernel, converting through solidcore-convert.
solidcore-ops = { path = "solidcore/crates/solidcore-ops" }
# STEP import and export (vendored with solidcore): the Part 21 parser is solidcore-record's.
solidcore-step = { path = "solidcore/crates/solidcore-step" }
# Geometry made drawable (solidcore's layers §3): the one mesher, SolidCraft's, moved into the
# subtree (RFC 0003, item 1.1), with the display mesh and its levels of detail, the types
# SolidCraft's meshes are. Vendored with solidcore, in the same subtree.
solidcore-tess = { path = "solidcore/crates/solidcore-tess" }
# and the native body file. Pinned like solidcore; for a local checkout, patch it as above.
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
@@ -82,8 +62,6 @@ semver = "1"
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0).
spade = { version = "2.15", default-features = false, features = ["std"] }
rayon = "1.12"
# Native render-memory admission; RAM-only queries through a safe MIT-licensed API.
sysinfo = { version = "0.40.1", default-features = false, features = ["system"] }
# packages (3MF)
zip = { version = "9", default-features = false, features = ["deflate"] }
@@ -125,12 +103,11 @@ tiny-skia = { version = "0.12", default-features = false, features = ["std", "si
toml = "1"
# ui
# default_fonts: the built-in Hack is the UI's symbol fallback (#2955); 0.37 no longer turns it on.
egui = { version = "0.37", features = ["default_fonts"] }
eframe = { version = "0.37", default-features = false, features = ["persistence"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false, features = ["persistence"] }
# Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown).
web-time = "1"
egui-wgpu = "0.37"
egui-wgpu = "0.36"
# the wgpu egui-wgpu uses, for compute (crates/gpu); backends come from the apps' features
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
naga = { version = "30", default-features = false }
@@ -185,9 +162,12 @@ debug = "line-tables-only"
egui-wgpu = { path = "vendor/egui-wgpu" }
# Deliver trackpad pinch and rotation gestures on Wayland (zwp_pointer_gestures_v1), which
# winit 0.30 never binds and eframe 0.37 still requires winit 0.30.
# winit 0.30 never binds and eframe 0.36 still requires winit 0.30.
winit = { path = "vendor/winit" }
# macOS: dropping the accessibility adapter leaves AppKit's key-value observers intact (#7535).
accesskit_macos = { path = "vendor/accesskit_macos" }
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
# builds, since every mutant rebuilds the crates above the one it changes.
[profile.mutants]
-62
View File
@@ -1,62 +0,0 @@
SolidCraft Brand and Interface Licence
Copyright (c) 2026 the SolidCraft maintainers.
SolidCraft's code that models, imports, exports and computes geometry is open source under the MIT
License or the Apache License 2.0 (LICENSE-MIT, LICENSE-APACHE). This licence covers something
different: how SolidCraft looks and feels. That is its brand. A CAD program can be built from
SolidCraft's open code; it should not look like SolidCraft unless the maintainers have agreed.
1. What this licence covers ("the Brand Material")
a. The UI kit, `crates/ui-kit` (solidcraft-ui-kit): the code and the design of SolidCraft's
controls, panels, menus, ribbon, app bar, history bar, navigation bar, view compass,
Preferences window, start page and window chrome.
b. The look, `crates/look` (solidcraft-look): the interface icon masters and their artwork,
the code-drawn glyphs, and the built-in themes.
c. The theme, `crates/theme` (solidcraft-theme): the design tokens, colour palettes, accent
ramps, sizes and spacing that give SolidCraft its appearance.
d. The SolidCraft name, logo, app icon and brand kit (`assets/branding`, `assets/icons/app`).
Third-party material inside these folders keeps its own licence, listed in ATTRIBUTION.md.
In particular the IBM Plex fonts are under the SIL Open Font License 1.1, and nothing here
limits what that licence allows.
2. What you may do without asking
a. Read, build, run and modify the Brand Material as part of SolidCraft itself, including
your own builds and forks of SolidCraft used for development, testing, learning or
contributing back.
b. Contribute changes to the Brand Material through the SolidCraft repository.
c. Show SolidCraft, with its look, in reviews, tutorials, articles, videos and teaching.
3. What needs the maintainers' written permission first
a. Using any Brand Material in another program, product, service or library, including
linking to the UI kit, the look or the theme from software that is not SolidCraft.
b. Distributing the Brand Material, or a modified version of it, other than as part of
SolidCraft.
c. Distributing a fork or modified build of SolidCraft to others under the SolidCraft name,
logo or app icon, or with a look that could be taken for SolidCraft's.
d. Using the SolidCraft name, logo or app icon to name, promote or endorse anything else.
To ask, open an issue at https://github.com/tangency-labs/solidcraft or contact the
maintainers there. Permission is given in writing and may come with conditions.
4. Contributions
By contributing to the Brand Material you agree that the maintainers may license your
contribution under this licence, and under the MIT License or the Apache License 2.0, at
their choice.
5. Earlier versions
Versions of SolidCraft published before this licence took effect stay under the licences
they were published with.
6. No warranty
The Brand Material is provided "as is", without warranty of any kind, express or implied,
including the warranties of merchantability, fitness for a particular purpose and
non-infringement. In no event shall the maintainers or contributors be liable for any
claim, damages or other liability arising from the Brand Material or its use.
+6 -14
View File
@@ -3,24 +3,16 @@ Copyright (c) 2026 SolidCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
Brand and interface
SolidCraft's brand and interface are not under MIT OR Apache-2.0. The UI kit (crates/ui-kit), the
look (crates/look: the interface icons, glyphs and built-in themes), the theme (crates/theme: the
design tokens and palettes), and the SolidCraft name, logo, app icon and brand kit are under the
SolidCraft Brand and Interface Licence (LICENSE-BRAND). They may be used, changed and contributed
to as part of SolidCraft; using them in anything else needs the maintainers' written permission.
Third-party fonts in those folders keep their own licences.
Third-party material
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
`solidcraft-cli licences`). The geometry kernel is solidcore, a Rust port of the SMLib and SM_API components of
USD BRep (https://github.com/NVIDIA-Omniverse/usd-brep), licensed under the Apache License 2.0.
solidcore is vendored in `solidcore/` together with its history; its own LICENSE, NOTICE and
ATTRIBUTION.md are there. Copyright (c) 2026 The solidcore contributors; USD BRep is
Copyright (c) 2020-2026 NVIDIA CORPORATION & AFFILIATES.
`solidcraft-cli licences`). The geometry kernel builds on the truck crates
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolidCraft
modifications (listed in vendor/README.md and marked in the source); its licence is in
vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
Some code is adapted from sibling projects in the storytold family (CADCraft, GridCraft), which are
licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
+1 -7
View File
@@ -28,7 +28,7 @@
<a href="https://docs.solidcraft.io/">Documentation</a> ·
<a href="https://www.blacksmith.sh"><img src="https://img.shields.io/badge/CI-Blacksmith-000000?logo=githubactions&logoColor=white" alt="CI powered by Blacksmith" /></a>
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
<a href="https://github.com/tangency-labs/solidcore">SolidCore</a>
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
</p>
<p align="center">
@@ -133,12 +133,6 @@ MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](AGENTS.md); third-party code and assets retain their
documented licences and attribution.
SolidCraft's brand and interface are licensed separately, under
[LICENSE-BRAND](LICENSE-BRAND): the UI kit, the look (interface icons, glyphs, built-in themes),
the theme's design tokens and palettes, and the SolidCraft name, logo and app icon. Use them freely
as part of SolidCraft. Using them in another program or product needs the maintainers' written
permission first.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk, Dassault Systèmes, PTC,
+3 -10
View File
@@ -225,12 +225,6 @@ completed drawings publish atomically. The browser still exports synchronously.
## Render foundation
- Wood surface images (#1345): native CPU and GPU opaque rendering share physical
repeat frames and filtered mip chains. Imported maps retain exact bytes and
declared provenance in the design, reopen offline, and expose grain-angle
controls. Scalar face appearances suppress body maps. Planar projection and
CPU encoding/optical limits remain explicit; see [portable surfaces](docs/render-portable-surfaces-and-stages.md).
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
@@ -731,7 +725,7 @@ and reference-object placement remain separate work, as do loft rails and center
a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
and geometry records, with a separate kernel adapter and bounded binary reader. Exact
and geometry records, with a separate truck adapter and bounded binary reader. Exact
face-source certificates and operation history survive codec round trips. A provisional
picture can be generated before original blobs are read; editing and measurement wait
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
@@ -780,6 +774,5 @@ and reference-object placement remain separate work, as do loft rails and center
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
solidcore (Apache-2.0), the Rust port of NVIDIA's SMLib, vendored in `solidcore` behind
our own `kernel` crate, with our own boundary and boolean retry/validation; see
`plan/adr/0001-geometry-kernel.md` (local) — summary in README.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
+208 -912
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -28,8 +28,6 @@ raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-viewport-gpu = { workspace = true }
solidcraft-viewport = { workspace = true }
solidcore-record = { workspace = true }
solidcraft-engine = { workspace = true }
solidcraft-gpu = { workspace = true }
@@ -85,9 +85,9 @@ fn run() -> Result<(), String> {
// Vulkan device, parse GPU policy or initialize a GPU host.
let (adapter_report, gpu_host_seconds) = if needs_gpu(&session, &params)? {
let gpu_host_start = Instant::now();
let policy = solidcraft_gpu::env::policy().map_err(|e| e.to_string())?;
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, report) =
solidcraft_viewport_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
} else {
+2 -2
View File
@@ -11,7 +11,7 @@ use std::process::{Command, ExitCode};
const TARGET: &str = "solidcraft-cli";
fn main() -> ExitCode {
solidcraft_engine::note!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
let sibling = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
@@ -23,7 +23,7 @@ fn main() -> ExitCode {
None => ExitCode::FAILURE,
},
Err(e) => {
solidcraft_engine::note!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
ExitCode::FAILURE
}
}
+1 -2
View File
@@ -68,8 +68,7 @@ impl Control {
if !addr.ip().is_loopback() || addr.port() == 0 {
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
}
let token = solidcraft_engine::control_auth::APP
.client_token(addr.port(), token)
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
.map(|t| t.token)
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
if token.len() > 4096 {
+6 -9
View File
@@ -33,8 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
std::panic::set_hook(Box::new(|_| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
solidcraft_engine::note!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| solidcraft_engine::note!("step {}", serde_json::to_string(st).unwrap_or_default()));
eprintln!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
let mut design = json!({
"outcome": run.outcome,
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
@@ -45,13 +45,13 @@ pub fn one(args: &[String]) -> Result<(), String> {
if run.outcome.is_bug() {
design["script"] = run.script();
if minimise {
solidcraft_engine::note!("minimise");
eprintln!("minimise");
let m = fuzz::minimise(&run);
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
solidcraft_engine::note!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
@@ -162,14 +162,11 @@ fn record(v: &Value, out_dir: Option<&str>) {
let d = &v["design"];
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
solidcraft_engine::note!(
"seed {seed}: {}",
if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") }
);
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
}
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
if bk != "ok" {
solidcraft_engine::note!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
}
if let Some(dir) = out_dir {
use std::io::Write;
+35 -148
View File
@@ -1,7 +1,7 @@
//! `kernel-cases --export=DIR`: one kernel rig case as a corpus entry for solidcore's rig. The
//! case's commands run as the rig runs them while the kernel's operations are traced
//! (`smlib_trace`); the entry holds every operation in execution order, including discarded
//! tool construction and handled refusals, each operand that came in as a record (the
//! (`smlib_trace`); the entry holds the operation (or the chain of operations) that made the
//! bodies the case ends with, each operand that came in as a record given as that record (the
//! B-rep record, solidcore-record's JSON), the fix set, the case's expected values and the rig's
//! own result. A case whose result no kernel operation alone accounts for is written too, with
//! the reasons, so nothing is dropped silently.
@@ -98,6 +98,32 @@ impl<'a> Trace<'a> {
}
}
/// The operations `ends` need, in the order they ran: each body's maker, and its operands' makers
/// in turn, back to bodies made from records. Operands made by nothing traced are listed in
/// `unknown`.
fn chain(t: &Trace<'_>, ends: &[u64], unknown: &mut BTreeSet<u64>) -> Vec<usize> {
let mut need = BTreeSet::new();
let mut todo: Vec<u64> = ends.to_vec();
while let Some(id) = todo.pop() {
if t.imports.contains_key(&id) {
continue;
}
match t.producer.get(&id) {
Some(&i) => {
if need.insert(i)
&& let Some(c) = t.calls.get(i)
{
todo.extend(c.1.iter().copied());
}
}
None => {
unknown.insert(id);
}
}
}
need.into_iter().collect()
}
/// The entry of case `dir`, run with result `out`, into `root/<area>/<id>.json`, and the fix set
/// into `root/fixes.json`.
pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<(), String> {
@@ -194,18 +220,14 @@ pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<()
}
let mut unknown = BTreeSet::new();
let mut available: BTreeSet<u64> = t.imports.keys().copied().collect();
for (_, inputs, _, outputs) in &t.calls {
unknown.extend(inputs.iter().filter(|id| !available.contains(id)).copied());
if let Ok(made) = outputs {
available.extend(made.iter().copied());
}
}
unknown.extend(ends.iter().filter(|id| !available.contains(id)).copied());
let mut order = chain(&t, &ends, &mut unknown);
if !unknown.is_empty() {
why_not.push(format!("operands made by nothing traced: {unknown:?}"));
}
// Operand records: every body a call takes, or the case ends with, that came in as a record.
if let Some(i) = failed_call.filter(|i| !order.contains(i)) {
order.push(i);
}
// Operand records: every body the chain takes, or the case ends with, that came in as a record.
let mut bodies = BTreeMap::new();
let given = |id: u64, bodies: &mut BTreeMap<String, Value>| {
if let Some((record, trusted, splines)) = t.imports.get(&id) {
@@ -213,21 +235,14 @@ pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<()
}
};
let mut calls: Vec<Value> = Vec::new();
for (i, (op, inputs, params, outputs)) in t.calls.iter().enumerate() {
for (op, inputs, params, outputs) in order.iter().filter_map(|&i| t.calls.get(i)) {
for &id in inputs.iter() {
given(id, &mut bodies);
}
let mut c = json!({"op": op, "inputs": inputs.iter().map(|i| format!("b{i}")).collect::<Vec<_>>(), "params": params});
match outputs {
Ok(made) => c["outputs"] = json!(made.iter().map(|i| format!("b{i}")).collect::<Vec<_>>()),
Err(e) => {
c["error"] = json!(e);
// The application handled this refusal. Replay must continue to any later
// calls and grade the final bodies, even when this was the last attempt.
if failed_call != Some(i) {
c["continued"] = json!(true);
}
}
Err(e) => c["error"] = json!(e),
}
calls.push(c);
}
@@ -275,134 +290,6 @@ pub fn write(root: &Path, dir: &str, out: &Value, capture: Capture) -> Result<()
mod tests {
use super::*;
fn empty_record() -> Brep {
Brep { version: 4, tolerance: 1e-6, regions: Vec::new(), shells: Vec::new(), faces: Vec::new(), edges: Vec::new(), vertices: Vec::new() }
}
fn call(op: &'static str, inputs: &[u64], outputs: Result<Vec<u64>, String>) -> Event {
Event::Call { op, inputs: inputs.to_vec(), params: json!({}), outputs }
}
fn exported_case(name: &str, events: Vec<Event>, ran: Result<Vec<(String, Brep)>, String>) -> Value {
let root = std::env::temp_dir().join(format!("solidcraft-export-{}-{name}", std::process::id()));
let dir = root.join("tests/kernel-cases/traces").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("expected.json"), "{}").unwrap();
let output = root.join("export");
write(&output, dir.to_str().unwrap(), &json!({"status": "pass"}), Capture { events, ran: Some(ran) }).unwrap();
let entry = output.join("traces").join(format!("{name}.json"));
serde_json::from_str(&std::fs::read_to_string(entry).unwrap()).unwrap()
}
#[test]
fn discarded_tool_construction_is_exported_in_execution_order() {
let record = empty_record();
let e = exported_case(
"discarded-tool",
vec![call("extrude", &[], Ok(vec![1])), call("extrude", &[], Ok(vec![2])), Event::Export { id: 1, record: Box::new(record.clone()) }],
Ok(vec![("Part".into(), record)]),
);
assert_eq!(e["calls"].as_array().unwrap().len(), 2);
assert_eq!(e["calls"][0]["outputs"], json!(["b1"]));
assert_eq!(e["calls"][1]["outputs"], json!(["b2"]));
assert_eq!(e["traced_ops"], json!(["extrude", "extrude"]));
}
#[test]
fn handled_refusals_are_exported_without_becoming_terminal() {
let record = empty_record();
let e = exported_case(
"handled-refusal",
vec![
call("extrude", &[], Ok(vec![1])),
call("fillet", &[1], Err("too large".into())),
call("fillet", &[1], Ok(vec![2])),
Event::Export { id: 2, record: Box::new(record.clone()) },
],
Ok(vec![("Part".into(), record)]),
);
assert_eq!(e["calls"].as_array().unwrap().len(), 3);
assert_eq!(e["calls"][1]["error"], "too large");
assert_eq!(e["calls"][1]["continued"], true);
}
#[test]
fn a_handled_last_attempt_keeps_the_retained_body_as_the_result() {
let record = empty_record();
let e = exported_case(
"handled-last-attempt",
vec![
call("extrude", &[], Ok(vec![1])),
call("fillet", &[1], Err("too large".into())),
Event::Export { id: 1, record: Box::new(record.clone()) },
],
Ok(vec![("Part".into(), record)]),
);
assert_eq!(e["calls"][1]["continued"], true);
assert_eq!(e["result"], json!([{"body": "Part", "made": "b1"}]));
assert_eq!(e["operation_expressible"], true);
}
#[test]
fn the_final_refusal_still_ends_replay() {
let e = exported_case(
"terminal-refusal",
vec![
call("extrude", &[], Ok(vec![1])),
call("fillet", &[1], Err("too large".into())),
call("fillet", &[1], Err("no room".into())),
call("copy", &[1], Ok(vec![2])),
],
Err("no room".into()),
);
assert_eq!(e["calls"].as_array().unwrap().len(), 4);
assert_eq!(e["calls"][1]["continued"], true);
assert!(e["calls"][2].get("continued").is_none());
assert_eq!(e["outcome"]["kernel_error"], "no room");
}
#[test]
fn discarded_operations_still_require_their_operand_records() {
let record = empty_record();
let e = exported_case(
"missing-tool-operand",
vec![call("extrude", &[], Ok(vec![1])), call("copy", &[99], Ok(vec![2])), Event::Export { id: 1, record: Box::new(record.clone()) }],
Ok(vec![("Part".into(), record)]),
);
assert_eq!(e["operation_expressible"], false);
assert!(e["why_not"].as_array().unwrap().iter().any(|why| why.as_str().unwrap().contains("99")));
}
#[test]
fn discarded_operations_keep_their_imported_operands() {
let record = empty_record();
let e = exported_case(
"imported-tool-operand",
vec![
call("extrude", &[], Ok(vec![1])),
Event::Import { id: 9, record: Box::new(record.clone()), trusted: false, keeping_splines: true },
call("copy", &[9], Ok(vec![2])),
Event::Export { id: 1, record: Box::new(record.clone()) },
],
Ok(vec![("Part".into(), record.clone())]),
);
assert_eq!(e["bodies"]["b9"], json!({"record": record, "trusted": false, "keeping_splines": true}));
assert_eq!(e["calls"][1]["inputs"], json!(["b9"]));
assert_eq!(e["operation_expressible"], true);
}
#[test]
fn a_later_output_cannot_supply_an_earlier_discarded_operation() {
let record = empty_record();
let e = exported_case(
"future-tool-operand",
vec![call("copy", &[9], Ok(vec![2])), call("extrude", &[], Ok(vec![9])), Event::Export { id: 9, record: Box::new(record.clone()) }],
Ok(vec![("Part".into(), record)]),
);
assert_eq!(e["operation_expressible"], false);
assert!(e["why_not"].as_array().unwrap().iter().any(|why| why.as_str().unwrap().contains("9")));
}
#[test]
fn the_area_is_the_path_under_kernel_cases() {
assert_eq!(area_and_id("tests/kernel-cases/booleans/1894-x").unwrap(), ("booleans".into(), "1894-x".into()));
+35 -88
View File
@@ -18,7 +18,7 @@
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli kernel-cases [--json] [--dumps=DIR] [--validation=MODE] <case-dir>... the kernel regression rig (tests/kernel-cases)
//! solidcraft-cli mcp [--headless] [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
//! [--param-check off|warn|strict] [--compact]
//! [--param-check off|warn|strict]
//! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli mcp setup [--dry-run] [--client NAME] [--live] (alias: mcp install) add it to OpenCode, Claude Desktop, Claude Code, Cursor
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
@@ -62,9 +62,9 @@ struct Command {
}
/// `run`'s lines in the usage.
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--profile-out trace.json] [--json-out FILE] [--allow-errors] [--validation each_step|each_feature|off]\n [--param-check off|warn|strict] [--quality standard|fine|finest]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--quality: how closely an STL, OBJ or 3MF --out follows curved faces, as file.export's quality: a chord of\n 2e-4, 5e-5 or 1e-5 of each body's size, never under 1 µm, default standard)\n (--validation: when SMLib's full check runs on the features computed (diagnostics.validation), default each_feature)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n (--profile also records where the time goes, SMLib's calls, kernel operations, features, commands and import stages,\n and prints the table to stderr: count, total, self time and longest per span; --profile-out FILE writes the spans\n as a Chrome trace for chrome://tracing or ui.perfetto.dev; SOLIDCRAFT_PROFILE=1 does the same for any command)\n";
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--profile-out trace.json] [--json-out FILE] [--allow-errors] [--validation each_step|each_feature|off]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--validation: when SMLib's full check runs on the features computed (diagnostics.validation), default each_feature)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n (--profile also records where the time goes, SMLib's calls, kernel operations, features, commands and import stages,\n and prints the table to stderr: count, total, self time and longest per span; --profile-out FILE writes the spans\n as a Chrome trace for chrome://tracing or ui.perfetto.dev; SOLIDCRAFT_PROFILE=1 does the same for any command)\n";
/// `eval`'s lines in the usage.
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] [--profile] [--profile-out trace.json] [--validation each_step|each_feature|off] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n (--profile, --profile-out and --validation as for run)\n";
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] [--profile] [--profile-out trace.json] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n (--profile and --profile-out as for run)\n";
/// `script`'s lines in the usage.
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.sc|script.json> [--out script.json]\n (the design's history as a command script that eval and run replay)\n";
/// `bench-open`'s lines in the usage.
@@ -76,7 +76,7 @@ const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect
/// `snapshot`'s lines in the usage.
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.sc|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27] [--quality coarse|normal|fine|ultra]\n solidcraft-cli snapshot --in <design.sc|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON;\n --quality: the display quality the bodies are meshed at, as the app's setting (default normal))\n";
/// `render`'s lines in the usage.
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted] [--legacy-memory-admission]\n --legacy-memory-admission skips available-RAM checks; fixed shared/host budgets and allocation limits still apply.\n";
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
/// `exec`'s lines in the usage.
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
/// `commands`'s lines in the usage.
@@ -91,7 +91,7 @@ const USAGE_KERNEL_CASES: &str = " solidcraft-cli kernel-cases <case-dir>... [-
const USAGE_FUZZ: &str =
" solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]\n";
/// `mcp`'s lines in the usage.
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.sc|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu] [--compact]\n (--compact, SOLIDCRAFT_MCP_COMPACT=1 or the app's Preferences › AI (with --connect): compact tool output for\n agents: inspect_design summarises sketches and takes `sketches` by name and a `bodies` filter, and tool JSON\n is not pretty-printed; off by default)\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\n";
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.sc|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\n";
/// `crashes`'s lines in the usage.
const USAGE_CRASHES: &str = " solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]\n";
/// `licences`'s lines in the usage.
@@ -154,7 +154,7 @@ fn main() -> ExitCode {
}
};
if let Err(e) = max_file_size(&mut args) {
solidcraft_engine::note!("error: {e}");
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
let r = match args.first().map(String::as_str) {
@@ -235,7 +235,7 @@ fn main() -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
solidcraft_engine::note!("error: {e}");
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
@@ -318,19 +318,6 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
/// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which
/// the session keeps.
fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result<Session, String> {
let s = read_session(path, check, validation)?;
// A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's.
for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) {
eprintln!("{path}: warning: {warning}");
}
Ok(s)
}
fn read_session(
path: &str,
check: solidcraft_engine::schema::ParamCheck,
validation: solidcraft_engine::kernel::Validation,
) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
s.set_validation(validation);
@@ -364,7 +351,7 @@ fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
fn print_param_warnings(path: &str, step: usize, r: &Value) {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
solidcraft_engine::note!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
}
@@ -451,12 +438,11 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
known_args(
"run",
args,
&["--in", "--out", "--save", "--json-out", "--param-check", "--profile-out", "--validation", "--quality"],
&["--in", "--out", "--save", "--json-out", "--param-check", "--profile-out", "--validation"],
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
true,
)?;
let validation = validation_flag("run", args)?;
let quality = export_quality("run", args)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
@@ -498,17 +484,11 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
// the wrong part with success, so fail the run unless asked to write it anyway.
// A feature only a newer SolidCraft can build isn't a broken design: saving the design keeps it
// and hands out no part. Exporting bodies without it would, so that still stops.
if !args.iter().any(|a| a == "--allow-errors") {
timeline_ok(&s, flags(args, "--out").is_empty())?;
timeline_ok(&s)?;
}
for out in flags(args, "--out") {
let mut params = json!({"path": out});
if let Some(q) = quality {
params["quality"] = json!(q);
}
s.execute("file.export", &params).map_err(|e| e.to_string())?;
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
}
if let Some(p) = flag(args, "--save") {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
@@ -549,20 +529,6 @@ fn open_validating(
}
}
/// `--quality standard|fine|finest`: how closely the meshes `--out` writes (STL, OBJ, 3MF)
/// follow curved faces, as `file.export`'s `quality` (#12270). Checked before anything runs;
/// given twice it is refused, since only one quality can apply.
fn export_quality<'a>(cmd: &str, args: &'a [String]) -> Result<Option<&'a str>, String> {
if flags(args, "--quality").len() > 1 {
return Err(format!("{cmd}: --quality is given more than once"));
}
match flag(args, "--quality") {
Some(q) if solidcraft_engine::io::MeshQuality::from_name(q).is_some() => Ok(Some(q)),
Some(q) => Err(format!("{cmd}: --quality must be standard, fine or finest, not `{q}`")),
None => Ok(None),
}
}
/// `--validation MODE` (`diagnostics.validation`): the default, each_feature, when not given.
fn validation_flag(cmd: &str, args: &[String]) -> Result<solidcraft_engine::kernel::Validation, String> {
match flag(args, "--validation") {
@@ -595,12 +561,8 @@ fn known_args(cmd: &str, args: &[String], valued: &[&str], switches: &[&str], po
}
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
fn timeline_ok(s: &Session, skip_newer_features: bool) -> Result<(), String> {
let newer = |id: u64| {
skip_newer_features && s.doc.feature(id).is_some_and(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::NewerFileFeature { .. }))
};
let failed: Vec<String> =
s.model.results.iter().filter(|r| !newer(r.id)).filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
fn timeline_ok(s: &Session) -> Result<(), String> {
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
if failed.is_empty() {
return Ok(());
}
@@ -611,15 +573,13 @@ fn timeline_ok(s: &Session, skip_newer_features: bool) -> Result<(), String> {
))
}
/// Runs `solidcraft-cli eval` (and its `inspect` alias), opening the input design or script with the requested `--validation` mode and reporting both `inspect.measure` and `document.inspect`.
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
known_args("eval", args, &["--in", "--json-out", "--profile-out", "--validation"], &["--profile"], true)?;
let validation = validation_flag("eval", args)?;
let mut s = open_validating(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off, validation)?;
known_args("eval", args, &["--in", "--json-out", "--profile-out"], &["--profile"], true)?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let mut out = json!({
let out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
@@ -627,10 +587,6 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
print_json(&out, json_out)
}
@@ -642,10 +598,10 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out");
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
solidcraft_engine::note!("note: the script leaves out {what}");
eprintln!("note: the script leaves out {what}");
}
match out {
Some(out) => solidcraft_engine::note!("{out}: {} steps", r["steps"]),
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
None => println!("{}", pretty(&r["script"])),
}
Ok(())
@@ -653,9 +609,9 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
/// `gpu`: the headless adapter survey and the policy's pick.
fn cmd_gpu(args: &[String]) -> Result<(), String> {
use solidcraft_viewport_gpu::select::{Policy, survey};
use solidcraft_gpu::select::{Policy, survey};
let policy = match args {
[] => solidcraft_gpu::env::policy()?,
[] => 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()),
};
@@ -675,8 +631,8 @@ fn cmd_gpu(args: &[String]) -> Result<(), String> {
}))
);
match s.lands_on {
Some(solidcraft_viewport_gpu::select::Landing::Cpu) => {
solidcraft_engine::note!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
Some(solidcraft_gpu::select::Landing::Cpu) => {
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
Ok(())
}
Some(_) => Ok(()),
@@ -691,7 +647,7 @@ fn cmd_gpu(args: &[String]) -> Result<(), String> {
/// 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::env::headless_rgba(scene, cam, w, h)?;
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
}));
}
@@ -753,7 +709,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
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) {
solidcraft_engine::note!("warning: {w}");
eprintln!("warning: {w}");
reply["warning"] = json!(w);
}
println!("{}", pretty(&reply));
@@ -778,7 +734,7 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
solidcraft_viewport::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
solidcraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
@@ -836,15 +792,11 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
return Err("mcp: --render-gpu installs a headless image host; a connected application owns its own device".into());
}
install_renderer();
let options = solidcraft_mcp::Options::from_args_and_env(args).map_err(|e| format!("mcp: {e}"))?;
if options.compact {
eprintln!("solidcraft mcp: compact output (inspect_design summarises sketches; tool JSON is not pretty-printed)");
}
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
if let Some(s) = &scope {
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
solidcraft_engine::note!(
eprintln!(
"solidcraft mcp: files limited to {} and the folders of designs opened or saved here (--allow-path DIR adds one, --allow-any-path lifts the limit)",
roots.join(", ")
);
@@ -852,7 +804,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
.and_then(|p| std::path::Path::new(p).parent())
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d });
if let Some(why) = design_dir.and_then(solidcraft_engine::io::vfs::design_folder_refusal) {
solidcraft_engine::note!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How calls that break a command's parameter contract are treated, headless or bridged.
@@ -866,7 +818,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
if let Err(e) = remote.try_connect() {
solidcraft_engine::note!(
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
);
@@ -883,9 +835,9 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
(None, None) => {
let auto = !args.iter().any(|a| a == "--headless");
if auto && let Some(port) = discover_app() {
solidcraft_engine::note!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
return serve_mcp(Box::new(remote), scope, options);
return serve_mcp(Box::new(remote), scope);
}
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
@@ -900,24 +852,19 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
}
}
};
serve_mcp(backend, scope, options)
serve_mcp(backend, scope)
}
fn serve_mcp(
backend: Box<dyn solidcraft_mcp::Backend>,
scope: Option<solidcraft_engine::io::vfs::PathScope>,
options: solidcraft_mcp::Options,
) -> Result<(), String> {
// Not the locked handles: the server reads stdin on a thread of its own (#14007).
let stdin = std::io::BufReader::new(std::io::stdin());
fn serve_mcp(backend: Box<dyn solidcraft_mcp::Backend>, scope: Option<solidcraft_engine::io::vfs::PathScope>) -> Result<(), String> {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
solidcraft_mcp::Server::new(backend).with_path_scope(scope).with_options(options).serve(stdin, stdout).map_err(|e| e.to_string())
solidcraft_mcp::Server::new(backend).with_path_scope(scope).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// The port of the running desktop app: the one its discovery file names, if something listens
/// there (a file left by an app that crashed names a closed port).
fn discover_app() -> Option<u16> {
let port = solidcraft_engine::control_auth::APP.read_current_port()?;
let port = solidcraft_engine::control_auth::read_current_port()?;
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
}
+4 -14
View File
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
use solidcraft_engine::{
Session,
doc::render_settings::{RenderJob, RenderSetup},
render::job::{CpuHost, JobId, NativeMemoryPolicy, Phase, ToneMap},
render::job::{CpuHost, JobId, Phase, ToneMap},
};
/// Only explicit runtime CLI flags are stored here. A typed setup owns these
@@ -74,7 +74,6 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut height = None;
let mut exposure = None;
let mut runtime = RuntimeOptions::default();
let mut memory_policy = NativeMemoryPolicy::AvailableRam;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
@@ -83,7 +82,6 @@ pub fn run(args: &[String]) -> Result<(), String> {
"--progressive" => runtime.progressive = true,
"--transparent" => runtime.transparent = true,
"--overwrite" => overwrite = true,
"--legacy-memory-admission" => memory_policy = NativeMemoryPolicy::SoftwarePoolOnly,
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
| "--tone-map" | "--environment" => {
i += 1;
@@ -136,15 +134,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
// Resolve saved intent before opening a GPU or explicitly settling geometry.
// Width/height/exposure only change this validated inline settings clone.
let (mut params, needs_gpu) = runtime.request(&session, settings)?;
// The machine admission policy may accompany any saved setup; it does not
// replace the setup's image-quality options or alter the saved document.
let cpu_host = CpuHost::for_platform().map_err(|e| e.to_string())?;
cpu_host.set_native_memory_policy(memory_policy);
session.set_render_job_host(Some(cpu_host)).map_err(|e| e.to_string())?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
if needs_gpu {
let gpu_host = solidcraft_gpu::path::host().map_err(|e| e.to_string())?;
gpu_host.set_native_memory_policy(memory_policy);
session.register_render_job_host(gpu_host).map_err(|e| e.to_string())?;
session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
}
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
if setup.is_none() {
@@ -152,9 +144,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
camera
} else {
let mut camera = RenderSetup::default().camera;
camera.fit(&solidcraft_engine::render::view_bounds(
&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds,
));
camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
camera
};
params["settings"]["camera"] = json!(camera);
+1 -1
View File
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
let mut unscoped = p.clone();
unscoped.as_object_mut().unwrap().remove("body");
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
assert!(err.contains("edges meet"), "{err}");
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
tried += 1;
}
@@ -1,7 +1,6 @@
//! The bracket example must cut two through holes, rather than successfully cutting empty
//! space beside the body. Check the shipped script and rebuild its saved design from scratch.
use solidcraft_engine::geom::MeshGeom;
use std::f64::consts::PI;
use serde_json::Value;
+1 -34
View File
@@ -152,17 +152,7 @@ fn render_help_keeps_the_registered_gpu_request_controls() {
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty());
let stdout = String::from_utf8_lossy(&output.stdout);
for flag in [
"--progressive",
"--samples",
"--max-depth",
"--seed",
"--exposure",
"--lighting",
"--environment",
"--tone-map",
"--legacy-memory-admission",
] {
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
}
}
@@ -198,26 +188,3 @@ fn max_file_size_raises_the_limit_and_refuses_bad_sizes() {
assert!(!output.status.success() && !stderr.contains("too large"), "{stderr}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #10111: a line for a stderr whose reader has gone (the pipe closed before it, as an agent may
/// leave the MCP server's, and the app that one starts) is dropped, and the command ends as it
/// would with stderr open: here a script that isn't there fails with 1, where `eprintln!`
/// panicked with "failed printing to stderr" (101).
#[test]
fn a_closed_stderr_does_not_stop_the_cli() {
for _ in 0..5 {
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["run", "no-such-script.json"])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stderr.take());
assert_eq!(child.wait().unwrap().code(), Some(1));
}
// The old name's alias writes its deprecation notice first: an agent still configured with
// `solvecraft-cli mcp` must get the server, not that panic.
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--help").stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stderr.take());
assert_eq!(child.wait().unwrap().code(), Some(0));
}
-48
View File
@@ -1,48 +0,0 @@
//! `mcp --compact` and `SOLIDCRAFT_MCP_COMPACT` turn on compact tool output; without them the
//! server's tools are as they always were.
use std::io::Write;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
/// `inspect_design`'s input schema as `solidcraft-cli mcp` with `args` and `env` lists it, or
/// the server's stderr when it does not start.
fn inspect_schema(args: &[&str], env: Option<&str>) -> Result<Value, String> {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"));
// --headless: never bridge to (and read the preference of) an app the developer has running.
cmd.args(["mcp", "--headless", "--allow-any-path"]).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
match env {
Some(v) => cmd.env("SOLIDCRAFT_MCP_COMPACT", v),
None => cmd.env_remove("SOLIDCRAFT_MCP_COMPACT"),
};
let mut child = cmd.spawn().unwrap();
{
let mut stdin = child.stdin.take().unwrap();
// The server may refuse to start and close stdin first.
let _ = writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
}
let out = child.wait_with_output().unwrap();
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).into_owned());
}
let reply: Value = serde_json::from_slice(&out.stdout).unwrap();
let tools = reply["result"]["tools"].as_array().cloned().unwrap_or_default();
Ok(tools.into_iter().find(|t| t["name"] == "inspect_design").map(|t| t["inputSchema"].clone()).unwrap_or(Value::Null))
}
#[test]
fn compact_output_is_off_unless_asked() {
let plain = inspect_schema(&[], None).unwrap();
assert_eq!(plain["properties"]["sketches"]["type"], "boolean", "{plain}");
assert!(plain["properties"].get("bodies").is_none(), "{plain}");
assert_eq!(inspect_schema(&[], Some("0")).unwrap(), plain);
for (args, env) in [(&["--compact"][..], None), (&[][..], Some("1")), (&[][..], Some("true"))] {
let compact = inspect_schema(args, env).unwrap();
assert!(compact["properties"]["bodies"].is_object(), "{args:?} {env:?}: {compact}");
}
let e = inspect_schema(&[], Some("maybe")).unwrap_err();
assert!(e.contains("SOLIDCRAFT_MCP_COMPACT"), "{e}");
}
+13 -34
View File
@@ -60,25 +60,15 @@ fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
let listener = TcpListener::bind(&addr).unwrap();
let app = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut lines = BufReader::new(stream.try_clone().unwrap());
loop {
let mut line = String::new();
lines.read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
// The server first asks for the agents' output options (the compact preference: off).
let options = req["method"] == "mcp.options";
let reply = if req["token"] != json!(token) {
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
} else if options {
json!({"id": req["id"], "ok": true, "result": {"compact": false}})
} else {
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
};
writeln!(&stream, "{reply}").unwrap();
if !options {
break;
}
}
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
let reply = if req["token"] == json!(token) {
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
} else {
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
};
writeln!(&stream, "{reply}").unwrap();
});
let r = ask(4, "tools/call", measure);
assert_ne!(r["result"]["isError"], true, "{r}");
@@ -121,21 +111,10 @@ fn mcp_without_arguments_finds_the_running_app() {
break (stream, line);
}
};
let mut lines = BufReader::new(stream.try_clone().unwrap());
let mut line = line;
// The server first asks for the agents' output options (the compact preference: off).
loop {
let req: Value = serde_json::from_str(&line).unwrap();
let ok = req["token"] == json!(token);
let options = req["method"] == "mcp.options";
let result = if options { json!({"compact": false}) } else { json!({"bodies": [], "method": req["method"]}) };
writeln!(&stream, "{}", json!({"id": req["id"], "ok": ok, "result": result, "error": "unauthorized"})).unwrap();
if !options {
break;
}
line.clear();
lines.read_line(&mut line).unwrap();
}
let req: Value = serde_json::from_str(&line).unwrap();
let ok = req["token"] == json!(token);
let reply = json!({"id": req["id"], "ok": ok, "result": {"bodies": [], "method": req["method"]}, "error": "unauthorized"});
writeln!(&stream, "{reply}").unwrap();
});
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--allow-any-path"])
+2 -2
View File
@@ -318,7 +318,7 @@ fn a_model_saved_under_another_check_mode_is_evaluated_again() {
/// opened from its model or evaluated anew. Two edges of a 20 mm box rounded r2 (a mitre at the
/// corner (20, 0, 20)), saved; opened, the upright edge there rounded r3: the corner all three
/// rounded at once make (SMLib's setback corner, kernel `three_rounds_at_a_corner`), 15 faces and
/// 7927.72428251348 mm³, the same body as the design evaluated anew.
/// 7928.142563938 mm³, the same body as the design evaluated anew.
#[test]
fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let mut built = Session::default();
@@ -336,7 +336,7 @@ fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let b = &st.body("Box").unwrap().body;
let v = solidcraft_engine::kernel::measure(b).unwrap().volume;
assert_eq!(b.face_count(), 15);
assert!((v - 7927.72428251348).abs() < 1e-6, "{v}"); // solidcore#2382's setback corner
assert!((v - 7928.142563938).abs() < 1e-6, "{v}");
}
assert_eq!(model("later round", &cached), model("later round", &rebuilt));
let _ = std::fs::remove_file(&path);
+23 -98
View File
@@ -67,9 +67,6 @@ fn run_rejects_invalid_options_before_script_execution_or_output_writes() {
(&["--out", "--quiet"][..], "--out needs a value"),
(&["--ouut", "part.step"][..], "doesn't take --ouut"),
(&["--unknown"][..], "doesn't take --unknown"),
(&["--quality"][..], "--quality needs a value"),
(&["--quality", "best"][..], "--quality must be standard, fine or finest, not `best`"),
(&["--quality", "standard", "--quality", "best"][..], "--quality is given more than once"),
];
for existing in [false, true] {
let saved = dir.0.join(format!("{existing}.solidcraft"));
@@ -162,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() {
}
/// #1369: a pending concept that did not land in the numbered release must not be silently
/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it.
/// dropped on open/resave, in either encoding or before replacing an existing destination.
#[test]
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
use solidcraft_engine::{Session, doc, io};
@@ -176,29 +173,34 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
let source = dir.0.join(format!("experimental-{version}.{extension}"));
std::fs::write(&source, &bytes).unwrap();
let mut session = Session::default();
// It opens, and the reply says what it is.
let reply = session.execute("doc.open", &json!({"path": source})).unwrap();
let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string();
assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}");
assert_eq!(reply["warnings"][0], notice.as_str());
assert_eq!(session.doc.name, "Experimental");
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let original = session.doc.clone();
let original_path = session.path.clone();
let undo = session.undo.len();
let dirty = session.is_dirty();
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
assert_eq!(session.doc, original);
assert_eq!(session.path, original_path);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.is_dirty(), dirty);
for existing in [false, true] {
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
let backup = io::backup_path(&destination);
if existing {
std::fs::write(&destination, b"existing design").unwrap();
std::fs::write(&backup, b"existing backup").unwrap();
}
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}");
// Saved, it still says it has the change, and opens the same way.
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert!(saved.format_pending.iter().any(|name| name == "experimental-card"));
assert!(saved.newer_file.is_some());
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
if existing {
assert_eq!(std::fs::read(&backup).unwrap(), b"existing design");
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
} else {
assert!(!destination.exists() && !backup.exists());
}
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
@@ -206,43 +208,6 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
}
}
/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have:
/// the rest builds, the reply names it, and the saved file still holds it, under the file's number.
#[test]
fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() {
use solidcraft_engine::{Session, doc, io};
let dir = Scratch::new();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap();
let newer = doc::format::format_string(doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
let source = dir.0.join("newer.json");
std::fs::write(&source, file.to_string()).unwrap();
let output = cli().args(["eval"]).arg(&source).output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["body_count"], 1);
assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}");
assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}");
for extension in ["json", "sc"] {
let destination = dir.0.join(format!("saved.{extension}"));
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert_eq!(saved.format, newer, "never a lower number");
let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap();
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
}
}
#[test]
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
use solidcraft_engine::{doc, io};
@@ -291,38 +256,6 @@ fn results_still_save_and_export_the_script_once() {
}
}
/// The triangles of a binary STL: the count after its 80-byte header.
fn stl_triangles(path: &Path) -> u32 {
let bytes = std::fs::read(path).unwrap();
u32::from_le_bytes(bytes[80..84].try_into().unwrap())
}
/// #12270: `run --out part.stl --quality fine` writes the mesh at that quality, as
/// `file.export`'s `quality` does, without a script to say so.
#[test]
fn run_exports_meshes_at_the_quality_asked_for() {
let dir = Scratch::new();
let script = dir.0.join("cylinder.json");
std::fs::write(&script, json!({"commands": [{"command": "solid.cylinder", "params": {"diameter": 40, "height": 10}}]}).to_string()).unwrap();
let export = |name: &str, quality: Option<&str>| {
let stl = dir.0.join(name);
let mut command = cli();
command.arg("run").arg(&script).arg("--quiet").arg("--out").arg(&stl);
if let Some(q) = quality {
command.args(["--quality", q]);
}
let output = command.output().unwrap();
assert!(output.status.success(), "{quality:?}: {}", String::from_utf8_lossy(&output.stderr));
stl_triangles(&stl)
};
let default = export("default.stl", None);
let standard = export("standard.stl", Some("standard"));
let fine = export("fine.stl", Some("fine"));
let finest = export("finest.stl", Some("finest"));
assert_eq!(default, standard);
assert!(standard < fine && fine < finest, "standard {standard}, fine {fine}, finest {finest}");
}
#[test]
fn results_with_base_design_and_quiet_still_report_and_save() {
let dir = Scratch::new();
@@ -507,7 +440,7 @@ fn snapshot_runs_its_script_on_the_in_design() {
std::fs::read(&png).unwrap()
};
// Pictures are drawn on the GPU: without an adapter only the option checks below run.
match solidcraft_viewport_gpu::capture::shared(solidcraft_gpu::env::policy().unwrap_or_else(|e| panic!("{e}"))) {
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()]);
@@ -515,7 +448,7 @@ fn snapshot_runs_its_script_on_the_in_design() {
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
}
Err(e @ solidcraft_viewport_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
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}"),
@@ -673,7 +606,6 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.unwrap(),
);
assert_eq!(result["preflight"]["resolved"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "available_ram");
assert_eq!(result["export"]["frame"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["request"]["integrator"], "raytrace");
assert_eq!(result["preflight"]["request"]["raytrace"].get("progressive"), options.get("progressive"));
@@ -690,12 +622,11 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.arg(&saved)
.args(["--setup", "Portable setup", "--out"])
.arg(&overridden)
.args(["--width", "12", "--height", "8", "--exposure", "1.25", "--legacy-memory-admission"])
.args(["--width", "12", "--height", "8", "--exposure", "1.25"])
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-cpu")
.output()
.unwrap(),
);
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "software_pool_only");
assert_eq!(result["preflight"]["request"]["width"], 12);
assert_eq!(result["preflight"]["request"]["height"], 8);
assert_eq!(result["preflight"]["request"]["raytrace"]["exposure_ev"], 1.25);
@@ -775,13 +706,7 @@ fn typed_gpu_render_selects_its_host_without_a_progressive_runtime_flag() {
let script = render_setup_script(&dir, job.clone());
let out = dir.0.join("gpu.png");
let mut command = cli();
command
.arg("render")
.arg(&script)
.args(["--setup", "Portable setup", "--out"])
.arg(&out)
.arg("--legacy-memory-admission")
.env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
command.arg("render").arg(&script).args(["--setup", "Portable setup", "--out"]).arg(&out).env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
if job.is_null() {
command.arg("--progressive");
}
+5 -5
View File
@@ -17,14 +17,14 @@ static NEXT: AtomicUsize = AtomicUsize::new(0);
/// 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_viewport_gpu::capture::shared(solidcraft_gpu::env::policy().unwrap_or_else(|e| panic!("{e}"))) {
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_viewport_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("skipping: {e}");
false
}
@@ -172,7 +172,7 @@ fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
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).unwrap();
cam.set_view(solidcraft_viewport::StandardView::Top);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam).unwrap();
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");
@@ -336,9 +336,9 @@ fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() {
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).unwrap();
cam.set_view(solidcraft_viewport::StandardView::Top);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam).unwrap();
let cpu = solidcraft_viewport::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
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);
}
-4
View File
@@ -22,11 +22,7 @@ profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
solidcraft-ui-egui = { workspace = true }
# SolidCraft's look (icons, glyphs, built-in themes), installed at startup.
solidcraft-look = { workspace = true }
solidcraft-ui-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+1 -1
View File
@@ -9,7 +9,7 @@
<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://app.solidcraft.io/">
<meta property="og:url" content="https://solidcraft.ai/">
<meta property="og:image" content="https://docs.solidcraft.io/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_control::ControlRequest;
use solidcraft_ui_egui::ControlRequest;
use wasm_bindgen::{JsValue, closure::Closure};
const MAX_PENDING: usize = 256;
+1 -1
View File
@@ -676,7 +676,7 @@ pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
/// any later page.
fn hold_page_lock(page: &str) {
let Some(locks) = web_locks() else { return };
let Ok(request) = Reflect::get(&locks, &"request".into()).and_then(|f| f.dyn_into::<Function>()) else { return };
let Ok(request) = Reflect::get(&locks, &"request".into()).and_then(|f| f.dyn_into::<Function>().map_err(JsValue::from)) else { return };
// A promise that never settles: the lock is let go only when the page goes.
let hold = Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(|_lock: JsValue| js_sys::Promise::new(&mut |_, _| {}));
let name = format!("{}{page}", keep::LOCK_PREFIX);
+2 -2
View File
@@ -23,9 +23,9 @@ fn prompt() {
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
}
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
.and_then(|f| f.dyn_into::<js_sys::Function>())
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
.and_then(|f| f.call0(&offer))
.and_then(|p| p.dyn_into::<js_sys::Promise>());
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
match shown {
// Accepted or dismissed, the browser handles it; a refusal is only logged.
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
-43
View File
@@ -173,35 +173,6 @@ pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
open == Some(wanted)
}
/// What follows once a design restored from a kept copy, or recovered, is open: it opens in
/// stages, a few features a frame with progress and Cancel (#10198), so this waits for the answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AfterOpen {
/// A kept copy (#10220): it is its own design again (saving goes to `path`, it is named
/// `name`), with its changes unsaved; then the copy `id` goes when `drop` says so.
KeptCopy { opened: String, id: String, path: Option<String>, name: String, drop: bool },
/// The saved design offered beside a kept copy: the copy `id` goes when `drop` says so.
KeptSaved { opened: String, id: String, drop: bool },
/// Unsaved work from the page's last visit: it belongs to `path` again, named `name`, unsaved.
Recovery { opened: String, path: Option<String>, name: String },
}
impl AfterOpen {
/// The file the open reads.
pub fn opened(&self) -> &str {
match self {
AfterOpen::KeptCopy { opened, .. } | AfterOpen::KeptSaved { opened, .. } | AfterOpen::Recovery { opened, .. } => opened,
}
}
}
/// Whether the open of `wanted` answered with `reply` put it in: the answer says so, and it is
/// the design now open (`open`: the open design's path). A refusal, a Cancel or another file
/// leaves the copy or the recovered work offered.
pub fn opened(reply: &Value, open: Option<&str>, wanted: &str) -> bool {
reply["ok"].as_bool() == Some(true) && is_open(open, wanted)
}
/// Whether leaving the page asks through the browser's "leave site?" box: there is unsaved work,
/// and either the user still wants to be asked or it isn't all kept in the browser yet (with the
/// keep preference off it never is, so there the box always stays).
@@ -253,20 +224,6 @@ pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
mod tests {
use super::*;
/// A restored or recovered design opens in stages (#10198): only an answer that it is in, for
/// the file asked for, counts; a refusal, a Cancel or another design open leaves it offered.
#[test]
fn a_staged_restore_counts_only_when_its_file_is_in() {
let copy = "/opfs/.kept/a.sc";
assert!(opened(&json!({"ok": true}), Some(copy), copy));
assert!(!opened(&json!({"ok": false, "error": "cancelled"}), Some(copy), copy));
assert!(!opened(&json!({"ok": false, "error": "not a design"}), None, copy));
assert!(!opened(&json!({"ok": true}), Some("/opfs/other.sc"), copy));
assert!(!opened(&json!({}), Some(copy), copy));
let after = AfterOpen::KeptCopy { opened: copy.into(), id: "a".into(), path: None, name: "Bracket".into(), drop: true };
assert_eq!(after.opened(), copy);
}
fn entry(id: &str, changed_at: f64, path: Option<&str>) -> Entry {
Entry { id: id.into(), name: "Bracket".into(), path: path.map(str::to_string), changed_at }
}
+4 -4
View File
@@ -48,15 +48,15 @@ impl Download {
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
let closed = ui_kit::Window::new(egui::Id::new("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
ui.horizontal(|ui| {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
+53 -162
View File
@@ -191,9 +191,8 @@ mod web {
const CANVAS_ID: &str = "solidcraft_canvas";
const LOADING_ID: &str = "solidcraft_loading";
/// The viewport scene's depth buffer, a 32-bit float one (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). egui's own pass has none: the scene is drawn offscreen.
/// 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;
/// The loading animation (assets/styles/web/loading-logo.svg) settles into the finished mark
@@ -361,7 +360,7 @@ mod web {
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand script fonts: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::ui_kit::type_face::CjkFontLoader,
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
@@ -377,47 +376,24 @@ mod web {
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
prefs_warning: Option<String>,
other_tab_prefs: PrefsInbox,
/// The open designs followed for their kept copy, by `Session::uid` (#10220).
keep_track: std::collections::HashMap<u64, Track>,
/// Every unsaved design's kept copy is current (and the preference is on).
all_kept: bool,
/// A kept copy or recovered work being opened, in stages with progress and Cancel
/// (#10198): the answer to come and what follows it ([`Shell::restored`]).
restoring: Option<(std::sync::mpsc::Receiver<serde_json::Value>, keep::AfterOpen)>,
}
impl Shell {
/// The settings as they are kept between visits: with the user's own language, not a
/// session-only English fallback.
fn persisted_prefs(&mut self, previous: impl FnOnce() -> Result<Option<String>, String>) -> Option<String> {
fn persisted_prefs(&mut self) -> String {
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
let (prefs, warning) = match self.app.try_prefs() {
Ok(prefs) => (Some(prefs), None),
Err(reason) => match previous().and_then(|previous| self.app.prefs_with_stored_panel_layout(previous.as_deref())) {
Ok(prefs) => (Some(prefs), Some(format!("Panel layout changes were not saved: {reason}"))),
Err(error) => (None, Some(format!("Settings were not written: {error}; panel layout: {reason}"))),
},
};
if self.prefs_warning != warning {
if let Some(warning) = &warning {
log::warn!("{warning}");
}
self.prefs_warning = warning;
}
let prefs = self.app.prefs();
self.app.preferences.language = live;
prefs
}
fn stored_prefs() -> Result<Option<String>, String> {
let storage = web_sys::window()
.and_then(|window| window.local_storage().ok().flatten())
.ok_or_else(|| "browser settings storage is unavailable".to_owned())?;
storage.get_item(&crate::prefs_key()).map_err(|_| "browser settings could not be read".to_owned())
}
/// A settings change goes to the browser's storage in the frame it is made, and from
/// there to the other open tabs (#8644). Input is what changes settings, so a frame
/// without any is skipped; eframe's own save still catches anything else.
@@ -425,7 +401,7 @@ mod web {
if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) {
return;
}
let Some(prefs) = self.persisted_prefs(Self::stored_prefs) else { return };
let prefs = self.persisted_prefs();
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
let stored = web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(&crate::prefs_key(), &prefs));
if !matches!(stored, Some(Ok(()))) {
@@ -439,9 +415,8 @@ mod web {
fn load_other_tabs_prefs(&mut self) {
let Some(prefs) = self.other_tab_prefs.take() else { return };
self.app.load_prefs(&prefs);
if let Some(held) = self.persisted_prefs(Self::stored_prefs) {
self.prefs_writer.loaded(held);
}
let held = self.persisted_prefs();
self.prefs_writer.loaded(held);
}
/// Files from the computer, writes to store, messages and the autosave.
@@ -637,7 +612,7 @@ mod web {
/// (when it has a file) and Discard.
fn keep_prompt(&mut self, ctx: &egui::Context) {
let offered = self.files.keep_offered.borrow().clone();
if offered.is_empty() || !self.files.ready.get() || self.restoring.is_some() {
if offered.is_empty() || !self.files.ready.get() {
return;
}
let now = (js_sys::Date::now() / 1000.0) as u64;
@@ -677,21 +652,24 @@ mod web {
return;
}
};
// Finished in `restored` once it is in.
let after = keep::AfterOpen::KeptCopy {
opened: copy.clone(),
id: entry.id.clone(),
path: entry.path.clone(),
name: entry.name.clone(),
drop: plan.drop_copy,
};
self.restore(&copy, after);
return;
self.app.open_path(&copy);
if self.app.session.path.as_deref() != Some(copy.as_str()) {
// Not opened (the status says why): it stays offered.
return;
}
// It is its own design again (saving goes to its file), with its changes unsaved.
self.app.session.path = entry.path.clone();
self.app.session.doc_mut().name = entry.name.clone();
self.app.session.mark_unsaved();
self.app.home.open = false;
}
if let Some(path) = &plan.open_saved {
if vfs::exists(path) {
self.restore(path, keep::AfterOpen::KeptSaved { opened: path.clone(), id: entry.id.clone(), drop: plan.drop_copy });
return;
self.app.open_path(path);
if !keep::is_open(self.app.session.path.as_deref(), path) {
// Not opened (the status says why): the copy stays offered.
return;
}
} else {
// A file on the computer: the browser asks for access again, in this click.
// The copy goes once the file has opened, not before: if access is refused,
@@ -709,62 +687,8 @@ mod web {
}
}
if plan.drop_copy {
self.drop_kept(&entry.id);
}
}
fn drop_kept(&self, id: &str) {
self.files.keep_offered.borrow_mut().retain(|e| e.id != id);
files::remove_kept(&self.files, id);
}
/// Open a kept copy, its saved design or recovered work as any design opens, a few
/// features a frame with progress and Cancel (#10198), so a large design doesn't freeze the
/// page; `after` follows once it is in ([`Self::restored`]).
fn restore(&mut self, path: &str, after: keep::AfterOpen) {
self.restoring = Some((self.app.open_path_answered(path), after));
self.restored();
}
/// What follows a restore that is in: the design gets back its file and name, and the
/// copy or the recovered work it came from goes. One that didn't open (refused or
/// cancelled; the status says why) stays offered.
fn restored(&mut self) {
let Some((rx, _)) = &self.restoring else { return };
let reply = match rx.try_recv() {
Ok(reply) => reply,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => serde_json::json!({"ok": false}),
};
let Some((_, after)) = self.restoring.take() else { return };
if !keep::opened(&reply, self.app.session.path.as_deref(), after.opened()) {
return;
}
match after {
keep::AfterOpen::KeptCopy { id, path, name, drop, .. } => {
// It is its own design again (saving goes to its file), with its changes unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
if drop {
self.drop_kept(&id);
}
}
keep::AfterOpen::KeptSaved { id, drop, .. } => {
if drop {
self.drop_kept(&id);
}
}
keep::AfterOpen::Recovery { path, name, .. } => {
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
}
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
files::remove_kept(&self.files, &entry.id);
}
}
@@ -802,7 +726,7 @@ mod web {
return;
}
let mut act: Option<(&'static str, String)> = None;
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_files"), "folder", "Files in this browser")
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_files"), "folder", "Files in this browser")
// Room for long names; the kit still caps the window at the browser's width.
.cards(4.0)
.show_body(ctx, |ui| {
@@ -811,7 +735,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -865,7 +789,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -904,9 +828,7 @@ mod web {
self.files.panel.set(false);
}
"insert" => {
if !solidcraft_ui_egui::mesh_units::ask(&mut self.app, &path, true) {
self.app.insert_path(&path);
}
self.app.insert_path(&path);
self.files.panel.set(false);
}
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
@@ -951,16 +873,10 @@ mod web {
*r = new.clone();
}
}
if let Some(at) = self.app.home.opened_at.remove(&old) {
self.app.home.opened_at.insert(new.clone(), at);
}
// A forgotten design follows its new name.
if self.app.home.forgotten.remove(&old) {
self.app.home.forgotten.insert(new.clone());
self.app.home.forgotten.insert(new);
}
// An open tab of that file keeps its path and, when the name came
// from the file, the title that matches the file list (#11375).
solidcraft_ui_egui::documents::follow_file_rename(&mut self.app, &old, &new);
}
Err(e) => self.app.set_status(e, true),
}
@@ -972,12 +888,9 @@ mod web {
/// Offer the autosave an earlier session left.
fn recovery_prompt(&mut self, ctx: &egui::Context) {
if self.restoring.is_some() {
return;
}
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
let mut answer = None;
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_recovery"), "restore", "Recover unsaved work?")
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_recovery"), "restore", "Recover unsaved work?")
.required()
.show_body(ctx, |ui| {
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
@@ -999,10 +912,15 @@ mod web {
return;
}
};
// Finished in `restored` once it is in; until then the prompt stays away.
let after = keep::AfterOpen::Recovery { opened: recovery.clone(), path, name };
self.restore(&recovery, after);
return;
self.app.open_path(&recovery);
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
return;
}
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
}
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
@@ -1017,7 +935,7 @@ mod web {
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
@@ -1059,8 +977,8 @@ mod web {
false
}
};
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::ui_kit::type_face::needs_cjk_font(effective) {
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
@@ -1068,13 +986,13 @@ mod web {
ctx.request_repaint();
}
let ready = self.script_font_ready();
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
self.apply_fonts(ctx);
}
}
@@ -1082,7 +1000,7 @@ mod web {
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
@@ -1094,7 +1012,7 @@ mod web {
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::ui_kit::type_face::painted_cjk(ctx) && self.font.text_needs_font() {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx, "ja");
}
}
@@ -1146,11 +1064,10 @@ mod web {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.linked.tick(&mut self.app, ui.ctx());
self.restored();
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
{
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
@@ -1179,7 +1096,7 @@ mod web {
if !self.launch.persists() {
return;
}
let Some(prefs) = self.persisted_prefs(|| Ok(storage.get_string(&crate::prefs_key()))) else { return };
let prefs = self.persisted_prefs();
storage.set_string(&crate::prefs_key(), prefs);
}
}
@@ -1211,25 +1128,6 @@ mod web {
storage.and_then(|s| s.get_item(&crate::channel::key(WEBGL_KEY)).ok().flatten()).is_some()
}
/// Install the GPU viewport the desktop's way: the scene is drawn offscreen with its own depth
/// buffer and the saved anti-aliasing, then copied into egui's pass, which has neither. Drawn
/// straight into egui's pass, the view was 1x whatever Graphics said (#11622).
///
/// The sample count follows the desktop's rule (`gpu::scene_samples`): the saved choice where
/// the browser's device draws it, else the supported default. WebGL draws 1x, as desktop
/// OpenGL does. eframe's web painter has no stencil buffer.
fn install_viewport(app: &mut SolveApp, rs: &eframe::egui_wgpu::RenderState) {
let backend = rs.adapter.get_info().backend;
let depth = eframe::egui_wgpu::depth_format_from_bits(DEPTH_BITS, 0);
let supported = eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth);
let requested = solidcraft_ui_egui::gpu::scene_samples(backend, app.preferences.msaa, &supported);
let drawn = app.set_wgpu_split(rs, DEPTH_BITS, 0, requested);
// After the install, which sets egui's own counts: those leave out the scene's depth
// buffer, and they include counts WebGL would draw at 1x.
app.msaa_supported = solidcraft_ui_egui::gpu::scene_sample_choices(backend, supported);
log::info!("SolidCraft graphics: {backend:?}, MSAA {drawn}x (saved {}x)", app.preferences.msaa);
}
/// Fetch font bytes asynchronously through the browser.
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
@@ -1245,10 +1143,6 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
solidcraft_ui_egui::i18n::install();
solidcraft_look::install_tables();
solidcraft_ui_egui::workspace::install_tabs();
solidcraft_ui_egui::navigation_slots::install();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
let launch = crate::Launch::parse(&query());
@@ -1262,9 +1156,7 @@ mod web {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// The window's pass (egui's) has no depth buffer: the scene has its own
// (`install_viewport`).
let mut options = eframe::WebOptions { depth_buffer: 0, ..Default::default() };
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
if webgl()
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
@@ -1338,7 +1230,8 @@ mod web {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
install_viewport(&mut app, rs);
// eframe's web painter has no stencil buffer.
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
}
if launch.open.is_none() && crate::query_flag(&query(), "sample") {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
@@ -1380,11 +1273,9 @@ mod web {
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
prefs_warning: None,
other_tab_prefs,
keep_track: Default::default(),
all_kept: false,
restoring: None,
}))
}),
)
+5 -19
View File
@@ -44,10 +44,8 @@ const UPDATE_TIMEOUT_MS = 20000;
const COMPLETE = "build-complete";
/** The entry that marks a build replaced by a newer worker. */
const RETIRED = "build-retired";
/** A page remembered this recently may still be loading (not yet listed among the clients). A slow
* device can take minutes between the launch's answer and the page appearing; a live page's record is
* kept for as long as it is listed. */
const PAGE_LOADING_MS = 10 * 60 * 1000;
/** A page remembered this recently may still be loading (not yet listed among the clients). */
const PAGE_LOADING_MS = 30 * 1000;
const url = (file) => new URL(file, self.registration.scope).href;
const has = (name, entry) => caches.match(url(entry), { cacheName: name }).then(Boolean);
@@ -104,13 +102,10 @@ async function started(id, build) {
await (await caches.open(PAGES)).put(pageEntry(id), new Response(record));
}
/**
* The build a page started, or null when it was not remembered. Never this worker's build instead:
* after an update that is another build than the page's, and its files would be mixed into the page.
*/
/** The build a page started; a page started before it was remembered gets this worker's. */
async function startedBy(id) {
const response = id ? await caches.match(pageEntry(id), { cacheName: PAGES }) : null;
return response ? (await response.json()).build : null;
return response ? (await response.json()).build : CACHE;
}
/** The builds open pages use (pages closed since are forgotten). */
@@ -153,10 +148,6 @@ self.addEventListener("activate", (event) => {
}
await sweep();
await self.clients.claim();
// Pages open when the worker first takes over came from the network, and get their files from it.
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
}
})(),
);
});
@@ -230,12 +221,7 @@ async function launchOrKept(event) {
/** A page's files come from the build it started, else the network. */
async function asset(event) {
const build = await startedBy(event.clientId).catch(() => null);
// A page whose build is not known, or whose build's copy is gone, would get another build's files
// (a mixed page that never starts). It fails visibly instead: reloading starts a whole build.
if (!build || (build !== NETWORK && !(await caches.has(build)))) {
return new Response("This page's version of the app is no longer kept. Reload the page.", { status: 503, headers: { "Content-Type": "text/plain" } });
}
const build = await startedBy(event.clientId).catch(() => CACHE);
const kept = build === NETWORK ? null : await caches.match(event.request, { cacheName: build, ignoreSearch: true });
return kept || fetch(event.request);
}
+1 -7
View File
@@ -30,17 +30,10 @@ type_complexity = "allow"
neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-viewport-gpu = { workspace = true }
solidcraft-viewport = { workspace = true }
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { workspace = true }
solidcraft-ui-egui = { workspace = true }
# SolidCraft's look (icons, glyphs, built-in themes), installed at startup.
solidcraft-look = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-theme = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
@@ -52,6 +45,7 @@ rfd = { workspace = true }
ron = "0.12"
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
pollster = { workspace = true }
getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -112,7 +112,7 @@ pub fn restart(software: bool, lost_at: u64, reason: &str) {
pub fn end() -> ! {
let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| {
std::thread::sleep(END_GRACE);
solidcraft_engine::note!("SolidCraft graphics: still closing after the device was lost; aborting");
eprintln!("SolidCraft graphics: still closing after the device was lost; aborting");
std::process::abort();
});
if let Err(e) = watchdog {
+60 -207
View File
@@ -35,7 +35,7 @@ mod native_crash;
mod native_crash_macos;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(any(target_os = "macos", test))]
#[cfg(target_os = "macos")]
mod native_menu;
mod nav_bridge;
#[cfg(target_os = "macos")]
@@ -79,7 +79,7 @@ fn mark(step: &str) {
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
if *on {
solidcraft_engine::note!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
}
}
@@ -87,8 +87,6 @@ struct App {
solve: SolveApp,
/// The settings folder whose start marker the first frame clears (`startup::started`).
starting: Option<std::path::PathBuf>,
/// A newly selected API is confirmed only once the first frame has been presented.
backend_attempt: Option<(std::path::PathBuf, startup::BackendAttempt)>,
/// Frames begun; the second one means the first was drawn (startup is over).
frames: u32,
/// Started over the control channel: the window doesn't ask for the foreground.
@@ -217,12 +215,6 @@ impl eframe::App for App {
solidcraft_ui_egui::ai_connect::begin(&mut self.solve);
solidcraft_ui_egui::agent_chat::begin(&mut self.solve);
}
if let Some((path, attempt)) = self.backend_attempt.take()
&& let Err(reason) = startup::confirm_backend_start(&path, &attempt)
{
log::warn!("graphics: could not confirm the startup choice: {reason}");
self.solve.set_status(reason, true);
}
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
@@ -269,11 +261,7 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
/// The frame's input before egui reads it: the release of a press handed to a window move
/// or resize, the macOS menu and full-screen fixes, the 3D mouse, then the app's own hook.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// First: the release of a press the last frame handed to a window move or resize (#13334).
solidcraft_ui_egui::window_chrome::release_press_taken_by_os(ctx, raw);
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.native_menu {
menu.raw_input_hook(&mut self.solve, ctx, raw);
@@ -294,20 +282,13 @@ impl eframe::App for App {
let now = std::time::Instant::now();
if self.prefs_writer.due(ui.input(|i| i.events.iter().any(changes_settings)), now)
&& let Some(storage) = frame.storage_mut()
&& let Ok(prefs) = self.prefs_for_storage(storage)
&& let Some(wait) = self.prefs_writer.store(storage, prefs, now)
&& let Some(wait) = self.prefs_writer.store(storage, self.solve.prefs(), now)
{
ui.ctx().request_repaint_after(wait);
}
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
let prefs = match self.prefs_for_storage(storage) {
Ok(prefs) => prefs,
Err(reason) => {
log::warn!("Settings were not written: {reason}");
return;
}
};
let prefs = self.solve.prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs.clone());
self.prefs_writer.written = Some(prefs);
}
@@ -349,17 +330,10 @@ impl Drop for App {
// never return (#9044).
device_lost::end();
}
// Restart only after eframe has flushed settings. Graphics restarts also reopen
// the saved designs collected by the normal quit flow.
let graphics_restart = solidcraft_ui_egui::update::take_graphics_restart(&mut self.solve);
// 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 mut command = if graphics_restart {
graphics_restart_command(&program, &args, &self.solve.preferences.open_designs)
} else {
let mut command = std::process::Command::new(&program);
command.args(&args);
command
};
let mut command = std::process::Command::new(&program);
command.args(&args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
@@ -375,62 +349,6 @@ impl Drop for App {
}
}
/// A UI restart applies the saved API instead of inheriting this session's recovery
/// overrides. Explicit developer overrides remain authoritative.
fn graphics_restart_command(program: &std::path::Path, args: &[String], designs: &[String]) -> std::process::Command {
let mut command = std::process::Command::new(program);
command.args(args).args(designs);
for variable in [
startup::RETRY_BACKEND_ENV,
startup::FALLBACK_NOTE_ENV,
startup::RENDERER_ENV,
startup::SAFE_MODE_ENV,
device_lost::LOST_ENV,
device_lost::REASON_ENV,
] {
command.env_remove(variable);
}
command
}
/// Re-enter an AppImage through its outer executable only when this process actually lives
/// in that image's mount. An inherited APPIMAGE from another application is not our launcher.
fn graphics_restart_program(
exe: Option<&std::path::Path>,
appimage: Option<&std::ffi::OsStr>,
appdir: Option<&std::ffi::OsStr>,
is_file: impl Fn(&std::path::Path) -> bool,
) -> Option<std::path::PathBuf> {
let exe = exe?;
if let (Some(image), Some(directory)) = (appimage, appdir) {
let image = std::path::Path::new(image);
let directory = std::path::Path::new(directory);
if image.is_absolute() && directory.is_absolute() && exe.starts_with(directory) && is_file(image) {
return Some(image.to_path_buf());
}
}
Some(exe.to_path_buf())
}
/// Keep launch-scoped settings through a UI restart. Safe mode and the sample are one-shot;
/// original file arguments are replaced by the current saved tabs at shutdown.
fn graphics_restart_args(args: impl IntoIterator<Item = String>) -> Vec<String> {
let mut retained = Vec::new();
let mut args = args.into_iter();
while let Some(argument) = args.next() {
match argument.as_str() {
"--control" | "--theme-file" | "--max-file-size" => {
if let Some(value) = args.next() {
retained.extend([argument, value]);
}
}
value if value.starts_with("--max-file-size=") => retained.push(argument),
_ => {}
}
}
retained
}
/// The window's graphics device, watched for its loss.
struct LostWatch {
lost: eframe::egui_wgpu::DeviceLost,
@@ -443,14 +361,6 @@ struct LostWatch {
}
impl App {
fn prefs_for_storage(&self, storage: &dyn eframe::Storage) -> Result<String, String> {
self.solve.try_prefs().or_else(|reason| {
log::warn!("Panel layout changes were not saved: {reason}");
let previous = solidcraft_ui_egui::prefs::stored(|key| storage.get_string(key));
self.solve.prefs_with_stored_panel_layout(previous.as_deref())
})
}
/// The graphics device was lost (#4172): the window can't draw again, so close, keeping
/// unsaved changes for recovery as a forced quit does (the window can't ask about them), and
/// start again once closed ([`device_lost::restart`]).
@@ -458,7 +368,7 @@ impl App {
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
let reason = gpu.lost.reason().unwrap_or_default();
log::error!("graphics: the device was lost ({reason}); closing to start again");
solidcraft_engine::note!("SolidCraft graphics: the device was lost ({reason}); starting again");
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
gpu.lost_at = Some(device_lost::now_secs());
gpu.reason = reason;
@@ -543,7 +453,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
let content = ui.ctx().content_rect();
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
}
@@ -593,7 +503,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
// The groups come from the format registry, so a new format joins this dialog by itself.
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
@@ -640,15 +550,6 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
Box::pin(async move { folder.answer(future.await) })
})),
graphics_adapters: Vec::new(),
graphics_backend: None,
graphics_backend_override: std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty()),
graphics_restart: graphics_restart_program(
std::env::current_exe().ok().as_deref(),
std::env::var_os("APPIMAGE").as_deref(),
std::env::var_os("APPDIR").as_deref(),
std::path::Path::is_file,
)
.map(|program| (program, graphics_restart_args(std::env::args().skip(1)))),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
window_system: window_system_now(),
// Linux: the desktop's dark or light setting, which the window is not told on every
@@ -912,12 +813,6 @@ fn restore_preferences(app: &mut SolveApp, stored_ui: Option<&str>, saved_graphi
/// Restore startup choices after loading the separately saved UI preferences.
fn restore_graphics_preferences(app: &mut SolveApp, saved: Option<&serde_json::Value>) {
// Preserve legacy comma lists until the user explicitly selects a different API.
app.preferences.graphics_backend = saved
.and_then(|v| v.get("backend").and_then(serde_json::Value::as_str))
.filter(|choice| !choice.trim().is_empty())
.unwrap_or("auto")
.to_string();
app.preferences.graphics_adapter = saved.and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str)).unwrap_or_default().to_string();
// graphics.json chooses the window before app.ron is loaded. Reflect that saved choice,
// not the current session's window system (which an environment override may change).
@@ -929,10 +824,21 @@ fn restore_graphics_preferences(app: &mut SolveApp, saved: Option<&serde_json::V
.to_string();
}
/// The viewport scene's sample count, by the rule the web app shares
/// (`solidcraft_ui_egui::gpu::scene_samples`).
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
/// The viewport scene's sample count: the app's anti-aliasing (the window's pass is 1x), clamped
/// by the backend's policy (OpenGL renders 1x, see `effective_msaa_samples`). A count the device
/// cannot render with falls back to the supported default.
fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]) -> u32 {
solidcraft_ui_egui::gpu::scene_samples(backend, samples, supported)
let requested = eframe::egui_wgpu::effective_msaa_samples(backend, samples.max(1));
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
requested
} else {
supported.iter().copied().filter(|&n| n <= solidcraft_ui_egui::prefs::Prefs::default().msaa).max().unwrap_or(1)
}
}
/// Keep the default until the installed renderer's startup check proves a fallback is needed.
@@ -992,7 +898,17 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
#[cfg(not(target_os = "macos"))]
let icon =
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
Ok(solidcraft_ui_egui::window_chrome::viewport("SolidCraft", APP_ID, custom_titlebar).with_icon(icon))
Ok(egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_icon(icon)
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID))
}
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
@@ -1014,10 +930,6 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
solidcraft_ui_egui::i18n::install();
solidcraft_look::install_tables();
solidcraft_ui_egui::workspace::install_tabs();
solidcraft_ui_egui::navigation_slots::install();
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
@@ -1081,7 +993,7 @@ fn main() -> eframe::Result {
if let Some(value) = max_file_size {
let applied = startup::max_file_size(value.as_deref()).and_then(|bytes| solidcraft_engine::io::limits::set_override(Some(bytes)));
if let Err(error) = applied {
solidcraft_engine::note!("SolidCraft: {error}");
eprintln!("SolidCraft: {error}");
return Err(eframe::Error::AppCreation(std::io::Error::other(error).into()));
}
}
@@ -1097,27 +1009,7 @@ fn main() -> eframe::Result {
safe_mode = true;
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
}
let mut cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
// Persist the one-start guard before any adapter, device or depth-format probe.
// Recovery launches and explicit overrides must not consume a pending UI choice.
let backend_override = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
let retry_backend = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let backend_path = settings.as_ref().map(|dir| dir.join("graphics.json"));
let backend_launch = match backend_path
.as_ref()
.map(|path| startup::prepare_backend_start(path, !cpu_renderer && !backend_override && retry_backend.is_none()))
.transpose()
{
Ok(launch) => launch.unwrap_or_default(),
Err(reason) => {
// A choice that could not be guarded must not load the requested driver.
cpu_renderer = true;
notes.push(reason);
startup::BackendLaunch::default()
}
};
let backend_attempt = backend_path.zip(backend_launch.attempt);
let backend_note = backend_launch.note;
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
@@ -1130,14 +1022,14 @@ fn main() -> eframe::Result {
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>()).or(backend_note);
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
solidcraft_engine::note!("SolidCraft: {note}");
eprintln!("SolidCraft: {note}");
notes.push(note.clone());
}
let saved_samples = if safe_mode { None } else { msaa() };
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_launch.backend.as_deref() });
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()
@@ -1218,7 +1110,6 @@ fn main() -> eframe::Result {
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_app = created.clone();
let starting = settings.clone();
let attempt_in_app = backend_attempt.clone();
// A panic on the window's thread ends the app without `on_exit`; this disconnects from the
// 3D mouse driver then too.
#[cfg(target_os = "macos")]
@@ -1247,13 +1138,13 @@ fn main() -> eframe::Result {
// Windows: the taskbar button shows the mark even when slow first frames keep the
// taskbar waiting for the window's icon.
if !native_frame::set_class_icon(cc) {
solidcraft_engine::note!("SolidCraft window: no class icon; the taskbar may show a generic one");
eprintln!("SolidCraft window: no class icon; the taskbar may show a generic one");
}
// The OS hit test offers the resize border, at the window's DPI, from the first
// message (#1381).
app.native_resize_border = custom_titlebar && native_frame::install(cc);
if custom_titlebar && !app.native_resize_border {
solidcraft_engine::note!("SolidCraft window: no native resize border; egui edge zones instead");
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
@@ -1283,18 +1174,10 @@ fn main() -> eframe::Result {
})
.collect();
let info = rs.adapter.get_info();
app.services.graphics_backend = match info.backend {
eframe::wgpu::Backend::Vulkan => Some("vulkan"),
eframe::wgpu::Backend::Dx12 => Some("dx12"),
eframe::wgpu::Backend::Gl => Some("gl"),
eframe::wgpu::Backend::Metal => Some("metal"),
_ => None,
};
if !app.preferences.graphics_adapter.is_empty() && !info.name.eq_ignore_ascii_case(app.preferences.graphics_adapter.as_str()) {
solidcraft_engine::note!(
eprintln!(
"SolidCraft graphics: preferred adapter {:?} was not used; using {:?} instead",
app.preferences.graphics_adapter,
info.name
app.preferences.graphics_adapter, info.name
);
}
let samples = scene_msaa_request(saved_samples);
@@ -1302,7 +1185,7 @@ fn main() -> eframe::Result {
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
});
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
solidcraft_engine::note!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
app.msaa_supported = supported;
@@ -1320,11 +1203,12 @@ fn main() -> eframe::Result {
));
}
mark("viewport pipelines");
// `--control`/the environment name a port for this run; otherwise the preference (off
// by default) opens its port, and turning it off or on later takes effect at once
// (docs/control-protocol.md).
let ctx = cc.egui_ctx.clone();
solidcraft_ui_egui::control_switch::launch(&mut app, Box::new(move |listen| control_server::start(listen, ctx.clone())), control_port);
// `--control`/the environment name a port; otherwise the preference (on by default)
// opens the default one (docs/control-protocol.md).
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
app = app.with_control(rx);
}
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
// on a free port the first time the chat connects (docs/agent-chat.md).
let ctx = cc.egui_ctx.clone();
@@ -1338,7 +1222,7 @@ fn main() -> eframe::Result {
app.session.undo.clear();
// Built straight into the session, so nothing asked for a fit: frame it at the
// home view as any opened design is (#9195). The fit waits for the first layout.
app.cam.set_view(solidcraft_viewport::StandardView::Iso);
app.cam.set_view(solidcraft_engine::render::StandardView::Iso);
app.fit_view();
}
// Lock files next to open designs: a design open on another computer says so (#5467),
@@ -1421,7 +1305,6 @@ fn main() -> eframe::Result {
prefs_writer: PrefsWriter::read(app.prefs()),
solve: app,
starting,
backend_attempt: attempt_in_app,
frames: 0,
take_focus_unwanted: control_port.is_some(),
come_forward: come_forward::ComeForward::default(),
@@ -1437,9 +1320,7 @@ fn main() -> eframe::Result {
);
control_server::remove_token_file();
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => {
no_window(e, cpu_renderer, started, settings.as_deref(), backend_attempt.as_ref())
}
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, settings.as_deref()),
r => r,
}
}
@@ -1458,24 +1339,21 @@ fn startup_step(step: &'static str, detail: &str) {
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
/// in use is started); `preference` is `graphics.json`'s `backend`.
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
// One frame in flight, as eframe's own default (`NativeOptions::default`): egui-wgpu's
// `WgpuConfiguration::default()` is HIGH_THROUGHPUT (two frames queued), which added a frame
// of input-to-display latency to every pan, orbit and drag.
let mut options = eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY);
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
// A retry applies only to this launch. WGPU_BACKEND always has priority.
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
solidcraft_engine::note!("SolidCraft graphics: starting {backends:?}");
eprintln!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
let intel_mac = cfg!(all(target_os = "macos", target_arch = "x86_64"));
let indirect = std::env::var(startup::INDIRECT_VALIDATION_ENV).ok();
setup.instance_descriptor.flags = startup::window_instance_flags(setup.instance_descriptor.flags, intel_mac, indirect.as_deref());
// Never a `dxcompiler.dll` another program put on PATH (#6756).
setup.instance_descriptor.backend_options.dx12.shader_compiler = solidcraft_viewport_gpu::dx12_compiler();
setup.instance_descriptor.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let environment = std::env::var("WGPU_ADAPTER_NAME").ok();
let adapter = graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string));
startup::configure_adapter(setup, cpu, environment, adapter);
@@ -1492,39 +1370,14 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
/// A failed renderer returns here before app creation. A fresh process avoids reusing winit's
/// event loop or a failed device; each retry advances through the APIs, then software once.
fn no_window(
error: eframe::Error,
tried_cpu: bool,
failed: eframe::wgpu::Backends,
settings: Option<&std::path::Path>,
backend_attempt: Option<&(std::path::PathBuf, startup::BackendAttempt)>,
) -> eframe::Result {
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
solidcraft_engine::note!("SolidCraft: the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
// This failure was handled, so neither the marker nor the journal should call it a crash.
if let Some(dir) = settings {
startup::started(dir);
}
solidcraft_engine::crash::end_session();
// A rejected UI choice goes back to its previous API before ordinary fallback.
// This is separate from startup::started: a handled failure is not confirmation.
if let Some((path, attempt)) = backend_attempt {
match startup::rollback_backend_start(path, attempt) {
Ok(()) => {
if let Ok(exe) = std::env::current_exe() {
let note = startup::backend_reverted_note(&attempt.requested, &attempt.previous);
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env_remove(startup::RETRY_BACKEND_ENV).env(startup::FALLBACK_NOTE_ENV, note);
match command.status() {
Ok(status) if status.success() => return Ok(()),
Ok(_) => return Err(error),
Err(reason) => log::error!("could not restart with the previous graphics API: {reason}"),
}
}
}
Err(reason) => log::warn!("could not restore the previous graphics API: {reason}"),
}
}
let explicit = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
if let Some(next) = startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed)
&& let Ok(exe) = std::env::current_exe()
@@ -1532,7 +1385,7 @@ fn no_window(
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
solidcraft_engine::note!("SolidCraft: {note}");
eprintln!("SolidCraft: {note}");
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
@@ -1938,7 +1791,7 @@ mod viewport_tests {
viewport.fullscreen = Some(fullscreen);
viewport.native_pixels_per_point = Some(retina);
super::fullscreen_content_insets(&mut raw, 32.0, zoom);
ctx.run_ui(raw, |ui| super::content_ui(app, ui)).textures_delta.clear();
let _ = ctx.run_ui(raw, |ui| super::content_ui(app, ui));
};
// Enter, leave, and enter again: no stale inset when returning to a normal window.
for fullscreen in [false, true, false, true] {
+162 -347
View File
@@ -1,221 +1,137 @@
//! SolidCraft's macOS menu bar: its entries as kit menu items
//! (`solidcraft_ui_egui::window_chrome::native_menu`), each with a command id or a `ui.*` id, run
//! through the same actions as the application bar's File menu (`toolbar::FileAction`).
//!
//! Entries that are control actions use their ids and do the same there: `app.quit`,
//! `ui.prefs {open}` (Settings and Keyboard Shortcuts), `ui.agent_chat {toggle}`, `ui.help {item}`. Undo and Redo are the commands
//! `edit.undo` and `edit.redo`. The other entries' ids are defined here: `ui.file.new`,
//! `ui.file.open`, `ui.file.open_recent {path}`, `ui.file.clear_recent`, `ui.file.save`,
//! `ui.file.save_as`, `ui.file.save_json`, `ui.file.export {format}`,
//! `ui.file.export_mesh {format}`. Menus are `menu.file`, `menu.file.recent`, `menu.edit`,
//! `menu.window` and `menu.help`.
//!
//! The menu bar itself is macOS's; the entries and their ids build everywhere, so their tests run
//! on every system.
//! macOS system menus, owned and updated on eframe's main thread.
use serde_json::{Value, json};
use solidcraft_ui_egui::{
SolveApp,
toolbar::FileAction,
ui_kit::Item,
window_chrome::native_menu::{self, MenuBar},
use std::sync::mpsc::{Receiver, sync_channel};
use muda::{
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
};
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
/// The Open Recent submenu, refilled when the recent designs change.
const RECENT: &str = "menu.file.recent";
const OPEN_RECENT: &str = "ui.file.open_recent";
/// Recent designs listed.
#[cfg(target_os = "macos")]
const RECENT_COUNT: usize = 32;
/// File > Insert entries.
const INSERTS: [(&str, FileAction); 3] = [
("Insert STEP\u{2026}", FileAction::Command("file.insert_step")),
("Insert Mesh\u{2026}", FileAction::Command("file.insert_mesh")),
("Insert Design", FileAction::Command("component.insert_design")),
];
/// File > Export entries.
const EXPORTS: [(&str, FileAction); 7] = [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
];
const DESIGN_PROPERTIES: FileAction = FileAction::Command("document.metadata");
/// The other actions with a fixed id (all but Open Recent), to map an id back to its action.
const FIXED: &[FileAction] = &[
FileAction::Settings,
FileAction::Shortcuts,
FileAction::Quit,
FileAction::New,
FileAction::Open,
FileAction::ClearRecent,
FileAction::Save,
FileAction::SaveAs,
FileAction::SaveJson,
DESIGN_PROPERTIES,
FileAction::Undo,
FileAction::Redo,
FileAction::AgentChat,
];
/// The Help menu's entries: the control channel's `ui.help {item}`.
const HELP_ID: &str = "ui.help";
/// The Help menu's items (`help::run`), top to bottom.
const HELP: [&str; 7] = ["docs", "updates", "shortcuts", "messages", "report", "crashes", "install_cli"];
fn help_label<'a>(app: &'a SolveApp, item: &str) -> &'a str {
match item {
"docs" => app.tr_label("Documentation"),
"updates" => app.tr("update.check_for_updates"),
"shortcuts" => app.tr_label("Keyboard Shortcuts"),
"messages" => app.tr_label("Messages\u{2026}"),
"report" => app.tr_label("Report an Issue\u{2026}"),
"crashes" => app.tr_label("Crash Reports\u{2026}"),
_ => app.tr_label("Install solidcraft-cli command"),
}
}
/// What a menu entry runs.
#[derive(Debug, PartialEq)]
enum Entry {
File(FileAction),
Help(&'static str),
}
/// The entry's id and parameters for `action`.
fn id_of(action: &FileAction) -> (String, Option<Value>) {
let fixed = |id: &str| (id.to_string(), None);
match action {
FileAction::Settings => ("ui.prefs".into(), Some(json!({ "open": "General" }))),
FileAction::Shortcuts => ("ui.prefs".into(), Some(json!({ "open": "Shortcuts" }))),
FileAction::Quit => fixed("app.quit"),
FileAction::New => fixed("ui.file.new"),
FileAction::Open => fixed("ui.file.open"),
FileAction::OpenRecent(path) => (OPEN_RECENT.into(), Some(json!({ "path": path }))),
FileAction::ClearRecent => fixed("ui.file.clear_recent"),
FileAction::Save => fixed("ui.file.save"),
FileAction::SaveAs => fixed("ui.file.save_as"),
FileAction::SaveJson => fixed("ui.file.save_json"),
FileAction::Export(format) => ("ui.file.export".into(), Some(json!({ "format": format }))),
FileAction::ExportMesh(format) => ("ui.file.export_mesh".into(), Some(json!({ "format": format }))),
FileAction::Command(command) => fixed(command),
FileAction::Undo => fixed("edit.undo"),
FileAction::Redo => fixed("edit.redo"),
FileAction::AgentChat => ("ui.agent_chat".into(), Some(json!({ "toggle": true }))),
}
}
/// The entry with `id` and `params`, `None` for one this menu doesn't have.
fn entry_of(id: &str, params: Option<&Value>) -> Option<Entry> {
if id == OPEN_RECENT {
return params.and_then(|p| p.get("path")).and_then(Value::as_str).map(|path| Entry::File(FileAction::OpenRecent(path.to_string())));
}
if id == HELP_ID {
let item = params.and_then(|p| p.get("item")).and_then(Value::as_str);
return HELP.into_iter().find(|h| Some(*h) == item).map(Entry::Help);
}
let wanted = (id.to_string(), params.cloned());
FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).find(|a| id_of(a) == wanted).map(Entry::File)
}
/// The entries with a key, as menu accelerators.
const SHORTCUTS: [(FileAction, &str); 8] = [
(FileAction::Settings, "Cmd+,"),
(FileAction::Quit, "Cmd+Q"),
(FileAction::New, "Cmd+N"),
(FileAction::Open, "Cmd+O"),
(FileAction::Save, "Cmd+S"),
(FileAction::SaveAs, "Cmd+Shift+S"),
(FileAction::Undo, "Cmd+Z"),
(FileAction::Redo, "Cmd+Shift+Z"),
];
fn item(label: &str, action: &FileAction) -> Item {
let (id, params) = id_of(action);
let shortcut = SHORTCUTS.iter().find(|(a, _)| a == action).map_or("", |(_, k)| *k);
let mut item = Item::action(&id, label, "").key(shortcut);
item.params = params;
item
}
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
let mut menu = Item::action(id, label, "");
menu.children = children;
menu
}
fn recent_items(app: &SolveApp, paths: &[String]) -> Vec<Item> {
let mut items: Vec<Item> = paths.iter().map(|path| item(path, &FileAction::OpenRecent(path.clone()))).collect();
items.push(Item::sep());
items.push(item(app.tr("ui.clear_recent"), &FileAction::ClearRecent));
items
}
fn menu_bar(app: &SolveApp, recent: &[String]) -> MenuBar {
let more = |key: &str| format!("{}\u{2026}", app.tr(key));
let mut file = vec![
item(app.tr("ui.new_design"), &FileAction::New),
item(&more("ui.open"), &FileAction::Open),
submenu(RECENT, app.tr("ui.open_recent"), recent_items(app, recent)),
Item::sep(),
];
file.extend(INSERTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
file.extend([
Item::sep(),
item(app.tr("ui.save"), &FileAction::Save),
item(&more("ui.save_as"), &FileAction::SaveAs),
item(app.tr_label("Save As JSON\u{2026}"), &FileAction::SaveJson),
Item::sep(),
]);
file.extend(EXPORTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
file.extend([Item::sep(), item(app.tr_label("Design Properties"), &DESIGN_PROPERTIES)]);
let edit = vec![item(app.tr("ui.undo"), &FileAction::Undo), item(app.tr("ui.redo"), &FileAction::Redo)];
let window = vec![item(app.tr_label("Agent Chat"), &FileAction::AgentChat)];
let help = HELP.iter().map(|h| Item::action(HELP_ID, help_label(app, h), "").with(json!({ "item": h }))).collect();
MenuBar {
title: "SolidCraft".into(),
version: env!("CARGO_PKG_VERSION").into(),
app: vec![item(&more("ui.preferences"), &FileAction::Settings), item(&more("ui.keyboard_shortcuts"), &FileAction::Shortcuts)],
quit: item(&format!("{} SolidCraft", app.tr("ui.quit")), &FileAction::Quit),
menus: vec![
submenu("menu.file", app.tr("ui.file"), file),
submenu("menu.edit", app.tr_label("Edit"), edit),
submenu(native_menu::WINDOW, "Window", window),
submenu("menu.help", app.tr("ui.help"), help),
],
}
}
/// The menu bar, owned and updated on eframe's main thread.
#[cfg(target_os = "macos")]
pub struct NativeMenu {
menu: native_menu::NativeMenu,
menu: Menu,
actions: Vec<(MenuItem, FileAction)>,
help_actions: Vec<(MenuItem, &'static str)>,
events: Receiver<MenuEvent>,
recent: Submenu,
recent_paths: Vec<String>,
}
#[cfg(target_os = "macos")]
impl NativeMenu {
pub fn new(app: &SolveApp, ctx: &egui::Context) -> Result<Self, String> {
let recent_paths: Vec<String> = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
let menu = native_menu::NativeMenu::new(&menu_bar(app, &recent_paths), ctx)?;
let native = Self { menu, recent_paths };
native.update(app, ctx);
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
let menu = Menu::new();
let application = Submenu::new("SolidCraft", true);
let file = Submenu::new(app.tr("ui.file"), true);
let edit = Submenu::new(app.tr_label("Edit"), true);
let recent = Submenu::new(app.tr("ui.open_recent"), true);
let window = Submenu::new("Window", true);
let help = Submenu::new(app.tr("ui.help"), true);
let mut help_actions = Vec::new();
let mut actions = Vec::new();
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
parent.append(&item)?;
actions.push((item, action));
Ok(())
};
application.append(&PredefinedMenuItem::about(
Some("About SolidCraft"),
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
))?;
application.append(&PredefinedMenuItem::separator())?;
add(
&application,
&format!("{}\u{2026}", app.tr("ui.preferences")),
FileAction::Settings,
Some(Accelerator::new(Modifiers::META, Code::Comma)),
)?;
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
application.append(&PredefinedMenuItem::separator())?;
application.append(&PredefinedMenuItem::services(None))?;
application.append(&PredefinedMenuItem::separator())?;
application.append_items(&[
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
])?;
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
file.append(&recent)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, command) in
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
{
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
add(
&file,
&format!("{}\u{2026}", app.tr("ui.save_as")),
FileAction::SaveAs,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
)?;
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, action) in [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
] {
add(&file, app.tr_label(label), action, None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
window.append(&PredefinedMenuItem::separator())?;
window.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::fullscreen(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
])?;
for (action, label) in [
("docs", "Documentation"),
("updates", app.tr("update.check_for_updates")),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
("crashes", "Crash Reports\u{2026}"),
("install_cli", "Install solidcraft-cli command"),
] {
let item = MenuItem::new(app.tr_label(label), true, None);
help.append(&item)?;
help_actions.push((item, action));
}
menu.append_items(&[&application, &file, &edit, &window, &help])?;
let (sender, events) = sync_channel(64);
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.try_send(event);
ctx.request_repaint();
}));
menu.init_for_nsapp();
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
native.update_recent(app)?;
Ok(native)
}
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
for (id, params) in self.menu.events() {
match entry_of(&id, params.as_ref()) {
Some(Entry::Help(item)) => solidcraft_ui_egui::help::run(app, ctx, item),
// A focused text field takes its own Undo and Redo.
Some(Entry::File(action @ (FileAction::Undo | FileAction::Redo))) if ctx.egui_wants_keyboard_input() => {
for event in self.events.try_iter().take(64) {
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
solidcraft_ui_egui::help::run(app, ctx, action);
}
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
raw.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
@@ -223,159 +139,58 @@ impl NativeMenu {
repeat: false,
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
});
} else {
action.run(app);
}
Some(Entry::File(action)) => action.run(app),
None => log::warn!("native menu: no entry `{id}`"),
}
}
}
fn update(&self, app: &SolveApp, ctx: &egui::Context) {
let focus = ctx.egui_wants_keyboard_input();
self.menu.update(|id, params| match entry_of(id, params) {
Some(Entry::File(action)) => action.menu_enabled(app, focus),
Some(Entry::Help(item)) => solidcraft_ui_egui::help::available(app, item),
None => false,
});
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
action.menu_enabled(app, ctx.egui_wants_keyboard_input())
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
if !self.recent_paths.iter().eq(app.home.recent.iter().take(RECENT_COUNT)) {
self.recent_paths = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
if let Err(error) = self.menu.replace(RECENT, &recent_items(app, &self.recent_paths)) {
app.set_status(format!("Recent designs menu: {error}"), true);
for (item, action) in &self.actions {
let enabled = Self::action_enabled(action, app, ctx);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
}
}
self.update(app, ctx);
for (item, action) in &self.help_actions {
let enabled = solidcraft_ui_egui::help::available(app, action);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
}
}
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
&& let Err(error) = self.update_recent(app)
{
app.set_status(format!("Recent designs menu: {error}"), true);
}
}
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
while self.recent.remove_at(0).is_some() {}
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
for path in &self.recent_paths {
let action = FileAction::OpenRecent(path.clone());
let item = MenuItem::new(path, action.enabled(app), None);
self.recent.append(&item)?;
self.actions.push((item, action));
}
self.recent.append(&PredefinedMenuItem::separator())?;
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
self.recent.append(&clear)?;
self.actions.push((clear, FileAction::ClearRecent));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every action the menu offers, Open Recent with a path.
fn actions() -> Vec<FileAction> {
let mut all: Vec<FileAction> = FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).collect();
all.push(FileAction::OpenRecent("/work/a.solidcraft".into()));
all
}
#[test]
fn every_action_and_help_item_round_trips_through_its_id() {
let mut seen = vec![];
for action in actions() {
let (id, params) = id_of(&action);
assert!(!seen.contains(&(id.clone(), params.clone())), "two actions share `{id}` {params:?}");
assert_eq!(entry_of(&id, params.as_ref()), Some(Entry::File(action)), "`{id}` {params:?}");
seen.push((id, params));
}
for h in HELP {
assert_eq!(entry_of(HELP_ID, Some(&json!({ "item": h }))), Some(Entry::Help(h)));
}
// The control channel's ids where it has one.
assert_eq!(id_of(&FileAction::Quit), ("app.quit".to_string(), None));
assert_eq!(id_of(&FileAction::Settings), ("ui.prefs".to_string(), Some(json!({"open": "General"}))));
assert_eq!(id_of(&FileAction::Shortcuts), ("ui.prefs".to_string(), Some(json!({"open": "Shortcuts"}))));
assert_eq!(id_of(&FileAction::AgentChat), ("ui.agent_chat".to_string(), Some(json!({"toggle": true}))));
assert_eq!(entry_of("ui.file.export", Some(&json!({"format": "step"}))), Some(Entry::File(FileAction::Export("step"))));
for (id, params) in [
("ui.file.export", None),
("ui.file.export", Some(json!({"format": "dwg"}))),
(HELP_ID, Some(json!({"item": "nothing"}))),
(HELP_ID, None),
("ui.prefs", None),
("solid.nothing", None),
(OPEN_RECENT, None),
(RECENT, None),
(native_menu::WINDOW, None),
] {
assert_eq!(entry_of(id, params.as_ref()), None, "{id} {params:?}");
}
}
#[test]
fn every_shortcut_is_one_actions_and_the_menu_shows_it() {
for (i, (action, key)) in SHORTCUTS.iter().enumerate() {
assert!(SHORTCUTS.iter().skip(i + 1).all(|(a, k)| a != action && k != key), "{action:?} {key}");
assert_eq!(item("label", action).shortcut, *key);
}
assert_eq!(item("label", &FileAction::SaveJson).shortcut, "");
}
#[cfg(target_os = "macos")]
#[test]
fn menu_shortcuts_parse_as_the_keys_they_were() {
use muda::accelerator::{Accelerator, Code, Modifiers};
let (cmd, shift) = (Modifiers::META, Modifiers::META | Modifiers::SHIFT);
let want = [
Accelerator::new(cmd, Code::Comma),
Accelerator::new(cmd, Code::KeyQ),
Accelerator::new(cmd, Code::KeyN),
Accelerator::new(cmd, Code::KeyO),
Accelerator::new(cmd, Code::KeyS),
Accelerator::new(shift, Code::KeyS),
Accelerator::new(cmd, Code::KeyZ),
Accelerator::new(shift, Code::KeyZ),
];
for ((action, key), want) in SHORTCUTS.iter().zip(want) {
assert_eq!(key.parse::<Accelerator>().ok(), Some(want), "{action:?}");
}
}
#[test]
fn every_entry_of_the_menu_bar_runs_its_action() {
fn leaves(items: &[Item], out: &mut Vec<Item>) {
for item in items {
if item.children.is_empty() {
out.push(item.clone());
} else {
leaves(&item.children, out);
}
}
}
let app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
let bar = menu_bar(&app, &["/work/a.solidcraft".to_string()]);
let mut entries = vec![];
leaves(&bar.app, &mut entries);
leaves(std::slice::from_ref(&bar.quit), &mut entries);
leaves(&bar.menus, &mut entries);
entries.retain(|i| i.id != "-");
// App menu 2 + Quit; File: New, Open, 1 recent, Clear, 3 inserts, Save, Save As, JSON,
// 7 exports, Properties; Edit 2; Window 1; Help 7.
assert_eq!(entries.len(), 3 + 4 + 3 + 3 + 7 + 1 + 2 + 1 + 7, "{:?}", entries.iter().map(|i| &i.id).collect::<Vec<_>>());
for item in &entries {
assert!(entry_of(&item.id, item.params.as_ref()).is_some(), "`{}` {:?} runs nothing", item.id, item.params);
}
assert!(bar.menus.iter().all(|m| m.id.starts_with("menu.")), "menu ids are never actions");
}
/// The entries whose ids are control actions do over the control channel what they do in
/// the menu.
#[test]
fn shared_ids_mean_the_same_over_the_control_channel() {
use solidcraft_ui_egui::scenario::Harness;
let mut h = Harness::new();
h.app.preferences.ai_features = true;
h.app.agent_chat.open = false;
let (id, params) = id_of(&FileAction::AgentChat);
for open in [true, false] {
let out = h.call(&id, params.clone().unwrap_or_default());
assert_eq!(out["ok"], true, "{out}");
assert_eq!(h.app.agent_chat.open, open, "Agent Chat toggles as in the menu");
}
for action in [FileAction::Settings, FileAction::Shortcuts] {
let (id, params) = id_of(&action);
let out = h.call(&id, params.unwrap_or_default());
assert_eq!(out["ok"], true, "{action:?}: {out}");
}
// install_cli links into the system; its menu and control paths are the same `help::run`.
for item in HELP.into_iter().filter(|h| *h != "install_cli") {
let out = h.call(HELP_ID, json!({ "item": item }));
assert_eq!(out["ok"], true, "{item}: {out}");
}
assert_eq!(h.call(HELP_ID, json!({"item": "nothing"}))["ok"], false, "an unknown help item is an error");
let out = h.call(&id_of(&FileAction::Quit).0, json!({}));
assert_eq!((out["ok"].clone(), h.app.quit_requested), (json!(true), true), "{out}");
impl Drop for NativeMenu {
fn drop(&mut self) {
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
self.menu.remove_for_nsapp();
}
}
+1 -1
View File
@@ -434,7 +434,7 @@ mod tests {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
app.home.open = false;
let ctx = Context::default();
ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {}).textures_delta.clear();
let _ = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {});
let mut bridge = NavBridge { shared: Arc::new(Mutex::new(Shared::default())) };
let queue = |bridge: &NavBridge| {
let mut shared = bridge.shared.lock().unwrap();
+1 -6
View File
@@ -1914,7 +1914,6 @@ mod tests {
fn wheel() -> egui::Event {
egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
modifiers: Default::default(),
@@ -2592,15 +2591,11 @@ mod tests {
let reader = Running::start(driver, fake.clone());
reader.reads("spacenavd");
// Still not readable, over two waits: the daemon read goes on, one connection, a few probes.
let slept = std::time::Instant::now();
std::thread::sleep(Duration::from_millis(700));
let waited = slept.elapsed();
assert_eq!(reader.key().as_deref(), Some("spacenavd"), "{driver:?}");
assert_eq!(fake.world().connects, 1, "{driver:?}: the daemon read was not restarted for a device that still cannot be read");
let opens = fake.world().opens;
// A loaded machine can sleep far past 700 ms: bound the tries by the time that really passed.
let allowed = 4 + (waited.as_millis().saturating_sub(700) / 300) as u32;
assert!(opens <= allowed, "{driver:?}: {opens} tries in {waited:?} with a 300 ms wait");
assert!(opens <= 4, "{driver:?}: {opens} tries in 700 ms with a 300 ms wait");
// The permission is fixed.
fake.world().openable = true;
reader.reads(&Source::Hid(Some(IDS)).key());
+4 -434
View File
@@ -114,158 +114,6 @@ pub fn started(dir: &Path) {
let _ = std::fs::remove_file(dir.join(MARKER));
}
/// A saved backend change that this launch is trying. Keep this value until the first
/// frame is presented, so only this launch's matching attempt can be confirmed or reverted.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BackendAttempt {
pub requested: String,
pub previous: String,
}
/// The backend to use before creating any graphics device, with any pending recovery notice.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BackendLaunch {
pub backend: Option<String>,
pub attempt: Option<BackendAttempt>,
pub note: Option<String>,
}
type GraphicsSettings = serde_json::Map<String, serde_json::Value>;
const BACKEND_TRIAL: &str = "backend_trial";
const MAX_BACKEND_PREFERENCE_BYTES: usize = 256;
/// Read only object-shaped settings. A missing or empty file means defaults; other failures
/// are reported so the caller can use its safe renderer without overwriting unreadable data.
fn read_backend_settings(path: &Path) -> Result<GraphicsSettings, String> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(GraphicsSettings::new()),
Err(error) => return Err(format!("could not read graphics settings: {error}")),
};
if text.trim().is_empty() {
return Ok(GraphicsSettings::new());
}
serde_json::from_str(&text).map_err(|error| format!("could not read graphics settings: {error}"))
}
/// The attempt marker and backend are written together: a crash must leave a whole old or
/// new record, never a selected API whose recovery data was only partly written.
fn write_backend_settings(path: &Path, graphics: &GraphicsSettings) -> Result<(), String> {
let bytes = serde_json::to_vec(graphics).map_err(|error| format!("could not encode graphics settings: {error}"))?;
solidcraft_engine::io::safe_write::write_atomic(path, &bytes, false).map_err(|error| format!("could not save graphics settings: {error}"))
}
/// UI choices are single API names. The previous value remains verbatim, including legacy
/// comma lists and blanks, because startup already knows how to interpret those preferences.
fn backend_trial(graphics: &GraphicsSettings) -> Option<(BackendAttempt, bool)> {
let trial = graphics.get(BACKEND_TRIAL)?.as_object()?;
let requested = trial.get("requested")?.as_str()?;
if !matches!(requested, "auto" | "dx12" | "vulkan" | "gl" | "metal") {
return None;
}
let previous = trial.get("previous")?.as_str()?;
if previous.len() > MAX_BACKEND_PREFERENCE_BYTES {
return None;
}
let started = trial.get("started")?.as_bool()?;
Some((BackendAttempt { requested: requested.into(), previous: previous.into() }, started))
}
/// Describe a restored choice using the same catalog as the Graphics preferences page.
pub(super) fn backend_reverted_note(requested: &str, previous: &str) -> String {
let requested = solidcraft_ui_egui::prefs::graphics_backend_label(requested);
let previous = solidcraft_ui_egui::prefs::graphics_backend_label(previous);
solidcraft_ui_text::format("prefs.graphics_api_start_reverted", &[("requested", &requested), ("previous", &previous)])
}
/// Prepare a UI-selected API before any adapter is opened. Safe mode, an explicit environment
/// override and process-local fallback launches pass `false` and leave the attempt untouched.
/// An interrupted attempt restores its previous API. A new attempt is returned only after its
/// started marker reaches disk; any I/O error must make the caller avoid the requested API.
pub fn prepare_backend_start(path: &Path, allow_trial: bool) -> Result<BackendLaunch, String> {
prepare_backend_launch(read_backend_settings(path)?, allow_trial, |graphics| write_backend_settings(path, graphics))
}
/// Separate the state transition from persistence so a refused marker write is testable
/// without depending on the test user's filesystem permissions.
fn prepare_backend_launch(
mut graphics: GraphicsSettings,
allow_trial: bool,
save: impl FnOnce(&GraphicsSettings) -> Result<(), String>,
) -> Result<BackendLaunch, String> {
let backend = graphics.get("backend").and_then(serde_json::Value::as_str).map(str::to_string);
let mut launch = BackendLaunch { backend, ..Default::default() };
if !allow_trial || !graphics.contains_key(BACKEND_TRIAL) {
return Ok(launch);
}
let parsed = backend_trial(&graphics);
let requested = graphics.get(BACKEND_TRIAL).and_then(|trial| trial.get("requested")).and_then(serde_json::Value::as_str);
if requested.is_some() && requested != launch.backend.as_deref() {
// Another edit replaced the selected API; stale recovery data cannot undo that edit.
graphics.remove(BACKEND_TRIAL);
} else if let Some((attempt, started)) = parsed {
if attempt.requested == attempt.previous {
graphics.remove(BACKEND_TRIAL);
} else if started {
graphics.insert("backend".into(), serde_json::Value::String(attempt.previous.clone()));
graphics.remove(BACKEND_TRIAL);
launch.backend = Some(attempt.previous.clone());
launch.note = Some(backend_reverted_note(&attempt.requested, &attempt.previous));
} else {
let trial = graphics
.get_mut(BACKEND_TRIAL)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| "graphics backend attempt changed before it could start".to_string())?;
trial.insert("started".into(), serde_json::Value::Bool(true));
launch.attempt = Some(attempt);
}
} else {
// A damaged guard must not turn a protected API into an unguarded attempt. Prefer its
// intact previous value; otherwise use Automatic, preserving the other graphics keys.
let previous = graphics
.get(BACKEND_TRIAL)
.and_then(|trial| trial.get("previous"))
.and_then(serde_json::Value::as_str)
.filter(|value| value.len() <= MAX_BACKEND_PREFERENCE_BYTES)
.unwrap_or("auto")
.to_string();
launch.note = Some(backend_reverted_note(launch.backend.as_deref().unwrap_or("auto"), &previous));
graphics.insert("backend".into(), serde_json::Value::String(previous.clone()));
graphics.remove(BACKEND_TRIAL);
launch.backend = Some(previous);
}
save(&graphics)?;
Ok(launch)
}
/// Only a matching, started attempt still owning the saved backend may change recovery state.
/// A newer preference edit is left alone, including a newly pending attempt at the same API.
fn finish_backend_start(path: &Path, attempt: &BackendAttempt, revert: bool) -> Result<(), String> {
let mut graphics = read_backend_settings(path)?;
let matches = backend_trial(&graphics).is_some_and(|(saved, started)| started && saved == *attempt)
&& graphics.get("backend").and_then(serde_json::Value::as_str) == Some(attempt.requested.as_str());
if !matches {
return Ok(());
}
graphics.remove(BACKEND_TRIAL);
if revert {
graphics.insert("backend".into(), serde_json::Value::String(attempt.previous.clone()));
}
write_backend_settings(path, &graphics)
}
/// Keep a backend only after the window has presented its first frame. Handled startup
/// failures must call `rollback_backend_start` instead, even if the ordinary crash marker clears.
pub fn confirm_backend_start(path: &Path, attempt: &BackendAttempt) -> Result<(), String> {
finish_backend_start(path, attempt, false)
}
/// A handled window-start failure restores the previous backend before the host retries.
/// Reading fresh settings and matching the attempt avoids erasing a later preference edit.
pub fn rollback_backend_start(path: &Path, attempt: &BackendAttempt) -> Result<(), String> {
finish_backend_start(path, attempt, true)
}
/// The software adapter among `adapters` (WARP on Windows, lavapipe or llvmpipe elsewhere), else
/// the first one that can draw to `surface`.
pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&eframe::wgpu::Surface<'_>>) -> Result<eframe::wgpu::Adapter, String> {
@@ -412,7 +260,7 @@ pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::
/// 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 = solidcraft_viewport_gpu::instance_descriptor_from_env();
let mut desc = solidcraft_gpu::instance_descriptor_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));
@@ -429,7 +277,7 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
desc.backend_options.dx12.shader_compiler = solidcraft_viewport_gpu::dx12_compiler();
desc.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
@@ -509,7 +357,7 @@ pub fn relaunch_with_version_compat() -> Result<bool, String> {
return Ok(false);
}
log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
solidcraft_engine::note!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?;
let status = std::process::Command::new(&exe)
.args(std::env::args_os().skip(1))
@@ -638,284 +486,6 @@ mod tests {
assert!(settings_directory(Some(file.as_os_str()), Some(default)).is_err());
}
// These restart contracts apply to Windows and macOS as well as Linux.
#[test]
fn graphics_restart_applies_saved_choices_and_reopens_saved_designs() {
use std::ffi::OsStr;
let program = std::env::current_exe().unwrap();
let designs = vec!["/saved/first design.sc".to_string(), "/saved/second.sc".to_string()];
let command = crate::graphics_restart_command(&program, &[], &designs);
assert_eq!(command.get_program(), program.as_os_str());
assert_eq!(command.get_args().collect::<Vec<_>>(), designs.iter().map(OsStr::new).collect::<Vec<_>>());
let changes: std::collections::BTreeMap<_, _> = command.get_envs().collect();
for variable in [
crate::startup::RETRY_BACKEND_ENV,
crate::startup::FALLBACK_NOTE_ENV,
crate::startup::RENDERER_ENV,
crate::startup::SAFE_MODE_ENV,
crate::device_lost::LOST_ENV,
crate::device_lost::REASON_ENV,
] {
assert_eq!(changes.get(OsStr::new(variable)), Some(&None), "{variable}");
}
assert!(!changes.contains_key(OsStr::new(crate::startup::WGPU_BACKEND_ENV)));
assert!(!changes.contains_key(OsStr::new("SOLIDCRAFT_SETTINGS_DIR")));
assert!(!changes.contains_key(OsStr::new("WGPU_ADAPTER_NAME")));
}
#[test]
fn graphics_restart_uses_an_appimage_only_from_its_own_existing_mount() {
use crate::graphics_restart_program;
use std::ffi::OsStr;
let root = std::env::temp_dir().join("solidcraft-graphics-restart-fixture");
let appdir = root.join("mount");
let mounted = appdir.join("usr").join("bin").join("solidcraft");
let image = root.join("SolidCraft.AppImage");
let outside = root.join("source").join("solidcraft");
let looks_similar = root.join("mount-other").join("solidcraft");
let exists = |path: &std::path::Path| path == image;
assert_eq!(graphics_restart_program(Some(&mounted), Some(image.as_os_str()), Some(appdir.as_os_str()), exists), Some(image.clone()));
for executable in [&outside, &looks_similar] {
assert_eq!(
graphics_restart_program(Some(executable), Some(image.as_os_str()), Some(appdir.as_os_str()), exists),
Some(executable.to_path_buf()),
"a launcher inherited from another process is ignored"
);
}
for (appimage, directory) in [
(None, Some(appdir.as_os_str())),
(Some(image.as_os_str()), None),
(Some(OsStr::new("")), Some(appdir.as_os_str())),
(Some(image.as_os_str()), Some(OsStr::new(""))),
(Some(OsStr::new("relative.AppImage")), Some(appdir.as_os_str())),
] {
assert_eq!(graphics_restart_program(Some(&mounted), appimage, directory, exists), Some(mounted.clone()));
}
assert_eq!(
graphics_restart_program(Some(&mounted), Some(image.as_os_str()), Some(appdir.as_os_str()), |_| false),
Some(mounted.clone()),
"a missing outer image cannot replace the running executable"
);
assert_eq!(graphics_restart_program(None, Some(image.as_os_str()), Some(appdir.as_os_str()), exists), None);
}
#[test]
fn graphics_restart_keeps_launch_options_and_uses_the_current_saved_tabs() {
let original = [
"--safe-mode",
"--control",
"7878",
"original design.sc",
"--theme-file",
"a theme.json",
"--sample",
"--max-file-size",
"2GB",
"other-original.sc",
"--max-file-size=3GB",
];
let args = crate::graphics_restart_args(original.into_iter().map(str::to_string));
assert_eq!(args, ["--control", "7878", "--theme-file", "a theme.json", "--max-file-size", "2GB", "--max-file-size=3GB"]);
let program = std::env::current_exe().unwrap();
let designs = ["current saved.sc".to_string()];
let command = crate::graphics_restart_command(&program, &args, &designs);
assert_eq!(
command.get_args().map(|value| value.to_string_lossy().into_owned()).collect::<Vec<_>>(),
["--control", "7878", "--theme-file", "a theme.json", "--max-file-size", "2GB", "--max-file-size=3GB", "current saved.sc"]
);
for missing_value in ["--control", "--theme-file", "--max-file-size"] {
assert!(crate::graphics_restart_args([missing_value.to_string()]).is_empty());
}
}
#[test]
fn startup_graphics_api_is_authoritative_over_stale_ui_preferences() {
use serde_json::json;
for choice in ["auto", "vulkan", "dx12", "metal", "gl", "dx12,vulkan"] {
for safe in [false, true] {
let mut app = crate::SolveApp::new(crate::Session::default(), crate::Services::default());
let stored = json!({"preferences":{"graphics_backend":"gl"}}).to_string();
let saved = json!({"backend":choice});
crate::restore_preferences(&mut app, Some(&stored), Some(&saved), safe);
assert_eq!(app.preferences.graphics_backend, choice);
}
}
let mut app = crate::SolveApp::new(crate::Session::default(), crate::Services::default());
app.preferences.graphics_backend = "vulkan".into();
crate::restore_graphics_preferences(&mut app, None);
assert_eq!(app.preferences.graphics_backend, "auto");
}
fn backend_settings(path: &Path) -> serde_json::Value {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
fn pending_backend(previous: &str) -> serde_json::Value {
serde_json::json!({
"backend": "vulkan",
"backend_trial": {"requested": "vulkan", "previous": previous, "started": false},
"msaa": 8,
"adapter": "Saved GPU",
"window_system": "x11",
"future_setting": {"enabled": true}
})
}
#[test]
fn backend_start_marks_the_attempt_before_selection_and_keeps_it_after_a_presented_frame() {
let dir = scratch("backend-confirm");
let path = dir.join("graphics.json");
let original = pending_backend("auto");
std::fs::write(&path, original.to_string()).unwrap();
let launch = prepare_backend_start(&path, true).unwrap();
assert_eq!(launch.backend.as_deref(), Some("vulkan"));
assert!(launch.note.is_none());
let attempt = launch.attempt.unwrap();
assert_eq!(attempt, BackendAttempt { requested: "vulkan".into(), previous: "auto".into() });
let mut started = original.clone();
started["backend_trial"]["started"] = serde_json::json!(true);
assert_eq!(backend_settings(&path), started, "selection is returned only after the started marker is durable");
confirm_backend_start(&path, &attempt).unwrap();
let mut kept = original;
kept.as_object_mut().unwrap().remove(BACKEND_TRIAL);
assert_eq!(backend_settings(&path), kept, "confirmation changes no other graphics preferences");
let next = prepare_backend_start(&path, true).unwrap();
assert_eq!(next.backend.as_deref(), Some("vulkan"));
assert!(next.attempt.is_none() && next.note.is_none());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn interrupted_backend_start_restores_the_exact_previous_choice_once() {
solidcraft_ui_egui::i18n::install();
let _language = solidcraft_ui_text::scope("en");
let dir = scratch("backend-interrupted");
let path = dir.join("graphics.json");
for (previous, label) in [("auto", "Automatic"), (" dx12, vulkan ", " dx12, vulkan "), ("", "Automatic")] {
let mut expected = pending_backend(previous);
std::fs::write(&path, expected.to_string()).unwrap();
assert!(prepare_backend_start(&path, true).unwrap().attempt.is_some());
// A process crash leaves the started marker; the next normal launch rolls back.
let recovered = prepare_backend_start(&path, true).unwrap();
assert_eq!(recovered.backend.as_deref(), Some(previous));
assert!(recovered.attempt.is_none());
assert_eq!(
recovered.note,
Some(format!("The previous start with Vulkan did not open the window. SolidCraft restored {label}.")),
"recovery describes both APIs using their displayed names"
);
expected["backend"] = serde_json::json!(previous);
expected.as_object_mut().unwrap().remove(BACKEND_TRIAL);
assert_eq!(backend_settings(&path), expected, "only the attempted API is reverted");
assert!(prepare_backend_start(&path, true).unwrap().note.is_none(), "the notice is not repeated");
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn safe_override_and_fallback_launches_leave_backend_attempts_untouched() {
let dir = scratch("backend-bypass");
let path = dir.join("graphics.json");
assert_eq!(prepare_backend_start(&path, true).unwrap(), BackendLaunch::default());
assert!(!path.exists(), "default startup does not create a settings file");
for started in [false, true] {
let mut saved = pending_backend("dx12");
saved["backend_trial"]["started"] = serde_json::json!(started);
let bytes = serde_json::to_vec_pretty(&saved).unwrap();
std::fs::write(&path, &bytes).unwrap();
let launch = prepare_backend_start(&path, false).unwrap();
assert_eq!(launch.backend.as_deref(), Some("vulkan"));
assert!(launch.attempt.is_none() && launch.note.is_none());
assert_eq!(std::fs::read(&path).unwrap(), bytes, "even an interrupted attempt waits for a normal launch");
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn backend_start_rollback_and_confirmation_cannot_overwrite_a_newer_choice() {
let dir = scratch("backend-ownership");
let path = dir.join("graphics.json");
let saved = pending_backend("dx12");
std::fs::write(&path, saved.to_string()).unwrap();
let attempt = prepare_backend_start(&path, true).unwrap().attempt.unwrap();
rollback_backend_start(&path, &attempt).unwrap();
let mut reverted = saved.clone();
reverted["backend"] = serde_json::json!("dx12");
reverted.as_object_mut().unwrap().remove(BACKEND_TRIAL);
assert_eq!(backend_settings(&path), reverted, "handled failure restores the last working preference");
for newer in [
serde_json::json!({"backend": "gl"}),
serde_json::json!({"backend": "vulkan", "backend_trial": {"previous": "dx12", "requested": "vulkan", "started": false}}),
serde_json::json!({"backend": "vulkan", "backend_trial": {"previous": "auto", "requested": "vulkan", "started": true}}),
serde_json::json!({"backend": "gl", "backend_trial": {"previous": "dx12", "requested": "vulkan", "started": true}}),
] {
let bytes = serde_json::to_vec_pretty(&newer).unwrap();
std::fs::write(&path, &bytes).unwrap();
confirm_backend_start(&path, &attempt).unwrap();
rollback_backend_start(&path, &attempt).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes, "stale launch state cannot consume another preference");
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn malformed_backend_guards_use_the_previous_api_or_automatic_without_touching_other_settings() {
let dir = scratch("backend-malformed");
let path = dir.join("graphics.json");
for (trial, expected_backend) in [
(serde_json::json!(null), "auto"),
(serde_json::json!(42), "auto"),
(serde_json::json!({}), "auto"),
(serde_json::json!({"requested": "vulkan", "previous": "dx12"}), "dx12"),
(serde_json::json!({"requested": "vulkan", "previous": "dx12", "started": "yes"}), "dx12"),
(serde_json::json!({"requested": "vulkan", "previous": "x".repeat(257), "started": false}), "auto"),
] {
let mut saved = pending_backend("auto");
saved["backend_trial"] = trial;
std::fs::write(&path, saved.to_string()).unwrap();
let launch = prepare_backend_start(&path, true).unwrap();
assert_eq!(launch.backend.as_deref(), Some(expected_backend));
assert!(launch.attempt.is_none());
saved["backend"] = serde_json::json!(expected_backend);
saved.as_object_mut().unwrap().remove(BACKEND_TRIAL);
assert_eq!(backend_settings(&path), saved);
}
let mut saved = pending_backend("auto");
saved["backend"] = serde_json::json!("gl");
std::fs::write(&path, saved.to_string()).unwrap();
let launch = prepare_backend_start(&path, true).unwrap();
assert_eq!(launch.backend.as_deref(), Some("gl"), "a stale marker cannot replace a newer API");
assert!(launch.attempt.is_none() && launch.note.is_none());
saved.as_object_mut().unwrap().remove(BACKEND_TRIAL);
assert_eq!(backend_settings(&path), saved);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_backend_attempt_is_never_returned_when_its_marker_cannot_be_saved() {
for started in [false, true] {
let mut saved = pending_backend("dx12");
saved["backend_trial"]["started"] = serde_json::json!(started);
let graphics = saved.as_object().unwrap().clone();
let error = prepare_backend_launch(graphics, true, |_| Err("fixture: write refused".into())).unwrap_err();
assert_eq!(error, "fixture: write refused", "no selected backend escapes a failed transition");
}
let dir = scratch("backend-unreadable");
let path = dir.join("graphics.json");
for damaged in ["{", "[]", "null"] {
std::fs::write(&path, damaged).unwrap();
assert!(prepare_backend_start(&path, true).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), damaged, "unreadable settings are never overwritten");
}
assert!(prepare_backend_start(&dir, true).is_err(), "a filesystem read failure is reported");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn intel_macs_start_without_indirect_call_validation() {
use eframe::wgpu::InstanceFlags as F;
@@ -1128,7 +698,7 @@ mod tests {
if cfg!(target_os = "windows") && std::env::var_os(WGPU_BACKEND_ENV).is_none() {
assert_eq!(backends, eframe::wgpu::Backends::DX12);
}
let mut desc = solidcraft_viewport_gpu::instance_descriptor_from_env();
let mut desc = solidcraft_gpu::instance_descriptor_from_env();
desc.backends = backends;
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
+1 -1
View File
@@ -2,7 +2,7 @@
Read `assets/README.md` before changing media, fonts, artwork or their consumers.
For **any UI icon, icon assignment or review-tool change**, also read
`crates/look/icons/AGENTS.md` and `crates/look/icons/STYLE_GUIDE.md` before editing.
`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
+4 -4
View File
@@ -18,10 +18,12 @@ assets/
ui/source/ # Rust viewport glyphs, markers and navigation
branding/web/source/og.png # shared social preview card
branding/logo/ # print/web logo files; guide in branding/README.md
styles/ui/palettes.rs # light/dark defaults
styles/web/loading.css # web loading/canvas styles
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
fonts/biz-udp-gothic/ # unmodified font and OFL licence
shaders/viewport.wgsl # original viewport shader
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
locales/{en,de,ja,ko}.json # shared offline catalogs
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
@@ -85,9 +87,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
Resources remain compiled by their owning layer through existing APIs:
`ui-egui::icons::paint_icon` and `Tokens` at L5, model presets in L2 `doc`, and sketch
fonts at L1. The sketch crate owns its built-in Liberation Sans font and OFL
licence under `crates/sketch/assets/fonts/liberation-sans/`, so Cargo source
packages and vendored builds include both files. State, projection, interaction, commands and GPU bindings stay in
fonts at L1. State, projection, interaction, commands and GPU bindings stay in
their current modules. Embedded data uses `CARGO_MANIFEST_DIR`; application code
needs no runtime resource registry or repository lookup.
+2 -8
View File
@@ -38,7 +38,7 @@ Other colours, all already used in the project:
| Colour | Hex | Where it comes from |
|---|---|---|
| Wordmark blue | `#15499e` | loading logo `--word` |
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/palettes.rs`) |
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
| Dark background | `#1f232b` | dark theme `file_background` token |
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
@@ -70,10 +70,4 @@ meetup. If you publish a fork or a modified build, give it its own name and logo
don't mistake it for SolidCraft.
The wordmark is set in IBM Plex Sans Bold (SIL Open Font License 1.1, the interface font in
`crates/look/fonts/ibm-plex-sans/`) and outlined, so you don't need the font to print it.
## Licence
The SolidCraft name, logo and brand kit are under the SolidCraft Brand and Interface Licence
([LICENSE-BRAND](../../LICENSE-BRAND)). Using them for anything other than SolidCraft needs the
maintainers' written permission first.
`assets/fonts/ibm-plex-sans/`) and outlined, so you don't need the font to print it.
+129
View File
@@ -0,0 +1,129 @@
# UI icons and review tool
**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.
## Review all existing and new icons
```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.** Discovery also reads `design/requested_icons.txt`, the sorted runtime audit
manifest; it supplements source scans and never restricts discovery. 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/requested_icons.txt` | Sorted runtime requests and placeholder flags, checked by the Harness audit |
| `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_icon`; 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/`. The runtime request manifest is a committed regression fixture; other generated
inventories and historical authoring batches do not belong in the repository. Every non-Markdown resource has an exact row in `ATTRIBUTION.md`.
## Runtime menu audit
`cargo test -p solidcraft-ui-egui --test icon_audit` drives the real UI through the
headless scenario Harness and rejects placeholder requests and unexpected blank
context-menu icons. It compares sorted requested ids with
`design/requested_icons.txt` (`id<TAB>placeholder`, one per line). After deliberately
changing menu coverage or assignments, regenerate and review its diff:
```sh
UPDATE_ICON_MANIFEST=1 cargo test -p solidcraft-ui-egui --test icon_audit
```
The review page includes every manifest id, even ids used only in tables or helper
arguments. A true placeholder flag, missing master or broken alias is reported as
missing; runtime collection never changes acceptance hashes. The live control
method `ui.icon_audit` can diagnose requests in a running UI (see
[control protocol](../../../docs/control-protocol.md)). Keep the Harness traversal
current when adding menus. A successful traversal covers only the contexts it
actually opens; document unreachable or untested contexts in the audit report.
@@ -7,7 +7,7 @@ entries, browser selectors or runtime request manifests are changed by this draf
`render_execute`, `render_environment` and `render_fit_product` now have their own
masters in `design/svg/model/`. Accepted source masters stay unchanged.
Review with `python3 -B crates/look/icons/design/tools/icon_status.py --serve`,
Review with `python3 -B assets/icons/ui/design/tools/icon_status.py --serve`,
search `render_`, and select Open. See [STYLE_GUIDE.md](STYLE_GUIDE.md) for
acceptance; drafts do not imply implemented commands.
@@ -184,7 +184,7 @@ whole set, especially final artwork, when adding one icon.
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 crates/look/icons/design/tools/author/new_icon.py my_icon --base cube`.
`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
@@ -103,9 +103,6 @@ dxf false
edit false
ellipse false
emboss false
end_cap false
end_cap_inward false
end_cap_outward false
environment_map false
equation_curve false
error false

Before

Width:  |  Height:  |  Size: 558 B

After

Width:  |  Height:  |  Size: 558 B

Before

Width:  |  Height:  |  Size: 758 B

After

Width:  |  Height:  |  Size: 758 B

Before

Width:  |  Height:  |  Size: 792 B

After

Width:  |  Height:  |  Size: 792 B

Before

Width:  |  Height:  |  Size: 939 B

After

Width:  |  Height:  |  Size: 939 B

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 224 B

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 230 B

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 228 B

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 243 B

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 241 B

Before

Width:  |  Height:  |  Size: 448 B

After

Width:  |  Height:  |  Size: 448 B

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 266 B

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 317 B

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 406 B

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 246 B

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 407 B

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 433 B

Before

Width:  |  Height:  |  Size: 567 B

After

Width:  |  Height:  |  Size: 567 B

Before

Width:  |  Height:  |  Size: 666 B

After

Width:  |  Height:  |  Size: 666 B

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 266 B

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 363 B

After

Width:  |  Height:  |  Size: 363 B

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 443 B

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 355 B

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