Compare commits

..

1 Commits

Author SHA1 Message Date
bherbruck c0236187d1 Renaming a body that later features change evaluates nothing (#2467)
Model::rename_body now keeps the results when later features change the
body (a pattern, a chamfer, a join). It still evaluates when a body of
that name comes and goes or isn't the same body throughout, when a later
body's name is worked out from it (X (2)), or when a message quotes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:36:00 -04:00
4 changed files with 115 additions and 78 deletions
+37 -53
View File
@@ -8,7 +8,7 @@
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
@@ -83,6 +83,28 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
@@ -271,19 +293,11 @@ jobs:
path: dist/release/*
if-no-files-found: error
# The two halves of the universal binary build in parallel on their own runners (each alone
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
macos-build:
name: macOS build ${{ matrix.arch }}
macos:
name: macOS universal
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14
strategy:
fail-fast: false
matrix:
include:
- { arch: aarch64, target: aarch64-apple-darwin }
- { arch: x86_64, target: x86_64-apple-darwin }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -291,45 +305,12 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-${{ matrix.arch }}
- name: Build
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
# Not named release-*: the release job collects every release-* artifact as an asset.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: macos-bin-${{ matrix.arch }}
path: |
target/${{ matrix.target }}/release/solidcraft
target/${{ matrix.target }}/release/solidcraft-cli
target/${{ matrix.target }}/release/solvecraft-cli
if-no-files-found: error
macos:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-aarch64
path: target/aarch64-apple-darwin/release
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-x86_64
path: target/x86_64-apple-darwin/release
- name: Join, package and check (dmg, CLI zip; the dmg is mounted and checked)
run: |
# Artifacts don't keep the executable bit.
chmod +x target/*-apple-darwin/release/solidcraft target/*-apple-darwin/release/solidcraft-cli target/*-apple-darwin/release/solvecraft-cli
packaging/macos/package.sh --arch universal --skip-build
key: release-macos-universal
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
@@ -338,12 +319,9 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: version
needs: [version, icons]
if: needs.version.outputs.build == 'true'
# An 8 vCPU runner: the release build was 35 minutes on a 2 vCPU windows-latest and is the
# longest job of the release. The installer icon is the committed
# assets/icons/app/windows/exports/solidcraft.ico (package.ps1's default), so nothing waits.
runs-on: blacksmith-8vcpu-windows-2025
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
@@ -356,6 +334,10 @@ jobs:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -369,6 +351,8 @@ jobs:
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+19 -6
View File
@@ -1532,23 +1532,36 @@ impl Model {
/// Follow a rename of body `old` to `new` without evaluating anything (#3044): `doc` is the
/// design after the rename, which changed nothing but names, so every result keeps its
/// geometry and meshes and only the body's name (and the names worked out from it) changes.
/// Later features that change the body (a pattern, a chamfer, a join) keep their results
/// too (#2467): a feature's geometry doesn't depend on its bodies' names.
/// False, leaving the model as it was, when the rename can't be followed that way and the
/// design must be evaluated: the model isn't the design's, the body is changed by a later
/// feature, or is sheet metal, threaded, or named in a later feature's face references.
/// design must be evaluated: the model isn't the design's, a body of that name comes and goes
/// or isn't the same body throughout, a later body's name is worked out from it (`X (2)`), a
/// message quotes it, or it is sheet metal, threaded, or named in a later feature's face
/// references.
pub fn rename_body(&mut self, doc: &Document, old: &str, new: &str) -> bool {
if self.results.len() != doc.features.len() || self.results.iter().zip(&doc.features).any(|(r, f)| r.id != f.id || r.stale) {
return false;
}
let Some(at) = self.results.iter().position(|r| r.state.body(old).is_some()) else { return false };
let Some(made) = self.results.get(at).and_then(|r| r.state.body(old)).map(|b| Arc::as_ptr(&b.mesh)) else { return false };
// Untouched from the feature that made it to the end, and nothing else holds its name.
let untouched = self.results.iter().skip(at).all(|r| r.state.body(old).is_some_and(|b| Arc::as_ptr(&b.mesh) == made));
let Some(feature) = self.results.get(at).and_then(|r| r.state.body(old)).map(|b| b.feature) else { return false };
// One body from the feature that made it until it is gone (joined into another), and no
// other body's name comes from its name.
let held: Vec<bool> = self.results.iter().skip(at).map(|r| r.state.body(old).is_some()).collect();
let one_run = held.iter().skip_while(|h| **h).all(|h| !*h);
let derived = format!("{old} (");
let same_body = self
.results
.iter()
.skip(at)
.all(|r| r.state.bodies.iter().all(|b| if b.name == old { b.feature == feature } else { !b.name.starts_with(&derived) }));
let quoted = self.results.iter().skip(at).any(|r| [&r.error, &r.warning].into_iter().flatten().any(|m| m.contains(old)));
let named_elsewhere = self
.results
.iter()
.any(|r| r.state.body(new).is_some() || r.state.sheets.iter().any(|x| x.body == old) || r.state.threads.iter().any(|t| t.body == old));
let faces_named = doc.features.iter().skip(at + 1).any(|f| f.face_names.iter().any(|n| n.contains(old)));
if !untouched || named_elsewhere || faces_named {
if !one_run || !same_body || quoted || named_elsewhere || faces_named {
return false;
}
// States shared between results stay shared.
+58
View File
@@ -3851,6 +3851,64 @@ fn renaming_an_imported_assembly_body_reads_nothing_again() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #2467: renaming a body that later features change (a hole patterned over it, then a chamfer)
/// evaluates nothing, and leaves the bodies, volumes and face names evaluating the renamed
/// design gives; undo and redo do the same.
#[test]
fn renaming_a_body_later_features_change_evaluates_nothing() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10}));
run(
&mut s,
"solid.hole",
json!({"position": [15, 12, 10], "diameter": 5, "type": "counterbore", "cb_diameter": 9, "cb_depth": 3, "name": "Hole1"}),
);
run(
&mut s,
"solid.pattern.rectangular",
json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "dir2": [0, 1, 0], "count2": 2, "spacing2": 25}),
);
run(&mut s, "solid.chamfer", json!({"edges": [[40, 0, 10], [80, 25, 10]], "distance": 1}));
let looks = |st: &solidcraft_doc::ModelState| {
st.bodies.iter().map(|b| (b.name.clone(), (*solidcraft_doc::naming::face_names(b)).clone())).collect::<Vec<_>>()
};
let before = looks(&s.model.state());
let v = body_volume(&s.model.state());
let old = before.first().map(|b| b.0.clone()).unwrap();
s.evaluations = 0;
run(&mut s, "body.rename", json!({"body": old, "name": "Plate"}));
assert_eq!(s.evaluations, 0, "a rename evaluates nothing");
let after = looks(&s.model.state());
assert_eq!(after.first().map(|b| b.0.as_str()), Some("Plate"));
let mut fresh = Session::new((*s.doc).clone());
fresh.refresh();
assert_eq!(looks(&fresh.model.state()), after, "the bodies and face names evaluating the renamed design gives");
assert!(rel(body_volume(&fresh.model.state()), v) < 1e-9);
run(&mut s, "edit.undo", json!({}));
assert_eq!(looks(&s.model.state()), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(looks(&s.model.state()), after);
assert_eq!(s.evaluations, 0, "nor do its undo and redo");
}
/// A body whose name later bodies' names are worked out from (`X` patterned into `X (2)`) is still
/// renamed by evaluating the design, so the copies follow it as they would on opening it.
#[test]
fn renaming_a_body_whose_copies_take_its_name_evaluates_the_design() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
let names = |s: &Session| s.model.state().bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>();
assert!(names(&s).iter().any(|n| n.starts_with("X (")), "{:?}", names(&s));
run(&mut s, "body.rename", json!({"body": "X", "name": "First"}));
let mut fresh = Session::new((*s.doc).clone());
fresh.refresh();
assert_eq!(names(&fresh), names(&s));
}
/// #3044: on a design with thousands of bodies the cheap commands stay cheap: showing, hiding,
/// selecting, colouring and renaming a body or a feature, and undoing them, recompute no feature
/// (renames don't even look over the timeline) and leave every body's display mesh as it was, so
+1 -19
View File
@@ -7,12 +7,6 @@
# compatibility alias) with the licence files
#
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
# packaging/macos/package.sh --build-only <rust-target>
#
# --build-only builds just that target (aarch64-apple-darwin or x86_64-apple-darwin) and stops,
# so the release workflow can build the two halves of the universal binary on separate runners;
# a later `--arch universal --skip-build` run joins the binaries it finds under
# $CARGO_TARGET_DIR/<target>/release/ and packages them.
#
# The app is ad-hoc signed (not notarized): the first launch needs right-click > Open.
# Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
@@ -24,13 +18,11 @@ MACOS_BUNDLE_ID=ai.storyteller.solidcraft
ARCH=universal
SKIP_BUILD=0
BUILD_ONLY=
while [ $# -gt 0 ]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
--build-only) BUILD_ONLY="$2"; shift 2 ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
-h | --help) sed -n '2,10p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
@@ -49,16 +41,6 @@ APP="$WORK/SolidCraft.app"
DMG="$DIST/solidcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/solidcraft-cli-$VERSION-macos-$ARCH.zip"
if [ -n "$BUILD_ONLY" ]; then
case "$BUILD_ONLY" in
aarch64-apple-darwin | x86_64-apple-darwin) ;;
*) echo "unknown --build-only target $BUILD_ONLY" >&2; exit 2 ;;
esac
echo "==> SolidCraft $VERSION for macOS ($BUILD_ONLY, build only)"
(cd "$ROOT" && cargo build --release --locked -p solidcraft -p solidcraft-cli --target "$BUILD_ONLY")
exit 0
fi
echo "==> SolidCraft $VERSION for macOS ($ARCH)"
if [ "$SKIP_BUILD" = 0 ]; then
args=()