Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eb3f8b53 | ||
|
|
db0a2a007a | ||
|
|
ed102b5393 | ||
|
|
970da30a99 | ||
|
|
113ded5fab | ||
|
|
b4491ac605 | ||
|
|
53b092d287 | ||
|
|
07e263d7e9 | ||
|
|
25ea43e89f | ||
|
|
c53aff7592 | ||
|
|
13aac4296d | ||
|
|
18d487a9bd | ||
|
|
ff9653a353 | ||
|
|
400f200efa | ||
|
|
16bf3ba593 |
No files matched your search
@@ -2,10 +2,9 @@
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; trace macro (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
|
||||
Generated
+14
-13
@@ -3290,13 +3290,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3306,7 +3307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3315,7 +3316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3330,7 +3331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3346,7 +3347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3354,7 +3355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3366,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3381,7 +3382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3389,7 +3390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3399,7 +3400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3410,7 +3411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3425,7 +3426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4848,7 +4849,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server. Part of the storytold "craft" family.
|
||||
model through the built-in MCP server.
|
||||
|
||||

|
||||
|
||||
|
||||
+25
@@ -67,6 +67,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
@@ -289,7 +292,29 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
|
||||
on a local case assembly; shape measurements and topology match the original build.
|
||||
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
@@ -21,6 +21,7 @@ rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -6,11 +6,14 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_ui_egui::SolveApp;
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
@@ -18,6 +21,172 @@ struct Axes {
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct AxisRule {
|
||||
output: usize,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct DriverMapping {
|
||||
axes: [AxisRule; 6],
|
||||
overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
rotation: [f64; 3],
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ProfileAxis {
|
||||
input: Option<usize>,
|
||||
output: Option<usize>,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ProfilePart {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
fn axis_index(name: &str) -> Option<usize> {
|
||||
match name.trim() {
|
||||
"HIDMultiAxis_X" => Some(0),
|
||||
"HIDMultiAxis_Y" => Some(1),
|
||||
"HIDMultiAxis_Z" => Some(2),
|
||||
"HIDMultiAxis_Rx" => Some(3),
|
||||
"HIDMultiAxis_Ry" => Some(4),
|
||||
"HIDMultiAxis_Rz" => Some(5),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
|
||||
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
|
||||
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
|
||||
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
|
||||
let mut reader = Reader::from_reader(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut mapping = DriverMapping::default();
|
||||
let mut axis: Option<ProfileAxis> = None;
|
||||
let mut part = None;
|
||||
let mut field = Vec::new();
|
||||
let mut found = false;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf).ok()? {
|
||||
Event::Start(start) => match start.local_name().as_ref() {
|
||||
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
|
||||
b"Input" => part = Some(ProfilePart::Input),
|
||||
b"Output" => part = Some(ProfilePart::Output),
|
||||
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
let value = String::from_utf8_lossy(text.as_ref());
|
||||
match field.as_slice() {
|
||||
b"ActionID" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
match part {
|
||||
Some(ProfilePart::Input) => current.input = axis_index(&value),
|
||||
Some(ProfilePart::Output) => current.output = axis_index(&value),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Enabled" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
current.enabled = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Reversed" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.reversed = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Scale" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
b"OverallScale" => {
|
||||
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
|
||||
mapping.overall_scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::End(end) => match end.local_name().as_ref() {
|
||||
b"Axis" => {
|
||||
if let Some(current) = axis.take()
|
||||
&& let (Some(input), Some(output)) = (current.input, current.output)
|
||||
{
|
||||
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
b"Input" | b"Output" => part = None,
|
||||
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
found.then_some(mapping)
|
||||
}
|
||||
|
||||
fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
|
||||
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
|
||||
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
|
||||
return None;
|
||||
}
|
||||
let xml = std::fs::read(&profile).ok()?;
|
||||
parse_driver_mapping(&xml)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
axes: Axes,
|
||||
@@ -107,6 +276,8 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -118,13 +289,21 @@ impl SpaceMouse {
|
||||
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
Self { shared, last_frame: now }
|
||||
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let now = Instant::now();
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_default();
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
if !app.preferences.space_mouse_enabled || app.home.open {
|
||||
return;
|
||||
}
|
||||
@@ -141,7 +320,14 @@ impl SpaceMouse {
|
||||
}
|
||||
app.begin_external_navigation();
|
||||
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
|
||||
navigate(&mut app.cam, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
|
||||
navigate(
|
||||
&mut app.cam,
|
||||
self.driver_mapping.apply(axes),
|
||||
seconds * speed.clamp(0.1, 5.0),
|
||||
height,
|
||||
app.session.active_sketch.is_some(),
|
||||
app.preferences.zoom_reverse,
|
||||
);
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
@@ -153,15 +339,21 @@ fn normalized(value: i16) -> f64 {
|
||||
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
|
||||
}
|
||||
|
||||
fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: bool) {
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = axes.translation.map(normalized);
|
||||
let [rx, ry, rz] = axes.rotation.map(normalized);
|
||||
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
|
||||
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile above can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
|
||||
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
|
||||
// navigation mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
@@ -171,6 +363,10 @@ fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: boo
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
@@ -192,15 +388,102 @@ mod tests {
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
let before = cam;
|
||||
navigate(&mut cam, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, false);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
|
||||
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
|
||||
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let before = Camera::default();
|
||||
|
||||
let mut physical_roll = before;
|
||||
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(physical_roll.roll, before.roll);
|
||||
|
||||
let mut physical_spin = before;
|
||||
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(physical_spin.yaw, before.yaw);
|
||||
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
|
||||
}
|
||||
}
|
||||
@@ -457,3 +457,124 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
let mut doc = Document::new("public stepped tab case");
|
||||
doc.set_param("length", "40", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
for (name, kind) in [
|
||||
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
|
||||
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
|
||||
] {
|
||||
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
|
||||
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
|
||||
}
|
||||
doc.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let tool = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(
|
||||
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
|
||||
)
|
||||
.unwrap(),
|
||||
Some("Pocket tool"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
|
||||
let cut = doc
|
||||
.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
|
||||
Some("Pocket"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
|
||||
let before = model.state_before(cut);
|
||||
// A real per-face paint control preserves the former slab construction;
|
||||
// no historical names or geometry are fabricated.
|
||||
let old_stock = before
|
||||
.body("Case")
|
||||
.unwrap()
|
||||
.body
|
||||
.clone()
|
||||
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(cut).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let at = Vec3::new(0., 15., 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
|
||||
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
|
||||
let mut old_state = crate::ModelState::default();
|
||||
old_state.bodies.push(old_body);
|
||||
let plane = solvecraft_geom::Plane::YZ;
|
||||
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
|
||||
let current_state = model.state();
|
||||
let current = current_state.body("Case").unwrap();
|
||||
let stem = crate::naming::strip_piece(&former);
|
||||
let current_names = crate::naming::face_names(current);
|
||||
let merged = current_names.iter().position(|name| name == stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
|
||||
assert!(!current_names.contains(&former));
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
|
||||
sketch.links[0].face_name = Some(former);
|
||||
let original = sketch.clone();
|
||||
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
|
||||
assert_eq!(captured.len(), 4);
|
||||
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
|
||||
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
|
||||
let material = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Saved band stock"),
|
||||
)
|
||||
.unwrap();
|
||||
for (length, wall) in [(40., 2.), (50., 3.)] {
|
||||
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let reopened = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&reopened);
|
||||
for current in [&model, &cold] {
|
||||
let result = current.results.iter().find(|r| r.id == sk).unwrap();
|
||||
assert!(result.error.is_none(), "{result:?}");
|
||||
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
|
||||
let state = current.state();
|
||||
let saved = state.sketch(sk).unwrap();
|
||||
assert!(saved.sketch.links[0].lost);
|
||||
assert_eq!(saved.sketch.points, original.points);
|
||||
assert_eq!(saved.sketch.curves, original.curves);
|
||||
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
|
||||
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
|
||||
solvecraft_kernel::measure(&stock.body).unwrap().volume,
|
||||
stock.mesh().bounds(),
|
||||
original.points
|
||||
);
|
||||
let case = state.body("Case").unwrap();
|
||||
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
|
||||
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
-3
@@ -3436,9 +3436,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
let name = doc.world_body_name(st, &b.name, comp, &path);
|
||||
// A body moved in from another component keeps its place (its offset).
|
||||
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
|
||||
let body =
|
||||
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
|
||||
let mut placed = ModelBody::new(name, body, b.feature);
|
||||
// Identical geometry also shares its display mesh cell with the definition.
|
||||
// A changed placement keeps a distinct mesh, at that placement's coordinates.
|
||||
let mut placed = if crate::is_identity(&bm) {
|
||||
let mut placed = b.clone();
|
||||
placed.name = name;
|
||||
placed
|
||||
} else {
|
||||
let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone());
|
||||
ModelBody::new(name, body, b.feature)
|
||||
};
|
||||
// World states keep their previous display-only evaluation-key semantics.
|
||||
placed.key = 0;
|
||||
placed.source_name = Some(b.definition_name().to_string());
|
||||
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
|
||||
placed.component = comp;
|
||||
@@ -3481,6 +3490,87 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_placement_cache_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn placed_real_feature_retains_names_from_its_original_feature_context() {
|
||||
let mut doc = Document::new("authored names");
|
||||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||||
let feature = doc
|
||||
.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: crate::point_expr(Vec3::ZERO),
|
||||
length: "2 mm".into(),
|
||||
width: "3 mm".into(),
|
||||
height: "4 mm".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
Some("AuthoredBox"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(feature).unwrap().component = component;
|
||||
let matrix = crate::rigid(Vec3::new(15., -2., 8.), Vec3::ZERO, Vec3::X, 0.7);
|
||||
doc.occurrences.iter_mut().find(|o| o.component == component).unwrap().transform = matrix;
|
||||
let mut model = Model::default();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let source = &state.bodies[0];
|
||||
assert!(source.names.origin.is_some());
|
||||
assert!(source.mesh.get().is_none());
|
||||
let world = world_state(&doc, &state);
|
||||
assert!(source.mesh.get().is_some(), "source naming is prepared before adoption, as before");
|
||||
let names = crate::naming::face_names(&world.bodies[0]);
|
||||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(source)));
|
||||
assert!(names.iter().all(|name| name.starts_with(&format!("F{feature}:"))));
|
||||
let local = source.mesh();
|
||||
let placed = world.bodies[0].mesh();
|
||||
assert_eq!(local.tri_face, placed.tri_face);
|
||||
let mapped: Vec<_> = local.positions.iter().map(|p| crate::apply_point(&matrix, *p)).collect();
|
||||
assert!(mapped.iter().all(|p| placed.positions.iter().any(|q| p.dist(*q) < 1e-8)));
|
||||
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
|
||||
let mut doc = Document::new("placements");
|
||||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||||
doc.body_components.insert("Block".into(), component);
|
||||
let matrix = crate::rigid(Vec3::new(10., 20., 0.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2);
|
||||
let second = doc.add_occurrence(component, 0, matrix).unwrap();
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1));
|
||||
state.bodies[0].key = 1234;
|
||||
let source = &state.bodies[0];
|
||||
let world = world_state(&doc, &state);
|
||||
assert_eq!(world.bodies.len(), 2);
|
||||
let first = world.body("Block").unwrap();
|
||||
let second_name = doc.world_body_name(&state, "Block", component, &[second]);
|
||||
let placed = world.body(&second_name).unwrap();
|
||||
assert!(source.mesh.get().is_some(), "persistent source names stay prepared before adoption");
|
||||
assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell");
|
||||
assert_eq!(first.key, 0);
|
||||
assert_eq!(placed.key, 0);
|
||||
assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates");
|
||||
assert_eq!(first.source_name.as_deref(), Some("Block"));
|
||||
assert_eq!(placed.source_name.as_deref(), Some("Block"));
|
||||
assert_eq!(placed.source_transform, matrix);
|
||||
assert_eq!(doc.model_body_component(placed), component);
|
||||
let bounds = placed.mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
|
||||
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
|
||||
let names = crate::naming::face_names(source);
|
||||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(first)));
|
||||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(placed)));
|
||||
let original_mesh = source.mesh();
|
||||
assert!(Arc::ptr_eq(&original_mesh, &first.mesh()));
|
||||
let again = world_state(&doc, &state);
|
||||
assert!(Arc::ptr_eq(&original_mesh, &again.body("Block").unwrap().mesh()));
|
||||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(again.body(&second_name).unwrap())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod chain_tests {
|
||||
use super::*;
|
||||
|
||||
+37
-12
@@ -150,6 +150,28 @@ pub struct Preview {
|
||||
pub after: Arc<ModelState>,
|
||||
}
|
||||
|
||||
/// Prepared world geometry depends on design/model identity and placement context, not
|
||||
/// selection or the view revision. Retained Arcs prevent pointer reuse after a design closes.
|
||||
#[derive(Clone)]
|
||||
struct WorldCache {
|
||||
doc: Arc<Document>,
|
||||
model: Arc<solvecraft_doc::ModelState>,
|
||||
pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
active_component: u64,
|
||||
active_occurrence_path: Option<Vec<u64>>,
|
||||
world: Arc<solvecraft_doc::ModelState>,
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
fn matches(&self, session: &Session, model: &Arc<solvecraft_doc::ModelState>) -> bool {
|
||||
Arc::ptr_eq(&self.doc, &session.doc)
|
||||
&& Arc::ptr_eq(&self.model, model)
|
||||
&& self.pending_moves == session.pending_moves
|
||||
&& self.active_component == session.active_component
|
||||
&& self.active_occurrence_path == session.active_occurrence_path
|
||||
}
|
||||
}
|
||||
|
||||
/// An editing session on one design.
|
||||
pub struct Session {
|
||||
pub doc: Arc<Document>,
|
||||
@@ -173,7 +195,7 @@ pub struct Session {
|
||||
pub active_occurrence_path: Option<Vec<u64>>,
|
||||
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
|
||||
world_cache: std::sync::Mutex<Option<WorldCache>>,
|
||||
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
|
||||
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
|
||||
pub clipboard: Vec<solvecraft_doc::Feature>,
|
||||
@@ -308,10 +330,10 @@ impl Session {
|
||||
return st;
|
||||
}
|
||||
if let Ok(c) = self.world_cache.lock()
|
||||
&& let Some((rev, w)) = c.as_ref()
|
||||
&& *rev == self.revision
|
||||
&& let Some(cached) = c.as_ref()
|
||||
&& cached.matches(self, &st)
|
||||
{
|
||||
return w.clone();
|
||||
return cached.world.clone();
|
||||
}
|
||||
let mut w = if self.pending_moves.is_empty() {
|
||||
solvecraft_doc::world_state(&self.doc, &st)
|
||||
@@ -350,7 +372,14 @@ impl Session {
|
||||
}
|
||||
let w = Arc::new(w);
|
||||
if let Ok(mut c) = self.world_cache.lock() {
|
||||
*c = Some((self.revision, w.clone()));
|
||||
*c = Some(WorldCache {
|
||||
doc: self.doc.clone(),
|
||||
model: st,
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
active_component: self.active_component,
|
||||
active_occurrence_path: self.active_occurrence_path.clone(),
|
||||
world: w.clone(),
|
||||
});
|
||||
}
|
||||
w
|
||||
}
|
||||
@@ -503,7 +532,7 @@ impl Session {
|
||||
active_component: self.active_component,
|
||||
active_occurrence_path: self.active_occurrence_path.clone(),
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
world_cache: Default::default(),
|
||||
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
|
||||
occurrence_visibility_cache: Default::default(),
|
||||
clipboard: self.clipboard.clone(),
|
||||
section: None,
|
||||
@@ -519,12 +548,6 @@ impl Session {
|
||||
pub fn invalidate_view(&mut self, previous_revision: u64) {
|
||||
let old = self.revision;
|
||||
self.revision = old.max(previous_revision) + 1;
|
||||
if let Ok(mut cache) = self.world_cache.lock()
|
||||
&& let Some((revision, _)) = cache.as_mut()
|
||||
&& *revision == old
|
||||
{
|
||||
*revision = self.revision;
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit commands evaluated on a scratch copy without losing this session's file,
|
||||
@@ -651,3 +674,5 @@ mod split_face_tests;
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod thread_tests;
|
||||
#[cfg(test)]
|
||||
mod world_placement_cache_tests;
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Placement-cache changes must retain physical picking, pending positions and named joints.
|
||||
use crate::{Session, geom::Vec3};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joint_frames() {
|
||||
let mut session = Session::default();
|
||||
let created = run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
run(&mut session, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
|
||||
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false}));
|
||||
let pending = session.pending_moves.clone();
|
||||
let path = vec![second];
|
||||
let placement = session.occurrence_placement(&path).unwrap();
|
||||
let name = placement.bodies[0].world.clone();
|
||||
let world = session.world_state();
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
let source_state = session.model.state();
|
||||
let source = source_state.body("Block").unwrap();
|
||||
let placed = world.body(&name).unwrap();
|
||||
assert_eq!(placed.source_name.as_deref(), Some("Block"));
|
||||
assert_eq!(placed.component, component);
|
||||
let bounds = placed.mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
|
||||
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
|
||||
assert_eq!(session.doc.model_body_look(placed), session.doc.model_body_look(source));
|
||||
let source_names = crate::doc::naming::face_names(source);
|
||||
assert_eq!(source_names, crate::doc::naming::face_names(placed));
|
||||
let pick = Vec3::new(8.5, 21., 4.);
|
||||
let top = placed.body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap().index;
|
||||
run(&mut session, "select.set", json!({"items":[{"type":"face","body":name,"index":top,"point":[pick.x,pick.y,pick.z]}]}));
|
||||
assert!(matches!(&session.selection[0], crate::Sel::Face { body, index, .. } if body == &name && *index == top));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()), "selection changes must not discard prepared placements");
|
||||
let scratch = session.scratch();
|
||||
assert_eq!(scratch.pending_moves, pending);
|
||||
let scratch_world = scratch.world_state();
|
||||
assert!(Arc::ptr_eq(&world, &scratch_world));
|
||||
let scratch_body = scratch_world.body(&name).unwrap();
|
||||
assert!(scratch_body.mesh().bounds().min.dist(bounds.min) < 1e-9);
|
||||
assert_eq!(crate::doc::naming::face_names(scratch_body), source_names);
|
||||
run(&mut session, "component.capture_position", json!({}));
|
||||
run(&mut session, "joint.origin", json!({"name":"Top joint","occurrence":second,"face":[pick.x,pick.y,pick.z]}));
|
||||
let joint = session.doc.assembly.origins[0].origin.clone();
|
||||
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
|
||||
let before_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
|
||||
assert!(crate::doc::apply_point(&before_joint, Vec3::ZERO).dist(pick) < 1e-9);
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":true}));
|
||||
let moved = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&world, &moved), "a changed transform must invalidate placed meshes");
|
||||
assert!(moved.body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
|
||||
assert!(world.body(&name).unwrap().mesh().bounds().min.dist(bounds.min) < 1e-9, "prior snapshots must remain immutable");
|
||||
assert_eq!(crate::doc::naming::face_names(moved.body(&name).unwrap()), source_names);
|
||||
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
|
||||
let after_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
|
||||
assert!(crate::doc::apply_point(&after_joint, Vec3::ZERO).dist(pick + Vec3::new(5., 0., 0.)) < 1e-9);
|
||||
let reopened = Session::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
assert!(reopened.world_state().body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
|
||||
assert_eq!(reopened.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_visibility_section_and_view_revisions_reuse_world_geometry() {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let world = session.world_state();
|
||||
let revision = session.revision;
|
||||
run(&mut session, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
|
||||
assert!(session.revision > revision);
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "select.clear", json!({}));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "browser.visibility", json!({"items":["Block"],"visible":false}));
|
||||
assert!(!session.body_visible("Block"));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "inspect.section", json!({"plane":{"origin":[0,0,2],"normal":[0,0,1]}}));
|
||||
assert!(session.section.is_some());
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
session.invalidate_view(session.revision + 10);
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
assert!(Arc::ptr_eq(&world, &session.scratch().world_state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_cache_requires_exact_design_model_pending_and_author_context() {
|
||||
let mut session = Session::default();
|
||||
let created = run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let sketch = run(&mut session, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
let initial = session.world_state();
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"capture":false}));
|
||||
let pending = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&initial, &pending));
|
||||
let name = session.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
|
||||
assert!(pending.body(&name).unwrap().mesh().bounds().min.dist(Vec3::new(10., 20., 0.)) < 1e-9);
|
||||
run(&mut session, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
let authored = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&pending, &authored));
|
||||
assert!(authored.sketch(sketch).unwrap().plane.origin.dist(Vec3::new(10., 20., 0.)) < 1e-9);
|
||||
let mut scratch = session.scratch();
|
||||
assert!(Arc::ptr_eq(&authored, &scratch.world_state()));
|
||||
scratch.active_occurrence_path = Some(vec![first]);
|
||||
let scratch_world = scratch.world_state();
|
||||
assert!(!Arc::ptr_eq(&authored, &scratch_world));
|
||||
assert!(scratch_world.sketch(sketch).unwrap().plane.origin.dist(Vec3::ZERO) < 1e-9);
|
||||
assert!(Arc::ptr_eq(&authored, &session.world_state()), "scratch context must not replace the source cache");
|
||||
run(&mut session, "appearance.assign", json!({"bodies":["Block"],"color":"#ff0000"}));
|
||||
let painted = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&authored, &painted));
|
||||
assert_ne!(session.doc.model_body_look(painted.body("Block").unwrap()), None);
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
let undone = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&painted, &undone));
|
||||
run(&mut session, "edit.redo", json!({}));
|
||||
let redone = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&undone, &redone));
|
||||
assert!(session.doc.model_body_look(redone.body("Block").unwrap()).is_some());
|
||||
let before_edit = session.world_state();
|
||||
run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Other","operation":"new"}));
|
||||
let edited = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&before_edit, &edited));
|
||||
assert!(edited.body("Other").is_some());
|
||||
session.doc = Arc::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
let replaced = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&edited, &replaced), "equal contents in a different document cannot share the cache key");
|
||||
session.model = crate::doc::Model::default();
|
||||
session.model.evaluate(&session.doc);
|
||||
assert!(!Arc::ptr_eq(&replaced, &session.world_state()), "a new evaluated model must invalidate even when the document Arc is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let source = session.world_state();
|
||||
let source_json = session.doc.to_json();
|
||||
let mut scratch = session.scratch();
|
||||
run(&mut scratch, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Draft","operation":"new"}));
|
||||
let warmed = scratch.world_state();
|
||||
assert!(!Arc::ptr_eq(&source, &warmed));
|
||||
assert_eq!(session.doc.to_json(), source_json);
|
||||
assert!(Arc::ptr_eq(&source, &session.world_state()));
|
||||
assert!(source.body("Draft").is_none());
|
||||
let feature = warmed.body("Draft").unwrap().feature;
|
||||
session.accept_computed(scratch);
|
||||
assert!(Arc::ptr_eq(&warmed, &session.world_state()), "adoption/view revision must retain the worker's prepared geometry");
|
||||
session.invalidate_view(session.revision + 20);
|
||||
assert!(Arc::ptr_eq(&warmed, &session.world_state()));
|
||||
run(&mut session, "timeline.rollback", json!({"position":1}));
|
||||
let rolled = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&warmed, &rolled));
|
||||
assert!(rolled.body("Block").is_some());
|
||||
assert!(rolled.body("Draft").is_none());
|
||||
run(&mut session, "timeline.rollback", json!({}));
|
||||
let restored = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&rolled, &restored));
|
||||
assert!(restored.body("Draft").is_some());
|
||||
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":true}));
|
||||
let suppressed = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&restored, &suppressed));
|
||||
assert!(suppressed.body("Draft").is_none());
|
||||
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false}));
|
||||
assert!(session.world_state().body("Draft").is_some());
|
||||
}
|
||||
@@ -244,8 +244,24 @@ impl Seg2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Parameter of the point of the segment closest to `p` (sampling, then Newton).
|
||||
/// Parameter of the closest point: exact projection for lines,
|
||||
/// sampling followed by Newton for the remaining curves.
|
||||
pub fn closest_param(&self, p: Vec2) -> f64 {
|
||||
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite();
|
||||
match *self {
|
||||
Seg2::Line { a, b } if finite(a) && finite(b) && finite(p) => {
|
||||
let d = b - a;
|
||||
if a == b {
|
||||
return 0.0;
|
||||
}
|
||||
let denominator = d.len2();
|
||||
let t = (p - a).dot(d) / denominator;
|
||||
if denominator > 0.0 && denominator.is_finite() && t.is_finite() {
|
||||
return t.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let n = 64;
|
||||
let mut t =
|
||||
(0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0);
|
||||
@@ -540,6 +556,76 @@ impl Region2 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn closest_line_projection_and_degeneracy() {
|
||||
let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) };
|
||||
for (p, t) in [(Vec2::new(-9.0, 20.0), 0.0), (Vec2::new(2.0, -5.0), 0.5), (Vec2::new(15.0, 4.0), 1.0)] {
|
||||
assert_eq!(line.closest_param(p), t);
|
||||
assert_eq!(line.reversed().closest_param(p), 1.0 - t);
|
||||
}
|
||||
let a = Vec2::new(1e100, -1e100);
|
||||
let b = a + Vec2::new(1e90, 2e90);
|
||||
let large = Seg2::Line { a, b };
|
||||
assert!((large.closest_param(a.lerp(b, 0.5)) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(Seg2::Line { a, b: a }.closest_param(Vec2::ZERO), 0.0);
|
||||
let tiny = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-12, 0.0) };
|
||||
assert_eq!(tiny.closest_param(Vec2::new(0.5e-12, 1.0)), 0.5);
|
||||
for line in [line, line.reversed(), tiny] {
|
||||
for k in 0..53 {
|
||||
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
|
||||
let closest = line.point_at(line.closest_param(p)).dist(p);
|
||||
let dense = (0..=8192).map(|i| line.point_at(i as f64 / 8192.0).dist(p)).fold(f64::INFINITY, f64::min);
|
||||
assert!(closest <= dense + 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sampled_closest(s: &Seg2, p: Vec2) -> f64 {
|
||||
let mut t = (0..=64).map(|i| i as f64 / 64.0).min_by(|x, y| s.point_at(*x).dist(p).total_cmp(&s.point_at(*y).dist(p))).unwrap_or(0.0);
|
||||
for _ in 0..30 {
|
||||
let (q, d) = (s.point_at(t), s.derivative(t));
|
||||
let (d2, h) = ((s.derivative((t + 1e-6).min(1.0)) - s.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p);
|
||||
let df = d.dot(d) + h.dot(d2);
|
||||
if df.abs() < 1e-300 {
|
||||
break;
|
||||
}
|
||||
let nt = (t - h.dot(d) / df).clamp(0.0, 1.0);
|
||||
if (nt - t).abs() < 1e-14 {
|
||||
t = nt;
|
||||
break;
|
||||
}
|
||||
t = nt;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_projection_preserves_clearance_tolerance_and_generic_fallback() {
|
||||
use std::f64::consts::TAU;
|
||||
let curves = [
|
||||
Seg2::Line { a: Vec2::new(-4.0, 7.0), b: Vec2::new(3.0, -2.0) },
|
||||
Seg2::Arc { center: Vec2::new(3.0, -2.0), radius: 6.0, start: 5.9, sweep: 1.3 },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.2, sweep: -TAU },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: TAU },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 0.0 },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1e-12 },
|
||||
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(2.0, 7.0), p2: Vec2::new(6.0, -3.0), p3: Vec2::new(8.0, 4.0) },
|
||||
];
|
||||
for (i, s) in curves.iter().enumerate() {
|
||||
for k in 0..101 {
|
||||
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
|
||||
let old = sampled_closest(s, p);
|
||||
let new = s.closest_param(p);
|
||||
if i > 0 {
|
||||
assert_eq!(new.to_bits(), old.to_bits());
|
||||
}
|
||||
assert!(s.point_at(new).dist(p) <= s.point_at(old).dist(p) + 1e-10);
|
||||
let on_curve = s.point_at(k as f64 / 100.0);
|
||||
assert!(s.point_at(s.closest_param(on_curve)).dist(on_curve) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangulated_area() {
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
|
||||
|
||||
+94
-31
@@ -33,6 +33,8 @@ pub struct Body {
|
||||
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
|
||||
/// Exact construction/import source identities, in this body's face order.
|
||||
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
|
||||
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
|
||||
pub(crate) regularization_protected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -120,6 +122,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
|
||||
/// `None` when nothing needs splitting or the parameters don't match the surface.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> {
|
||||
refined_with_cache(pm, surf, tol, true)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn refined_with_cache(pm: &PolygonMesh, surf: &mt::Surface, tol: f64, memoize: bool) -> Option<Vec<[(Vec3, Vec3); 3]>> {
|
||||
use mt::{ParametricSurface, ParametricSurface3D};
|
||||
let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals());
|
||||
if uvs.is_empty() {
|
||||
@@ -148,6 +155,15 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
|
||||
// Edges on the face's boundary are shared with the next face: never split them.
|
||||
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
|
||||
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
|
||||
// Only exact finite values identify reusable evaluations. In particular,
|
||||
// the rounded UV keys used for boundary incidence cannot identify a point
|
||||
// whose derivatives or midpoint may be reused.
|
||||
let vertex_key = |v: &V| {
|
||||
let values = [v.0.x, v.0.y, v.0.z, v.1.0, v.1.1, v.2.x, v.2.y, v.2.z];
|
||||
values.iter().all(|x| x.is_finite()).then(|| values.map(f64::to_bits))
|
||||
};
|
||||
let cache_limit = cap * 3;
|
||||
let mut singular_cache = std::collections::HashMap::<(u64, u64), bool>::new();
|
||||
let mut changed = false;
|
||||
for _ in 0..4 {
|
||||
let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new();
|
||||
@@ -156,34 +172,60 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
|
||||
*uses.entry(e).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let at = |a: &V, b: &V| -> Option<V> {
|
||||
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
|
||||
return None;
|
||||
let mut edge_cache = std::collections::HashMap::<([u64; 8], [u64; 8]), Option<V>>::new();
|
||||
let mut at = |a: &V, b: &V| -> Option<V> {
|
||||
let key = if memoize { vertex_key(a).zip(vertex_key(b)).map(|(a, b)| if a < b { (a, b) } else { (b, a) }) } else { None };
|
||||
if let Some(key) = key
|
||||
&& let Some(value) = edge_cache.get(&key)
|
||||
{
|
||||
return *value;
|
||||
}
|
||||
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
|
||||
let singular = |x: &V| {
|
||||
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
|
||||
let c = du.cross(dv);
|
||||
c.x * c.x + c.y * c.y + c.z * c.z
|
||||
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300
|
||||
let mut calculate = || -> Option<V> {
|
||||
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
|
||||
return None;
|
||||
}
|
||||
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
|
||||
let mut singular = |x: &V| {
|
||||
let key = (x.1.0.to_bits(), x.1.1.to_bits());
|
||||
let cacheable = memoize && x.1.0.is_finite() && x.1.1.is_finite();
|
||||
if cacheable && let Some(value) = singular_cache.get(&key) {
|
||||
return *value;
|
||||
}
|
||||
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
|
||||
let c = du.cross(dv);
|
||||
let value = c.x * c.x + c.y * c.y + c.z * c.z
|
||||
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300;
|
||||
if cacheable && singular_cache.len() < cache_limit {
|
||||
singular_cache.insert(key, value);
|
||||
}
|
||||
value
|
||||
};
|
||||
if singular(a) || singular(b) {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
|
||||
let q = from_p3(surf.subs(u, v));
|
||||
// Within tolerance, or so far off that the parameters wrapped (a seam).
|
||||
let sag = q.dist((a.0 + b.0) * 0.5);
|
||||
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
|
||||
return None;
|
||||
}
|
||||
let n = surf.normal(u, v);
|
||||
let mut n = Vec3::new(n.x, n.y, n.z);
|
||||
// Keep the mesh's normal side.
|
||||
if n.dot(a.2 + b.2) < 0.0 {
|
||||
n = -n;
|
||||
}
|
||||
Some((q, (u, v), n))
|
||||
};
|
||||
if singular(a) || singular(b) {
|
||||
return None;
|
||||
let value = calculate();
|
||||
if let Some(key) = key
|
||||
&& edge_cache.len() < cache_limit
|
||||
&& value.is_none_or(|v| vertex_key(&v).is_some())
|
||||
{
|
||||
edge_cache.insert(key, value);
|
||||
}
|
||||
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
|
||||
let q = from_p3(surf.subs(u, v));
|
||||
// Within tolerance, or so far off that the parameters wrapped (a seam).
|
||||
let sag = q.dist((a.0 + b.0) * 0.5);
|
||||
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
|
||||
return None;
|
||||
}
|
||||
let n = surf.normal(u, v);
|
||||
let mut n = Vec3::new(n.x, n.y, n.z);
|
||||
// Keep the mesh's normal side.
|
||||
if n.dot(a.2 + b.2) < 0.0 {
|
||||
n = -n;
|
||||
}
|
||||
Some((q, (u, v), n))
|
||||
value
|
||||
};
|
||||
let mut next = Vec::with_capacity(tris.len());
|
||||
let mut any = false;
|
||||
@@ -320,6 +362,7 @@ impl Body {
|
||||
&& self.paint == other.paint
|
||||
&& self.splits == other.splits
|
||||
&& self.face_sources == other.face_sources
|
||||
&& self.regularization_protected == other.regularization_protected
|
||||
}
|
||||
|
||||
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
||||
@@ -358,9 +401,15 @@ impl Body {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
|
||||
self.face_sources.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
@@ -588,6 +637,7 @@ impl Body {
|
||||
/// The same body with the lines its faces were split along on purpose.
|
||||
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
|
||||
self.splits = s.filter(|l| !l.is_empty());
|
||||
self.regularization_protected |= self.splits.is_some();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -612,6 +662,7 @@ impl Body {
|
||||
})
|
||||
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
|
||||
.map(std::sync::Arc::new);
|
||||
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -658,11 +709,15 @@ impl Body {
|
||||
let flip = !face.orientation();
|
||||
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
|
||||
// Curved faces: split triangles that cut through the surface.
|
||||
if refine
|
||||
&& let Some(sf) = solid_faces.get(fi)
|
||||
&& !matches!(sf.surface(), mt::Surface::Plane(_))
|
||||
&& let Some(tris) = refined(&pm, &sf.surface(), tol)
|
||||
{
|
||||
let refinement = if refine {
|
||||
solid_faces.get(fi).and_then(|sf| {
|
||||
let surface = sf.surface();
|
||||
if matches!(surface, mt::Surface::Plane(_)) { None } else { refined(&pm, &surface, tol) }
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(tris) = refinement {
|
||||
let fid = u32::try_from(fi).unwrap_or(u32::MAX);
|
||||
for t in tris {
|
||||
let mut idx = [0u32; 3];
|
||||
@@ -1019,7 +1074,7 @@ impl Body {
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)))
|
||||
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(self))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1029,3 +1084,11 @@ impl Body {
|
||||
#[cfg(test)]
|
||||
#[path = "body_lump_tests.rs"]
|
||||
mod lump_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "refine_cache_tests.rs"]
|
||||
mod refine_cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "regularization_protection_tests.rs"]
|
||||
mod regularization_protection_tests;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Regressions against the actual patched truck constructor, run by workspace CI.
|
||||
use truck_meshalgo::prelude::PolylineCurve;
|
||||
use truck_modeling::{BoundedCurve, ParametricCurve, Plane, Point3, Sphere};
|
||||
use truck_shapeops::IntersectionCurveWithParameters;
|
||||
|
||||
fn planes() -> (Plane, Plane) {
|
||||
let o = Point3::new(0.0, 0.0, 0.0);
|
||||
(Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)), Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 0.0, 1.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_intersection_polylines_return_none_without_panicking() {
|
||||
let origin = Point3::new(0.0, 0.0, 0.0);
|
||||
for points in [
|
||||
vec![],
|
||||
vec![origin],
|
||||
vec![origin, origin],
|
||||
vec![origin; 3],
|
||||
vec![origin, Point3::new(f64::NAN, 0.0, 0.0)],
|
||||
vec![origin, Point3::new(f64::INFINITY, 0.0, 0.0)],
|
||||
] {
|
||||
let (a, b) = planes();
|
||||
let result = std::panic::catch_unwind(|| IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(points.clone()), 0.01));
|
||||
assert!(result.is_ok(), "constructor panicked for {points:?}");
|
||||
assert!(result.unwrap().is_none(), "collapsed input must not create a curve: {points:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_intersection_retains_the_closure_and_spherical_supports() {
|
||||
let a = Sphere::new(Point3::new(0.0, 0.0, 1.0), 2.0_f64.sqrt());
|
||||
let b = Sphere::new(Point3::new(0.0, 0.0, -1.0), 2.0_f64.sqrt());
|
||||
let poly = PolylineCurve(vec![
|
||||
Point3::new(1.0, 0.0, 0.0),
|
||||
Point3::new(0.0, 1.0, 0.0),
|
||||
Point3::new(-1.0, 0.0, 0.0),
|
||||
Point3::new(0.0, -1.0, 0.0),
|
||||
Point3::new(1.0, 0.0, 0.0),
|
||||
]);
|
||||
let curve = IntersectionCurveWithParameters::try_new(a, b, poly, 0.01).unwrap();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
assert_eq!(curve.subs(lo), curve.subs(hi));
|
||||
for i in 0..=40 {
|
||||
let p = curve.subs(lo + (hi - lo) * i as f64 / 40.0);
|
||||
assert!(p.z.abs() < 1e-8, "{p:?}");
|
||||
assert!(((p.x * p.x + p.y * p.y).sqrt() - 1.0).abs() <= 0.01, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_intersection_keeps_endpoints_on_both_planes() {
|
||||
let (a, b) = planes();
|
||||
let start = Point3::new(1.0, 0.0, 0.0);
|
||||
let end = Point3::new(3.0, 0.0, 0.0);
|
||||
let curve = IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(vec![start, end]), 0.01).unwrap();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
assert_eq!(curve.subs(lo), start);
|
||||
assert_eq!(curve.subs(hi), end);
|
||||
assert_eq!(curve.subs((lo + hi) / 2.0), Point3::new(2.0, 0.0, 0.0));
|
||||
}
|
||||
@@ -30,6 +30,8 @@ mod heal;
|
||||
mod helix;
|
||||
mod iges_in;
|
||||
mod iges_out;
|
||||
#[cfg(test)]
|
||||
mod intersection_curve_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
|
||||
@@ -109,6 +109,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,6 +202,7 @@ impl Body {
|
||||
paint: self.paint.clone(),
|
||||
splits: self.splits.clone(),
|
||||
face_sources: None,
|
||||
regularization_protected: self.regularization_protected,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
)),
|
||||
};
|
||||
}
|
||||
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
|
||||
if matches!(op, BoolOp::Cut | BoolOp::Union) {
|
||||
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
|
||||
}
|
||||
@@ -538,7 +539,11 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
|
||||
_ => {
|
||||
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
|
||||
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
|
||||
Body::new(solid).map(Some)
|
||||
let protected = pieces.iter().any(|piece| piece.regularization_protected);
|
||||
Body::new(solid).map(|mut body| {
|
||||
body.regularization_protected = protected;
|
||||
Some(body)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,8 +563,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
);
|
||||
// An affine placement retains face order and appearance metadata.
|
||||
let sources = body.transformed_sources(m)?;
|
||||
Body::new(placed_topology(&body.solid, mat)?)
|
||||
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources))
|
||||
Body::new(placed_topology(&body.solid, mat)?).map(|placed| {
|
||||
placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,36 @@ fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &
|
||||
|
||||
impl Body {
|
||||
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
|
||||
self.regularize_planar_faces(false)
|
||||
}
|
||||
|
||||
/// Only newly produced Cut walls call this path. The temporary planes carry
|
||||
/// construction support, never persistent ancestor/source-role authority.
|
||||
pub(crate) fn regularize_generated_planar_faces(self) -> Result<Self> {
|
||||
let body = self.regularize_planar_faces(true)?;
|
||||
let sources = body.face_sources().map(|sources| {
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let mut source = source.clone();
|
||||
if let Some(super::FaceSource::ExtrudeSide { plane, coalesced, .. }) = &mut source {
|
||||
if plane.is_some() && !*coalesced {
|
||||
source = None;
|
||||
} else {
|
||||
*plane = None;
|
||||
}
|
||||
}
|
||||
source
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(body.with_face_sources(sources.map(Arc::new)))
|
||||
}
|
||||
|
||||
fn regularize_planar_faces(self, generated: bool) -> Result<Self> {
|
||||
if self.regularization_protected {
|
||||
return Ok(self);
|
||||
}
|
||||
let Some(sources) = self.face_sources() else { return Ok(self) };
|
||||
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
|
||||
return Ok(self);
|
||||
@@ -148,7 +178,16 @@ impl Body {
|
||||
.zip(sources)
|
||||
.map(|(f, s)| {
|
||||
s.as_ref()
|
||||
.and_then(super::planar_sources::certified_plane)
|
||||
.and_then(|source| {
|
||||
if generated {
|
||||
match source {
|
||||
super::FaceSource::ExtrudeSide { segment: solvecraft_geom::Seg2::Line { .. }, plane: Some(p), .. } => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
super::planar_sources::certified_plane(source)
|
||||
}
|
||||
})
|
||||
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
|
||||
})
|
||||
.collect();
|
||||
@@ -179,7 +218,7 @@ impl Body {
|
||||
};
|
||||
let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
|
||||
identity = left;
|
||||
if sa != sb {
|
||||
if !generated && sa != sb {
|
||||
continue;
|
||||
}
|
||||
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
|
||||
@@ -235,7 +274,14 @@ impl Body {
|
||||
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
|
||||
metadata = left;
|
||||
}
|
||||
output_sources.push(source.cloned());
|
||||
let mut source = source.cloned();
|
||||
if generated
|
||||
&& replacements.contains_key(&leader)
|
||||
&& let Some(super::FaceSource::ExtrudeSide { coalesced, .. }) = &mut source
|
||||
{
|
||||
*coalesced = true;
|
||||
}
|
||||
output_sources.push(source);
|
||||
offset += 1;
|
||||
}
|
||||
shells.push(mt::Shell::from(output));
|
||||
|
||||
@@ -114,13 +114,15 @@ impl Body {
|
||||
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
|
||||
/// supports without a competing coplanar tool. No imported or fitted role is inferred.
|
||||
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
|
||||
let self_ = self.inherit_regularization_protection(input).inherit_regularization_protection(tool);
|
||||
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
|
||||
return Ok(self);
|
||||
return Ok(self_);
|
||||
}
|
||||
self.map_planar_sources(input, None, Some(tool), &[], constructed)
|
||||
self_.map_planar_sources(input, None, Some(tool), &[], constructed)
|
||||
}
|
||||
|
||||
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
let Some(sources) = input.face_sources() else { return Ok(self) };
|
||||
if input.solid.face_iter().count() > 16_384
|
||||
|| self.solid.face_iter().count() > 16_384
|
||||
|
||||
@@ -342,7 +342,15 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
|
||||
/// by the thickness (open faces outward, past the body) keeping the body's topology, which
|
||||
/// gives the cavity; the shell is the body minus the cavity.
|
||||
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
|
||||
// Face paint and authored splits are construction boundaries. Retain their
|
||||
// eligibility veto through the temporary body used for the cavity Cut.
|
||||
// Paint indices stay in source order by avoiding healing in that case.
|
||||
let solid = if b.paint().is_some_and(|paint| !paint.faces.is_empty()) {
|
||||
b.deep_copy()
|
||||
} else {
|
||||
crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep())
|
||||
};
|
||||
let healed = Body::new(solid)?.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b);
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
// Faces to open: the face of the triangle nearest each point.
|
||||
|
||||
@@ -848,6 +848,16 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
}
|
||||
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||
let mut sources = Vec::new();
|
||||
let frame_is_orthonormal = plane.origin.is_finite()
|
||||
&& plane.x.is_finite()
|
||||
&& plane.y.is_finite()
|
||||
&& d.is_finite()
|
||||
&& (plane.x.len2() - 1.).abs() <= 64. * f64::EPSILON
|
||||
&& (plane.y.len2() - 1.).abs() <= 64. * f64::EPSILON
|
||||
&& (d.len2() - 1.).abs() <= 64. * f64::EPSILON
|
||||
&& plane.x.dot(plane.y).abs() <= 64. * f64::EPSILON
|
||||
&& plane.x.dot(d).abs() <= 64. * f64::EPSILON
|
||||
&& plane.y.dot(d).abs() <= 64. * f64::EPSILON;
|
||||
for (first, end, sg) in runs {
|
||||
let (z0, z1) = (levels[first], levels[end]);
|
||||
let (a, b) = (sg.start(), sg.end());
|
||||
@@ -868,7 +878,28 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
|
||||
// Axis-aligned generated lines have a common oriented support frame
|
||||
// independent of axial stations or along-line endpoint interpolation.
|
||||
let generated_plane = if coalesce
|
||||
&& frame_is_orthonormal
|
||||
&& let Seg2::Line { a, b } = sg
|
||||
{
|
||||
let (origin, x) = if a.y == b.y && a.x != b.x {
|
||||
(at(Vec2::new(0., a.y), 0.), plane.x * (b.x - a.x).signum())
|
||||
} else if a.x == b.x && a.y != b.y {
|
||||
(at(Vec2::new(a.x, 0.), 0.), plane.y * (b.y - a.y).signum())
|
||||
} else {
|
||||
(Vec3::ZERO, Vec3::ZERO)
|
||||
};
|
||||
(origin.is_finite() && x.len2() > 0.).then_some(Plane { origin, x, y: d })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
sources.push((generated_plane.is_some() || end > first + 1).then_some(crate::FaceSource::ExtrudeSide {
|
||||
segment: sg,
|
||||
plane: generated_plane,
|
||||
coalesced: end > first + 1,
|
||||
}));
|
||||
}
|
||||
for (z, up, rs) in &caps {
|
||||
for r in rs {
|
||||
@@ -885,7 +916,11 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
|
||||
sources.push(None);
|
||||
}
|
||||
}
|
||||
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
|
||||
Some(crate::sew::sew_touching_with_sources(&faces, &sources).and_then(
|
||||
|body| {
|
||||
if coalesce { body.regularize_generated_planar_faces() } else { Ok(body) }
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
@@ -1109,6 +1144,8 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
|
||||
&slabs,
|
||||
tol,
|
||||
op == BoolOp::Cut
|
||||
&& !a.regularization_protected
|
||||
&& !b.regularization_protected
|
||||
&& a.split_keep().is_empty()
|
||||
&& b.split_keep().is_empty()
|
||||
&& a.paint().is_none_or(|p| p.faces.is_empty())
|
||||
@@ -1216,6 +1253,62 @@ mod planar_tests {
|
||||
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
|
||||
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
|
||||
}
|
||||
fn stepped_tab_stock() -> Body {
|
||||
let stock = crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap();
|
||||
let tab = crate::box_solid(Vec3::new(-12., -25., 8.), Vec3::new(-4., -15., 16.)).unwrap();
|
||||
crate::boolean(&stock, &tab, BoolOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
fn coplanar_joins(body: &Body) -> usize {
|
||||
let faces: Vec<_> = body.solid.face_iter().collect();
|
||||
let mut adjacency = std::collections::HashMap::<_, Vec<usize>>::new();
|
||||
for (i, face) in faces.iter().enumerate() {
|
||||
for edge in face.edge_iter() {
|
||||
adjacency.entry(edge.id()).or_default().push(i);
|
||||
}
|
||||
}
|
||||
adjacency
|
||||
.values()
|
||||
.filter(|faces| {
|
||||
let [a, b] = faces.as_slice() else { return false };
|
||||
let (Some(pa), Some(pb)) = (body.planar_face(*a), body.planar_face(*b)) else { return false };
|
||||
let (na, nb) = (pa.normal(), pb.normal());
|
||||
na.dot(nb) > 1. - 1e-12 && (pb.origin - pa.origin).dot(na).abs() < 1e-10
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_of_joined_stepped_tabs_has_no_artificial_planar_stations() {
|
||||
let stock = stepped_tab_stock();
|
||||
let snapshot = crate::step_export(&[&stock], "unchanged stock").unwrap();
|
||||
assert_eq!(coplanar_joins(&stock), 0);
|
||||
let shell = crate::shell(&stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap();
|
||||
assert_eq!(coplanar_joins(&shell), 0, "Shell must not add internal edges across continuous planar walls");
|
||||
assert!(shell.validity().is_empty());
|
||||
let mesh = shell.tessellate(1e-5).unwrap();
|
||||
let expected = 40. * 30. * 24. + 8. * 10. * 8. - (38. * 28. * 23. + 6. * 10. * 6.);
|
||||
assert!((mesh.measure().volume - expected).abs() < 1e-6);
|
||||
for (point, material) in [
|
||||
(Vec3::new(0., 0., 0.5), true),
|
||||
(Vec3::new(0., 0., 10.), false),
|
||||
(Vec3::new(0., -14.5, 10.), true),
|
||||
(Vec3::new(19.5, 0., 10.), true),
|
||||
(Vec3::new(-8., -24.5, 12.), true),
|
||||
(Vec3::new(-8., -20., 12.), false),
|
||||
(Vec3::new(0., -18., 9.), false),
|
||||
] {
|
||||
assert_eq!(mesh.contains(point), material, "wrong material at {point:?}");
|
||||
}
|
||||
let step = crate::step_export(&[&shell], "stepped tab shell").unwrap();
|
||||
let reopened = crate::step_import(&step).unwrap();
|
||||
let body = &reopened.bodies[0].body;
|
||||
assert_eq!(body.face_count(), shell.face_count());
|
||||
assert_eq!(coplanar_joins(body), 0);
|
||||
assert!((body.tessellate(1e-5).unwrap().measure().volume - expected).abs() < 1e-6);
|
||||
assert_eq!(crate::step_export(&[&stock], "unchanged stock").unwrap(), snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
|
||||
for curved in [false, true] {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::*;
|
||||
|
||||
fn bits(v: Vec3) -> [u64; 3] {
|
||||
[v.x.to_bits(), v.y.to_bits(), v.z.to_bits()]
|
||||
}
|
||||
|
||||
fn check_body(body: &Body) -> usize {
|
||||
let meshes = mesh_shells(&body.solid, 0.5);
|
||||
let mut changed = 0;
|
||||
for (face, meshed) in body.solid.face_iter().zip(meshes.iter().flat_map(|shell| shell.face_iter())) {
|
||||
let Some(mesh) = meshed.surface() else { continue };
|
||||
let surface = face.surface();
|
||||
let cached = refined_with_cache(&mesh, &surface, 0.002, true);
|
||||
let original = refined_with_cache(&mesh, &surface, 0.002, false);
|
||||
match (cached, original) {
|
||||
(None, None) => {}
|
||||
(Some(cached), Some(original)) => {
|
||||
changed += 1;
|
||||
assert_eq!(cached.len(), original.len(), "triangle count/winding must not change");
|
||||
for (cached, original) in cached.into_iter().zip(original) {
|
||||
for ((point, normal), (old_point, old_normal)) in cached.into_iter().zip(original) {
|
||||
assert_eq!(bits(point), bits(old_point), "every refined coordinate must remain bit-identical");
|
||||
assert_eq!(bits(normal), bits(old_normal), "every refined normal must remain bit-identical");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => panic!("memoization changed the refine/no-refine decision"),
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_refinement_cache_preserves_periodic_seams_and_normals() {
|
||||
let cylinder = crate::cylinder(Vec3::ZERO, Vec3::Z, 15., 8.).unwrap();
|
||||
let torus = crate::torus(Vec3::ZERO, 18., 4.).unwrap();
|
||||
assert!(check_body(&cylinder) > 0, "the fixture must actually refine");
|
||||
assert!(check_body(&torus) > 0, "the periodic fixture must actually refine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_refinement_cache_preserves_singular_poles_and_placed_surfaces() {
|
||||
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
|
||||
assert!(check_body(&sphere) > 0, "the polar fixture must actually refine");
|
||||
let placed = crate::transform(&sphere, Vec3::new(120., -85., 40.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.371).unwrap();
|
||||
assert!(check_body(&placed) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_refinement_cache_keeps_unnecessary_planar_refinement_absent() {
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
|
||||
assert_eq!(check_body(&body), 0);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::*;
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
fn stamped_stock() -> Body {
|
||||
crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap().with_box_certificate(7).unwrap()
|
||||
}
|
||||
|
||||
fn cup(stock: &Body) -> Body {
|
||||
crate::shell(stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap().with_shell_certificate(9).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
|
||||
let stock = stamped_stock();
|
||||
let ordinary = cup(&stock);
|
||||
assert!(!ordinary.regularization_protected);
|
||||
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let side = stock
|
||||
.solid
|
||||
.face_iter()
|
||||
.enumerate()
|
||||
.find(|(i, _)| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.))
|
||||
.unwrap()
|
||||
.0;
|
||||
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
|
||||
assert!(split.split_lines().is_some());
|
||||
for protected in [painted, split] {
|
||||
let result = cup(&protected);
|
||||
assert!(result.regularization_protected);
|
||||
assert!(result.face_count() > ordinary.face_count(), "authored boundaries must survive both Shell regularizers");
|
||||
assert!(result.validity().is_empty());
|
||||
let expected = 40. * 30. * 24. - 38. * 28. * 23.;
|
||||
let mesh = result.tessellate(1e-5).unwrap();
|
||||
assert!((mesh.measure().volume - expected).abs() < 1e-6);
|
||||
assert!(mesh.contains(Vec3::new(19.5, 0., 12.)));
|
||||
assert!(!mesh.contains(Vec3::new(0., 0., 12.)));
|
||||
let count = result.face_count();
|
||||
assert_eq!(result.clone().regularize_certified_planar_faces().unwrap().face_count(), count);
|
||||
assert_eq!(result.clone().regularize_generated_planar_faces().unwrap().face_count(), count);
|
||||
let placed = crate::transform(&result, Vec3::new(30., -40., 7.), Vec3::ZERO, Vec3::Z, 0.3).unwrap();
|
||||
assert!(placed.regularization_protected);
|
||||
for lump in placed.lumps().unwrap() {
|
||||
assert!(lump.regularization_protected);
|
||||
}
|
||||
let cleared = result.clone().with_paint(None).with_split_lines(None);
|
||||
assert!(cleared.regularization_protected, "cleared appearance must not erase inherited boundary protection");
|
||||
assert_eq!(cleared.regularize_certified_planar_faces().unwrap().face_count(), count);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_is_identity_and_survives_tool_operand_and_compounds() {
|
||||
let a = stamped_stock();
|
||||
let protected = a.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1. }] }));
|
||||
let cleared = protected.with_paint(None);
|
||||
assert!(!a.same(&cleared));
|
||||
let tool = crate::box_solid(Vec3::new(-5., -5., 23.), Vec3::new(5., 5., 30.)).unwrap().inherit_regularization_protection(&cleared);
|
||||
let cut = crate::boolean(&a, &tool, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.regularization_protected);
|
||||
let remote = crate::box_solid(Vec3::new(100., 0., 0.), Vec3::new(110., 10., 10.)).unwrap();
|
||||
let joined = crate::ops::join_pieces(vec![cleared, remote]).unwrap().unwrap();
|
||||
assert!(joined.regularization_protected);
|
||||
assert_eq!(joined.lumps().unwrap().len(), 2);
|
||||
assert!(joined.lumps().unwrap().iter().all(|body| body.regularization_protected));
|
||||
}
|
||||
@@ -886,6 +886,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
paint: None,
|
||||
splits: body.splits.clone(),
|
||||
face_sources: None,
|
||||
regularization_protected: true,
|
||||
},
|
||||
imp,
|
||||
))
|
||||
|
||||
@@ -30,6 +30,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ fn start(app: &mut SolveApp) {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
|
||||
let profile = std::env::var_os("SOLVECRAFT_PROFILE_OPEN").is_some();
|
||||
let began = std::time::Instant::now();
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err("file operation cancelled".into());
|
||||
}
|
||||
@@ -113,16 +115,31 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can
|
||||
"file.insert_step"
|
||||
};
|
||||
let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
let geometry_ms = began.elapsed().as_secs_f64() * 1000.0;
|
||||
let view_began = std::time::Instant::now();
|
||||
phase.store(1, Ordering::Relaxed);
|
||||
let world = session.world_state();
|
||||
let placement_ms = view_began.elapsed().as_secs_f64() * 1000.0;
|
||||
let mesh_began = std::time::Instant::now();
|
||||
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
|
||||
for b in &session.world_state().bodies {
|
||||
for b in &world.bodies {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err("file operation cancelled".into());
|
||||
}
|
||||
let _ = b.mesh();
|
||||
}
|
||||
let tessellation_ms = mesh_began.elapsed().as_secs_f64() * 1000.0;
|
||||
phase.store(2, Ordering::Relaxed);
|
||||
let view_ms = view_began.elapsed().as_secs_f64() * 1000.0;
|
||||
let thumbnail_began = std::time::Instant::now();
|
||||
let image = if insert { None } else { crate::home::remember(&session) };
|
||||
if profile {
|
||||
eprintln!(
|
||||
"SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}",
|
||||
thumbnail_began.elapsed().as_secs_f64() * 1000.0,
|
||||
began.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
Ok(Made { session, result, image })
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ impl Catalog {
|
||||
|
||||
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
|
||||
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
|
||||
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE];
|
||||
static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!("locales/de.json"));
|
||||
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE, &GERMAN];
|
||||
|
||||
pub fn is_supported(id: &str) -> bool {
|
||||
LOCALES.iter().any(|catalog| catalog.id == id)
|
||||
|
||||
@@ -519,22 +519,6 @@ impl SolveApp {
|
||||
let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. }));
|
||||
return self.start(if edges { "solid.fillet" } else { "solid.extrude" });
|
||||
}
|
||||
// Fix/Unfix: the selected sketch points and curves change at once, otherwise pick them.
|
||||
if id == "sketch.constraint.fix" {
|
||||
let ents: Vec<String> = self
|
||||
.session
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !ents.is_empty() {
|
||||
let _ = self.run(id, json!({ "entities": ents }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Construction toggle: selected sketch curves switch at once, otherwise pick them.
|
||||
// Construction (X): convert the selection, or switch construction mode; no pick tool.
|
||||
if id == "sketch.construction" {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"browser.body_name": "Körper {number}",
|
||||
"browser.component_name": "Komponente {number}",
|
||||
"browser.parameters": "Parameter ({count})",
|
||||
"browser.precision": "Genauigkeit: {precision}",
|
||||
"browser.sketch_name": "Skizze {number}",
|
||||
"browser.units": "Einheiten: {units}",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
"dialog.selected_count": "↖ {count} ausgewählt",
|
||||
"document.save_changes": "Änderungen in {name} speichern?",
|
||||
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
|
||||
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
|
||||
"sketch.draw_curves": "{linetype}-Kurven zeichnen",
|
||||
"sketch.make_selection": "Auswahl als {linetype} markieren",
|
||||
"ui.1_degree_of_freedom": "1 Freiheitsgrad",
|
||||
"ui.3d_sketch": "3D-Skizze",
|
||||
"ui.about": "Über",
|
||||
"ui.activate": "Aktivieren",
|
||||
"ui.active_component": "Aktive Komponente",
|
||||
"ui.agent_cursor": "Agent-Cursor",
|
||||
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
|
||||
"ui.all": "Alle",
|
||||
"ui.angle": "Winkel",
|
||||
"ui.angle_decimals": "Winkel-Dezimalstellen",
|
||||
"ui.animate_view_changes": "Ansichtsänderungen animieren",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animationsgeschwindigkeit",
|
||||
"ui.apply": "Anwenden",
|
||||
"ui.arc": "Kreisbogen",
|
||||
"ui.assemble": "Zusammenbau",
|
||||
"ui.autosave_every": "Autospeichern alle",
|
||||
"ui.axes": "Achsen",
|
||||
"ui.axis": "Achse",
|
||||
"ui.bodies": "Körper",
|
||||
"ui.box": "Block",
|
||||
"ui.break": "Unterbrechen",
|
||||
"ui.cancel": "Abbrechen",
|
||||
"ui.canvases": "Kanvas",
|
||||
"ui.centerline": "Mittellinie",
|
||||
"ui.chamfer": "Abschrägung",
|
||||
"ui.change_workspace": "Arbeitsbereich wechseln",
|
||||
"ui.chord_length": "Sehnenlänge",
|
||||
"ui.circle": "Kreis",
|
||||
"ui.circular_pattern": "Radiales Muster",
|
||||
"ui.clear_recent": "Zuletzt verwendet leeren",
|
||||
"ui.clear_the_selection": "Auswahl aufheben",
|
||||
"ui.close": "Schließen",
|
||||
"ui.coil": "Schraube",
|
||||
"ui.coincident": "Koinzident",
|
||||
"ui.collapse": "Einklappen",
|
||||
"ui.collinear": "Kollinear",
|
||||
"ui.combine": "Kombinieren",
|
||||
"ui.coming_later": "bald verfügbar",
|
||||
"ui.concentric": "Konzentrisch",
|
||||
"ui.conic_curve": "Kegelschnitt",
|
||||
"ui.constant": "Konstant",
|
||||
"ui.constraints_conflict": "Bedingungen konfliktreich",
|
||||
"ui.construct": "Konstruktion",
|
||||
"ui.construction": "Konstruktionslinie",
|
||||
"ui.copy": "Kopieren",
|
||||
"ui.counterbore": "Tiefenbohrung",
|
||||
"ui.countersink": "Senkung",
|
||||
"ui.create": "Erstellen",
|
||||
"ui.create_sketch": "Skizze erstellen",
|
||||
"ui.current": "aktuell",
|
||||
"ui.curvature": "Krümmung",
|
||||
"ui.cut": "Ausschneiden",
|
||||
"ui.cylinder": "Zylinder",
|
||||
"ui.dark": "Dunkel",
|
||||
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
|
||||
"ui.degrees_of_freedom": "Freiheitsgrade",
|
||||
"ui.delete": "Löschen",
|
||||
"ui.depth": "Tiefe",
|
||||
"ui.design": "Design",
|
||||
"ui.diameter": "Durchmesser",
|
||||
"ui.dimension": "Maß",
|
||||
"ui.direction": "Richtung",
|
||||
"ui.distance": "Abstand",
|
||||
"ui.distance_1": "Abstand 1",
|
||||
"ui.distance_2": "Abstand 2",
|
||||
"ui.document_settings": "Dokumenteneinstellungen",
|
||||
"ui.don_t_save": "Nicht speichern",
|
||||
"ui.draft": "Abziehen",
|
||||
"ui.drawing": "Zeichnung",
|
||||
"ui.drill_point": "Bohrpunkt",
|
||||
"ui.edit": "Bearbeiten",
|
||||
"ui.ellipse": "Ellipse",
|
||||
"ui.end_radius": "Endradius",
|
||||
"ui.equal": "Gleich",
|
||||
"ui.expand": "Aufklappen",
|
||||
"ui.export_ellipsis": "Exportieren...",
|
||||
"ui.extend": "Verlängern",
|
||||
"ui.extent_type": "Ausdehnungstyp",
|
||||
"ui.extrude": "Extrudieren",
|
||||
"ui.features": "Funktionen",
|
||||
"ui.file": "Datei",
|
||||
"ui.fillet": "Vollrundung",
|
||||
"ui.finish_sketch": "Skizze beenden",
|
||||
"ui.fix_unfix": "Fixieren/Lösen",
|
||||
"ui.flat": "Flach",
|
||||
"ui.flip": "Umkehren",
|
||||
"ui.front": "Vorne",
|
||||
"ui.fully_constrained": "Vollständig bestimmt",
|
||||
"ui.general": "Allgemein",
|
||||
"ui.generative_design": "Generatives Design",
|
||||
"ui.graphics": "Grafik",
|
||||
"ui.heading_assemble": "ZUSAMMENBAU",
|
||||
"ui.heading_box": "BLOCK",
|
||||
"ui.heading_chamfer": "ABSCHRÄGUNG",
|
||||
"ui.heading_circular_pattern": "RADIALES MUSTER",
|
||||
"ui.heading_coil": "SCHRAUBE",
|
||||
"ui.heading_combine": "KOMBINIEREN",
|
||||
"ui.heading_construct": "KONSTRUKTION",
|
||||
"ui.heading_create": "ERSTELLEN",
|
||||
"ui.heading_create_sketch": "SKIZZE ERSTELLEN",
|
||||
"ui.heading_cylinder": "ZYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "ABZIEHEN",
|
||||
"ui.heading_edit": "BEARBEITEN",
|
||||
"ui.heading_extrude": "EXTRUDIEREN",
|
||||
"ui.heading_fillet": "VOLLRUNDUNG",
|
||||
"ui.heading_finish_sketch": "SKIZZE BEENDEN",
|
||||
"ui.heading_front": "VORNE",
|
||||
"ui.heading_hole": "BOHRUNG",
|
||||
"ui.heading_home": "START",
|
||||
"ui.heading_insert": "EINFÜGEN",
|
||||
"ui.heading_inspect": "PRÜFEN",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_measure": "Messen",
|
||||
"ui.heading_mesh": "MESH",
|
||||
"ui.heading_mirror": "SPIEGELN",
|
||||
"ui.heading_modify": "ÄNDERN",
|
||||
"ui.heading_move": "VERSCHIEBEN",
|
||||
"ui.heading_offset_plane": "ABSTANDSEBENE",
|
||||
"ui.heading_ok": "OK",
|
||||
"ui.heading_parameters": "PARAMETER",
|
||||
"ui.heading_pattern_on_path": "PFADMUSTER",
|
||||
"ui.heading_pipe": "ROHR",
|
||||
"ui.heading_plane_at_angle": "EBENE IM WINKEL",
|
||||
"ui.heading_plastic": "PLASTIK",
|
||||
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
|
||||
"ui.heading_revolve": "DREHEN",
|
||||
"ui.heading_right": "RECHTS",
|
||||
"ui.heading_scale": "SKALIEREN",
|
||||
"ui.heading_section_analysis": "QUERSCHNITTSANALYSE",
|
||||
"ui.heading_select": "AUSWÄHLEN",
|
||||
"ui.heading_sheet_metal": "BLECH",
|
||||
"ui.heading_shell": "HÜLLE",
|
||||
"ui.heading_sketch": "SKIZZE",
|
||||
"ui.heading_sketch_palette": "SKIZZENPALETTE",
|
||||
"ui.heading_solid": "KÖRPER",
|
||||
"ui.heading_sphere": "SPHÄRE",
|
||||
"ui.heading_split_body": "KÖRPER TRENNEN",
|
||||
"ui.heading_surface": "FLÄCHE",
|
||||
"ui.heading_sweep": "FÜHREN",
|
||||
"ui.heading_thread": "GEWINDE",
|
||||
"ui.heading_top": "OBEN",
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.help": "Hilfe",
|
||||
"ui.hide": "Ausblenden",
|
||||
"ui.hole": "Bohrung",
|
||||
"ui.hole_tap_type": "Gewindetyp",
|
||||
"ui.hole_type": "Bohrungstyp",
|
||||
"ui.home": "Start",
|
||||
"ui.horizontal_vertical": "Horizontal/Vertikal",
|
||||
"ui.insert": "Einfügen",
|
||||
"ui.inspect": "Prüfen",
|
||||
"ui.intersect": "Schnitt",
|
||||
"ui.join": "Verbinden",
|
||||
"ui.joints": "Gelenke",
|
||||
"ui.keyboard_shortcuts": "Tastenkürzel",
|
||||
"ui.language": "Sprache",
|
||||
"ui.length_decimals": "Längen-Dezimalstellen",
|
||||
"ui.light": "Hell",
|
||||
"ui.line": "Linie",
|
||||
"ui.linetype": "Linientyp",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Ansehen",
|
||||
"ui.manufacture": "Fertigung",
|
||||
"ui.measure": "Messen",
|
||||
"ui.midpoint": "Mittelpunkt",
|
||||
"ui.mirror": "Spiegeln",
|
||||
"ui.modify": "Ändern",
|
||||
"ui.move": "Verschieben",
|
||||
"ui.multiple": "Mehrfach",
|
||||
"ui.named_views": "Benannte Ansichten",
|
||||
"ui.navigation": "Navigation",
|
||||
"ui.new_body": "Neuer Körper",
|
||||
"ui.new_design": "Neues Design",
|
||||
"ui.normal": "Normal",
|
||||
"ui.object_type": "Objekttyp",
|
||||
"ui.objects": "Objekte",
|
||||
"ui.offer_to_stand_up_y_up_files": "Y-hoch-Dateien aufrecht anbieten",
|
||||
"ui.offset": "Abstand",
|
||||
"ui.offset_plane": "Abstandsebene",
|
||||
"ui.one_side": "Einseitig",
|
||||
"ui.open": "Öffnen",
|
||||
"ui.open_ellipsis": "Öffnen...",
|
||||
"ui.open_recent": "Zuletzt verwendet",
|
||||
"ui.operation": "Operation",
|
||||
"ui.options": "Optionen",
|
||||
"ui.orbit_around": "Umkreisen um",
|
||||
"ui.origin": "Ursprung",
|
||||
"ui.pan_zoom_orbit": "Verschieben, zoomen, umkreisen",
|
||||
"ui.parallel": "Parallel",
|
||||
"ui.parameters": "Parameter",
|
||||
"ui.paste": "Einfügen",
|
||||
"ui.path": "Pfad",
|
||||
"ui.pattern_on_path": "Pfadmuster",
|
||||
"ui.perpendicular": "Senkrecht",
|
||||
"ui.pipe": "Rohr",
|
||||
"ui.placement": "Platzierung",
|
||||
"ui.plane": "Ebene",
|
||||
"ui.plane_at_angle": "Ebene im Winkel",
|
||||
"ui.point": "Punkt",
|
||||
"ui.point_under_the_cursor": "Punkt unter dem Cursor",
|
||||
"ui.points": "Punkte",
|
||||
"ui.polygon": "Polygon",
|
||||
"ui.preferences": "Einstellungen",
|
||||
"ui.profile": "Profil",
|
||||
"ui.profile_plane": "Profilebene",
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.quit": "Beenden",
|
||||
"ui.radius": "Radius",
|
||||
"ui.radius_type": "Radius-Typ",
|
||||
"ui.rectangle": "Rechteck",
|
||||
"ui.rectangular_pattern": "Rechteckmuster",
|
||||
"ui.redo": "Wiederholen",
|
||||
"ui.render": "Rendern",
|
||||
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
|
||||
"ui.restore_defaults": "Standardeinstellungen wiederherstellen",
|
||||
"ui.reverse_zoom_direction": "Zoomrichtung umkehren",
|
||||
"ui.revolve": "Drehen",
|
||||
"ui.right": "Rechts",
|
||||
"ui.save": "Speichern",
|
||||
"ui.save_as": "Speichern unter",
|
||||
"ui.save_as_ellipsis": "Speichern unter...",
|
||||
"ui.scale": "Skalieren",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
"ui.select": "Auswählen",
|
||||
"ui.selection": "Auswahl",
|
||||
"ui.shell": "Hülle",
|
||||
"ui.show": "Einblenden",
|
||||
"ui.show_constraints": "Bedingungen anzeigen",
|
||||
"ui.show_dimensions": "Maße anzeigen",
|
||||
"ui.show_points": "Punkte anzeigen",
|
||||
"ui.show_profile": "Profil anzeigen",
|
||||
"ui.show_projected_geometries": "Projizierte Geometrien anzeigen",
|
||||
"ui.simple": "Einfach",
|
||||
"ui.simulation": "Simulation",
|
||||
"ui.single": "Einzel",
|
||||
"ui.sketch": "Skizze",
|
||||
"ui.sketch_dimension": "Skizzenmaß",
|
||||
"ui.sketch_grid": "Skizzenraster",
|
||||
"ui.sketch_scale": "Skizzenskalierung",
|
||||
"ui.sketches": "Skizzen",
|
||||
"ui.slice": "Scheibe",
|
||||
"ui.slot": "Langloch",
|
||||
"ui.smooth": "Glatt",
|
||||
"ui.snap": "Einrasten",
|
||||
"ui.sphere": "Sphäre",
|
||||
"ui.spline": "Spline",
|
||||
"ui.split_body": "Körper trennen",
|
||||
"ui.start": "Start",
|
||||
"ui.start_radius": "Startradius",
|
||||
"ui.sweep": "Führen",
|
||||
"ui.symmetric": "Symmetrisch",
|
||||
"ui.symmetry": "Symmetrie",
|
||||
"ui.system": "System",
|
||||
"ui.tangent": "Tangential",
|
||||
"ui.taper_angle": "Kegelwinkel",
|
||||
"ui.tapped": "Gewindebohrung",
|
||||
"ui.text": "Text",
|
||||
"ui.theme": "Design",
|
||||
"ui.thickness": "Dicke",
|
||||
"ui.thread": "Gewinde",
|
||||
"ui.tip_angle": "Spitzenwinkel",
|
||||
"ui.to_object": "Bis Objekt",
|
||||
"ui.top": "Oben",
|
||||
"ui.torus": "Torus",
|
||||
"ui.trim": "Kürzen",
|
||||
"ui.two_sides": "Beidseitig",
|
||||
"ui.ui_scale": "UI-Skalierung",
|
||||
"ui.undo": "Rückgängig",
|
||||
"ui.units": "Einheiten",
|
||||
"ui.units_of_new_designs": "Einheiten für neue Designs",
|
||||
"ui.untitled": "Ohne Titel",
|
||||
"ui.variable": "Variabel",
|
||||
"ui.view_centre": "Ansichtszentrum",
|
||||
"ui.visual_style": "Ansichtsstil",
|
||||
"ui.workspace": "Arbeitsbereich",
|
||||
"ui.zoom_to_the_cursor": "Zum Cursor zoomen"
|
||||
}
|
||||
@@ -58,6 +58,8 @@ pub struct Prefs {
|
||||
pub ui_scale: f32,
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
pub zoom_to_cursor: bool,
|
||||
/// Spread discrete wheel input across frames (off: apply each event immediately).
|
||||
pub smooth_wheel_zoom: bool,
|
||||
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
pub look_at_anim: bool,
|
||||
pub look_at_speed: f32,
|
||||
@@ -84,6 +86,7 @@ impl Default for Prefs {
|
||||
language: "en".into(),
|
||||
ui_scale: 1.0,
|
||||
zoom_to_cursor: true,
|
||||
smooth_wheel_zoom: true,
|
||||
look_at_anim: true,
|
||||
look_at_speed: 1.0,
|
||||
up_axis: "z".into(),
|
||||
@@ -184,6 +187,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.radial_context_menu = d.radial_context_menu;
|
||||
p.zoom_reverse = d.zoom_reverse;
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
p.space_mouse_enabled = d.space_mouse_enabled;
|
||||
p.space_mouse_speed = d.space_mouse_speed;
|
||||
@@ -577,6 +581,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
|
||||
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
|
||||
ui.end_row();
|
||||
ui.label("Smooth wheel zoom");
|
||||
ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "")
|
||||
.on_hover_text("Turn off to apply each wheel event immediately, without a smoothing tail");
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
@@ -782,6 +790,26 @@ mod tests {
|
||||
assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_smoothing_persists_and_follows_dialog_buttons() {
|
||||
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().smooth_wheel_zoom);
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
|
||||
control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert!(app.preferences.smooth_wheel_zoom);
|
||||
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
|
||||
control(&mut app, &json!({"button":"apply"})).unwrap();
|
||||
control(&mut app, &json!({"smooth_wheel_zoom":true, "button":"cancel"})).unwrap();
|
||||
assert!(!app.preferences.smooth_wheel_zoom);
|
||||
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert!(!other.preferences.smooth_wheel_zoom);
|
||||
control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap();
|
||||
assert!(app.preferences.smooth_wheel_zoom);
|
||||
control(&mut app, &json!({"button":"cancel"})).unwrap();
|
||||
assert!(!app.preferences.smooth_wheel_zoom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_map_the_mouse_and_prefs_round_trip() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
|
||||
@@ -542,29 +542,41 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new("Not available yet").color(t.text_dim));
|
||||
}
|
||||
let row_w = dropdown_width(app, ui, &cmds);
|
||||
// Up and Down move the keyboard focus through the enabled rows (from none
|
||||
// focused: the first or the last), instead of egui's own spatial move.
|
||||
let (down, up) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp)));
|
||||
if down || up {
|
||||
let enabled: Vec<&str> = cmds.iter().filter(|c| c.info(&app.session).enabled).map(|c| c.id).collect();
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let at = enabled.iter().position(|id| focused == Some(dropdown_row_id(ui, id)));
|
||||
let n = enabled.len();
|
||||
let next = match (at, down) {
|
||||
(None, true) => Some(0),
|
||||
(None, false) => n.checked_sub(1),
|
||||
(Some(i), true) => Some((i + 1) % n.max(1)),
|
||||
(Some(i), false) => Some((i + n.max(1) - 1) % n.max(1)),
|
||||
};
|
||||
if let Some(id) = next.and_then(|i| enabled.get(i)) {
|
||||
ui.ctx().memory_mut(|m| {
|
||||
m.request_focus(dropdown_row_id(ui, id));
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
for c in &cmds {
|
||||
let info = c.info(&app.session);
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(
|
||||
info.enabled,
|
||||
egui::Button::new(app.tr_label(c.label)).frame(false).shortcut_text(c.shortcut.unwrap_or("")),
|
||||
);
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
|
||||
if b.secondary_clicked() {
|
||||
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
|
||||
}
|
||||
if b.clicked() {
|
||||
let id = c.id;
|
||||
app.start(id);
|
||||
match dropdown_row(app, ui, c, row_w) {
|
||||
RowAction::None => {}
|
||||
RowAction::Menu(at) => menu = Some((at, c.id)),
|
||||
RowAction::Start => {
|
||||
app.start(c.id);
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if area.response.clicked_elsewhere() && !lresp.clicked() {
|
||||
if (area.response.clicked_elsewhere() && !lresp.clicked()) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
}
|
||||
@@ -608,6 +620,72 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What a click on a panel drop-down row asks for.
|
||||
enum RowAction {
|
||||
None,
|
||||
Start,
|
||||
Menu(egui::Pos2),
|
||||
}
|
||||
|
||||
const ROW_H: f32 = 24.0;
|
||||
|
||||
/// The id of a drop-down row (for keyboard focus).
|
||||
fn dropdown_row_id(ui: &egui::Ui, id: &str) -> egui::Id {
|
||||
ui.id().with(("dropdown_row", id))
|
||||
}
|
||||
|
||||
/// How wide a panel drop-down's rows are: the widest label and shortcut, at least 200.
|
||||
fn dropdown_width(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solvecraft_engine::CommandSpec]) -> f32 {
|
||||
let t = Tokens::get();
|
||||
let font = FontId::proportional(13.0);
|
||||
cmds.iter()
|
||||
.map(|c| {
|
||||
let label = ui.painter().layout_no_wrap(app.tr_label(c.label).to_string(), font.clone(), t.text).size().x;
|
||||
let key = c.shortcut.map(|k| ui.painter().layout_no_wrap(k.to_string(), font.clone(), t.text).size().x + 24.0).unwrap_or(0.0);
|
||||
6.0 + 18.0 + 8.0 + label + key + 30.0
|
||||
})
|
||||
.fold(200.0, f32::max)
|
||||
}
|
||||
|
||||
/// One command row of a panel drop-down: icon, label and shortcut, with the hover background
|
||||
/// under the pointer or keyboard focus (like every menu).
|
||||
fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solvecraft_engine::CommandSpec, width: f32) -> RowAction {
|
||||
let t = Tokens::get();
|
||||
let info = c.info(&app.session);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let sense = if info.enabled { Sense::click() } else { Sense::hover() };
|
||||
let resp = ui.interact(rect, dropdown_row_id(ui, c.id), sense);
|
||||
let label = app.tr_label(c.label).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
|
||||
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
|
||||
let hot = info.enabled && (resp.hovered() || resp.has_focus());
|
||||
let p = ui.painter();
|
||||
if hot {
|
||||
p.rect_filled(rect, 3.0, t.hover);
|
||||
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
|
||||
}
|
||||
let (ink, fill, acc, text) =
|
||||
if info.enabled { (t.icon, t.icon_fill, t.accent, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) };
|
||||
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(p, ir, c.icon, ink, fill, acc);
|
||||
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
|
||||
if let Some(k) = c.shortcut {
|
||||
p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, k, FontId::proportional(13.0), t.text_dim);
|
||||
}
|
||||
let resp = match &info.disabled_reason {
|
||||
Some(why) if !info.enabled => resp.on_hover_text(why),
|
||||
_ => resp,
|
||||
};
|
||||
if resp.secondary_clicked() {
|
||||
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
|
||||
}
|
||||
if resp.clicked() && info.enabled {
|
||||
return RowAction::Start;
|
||||
}
|
||||
RowAction::None
|
||||
}
|
||||
|
||||
/// A toolbar button being dragged (its command id).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ToolbarDrag(&'static str);
|
||||
|
||||
@@ -326,7 +326,25 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
|
||||
/// Only consume a complete, unambiguous selection; command validation owns geometry.
|
||||
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
|
||||
use solvecraft_engine::Sel::{SketchCurve, SketchPoint};
|
||||
let selected_ids = |curves_only: bool, minimum: usize| -> Option<Vec<&str>> {
|
||||
if !(minimum..=200).contains(&selection.len()) {
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<&str> = selection
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
SketchCurve { id } => Some(id.as_str()),
|
||||
SketchPoint { id } if !curves_only => Some(id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect();
|
||||
(unique.len() == ids.len()).then_some(ids)
|
||||
};
|
||||
match (command, selection) {
|
||||
("sketch.constraint.fix", _) => selected_ids(false, 1).map(|ids| json!({"entities":ids})),
|
||||
("sketch.constraint.polygon", _) => selected_ids(true, 3).map(|ids| json!({"lines":ids})),
|
||||
("sketch.constraint.curvature", [SketchCurve { id: a }, SketchCurve { id: b }]) if a != b => Some(json!({"a":a,"b":b})),
|
||||
("sketch.constraint.horizontal_vertical", [SketchCurve { id }]) => Some(json!({"line":id})),
|
||||
("sketch.constraint.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})),
|
||||
("sketch.constraint.coincident", [a, b]) => {
|
||||
|
||||
+175
-61
@@ -62,6 +62,8 @@ pub struct ViewportState {
|
||||
pub orbit_pivot: Option<Vec3>,
|
||||
/// Continuous orbit: the view turns about the vertical until the next click or key.
|
||||
pub spin: bool,
|
||||
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
|
||||
wheel_zoom_raw_until_idle: bool,
|
||||
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
|
||||
pub axis_drag: Option<Pos2>,
|
||||
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
|
||||
@@ -1471,6 +1473,34 @@ pub fn delete_selection(app: &mut SolveApp) {
|
||||
crate::delete::delete_selection(app);
|
||||
}
|
||||
|
||||
/// Match egui's wheel units and modifier routing, without its temporal smoothing.
|
||||
fn immediate_wheel_delta(input: &egui::InputState, options: egui::InputOptions, page_height: f32) -> f32 {
|
||||
input
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
let egui::Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move } = event else { return None };
|
||||
if modifiers.matches_any(options.zoom_modifier) {
|
||||
return None;
|
||||
}
|
||||
let delta = match unit {
|
||||
egui::MouseWheelUnit::Point => *delta,
|
||||
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
|
||||
egui::MouseWheelUnit::Page => *delta * page_height,
|
||||
};
|
||||
let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
|
||||
let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
|
||||
Some(if horizontal && !vertical {
|
||||
0.0
|
||||
} else if vertical && !horizontal {
|
||||
delta.x + delta.y
|
||||
} else {
|
||||
delta.y
|
||||
})
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let rect = ui.max_rect();
|
||||
@@ -1499,6 +1529,18 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
|
||||
|
||||
// ---- navigation ----
|
||||
if !app.preferences.smooth_wheel_zoom {
|
||||
app.viewport.wheel_zoom_raw_until_idle = true;
|
||||
} else if !ui.input(|i| i.is_scrolling()) {
|
||||
app.viewport.wheel_zoom_raw_until_idle = false;
|
||||
}
|
||||
let scroll = if app.viewport.wheel_zoom_raw_until_idle {
|
||||
let options = ui.ctx().options(|o| o.input_options);
|
||||
let page_height = ui.ctx().viewport_rect().height();
|
||||
ui.input(|i| immediate_wheel_delta(i, options, page_height))
|
||||
} else {
|
||||
scroll
|
||||
};
|
||||
let h = rect.height().max(1.0) as f64;
|
||||
if inside && scroll != 0.0 {
|
||||
app.viewport.orbit_snap = None;
|
||||
@@ -3113,72 +3155,144 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_wheel_zoom_applies_normalized_input_once() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
h.app.preferences.zoom_to_cursor = false;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
let before = h.app.cam;
|
||||
let line = h.ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
for (unit, amount, points) in
|
||||
[(egui::MouseWheelUnit::Point, 120.0, 120.0), (egui::MouseWheelUnit::Line, 1.0, line), (egui::MouseWheelUnit::Page, 0.1, 100.0)]
|
||||
{
|
||||
let height = h.app.cam.half_height();
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit,
|
||||
delta: vec2(0.0, amount),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.frame();
|
||||
let expected = height * (-f64::from(points) * 0.0015).exp();
|
||||
assert!((h.app.cam.half_height() - expected).abs() < 1e-6);
|
||||
let after = h.app.cam;
|
||||
h.frames(30);
|
||||
assert_eq!(h.app.cam, after, "idle frames must not replay wheel input");
|
||||
}
|
||||
assert_eq!(h.app.cam.target, before.target);
|
||||
h.app.preferences.zoom_reverse = true;
|
||||
let height = h.app.cam.half_height();
|
||||
h.call("ui.scroll", json!({"x":800,"y":500,"delta":120}));
|
||||
assert!((h.app.cam.half_height() - height * 0.18_f64.exp()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_setting_changes_do_not_replay_smoothing() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.zoom_to_cursor = false;
|
||||
h.call("ui.move", json!({"x":800,"y":500}));
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 120.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.frame();
|
||||
assert!(h.ctx.input(|i| i.is_scrolling()));
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
let stopped = h.app.cam;
|
||||
h.frame();
|
||||
assert_eq!(h.app.cam, stopped);
|
||||
h.app.preferences.smooth_wheel_zoom = true;
|
||||
h.frames(90);
|
||||
assert_eq!(h.app.cam, stopped, "re-enabling must not replay the old smoothing tail");
|
||||
assert!(!h.app.viewport.wheel_zoom_raw_until_idle);
|
||||
h.app.preferences.smooth_wheel_zoom = false;
|
||||
for _ in 0..3 {
|
||||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 120.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.frame();
|
||||
}
|
||||
assert!((h.app.cam.half_height() - stopped.half_height() * (-0.54_f64).exp()).abs() < 1e-6);
|
||||
let after = h.app.cam;
|
||||
h.frames(90);
|
||||
assert_eq!(h.app.cam, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||||
let mut time = 0.0;
|
||||
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
|
||||
time += 1.0 / 60.0;
|
||||
let mut events = Vec::new();
|
||||
if let Some(p) = pointer {
|
||||
events.push(egui::Event::PointerMoved(p));
|
||||
for smooth in [true, false] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||
app.preferences.smooth_wheel_zoom = smooth;
|
||||
app.viewport.no_pixels = true;
|
||||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||||
let mut time = 0.0;
|
||||
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
|
||||
time += 1.0 / 60.0;
|
||||
let mut events = Vec::new();
|
||||
if let Some(p) = pointer {
|
||||
events.push(egui::Event::PointerMoved(p));
|
||||
}
|
||||
if wheel != 0.0 {
|
||||
events.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, wheel),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
}
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
|
||||
crate::prefs::show(app, ui.ctx());
|
||||
},
|
||||
);
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
// Let egui establish the floating window's size and hit-test widgets.
|
||||
for _ in 0..4 {
|
||||
frame(&mut app, None, 0.0);
|
||||
}
|
||||
if wheel != 0.0 {
|
||||
events.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, wheel),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
}
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| {
|
||||
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
|
||||
crate::prefs::show(app, ui.ctx());
|
||||
},
|
||||
);
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
// Let egui establish the floating window's size and hit-test widgets.
|
||||
for _ in 0..4 {
|
||||
frame(&mut app, None, 0.0);
|
||||
}
|
||||
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||||
let list = window.center() + vec2(140.0, 0.0);
|
||||
let height = app.cam.half_height();
|
||||
let target = app.cam.target;
|
||||
frame(&mut app, Some(list), 0.0);
|
||||
for n in 0..12 {
|
||||
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
|
||||
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
|
||||
assert_eq!(app.cam.target, target);
|
||||
}
|
||||
// The window's non-scrolling sidebar must also shield the model.
|
||||
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
|
||||
assert_eq!(app.cam.half_height(), height);
|
||||
// An exposed part of the viewport still accepts wheel navigation with the popup open.
|
||||
let exposed = pos2(40.0, 700.0);
|
||||
assert!(!window.contains(exposed));
|
||||
frame(&mut app, Some(exposed), 0.0);
|
||||
frame(&mut app, Some(exposed), -120.0);
|
||||
assert_ne!(app.cam.half_height(), height);
|
||||
// Closing the popup returns its former area to the viewport.
|
||||
app.prefs_window.open = false;
|
||||
for _ in 0..12 {
|
||||
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||||
let list = window.center() + vec2(140.0, 0.0);
|
||||
let height = app.cam.half_height();
|
||||
let target = app.cam.target;
|
||||
frame(&mut app, Some(list), 0.0);
|
||||
for n in 0..12 {
|
||||
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
|
||||
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
|
||||
assert_eq!(app.cam.target, target);
|
||||
}
|
||||
// The window's non-scrolling sidebar must also shield the model.
|
||||
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
|
||||
assert_eq!(app.cam.half_height(), height);
|
||||
// An exposed part of the viewport still accepts wheel navigation with the popup open.
|
||||
let exposed = pos2(40.0, 700.0);
|
||||
assert!(!window.contains(exposed));
|
||||
frame(&mut app, Some(exposed), 0.0);
|
||||
frame(&mut app, Some(exposed), -120.0);
|
||||
assert_ne!(app.cam.half_height(), height);
|
||||
// Closing the popup returns its former area to the viewport.
|
||||
app.prefs_window.open = false;
|
||||
for _ in 0..12 {
|
||||
frame(&mut app, Some(list), 0.0);
|
||||
}
|
||||
let height = app.cam.half_height();
|
||||
frame(&mut app, Some(list), -120.0);
|
||||
assert_ne!(app.cam.half_height(), height);
|
||||
}
|
||||
let height = app.cam.half_height();
|
||||
frame(&mut app, Some(list), -120.0);
|
||||
assert_ne!(app.cam.half_height(), height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -146,3 +146,106 @@ fn rejected_preselection_preserves_document_and_error() {
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_polygon_and_fix_consume_clicked_operands_atomically() {
|
||||
for command in ["fix", "curvature", "polygon"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
let picks = match command {
|
||||
"curvature" => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.spline.control_point", json!({"points":["l1.end",[40,12],[50,16],[60,25]]})).unwrap();
|
||||
vec![[20., 10.], [45., 14.875]]
|
||||
}
|
||||
"polygon" => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[40,10],[40,30],[10,30]],"closed":true,"infer":false})).unwrap();
|
||||
vec![[25., 10.], [40., 20.], [25., 30.], [10., 20.]]
|
||||
}
|
||||
_ => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[40,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[20,25],"id":"p"})).unwrap();
|
||||
vec![[25., 10.], [20., 25.]]
|
||||
}
|
||||
};
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 80.0;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(30.0, 15.0, 0.0);
|
||||
h.settle();
|
||||
for (i, p) in picks.iter().enumerate() {
|
||||
h.step(&json!({"click":{"sketch":p},"shift":i>0})).unwrap();
|
||||
}
|
||||
assert_eq!(h.app.session.selection.len(), picks.len(), "{command}: {:?}", h.app.session.selection);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status);
|
||||
assert!(h.app.tool.is_none());
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let st = h.app.session.world_state();
|
||||
let sk = &st.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert!(sk.constraints.iter().any(|c| match command {
|
||||
"fix" => matches!(c.kind, ConstraintKind::Fix { .. }),
|
||||
"curvature" => matches!(c.kind, ConstraintKind::Smooth { .. }),
|
||||
_ => matches!(c.kind, ConstraintKind::Equal { .. }),
|
||||
}));
|
||||
match command {
|
||||
"fix" => {
|
||||
assert!(sk.curve_locked(sk.curve_index("l1").unwrap()));
|
||||
assert!(sk.point_locked(sk.resolve_point("p").unwrap()));
|
||||
}
|
||||
"curvature" => {
|
||||
let spline = sk.curves.iter().find(|c| c.id != "l1").unwrap();
|
||||
let pts: Vec<_> = spline.kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
|
||||
let line = sk.polyline(sk.curve_index("l1").unwrap());
|
||||
let direction = (line[1] - line[0]).normalized().unwrap();
|
||||
assert!((pts[1] - pts[0]).cross(direction).abs() < 1e-5);
|
||||
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3);
|
||||
}
|
||||
_ => {
|
||||
let lengths: Vec<_> = ["l1", "l2", "l3", "l4"]
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let pts = sk.polyline(sk.curve_index(id).unwrap());
|
||||
pts[0].dist(pts[1])
|
||||
})
|
||||
.collect();
|
||||
assert!(lengths[0] > 1.0);
|
||||
assert!(lengths.iter().all(|l| (l - lengths[0]).abs() < 1e-6));
|
||||
}
|
||||
}
|
||||
assert!(st.sketch(h.app.session.active_sketch.unwrap()).unwrap().report.ok());
|
||||
let after = h.app.session.doc.to_json();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_constraints_preserve_mixed_selections_and_rejected_geometry() {
|
||||
for command in ["fix", "curvature", "polygon"] {
|
||||
let mut h = lines();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let mixed = vec![Sel::SketchCurve { id: "l1".into() }, Sel::Body { name: "missing".into() }];
|
||||
h.app.session.selection = mixed.clone();
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(h.app.tool.is_some());
|
||||
assert_eq!(h.app.session.selection, mixed);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
let mut h = lines();
|
||||
let selection = vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "missing".into() }];
|
||||
h.app.session.selection = selection.clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.constraint.curvature"})).unwrap();
|
||||
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
[
|
||||
{
|
||||
"note": "#288: a panel drop-down highlights the row under the pointer with the hover background, and the row with keyboard focus. Drop-down rows publish `dropdown:<command id>`, the highlighted one also `dropdown_hot:<command id>`."
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "panel:SOLID:CREATE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box", "dropdown:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box", "dropdown_hot:solid.sphere"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.box"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.cylinder"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.sphere"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": [700, 600]
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The keyboard: Down focuses the first row, Down again the next; the highlight follows."
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.extrude"],
|
||||
"hidden_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["dropdown:solid.box"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -39,5 +39,10 @@ Preferences (File › Preferences) hold the theme, the units of new designs, the
|
||||
visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard
|
||||
Shortcuts changes them.
|
||||
|
||||
Navigation › **Smooth wheel zoom** is on by default. Turn it off to apply each wheel or
|
||||
trackpad event immediately, with no smoothing tail. It is independent of **Animate view
|
||||
changes**, which controls axis views, Look At, Home and Fit. Re-enabling smoothing during
|
||||
a wheel gesture takes effect after that gesture ends, avoiding leftover zoom being replayed.
|
||||
|
||||
Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a
|
||||
lightly damped spring transition and a small boundary margin to prevent flicker.
|
||||
@@ -70,7 +70,7 @@ rolls the numbering back with the design), so they can go straight into later co
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# Design loading
|
||||
|
||||
## Cold geometry rebuilds after 0.3.1
|
||||
|
||||
Three alternating cold-process comparisons on the same Windows machine and optimized
|
||||
`ci` profile, with no concurrent build, compare the unchanged 0.3.1 source with the
|
||||
straight-line projection and exact surface-refinement reuse changes:
|
||||
|
||||
| Saved design | Before, median evaluation | After | Reduction |
|
||||
|---|---:|---:|---:|
|
||||
| Public sensor enclosure, 21 features, 52 KB | 11.96 s | 8.85 s | 26.0% |
|
||||
| Local Pi case assembly, 25 features, 2,990 bodies | 14.75 s | 11.57 s | 21.6% |
|
||||
|
||||
These are geometry rebuild times, not complete desktop opening times. The public fixture
|
||||
can be generated with `solvecraft-cli run examples/acceptance/sensor_enclosure.json
|
||||
--save sensor.solvecraft --quiet`, then measured with `solvecraft-cli bench-open
|
||||
sensor.solvecraft`. Each benchmark starts a new process; this does not measure a warm
|
||||
timeline-cache hit. The local assembly is private and is not part of the repository.
|
||||
|
||||
Straight segments now use guarded algebraic closest-point projection instead of sampling
|
||||
65 points and running Newton iterations. Other curve types retain their existing behavior.
|
||||
Curved-face refinement reuses derivative and midpoint evaluations with exact finite input
|
||||
keys and bounded, local caches. It keeps the original tolerance, seam and singularity
|
||||
checks, refinement passes, and triangle limits.
|
||||
|
||||
Identity assembly placements retain the definition body's shared display-mesh cache.
|
||||
Changed placements continue to build separate meshes in their own coordinates. Face-name
|
||||
preparation remains eager during placement: deferring it merely shifted expensive work to
|
||||
thumbnails and the first face query, so that approach was excluded.
|
||||
|
||||
Both saved fixtures retain exactly the same body names, face/edge/vertex/shell counts,
|
||||
bounds, volumes, and surface areas. Focused tests compare cached and uncached refinement
|
||||
coordinates and normals bit for bit on periodic, singular and transformed surfaces.
|
||||
|
||||
For desktop phase profiling, set `SOLVECRAFT_PROFILE_OPEN=1` before launching the app.
|
||||
Interactive Open reports geometry, placement, tessellation, thumbnail and total worker
|
||||
times to stderr without logging the source path. Thumbnails were about 0.1 seconds of
|
||||
the slow assembly's open and were not the main bottleneck. Further cold Boolean and
|
||||
import costs remain; these changes do not make all documents open instantly.
|
||||
|
||||
### Prepared assembly geometry and selection
|
||||
|
||||
Prepared world geometry is now keyed by the document, evaluated model and exact placement
|
||||
context instead of the session revision. Selecting a face or edge, changing visibility,
|
||||
or moving the view no longer discards already prepared assembly bodies and meshes.
|
||||
Geometry edits, pending occurrence moves, authoring context changes, undo/redo and timeline
|
||||
rollback still invalidate the cache. Scratch sessions share prepared geometry while their
|
||||
inputs match, and adopting a computed session retains its warm cache.
|
||||
|
||||
On the private 2,990-body Pi assembly, a matched Linux desktop control probe reduced an
|
||||
edge-selection round trip from 9.24 s to 0.57 s. Initial opening stayed essentially unchanged
|
||||
(33.54 s to 33.32 s); this fixes a separate selection stall, not cold geometry evaluation.
|
||||
Three fresh Windows desktop runs with the corrected cache measured a median 15.87 s to
|
||||
ready: 13.04 s geometry, 2.20 s placement, 0.53 s meshing and 0.07 s thumbnails. These used
|
||||
the optimized `ci` profile and actual Vulkan rendering, with isolated settings and caches.
|
||||
Linux and Windows timings are separate measurements and must not be compared as a speedup.
|
||||
|
||||
`solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading,
|
||||
JSON decoding and migrations (including input-name repair), timeline evaluation, individual
|
||||
features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
|
||||
2. Update `[workspace.package] version` in root `Cargo.toml`. Then run
|
||||
`cargo metadata --format-version 1 --no-deps > /dev/null` to refresh workspace package
|
||||
versions in `Cargo.lock`. Inspect the diff and keep unrelated dependency changes out.
|
||||
SolidCraft does not currently implement the siblings' `cargo xtask version set` command.
|
||||
SolidCraft does not currently implement `cargo xtask version set`.
|
||||
3. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
|
||||
Commit the version files through the normal review flow and land them on `main`.
|
||||
Do not use `[skip ci]`. Record the exact commit SHA to release. If it changes, revalidate it.
|
||||
|
||||
Vendored
+3
@@ -7,6 +7,9 @@ are marked `SolveCraft:` in the source.
|
||||
## truck-shapeops 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck)
|
||||
|
||||
Changes:
|
||||
- `transversal/intersection_curve/mod.rs`: reject empty/single-point polylines and
|
||||
collapsed or non-finite segments before endpoint indexing and normalization. A hidden
|
||||
constructor re-export lets the kernel workspace tests exercise the actual vendored code.
|
||||
- `transversal/integrate/mod.rs`: mesh both shells with `robust_triangulation` (faces made by
|
||||
earlier booleans have boundaries that lie on their surface only within tolerance).
|
||||
- `transversal/divide_face/mod.rs`: when projecting a loop onto a face's surface, fall back to
|
||||
|
||||
Vendored
+3
@@ -34,4 +34,7 @@ mod healing;
|
||||
pub use healing::{RobustSplitClosedEdgesAndFaces, SplitClosedEdgesAndFaces};
|
||||
mod transversal;
|
||||
pub use transversal::{and, or, ShapeOpsCurve, ShapeOpsSurface};
|
||||
// SolveCraft: exercised by kernel regressions in the workspace CI.
|
||||
#[doc(hidden)]
|
||||
pub use transversal::IntersectionCurveWithParameters;
|
||||
mod alternative;
|
||||
@@ -19,12 +19,21 @@ impl<S> From<IntersectionCurveWithParameters<S>> for IntersectionCurve<PolylineC
|
||||
impl<S> IntersectionCurveWithParameters<S>
|
||||
where S: ParametricSurface3D + SearchNearestParameter<D2, Point = Point3>
|
||||
{
|
||||
/// SolveCraft: construct a projected curve, returning `None` for degenerate input.
|
||||
pub fn try_new(
|
||||
surface0: S,
|
||||
surface1: S,
|
||||
poly: PolylineCurve<Point3>,
|
||||
tol: f64,
|
||||
) -> Option<Self> {
|
||||
// SolveCraft: a point contact is not a curve. Reject it before indexing the
|
||||
// endpoints, and reject collapsed/non-finite segments before normalizing.
|
||||
if poly.len() < 2 || poly.windows(2).any(|p| {
|
||||
let length2 = (p[1] - p[0]).magnitude2();
|
||||
!length2.is_finite() || length2 <= 0.0
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let mut polyline = PolylineCurve(Vec::new());
|
||||
let mut params0 = PolylineCurve(Vec::new());
|
||||
let mut params1 = PolylineCurve(Vec::new());
|
||||
|
||||
@@ -2,6 +2,9 @@ mod divide_face;
|
||||
mod faces_classification;
|
||||
mod integrate;
|
||||
mod intersection_curve;
|
||||
// SolveCraft: expose the constructor for workspace regression coverage of the vendored fix.
|
||||
#[doc(hidden)]
|
||||
pub use intersection_curve::IntersectionCurveWithParameters;
|
||||
mod loops_store;
|
||||
mod polyline_construction;
|
||||
pub use integrate::{and, or, ShapeOpsCurve, ShapeOpsSurface};
|
||||
Reference in new issue
Block a user