Compare commits

..

8 Commits

Author SHA1 Message Date
bherbruck 0f8aa72732 Release: Windows jobs start at once on 8 vCPU runners
The icons job only copied the committed .ico after a cold build of the CLI, and the Windows
jobs waited for it; package.ps1 falls back to the same file. The Windows builds were 35 minutes
on a 2 vCPU runner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:43:43 -04:00
bherbruck 2c5f8c1776 Build the macOS release binaries per architecture in parallel
The universal macOS job built aarch64 and x86_64 one after the other on a
3-core runner and took 45-50 minutes, the longest job of a release. Each
architecture now builds on its own runner and a small job joins them with lipo
and packages the dmg and CLI zip exactly as before.
2026-10-20 17:43:07 -04:00
bherbruck 522ae77556 Release 0.18.1 (#3851)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:33:29 -04:00
raykholo 5ba8c815a6 IGES import: trim curves with reversed pieces no longer drop their faces (#3358) (#3359)
* claim: #3358

* IGES import: trim curves with reversed pieces no longer drop their faces

A curve on a surface (142) gives each trim loop twice: B in the surface's
parameter space and C in model space, running the same way. Some exporters
write C as a composite curve (102) whose pieces are in order but some of
them end to start. The reader passed those pieces on as they were, so the
loop did not close and the STEP reader left the face out ("face boundary is
not a closed loop").

The pieces of C are now turned to run head to tail, starting at S(B(start)),
the point where the parameter-space curve begins on the surface. That keeps
the loop's sense from the file, so holes still run against the outer loop.
A piece whose ends are one vertex at the sewing tolerance keeps its
direction.

On a 9,276-face trimmed-surface car model, 7,887 faces now import instead
of 3,882.
2026-10-20 17:32:48 -04:00
bherbruck cb1b230732 Add Dutch, Swedish, Traditional Chinese, Indonesian, Romanian and Hungarian (#3826)
The new catalogs are machine translated; native speakers, corrections are very welcome.
2026-10-20 17:32:44 -04:00
bherbruck 6e22e3709d Web: reopen designs saved on the computer from the recent list after a reload; forget stale remembered files (#104) (#3834) 2026-10-20 17:32:36 -04:00
Krzysztof Heim 1b84ce6330 fix: skip impossible drags of determined sketch geometry (#2006) (#2199)
Co-authored-by: Krzysztof Heim <krzysztof.heim@b4bridge.com>
2026-10-20 20:56:50 +00:00
bherbruck 687caad24c IGES import: a revolved arc is a cap, not a whole sphere (#3806) (#3813)
A surface of revolution (120) wrote its arc generatrix as a whole STEP circle, which
turns into a whole sphere twice over when the arc is centred on the axis; the trim could
not pick the cap out of it and the face meshed as the full ball. An arc in a plane through
the axis now comes in as a sphere or torus, any other arc as an exact rational B-spline of
just the arc.
2026-10-20 20:56:35 +00:00
29 changed files with 12107 additions and 475 deletions
+53 -37
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 packaging/macos
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) 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,28 +83,6 @@ 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
@@ -293,11 +271,19 @@ jobs:
path: dist/release/*
if-no-files-found: error
macos:
name: macOS universal
# 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 }}
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 }}
@@ -305,12 +291,45 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
run: packaging/macos/package.sh --arch universal
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
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
@@ -319,9 +338,12 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
needs: version
if: needs.version.outputs.build == 'true'
runs-on: windows-latest
# 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
strategy:
fail-fast: false
matrix:
@@ -334,10 +356,6 @@ 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 }}
@@ -351,8 +369,6 @@ 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:
+6
View File
@@ -106,17 +106,23 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/hu.json | SolidCraft contributors | Original Hungarian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/id.json | SolidCraft contributors | Original Indonesian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
| assets/locales/nl.json | SolidCraft contributors | Original Dutch translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/sv.json | SolidCraft contributors | Original Swedish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ro.json | SolidCraft contributors | Original Romanian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/zh-TW.json | SolidCraft contributors | Original Traditional Chinese (Taiwan) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
Generated
+17 -17
View File
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4193,11 +4193,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.18.0"
version = "0.18.1"
[[package]]
name = "solidcraft-cli"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"image",
"serde_json",
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4217,7 +4217,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"serde",
@@ -4234,7 +4234,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"schemars",
@@ -4253,7 +4253,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde",
"serde_json",
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"naga",
@@ -4279,7 +4279,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4302,7 +4302,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"log",
"proptest",
@@ -4322,7 +4322,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"image",
"moxcms",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde",
"serde_json",
@@ -4356,14 +4356,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"arboard",
"egui",
@@ -4390,7 +4390,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"eframe",
"egui",
@@ -5920,7 +5920,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.18.0"
version = "0.18.1"
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.18.0"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+143 -3
View File
@@ -59,6 +59,34 @@ pub fn make_id(ms: u64, random: u64) -> String {
format!("{ms:x}-{random:x}")
}
/// How long a remembered file may go unused, and unreferenced by the recent designs and the
/// autosave, before the browser's copy of its handle is dropped.
pub const HANDLE_MAX_AGE_MS: f64 = 90.0 * 24.0 * 3600.0 * 1000.0;
/// What to do with the remembered handles.
#[derive(Debug, Default, PartialEq)]
pub struct PrunePlan {
/// Handles to forget.
pub delete: Vec<String>,
/// Handles seen unused for the first time: start their clock.
pub stamp: Vec<String>,
}
/// Decide which of the remembered handles `ids` (id, when it was last stamped) to forget: those
/// that no recent design or autosave (`keep`) names and that have gone unused for `max_age`. One
/// never stamped starts its clock now, so a file another tab has just saved is never dropped.
pub fn plan_prune(ids: &[(String, Option<f64>)], keep: &std::collections::HashSet<String>, now: f64, max_age: f64) -> PrunePlan {
let mut plan = PrunePlan::default();
for (id, stamped) in ids.iter().filter(|(id, _)| !keep.contains(id)) {
match stamped {
None => plan.stamp.push(id.clone()),
Some(at) if now - at > max_age => plan.delete.push(id.clone()),
Some(_) => {}
}
}
plan
}
/// Where a design saved with Save As goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SaveTarget {
@@ -112,7 +140,7 @@ mod web {
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
use super::{is_abort, make_id, opens_design, parse, path, save_type};
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
use crate::files::{Files, err, read_handle};
const DB: &str = "solidcraft-disk-handles";
@@ -397,10 +425,106 @@ mod web {
db.transaction_with_str_and_mode(STORE, mode).map_err(err)?.object_store(STORE).map_err(err)
}
/// Key of the time a handle was last used (the handle's own key is its id).
fn stamp_key(id: &str) -> String {
format!("at:{id}")
}
async fn remember(id: &str, handle: &FileSystemFileHandle) -> Result<(), String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readwrite)?.put_with_key(handle, &id.into()).map_err(err)?;
result(&req).await.map(|_| ())
let store = store(&db, IdbTransactionMode::Readwrite)?;
result(&store.put_with_key(handle, &id.into()).map_err(err)?).await?;
result(&store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into()).map_err(err)?).await.map(|_| ())
}
/// Note that the file was used now.
async fn touch(id: &str) {
let Ok(db) = open_db().await else { return };
if let Ok(store) = store(&db, IdbTransactionMode::Readwrite)
&& let Ok(req) = store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into())
{
let _ = result(&req).await;
}
}
/// The ids of every remembered file.
pub async fn known_ids() -> Result<Vec<String>, String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readonly)?.get_all_keys().map_err(err)?;
let keys: Array = result(&req).await?.dyn_into().map_err(|_| "no keys".to_string())?;
Ok(keys.iter().filter_map(|k| k.as_string()).filter(|k| !k.starts_with("at:")).collect())
}
async fn forget(ids: &[String]) -> Result<(), String> {
let db = open_db().await?;
let store = store(&db, IdbTransactionMode::Readwrite)?;
for id in ids {
result(&store.delete(&id.as_str().into()).map_err(err)?).await?;
result(&store.delete(&stamp_key(id).into()).map_err(err)?).await?;
HANDLES.with(|h| h.borrow_mut().remove(id));
}
Ok(())
}
/// Forget the remembered files nothing refers to any more (`keep`: the ids the recent designs
/// and the autosave name), once they have gone unused for a long while.
pub fn prune(keep: std::collections::HashSet<String>) {
spawn_local(async move {
let r = async {
// A transaction ends when the page awaits anything but its own requests, so each
// step opens its own.
let known = known_ids().await?;
let db = open_db().await?;
let reading = store(&db, IdbTransactionMode::Readonly)?;
let mut ids = Vec::new();
for id in known {
let at = result(&reading.get(&stamp_key(&id).into()).map_err(err)?).await?.as_f64();
ids.push((id, at));
}
let plan = plan_prune(&ids, &keep, js_sys::Date::now(), HANDLE_MAX_AGE_MS);
for id in &plan.stamp {
touch(id).await;
}
forget(&plan.delete).await
};
if let Err(e) = r.await {
log::warn!("remembered files weren't tidied: {e}");
}
});
}
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
/// for access to the file (call in the click), read it into the engine's files and queue it
/// to open. A file that is gone is forgotten and dropped from the recent designs.
pub fn restore(files: &Rc<Files>, path: &str) {
let Some((id, name)) = parse(path) else { return };
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
spawn_local(async move {
let r = async {
let handle = handle_for(&id).await?;
ensure_readwrite(&handle).await?;
read_handle(&handle).await
};
match r.await {
Ok(bytes) => {
vfs::insert(&path, bytes);
files.opened.borrow_mut().push(path);
files.wake();
touch(&id).await;
}
Err(e) => {
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
if gone {
let _ = forget(std::slice::from_ref(&id)).await;
files.disk_known.borrow_mut().remove(&id);
files.gone.borrow_mut().push(path);
files.say(format!("{name} isn't where it was saved any more"), true);
} else {
files.say(format!("{name}: {e}"), true);
}
}
}
});
}
async fn recall(id: &str) -> Result<Option<JsValue>, String> {
@@ -455,6 +579,22 @@ mod tests {
assert_eq!(route("/upload/part.step"), WriteTo::Memory);
}
#[test]
fn only_unreferenced_handles_unused_for_long_are_forgotten() {
let keep: std::collections::HashSet<String> = ["recent".to_string()].into();
let day = 24.0 * 3600.0 * 1000.0;
let now = 1000.0 * day;
let ids = vec![
("recent".to_string(), Some(now - 200.0 * day)),
("old".to_string(), Some(now - 100.0 * day)),
("fresh".to_string(), Some(now - 5.0 * day)),
("unstamped".to_string(), None),
];
let plan = plan_prune(&ids, &keep, now, HANDLE_MAX_AGE_MS);
assert_eq!(plan, PrunePlan { delete: vec!["old".to_string()], stamp: vec!["unstamped".to_string()] });
assert_eq!(plan_prune(&[], &keep, now, HANDLE_MAX_AGE_MS), PrunePlan::default());
}
#[test]
fn handle_ids_differ_by_time_and_by_chance() {
assert_ne!(make_id(1, 5), make_id(2, 5));
+9 -1
View File
@@ -57,6 +57,10 @@ pub struct Files {
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
/// Designs opened from the computer's disk: their paths in the engine's files.
pub opened: RefCell<Vec<String>>,
/// The ids of the files remembered from earlier sessions (see `disk::restore`).
pub disk_known: RefCell<std::collections::HashSet<String>>,
/// Recent designs whose file is gone: to drop from the recent list.
pub gone: RefCell<Vec<String>>,
/// Disk designs whose last write failed: they are not saved.
pub unsaved: RefCell<Vec<String>>,
/// Messages for the status line (errors and confirmations).
@@ -90,7 +94,7 @@ impl Files {
}
/// Run a frame (the app sleeps until something happens).
fn wake(&self) {
pub fn wake(&self) {
if let Some(c) = self.ctx.borrow().as_ref() {
c.request_repaint();
}
@@ -267,6 +271,10 @@ pub fn start(files: &Rc<Files>) {
}
let files = files.clone();
spawn_local(async move {
match disk::known_ids().await {
Ok(ids) => files.disk_known.borrow_mut().extend(ids),
Err(e) => log::info!("no remembered files: {e}"),
}
match read_all().await {
Ok(all) => {
if local_only() {
+19 -1
View File
@@ -136,6 +136,11 @@ mod web {
self.app.open_path(&path);
self.files.panel.set(false);
}
// Recent designs whose file is gone are dropped from the list.
for path in self.files.gone.borrow_mut().drain(..) {
self.app.home.recent.retain(|r| *r != path);
self.app.home.opened_at.remove(&path);
}
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
@@ -203,7 +208,9 @@ mod web {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
let known = self.files.disk_known.borrow().clone();
// A design on the computer's disk stays listed while its file is remembered.
recent.retain(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| known.contains(id)));
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
@@ -211,6 +218,10 @@ mod web {
}
}
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
// Remembered files that no recent design or autosave names are forgotten in time.
let recovered = self.files.recovery.borrow().as_ref().and_then(|(_, p)| p.clone());
let keep = recent.iter().chain(recovered.as_ref()).filter_map(|p| disk::parse(p)).map(|(id, _)| id.to_string()).collect();
disk::prune(keep);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
@@ -563,6 +574,7 @@ mod web {
let files = Rc::new(Files::default());
files::start(&files);
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
let (restorable, restorer) = (files.clone(), files.clone());
let services = Services {
// Open shows the browser's open dialog for a design where it has one
// (the file keeps its handle, so Save writes back to it); otherwise,
@@ -587,6 +599,12 @@ mod web {
SaveTarget::Download => Box::pin(std::future::ready(Some(format!("{}{name}", files::DOWNLOADS)))),
}
})),
// A recent design on the disk, from an earlier session, opens by asking
// the browser for its remembered file.
can_restore: Some(Box::new(move |path: &str| {
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
})),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
download_design: Some(Box::new(files::download)),
browser_files: Some(Box::new(move || shown.panel.set(true))),
install_app: Some(crate::install::service(&cc.egui_ctx)),
+2
View File
@@ -210,6 +210,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
install_app: None,
can_restore: None,
restore_file: None,
download_design: None,
browser_files: None,
agent_http: Some(std::sync::Arc::new(agent_http)),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-126
View File
@@ -1,126 +0,0 @@
//! Faces that are picked as one (#3745 follow-up): a modelled thread is thousands of small faces
//! (its flanks, and the pieces of the surface it was cut into), but it is one surface to point at,
//! light and select; so are the pieces of one face an imported file had (a face wrapping all the
//! way round, split in two for the kernel).
//!
//! The groups come from what made the faces, never from their shape: a face made by a Thread
//! feature is named after it (`F<id>:…`), and a piece of a face the thread cut names it among the
//! faces around it (`…[F<id>:…]`). Working the names out for a large body takes a while, so it is
//! done on a worker thread, once per body, and asked for without waiting.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::{Document, FeatureKind, ModelBody};
/// Each face's group (an index into `groups`), and the groups: faces picked together, in order.
#[derive(Debug, Default)]
pub struct FaceGroups {
of_face: Vec<Option<u32>>,
groups: Vec<Arc<Vec<usize>>>,
}
impl FaceGroups {
/// The group of `face` (`None`: it is picked alone).
pub fn group_of(&self, face: usize) -> Option<u32> {
self.of_face.get(face).copied().flatten()
}
/// The faces picked together with `face` (`None`: it is picked alone).
pub fn of(&self, face: usize) -> Option<Arc<Vec<usize>>> {
let g = self.of_face.get(face).copied().flatten()?;
self.groups.get(g as usize).cloned()
}
/// Work out the groups of `b` (the names of its faces are worked out here: slow for a big body).
pub fn of_body(threads: &[u64], b: &ModelBody) -> FaceGroups {
let mut keyed: HashMap<(u8, u64), Vec<usize>> = HashMap::new();
if !threads.is_empty() {
let names = crate::naming::face_names(b);
let marks: Vec<(u64, String, String)> = threads.iter().map(|id| (*id, format!("F{id}:"), format!("[F{id}:"))).collect();
for (i, name) in names.iter().enumerate() {
// Made by the thread, or a piece of a face the thread cut (it names the thread's faces
// around it).
let own = |p: &str, inside: &str| name.starts_with(p) || name.contains(inside) || name.contains(&format!(",{p}"));
if let Some((id, _, _)) = marks.iter().find(|(_, p, inside)| own(p, inside)) {
keyed.entry((0, *id)).or_default().push(i);
}
}
}
// Pieces of one face of an imported file.
if let Some(sources) = b.body.face_sources() {
for (i, s) in sources.iter().enumerate() {
if let Some(solidcraft_kernel::FaceSource::Imported { face, .. }) = s
&& !keyed.values().any(|faces| faces.contains(&i))
{
keyed.entry((1, *face)).or_default().push(i);
}
}
}
let mut out = FaceGroups { of_face: vec![None; b.body.face_count()], groups: Vec::new() };
let mut groups: Vec<Vec<usize>> = keyed.into_values().filter(|faces| faces.len() > 1).collect();
groups.sort();
for faces in groups {
let g = u32::try_from(out.groups.len()).unwrap_or(u32::MAX);
for &f in &faces {
if let Some(slot) = out.of_face.get_mut(f) {
*slot = Some(g);
}
}
out.groups.push(Arc::new(faces));
}
out
}
}
/// The modelled threads of `doc`, by feature id.
pub fn thread_features(doc: &Document) -> Vec<u64> {
doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::Thread { modeled: true, .. })).map(|f| f.id).collect()
}
/// Groups worked out (or being worked out: `None`), by the identity of a body's names and the
/// threads they were grouped by.
type Cache = HashMap<(usize, Vec<u64>), Option<Arc<FaceGroups>>>;
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
/// Bodies whose groups are kept; the oldest go first.
const KEPT: usize = 256;
/// The groups of `b` in `doc` if they are worked out; otherwise they are worked out on a worker
/// thread (once) and `None` comes back meanwhile. The browser build has no threads: there they
/// are worked out now.
pub fn face_groups_soon(doc: &Document, b: &ModelBody) -> Option<Arc<FaceGroups>> {
let threads = thread_features(doc);
if threads.is_empty() && b.body.face_sources().is_none() {
return Some(Arc::new(FaceGroups::default()));
}
let key = (b.names.id(), threads.clone());
{
let Ok(mut cache) = CACHE.lock() else { return None };
let cache = cache.get_or_insert_with(HashMap::new);
if let Some(entry) = cache.get(&key) {
return entry.clone();
}
if cache.len() >= KEPT {
cache.clear();
}
cache.insert(key.clone(), None);
}
let work = move |b: ModelBody| {
let groups = Arc::new(FaceGroups::of_body(&threads, &b));
if let Ok(mut cache) = CACHE.lock()
&& let Some(slot) = cache.as_mut().and_then(|c| c.get_mut(&key))
{
*slot = Some(groups.clone());
}
groups
};
#[cfg(not(target_arch = "wasm32"))]
{
let body = b.clone();
let work = work.clone();
if std::thread::Builder::new().name("face-groups".into()).spawn(move || drop(work(body))).is_ok() {
return None;
}
}
Some(work(b.clone()))
}
-1
View File
@@ -22,7 +22,6 @@ mod document;
mod eval;
pub mod expr;
pub mod face_anchor;
pub mod face_groups;
mod feature_geometry;
mod feature_refs;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
+7 -4
View File
@@ -2513,9 +2513,11 @@ fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move_point";
let to = req_vec2(cmd, p, "to")?;
let id = target_sketch(s, p, cmd)?;
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
let (_, rep) = edit_solved(s, id, false, Analysis::Freedom, |sk, _| {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false).1.solve_again()))
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false, last).1.solve_again()))
})?;
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
}
@@ -2538,7 +2540,8 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
};
let id = target_sketch(s, p, cmd)?;
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
let analysis = if live { Analysis::Full } else { Analysis::Freedom };
let (d, rep) = edit_solved(s, id, false, analysis, |sk, doc| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
@@ -2565,7 +2568,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
}
// The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, &with, from, to, live);
let (d, left) = drag_entity(sk, e, &with, from, to, live, last);
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
// its degrees of freedom.
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
@@ -2574,7 +2577,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
// average displacement of their moving points, as before.
let sk = s.doc.sketch(id)?;
let at = d.at(sk, from);
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
let rep = rep.or_else(|| last.map(|ss| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..ss.report.clone() }));
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y], "retry": d.retry});
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
}
+27 -1
View File
@@ -125,7 +125,33 @@ impl Path {
///
/// `live`: one frame of an interactive drag, whose held solves and cursor correction share
/// one frame's work budget ([`Budget::frame`]); otherwise each has a solve's own.
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, with: &[Ent], from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
///
/// `solved`: the sketch as last evaluated. When it is unchanged and solved, a grabbed entity it
/// determined is not dragged.
pub(super) fn drag_entity(
sk: &mut Sketch,
e: Ent,
with: &[Ent],
from: Vec2,
to: Vec2,
live: bool,
solved: Option<&solidcraft_doc::SolvedSketch>,
) -> (Dragged, Left) {
// An unchanged, solved entity with no freedom cannot follow a drag. Holding it at a
// different position only makes its constraints conflict, and a large pattern would spend
// minutes trying dense solves before returning to the same geometry (#2006).
if solved.is_some_and(|ss| {
ss.report.ok()
&& ss.sketch == *sk
&& match e {
Ent::Point(q) => ss.report.point_determined.get(q),
Ent::Curve(c) => ss.report.curve_determined.get(c),
}
.copied()
.unwrap_or(false)
}) {
return (Dragged::default(), Left::Solved);
}
let Some((path, hold, n, short)) = prepare(sk, e, with, from, to) else {
return (Dragged::default(), Left::Unsolved);
};
@@ -7,6 +7,52 @@ use solidcraft_geom::Vec3;
use super::solve_counts;
use crate::Session;
#[test]
fn determined_pattern_geometry_does_not_attempt_conflicting_held_solves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [6.5, 6.5]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 1.5}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 1.5}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
let pattern = run(
&mut s,
"sketch.pattern.rectangular",
json!({"entities": ["l1", "l2", "l3", "l4"], "count": 3, "count2": 3, "spacing": 2.2, "spacing2": -2.2}),
);
let line = pattern["curves"][0].as_str().expect("a patterned line");
let endpoint = format!("{line}.start");
let before = sketch_of(&s);
assert!(!before.curve_locked(before.curve_index(line).expect("patterned line index")));
for entity in [line, endpoint.as_str()] {
for live in [true, false] {
reset(&mut s);
let result = run(&mut s, "sketch.drag", json!({"entity": entity, "from": [5, 5], "to": [9, 12], "live": live}));
assert_eq!(result["reached"], json!(0.0));
assert_eq!(result["at"], json!([5.0, 5.0]));
assert_eq!(result["sketch"]["solved"], json!(true));
let full = usize::from(!live);
assert_eq!(solve_counts::take(), (full, full), "no held solve for determined geometry");
assert_eq!(sketch_of(&s), before);
}
}
reset(&mut s);
run(&mut s, "sketch.move_point", json!({"point": endpoint, "to": [9, 12]}));
assert_eq!(solve_counts::take(), (1, 1), "move_point also skips held solves");
assert_eq!(sketch_of(&s), before);
// A cached determined report must not suppress an outstanding dimension edit.
let dimension = before
.constraints
.iter()
.find(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Length { l: 0, .. }))
.and_then(|c| c.param.as_ref())
.expect("the first line's dimension");
s.doc_mut().params.iter_mut().find(|p| &p.name == dimension).expect("dimension parameter").expr = "3".into();
run(&mut s, "sketch.drag", json!({"entity": line, "from": [5, 5], "to": [9, 12], "live": true}));
assert!((pt(&s, "l1.start").dist(pt(&s, "l1.end")) - 3.0).abs() < 1e-6);
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
+199 -25
View File
@@ -347,6 +347,11 @@ struct Nurbs {
impl Nurbs {
fn eval(&self, t: f64) -> Option<V3> {
let h = self.eval_h(t)?;
(h[3].abs() > 1e-300).then(|| [h[0] / h[3], h[1] / h[3], h[2] / h[3]])
}
/// The homogeneous point (x·w, y·w, z·w, w) at `t`.
fn eval_h(&self, t: f64) -> Option<[f64; 4]> {
let p = self.deg;
let n = self.pts.len();
if n == 0 || self.knots.len() != n + p + 1 {
@@ -368,14 +373,49 @@ impl Nurbs {
d[j] = [0, 1, 2, 3].map(|c| (1.0 - alpha) * x[c] + alpha * y[c]);
}
}
let h = d[p];
(h[3].abs() > 1e-300).then(|| [h[0] / h[3], h[1] / h[3], h[2] / h[3]])
d.get(p).copied()
}
fn range(&self) -> (f64, f64) {
(self.knots.get(self.deg).copied().unwrap_or(0.0), self.knots.get(self.pts.len()).copied().unwrap_or(0.0))
}
}
/// A circular arc in its definition plane (at height `c[2]`), counter-clockwise from `a0` to
/// `a1` (radians, `a1 > a0`).
struct Arc {
c: V3,
rad: f64,
a0: f64,
a1: f64,
}
impl Arc {
/// The arc placed by `x`, as an exact rational quadratic B-spline (pieces of at most a
/// quarter turn).
fn nurbs(&self, x: &Xf) -> Nurbs {
let sweep = self.a1 - self.a0;
let pieces = (sweep / std::f64::consts::FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = sweep / pieces as f64;
let w = (step / 2.0).cos();
let on = |t: f64, k: f64| x.point([self.c[0] + k * t.cos(), self.c[1] + k * t.sin(), self.c[2]]);
let mut pts = Vec::with_capacity(2 * pieces + 1);
let mut knots = vec![0.0; 3];
for i in 0..pieces {
let t = self.a0 + step * i as f64;
let (p, m) = (on(t, self.rad), on(t + step / 2.0, self.rad / w));
pts.push([p[0], p[1], p[2], 1.0]);
pts.push([m[0] * w, m[1] * w, m[2] * w, w]);
if i > 0 {
knots.extend([i as f64 / pieces as f64; 2]);
}
}
let p = on(self.a1, self.rad);
pts.push([p[0], p[1], p[2], 1.0]);
knots.extend([1.0; 3]);
Nurbs { deg: 2, knots, pts }
}
}
struct Tr<'a> {
f: &'a Iges,
o: Out,
@@ -423,6 +463,11 @@ impl<'a> Tr<'a> {
}
/// The entity's own transformation (its chain), in millimetres.
fn own_xf(&self, seq: i64, depth: usize) -> R<Xf> {
self.own_xf_in(seq, depth, self.f.unit)
}
/// The entity's own transformation with translations in units of `unit` millimetres (1 for
/// a curve in a surface's parameter space, which has no length unit).
fn own_xf_in(&self, seq: i64, depth: usize, unit: f64) -> R<Xf> {
let de = self.de(seq)?;
if de.xform <= 0 || depth > MAX_DEPTH {
return Ok(Xf::ID);
@@ -437,13 +482,15 @@ impl<'a> Tr<'a> {
for (col, x) in r.iter_mut().enumerate() {
*x = self.n(t, 1 + row * 4 + col)?;
}
r[3] *= self.f.unit;
r[3] *= unit;
}
let parent = self.own_xf(t, depth + 1)?;
let parent = self.own_xf_in(t, depth + 1, unit)?;
Ok(parent.then(&Xf(m)))
}
fn pt(&self, seq: i64, i: usize) -> R<V3> {
let u = self.f.unit;
self.pt_in(seq, i, self.f.unit)
}
fn pt_in(&self, seq: i64, i: usize, u: f64) -> R<V3> {
Ok([self.n(seq, i)? * u, self.n(seq, i + 1)? * u, self.n(seq, i + 2)? * u])
}
/// A point entity (116) or direction (123), placed.
@@ -489,20 +536,8 @@ impl<'a> Tr<'a> {
Ok(vec![Seg::along(c, |t| add(a, mul(sub(b, a), t)))])
}
100 => {
let u = self.f.unit;
let zt = self.n(seq, 1)? * u;
let c = [self.n(seq, 2)? * u, self.n(seq, 3)? * u, zt];
let s = [self.n(seq, 4)? * u, self.n(seq, 5)? * u, zt];
let e = [self.n(seq, 6)? * u, self.n(seq, 7)? * u, zt];
let rad = norm(sub(s, c));
if !(rad > 0.0) {
return Err(format!("entity {seq}: arc of zero radius"));
}
let a0 = (s[1] - c[1]).atan2(s[0] - c[0]);
let mut a1 = (e[1] - c[1]).atan2(e[0] - c[0]);
while a1 <= a0 + 1e-12 {
a1 += std::f64::consts::TAU;
}
let Arc { c, rad, a0, a1 } = self.arc(seq)?;
let zt = c[2];
let at = |t: f64| x.point([c[0] + rad * t.cos(), c[1] + rad * t.sin(), zt]);
let z = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate arc frame")?;
let xa = unit(x.dir([1.0, 0.0, 0.0])).ok_or("degenerate arc frame")?;
@@ -571,7 +606,60 @@ impl<'a> Tr<'a> {
}
}
/// A circular arc (100) in its definition plane, in millimetres.
fn arc(&self, seq: i64) -> R<Arc> {
let u = self.f.unit;
let zt = self.n(seq, 1)? * u;
let c = [self.n(seq, 2)? * u, self.n(seq, 3)? * u, zt];
let s = [self.n(seq, 4)? * u, self.n(seq, 5)? * u, zt];
let e = [self.n(seq, 6)? * u, self.n(seq, 7)? * u, zt];
let rad = norm(sub(s, c));
if !(rad > 0.0) {
return Err(format!("entity {seq}: arc of zero radius"));
}
let a0 = (s[1] - c[1]).atan2(s[0] - c[0]);
let mut a1 = (e[1] - c[1]).atan2(e[0] - c[0]);
while a1 <= a0 + 1e-12 {
a1 += std::f64::consts::TAU;
}
Ok(Arc { c, rad, a0, a1 })
}
/// A surface of revolution (120) whose generatrix is the circular arc `seq`, about the axis
/// through `o` along `d`. A STEP surface of revolution turns the whole of its curve, and the
/// whole circle turns into a whole sphere or torus (a sphere twice over when the arc is
/// centred on the axis): the face's trim cannot pick its piece out of that, and the face
/// meshed as the full ball. An arc in a plane through the axis is a sphere or a torus; any
/// other arc turns as an exact rational B-spline of just the arc.
fn revolved_arc(&mut self, seq: i64, xf: &Xf, o: V3, d: V3) -> R<u64> {
let x = xf.then(&self.own_xf(seq, 0)?);
let arc = self.arc(seq)?;
let n = unit(x.dir([0.0, 0.0, 1.0])).ok_or("degenerate arc frame")?;
let (centre, rad) = (x.point(arc.c), arc.rad * x.scale());
let off = sub(centre, o);
let h = dot(off, d);
let rho = norm(sub(off, mul(d, h)));
let tol = self.tol.max(rad * 1e-9);
if dot(n, d).abs() < 1e-7 && dot(off, n).abs() <= tol {
let ax = self.o.placement(add(o, mul(d, h)), d, perp(d));
if rho <= tol {
return Ok(self.o.add(format!("SPHERICAL_SURFACE('',#{ax},{})", r(rad))));
}
if rad < rho {
return Ok(self.o.add(format!("TOROIDAL_SURFACE('',#{ax},{},{})", r(rho), r(rad))));
}
}
let nb = arc.nurbs(&x);
let curve = self.write_nurbs_curve(&nb);
let (pa, dd) = (self.o.point(o), self.o.dir(d));
let a1 = self.o.add(format!("AXIS1_PLACEMENT('',#{pa},#{dd})"));
Ok(self.o.add(format!("SURFACE_OF_REVOLUTION('',#{curve},#{a1})")))
}
fn nurbs_curve(&self, seq: i64, x: &Xf) -> R<Nurbs> {
self.nurbs_curve_in(seq, x, self.f.unit)
}
fn nurbs_curve_in(&self, seq: i64, x: &Xf, unit: f64) -> R<Nurbs> {
let k = self.i(seq, 1)?;
let m = self.i(seq, 2)?;
if !(0..=MAX_LIST as i64).contains(&k) || !(1..=32).contains(&m) || m > k.max(1) + 1 {
@@ -591,7 +679,7 @@ impl<'a> Tr<'a> {
if !(w > 0.0) {
return Err(format!("entity {seq}: non-positive weight"));
}
let p = x.point(self.pt(seq, base_p + 3 * j)?);
let p = x.point(self.pt_in(seq, base_p + 3 * j, unit)?);
pts.push([p[0] * w, p[1] * w, p[2] * w, w]);
}
if knots.windows(2).any(|w| w[1] < w[0]) {
@@ -784,6 +872,9 @@ impl<'a> Tr<'a> {
let ax_x = x.then(&self.own_xf(axis, 0)?);
let (a, b) = (ax_x.point(self.pt(axis, 1)?), ax_x.point(self.pt(axis, 4)?));
let d = unit(sub(b, a)).ok_or("zero-length revolution axis")?;
if self.de(generatrix)?.typ == 100 {
return self.revolved_arc(generatrix, &x, a, d);
}
let segs = self.curve(generatrix, &x, 1)?;
let [seg] = segs.as_slice() else { return Err(format!("entity {seq}: composite generatrix is not read")) };
let (pa, dd) = (self.o.point(a), self.o.dir(d));
@@ -806,6 +897,61 @@ impl<'a> Tr<'a> {
}
}
/// The start point of a curve in a surface's parameter space (the B curve of a curve on a
/// surface, 142): its (u, v) are the first two coordinates. Lines, arcs, B-splines and
/// composites of them, as the specification lists for parameter-space curves.
fn param_start(&self, seq: i64, depth: usize) -> R<V3> {
if depth > MAX_DEPTH {
return Err("parameter-space curves nested too deeply".into());
}
let de = self.de(seq)?;
let x = self.own_xf_in(seq, 0, 1.0)?;
let p = match de.typ {
110 => self.pt_in(seq, 1, 1.0)?,
100 => [self.n(seq, 4)?, self.n(seq, 5)?, self.n(seq, 1)?],
126 => {
let nb = self.nurbs_curve_in(seq, &Xf::ID, 1.0)?;
nb.eval(nb.range().0).ok_or_else(|| format!("entity {seq}: B-spline cannot be evaluated"))?
}
102 if self.i(seq, 1)? > 0 => self.param_start(self.i(seq, 2)?, depth + 1)?,
t => return Err(format!("parameter-space curve type {t} is not read")),
};
Ok(x.point(p))
}
/// The point of a B-spline surface (128) at parameters (u, v), placed as `surface` places it.
fn surface_point(&self, seq: i64, xf: &Xf, u: f64, v: f64) -> R<V3> {
if self.de(seq)?.typ != 128 {
return Err(format!("entity {seq}: only B-spline surfaces are evaluated"));
}
let x = xf.then(&self.own_xf(seq, 0)?);
let (k1, k2, m1, m2) = (self.i(seq, 1)?, self.i(seq, 2)?, self.i(seq, 3)?, self.i(seq, 4)?);
if !(0..=4096).contains(&k1) || !(0..=4096).contains(&k2) || !(1..=32).contains(&m1) || !(1..=32).contains(&m2) {
return Err(format!("entity {seq}: bad B-spline surface size"));
}
let (k1, k2, m1, m2) = (k1 as usize, k2 as usize, m1 as usize, m2 as usize);
let (n1, n2) = (k1 + m1 + 2, k2 + m2 + 2);
let s_knots: Vec<f64> = (0..n1).map(|j| self.n(seq, 10 + j)).collect::<R<_>>()?;
let t_knots: Vec<f64> = (0..n2).map(|j| self.n(seq, 10 + n1 + j)).collect::<R<_>>()?;
let base_w = 10 + n1 + n2;
let base_p = base_w + (k1 + 1) * (k2 + 1);
// Each row of the net at u, then the column of those points at v (homogeneous, so the
// weights combine as the rational tensor product does).
let mut column = Vec::with_capacity(k2 + 1);
for j in 0..=k2 {
let mut row = Vec::with_capacity(k1 + 1);
for i in 0..=k1 {
let idx = j * (k1 + 1) + i;
let w = self.n(seq, base_w + idx)?;
let p = x.point(self.pt(seq, base_p + 3 * idx)?);
row.push([p[0] * w, p[1] * w, p[2] * w, w]);
}
let h = Nurbs { deg: m1, knots: s_knots.clone(), pts: row }.eval_h(u).ok_or("surface cannot be evaluated")?;
column.push(h);
}
Nurbs { deg: m2, knots: t_knots, pts: column }.eval(v).ok_or_else(|| "surface cannot be evaluated".into())
}
/// The natural boundary of a B-spline surface (its four edge curves), for trimmed surfaces
/// with no outer curve.
fn natural_boundary(&mut self, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
@@ -1197,10 +1343,10 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
let n1 = t.i(seq, 2)?;
let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize;
let mut loops = Vec::new();
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf)? };
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf, surf_seq)? };
loops.push(loop_of(t, sw, outer));
for k in 0..n2 {
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf)?;
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf, surf_seq)?;
loops.push(loop_of(t, sw, segs));
}
loops.retain(|l| !l.is_empty());
@@ -1234,8 +1380,16 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
}
}
/// The model-space curve of a curve on a surface (142).
fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
/// The model-space curve of a curve on a surface (142) bounding a trimmed `surface`, its pieces
/// head to tail.
///
/// A curve on a surface is one curve given twice: B in the surface's parameter space and C in
/// model space, both running the same way (C = S∘B). A composite C must run each piece on from
/// the end of the one before, but some exporters write its pieces in order with some of them
/// reversed, so the loop does not close and the face is lost. The pieces are turned to run on,
/// starting where S(B) starts (the end of the first piece there), so the loop also keeps the
/// sense the file gives it (outer loop and holes the opposite ways round).
fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf, surface: i64) -> R<Vec<Seg>> {
let de = t.de(seq)?;
if de.typ != 142 {
return t.curve(seq, xf, 0);
@@ -1245,7 +1399,27 @@ fn cos_curve(t: &mut Tr, seq: i64, xf: &Xf) -> R<Vec<Seg>> {
if c <= 0 {
return Err("a curve on a surface without its model-space curve".into());
}
t.curve(c, &x, 0)
let segs = t.curve(c, &x, 0)?;
let b = t.i(seq, 3).unwrap_or(0);
let start = if b > 0 { t.param_start(b, 0).and_then(|p| t.surface_point(surface, xf, p[0], p[1])).ok() } else { None };
Ok(head_to_tail(segs, start, t.tol))
}
/// Pieces of a composite curve turned so that each starts where the one before ends, the first
/// at the end nearer `start` (when known). A closed piece (its ends within `tol`, the sewing
/// tolerance that decides they are one vertex) keeps its direction.
fn head_to_tail(segs: Vec<Seg>, start: Option<V3>, tol: f64) -> Vec<Seg> {
let mut prev = start;
segs.into_iter()
.map(|s| {
let s = match prev {
Some(p) if norm(sub(s.a, s.b)) > tol && norm(sub(s.b, p)) < norm(sub(s.a, p)) => s.reversed(),
_ => s,
};
prev = Some(s.b);
s
})
.collect()
}
/// The model-space curves of a boundary (141), each in its sense.
+136 -3
View File
@@ -85,10 +85,16 @@ fn hostile_iges_never_panics() {
/// An IGES file from (type, form, parameters) entities, in order (pointers are 2k + 1).
fn iges_file(ents: &[(i64, i64, String)]) -> String {
let placed: Vec<(i64, i64, String, i64)> = ents.iter().map(|(t, f, p)| (*t, *f, p.clone(), 0)).collect();
iges_file_placed(&placed)
}
/// Like [`iges_file`], with each entity's transformation matrix pointer (0 for none).
fn iges_file_placed(ents: &[(i64, i64, String, i64)]) -> String {
let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)];
let mut p_lines: Vec<(usize, String)> = Vec::new();
let mut starts = Vec::new();
for (k, (t, _, p)) in ents.iter().enumerate() {
for (k, (t, _, p, _)) in ents.iter().enumerate() {
starts.push(p_lines.len() + 1);
let text = format!("{t},{p};");
let chars: Vec<char> = text.chars().collect();
@@ -96,9 +102,9 @@ fn iges_file(ents: &[(i64, i64, String)]) -> String {
p_lines.push((2 * k + 1, c.iter().collect()));
}
}
for (k, (t, form, _)) in ents.iter().enumerate() {
for (k, (t, form, _, xf)) in ents.iter().enumerate() {
let count = p_lines.iter().filter(|(d, _)| *d == 2 * k + 1).count();
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, xf, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, 0, 0, count, form, 0, 0, "", 0, 2 * k + 2));
}
for (i, (d, l)) in p_lines.iter().enumerate() {
@@ -108,6 +114,48 @@ fn iges_file(ents: &[(i64, i64, String)]) -> String {
out.join("\n")
}
/// A spherical cap written the way analytic IGES exports write one: a surface of revolution
/// (120) whose generatrix is a short circular arc (100) centred on the axis, turned into the
/// meridian plane by its transformation matrix (124), trimmed by a circle round the axis. The arc
/// is only the part of the circle the cap needs; read as the whole circle, the revolution was a
/// whole sphere twice over and the cap meshed as the full ball.
#[test]
fn an_arc_revolved_about_its_centre_is_a_cap_not_a_ball() {
let (r, polar) = (50.0f64, 30f64.to_radians());
let (rim_r, rim_z) = (r * polar.sin(), r * polar.cos());
let ents: Vec<(i64, i64, String, i64)> = vec![
// 1: the meridian plane: local x stays x, local y becomes z.
(124, 0, "1.,0.,0.,0.,0.,0.,-1.,0.,0.,1.,0.,0.".into(), 0),
// 3: the axis, 5: the arc from the rim (60° in its plane) to the pole (90°).
(110, 0, "0.,0.,0.,0.,0.,1.".into(), 0),
(100, 0, format!("0.,0.,0.,{:?},{:?},0.,{r:?}", rim_r, rim_z), 1),
// 7: the surface, 9: the rim (a full circle at the cap's height), 11: on the surface.
(120, 0, format!("3,5,0.,{:?}", std::f64::consts::TAU), 0),
(100, 0, format!("{rim_z:?},0.,0.,{rim_r:?},0.,{rim_r:?},0."), 0),
(142, 0, "0,7,0,9,2".into(), 0),
// 13: the cap.
(144, 0, "7,1,0,11".into(), 0),
];
let imp = iges_import(&iges_file_placed(&ents)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let mesh = imp.bodies[0].body.tessellate(0.01).unwrap();
let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);
for p in &mesh.positions {
for (k, x) in [p.x, p.y, p.z].into_iter().enumerate() {
lo[k] = lo[k].min(x);
hi[k] = hi[k].max(x);
}
}
for k in 0..2 {
assert!((lo[k] + rim_r).abs() < 0.1 && (hi[k] - rim_r).abs() < 0.1, "cap is {lo:?} .. {hi:?}");
}
assert!((lo[2] - rim_z).abs() < 0.1 && (hi[2] - r).abs() < 0.1, "cap is {lo:?} .. {hi:?}");
// Area of a cap: 2π r h.
let area = measure(&imp.bodies[0].body).unwrap().area;
let cap = std::f64::consts::TAU * r * (r - rim_z);
assert!(rel(area, cap) < 1e-3, "area {area} vs {cap}");
}
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
/// boundaries are lines: sewn into a closed solid.
#[test]
@@ -209,3 +257,88 @@ fn directory_entries_cannot_share_their_way_past_the_file_size() {
assert!(error.contains("parameter data is too large"), "{error}");
assert!(t.elapsed() < std::time::Duration::from_secs(10), "took {:?}", t.elapsed());
}
/// A curve on a surface (142) for a trimmed patch: model-space lines through `corners` with the
/// pieces `reversed` written end to start (as some exporters write composite trim curves), and
/// the same loop in the patch's parameter space through `uv`. Returns the 142's pointer.
fn trim_loop(ents: &mut Vec<(i64, i64, String)>, surface: usize, corners: &[[f64; 3]], uv: &[[f64; 2]], reversed: &[usize]) -> usize {
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
let mut model = Vec::new();
let mut param = Vec::new();
for k in 0..corners.len() {
let (p0, p1) = (corners[k], corners[(k + 1) % corners.len()]);
let (p0, p1) = if reversed.contains(&k) { (p1, p0) } else { (p0, p1) };
ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1))));
model.push(2 * ents.len() - 1);
let (q0, q1) = (uv[k], uv[(k + 1) % uv.len()]);
ents.push((110, 0, format!("{},{}", fmt([q0[0], q0[1], 0.0]), fmt([q1[0], q1[1], 0.0]))));
param.push(2 * ents.len() - 1);
}
let list = |v: &[usize]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(",");
ents.push((102, 0, format!("{},{}", model.len(), list(&model))));
let c = 2 * ents.len() - 1;
ents.push((102, 0, format!("{},{}", param.len(), list(&param))));
let b = 2 * ents.len() - 1;
// Created by projection, preferring the parameter-space curve (PREF 1).
ents.push((142, 0, format!("0,{surface},{b},{c},1")));
2 * ents.len() - 1
}
/// A bilinear B-spline patch through corners `a` (u, v = 0), `b` (u = 1), `c` (u, v = 1) and
/// `d` (v = 1).
fn bilinear_patch(a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) -> (i64, i64, String) {
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
(128, 0, format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(c)))
}
/// The box of `trimmed_surfaces_sew_into_a_solid` with some pieces of every trim curve written
/// end to start (the first piece on half the faces): each face's loop still closes and the box
/// is a solid.
#[test]
fn trim_curves_with_reversed_pieces_still_close_their_faces() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut ents: Vec<(i64, i64, String)> = Vec::new();
for (fi, q) in quads.iter().enumerate() {
let corners = q.map(c);
ents.push(bilinear_patch(corners[0], corners[1], corners[2], corners[3]));
let surface = 2 * ents.len() - 1;
let reversed: &[usize] = if fi % 2 == 0 { &[0, 2] } else { &[1, 3] };
let cos = trim_loop(&mut ents, surface, &corners, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], reversed);
ents.push((144, 0, format!("{surface},1,0,{cos}")));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
/// A plate with a square hole whose trim curve starts with a reversed piece: where the
/// parameter-space curve starts decides which way the hole runs, so it stays a hole.
#[test]
fn a_hole_whose_first_trim_piece_is_reversed_stays_a_hole() {
let mut ents: Vec<(i64, i64, String)> = vec![bilinear_patch([0.0; 3], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0])];
let outer = trim_loop(
&mut ents,
1,
&[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]],
&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
&[],
);
// Clockwise about the patch's normal, as a hole runs.
let hole = trim_loop(
&mut ents,
1,
&[[3.0, 3.0, 0.0], [3.0, 7.0, 0.0], [7.0, 7.0, 0.0], [7.0, 3.0, 0.0]],
&[[0.3, 0.3], [0.3, 0.7], [0.7, 0.7], [0.7, 0.3]],
&[0],
);
ents.push((144, 0, format!("1,1,1,{outer},{hole}")));
let imp = iges_import(&iges_file(&ents)).unwrap();
assert_eq!(imp.bodies.len(), 1);
assert!(!imp.warnings.iter().any(|w| w.contains("not read") || w.contains("face #")), "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.area, 84.0) < 1e-6, "area {}", m.area);
}
+3 -2
View File
@@ -164,7 +164,7 @@ pub fn with_symbol_fallback(mut fonts: egui::FontDefinitions) -> egui::FontDefin
/// UI languages whose text needs the CJK font. The web app's bundled font covers Japanese only;
/// add Chinese or Korean here together with a font that covers them.
pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "ko"];
pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "zh-TW", "ko"];
/// Whether the UI language needs a CJK font (the web app fetches one only then).
pub fn needs_cjk_font(language: &str) -> bool {
@@ -648,7 +648,7 @@ fn find_system_cjk_font() -> Option<Vec<u8>> {
/// Thai comes last whatever the language: no other face draws it.
pub fn fallback_order(language: &str) -> [&'static str; 4] {
match language {
"zh" => [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
"zh" | "zh-TW" => [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
"ko" => [HANGUL_FALLBACK, CJK_FALLBACK, CHINESE_FALLBACK, THAI_FALLBACK],
_ => [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
}
@@ -991,6 +991,7 @@ mod tests {
#[test]
fn a_chinese_ui_puts_the_chinese_face_before_the_japanese_one() {
assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]);
assert_eq!(fallback_order("zh-TW"), fallback_order("zh"));
assert_eq!(fallback_order("ko"), [HANGUL_FALLBACK, CJK_FALLBACK, CHINESE_FALLBACK, THAI_FALLBACK]);
for lang in ["en", "ja", "de"] {
assert_eq!(
+31 -1
View File
@@ -425,8 +425,17 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
}
}
/// Is this recent design there to open: in memory, or something the host can bring back?
pub fn available(app: &SolveApp, path: &str) -> bool {
solidcraft_engine::io::vfs::exists(path) || app.services.can_restore.as_ref().is_some_and(|can| can(path))
}
pub fn open_recent(app: &mut SolveApp, path: &str) {
if !solidcraft_engine::io::vfs::exists(path) {
if let Some(restore) = app.services.restore_file.as_ref().filter(|_| available(app, path)) {
restore(path);
return;
}
app.set_status(format!("{path} is no longer there"), true);
return;
}
@@ -529,9 +538,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(p) = recent.get(i) else { return };
let path = std::path::Path::new(p);
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
let missing = !solidcraft_engine::io::vfs::exists(p);
let missing = !available(app, p);
let mut sub = if missing {
app.tr("home.missing").to_string()
} else if p.starts_with("/disk/") {
// The web build remembers files it saved on the computer.
crate::i18n::tr_label("On your computer").to_string()
} else if p.starts_with("/opfs/") {
// The web build keeps designs in the browser's own storage.
app.tr("home.in_this_browser").to_string()
@@ -695,6 +707,24 @@ mod tests {
assert!(sample_picture(samples.len()).is_none());
}
/// A recent design the host can bring back (the browser build's remembered files) is not
/// "missing": choosing it asks the host to restore it, and one the host doesn't know still is.
#[test]
fn a_recent_design_the_host_can_restore_is_asked_for_not_reported_missing() {
let mut h = crate::scenario::Harness::new();
let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
let sink = asked.clone();
h.app.services.can_restore = Some(Box::new(|p| p.starts_with("/disk/known/")));
h.app.services.restore_file = Some(Box::new(move |p| sink.borrow_mut().push(p.to_string())));
assert!(available(&h.app, "/disk/known/Bracket.solidcraft"));
assert!(!available(&h.app, "/disk/other/Bracket.solidcraft"));
open_recent(&mut h.app, "/disk/known/Bracket.solidcraft");
assert_eq!(*asked.borrow(), ["/disk/known/Bracket.solidcraft"]);
open_recent(&mut h.app, "/disk/other/Bracket.solidcraft");
assert_eq!(asked.borrow().len(), 1, "a file the host doesn't remember is not asked for");
assert!(h.app.status.as_ref().is_some_and(|(text, _, error)| *error && text.contains("no longer there")));
}
/// Rebuild the shipped pictures from the authoritative sample command scripts.
#[cfg(not(target_arch = "wasm32"))]
#[test]
+36
View File
@@ -38,18 +38,26 @@ static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!
static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/es.json")));
static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json")));
static GALICIAN: Catalog = Catalog::new("gl", "Galego", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/gl.json")));
static HUNGARIAN: Catalog = Catalog::new("hu", "Magyar", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/hu.json")));
static INDONESIAN: Catalog =
Catalog::new("id", "Bahasa Indonesia", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/id.json")));
static ITALIAN: Catalog = Catalog::new("it", "Italiano", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/it.json")));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json")));
static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ko.json")));
static MALAY: Catalog = Catalog::new("ms", "Bahasa Melayu", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ms.json")));
static DUTCH: Catalog = Catalog::new("nl", "Nederlands", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/nl.json")));
static POLISH: Catalog = Catalog::new("pl", "Polski", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/pl.json")));
static PORTUGUESE_BR: Catalog =
Catalog::new("pt-BR", "Português (Brasil)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/pt-BR.json")));
static ROMANIAN: Catalog = Catalog::new("ro", "Română", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ro.json")));
static RUSSIAN: Catalog = Catalog::new("ru", "Русский", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ru.json")));
static SWEDISH: Catalog = Catalog::new("sv", "Svenska", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/sv.json")));
static TURKISH: Catalog = Catalog::new("tr", "Türkçe", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/tr.json")));
static UKRAINIAN: Catalog = Catalog::new("uk", "Українська", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/uk.json")));
static VIETNAMESE: Catalog = Catalog::new("vi", "Tiếng Việt", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/vi.json")));
static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/zh.json")));
static CHINESE_TRADITIONAL: Catalog =
Catalog::new("zh-TW", "繁體中文", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/zh-TW.json")));
/// English first (the fallback), then by language id, one per line so that adding a language
/// doesn't edit a line another addition edits.
#[rustfmt::skip]
@@ -61,17 +69,23 @@ pub const LOCALES: &[&Catalog] = &[
&SPANISH,
&FRENCH,
&GALICIAN,
&HUNGARIAN,
&INDONESIAN,
&ITALIAN,
&JAPANESE,
&KOREAN,
&MALAY,
&DUTCH,
&POLISH,
&PORTUGUESE_BR,
&ROMANIAN,
&RUSSIAN,
&SWEDISH,
&TURKISH,
&UKRAINIAN,
&VIETNAMESE,
&CHINESE,
&CHINESE_TRADITIONAL,
];
pub fn is_supported(id: &str) -> bool {
@@ -715,6 +729,28 @@ mod tests {
assert_eq!(vi.dynamic("Sketch of bracket"), "Sketch of bracket");
}
#[test]
fn new_languages_translate_keys_labels_and_default_names() {
for (id, name, preferences, extrude, sketch1, save) in [
("hu", "Magyar", "Beállítások", "Kihúzás", "Vázlat1", "Menti a(z) My {design} módosításait?"),
("id", "Bahasa Indonesia", "Preferensi", "Ekstrusi", "Sketsa1", "Simpan perubahan ke My {design}?"),
("nl", "Nederlands", "Voorkeuren", "Extruderen", "Schets1", "Wijzigingen in My {design} opslaan?"),
("ro", "Română", "Preferințe", "Extrudare", "Schița1", "Salvați modificările în My {design}?"),
("sv", "Svenska", "Inställningar", "Extrudera", "Skiss1", "Spara ändringar i My {design}?"),
("zh-TW", "繁體中文", "偏好設定", "擠出", "草圖1", "儲存對 My {design} 的變更?"),
] {
let locale = Locale::for_language(id);
assert_eq!(locale.name(), name);
assert!(is_supported(id));
assert_eq!(locale.text("ui.preferences"), preferences);
assert_eq!(locale.label("Extrude"), extrude);
assert_eq!(locale.label("NonExistentKey"), "NonExistentKey");
assert_eq!(locale.format("document.save_changes", &[("name", "My {design}")]), save);
assert_eq!(locale.dynamic("Sketch1"), sketch1);
assert_eq!(locale.dynamic("Sketch of bracket"), "Sketch of bracket");
}
}
#[test]
fn render_scopes_restore_the_previous_language() {
let _english = scope("en");
+6
View File
@@ -332,6 +332,12 @@ pub struct Services {
/// Hand a design's bytes to the user as a downloaded file (name, bytes); the browser build's
/// File > Download design. None: the host saves real files, so there is nothing to offer.
pub download_design: Option<Box<dyn Fn(&str, &[u8]) -> Result<(), String>>>,
/// Whether a recent design that is not in memory can be brought back by the host (the browser
/// build remembers files it saved to the disk); None: it can't.
pub can_restore: Option<Box<dyn Fn(&str) -> bool>>,
/// Bring such a design back and open it when it arrives (asks the user for access, so call it
/// in the click that chose it).
pub restore_file: Option<Box<dyn Fn(&str)>>,
/// Show the designs the browser keeps for itself; None: the host has no such store.
pub browser_files: Option<Box<dyn Fn()>>,
/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints); None: no API agents.
-64
View File
@@ -2173,67 +2173,3 @@ fn hovering_a_large_body_does_not_wait_for_its_pick_index() {
assert!(matches!(h.app.viewport.hover, Some(crate::viewport::Hit::Face { .. })), "{:?}", h.app.viewport.hover);
assert!(hover_ms < index_ms / 3.0, "the first hover took {hover_ms:.1} ms; making the index takes {index_ms:.1} ms");
}
/// A modelled thread is thousands of small faces but one surface to point at: the pointer over
/// it lands on a face (not on the vertices and edges between the thread's own faces), the face
/// lights up with the whole thread, and a click selects the thread as one item, which stands for
/// every one of its faces when it is shown and when a command takes faces. No hover waits for the
/// thread's faces to be worked out.
#[test]
fn a_modelled_thread_hovers_and_selects_as_one_surface() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.cylinder", json!({"diameter": 8, "height": 16, "base": [0, 0, 0], "axis": [0, 0, 1]})).unwrap();
h.app.run("solid.thread", json!({"face": [4, 0, 8], "designation": "M8", "length": 10, "modeled": true})).unwrap();
let st = h.app.session.world_state();
let b = &st.bodies[0];
let names = solidcraft_engine::doc::naming::face_names(b);
let thread = h.app.session.doc.features.iter().find(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::Thread { .. })).unwrap().id;
let made = names.iter().filter(|n| n.starts_with(&format!("F{thread}:"))).count();
assert!(made > 500, "{made} thread faces");
// Close in on the threaded part, side on.
h.app.cam.target = Vec3::new(0.0, 0.0, 5.0);
h.app.cam.distance = 30.0;
h.frames(3);
let mut slowest = 0.0f64;
let mut grouped = None;
for k in 0..40 {
let at = h.at(&json!({"world": [4.0 * (0.3 * k as f64).cos(), 4.0 * (0.3 * k as f64).sin(), 2.0 + (k % 6) as f64]})).unwrap();
let t = std::time::Instant::now();
h.step(&json!({"move": at})).unwrap();
slowest = slowest.max(t.elapsed().as_secs_f64() * 1000.0);
if let Some(crate::viewport::Hit::Face { body, index, point }) = h.app.viewport.hover.clone() {
let sel = Sel::Face { body, index, point };
let pieces = crate::viewport::grouped_selection(&h.app.session, &sel);
if pieces.len() >= made {
grouped = Some(sel);
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(slowest < 250.0, "a hover took {slowest:.0} ms");
let sel = grouped.expect("hovering the thread lands on its faces, and they are worked out as one surface");
// Over the thread, the pointer finds the surface, not the vertices and edges inside it.
let faces = (0..12)
.filter(|k| {
let a = 0.5 * *k as f64;
let at = h.at(&json!({"world": [4.0 * a.cos(), 4.0 * a.sin(), 3.0 + (*k % 4) as f64]})).unwrap();
h.step(&json!({"move": at})).unwrap();
matches!(h.app.viewport.hover, Some(crate::viewport::Hit::Face { .. }))
})
.count();
assert!(faces >= 9, "{faces} of 12 hovers over the thread landed on its surface");
// A click selects it as one item, which stands for every face of the thread.
let point = match &sel {
Sel::Face { point, .. } => *point,
_ => Vec3::ZERO,
};
let at = h.at(&json!({"world": [point.x, point.y, point.z]})).unwrap();
let t = std::time::Instant::now();
h.step(&json!({"click": at})).unwrap();
let click_ms = t.elapsed().as_secs_f64() * 1000.0;
assert_eq!(h.app.session.selection.len(), 1, "one item: {:?}", h.app.session.selection.len());
let pieces = crate::viewport::grouped_selection(&h.app.session, &h.app.session.selection[0]);
assert!(pieces.len() >= made, "the thread stands for {} faces of {made}", pieces.len());
assert!(click_ms < 250.0, "the click took {click_ms:.0} ms");
}
+1 -1
View File
@@ -503,7 +503,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
crate::ui_kit::Menu::button(ui, app.tr("ui.open_recent"), |ui| {
for path in app.home.recent.clone() {
let missing = !solidcraft_engine::io::vfs::exists(&path);
let missing = !crate::home::available(app, &path);
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
if crate::context_menu::button(ui, &label, "open").clicked() {
crate::home::open_recent(app, &path);
+30 -186
View File
@@ -1365,41 +1365,12 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
// The edges seen near the cursor, when the GPU pick buffer has them for this view (Select
// Other wants hidden ones too).
let seen = if all { None } else { crate::pick_buffer::edges_near(app, proj, pos, &st) };
// The face under the pointer wins over the vertices and edges inside it when they are not
// the boundary the pointer means: the edges between faces picked as one with it (a modelled
// thread's thousands of faces), and, when the face is small on screen (a finely faceted
// surface), any vertex or edge further than a couple of points away.
let inside = match bestf.as_ref().map(|(_, h)| h) {
Some(Hit::Face { body, index, .. }) if !all => st.body(body).map(|b| {
let m = b.mesh();
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&app.session.doc, b);
let group = groups.as_ref().and_then(|g| g.group_of(*index));
// Small on screen: only worth the look for a face picked alone.
let small = group.is_none() && {
let mut lo = egui::pos2(f32::INFINITY, f32::INFINITY);
let mut hi = egui::pos2(f32::NEG_INFINITY, f32::NEG_INFINITY);
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize == *index {
for p in m.tri(t).into_iter().flatten().filter_map(|p| proj.to_screen(p)) {
lo = lo.min(p);
hi = hi.max(p);
}
}
}
lo.x <= hi.x && (hi - lo).length() < 21.0
};
(body.clone(), groups.zip(group), small)
}),
_ => None,
};
let mut bestv = Near::new(all);
let mut beste = Near::new(all);
for (i, b) in st.bodies.iter().enumerate() {
if !app.session.body_visible(&b.name) {
continue;
}
let inside = inside.as_ref().filter(|(body, _, _)| *body == b.name);
let (vertex_px, edge_px) = if inside.is_some_and(|(_, _, small)| *small) { (2.0, 2.0) } else { (7.0, 6.0) };
// Only bodies whose outline on screen is near the cursor can have an edge under it.
if let Some((_, bb)) = bounds.get(i)
&& screen_box(proj, bb, 8.0).is_some_and(|r| !r.contains(pos))
@@ -1420,18 +1391,12 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
if !m.edge_pickable(ei) {
continue;
}
// Between two faces picked as one with the face under the pointer: not a boundary.
if let Some((_, Some((groups, group)), _)) = inside
&& m.edge_faces.get(ei).is_some_and(|fs| !fs.is_empty() && fs.iter().all(|f| groups.group_of(*f as usize) == Some(*group)))
{
continue;
}
if last_edge != Some(ei) {
last_edge = Some(ei);
for v in [e.first(), e.last()].into_iter().flatten() {
if let Some(sp) = proj.to_screen(*v) {
let dd = sp.distance(pos);
if dd < vertex_px && visible(*v) && bestv.wants(dd) {
if dd < 7.0 && visible(*v) && bestv.wants(dd) {
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
}
}
@@ -1442,7 +1407,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
let dd = seg_dist(pos, a, c);
// Visible where the cursor is: the edge's point under the cursor, not an end or
// the middle, which can lie deeper than the face the cursor is on.
if dd < edge_px && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) {
if dd < 6.0 && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) {
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) });
}
}
@@ -1867,7 +1832,6 @@ fn edge_of(m: &solidcraft_engine::geom::Mesh, index: usize, point: Vec3) -> Opti
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).filter(|e| near(e) < tol * 1e3)
}
#[cfg(test)]
fn face_tris(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: [u8; 4], lit: bool) {
mesh_tris(sc, m, col, lit, |f| f as usize == face);
}
@@ -1902,25 +1866,9 @@ fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f3
}
/// The outline of a highlighted face where the model hides it (dimmed).
/// Whether an edge (between `faces`) lies inside one thread's faces: no boundary to draw or pick.
fn inside_group(groups: Option<&solidcraft_engine::doc::face_groups::FaceGroups>, faces: &[u32]) -> bool {
let Some(groups) = groups else { return false };
let mut of = faces.iter().map(|f| groups.group_of(*f as usize));
match of.next() {
Some(Some(g)) => faces.len() > 1 && of.all(|x| x == Some(g)),
_ => false,
}
}
fn hidden_faces_outline(
sc: &mut GpuScene,
m: &solidcraft_engine::geom::Mesh,
faces: &std::collections::HashSet<usize>,
groups: Option<&solidcraft_engine::doc::face_groups::FaceGroups>,
col: Color32,
) {
fn hidden_face_outline(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: Color32) {
for (ei, e) in m.edges.iter().enumerate() {
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| faces.contains(&(*x as usize))) && !inside_group(groups, f));
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
if on && m.edge_pickable(ei) {
for p in e.windows(2) {
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
@@ -1988,32 +1936,8 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 {
let selected = app.highlighted();
app.session.revision.hash(&mut h);
(std::sync::Arc::as_ptr(&crate::open_meshing::shown_world(app)) as usize).hash(&mut h);
// A thread selected is thousands of faces: they are hashed by body and index, not written
// out every frame.
selected.len().hash(&mut h);
for item in &selected {
match item {
Sel::Face { body, index, .. } => (0u8, body, index).hash(&mut h),
other => serde_json::to_string(other).unwrap_or_default().hash(&mut h),
}
}
// A face hovered stands for the faces lit with it: moving over them (or within one) lights
// the same, and the highlight is not built again.
let group = match &app.viewport.hover {
Some(Hit::Face { body, index, .. }) => app
.session
.world_state()
.body(body)
.and_then(|b| solidcraft_engine::doc::face_groups::face_groups_soon(&app.session.doc, b))
.and_then(|g| g.of(*index))
.and_then(|g| g.first().copied())
.map(|first| (body.clone(), first)),
_ => None,
};
match group {
Some(group) => group.hash(&mut h),
None => format!("{:?}", app.viewport.hover).hash(&mut h),
}
serde_json::to_string(&selected).unwrap_or_default().hash(&mut h);
format!("{:?}", app.viewport.hover).hash(&mut h);
app.viewport.hover_feature.hash(&mut h);
app.viewport.hover_bodies.hash(&mut h);
// Which mesh lights a face follows how the model draws its body (see `shown_mesh`).
@@ -2069,15 +1993,9 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
// Faces lit over the model use the triangles the model shows them with.
let shown = |b: &solidcraft_engine::doc::ModelBody| shown_mesh(app, b, &active_bodies);
let mut sel = Vec::new();
// Faces by body and index: a selected thread stands for thousands of them.
let mut seen_faces: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
for item in app.highlighted() {
for piece in displayed_selection(app, &item) {
let fresh = match &piece {
Sel::Face { body, index, .. } => seen_faces.insert((body.clone(), *index)),
_ => !sel.iter().any(|x| crate::selection::same_item(x, &piece)),
};
if fresh {
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(piece);
}
}
@@ -2158,31 +2076,16 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
return sc;
}
let mut feedback = GpuScene::default();
// Selected faces, a pass per body however many of its faces are selected (a modelled
// thread is thousands).
let mut faces_of: Vec<(&str, std::collections::HashSet<usize>)> = Vec::new();
for x in &sel {
if let Sel::Face { body, index, .. } = x {
match faces_of.iter_mut().find(|(b, _)| *b == body.as_str()) {
Some((_, faces)) => {
faces.insert(*index);
}
None => faces_of.push((body.as_str(), std::collections::HashSet::from([*index]))),
}
}
}
for (body, faces) in &faces_of {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.effective_replaced().iter().any(|r| r == *body)) {
mesh_tris(&mut feedback, &shown(b), c4(t.sel_face), false, |f| faces.contains(&(f as usize)));
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b);
hidden_faces_outline(&mut feedback, &drawn_mesh(app, b), faces, groups.as_deref(), t.sel_edge);
}
}
// Selected things.
for x in &sel {
match x {
Sel::Face { .. } => {}
Sel::Face { body, index, .. } => {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.effective_replaced().contains(body)) {
face_tris(&mut feedback, &shown(b), *index, c4(t.sel_face), false);
hidden_face_outline(&mut feedback, &drawn_mesh(app, b), *index, t.sel_edge);
}
}
Sel::Body { name } => {
if let Some(b) = st.body(name)
&& app.session.body_visible(name)
@@ -2308,21 +2211,12 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
let k = t.hover_face_lift;
let c = body_rgb(app, body);
let m = drawn_mesh(app, b);
// The face with the faces picked as one with it (a modelled thread), in one pass.
let patch = face_patch(s, b, &m, *index);
let mut in_patch = vec![false; m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0).max(*index + 1)];
for &f in &patch {
if let Some(slot) = in_patch.get_mut(f) {
*slot = true;
}
}
let patch = m.surface_patch(*index);
let lit = shown(b);
let col = [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255];
mesh_tris(&mut feedback, &lit, col, true, |f| in_patch.get(f as usize).copied().unwrap_or(false));
// Its edges, but not those between two faces of one thread: no boundary there.
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b);
let lit_face = |f: &u32| in_patch.get(*f as usize).copied().unwrap_or(false);
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(lit_face) && !inside_group(groups.as_deref(), fs)) {
for &f in &patch {
face_tris(&mut feedback, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
if let Some(p) = m.edges.get(e)
&& m.edge_pickable(e)
{
@@ -3243,49 +3137,12 @@ pub(crate) fn picked_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec
{
return letters.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
}
selection_pieces(s, sel)
grouped_selection(s, sel)
}
/// Expand a visible rim or surface into the kernel pieces a command needs. The original
/// face pick keeps its point (used by tools such as Hole and construction geometry).
/// The faces picked as one with `face` of `b` (`m` its mesh): the pieces of its surface joined
/// across hidden seams, and every face of a modelled thread (or of one imported face) it is part
/// of. The thread's faces are worked out on a worker; until then the face goes with its surface.
fn face_patch(s: &solidcraft_engine::Session, b: &solidcraft_engine::doc::ModelBody, m: &solidcraft_engine::geom::Mesh, face: usize) -> Vec<usize> {
face_pieces(s, b, m, face, true)
}
/// [`face_patch`], with the faces of the thread it is part of when `groups`.
fn face_pieces(
s: &solidcraft_engine::Session,
b: &solidcraft_engine::doc::ModelBody,
m: &solidcraft_engine::geom::Mesh,
face: usize,
groups: bool,
) -> Vec<usize> {
let mut patch = m.surface_patch(face);
if !groups {
return patch;
}
if let Some(group) = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b).and_then(|g| g.of(face)) {
let mut seen: std::collections::HashSet<usize> = patch.iter().copied().collect();
patch.extend(group.iter().copied().filter(|f| seen.insert(*f)));
}
patch
}
pub(crate) fn grouped_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
pieces(s, sel, true)
}
/// What a click or a box puts in the selection for `sel`: as [`grouped_selection`], but a face
/// of a modelled thread stays one item (the thread's thousands of faces are what it stands for
/// when shown or given to a command).
pub(crate) fn selection_pieces(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
pieces(s, sel, false)
}
fn pieces(s: &solidcraft_engine::Session, sel: &Sel, groups: bool) -> Vec<Sel> {
let st = s.world_state();
match sel {
Sel::Edge { body, index, .. } => {
@@ -3301,28 +3158,21 @@ fn pieces(s: &solidcraft_engine::Session, sel: &Sel, groups: bool) -> Vec<Sel> {
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let mut patch = face_pieces(s, b, &m, *index, groups);
let mut patch = m.surface_patch(*index);
patch.sort_by_key(|i| usize::from(i != index));
// A point on each face, from one pass over the triangles.
let wanted: std::collections::HashSet<usize> = patch.iter().copied().collect();
let mut point: std::collections::HashMap<usize, Vec3> = std::collections::HashMap::new();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let f = *f as usize;
if f != *index
&& !point.contains_key(&f)
&& wanted.contains(&f)
&& let Some([a, b, c]) = m.tri(t)
{
point.insert(f, (a + b + c) / 3.0);
}
}
return patch
.into_iter()
.filter_map(|i| {
if i == *index {
return Some(sel.clone());
}
point.get(&i).map(|p| Sel::Face { body: body.clone(), index: i, point: *p })
m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
if *f as usize != i {
return None;
}
let [a, b, c] = m.tri(t)?;
Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 })
})
})
.collect();
}
@@ -3497,15 +3347,9 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
}
}
let mut grouped = Vec::new();
// Faces by body and index: a box over a thread takes thousands of them.
let mut faces: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
for item in out {
for piece in selection_pieces(&app.session, &item) {
let fresh = match &piece {
Sel::Face { body, index, .. } => faces.insert((body.clone(), *index)),
_ => !grouped.iter().any(|x| crate::selection::same_item(x, &piece)),
};
if fresh {
for piece in grouped_selection(&app.session, &item) {
if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) {
grouped.push(piece);
}
}
+19 -1
View File
@@ -7,6 +7,12 @@
# 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).
@@ -18,11 +24,13 @@ 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 ;;
-h | --help) sed -n '2,10p' "$0"; exit 0 ;;
--build-only) BUILD_ONLY="$2"; shift 2 ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
@@ -41,6 +49,16 @@ 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=()