Compare commits

...

23 Commits

Author SHA1 Message Date
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
6 changed files with 91 additions and 50 deletions
Generated
+17 -17
View File
@@ -4078,7 +4078,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4109,11 +4109,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.16.1"
version = "0.16.2"
[[package]]
name = "solidcraft-cli"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"image",
"serde_json",
@@ -4124,7 +4124,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"proc-macro2",
"quote",
@@ -4133,7 +4133,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4150,7 +4150,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4176,7 +4176,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"naga",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4213,7 +4213,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"proptest",
@@ -4233,7 +4233,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"image",
"moxcms",
@@ -4256,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4267,11 +4267,11 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.16.1"
version = "0.16.2"
[[package]]
name = "solidcraft-ui-egui"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"arboard",
"egui",
@@ -4296,7 +4296,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"eframe",
"egui",
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.16.1"
version = "0.16.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+2 -10
View File
@@ -91,16 +91,8 @@ fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
cam.pan(delta.x, delta.y, viewport()?.y);
} else if p.get("pivot").is_some() {
let pivot = req_vec3(cmd, p, "pivot")?;
let size = viewport()?;
let before = cam.to_screen(pivot, size.x, size.y, 1.0);
cam.orbit(delta.x, delta.y);
let after = cam.to_screen(pivot, size.x, size.y, 1.0);
if let (Some((ax, ay, _)), Some((bx, by, _))) = (before, after) {
// Pan operates at target depth. Perspective screen correction must instead
// use the pivot's depth after orbiting; lateral pan leaves that depth fixed.
let scale = if cam.fov > 0.0 { (cam.eye() - pivot).dot(cam.back()) / cam.distance } else { 1.0 };
cam.pan((ax - bx) * scale, (ay - by) * scale, size.y);
}
viewport()?;
cam.orbit_about(delta.x, delta.y, pivot);
} else {
cam.orbit(delta.x, delta.y);
}
+37
View File
@@ -353,6 +353,25 @@ impl Camera {
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
}
/// Orbit by pixel deltas about `pivot`: the camera turns rigidly about it, so the pivot keeps
/// its place in the camera's frame and on screen, in perspective as in parallel views.
pub fn orbit_about(&mut self, dx: f64, dy: f64, pivot: Vec3) {
let keep = self.frame_coords(pivot);
self.orbit(dx, dy);
self.place(pivot, keep);
}
/// Where `p` lies from the target along the camera's right, up and back axes.
pub fn frame_coords(&self, p: Vec3) -> Vec3 {
let (r, u, b) = self.basis();
let d = p - self.target;
Vec3::new(d.dot(r), d.dot(u), d.dot(b))
}
/// Move the target so `p` lies at `coords` (from [`Camera::frame_coords`]) in the camera's
/// frame: with the distance unchanged, `p` is where it was on screen.
pub fn place(&mut self, p: Vec3, coords: Vec3) {
let (r, u, b) = self.basis();
self.target = p - (r * coords.x + u * coords.y + b * coords.z);
}
/// Rotate the image about the viewing direction.
pub fn roll_by(&mut self, radians: f64) {
if radians.is_finite() {
@@ -432,6 +451,24 @@ impl Camera {
mod tests {
use super::*;
/// Orbiting about a point keeps it on its pixel, with and without perspective and roll.
#[test]
fn orbit_about_keeps_the_pivot_on_screen() {
let pivot = Vec3::new(12.0, -7.0, 9.0);
for fov in [0.0, 0.6] {
for roll in [0.0, 0.4] {
let mut cam = Camera { target: Vec3::new(3.0, 4.0, -2.0), distance: 150.0, yaw: 0.3, pitch: 0.5, roll, fov, ..Camera::default() };
let before = cam.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
for _ in 0..10 {
cam.orbit_about(25.0, -12.0, pivot);
}
let after = cam.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
assert!((after.0 - before.0).abs() < 1e-6 && (after.1 - before.1).abs() < 1e-6, "fov {fov}, roll {roll}: {before:?} -> {after:?}");
assert!((cam.eye() - pivot).len() > 1.0);
}
}
}
#[test]
fn front_view_projects_target_to_centre() {
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
+7 -22
View File
@@ -574,7 +574,8 @@ pub(crate) struct OrbitSnap {
pub(crate) target: Camera,
pub(crate) yaw_velocity: f64,
pub(crate) pitch_velocity: f64,
pub(crate) pivot: Option<(Vec3, Pos2)>,
/// The pivot and where it lies in the camera's frame ([`Camera::frame_coords`]).
pub(crate) pivot: Option<(Vec3, Vec3)>,
}
/// Pointer motion accumulates independently of the spring preview, so many small deltas
@@ -610,10 +611,7 @@ pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press
} else {
None
}
.and_then(|p| {
app.cam = displayed;
projection(app, rect).to_screen(p).map(|at| (p, at))
});
.map(|p| (p, displayed.frame_coords(p)));
let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot });
spring.target = target;
spring.pivot = pivot;
@@ -623,7 +621,7 @@ pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press
/// A lightly damped spring: mostly settled in about a third of a second, with a small
/// elastic finish. Bounded substeps keep the behavior consistent across frame rates.
pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
pub(crate) fn step_orbit_snap(app: &mut SolveApp, _rect: Rect, dt: f64) {
let Some(mut spring) = app.viewport.orbit_snap.take() else { return };
let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 };
let steps = (dt * 240.0).ceil().max(1.0) as usize;
@@ -645,10 +643,8 @@ pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
if done {
app.cam = spring.target;
}
if let Some((pivot, at)) = spring.pivot
&& let Some(after) = projection(app, rect).to_screen(pivot)
{
app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0)));
if let Some((pivot, coords)) = spring.pivot {
app.cam.place(pivot, coords);
}
if !done {
app.viewport.orbit_snap = Some(spring);
@@ -707,18 +703,7 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
app.cam.orbit(dx, dy);
return;
};
orbit_about(app, rect, dx, dy, pivot);
}
/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan
/// correction puts it back where it was.
pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
let before = crate::viewport::projection(app, rect).to_screen(pivot);
app.cam.orbit(dx, dy);
let after = crate::viewport::projection(app, rect).to_screen(pivot);
if let (Some(a), Some(b)) = (before, after) {
app.cam.pan(f64::from(a.x - b.x), f64::from(a.y - b.y), f64::from(rect.height().max(1.0)));
}
app.cam.orbit_about(dx, dy, pivot);
}
#[cfg(test)]
+27
View File
@@ -5911,6 +5911,33 @@ mod tests {
}
}
/// An orbit drag that starts on the model turns about the point under the cursor, which
/// stays on its pixel, in perspective as in parallel views. The perspective view used to
/// correct with a pan at the target's depth, so the point slid away and the view swung
/// about the camera instead.
#[test]
fn cursor_orbit_keeps_the_picked_point_on_its_pixel() {
for perspective in [false, true] {
for offset in [vec2(30.0, 20.0), vec2(-60.0, 10.0), vec2(120.0, -40.0)] {
let mut h = crate::scenario::Harness::new();
h.app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor;
h.app.ui.perspective = perspective;
h.app.run("solid.box", json!({"length":80,"width":50,"height":20})).unwrap();
h.app.fit_view();
let rect = h.app.viewport.rect.unwrap();
let at = rect.center() + offset;
let proj = projection(&h.app, rect);
assert!(pick(&h.app, &proj, at).iter().any(|hit| matches!(hit, Hit::Face { .. })), "the drag starts on the box");
let pivot = crate::prefs::pivot_under(&h.app, &proj, at);
let before = h.app.cam;
h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+150.0,"y1":at.y+60.0,"button":"middle","shift":true,"steps":20}));
assert!((h.app.cam.yaw - before.yaw).abs() > 0.5, "the view turned");
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
assert!(after.distance(at) < 0.5, "perspective={perspective}: {at:?} -> {after:?}");
}
}
}
#[test]
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
let mut h = crate::scenario::Harness::new();