Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b55174743 | |||
| 5821ca2b5e | |||
| 8829c1c3c5 |
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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. -->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -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 }})
|
||||
|
||||
@@ -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}\`:"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.4"
|
||||
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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ¶ms)? {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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", ¶ms).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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"])
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -361,54 +361,23 @@ const UNSTABLE: [(&str, &str); 1] = [(
|
||||
"the halves of each swept wall lie level along every axis, so which is `#1` and which `#2` follows the order the kernel gives the faces in, which changes from one build to the next",
|
||||
)];
|
||||
|
||||
/// The designs that take longest to build, heaviest first, with one build's time (ci profile,
|
||||
/// CLI `run`, one design at a time, on P-cores, 2026-11-10). The test hands them out first, and
|
||||
/// each design's two builds go to two workers, so a heavy design is never built twice in a row on
|
||||
/// one worker while the rest wait. Every other design took under 3.6 s. Only the order of the
|
||||
/// work depends on this list: a stale entry makes the test slower, never wrong. Re-measure when a
|
||||
/// design here gets much faster, or one not listed takes over 5 s.
|
||||
const HEAVIEST: [&str; 6] = [
|
||||
"oracle/77-fillet-variable-linear", // 35.6 s
|
||||
"oracle/83-boundary-loop-thicken", // 12.4 s
|
||||
"regressions/closed_sweep_torus_cut", // 6.8 s
|
||||
"oracle/41-tube-radial-holes", // 4.6 s
|
||||
"oracle/42-overlapping-patterns", // 4.2 s
|
||||
"oracle/35-fillet-curved-curved", // 3.2 s
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone() {
|
||||
let designs = designs();
|
||||
// Every design is built twice, as two jobs: (design, which build). The heaviest go first.
|
||||
for heavy in HEAVIEST {
|
||||
assert!(designs.iter().any(|(name, _)| name == heavy), "HEAVIEST lists `{heavy}`, which is not in the corpus any more");
|
||||
}
|
||||
let rank = |name: &str| HEAVIEST.iter().position(|h| *h == name).unwrap_or(HEAVIEST.len());
|
||||
let mut jobs: Vec<(usize, usize)> = (0..designs.len()).flat_map(|d| [(d, 0), (d, 1)]).collect();
|
||||
jobs.sort_by_key(|&(d, build)| (rank(&designs[d].0), d, build));
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let builds = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(jobs.len().max(1));
|
||||
let results = std::sync::Mutex::new(Vec::new());
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(designs.len().max(1));
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..workers {
|
||||
scope.spawn(|| {
|
||||
while let Some(&(d, which)) = jobs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let got = load(&designs[d].1).map(|s| build(&s));
|
||||
builds.lock().unwrap_or_else(|e| e.into_inner()).insert((d, which), got);
|
||||
while let Some((name, source)) = designs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let got = load(source).and_then(|first| Ok((build(&first), build(&load(source)?))));
|
||||
results.lock().unwrap_or_else(|e| e.into_inner()).push((name.clone(), got));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut builds = builds.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
let mut results: Vec<(String, Result<(Build, Build), String>)> = designs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(d, (name, _))| {
|
||||
let first = builds.remove(&(d, 0)).unwrap();
|
||||
let second = builds.remove(&(d, 1)).unwrap();
|
||||
(name.clone(), first.and_then(|first| Ok((first, second?))))
|
||||
})
|
||||
.collect();
|
||||
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
results.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut problems = Vec::new();
|
||||
for (name, got) in &results {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(©, after);
|
||||
return;
|
||||
self.app.open_path(©);
|
||||
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,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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). */
|
||||
@@ -148,22 +143,11 @@ self.addEventListener("activate", (event) => {
|
||||
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
|
||||
return self.registration.unregister();
|
||||
}
|
||||
const older = (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE);
|
||||
// Claim first: from the moment this worker is the active one, a page the old worker controlled has
|
||||
// no controller until it is claimed, and a request it makes then goes straight to the network, which
|
||||
// serves the newest build, not the page's. Nothing slow goes before this.
|
||||
await self.clients.claim();
|
||||
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
await sweep();
|
||||
// With no earlier build kept, the pages open when the first worker takes over came from the
|
||||
// network, and get their files from it. After an update a page with no record is not
|
||||
// assumed to be anything: its record may only be late, and the network now serves a newer
|
||||
// build than the page's.
|
||||
if (!older.length) {
|
||||
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
|
||||
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
|
||||
}
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
}
|
||||
await sweep();
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -237,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);
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -184,7 +181,6 @@ pipe false
|
||||
plane false
|
||||
plane_along_path false
|
||||
plane_angle false
|
||||
plane_auto false
|
||||
plane_axis_point false
|
||||
plane_midplane false
|
||||
plane_perpendicular 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 |