Compare commits

..
Author SHA1 Message Date
bherbruckandClaude Sonnet 5.5 a940f77447 Sketch: always show a grid on the open sketch's plane
While a sketch is open its grid is drawn on the sketch's own plane, centred
near the view target and snapped to the major step, whatever the Grid switch,
the sketch's Sketch Grid option or Grid follows the view say. Headless window
pictures follow. Outside a sketch nothing changes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:44:15 +00:00
bherbruckandClaude Opus 5.5 ae6a33bf96 CI: run the list checks inside CI's repository-checks job on PRs (#2292)
Lists and Packaging lint each took their own runner slot on every PR push. The list checks now run
in the existing checks job; Lists stays on main. Packaging lint skips PRs that only touch icons.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 21:00:34 -04:00
bherbruckandClaude Opus 5.5 675c865431 CI: fork PRs' light jobs on GitHub, their heavy jobs on the self-hosted pool (#2284)
With Windows off pull requests, GitHub's 20 runners sat idle while every
fork job queued for the self-hosted pool. Select checks, repository
checks, Clippy, doc tests and the summary go back to GitHub's runners;
Build tests and the test shards stay on the isolated self-hosted pool.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 20:49:16 -04:00
8 changed files with 95 additions and 56 deletions

No files matched your search

+9 -5
View File
@@ -72,7 +72,7 @@ jobs:
changes:
name: Select checks
# Trusted runs (see the top of this file) use the self-hosted runners.
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
permissions:
contents: read
# To ask whether the merge queue already tested a commit pushed to main.
@@ -176,7 +176,7 @@ jobs:
checks:
name: Format and repository checks
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -202,6 +202,10 @@ jobs:
run: cargo xtask licences --check
- name: Dependency layers
run: cargo xtask layers
- name: Sorted lists
run: cargo xtask lists
- name: Design format pending changes
run: bash scripts/test-format-pending.sh
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
- name: Affected-package selection self-test
@@ -213,7 +217,7 @@ jobs:
name: Native Linux Clippy
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -434,7 +438,7 @@ jobs:
name: Doc tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
@@ -579,7 +583,7 @@ jobs:
ci:
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always()
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
steps:
- name: Verify required checks
env:
+2 -1
View File
@@ -1,9 +1,10 @@
# Files many pull requests add to are kept so that additions don't conflict: one file per change
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
# per PR instead of three); this copy keeps main honest.
name: Lists
on:
pull_request:
push:
branches: [main]
+1 -1
View File
@@ -8,7 +8,7 @@ on:
branches: [main]
paths: [".github/workflows/**", "packaging/**", "assets/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**", "assets/**"]
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
workflow_dispatch:
permissions:
+25 -6
View File
@@ -349,6 +349,20 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3
scene_with_guides(s, cam, section, Guides::default())
}
/// The plane of the sketch that is open, if any: it always shows a grid, whatever the Grid switch
/// says (outside a sketch the grid is the user's choice).
pub fn open_sketch_plane(s: &Session) -> Option<Plane> {
let id = s.active_sketch?;
s.world_state().sketch(id).map(|ss| ss.plane)
}
/// The grid on a sketch plane: its centre (the view target's foot on the plane, snapped to the
/// major step in the plane's own axes) and the plane's x and y directions.
pub fn sketch_grid_frame(plane: &Plane, target: Vec3, major: f64) -> (Vec3, Vec3, Vec3) {
let snap = |d: Vec3| d * (((target - plane.origin).dot(d) / major).round() * major);
(plane.origin + snap(plane.x) + snap(plane.y), plane.x, plane.y)
}
/// What a headless render draws besides the model: the ground grid and the origin's axes.
/// Both are on by default; the grid lies on Z 0, so it cuts through a model that straddles it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -434,12 +448,17 @@ fn build_scene(
if guides.grid {
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
let tf = cam.to_frame(cam.target);
// Headless pictures keep the grid on the ground, the viewport's default.
let (u, v) = (Vec3::X, Vec3::Y);
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
let c = snap(u) + snap(v);
let w = |p: Vec3| cam.from_frame(p);
// Headless pictures keep the grid on the ground, the viewport's default; an open sketch
// has its own plane's grid (world coordinates, no camera frame).
let (c, u, v, w): (Vec3, Vec3, Vec3, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
let (c, u, v) = sketch_grid_frame(&pl, cam.target, major);
(c, u, v, Box::new(|p| p))
} else {
let tf = cam.to_frame(cam.target);
let (u, v) = (Vec3::X, Vec3::Y);
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
(snap(u) + snap(v), u, v, Box::new(|p| cam.from_frame(p)))
};
let n = ((ext / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
+1 -1
View File
@@ -208,7 +208,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides
/// The grid and the origin's axes as the window shows them (the Grid and Origin switches).
pub(crate) fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides {
solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }
solidcraft_engine::view::Guides { grid: app.session.active_sketch.is_some() || (app.ui.show_grid && !crate::viewport::active_view(app).hide_grid), axes: app.origin_visible() }
}
/// A picture of the view as the window shows it, RGBA, drawn on the GPU with the viewport's
+46 -18
View File
@@ -733,33 +733,39 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene {
let s = &app.session;
let st = s.world_state();
let mut fill_budget = 131_072;
if app.ui.show_grid && !active_view(app).hide_grid {
let sketch_plane = solidcraft_engine::view::open_sketch_plane(s);
if sketch_plane.is_some() || (app.ui.show_grid && !active_view(app).hide_grid) {
let (minor, major) = grid_step(app.cam.half_height());
let ext = (app.cam.half_height() * 3.0 / major).ceil() * major;
// The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame), or
// the upright origin plane facing the view when the ground is seen edge-on.
let tf = app.cam.to_frame(app.cam.target);
// On the ground (XY) unless the Grid follows the view preference is on (#1470); then on
// the plane the last frame drew, so the choice is hysteretic near the thresholds (#1993).
let (u, v) = if app.preferences.grid_follows_view {
app.viewport.grid_plane.lock().map_or((Vec3::X, Vec3::Y), |mut last| {
*last = grid_plane(app.cam.to_frame(app.cam.back()), *last);
last.axes()
})
// An open sketch always has its grid on its own plane; otherwise the ground plane: XY with
// Z up, XZ with Y up (laid out in the camera's Z-up frame), or the upright origin plane
// facing the view when the ground is seen edge-on.
let (c, u, v, w3): (Vec3, Vec3, Vec3, Box<dyn Fn(Vec3) -> [f32; 3]>) = if let Some(pl) = &sketch_plane {
let (c, u, v) = solidcraft_engine::view::sketch_grid_frame(pl, app.cam.target, major);
(c, u, v, Box::new(|p| p.to_f32()))
} else {
(Vec3::X, Vec3::Y)
let tf = app.cam.to_frame(app.cam.target);
// On the ground (XY) unless the Grid follows the view preference is on (#1470); then on
// the plane the last frame drew, so the choice is hysteretic near the thresholds (#1993).
let (u, v) = if app.preferences.grid_follows_view {
app.viewport.grid_plane.lock().map_or((Vec3::X, Vec3::Y), |mut last| {
*last = grid_plane(app.cam.to_frame(app.cam.back()), *last);
last.axes()
})
} else {
(Vec3::X, Vec3::Y)
};
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
(snap(u) + snap(v), u, v, Box::new(|p| app.cam.from_frame(p).to_f32()))
};
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
let c = snap(u) + snap(v);
let w3 = |p: Vec3| app.cam.from_frame(p).to_f32();
let n = ((ext / minor) as i64).clamp(1, 300);
for i in -n..=n {
let t = i as f64 * minor;
let maj = (t / major).round() * major == t;
let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) };
let w = if maj { 1.0 } else { 0.7 };
sc.under_line(w3(c + u * t - v * ext), w3(c + u * t + v * ext), col, w);
sc.under_line(w3(c - u * ext + v * t), w3(c + u * ext + v * t), col, w);
let wd = if maj { 1.0 } else { 0.7 };
sc.under_line(w3(c + u * t - v * ext), w3(c + u * t + v * ext), col, wd);
sc.under_line(w3(c - u * ext + v * t), w3(c + u * ext + v * t), col, wd);
}
}
for sid in visible_sketches(app) {
@@ -6188,6 +6194,28 @@ mod grid_plane_tests {
assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Ground, "a clear turn off the ground returns");
}
/// An open sketch always has its grid, on its own plane, whatever the Grid switch and the
/// follow-the-view preference say; outside a sketch the switch decides.
#[test]
fn an_open_sketch_always_shows_a_grid_on_its_plane() {
let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0));
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.ui.show_grid = false;
let grid = |app: &SolveApp| build_sketch_scene(app, &projection(app, rect)).under;
assert!(grid(&app).is_empty(), "no grid outside a sketch with the switch off");
app.run("sketch.create", json!({"plane": "XZ"})).unwrap();
for follows in [false, true] {
app.preferences.grid_follows_view = follows;
let under = grid(&app);
assert!(!under.is_empty(), "the open sketch shows a grid (follows the view: {follows})");
// Each line is 32 bytes: two f32 points, a colour and a width; the XZ plane is Y = 0.
let ys: Vec<f32> = under.chunks_exact(32).flat_map(|l| [&l[4..8], &l[16..20]]).map(|b| f32::from_le_bytes(b.try_into().unwrap())).collect();
assert!(ys.iter().all(|y| y.abs() < 1e-3), "the grid lies on the sketch plane");
}
app.ui.show_grid = true;
assert!(!grid(&app).is_empty());
}
/// A sketch is drawn in a parallel view even when the preference asks for perspective; the
/// preference comes back when the sketch is finished.
#[test]
+1 -3
View File
@@ -84,9 +84,7 @@ class IconExportTest(unittest.TestCase):
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
url = 'http://127.0.0.1:%d' % port
# Up to a minute: CI machines can be busy enough that the server starts slowly.
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
for attempt in range(100):
try:
with urllib.request.urlopen(url + '/api/review', timeout=1) as response:
base = json.load(response)
+10 -21
View File
@@ -61,10 +61,8 @@ use crate::web_offline;
const PREFIX: &str = "/solidcraft/app/";
/// Another copy of the app on the same origin.
const ELSEWHERE: &str = "/solidcraft/elsewhere/";
/// How long one launch may take (a real build compiles ~30 MB of wasm). Generous: the CI
/// runners that take fork pull requests run several jobs per machine under gVisor, where one
/// launch has taken over 90 s.
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(240);
/// How long one launch may take (a real build compiles ~30 MB of wasm).
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(90);
/// sw.js's NAVIGATE_TIMEOUT_MS, less a margin: a hung network is waited for at least this long.
const SLOW_NETWORK_WAIT: Duration = Duration::from_secs(3);
/// How long a file takes in Mode::SlowFiles.
@@ -195,24 +193,15 @@ pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String
let n = count.get() + 1;
count.set(n);
let t = Instant::now();
let search = format!("?launch={n}");
let what = format!("launch {n} starts build {build}");
// One retry: on a heavily loaded machine a launch can stall past the timeout. Loading the
// same page again must still start the same build.
for attempt in 1..=2 {
let r = cdp.call("Page.navigate", json!({ "url": format!("http://127.0.0.1:{}{at}?launch={n}", server.port) }))?;
if let Some(e) = r["errorText"].as_str().filter(|e| !e.is_empty()) {
return Err(format!("launch {n} (build {build}): the page didn't load: {e}"));
}
match wait_for(cdp, &state_js, &what, |s| {
s["search"] == search.as_str() && s["ready"] == "complete" && s["build"] == build && s["started"] == true
}) {
Ok(()) => return Ok(t.elapsed()),
Err(e) if attempt == 1 => println!("retry {e}"),
Err(e) => return Err(e),
}
let r = cdp.call("Page.navigate", json!({ "url": format!("http://127.0.0.1:{}{at}?launch={n}", server.port) }))?;
if let Some(e) = r["errorText"].as_str().filter(|e| !e.is_empty()) {
return Err(format!("launch {n} (build {build}): the page didn't load: {e}"));
}
unreachable!()
let search = format!("?launch={n}");
wait_for(cdp, &state_js, &format!("launch {n} starts build {build}"), |s| {
s["search"] == search.as_str() && s["ready"] == "complete" && s["build"] == build && s["started"] == true
})?;
Ok(t.elapsed())
};
let launch = |cdp: &mut Cdp, build: &str| launch_at(cdp, PREFIX, build);
// The page is controlled by the worker, which keeps exactly this build's files (and the mark