Add bounded saved opening previews and integrate upstream

This commit is contained in:
lillith
2026-10-12 21:10:11 +02:00
98 changed files with 15545 additions and 671 deletions
Generated
+14 -13
View File
@@ -3290,7 +3290,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -3307,7 +3307,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3316,7 +3316,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -3331,7 +3331,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -3347,15 +3347,16 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "solvecraft-io"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"quick-xml 0.38.4",
"serde",
@@ -3370,7 +3371,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -3385,7 +3386,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3393,7 +3394,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"image",
"serde",
@@ -3403,7 +3404,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -3414,7 +3415,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3429,7 +3430,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -4852,7 +4853,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.3.2"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+17 -2
View File
@@ -75,7 +75,8 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
separately from through-hole outlines in the flat preview and DXF.
- **Files**: design containers (`.solvecraft`) with lossless binary mesh payloads;
plain JSON remains supported for opening, Save As and export. Export to STEP (AP242 with body names, colours and
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
preview while checking geometry before editing. Export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
@@ -109,11 +110,25 @@ cargo xtask ci # fmt, clippy, tests, as
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
device. A udev rule that adds the `uaccess` tag does that, for example
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
```
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
```
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
report descriptor, so they work as well.
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
+7 -1
View File
@@ -44,7 +44,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
@@ -137,6 +137,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## View navigation
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
@@ -340,6 +342,10 @@ and reference-object placement remain separate work, as do loft rails and center
format. Generated-mesh storage measurements reduce serialization and decoding work;
evaluated geometry caching and instant cold display remain separate follow-up work.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Desktop Open can show a bounded saved display preview while rebuilding in the existing
file worker. Saves cache only already warm meshes; document/source fingerprints reject
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale
completions cannot replace a newer document. Evaluated B-rep restoration remains open.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
+3 -1
View File
@@ -19,9 +19,11 @@ serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { 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"] }
+7 -5
View File
@@ -15,7 +15,7 @@
#[path = "../../app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod space_mouse;
use solvecraft_engine::Session;
@@ -23,7 +23,7 @@ use solvecraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse: space_mouse::SpaceMouse,
}
@@ -36,7 +36,7 @@ impl eframe::App for App {
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
}
self.solve.logic(ctx);
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
self.space_mouse.tick(&mut self.solve, ctx);
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -210,6 +210,8 @@ fn main() -> eframe::Result {
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
// A saved anti-aliasing the device can't do: back to the default, with a notice.
solvecraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -237,11 +239,11 @@ fn main() -> eframe::Result {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
Ok(Box::new(App {
solve: app,
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse,
}))
}),
+131 -19
View File
@@ -1,10 +1,17 @@
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
//! it publishes the latest axes, and the egui thread applies them once per frame.
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
//! applies them once per frame.
mod hid_descriptor;
#[cfg(target_os = "linux")]
mod hidraw;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
use hid_descriptor::Layout;
#[cfg(target_os = "windows")]
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
@@ -208,9 +215,14 @@ impl Reading {
}
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
matches!(vendor_id, 0x046d | 0x256f)
}
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
#[cfg(target_os = "windows")]
fn is_3d_mouse(info: &DeviceInfo) -> bool {
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
}
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
@@ -220,8 +232,18 @@ fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
match report.first().copied() {
Some(1) if layout == Layout::Combined => {
let Some(body) = report.get(1..) else { return false };
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
reading.axes = Axes { translation, rotation };
reading.translation_at = now;
reading.rotation_at = now;
true
}
Some(1) => {
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.translation = translation;
@@ -238,6 +260,24 @@ fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
}
}
/// Publishes one input report from the device to the egui thread.
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
if let Ok(mut reading) = shared.lock()
&& decode_report(report, layout, &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
/// The device is gone: stop reporting its last position.
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
}
ctx.request_repaint();
}
#[cfg(target_os = "windows")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
log::warn!("3D mouse: HID initialization failed");
@@ -247,27 +287,72 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
let layout = device_layout(&device);
let mut report = [0_u8; 64];
loop {
match device.read_timeout(&mut report, 100) {
Ok(0) => {}
Ok(size) => {
if let Ok(mut reading) = shared.lock()
&& decode_report(&report[..size], &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
reset(&ctx, &shared);
}
std::thread::sleep(RETRY_AFTER);
}
}
/// How the device packs its axes, from its report descriptor; the usual split layout if the
/// descriptor cannot be read.
#[cfg(target_os = "windows")]
fn device_layout(device: &hidapi::HidDevice) -> Layout {
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
match device.get_report_descriptor(&mut descriptor) {
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
Err(error) => {
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
Layout::Split
}
}
}
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
#[cfg(target_os = "linux")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
use std::io::Read;
let mut denied = None;
loop {
if let Some(device) = hidraw::find() {
match std::fs::File::open(&device.path) {
Ok(mut file) => {
denied = None;
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
let mut report = [0_u8; 64];
loop {
match file.read(&mut report) {
Ok(0) => break,
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
reset(&ctx, &shared);
}
// Say so once per device node, not every retry.
Err(error) if denied.as_ref() != Some(&device.path) => {
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
denied = Some(device.path);
}
Err(_) => {}
}
ctx.request_repaint();
}
std::thread::sleep(RETRY_AFTER);
}
@@ -371,19 +456,46 @@ mod tests {
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
let later = now + STALE_AFTER + Duration::from_millis(1);
assert_eq!(reading.current(later), Axes::default());
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn combined_report_one_carries_translation_and_rotation_together() {
let now = Instant::now();
let mut reading = Reading::new(now);
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
// The same bytes on a split device are only the translation report; its padding is not rotation.
let mut split = Reading::new(now);
assert!(decode_report(&report, Layout::Split, &mut split, now));
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
}
#[test]
fn combined_layout_ignores_short_and_other_reports() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
// Extra padding after the six values is fine.
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
@@ -0,0 +1,253 @@
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
//! it simply yields less information.
/// Generic Desktop usage page, and the usages in it that matter here.
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
const AXIS_COUNT: u32 = 6;
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
const MOTION_REPORT_ID: u8 = 1;
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
const MAX_DESCRIPTOR_BYTES: usize = 4096;
/// Bound on the usages one main item can declare through a usage range.
const MAX_USAGES_PER_ITEM: u32 = 256;
/// How a device spreads its six motion axes over input reports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Layout {
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
#[default]
Split,
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
Combined,
}
#[derive(Clone, Copy)]
struct Item {
/// 0 main, 1 global, 2 local (3 is reserved).
kind: u8,
tag: u8,
value: u32,
}
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
let mut pos = 0_usize;
std::iter::from_fn(move || {
loop {
let prefix = *descriptor.get(pos)?;
pos += 1;
if prefix == 0xFE {
// Long item: a data length byte, a tag byte, then the data.
let length = usize::from(*descriptor.get(pos)?);
pos = pos.checked_add(2 + length)?;
continue;
}
let size = match prefix & 0b11 {
0 => 0,
1 => 1,
2 => 2,
_ => 4,
};
let data = descriptor.get(pos..pos + size)?;
pos += size;
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
}
})
}
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Descriptor {
multi_axis_controller: bool,
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
motion_report_axes: u8,
}
impl Descriptor {
pub fn parse(descriptor: &[u8]) -> Self {
let mut summary = Self::default();
let mut page = 0_u32;
let mut report_id = 0_u8;
let mut depth = 0_u32;
// Local items belong to the next main item only.
let mut usages: Vec<u32> = Vec::new();
let mut usage_min = None;
for item in items(descriptor) {
match (item.kind, item.tag) {
// Global: usage page, report id.
(1, 0) => page = item.value,
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
// Local: usage, usage minimum, usage maximum.
(2, 0) => usages.push(Self::full_usage(page, item)),
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
(2, 2) => {
if let Some(min) = usage_min.take() {
let max = Self::full_usage(page, item);
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
}
}
// Main: input, collection, end collection.
(0, 8) => {
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
summary.note_axes(&usages);
}
}
(0, 0xA) => {
// An application collection (1) at the top level names the device.
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
summary.multi_axis_controller = true;
}
depth += 1;
}
(0, 0xC) => depth = depth.saturating_sub(1),
_ => {}
}
if item.kind == 0 {
usages.clear();
usage_min = None;
}
}
summary
}
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
fn full_usage(page: u32, item: Item) -> u32 {
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
}
fn note_axes(&mut self, usages: &[u32]) {
for usage in usages {
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
if axis < AXIS_COUNT {
self.motion_report_axes |= 1 << axis;
}
}
}
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn is_multi_axis_controller(&self) -> bool {
self.multi_axis_controller
}
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
pub fn layout(&self) -> Layout {
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
const SPLIT: &[u8] = &[
0x05, 0x01, // usage page: generic desktop
0x09, 0x08, // usage: multi-axis controller
0xA1, 0x01, // collection: application
0x85, 0x01, // report id 1
0x16, 0xA2, 0xFE, // logical minimum -350
0x26, 0x5E, 0x01, // logical maximum 350
0x75, 0x10, // report size 16
0x95, 0x03, // report count 3
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
0x81, 0x06, // input: data, variable, relative
0x85, 0x02, // report id 2
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
0x81, 0x06, // input
0xC0, // end collection
];
/// Report 1 = X Y Z Rx Ry Rz.
const COMBINED: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
0x81, 0x06, 0xC0,
];
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
const COMBINED_RANGE: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
0x81, 0x06, 0xC0,
];
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
const KEYBOARD: &[u8] =
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
const MOUSE: &[u8] = &[
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
0x06, 0xC0, 0xC0,
];
#[test]
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
let descriptor = Descriptor::parse(SPLIT);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0b000_111);
assert_eq!(descriptor.layout(), Layout::Split);
}
#[test]
fn six_axes_in_report_one_are_a_combined_layout() {
for bytes in [COMBINED, COMBINED_RANGE] {
let descriptor = Descriptor::parse(bytes);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Combined);
}
}
#[test]
fn other_interfaces_are_not_3d_mice() {
for bytes in [KEYBOARD, MOUSE, &[][..]] {
let descriptor = Descriptor::parse(bytes);
assert!(!descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Split);
}
}
#[test]
fn four_byte_usages_carry_their_own_page() {
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0);
}
#[test]
fn truncated_and_hostile_descriptors_never_panic() {
for end in 0..=COMBINED.len() {
let _ = Descriptor::parse(&COMBINED[..end]);
}
// Long item with a length running past the end, huge usage ranges, deep nesting.
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
let _ = Descriptor::parse(&[0xC0; 64]);
let _ = Descriptor::parse(&[0xA1; 64]);
let mut state = 0x2545_F491_4F6C_DD1D_u64;
for _ in 0..200 {
let bytes: Vec<u8> = (0..64)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state.to_le_bytes()[0]
})
.collect();
let _ = Descriptor::parse(&bytes);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
//! plain file I/O, so no udev or hidapi library is needed.
use std::path::{Path, PathBuf};
use super::hid_descriptor::{Descriptor, Layout};
const SYSFS: &str = "/sys/class/hidraw";
const DEV: &str = "/dev";
pub struct Device {
pub path: PathBuf,
pub layout: Layout,
}
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
fn vendor_id(uevent: &str) -> Option<u16> {
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
let vendor = id.split(':').nth(1)?;
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
}
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
pub fn find() -> Option<Device> {
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
names.sort();
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
}
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
return None;
}
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_vendor_from_the_hid_id() {
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
assert_eq!(vendor_id(uevent), Some(0x256f));
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
}
#[test]
fn malformed_ids_are_rejected() {
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
}
}
}
+22
View File
@@ -2181,6 +2181,28 @@ impl Document {
}
Ok(())
}
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
/// jump it reaches by stepping the changed values from there, and an edit that would turn
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
/// from the stored state again after a refused or failed first solve (the geometry is still
/// the stored one), from the solved one otherwise.
pub fn solve_sketch(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
sk: &mut Sketch,
stored: &solvecraft_sketch::DrivingValues,
) -> Result<solvecraft_sketch::SolveReport> {
self.apply_dimension_values(vals, sk)?;
let mut report = solvecraft_sketch::solve_after_value_edit(sk, stored);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = if report.ok() { solvecraft_sketch::solve(sk) } else { solvecraft_sketch::solve_after_value_edit(sk, stored) };
}
Ok(report)
}
}
/// Density of the default material (g/cm³), as Fusion's default.
+239 -54
View File
@@ -7,7 +7,7 @@ use std::sync::{Arc, OnceLock};
use serde::Serialize;
use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
use solvecraft_kernel::{self as kernel, Body, BoolOp};
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve};
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
use crate::expr::{self, Kind, Value};
use crate::lineage::Traced;
@@ -71,6 +71,11 @@ impl ModelBody {
})
.clone()
}
/// Already computed display geometry; saving must not trigger cold tessellation.
pub fn cached_mesh(&self) -> Option<Arc<Mesh>> {
self.mesh.get().cloned()
}
}
/// A solved sketch in the evaluated model.
@@ -262,7 +267,8 @@ pub struct ModelState {
/// Cosmetic threads (from Thread features and threaded holes).
pub threads: Vec<ThreadInfo>,
pub bodies: Vec<ModelBody>,
pub sketches: Vec<SolvedSketch>,
/// Shared between states: a feature that does not change a sketch keeps it as it was.
pub sketches: Vec<Arc<SolvedSketch>>,
pub body_counter: usize,
/// Sheet metal bodies (by body name) and their flat patterns.
pub sheets: Vec<crate::sheet::SheetBody>,
@@ -295,7 +301,8 @@ impl ModelState {
}
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
/// made or changed, and sketches it made, take that key; the rest keep theirs.
/// made or changed take that key, sketches it made a digest of their solved geometry (see
/// [`quantize`]); the rest keep theirs.
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
@@ -342,8 +349,16 @@ impl ModelState {
}
}
}
if self.sketches.iter().any(|s| s.feature == feature) {
self.sketch_keys.insert(feature, key);
// Later features read a sketch's solved geometry: they are keyed by it (to the
// quantum), so a value set back that solves to the same geometry reuses their results.
if let Some(s) = self.sketches.iter().find(|s| s.feature == feature) {
let mut h = std::collections::hash_map::DefaultHasher::new();
serde_json::to_string(&s.plane).unwrap_or_default().hash(&mut h);
let mut sketch = s.sketch.clone();
quantize(&mut sketch);
serde_json::to_string(&sketch).unwrap_or_default().hash(&mut h);
s.report.ok().hash(&mut h);
self.sketch_keys.insert(feature, h.finish());
}
}
}
@@ -394,7 +409,7 @@ impl ModelState {
self.bodies.iter().find(|b| b.name == name)
}
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
self.sketches.iter().find(|s| s.feature == feature)
self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s)
}
}
@@ -408,6 +423,11 @@ pub struct FeatureResult {
pub boolean_diagnostics: kernel::BooleanDiagnostics,
pub ms: f64,
pub skipped: bool,
/// Not evaluated on the document's current inputs: a feature at or after a sketch shown live
/// ([`Model::show_live_sketch`]). It keeps its last result until the next `evaluate`, which
/// never reuses it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub stale: bool,
#[serde(skip)]
fingerprint: u64,
#[serde(skip)]
@@ -448,7 +468,7 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
prev.hash(&mut h);
rolled_back.hash(&mut h);
vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h);
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
feature_json(f).hash(&mut h);
for n in param_names(f) {
n.hash(&mut h);
match vals.get(&n) {
@@ -463,6 +483,123 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
h.finish()
}
/// Positions and radii are fingerprinted to this quantum (mm): far below anything a design
/// can show, far above the solver's rounding, so a sketch solved again to the same geometry
/// reads as the same input.
const QUANTUM: f64 = 1e-6;
/// Put what a solve writes into a sketch on the [`QUANTUM`] grid: positions, radii and the
/// measured values of driven dimensions.
fn quantize(sk: &mut solvecraft_sketch::Sketch) {
let q = |v: f64| (v / QUANTUM).round() * QUANTUM;
for p in &mut sk.points {
p.pos = Vec2::new(q(p.pos.x), q(p.pos.y));
}
for c in &mut sk.curves {
if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind {
*r = q(*r);
}
}
for c in sk.constraints.iter_mut().filter(|c| c.driven) {
if let Some(v) = c.kind.value() {
c.kind.set_value(q(v));
}
}
}
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized.
fn feature_json(f: &Feature) -> String {
if let FeatureKind::Sketch { .. } = &f.kind {
let mut g = f.clone();
if let FeatureKind::Sketch { sketch, .. } = &mut g.kind {
quantize(sketch);
}
return serde_json::to_string(&g).unwrap_or_default();
}
serde_json::to_string(f).unwrap_or_default()
}
/// Store in `stored` the solution `solved` holds for it: the positions, the circle and ellipse
/// radii, the arcs' end order and orientation (a signed sweep can swap them) and the
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
/// match `stored` entity for entity is not adopted.
fn adopt_solution(stored: &mut solvecraft_sketch::Sketch, solved: &solvecraft_sketch::Sketch) {
if stored.points.len() != solved.points.len()
|| stored.curves.len() != solved.curves.len()
|| stored.constraints.len() != solved.constraints.len()
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
{
return;
}
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
p.pos = q.pos;
}
for (c, d) in stored.curves.iter_mut().zip(&solved.curves) {
match (&mut c.kind, &d.kind) {
(CurveKind::Circle { r, .. }, CurveKind::Circle { r: s, .. }) | (CurveKind::Ellipse { r, .. }, CurveKind::Ellipse { r: s, .. }) => {
*r = *s
}
(CurveKind::Arc { a, b, .. }, CurveKind::Arc { a: sa, b: sb, .. }) => {
(*a, *b) = (*sa, *sb);
c.reversed = d.reversed;
}
_ => {}
}
}
for (c, d) in stored.constraints.iter_mut().zip(&solved.constraints) {
if let Some(v) = d.kind.value() {
c.kind.set_value(v);
}
}
}
/// The values features are evaluated with: the parameters, and the design's length unit.
fn evaluation_values(doc: &Document) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
let (mut vals, perr) = doc.param_values();
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
&& scale != 1.0
{
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
}
(vals, perr)
}
/// A feature's fingerprint in the chain after `prev`: its own inputs, where it is placed, and
/// the sheet metal or plastic rules it takes defaults from.
fn chain_fingerprint(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, prev: u64, rolled_back: bool) -> u64 {
let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
for r in &doc.sheet.rules {
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
// Plastic features take defaults from the plastic rules.
if f.kind.is_plastic() && !doc.plastic.is_empty() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
for r in &doc.plastic.rules {
for (_, e, _) in r.fields() {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
fp
}
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
/// metal and plastic rules (and the parameters they use), components and body placement.
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
@@ -579,14 +716,79 @@ impl Model {
self.results.iter().find(|r| r.id == id)
}
/// Show a sketch's new solution before the model is evaluated again (the frames of an
/// interactive drag): `sketch`, already solved with `report`, on the plane it was last
/// evaluated on. The sketch's own result and the final state show it, and the sketch and
/// every feature after it are marked stale, keeping their last geometry. Nothing else is
/// recomputed; the next [`Model::evaluate`] recomputes from the sketch on. Returns `false`,
/// changing nothing, when the sketch has no evaluated result or a feature before it is
/// already stale (only a full evaluation brings that up to date).
pub fn show_live_sketch(&mut self, feature: u64, sketch: solvecraft_sketch::Sketch, report: SolveReport) -> bool {
let Some(i) = self.results.iter().position(|r| r.id == feature) else { return false };
if self.results.iter().take(i).any(|r| r.stale) {
return false;
}
let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else {
return false;
};
let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report });
let shown = self.results.iter().rposition(|r| !r.skipped);
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
r.stale = true;
// The sketch's own state and the final one show it: only that entry changes (the
// states are copied on the first frame; later frames own them and swap in place).
if (k == i || Some(k) == shown)
&& let Some(s) = Arc::make_mut(&mut r.state).sketches.iter_mut().find(|s| s.feature == feature)
{
*s = solved.clone();
}
}
true
}
/// Some results are stale ([`Model::show_live_sketch`]): the model needs an `evaluate`.
pub fn is_stale(&self) -> bool {
self.results.iter().any(|r| r.stale)
}
/// Store in `doc` the sketches whose last evaluation solved them for new driving values
/// (see `Document::solve_sketch`), so the stored sketch is the solved state the next value
/// edit continues from (edits depend on their history, as a user editing dimensions one
/// after another expects) and later evaluations repeat no continuation. Their results
/// stand for the updated document: the solved sketch is what evaluating it gives.
/// Returns how many sketches were stored.
pub fn adopt_solved_sketches(&mut self, doc: &mut Document) -> usize {
let solved: Vec<SolvedSketch> = self.edited_sketches(doc).cloned().collect();
let Some(first) = solved.iter().filter_map(|s| doc.features.iter().position(|f| f.id == s.feature)).min() else { return 0 };
for s in &solved {
if let Ok(stored) = doc.sketch_mut(s.feature) {
adopt_solution(stored, &s.sketch);
}
}
// The fingerprints from the first stored sketch on.
let vals = evaluation_values(doc).0;
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
for (i, f) in doc.features.iter().enumerate().skip(first) {
prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker);
if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) {
r.fingerprint = prev_fp;
}
}
solved.len()
}
/// The sketches the last evaluation solved for driving values other than the ones `doc`
/// stores them with (a value edit since they were stored).
pub fn edited_sketches<'a>(&'a self, doc: &'a Document) -> impl Iterator<Item = &'a SolvedSketch> + 'a {
self.results.iter().filter(|r| !r.skipped && r.error.is_none()).filter_map(|r| r.state.sketch(r.id)).filter(|s| {
s.report.keeps_orientation() && doc.sketch(s.feature).is_ok_and(|stored| stored.driving_values() != s.sketch.driving_values())
})
}
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
let (mut vals, perr) = doc.param_values();
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
&& scale != 1.0
{
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
}
let (vals, perr) = evaluation_values(doc);
self.param_errors = perr;
let mut prev_fp = 0u64;
let mut state = self.empty.clone();
@@ -598,40 +800,13 @@ impl Model {
let ctx = context_digest(doc, &vals);
for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker;
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
for r in &doc.sheet.rules {
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
// Plastic features take defaults from the plastic rules.
if f.kind.is_plastic() && !doc.plastic.is_empty() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
for r in &doc.plastic.rules {
for (_, e, _) in r.fields() {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
prev_fp = fp;
if reuse
&& let Some(old) = self.results.get(i)
&& old.id == f.id
&& old.fingerprint == fp
&& !old.stale
{
if !old.skipped {
state = old.state.clone();
@@ -649,6 +824,7 @@ impl Model {
boolean_diagnostics: Default::default(),
ms: 0.0,
skipped: true,
stale: false,
fingerprint: fp,
state: state.clone(),
});
@@ -671,6 +847,7 @@ impl Model {
boolean_diagnostics: c.boolean_diagnostics,
ms: c.ms,
skipped: false,
stale: false,
fingerprint: fp,
state: state.clone(),
});
@@ -775,6 +952,7 @@ impl Model {
boolean_diagnostics,
ms,
skipped: false,
stale: false,
fingerprint: fp,
state: state.clone(),
});
@@ -2920,15 +3098,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if let Some(w) = face_warning {
warns.insert(0, w);
}
doc.apply_dimension_values(vals, &mut sk)?;
let mut report = solve(&mut sk);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = solve(&mut sk);
}
if !report.ok() {
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
// The stored sketch is solved for its stored values.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
}
if !warns.is_empty() {
*warning = Some(warns.join("; "));
@@ -2936,7 +3110,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
sk.validate_periodic_splines()?;
let profiles = find_profiles(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }));
Ok(())
}
FeatureKind::Extrude { operation, targets, .. }
@@ -3580,6 +3754,12 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
}
}
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
// Only a sketch the frame moves is copied.
let maps_wire = |w: &solvecraft_sketch::Wire| !crate::is_identity(if w.space == solvecraft_sketch::WireSpace::Component { m } else { &inv });
if crate::is_identity(m) && !s.sketch.wires.iter().any(maps_wire) {
continue;
}
let s = Arc::make_mut(s);
if !crate::is_identity(m)
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
{
@@ -3646,7 +3826,12 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
}
if primary {
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
let mut s2 = s.clone();
if ident {
// In place: shared as it is.
out.sketches.push(s.clone());
continue;
}
let mut s2 = SolvedSketch::clone(s);
if !ident {
let p = &s.plane;
if let Some(np) = Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)) {
@@ -3658,7 +3843,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
map_wire(wire, &m);
}
}
out.sketches.push(s2);
out.sketches.push(Arc::new(s2));
}
for t in st
.threads
+3 -1
View File
@@ -32,7 +32,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
@@ -312,6 +312,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
// What the geometry reads: every feature's length inputs.
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
let before = s.doc.length_inputs();
let solved = s.solved_sketches();
s.doc_mut().units = u.to_string();
// Unit-less user parameters holding a bare number read it in the design's units where they
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
@@ -346,6 +347,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(row) = matched_configuration {
s.doc_mut().apply_configuration(&row)?;
}
s.settle_value_edit(&solved)?;
Ok(json!({ "units": u, "kept": kept }))
}
+2 -1
View File
@@ -393,8 +393,9 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
}
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
let solved = s.solved_sketches();
s.doc = trial.doc;
s.refresh();
s.settle_value_edit(&solved)?;
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
let mut out = show(s, p)?;
out["errors"] = json!(errors);
+1 -2
View File
@@ -19,8 +19,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "De
/// Run another command's body inside this one (so the whole delete is one undo step).
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(s, &p)?;
let r = spec.run_nested(s, &p)?;
s.refresh();
Ok(r)
}
+24 -18
View File
@@ -8,8 +8,8 @@ use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
@@ -51,28 +51,35 @@ pub static COMMANDS: &[CommandSpec] = &[
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
let label = prev.label.clone();
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
s.doc = prev.doc;
s.active_sketch = prev.active_sketch;
s.pending_moves = prev.pending_moves;
s.selection.clear();
s.refresh();
let now = go_to(s, prev);
let label = now.label.clone();
s.redo.push(now);
Ok(json!({"undone": label}))
}
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
let label = next.label.clone();
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
s.doc = next.doc;
s.active_sketch = next.active_sketch;
s.pending_moves = next.pending_moves;
s.selection.clear();
s.refresh();
let now = go_to(s, next);
let label = now.label.clone();
s.undo.push(now);
Ok(json!({"redone": label}))
}
/// Put the session at `snap`; returns where it was, under the same label.
fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot {
let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() };
let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc);
s.doc = snap.doc;
s.active_sketch = snap.active_sketch;
s.pending_moves = snap.pending_moves;
s.selection.clear();
// A new document is evaluated by `execute`.
if same {
s.refresh();
}
was
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
@@ -477,8 +484,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
}
trial.doc_mut().marker = Some(idx + 1);
trial.refresh();
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(&mut trial, &params)?;
let r = spec.run_nested(&mut trial, &params)?;
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
+11 -6
View File
@@ -23,10 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, name?, body_name?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, name?, body_name?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, name?, body_name?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
@@ -413,9 +413,14 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
solvecraft_doc::naming::face_names_at(&s.model.state(), &pts)
}
};
let participants = string_list(p, "participants");
// Primitives have no `targets` of their own: their `targets` are the bodies taking part (#259).
let primitive = matches!(kind, FeatureKind::Box { .. } | FeatureKind::Cylinder { .. } | FeatureKind::Sphere { .. } | FeatureKind::Torus { .. });
let (participants, key) = match string_list(p, "participants") {
v if v.is_empty() && primitive => (string_list(p, "targets"), "targets"),
v => (v, "participants"),
};
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other("`participants` must list existing bodies".into()));
return Err(EngineError::Other(format!("`{key}` must list existing bodies")));
}
if matches!(kind, FeatureKind::ConstructionPlane { .. })
&& let Some(n) = name
+153 -1
View File
@@ -133,6 +133,11 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
}
let bytes = solvecraft_io::vfs::read(path, solvecraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solvecraft_io::read_design(&bytes)?;
if let Some(notify) = s.open_preview.take()
&& let Some(preview) = solvecraft_io::read_design_preview(&bytes)
{
notify(preview);
}
*s = Session::new(doc);
s.path = Some(path.to_string());
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
@@ -146,7 +151,7 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str) -> Result
_ => return Err(bad("file.save", "compression must be fast or compact")),
};
let bytes = match format {
"container" => solvecraft_io::write_design_container_with_compression(&s.doc, compression)?,
"container" => solvecraft_io::write_design_container_with_preview(&s.doc, compression, saved_preview(s).as_ref())?,
"json" => solvecraft_io::write_design(&s.doc),
_ => return Err(bad("file.save", "format must be container or json")),
};
@@ -157,6 +162,36 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str) -> Result
Ok(json!({"path": path, "bytes": bytes.len()}))
}
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
fn saved_preview(s: &Session) -> Option<solvecraft_io::DesignPreview> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
let world = s.cached_world_state()?;
if world.bodies.is_empty() || world.bodies.len() > 1024 {
return None;
}
let mut preview = solvecraft_io::DesignPreview::default();
let mut bytes = 0usize;
let mut triangles = 0usize;
for body in &world.bodies {
let mesh = body.cached_mesh()?;
bytes =
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
triangles = triangles.saturating_add(mesh.triangles.len());
if bytes > 8 << 20 || triangles > 100_000 {
return None;
}
preview.meshes.push(solvecraft_doc::MeshData {
name: body.name.clone(),
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: s.doc.body_color(body),
});
}
Some(preview)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match str_(p, "path") {
Some(x) if !x.trim().is_empty() => x.to_string(),
@@ -249,6 +284,123 @@ fn recovery_discard(_s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
#[test]
#[ignore = "manual warmed engine save and provisional-open benchmark"]
fn benchmark_warm_design_save_with_preview() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
// The mesh storage fixture intentionally does not evaluate geometry. Use
// original valid STEP boxes for the engine/display-cache measurement.
let input = target.join("issue303-step.json");
let doc = solvecraft_io::read_design(&std::fs::read(input).unwrap()).unwrap();
let mut session = Session::new(doc);
for body in &session.world_state().bodies {
body.mesh();
}
let mut samples = [Vec::new(), Vec::new()];
for round in 0..9 {
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } {
let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solvecraft" });
let began = std::time::Instant::now();
session.execute("file.save", &json!({"path":path})).unwrap();
samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
}
}
for s in &mut samples {
s.sort_by(f64::total_cmp);
}
let bytes = std::fs::read(target.join("issue303-warm.solvecraft")).unwrap();
assert!(solvecraft_io::read_design_preview(&bytes).is_some());
assert_eq!(solvecraft_io::read_design(&bytes).unwrap(), *session.doc);
let mut reads = Vec::new();
for _ in 0..9 {
let began = std::time::Instant::now();
assert!(solvecraft_io::read_design_preview(&bytes).is_some());
reads.push(began.elapsed().as_secs_f64() * 1000.0);
}
reads.sort_by(f64::total_cmp);
eprintln!(
"warmed engine median atomic save ms json={:.3} container_with_preview={:.3}; preview_decode_ms={:.3}; container_bytes={}",
samples[0][4],
samples[1][4],
reads[4],
bytes.len()
);
}
#[test]
fn save_uses_only_warm_meshes_and_open_preview_does_not_replace_evaluation() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
assert!(saved_preview(&s).is_none(), "save must not triangulate a cold body");
let state = s.world_state();
for body in &state.bodies {
body.mesh();
}
let mut preview = saved_preview(&s).unwrap();
// A checksum-valid but forged cached shape must not become editable geometry.
for mesh in &mut preview.meshes {
for coordinate in &mut mesh.positions {
*coordinate *= 10.0;
}
}
let bytes = solvecraft_io::write_design_container_with_preview(&s.doc, solvecraft_io::DesignCompression::Fast, Some(&preview)).unwrap();
let path = std::env::temp_dir().join(format!("solvecraft-preview-{}.solvecraft", std::process::id()));
std::fs::write(&path, bytes).unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let callback_seen = seen.clone();
let mut reopened = Session {
open_preview: Some(Box::new(move |preview| {
callback_seen.fetch_add(preview.meshes.len(), std::sync::atomic::Ordering::Relaxed);
})),
..Session::default()
};
reopened.execute("doc.open", &json!({"path":path})).unwrap();
assert_eq!(seen.load(std::sync::atomic::Ordering::Relaxed), 1);
assert!(reopened.open_preview.is_none());
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.0).abs() < 1e-6);
assert_eq!(*reopened.doc, *s.doc);
std::fs::remove_file(path).unwrap();
}
#[test]
fn preview_saves_do_not_build_cold_placements_or_persist_transient_edits() {
let mut s = Session::default();
let created = s.execute("component.create", &json!({"name":"PreviewPart"})).unwrap();
let occurrence = created["occurrence"].as_u64().unwrap();
s.execute("solid.box", &json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"})).unwrap();
for body in &s.model.state().bodies {
body.mesh();
}
*s.world_cache.lock().unwrap() = None;
assert!(saved_preview(&s).is_none());
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
for body in &s.world_state().bodies {
body.mesh();
}
assert!(saved_preview(&s).is_some());
s.execute("occurrence.move", &json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false})).unwrap();
for body in &s.world_state().bodies {
body.mesh();
}
assert!(!s.pending_moves.is_empty());
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
s.execute("component.capture_position", &json!({})).unwrap();
for body in &s.world_state().bodies {
body.mesh();
}
let preview = saved_preview(&s).unwrap();
let min_x = preview.meshes.iter().flat_map(|mesh| mesh.positions.as_chunks::<3>().0.iter().map(|p| p[0])).fold(f64::INFINITY, f64::min);
assert!((min_x - 10.0).abs() < 1e-9);
let mut editing = s.scratch();
editing.execute("sketch.create", &json!({"plane":"XY"})).unwrap();
for body in &editing.world_state().bodies {
body.mesh();
}
assert!(editing.active_sketch.is_some());
assert!(saved_preview(&editing).is_none());
}
#[test]
fn design_save_formats_reopen_and_preserve_atomic_backups() {
let dir = std::env::temp_dir().join(format!("solvecraft-container-{}", std::process::id()));
+1 -1
View File
@@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
+40 -1
View File
@@ -57,6 +57,10 @@ use serde_json::Value;
use crate::{Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
/// A command's form for one frame of an interactive gesture: its result and the sketch it
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
/// evaluate as usual).
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub struct CommandSpec {
@@ -74,11 +78,40 @@ pub struct CommandSpec {
pub enabled: Enabled,
pub run: Run,
pub undoable: bool,
/// What runs instead of `run` when the command is given `live: true` (a frame of an
/// interactive gesture, see [`Session::execute`]).
pub live: Option<LiveRun>,
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
CommandSpec {
id,
label,
tab: "",
panel: "",
icon: "",
shortcut: None,
params: "",
enabled: always,
run,
undoable: true,
live: None,
reads_model: true,
}
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
self
}
/// Reads only the document (and history), never the evaluated model.
pub const fn doc_only(mut self) -> Self {
self.reads_model = false;
self
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
@@ -105,6 +138,12 @@ impl CommandSpec {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
(self.run)(s, p)
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
+1 -1
View File
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("joint")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
+12 -3
View File
@@ -15,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
),
@@ -38,9 +38,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
.noundo()
.doc_only()
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"),
CommandSpec::new("parameters.complete", "Complete Expression", complete)
.noundo()
.doc_only()
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
];
@@ -67,8 +69,10 @@ fn known(s: &Session, name: &str) -> bool {
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.change";
let solved = s.solved_sketches();
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
s.settle_value_edit(&solved)?;
return Ok(json!({"deleted": n.trim()}));
}
let mut name = param_name(cmd, p)?.to_string();
@@ -101,7 +105,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = bool_(p, "favorite") {
s.doc_mut().set_favorite(&name, f)?;
}
s.refresh();
s.settle_value_edit(&solved)?;
let (vals, _) = s.doc.param_values();
out["name"] = json!(name);
out["expression"] = json!(e);
@@ -140,7 +144,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.add";
let name = param_name(cmd, p)?.to_string();
let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?;
let solved = s.solved_sketches();
s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?;
s.settle_value_edit(&solved)?;
if let Some(f) = bool_(p, "favorite") {
s.doc_mut().set_favorite(&name, f)?;
}
@@ -160,7 +166,9 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = param_name("parameters.delete", p)?.to_string();
let solved = s.solved_sketches();
s.doc_mut().remove_param(&name)?;
s.settle_value_edit(&solved)?;
Ok(json!({"deleted": name}))
}
@@ -238,8 +246,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(cmd, "give `text` or `path`")),
};
let solved = s.solved_sketches();
let names = s.doc_mut().import_params(&text)?;
s.refresh();
s.settle_value_edit(&solved)?;
Ok(json!({"imported": names, "errors": failing(s)}))
}
+305 -4
View File
@@ -67,9 +67,11 @@ fn width_follows_an_expression() {
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
/// for "nothing changed". timeline.edit reports the same.
/// Setting a value back reuses the earlier results and says so: the features it restored are
/// reported next to `recomputed`, so `recomputed: 0` is not mistaken for "nothing changed". A
/// sketch dimension set back solves the sketch again from its current state (value edits
/// continue from the last solved state); the features after it see the same geometry and are
/// restored.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
@@ -77,7 +79,7 @@ fn setting_a_parameter_back_reports_restored_features() {
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
@@ -243,3 +245,302 @@ fn csv_and_json_round_trip() {
assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
let _ = std::fs::remove_dir_all(&dir);
}
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
/// Returns the session, the sketch feature and the nine dimensions' parameters.
fn linkage_session() -> (Session, u64, Vec<String>) {
let layout = [[0.0, 0.0], [40.0, 0.0], [18.0, 30.0], [8.0, 55.0], [52.0, 48.0], [30.0, 80.0]];
let pairs = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
let ids = ["a", "b", "c", "d", "e", "f"];
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Linkage"}));
for (id, at) in ids.iter().zip(layout) {
run(&mut s, "sketch.point", json!({"point": at, "id": id}));
}
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["a", "b"], "mode": "horizontal"}));
let params = pairs
.iter()
.map(|&(i, j)| run(&mut s, "sketch.dimension", json!({"entities": [ids[i], ids[j]]}))["param"].as_str().unwrap_or_default().to_string())
.collect();
let feature = s.doc.find_feature("Linkage").map(|f| f.id).unwrap_or_default();
(s, feature, params)
}
/// Edited values the iteration cannot reach in one jump from the layout.
const REACHED_BY_CONTINUATION: [f64; 9] = [
49.75798473660107,
44.249179252138724,
28.66332553713371,
49.725624597008775,
45.81086166277159,
35.887660702567544,
45.64700450204954,
50.756441510188495,
63.72063313963373,
];
/// Turn of both triangles in the evaluated sketch.
fn linkage_turns(s: &Session, feature: u64) -> [bool; 2] {
let state = s.model.state();
let sk = &state.sketch(feature).unwrap().sketch;
let p = |id: &str| sk.point(sk.resolve_point(id).unwrap()).unwrap();
[(p("b") - p("a")).cross(p("c") - p("a")) > 0.0, (p("e") - p("d")).cross(p("f") - p("d")) > 0.0]
}
/// Set the parameters in the document only (as an edit that does not store the sketch).
fn set_params(s: &mut Session, params: &[String], values: &[f64]) {
let changes: Vec<(String, String)> = params.iter().zip(values).map(|(name, value)| (name.clone(), format!("{value} mm"))).collect();
s.doc_mut().change_params_atomic(&changes).unwrap();
}
#[test]
fn dimension_edits_of_a_triconnected_linkage_continue_and_store_the_solved_sketch() {
// The edit steps to the new values from the stored sketch, keeping both triangles' turn,
// and stores what it reached: evaluating the document afresh needs no continuation.
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let before = linkage_turns(&s, feature);
// All nine at once: one at a time, some intermediate values have no solution.
let batch: Vec<_> = params.iter().zip(REACHED_BY_CONTINUATION).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
run(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
let solved = s.model.state().sketch(feature).unwrap().report.clone();
assert!(solved.ok(), "{solved:?}");
assert_eq!(linkage_turns(&s, feature), before);
let mut fresh = solvecraft_doc::Model::default();
fresh.evaluate(&s.doc);
let again = fresh.state().sketch(feature).unwrap().report.clone();
assert!(again.ok() && again.continuations == 0, "{again:?}");
// The kept results are what evaluating the stored document gives.
let (kept, afresh) = (s.model.state(), fresh.state());
let pairs = kept.sketch(feature).unwrap().sketch.points.iter().zip(&afresh.sketch(feature).unwrap().sketch.points);
assert!(pairs.into_iter().all(|(a, b)| a.pos.dist(b.pos) < 1e-9));
// In one go (one evaluation from the layout) it needs continuation.
let (mut t, feature, params) = linkage_session();
run(&mut t, "sketch.finish", json!({}));
set_params(&mut t, &params, &REACHED_BY_CONTINUATION);
t.refresh();
let once = t.model.state().sketch(feature).unwrap().report.clone();
assert!(once.ok() && once.continuations > 0, "{once:?}");
}
#[test]
fn extending_after_a_parameter_edit_continues_from_the_stored_sketch() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.line", json!({"points": [[100, 0], [110, 0]], "ids": ["ext"]}));
run(&mut s, "sketch.line", json!({"points": [[130, -20], [130, 20]], "ids": ["wall"]}));
let before = linkage_turns(&s, feature);
set_params(&mut s, &params, &REACHED_BY_CONTINUATION);
run(&mut s, "sketch.extend", json!({"curve": "ext", "at": [110, 0]}));
let solved = s.model.state().sketch(feature).unwrap().report.clone();
assert!(solved.ok(), "{solved:?}");
assert_eq!(linkage_turns(&s, feature), before);
}
#[test]
fn a_failed_edit_keeps_the_stored_sketch_solved_for_its_values() {
// Parameters no geometry satisfies, then an edit that does not insist on a solution: the
// stored dimensions keep the values its geometry is solved for.
let (mut s, feature, params) = linkage_session();
let mut values = REACHED_BY_CONTINUATION;
values[7] = 500.0;
set_params(&mut s, &params, &values);
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
let stored = s.doc.sketch(feature).unwrap();
for c in stored.constraints.iter().filter(|c| c.param.is_some()) {
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
assert!((measured - c.kind.value().unwrap()).abs() < 1e-6, "{}: {measured} vs {:?}", c.id, c.kind.value());
}
}
/// Values the linkage reaches only with its top triangle mirrored.
const FLIPPING: [f64; 9] = [
26.483003374917082,
90.0253808040292,
84.67215428223084,
54.580784880976275,
68.12871394439186,
30.98508537669166,
116.94895853350752,
103.75535113133466,
32.83103173640168,
];
/// All nine parameters set by one command.
fn import_values(params: &[String], values: &[f64]) -> Value {
let batch: Vec<_> = params.iter().zip(values).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
json!({"text": serde_json::to_string(&batch).unwrap()})
}
#[test]
fn an_edit_that_would_flip_the_sketch_is_refused_and_named() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let (points, before) = (stored_points(&s, feature), params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>());
let why = err(&mut s, "parameters.import", import_values(&params, &FLIPPING));
assert!(why.contains("Linkage") && why.contains("flip the sketch over"), "{why}");
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
assert_eq!(stored_points(&s, feature), points);
assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation());
}
#[test]
fn a_design_stored_with_other_values_still_rebuilds() {
// A design whose stored sketch is not solved for its parameters (saved before value edits
// stored their solve): evaluating it never refuses. The flipped solve is kept, with a
// warning, the flipped constraints listed as conflicts, and the solid built from it.
let (mut s, feature, params) = linkage_session();
for line in [[[8.0, 55.0], [52.0, 48.0]], [[52.0, 48.0], [30.0, 80.0]], [[30.0, 80.0], [8.0, 55.0]]] {
run(&mut s, "sketch.line", json!({"points": line}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Linkage", "distance": 5}));
set_params(&mut s, &params, &FLIPPING);
let s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let state = s.model.state();
let solved = state.sketch(feature).unwrap();
assert!(solved.report.ok() && !solved.report.folded.is_empty(), "{:?}", solved.report);
let warning = s.model.result(feature).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("flip the sketch over") && solved.report.folded.iter().all(|id| warning.contains(id.as_str())), "{warning}");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(state.bodies.len(), 1);
// Listed as conflicts wherever conflicts are shown.
let mut s = s;
s.active_sketch = Some(feature);
let si = run(&mut s, "sketch.inspect", json!({}));
let glyphs = run(&mut s, "sketch.glyphs", json!({}));
for id in &solved.report.folded {
assert!(si["failing"].as_array().unwrap().contains(&json!(id)), "{si}");
assert!(glyphs["conflicts"].as_array().unwrap().contains(&json!(id)), "{glyphs}");
}
}
#[test]
fn an_edit_that_breaks_a_projecting_sketch_downstream_applies_with_a_warning() {
// `w` places a guide line; a second sketch projects it and ends a 12 mm line on it. Moving
// the guide out of reach is the guide sketch's edit, not a value edit of the projecting
// sketch: it applies, and the projecting sketch carries a warning.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"}));
run(&mut s, "sketch.line", json!({"points": [[10, -30], [10, 30]], "ids": ["guide"], "connect": false}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "guide", "mode": "vertical"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "guide.start"], "type": "horizontal", "value": "w"}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Reach"}))["sketch"].as_u64().unwrap();
let projected = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "Guide", "curve": "guide"}]}))["links"][0]["curves"][0]
.as_str()
.unwrap()
.to_string();
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -(44.0_f64).sqrt()]]}))["curves"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.dimension", json!({"entities": [line], "value": 12}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": projected}));
run(&mut s, "sketch.finish", json!({}));
assert!(s.model.state().sketch(id).unwrap().report.ok());
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert_eq!(r["value"], json!(20.0), "the edit applies: {r}");
assert!(!s.model.state().sketch(id).unwrap().report.ok());
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("conflict")));
}
#[test]
fn a_failed_auto_constrain_keeps_the_stored_sketch_consistent() {
// Auto-constrain works on the current values: reached by continuation, its result is
// stored solved for them; values no geometry satisfies refuse it and change nothing.
let consistent = |s: &Session, feature: u64| {
let stored = s.doc.sketch(feature).unwrap();
stored.constraints.iter().filter(|c| c.param.is_some()).all(|c| {
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
(measured - c.kind.value().unwrap()).abs() < 1e-6
})
};
let (mut s, feature, params) = linkage_session();
let mut values = REACHED_BY_CONTINUATION;
values[7] = 500.0;
set_params(&mut s, &params, &values);
let before = s.doc.sketch(feature).unwrap().clone();
let why = err(&mut s, "sketch.auto_constrain", json!({}));
assert!(why.contains("conflict"), "{why}");
assert_eq!(*s.doc.sketch(feature).unwrap(), before);
assert!(consistent(&s, feature));
set_params(&mut s, &params, &REACHED_BY_CONTINUATION);
run(&mut s, "sketch.auto_constrain", json!({}));
assert!(consistent(&s, feature));
let stored = s.doc.sketch(feature).unwrap();
for (c, v) in stored.constraints.iter().filter(|c| c.param.is_some()).zip(REACHED_BY_CONTINUATION) {
assert!((c.kind.value().unwrap() - v).abs() < 1e-9, "{}", c.id);
}
}
/// The stored sketch's points, by id.
fn stored_points(s: &Session, feature: u64) -> Vec<solvecraft_geom::Vec2> {
s.doc.sketch(feature).unwrap().points.iter().map(|p| p.pos).collect()
}
#[test]
fn an_unsolvable_value_edit_is_refused_and_changes_nothing() {
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let (expr, points) = (s.doc.param(&params[7]).map(|p| p.expr.clone()), stored_points(&s, feature));
let why = err(&mut s, "parameters.change", json!({"name": params[7], "expression": "500 mm"}));
assert!(why.contains("Linkage") && why.contains("conflict"), "{why}");
assert_eq!(s.doc.param(&params[7]).map(|p| p.expr.clone()), expr);
assert_eq!(stored_points(&s, feature), points);
assert!(s.model.state().sketch(feature).unwrap().report.ok());
// A batch with one such value: none of it.
let before: Vec<_> = params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect();
let batch: Vec<_> = params
.iter()
.enumerate()
.map(|(i, n)| json!({"name": n, "expression": if i == 7 { "500 mm".to_string() } else { format!("{} mm", REACHED_BY_CONTINUATION[i]) }}))
.collect();
let why = err(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
assert!(why.contains("Linkage"), "{why}");
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
assert_eq!(stored_points(&s, feature), points);
}
#[test]
fn value_edits_continue_from_the_last_solved_state() {
// A → B → C, each a small edit solved in one jump; C is solved from B (the stored state),
// and setting both values back reproduces A (the linkage is fully constrained).
let close = |a: &[solvecraft_geom::Vec2], b: &[solvecraft_geom::Vec2], tol: f64| a.iter().zip(b).all(|(p, q)| p.dist(*q) < tol);
let (mut s, feature, params) = linkage_session();
run(&mut s, "sketch.finish", json!({}));
let a = stored_points(&s, feature);
let (p7, p8) = (s.doc.param(&params[7]).unwrap().expr.clone(), s.doc.param(&params[8]).unwrap().expr.clone());
run(&mut s, "parameters.change", json!({"name": params[8], "expression": "53 mm"}));
let b_sketch = s.doc.sketch(feature).unwrap().clone();
let b = stored_points(&s, feature);
assert!(!close(&a, &b, 1e-3) && s.model.state().sketch(feature).unwrap().report.continuations == 0);
run(&mut s, "parameters.change", json!({"name": params[7], "expression": "50 mm"}));
let c = stored_points(&s, feature);
// The same edit solved from B directly.
let mut from_b = b_sketch.clone();
let stored = from_b.driving_values();
let (vals, _) = s.doc.param_values();
s.doc.apply_dimension_values(&vals, &mut from_b).unwrap();
assert!(solvecraft_sketch::solve_after_value_edit(&mut from_b, &stored).ok());
assert!(close(&c, &from_b.points.iter().map(|p| p.pos).collect::<Vec<_>>(), 1e-6) && !close(&c, &b, 1e-3));
run(&mut s, "parameters.change", json!({"name": params[7], "expression": p7}));
run(&mut s, "parameters.change", json!({"name": params[8], "expression": p8}));
assert!(close(&stored_points(&s, feature), &a, 1e-6));
}
#[test]
fn switching_configurations_back_reuses_the_later_features() {
// Rows A and B of `len`: back on A, the sketch solves again from B's state to A's
// geometry, and the extrude after it is restored from the cache.
let mut s = Session::default();
block(&mut s);
run(&mut s, "config.configure", json!({"params": ["len"]}));
run(&mut s, "config.row", json!({"name": "A", "values": {"len": "60 mm"}}));
run(&mut s, "config.row", json!({"name": "B", "values": {"len": "100 mm"}}));
run(&mut s, "config.activate", json!({"row": "B"}));
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
let before = hits(&s);
let r = run(&mut s, "config.activate", json!({"row": "A"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
assert!(hits(&s) > before, "{r}");
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
+212 -112
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_with};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
@@ -11,9 +11,12 @@ use crate::{EngineError, Result, Session};
#[path = "sketch_dimensioned.rs"]
mod dimensioned;
#[path = "sketch_drag.rs"]
mod drag;
#[path = "sketch_face.rs"]
pub(super) mod face;
use dimensioned::draw as draw_dimensioned;
use drag::drag_entity;
/// The `connect` parameter every drawing command takes (see `connect_points`).
macro_rules! connect_doc {
@@ -170,8 +173,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params("entities: [curve, point or constraint ids]"),
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can",
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
),
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params(
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
),
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text)
.enabled(in_sketch)
@@ -219,25 +225,143 @@ pub(super) fn edit<T>(
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<T>,
) -> Result<(T, Value)> {
let id = target_sketch(s, p, cmd)?;
let (out, rep) = edit_solved(s, id, strict, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
}
/// A solve's outcome as commands return it.
pub(super) fn report_json(rep: &SolveReport) -> Value {
json!({"solved": rep.keeps_orientation(), "dof": rep.dof})
}
/// What an [`edit_solved`] step leaves for the edit's own solve.
pub(super) enum Left {
/// The sketch, solved from where it is.
Unsolved,
/// The sketch, solved keeping the orientation of these positions (the last solved ones,
/// before a drag left a step placed but unsolved).
MovedFrom(Vec<Vec2>),
/// The sketch, solved with the dimensions' values (from the parameters) applied.
Solved,
}
impl Left {
/// Solved again by the edit even when already solved (for its report).
pub(super) fn solve_again(self) -> Left {
match self {
Left::Solved => Left::Unsolved,
left => left,
}
}
}
/// `edit` of sketch `id` for a step that may solve the sketch itself: when it returns
/// [`Left::Solved`] (and offsets need not be recomputed) the sketch is not solved again and the
/// report is `None`.
pub(super) fn edit_solved<T>(
s: &mut Session,
id: u64,
strict: bool,
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(T, Left)>,
) -> Result<(T, Option<SolveReport>)> {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
super::sketch_project::refresh(s, &doc, id, &mut sk);
let out = f(&mut sk, &mut doc)?;
let (vals, _) = doc.param_values();
doc.apply_dimension_values(&vals, &mut sk)?;
sk.refresh_offsets()?;
let mut rep = solve(&mut sk);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
sk.refresh_offsets()?;
rep = solve(&mut sk);
}
if strict && !rep.ok() {
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
}
// The stored sketch is solved for its stored values.
let stored = sk.driving_values();
let (out, left) = f(&mut sk, &mut doc)?;
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
let rep = match left {
// A drag step solved it (held, with the dimensions' values applied).
Left::Solved if !offsets => None,
// A drag left a step placed but unsolved: solve it keeping the orientation of the last
// solved positions. A drag changes no values, so there is nothing to continue.
Left::MovedFrom(reference) => {
doc.apply_dimension_values(&doc.param_values().0, &mut sk)?;
sk.refresh_offsets()?;
let unsolved = sk.clone();
let mut rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
if offsets {
sk.refresh_offsets()?;
rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
}
Some(settle(strict, rep, &mut sk, unsolved)?)
}
// Solved for the document's values, continuing from the stored solved state.
Left::Solved | Left::Unsolved => {
let (vals, _) = doc.param_values();
sk.refresh_offsets()?;
let unsolved = sk.clone();
#[cfg(test)]
solve_counts::record(Analysis::Full);
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
Some(settle(strict, rep, &mut sk, unsolved)?)
}
};
sk.validate_periodic_splines()?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
Ok((out, rep))
}
/// An edit's solve outcome: with `strict`, a solve that fails or turns the sketch over is an
/// error; otherwise such a sketch is stored as the edit left it before the solve, its values
/// and geometry together, so the stored sketch stays solved for its stored values.
fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) -> Result<SolveReport> {
if strict && !rep.keeps_orientation() {
return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})"))));
}
if !rep.keeps_orientation() {
*sk = unsolved;
}
Ok(rep)
}
/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]).
pub(super) fn counted_solve(
sk: &mut Sketch,
hold: &Hold,
reference: Option<&[Vec2]>,
analysis: Analysis,
budget: Option<&mut Budget>,
) -> SolveReport {
#[cfg(test)]
solve_counts::record(analysis);
solve_with(sk, hold, reference, analysis, budget)
}
/// Inside an `edit` closure: solve the sketch for the document's parameter values from its
/// stored solved state, as the edit's own solve does, for commands that work on the current
/// geometry.
pub(super) fn solve_current(sk: &mut Sketch, doc: &solvecraft_doc::Document) -> Result<SolveReport> {
let stored = sk.driving_values();
let (values, _) = doc.param_values();
Ok(doc.solve_sketch(&values, sk, &stored)?)
}
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
/// with a rank analysis).
#[cfg(test)]
pub(super) mod solve_counts {
use std::cell::Cell;
use solvecraft_sketch::Analysis;
thread_local! {
static COUNTS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
}
pub fn record(analysis: Analysis) {
COUNTS.with(|c| {
let (n, full) = c.get();
c.set((n + 1, full + usize::from(analysis == Analysis::Full)));
});
}
/// The counts since the last call.
pub fn take() -> (usize, usize) {
COUNTS.with(|c| c.replace((0, 0)))
}
}
/// How close (mm) a new point must land to an existing one to be connected to it.
@@ -1165,7 +1289,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
driven: false,
text: None,
}));
let ok = solve_holding(&mut trial, &h).ok();
let ok = solve_with(&mut trial, &h, None, Analysis::Full, None).ok();
trial.constraints.retain(|c| c.id != TEMP);
if ok && !collapsed(sk, &trial) {
*sk = trial;
@@ -1240,7 +1364,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
{
let rep = solve(&mut sk.clone());
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)}));
}
constrain(s, p, cmd, |sk| {
if let Some((a, b)) = line_points {
@@ -1823,109 +1947,82 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move_point";
let to = req_vec2(cmd, p, "to")?;
let (_, info) = edit(s, p, cmd, false, |sk, _| {
let id = target_sketch(s, p, cmd)?;
let (_, rep) = edit_solved(s, id, false, |sk, _| {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
drag_entity(sk, Ent::Point(q), to, to);
Ok(())
Ok(((), drag_entity(sk, Ent::Point(q), to, to, false).1.solve_again()))
})?;
Ok(json!({"sketch": info}))
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
}
fn drag(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.drag";
let to = req_vec2(cmd, p, "to")?;
let from = p.get("from").and_then(vec2).unwrap_or(to);
let (_, info) = edit(s, p, cmd, false, |sk, _| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
drag_entity(sk, e, from, to);
Ok(())
})?;
Ok(json!({"sketch": info}))
drag_frame(s, p, false).map(|(v, _)| v)
}
/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line,
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and
/// fully constrained geometry stays (the solve that follows pulls it back).
fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
let d = to - from;
let mut hold = Hold::default();
match e {
Ent::Point(q) => {
if sk.point_locked(q) {
return;
}
if let Some(pt) = sk.points.get_mut(q) {
pt.pos = to;
}
hold.points.push(q);
}
Ent::Curve(c) => {
if sk.curve_locked(c) {
return;
}
let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return };
let locked = sk.locked_points();
let set = |sk: &mut Sketch, q: usize, at: Vec2| {
if !locked.get(q).copied().unwrap_or(true)
&& let Some(pt) = sk.points.get_mut(q)
{
pt.pos = at;
}
};
match kind {
CurveKind::Circle { c: cc, .. } => {
let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0);
if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
&& r > 1e-9
{
*slot = r;
}
hold.points.push(cc);
hold.radii.push(c);
}
CurveKind::Arc { c: cc, a, b } => {
let Some(o) = sk.point(cc) else { return };
let r = o.dist(to);
if r > 1e-9 {
for q in [a, b] {
if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) {
set(sk, q, o + u * r);
}
}
}
hold.points.extend([cc, a, b]);
}
CurveKind::Ellipse { c: cc, m, .. } => {
// Grabbed on the outline: the minor radius follows (the major stays).
let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return };
let u = (pm - o).normalized().unwrap_or(Vec2::X);
let minor = u.cross(to - o).abs();
if minor > 1e-9
&& let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
{
*slot = minor;
}
hold.points.extend([cc, m]);
hold.radii.push(c);
}
k => {
// Lines, splines and conics slide with the cursor.
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
if let Some(at) = sk.point(q) {
set(sk, q, at + d);
}
}
hold.points.extend(k.point_ids());
}
}
}
fn drag_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
drag_frame(s, p, true)
}
/// `sketch.drag`; `live`: one frame of an interactive drag, returned with the sketch to show.
fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<crate::LiveEdit>)> {
let cmd = "sketch.drag";
let to = req_vec2(cmd, p, "to")?;
let from = match p.get("from") {
None | Some(Value::Null) => to,
Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y]"))?,
};
let id = target_sketch(s, p, cmd)?;
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
// The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, from, to, live);
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
// its degrees of freedom.
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
})?;
// Where the grabbed spot went: `from` moved as the dragged points did on average (with the
// whole step when it followed; otherwise as the sketch's own solve left them).
let sk = s.doc.sketch(id)?;
let moves: Vec<Vec2> = d.moving.iter().filter_map(|&(q, was)| Some(sk.point(q)? - was)).collect();
let at = from + moves.iter().fold(Vec2::ZERO, |a, m| a + *m) * (1.0 / moves.len().max(1) as f64);
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solvecraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y]});
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
}
/// Ends a live drag: the snap constraint it let go on, if any, then one evaluation of the model.
/// It reads the model only before the sketch it edits (never stale), so `execute` does not
/// evaluate first.
fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.drag_end";
let id = target_sketch(s, p, cmd)?;
let start = s.doc.clone();
let mut constraint = Value::Null;
if let Some(c) = p.get("constraint").filter(|c| !c.is_null()) {
let name = c.get("command").and_then(Value::as_str).unwrap_or_default();
let spec = super::find_command(name)
.filter(|spec| spec.id.starts_with("sketch.constraint."))
.ok_or_else(|| bad(cmd, "`constraint.command` must be a sketch.constraint.* command"))?;
let params = c.get("params").cloned().unwrap_or(Value::Null);
// A constraint that cannot be added (it would over-constrain the sketch) is not kept.
constraint = spec.run_nested(s, &params).unwrap_or_else(|e| {
s.doc = start.clone();
json!({"error": e.to_string()})
});
}
let mut trial = sk.clone();
if solve_holding(&mut trial, &hold).ok() {
*sk = trial;
// A new constraint changed the document: `execute` evaluates the model after this.
if !std::sync::Arc::ptr_eq(&start, &s.doc) {
return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")}));
}
if s.model.is_stale() {
s.refresh();
}
let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report));
Ok(json!({"constraint": constraint, "sketch": report}))
}
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
@@ -2000,6 +2097,9 @@ mod rect_tests;
#[path = "sketch_ux_tests.rs"]
mod ux_tests;
#[cfg(test)]
#[path = "sketch_drag_live_tests.rs"]
mod drag_live_tests;
#[cfg(test)]
#[path = "sketch_ids_tests.rs"]
mod ids_tests;
+7 -3
View File
@@ -249,9 +249,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
Some(r) => sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?,
None => 0,
};
// Values of existing dimensions must be applied before judging degrees of freedom.
let (vals, _) = doc.param_values();
doc.apply_dimension_values(&vals, sk)?;
// Values of existing dimensions must be applied (and reached from the stored solved
// state) before judging degrees of freedom; a sketch they would turn over keeps its
// stored geometry, as the edit's own solve then refuses them.
let rep = super::sketch::solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|f| format!("the sketch constraints conflict ({f})"))));
}
Ok(run_auto(sk, doc, p, datum))
})?;
Ok(json!({"added": added, "dof": dof, "sketch": info}))
+545
View File
@@ -0,0 +1,545 @@
//! Dragging sketch geometry without jumping to another solution.
//!
//! A constrained sketch usually has several solutions for one cursor position (a two-link arm's
//! elbow up or down, a fillet inside or outside its corner). Each held solve of a drag is seeded
//! on the positions from before it: the points the solver's construction places (from distances
//! and alignments to placed points) keep the orientation they had there however far the step.
//! The rest is left to the iteration, which from far away can land on any solution, so a long
//! drag still walks from where the geometry is to where the cursor puts it in short steps, each
//! solved from the previous step's result (continuation). Continuation is what keeps lengths the
//! construction does not read (an Equal to another line, a rigid shape hanging off a held point);
//! it also lets the arc sweep guard see every step, and lets a path that crosses where the
//! geometry cannot reach stop short at the edge of that stretch and go on past it. It steps only
//! when the move is long next to the dragged geometry, so a normal mouse frame is a single solve.
use std::f64::consts::{PI, TAU};
use solvecraft_geom::Vec2;
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
use super::{Ent, Left, counted_solve};
/// Longest step, as a fraction of the smallest driving length or radius of the dragged group.
/// Each step is seeded on its pre-step positions, so what the seed constructs keeps its branch
/// whatever the step; the steps are for what it does not (an arc's sweep guard, paths across
/// unreachable stretches). The drag tests first fail at 1 (an arc end wraps through its start):
/// this keeps a factor of four. It holds where the seed placed what it could: once a held
/// solve's decomposition runs out of budget (see [`Budget::exhausted`]), the rest of the drag
/// takes `SHORT_STEP_FRACTION` steps, and continuation keeps the branch.
const STEP_FRACTION: f64 = 0.25;
/// The steps of a drag whose seed did not place everything it could (as before seeding steps).
const SHORT_STEP_FRACTION: f64 = 0.125;
/// Shortest step (mm), so a tiny feature cannot make a drag crawl.
const MIN_STEP: f64 = 1e-3;
/// Most steps one drag is split into.
const MAX_STEPS: usize = 64;
/// After a step fails, the way to it from the last solved state is halved until what is left is
/// shorter than a step over this; at most `MAX_STEPS` such solves per drag.
const HALVINGS: usize = 16;
/// An arc whose sweep gets this close (radians) to 0 or a full turn has run into its start.
const SWEEP_EPS: f64 = 1e-6;
/// What a drag moves, from where it is (`t = 0`) to where the cursor puts it (`t = 1`).
enum Path {
/// Points slide by `d` (a dragged point by its way to the cursor).
Slide { pts: Vec<(usize, Vec2)>, d: Vec2 },
/// A circle's radius or an ellipse's minor radius follows the cursor.
Radius { c: usize, from: f64, to: f64 },
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
}
impl Path {
fn length(&self) -> f64 {
match self {
Path::Slide { d, .. } => d.len(),
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
}
}
/// The points the drag moves.
fn points(&self) -> impl Iterator<Item = usize> + '_ {
let pts = match self {
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
Path::Radius { .. } => &[],
};
pts.iter().map(|(q, _)| *q)
}
/// Put the dragged geometry where it is at `t` along the drag.
fn apply(&self, sk: &mut Sketch, t: f64) {
let mut set = |q: usize, at: Vec2| {
if let Some(pt) = sk.points.get_mut(q) {
pt.pos = at;
}
};
match self {
Path::Slide { pts, d } => {
for &(q, at) in pts {
set(q, at + *d * t);
}
}
Path::ArcRadius { o, ends, from, to } => {
let r = from + (to - from) * t;
for &(q, u) in ends {
set(q, *o + u * r);
}
}
Path::Radius { c, from, to } => {
if let Some(CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
*r = from + (to - from) * t;
}
}
}
}
}
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
/// it, and the next steps are tried from the last solved state, the last of them being the whole
/// remaining move. When none past it solves, the first failed step stays placed but unsolved:
/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see
/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch
/// unsolved for the user to see. A step that would sweep an arc through its own start is undone
/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve
/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved).
///
/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
let Some((path, hold, n, short)) = prepare(sk, e, from, to) else {
return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved);
};
#[cfg(test)]
let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get()));
let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
let mut frame = live.then(frame_budget);
// Only success matters to these solves: the degrees of freedom of a sketch with the entity
// held are not the sketch's. Each is seeded on the last solved state (`last`), so what the
// constraints pin down keeps the orientation it had there. A failed or wrapping step is
// undone (a failed solve leaves the step placed). Also returns whether the decomposition ran
// out of budget.
let mut solves = Solves::default();
let mut step = |sk: &mut Sketch, last: &Geometry, t: f64, solves: &mut Solves| {
path.apply(sk, t);
let mut own = Budget::full();
let budget = frame.as_mut().unwrap_or(&mut own);
let spent = budget.spent();
let ok = counted_solve(sk, &hold, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok();
solves.count += 1;
solves.work += budget.spent() - spent;
solves.cut |= budget.exhausted();
let outcome = if !ok {
Step::Failed
} else if last.sweep_wrapped(sk) {
Step::Wrapped
} else {
return Step::Solved;
};
last.restore(sk);
outcome
};
let mut last = Geometry::default();
last.take(sk);
// How far along the last solved state is; the first step since it that failed.
let (mut good, mut failed) = (0.0, None);
let mut spare = MAX_STEPS;
// The steps: `k` of `dt` from `base`; once a seed ran out of budget, the rest of the way in
// short steps.
let (mut base, mut dt, mut k, mut t) = (0.0, 1.0 / n as f64, 0, 0.0);
let mut shortened = false;
'walk: while t < 1.0 {
if solves.cut && !shortened {
shortened = true;
let rest = 1.0 - t;
let m = steps(rest * short as f64, 1.0);
(base, dt, k) = (t, rest / m as f64, 0);
}
k += 1;
t = base + dt * k as f64;
// The last step ends exactly at the cursor.
if 1.0 - t < 0.5 * dt {
t = 1.0;
}
match step(sk, &last, t, &mut solves) {
Step::Solved => {
(good, failed) = (t, None);
if t < 1.0 {
last.take(sk);
}
continue;
}
Step::Wrapped => {
failed = None;
break 'walk;
}
Step::Failed => {}
}
if failed.is_none() {
// Halve the way to the failed step, so the state the next steps start from (and are
// seeded on) is as close to it as the geometry follows. The shortest move is tried
// first: geometry that cannot move at all (pinned) costs one solve, not every halving.
let fine = dt / HALVINGS as f64;
let mut miss = t;
let mut first = true;
while spare > 0 && miss - good > fine {
spare -= 1;
let mid = if std::mem::take(&mut first) { good + fine } else { 0.5 * (good + miss) };
match step(sk, &last, mid, &mut solves) {
Step::Solved => {
good = mid;
last.take(sk);
}
Step::Wrapped => break 'walk,
Step::Failed => miss = mid,
}
}
failed = Some(t);
}
}
#[cfg(test)]
last_drag::set(solves);
let left = match failed {
// The failed step is left placed for the edit's solve, seeded on the last solved state.
Some(t) => {
path.apply(sk, t);
Left::MovedFrom(last.pos)
}
None if good > 0.0 => Left::Solved,
None => Left::Unsolved,
};
(Dragged { reached: good, moving }, left)
}
/// The held solves of one drag: how many, the decomposition work they did, and whether one ran
/// out of budget.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Solves {
pub count: usize,
pub work: usize,
pub cut: bool,
}
/// One live frame's decomposition budget (smaller in tests that set one).
fn frame_budget() -> Budget {
#[cfg(test)]
if let Some(work) = last_drag::frame_work() {
return Budget::new(work);
}
Budget::frame()
}
/// What the last drag on this thread did, and a frame budget for tests to set.
#[cfg(test)]
pub(super) mod last_drag {
use std::cell::Cell;
use super::Solves;
thread_local! {
static LAST: Cell<Solves> = const { Cell::new(Solves { count: 0, work: 0, cut: false }) };
static FRAME: Cell<Option<usize>> = const { Cell::new(None) };
}
pub fn set(s: Solves) {
LAST.with(|c| c.set(s));
}
/// The held solves of the last drag.
pub fn get() -> Solves {
LAST.with(Cell::get)
}
pub fn frame_work() -> Option<usize> {
FRAME.with(Cell::get)
}
/// Restores the frame budget when dropped, also when the test panics.
struct Restore(Option<usize>);
impl Drop for Restore {
fn drop(&mut self) {
FRAME.with(|c| c.set(self.0));
}
}
/// Run `f` with live frames given `work` units of decomposition budget.
pub fn with_frame_work<R>(work: usize, f: impl FnOnce() -> R) -> R {
let _restore = Restore(FRAME.with(|c| c.replace(Some(work))));
f()
}
}
/// How one held solve of a drag went.
enum Step {
Solved,
Failed,
/// Solved, but it swept an arc through its own start.
Wrapped,
}
/// How far a drag got.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Dragged {
/// The part of the way from where it was grabbed to the cursor the geometry followed (0..1).
pub reached: f64,
/// The points the drag moves (not the fixed ones it holds), where they were before it.
pub moving: Vec<(usize, Vec2)>,
}
/// The geometry a step changes (point positions; curves, for radii and the arc ends a solve
/// may swap), kept to undo a step. Its buffers are reused from step to step.
#[derive(Default)]
struct Geometry {
pos: Vec<Vec2>,
curves: Vec<(CurveKind, bool)>,
}
impl Geometry {
fn take(&mut self, sk: &Sketch) {
self.pos.clear();
self.pos.extend(sk.points.iter().map(|p| p.pos));
self.curves.clear();
self.curves.extend(sk.curves.iter().map(|c| (c.kind.clone(), c.reversed)));
}
fn restore(&self, sk: &mut Sketch) {
for (p, at) in sk.points.iter_mut().zip(&self.pos) {
p.pos = *at;
}
for (c, (kind, reversed)) in sk.curves.iter_mut().zip(&self.curves) {
c.kind = kind.clone();
c.reversed = *reversed;
}
}
/// Did the step drive some arc's end through its start: the sweep jumped through 0 / a full
/// turn, or ran from clear of them onto one? An arc already that close may still move.
/// Measured on the arc's ends as they were before the step (a solve may swap them).
fn sweep_wrapped(&self, sk: &Sketch) -> bool {
let was = |q: usize| self.pos.get(q).copied();
let now = |q: usize| sk.point(q);
let clear = |s: f64| (SWEEP_EPS..=TAU - SWEEP_EPS).contains(&s);
self.curves.iter().any(|(kind, _)| {
let CurveKind::Arc { c, a, b } = *kind else { return false };
let (Some(s0), Some(s1)) = (sweep(was(c), was(a), was(b)), sweep(now(c), now(a), now(b))) else { return false };
(s1 - s0).abs() > PI || (clear(s0) && !clear(s1))
})
}
}
/// The held solves dragging `e` from `from` to `to` takes (0 when it cannot move): the plan
/// `drag_entity` runs, for tests.
#[cfg(test)]
pub(super) fn drag_steps(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> usize {
prepare(sk, e, from, to).map_or(0, |(_, _, n, _)| n)
}
/// A drag's path, what its solves hold and its number of steps (and of short steps, see
/// `SHORT_STEP_FRACTION`), or `None` if it cannot move.
fn prepare(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold, usize, usize)> {
if !(from.is_finite() && to.is_finite()) {
return None;
}
let (path, hold) = plan(sk, e, from, to)?;
let n = step_count(sk, &hold, path.length(), STEP_FRACTION);
let short = step_count(sk, &hold, path.length(), SHORT_STEP_FRACTION);
Some((path, hold, n, short))
}
/// A cap on the steps `drag_entity` takes on this thread, for tests that measure what fewer
/// steps keep (the planned count, `drag_steps`, is not capped).
#[cfg(test)]
pub(super) mod step_cap {
use std::cell::Cell;
thread_local! {
static CAP: Cell<usize> = const { Cell::new(super::MAX_STEPS) };
}
pub fn get() -> usize {
CAP.with(Cell::get)
}
/// Restores the cap when dropped, also when the test panics.
struct Restore(usize);
impl Drop for Restore {
fn drop(&mut self) {
CAP.with(|c| c.set(self.0));
}
}
/// Run `f` with drags split into at most `cap` steps (at least one).
pub fn with<R>(cap: usize, f: impl FnOnce() -> R) -> R {
let _restore = Restore(CAP.with(|c| c.replace(cap.max(1))));
f()
}
}
/// Steps of at most `step` covering `length`: at least one, at most `MAX_STEPS`.
fn steps(length: f64, step: f64) -> usize {
let n = (length / step).ceil();
if n.is_finite() && n >= 1.0 { (n as usize).min(MAX_STEPS) } else { 1 }
}
/// The dragged geometry's path and what the solves hold, or `None` if it cannot move.
fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
let mut hold = Hold::default();
let path = match e {
Ent::Point(q) => {
if sk.point_locked(q) {
return None;
}
hold.points.push(q);
let at = sk.point(q)?;
Path::Slide { pts: vec![(q, at)], d: to - at }
}
Ent::Curve(c) => {
if sk.curve_locked(c) {
return None;
}
let kind = sk.curves.get(c).map(|x| x.kind.clone())?;
let locked = sk.locked_points();
let free = |q: &usize| !locked.get(*q).copied().unwrap_or(true);
// A cursor on the centre leaves the radius as it is.
let radius_to = |o: Vec2, r: f64| Some(o.dist(to)).filter(|x| *x > 1e-9).unwrap_or(r);
match kind {
CurveKind::Circle { c: cc, r } => {
let to_r = sk.point(cc).map_or(r, |o| radius_to(o, r));
hold.points.push(cc);
hold.radii.push(c);
Path::Radius { c, from: r, to: to_r }
}
CurveKind::Arc { c: cc, a, b } => {
let o = sk.point(cc)?;
let r = sk.radius(c).unwrap_or(0.0);
hold.points.extend([cc, a, b]);
let ends = [a, b].into_iter().filter(free).filter_map(|q| Some((q, (sk.point(q)? - o).normalized()?))).collect();
Path::ArcRadius { o, ends, from: r, to: radius_to(o, r) }
}
CurveKind::Ellipse { c: cc, m, r } => {
// Grabbed on the outline: the minor radius follows (the major stays).
let (o, pm) = (sk.point(cc)?, sk.point(m)?);
let u = (pm - o).normalized().unwrap_or(Vec2::X);
let minor = u.cross(to - o).abs();
hold.points.extend([cc, m]);
hold.radii.push(c);
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
}
k => {
// Lines, splines and conics slide with the cursor.
let mut ids = k.point_ids();
ids.sort_unstable();
ids.dedup();
let pts = ids.iter().filter(|q| free(q)).filter_map(|&q| Some((q, sk.point(q)?))).collect();
hold.points.extend(ids);
Path::Slide { pts, d: to - from }
}
}
}
};
Some((path, hold))
}
/// Steps for a move of `length`: enough that none is longer than `fraction` of the smallest
/// size of the geometry the solve moves with what it holds (at least `MIN_STEP`), at most
/// `MAX_STEPS`. A move short next to every size in the sketch (a normal mouse frame) is one
/// step, found without grouping the sketch.
fn step_count(sk: &Sketch, hold: &Hold, length: f64, fraction: f64) -> usize {
let step = |size: f64| (size * fraction).max(MIN_STEP);
if length <= step(smallest_size(sk, hold, None)) {
return 1;
}
let (points, radii) = sk.coupled(&hold.points, &hold.radii);
steps(length, step(smallest_size(sk, hold, Some(Group { points: &points, radii: &radii }))))
}
/// What a solve moves together: per point and per curve radius.
#[derive(Clone, Copy)]
struct Group<'a> {
points: &'a [bool],
radii: &'a [bool],
}
impl Group<'_> {
/// Does an entity with these points and curves belong to the group?
fn has(&self, points: &[usize], curves: &[usize]) -> bool {
let any = |flags: &[bool], ids: &[usize]| ids.iter().any(|i| flags.get(*i).copied().unwrap_or(false));
any(self.points, points) || any(self.radii, curves)
}
}
/// The smallest size that can make a constrained shape take another solution (of the geometry
/// in `group`, or of the whole sketch), or infinity when there is none: every length the
/// constraints hold. That is driving length dimensions (offsets along an axis or from a line
/// too: the solver holds their size, either side), radii and the current lengths of lines held
/// equal to another (a link's length can come from an Equal as well as a dimension). A line that
/// only keeps a direction (a rectangle's side) holds no length: it may pass through zero and turn
/// over, so it does not shrink the steps. Nor does a distance between two held points (the
/// dragged line's own length): held geometry moves as one. A circle's radius counts when the
/// solve moves the radius itself (not merely its centre).
fn smallest_size(sk: &Sketch, hold: &Hold, group: Option<Group>) -> f64 {
// Point lists are built only to test a size smaller than the smallest so far against the
// group; without a group nothing is built.
let in_group = |points: &dyn Fn() -> Vec<usize>, curves: &[usize]| group.is_none_or(|g| g.has(&points(), curves));
// A radius is the circle's own variable, or (an arc's) the distance between its points.
let radius_in_group = |c: usize| match sk.curves.get(c).map(|x| &x.kind) {
Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) => in_group(&Vec::new, &[c]),
Some(k) => in_group(&|| k.point_ids(), &[]),
None => false,
};
let line_length = |l: usize| super::line_pts(sk, l).map(|(a, b)| a.dist(b));
let points = |k: &ConstraintKind| sk.constraint_points(k);
let mut min = f64::INFINITY;
let mut take = |size: f64, counts: &dyn Fn() -> bool| {
let size = size.abs();
if size.is_finite() && size > 1e-9 && size < min && counts() {
min = size;
}
};
for k in sk.constraints.iter().filter(|k| !k.driven).map(|k| &k.kind) {
match *k {
ConstraintKind::Equal { a, b } => {
if let Some(v) = line_length(a).into_iter().chain(line_length(b)).reduce(f64::min) {
take(v, &|| in_group(&|| points(k), &[]));
}
}
ConstraintKind::Radius { c, value } => take(value, &|| radius_in_group(c)),
ConstraintKind::Diameter { c, value } => take(value / 2.0, &|| radius_in_group(c)),
_ => {
if let Some(v) = driving_length(k) {
// Held points move as one: a length between them never changes.
take(v, &|| {
let pts = points(k);
!pts.iter().all(|p| hold.points.contains(p)) && in_group(&|| pts.clone(), &k.indices().1)
});
}
}
}
}
for c in 0..sk.curves.len() {
if let Some(r) = sk.radius(c) {
take(r, &|| radius_in_group(c));
}
}
min
}
/// The length a driving dimension sets (a linear diameter's radius), if it sets one.
fn driving_length(k: &ConstraintKind) -> Option<f64> {
if k.is_angle() {
return None;
}
let v = k.value()?;
Some(if matches!(k, ConstraintKind::LinearDiameter { .. }) { v / 2.0 } else { v })
}
/// Counter-clockwise sweep (radians, `[0, 2π)`) from `a` to `b` about `c`.
fn sweep(c: Option<Vec2>, a: Option<Vec2>, b: Option<Vec2>) -> Option<f64> {
let (o, pa, pb) = (c?, a?, b?);
Some(((pb - o).angle() - (pa - o).angle()).rem_euclid(TAU))
}
@@ -0,0 +1,290 @@
//! Live drags (`sketch.drag` with `live: true`): each frame solves the sketch once, without a
//! rank analysis, and evaluates nothing after it; the gesture's end evaluates the model once.
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use super::solve_counts;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// A dimensioned 20 x 15 rectangle from (5, 5) extruded 5 mm, its sketch being edited again;
/// returns the session and the extrude's id.
fn extruded() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let extrude = s.doc.features.last().map(|f| f.id).expect("the extrude");
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
(s, extrude)
}
/// Counters from here on.
fn reset(s: &mut Session) {
s.evaluations = 0;
solve_counts::take();
}
/// Frames moving the rectangle's corner (5, 5) (and so the whole rectangle) left by 1 mm each.
fn frames(s: &mut Session, n: usize, live: bool) -> Vec<Value> {
(1..=n)
.map(|k| {
let (from, to) = ([6.0 - k as f64, 5.0], [5.0 - k as f64, 5.0]);
run(s, "sketch.drag", json!({"entity": "l1.start", "from": from, "to": to, "live": live}))
})
.collect()
}
fn body_contains(s: &Session, p: Vec3) -> bool {
s.model.state().bodies.first().expect("a body").body.contains(p)
}
fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 {
let sk = s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).expect("sketch mode");
sk.resolve_point(id).and_then(|i| sk.point(i)).expect("the point")
}
fn corner(s: &Session) -> solvecraft_geom::Vec2 {
let id = s.active_sketch.expect("sketch mode");
let st = s.world_state();
let sk = &st.sketch(id).expect("the shown sketch").sketch;
sk.resolve_point("l1.start").and_then(|i| sk.point(i)).expect("the corner")
}
#[test]
fn a_live_frame_solves_once_and_leaves_the_features_after_the_sketch_stale() {
let (mut s, extrude) = extruded();
let body = s.model.state().bodies.first().expect("a body").body.clone();
reset(&mut s);
let r = frames(&mut s, 1, true).remove(0);
assert_eq!(s.evaluations, 0, "a live frame evaluates no feature");
assert_eq!(solve_counts::take(), (1, 0), "one held solve, no rank analysis");
assert_eq!(r["reached"], json!(1.0));
assert_eq!(r["sketch"]["solved"], json!(true));
assert_eq!(r["sketch"]["dof"], json!(2), "the degrees of freedom from before the drag");
// The sketch shows its new place; the extrude keeps its last result.
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(4.0, 5.0)) < 1e-9);
let result = s.model.result(extrude).expect("the extrude's result");
assert!(result.stale);
assert!(s.model.state().bodies.first().expect("a body").body.same(&body), "the body is not rebuilt");
}
#[test]
fn the_drag_end_evaluates_once_to_the_scripted_drags_result() {
let (mut live, extrude) = extruded();
let (mut scripted, _) = extruded();
frames(&mut live, 3, true);
reset(&mut live);
let end = run(&mut live, "sketch.drag_end", json!({}));
assert_eq!(live.evaluations, 1, "one full evaluation at the end");
assert_eq!(solve_counts::take(), (0, 0));
assert_eq!(end["sketch"], json!({"solved": true, "dof": 2}));
assert!(!live.model.is_stale());
frames(&mut scripted, 3, false);
assert_eq!(*live.doc, *scripted.doc, "the same design as dragging without `live`");
let (a, b) = (live.model.state(), scripted.model.state());
let (sa, sb) = (&a.sketches[0], &b.sketches[0]);
assert_eq!(sa.sketch, sb.sketch);
assert_eq!((sa.report.dof, &sa.report.point_determined), (sb.report.dof, &sb.report.point_determined));
assert!(body_contains(&live, Vec3::new(2.5, 6.0, 2.5)) && !body_contains(&live, Vec3::new(22.5, 6.0, 2.5)), "the body followed");
assert!(live.model.result(extrude).is_some_and(|r| !r.stale && r.error.is_none()));
// One undo step for the gesture is the UI's business; each frame is still undoable.
run(&mut live, "edit.undo", json!({}));
assert!(corner(&live).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
}
#[test]
fn an_abandoned_live_drag_is_evaluated_before_the_next_command() {
let (mut s, extrude) = extruded();
frames(&mut s, 2, true);
assert!(s.model.is_stale());
reset(&mut s);
// A command that changes nothing still sees (and leaves) a fully evaluated model.
run(&mut s, "sketch.solve", json!({}));
assert_eq!(s.evaluations, 1);
assert!(!s.model.is_stale());
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
assert!(body_contains(&s, Vec3::new(3.5, 6.0, 2.5)));
// Leaving the sketch does too.
frames(&mut s, 1, true);
run(&mut s, "sketch.finish", json!({}));
assert!(!s.model.is_stale());
}
#[test]
fn a_scripted_drag_still_evaluates_the_whole_model() {
let (mut s, extrude) = extruded();
reset(&mut s);
let r = frames(&mut s, 1, false).remove(0);
assert_eq!(s.evaluations, 1);
assert_eq!(solve_counts::take(), (2, 1), "the held solve, then the sketch's own solve with its analysis");
assert_eq!(r["sketch"], json!({"solved": true, "dof": 2}));
assert!(!s.model.is_stale());
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
assert!(body_contains(&s, Vec3::new(4.5, 6.0, 2.5)));
}
#[test]
fn a_drag_reports_how_far_the_geometry_followed() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 30], [40, 30]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
for live in [false, true] {
// A line of fixed length with a fixed end cannot slide: the sketch's own solve only
// turns it a little toward the cursor, and the grab follows its free end.
let before = pt(&s, "l1.end");
let r = run(&mut s, "sketch.drag", json!({"entity": "l1", "from": [25, 10], "to": [25, 20], "live": live}));
assert_eq!(r["reached"], json!(0.0), "{r}");
let moved = pt(&s, "l1.end") - before;
let at = crate::params::vec2(&r["at"]).expect("`at`");
assert!(at.dist(solvecraft_geom::Vec2::new(25.0, 10.0) + moved) < 1e-9 && at.y < 20.0, "{r}");
let to = 40 + 10 * usize::from(live);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2", "from": [25, to - 10], "to": [25, to]}));
assert_eq!((&r["reached"], &r["at"]), (&json!(1.0), &json!([25.0, to as f64])), "{r}");
}
// A point that cannot move at all.
let r = run(&mut s, "sketch.drag", json!({"entity": "l1.start", "from": [10, 10], "to": [0, 0], "live": true}));
assert_eq!(r["reached"], json!(0.0));
}
#[test]
fn a_live_drag_of_a_lone_sketch_shows_it_without_evaluating() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
reset(&mut s);
frames(&mut s, 2, true);
assert_eq!(s.evaluations, 0);
assert_eq!(solve_counts::take(), (2, 0));
// The free rectangle's corner moves alone; its profile follows.
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
let st = s.model.state();
let profile = st.sketches[0].profiles.first().expect("a profile");
assert!((profile.area - 22.0 * 15.0).abs() < 1e-9, "{}", profile.area);
run(&mut s, "sketch.drag_end", json!({}));
assert_eq!(s.evaluations, 1);
}
/// Frames of a UI drag of `entity` grabbed at `grab`, the cursor moving by `moves` in turn: each
/// frame's `from` is the previous frame's `at`, as the viewport does.
fn ui_frames(s: &mut Session, entity: &str, grab: [f64; 2], moves: &[[f64; 2]]) {
let (mut from, mut cursor) = (grab, grab);
for m in moves {
cursor = [cursor[0] + m[0], cursor[1] + m[1]];
let r = run(s, "sketch.drag", json!({"entity": entity, "from": from, "to": cursor, "live": true}));
let at = crate::params::vec2(&r["at"]).expect("`at`");
from = [at.x, at.y];
}
}
#[test]
fn the_grab_follows_the_points_that_move() {
// A line with a fixed end, dragged by its middle: only the free end moves, and it keeps the
// cursor's offset frame after frame.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [30, 10]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
ui_frames(&mut s, "l1", [20.0, 10.0], &[[0.0, 1.0]; 6]);
assert!(pt(&s, "l1.end").dist(solvecraft_geom::Vec2::new(30.0, 16.0)) < 1e-9, "{:?}", pt(&s, "l1.end"));
assert!(pt(&s, "l1.start").dist(solvecraft_geom::Vec2::new(10.0, 10.0)) < 1e-12);
}
#[test]
fn a_query_during_a_live_drag_does_not_evaluate_the_model() {
let (mut s, _) = extruded();
frames(&mut s, 1, true);
reset(&mut s);
for (id, p) in
[("parameters.complete", json!({"prefix": "d"})), ("parameters.list", json!({})), ("expr.evaluate", json!({"expression": "d1 * 2"}))]
{
run(&mut s, id, p);
}
assert_eq!(s.evaluations, 0);
assert!(s.model.is_stale(), "the drag goes on");
frames(&mut s, 1, true);
assert_eq!(s.evaluations, 0);
}
fn sketch_of(s: &Session) -> solvecraft_sketch::Sketch {
s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).cloned().expect("sketch mode")
}
#[test]
fn a_live_frame_solves_the_dimensions_current_values() {
let (mut s, _) = extruded();
// The bottom side's parameter now says 25 while the sketch still holds 20 mm.
let sk = sketch_of(&s);
let l1 = sk.curve_index("l1").expect("l1");
let param = sk
.constraints
.iter()
.find(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Length { l, .. } if l == l1))
.and_then(|c| c.param.clone())
.expect("l1's dimension");
if let Some(p) = s.doc_mut().params.iter_mut().find(|p| p.name == param) {
p.expr = "25".into();
}
let r = frames(&mut s, 1, true).remove(0);
assert_eq!(r["sketch"]["solved"], json!(true));
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!((a.dist(b) - 25.0).abs() < 1e-6, "l1 is {} mm", a.dist(b));
}
#[test]
fn a_live_frame_swaps_only_the_dragged_sketch() {
let (mut s, _) = extruded();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
let id = s.active_sketch.expect("sketch mode");
let other = |s: &Session| s.model.state().sketches.iter().find(|x| x.feature != id).cloned().expect("the other sketch");
let before = other(&s);
frames(&mut s, 1, true);
assert!(std::sync::Arc::ptr_eq(&before, &other(&s)), "the other sketch is shared, not copied");
// Later frames change the state they own in place.
let state = std::sync::Arc::as_ptr(&s.model.state());
frames(&mut s, 1, true);
assert_eq!(state, std::sync::Arc::as_ptr(&s.model.state()));
}
#[test]
fn letting_go_on_a_snap_or_undoing_evaluates_once() {
let (mut s, _) = extruded();
run(&mut s, "sketch.point", json!({"point": [-10, 5], "connect": false}));
let point = sketch_of(&s).points.last().map(|p| p.id.clone()).expect("the point");
frames(&mut s, 3, true);
reset(&mut s);
let snap = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": point}});
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": snap }));
assert_eq!(s.evaluations, 1, "{r}");
assert!(r["constraint"].get("error").is_none(), "{r}");
assert!(!s.model.is_stale());
// Undo after an abandoned drag.
frames(&mut s, 1, true);
reset(&mut s);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.evaluations, 1);
assert!(!s.model.is_stale());
// A snap that over-constrains the sketch is not kept; the drag still ends.
frames(&mut s, 1, true);
let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": "l1.end"}});
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": bad }));
assert!(r["constraint"]["error"].is_string(), "{r}");
assert!(!s.model.is_stale());
}
+11 -18
View File
@@ -8,7 +8,7 @@ use solvecraft_doc::ProfileSel;
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
use super::sketch::{add_c, edit, ids_of};
use super::sketch::{add_c, edit, ids_of, solve_current};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
@@ -378,10 +378,9 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.extend";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
}
let ci = curve_arg(sk, p, cmd)?;
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
@@ -453,7 +452,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
pin(sk, end, oi)?;
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
if !solvecraft_sketch::solve_with(sk, &hold, None, solvecraft_sketch::Analysis::Full, None).ok()
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
{
@@ -928,11 +927,10 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let side = p.get("side").and_then(vec2);
let use_chain = bool_(p, "chain").unwrap_or(true);
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
sk.refresh_offsets()?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve existing constraints before offsetting"));
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve existing constraints before offsetting".into())));
}
sk.refresh_offsets()?;
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
@@ -1137,14 +1135,9 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
// A parameter edit may have changed the evaluated source without
// rewriting the stored fit points. Break must freeze the displayed,
// current dimensioned geometry, rather than its stale stored predecessor.
let (values, _) = doc.param_values();
doc.apply_dimension_values(&values, sk)?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
}
sk.refresh_offsets()?;
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
}
sk.validate_periodic_splines()?;
if let Some(association) = sk
+659
View File
@@ -552,6 +552,584 @@ fn constrained_geometry_resists_drags() {
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
}
/// How the arm's link lengths are set.
#[derive(Clone, Copy, Debug)]
enum Links {
/// Length dimensions on both links.
Dimensioned,
/// Equal to fixed reference lines (no length dimension anywhere).
EqualToFixed,
}
/// Two-link arm: shoulder `l1.start` fixed, |l1| = 30 and |l2| = 20, the hand `l2.end` dragged
/// in one `sketch.drag` call along a straight path that stays clear of the singular poses. The
/// elbow has two solutions for every hand position; a drag must keep the one it started on.
/// 1000 deterministic cases (fixed-seed LCG). Returns (elbow flips, drags that stopped short).
fn arm_drags(links: Links) -> (usize, usize) {
use std::f64::consts::TAU;
let (a, b) = (30.0, 20.0);
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
let mut s = new_sketch();
// Away from the sketch origin so nothing connects to it.
let o = v(200.0, 100.0);
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
match links {
Links::Dimensioned => {
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
}
Links::EqualToFixed => {
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 100.0], [o.x + a, o.y - 100.0]], "ids": ["r1"]}));
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 120.0], [o.x + b, o.y - 120.0]], "ids": ["r2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["r1", "r2"]}));
run(&mut s, "sketch.constraint.equal", json!({"a": "r1", "b": "l1"}));
run(&mut s, "sketch.constraint.equal", json!({"a": "r2", "b": "l2"}));
}
}
let id = s.active_sketch.expect("sketch");
let sk0 = sketch(&s);
assert_eq!(sk0.constraints.iter().any(|c| c.kind.is_dimension()), matches!(links, Links::Dimensioned));
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
let (p1, p2, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.end"));
let p2b = ix("l2.start");
let side = |sk: &Sketch| {
let (x1, x2, x3) = (sk.points[p1].pos, sk.points[p2].pos, sk.points[p3].pos);
(x3 - x1).cross(x2 - x1).signum()
};
let mut seed = 11u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut kept, mut flips, mut short) = (0, 0, 0);
while kept < 1000 {
let th1 = rnd() * TAU;
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
let v2 = o + v(a * th1.cos(), a * th1.sin());
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
let to = o + v(tr * tt.cos(), tr * tt.sin());
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
continue;
}
kept += 1;
{
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3)] {
sk.points[p].pos = at;
}
}
let before = side(&sketch(&s));
drag(&mut s, "l2.end", [v3.x, v3.y], [to.x, to.y]);
let sk = sketch(&s);
if side(&sk) != before {
flips += 1;
}
if sk.points[p3].pos.dist(to) > 1e-6 {
short += 1;
}
}
(flips, short)
}
#[test]
fn a_dragged_two_link_arm_keeps_its_elbow_side() {
for links in [Links::Dimensioned, Links::EqualToFixed] {
let (flips, short) = arm_drags(links);
eprintln!("two-link arm ({links:?}), one-call drags: {flips}/1000 elbow flips, {short} stopped short");
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 drags");
assert_eq!(short, 0, "{links:?}: {short}/1000 drags stopped short of the cursor");
}
}
/// Each step of a drag is seeded on the positions from before it, so even a drag made in one
/// step (one held solve from the grab to the cursor) keeps the elbow of an arm whose links are
/// dimensioned or held equal to fixed lines (the seed reads both).
#[test]
fn a_single_step_arm_drag_keeps_its_elbow_side() {
for links in [Links::Dimensioned, Links::EqualToFixed] {
let (flips, short) = super::drag::step_cap::with(1, || arm_drags(links));
eprintln!("two-link arm ({links:?}), one-step drags: {flips}/1000 elbow flips, {short} stopped short");
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 one-step drags");
assert_eq!(short, 0, "{links:?}: {short}/1000 one-step drags stopped short of the cursor");
}
}
/// Two-link arm (shoulder fixed, |l1| = 30, |l2| = 20) with a thin triangle (sides 3.9, 2, 2)
/// hanging off the hand: pinned there at one point, free to turn about it. The hand is dragged
/// in one `sketch.drag` call along a straight path clear of the arm's singular poses, `n`
/// deterministic cases with the triangle at random turns and on either side. Returns the drags
/// that turned the triangle over.
fn hanging_triangle_flips(n: usize) -> usize {
use std::f64::consts::TAU;
let (a, b) = (30.0, 20.0);
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
let o = v(200.0, 100.0);
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
let hand = pt(&s, "l2.end");
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
}
let id = s.active_sketch.expect("sketch");
let sk0 = sketch(&s);
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
let (p1, p2, p2b, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.start"), ix("l2.end"));
let (t1a, t1b, t2a, t2b, t3a, t3b) = (ix("t1.start"), ix("t1.end"), ix("t2.start"), ix("t2.end"), ix("t3.start"), ix("t3.end"));
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
let mut seed = 23u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut kept, mut flips) = (0, 0);
while kept < n {
let th1 = rnd() * TAU;
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
let v2 = o + v(a * th1.cos(), a * th1.sin());
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
let to = o + v(tr * tt.cos(), tr * tt.sin());
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
continue;
}
kept += 1;
let turn = rnd() * TAU;
let u = solvecraft_geom::Vec2::from_angle(turn);
let normal = u.perp() * if rnd() < 0.5 { 1.0 } else { -1.0 };
let (tq, tr) = (v3 + u * 3.9, v3 + u * 1.95 + normal * (4.0f64 - 1.95 * 1.95).sqrt());
{
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3), (t1a, v3), (t1b, tq), (t2a, tq), (t2b, tr), (t3a, tr), (t3b, v3)] {
sk.points[p].pos = at;
}
}
let before = side(&s);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [v3.x, v3.y], "to": [to.x, to.y]}));
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?} vs {to:?}: {r}", pt(&s, "l2.end"));
if side(&s) != before {
flips += 1;
}
}
flips
}
/// A rigid cluster pinned at a single point is placed by its reference pose (turned about the
/// pin, never reflected): a thin triangle hanging off a dragged arm's hand never turns over,
/// however few steps the drag takes.
#[test]
fn a_triangle_hanging_off_a_dragged_point_keeps_its_side() {
let flips: Vec<(usize, usize)> =
[1, 2, 4, usize::MAX].into_iter().map(|cap| (cap, super::drag::step_cap::with(cap, || hanging_triangle_flips(100)))).collect();
eprintln!("thin triangle on a dragged hand, (step cap, turned over /100): {flips:?}");
assert!(flips.iter().all(|(_, f)| *f == 0), "{flips:?}");
}
/// Two-link arm with both ends fixed: the elbow is pinned to one of two places. Its drags (one
/// step each) to `n` cursors across the shoulder-hand line all fail their held solve and leave
/// the elbow placed there; returns how many of the sketch's own solves that follow put it on
/// the other side. `move_point`: moved by `sketch.move_point` rather than `sketch.drag`.
fn pinned_elbow_flips(n: usize, move_point: bool) -> usize {
let o = v(200.0, 100.0);
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y + 10.0], [o.x + 40.0, o.y]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l2.end"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
let start = s.doc.clone();
let side = |s: &Session| (pt(s, "l2.end") - pt(s, "l1.start")).cross(pt(s, "l1.end") - pt(s, "l1.start")).signum();
let before = side(&s);
let mut flips = 0;
for i in 0..n {
s.doc = start.clone();
let elbow = pt(&s, "l1.end");
// Across the line, spread along it.
let to = o + v(-10.0 + 60.0 * i as f64 / n as f64, -2.0 - 20.0 * ((i * 7) % n) as f64 / n as f64);
let r = if move_point {
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [to.x, to.y]}))
} else {
run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [elbow.x, elbow.y], "to": [to.x, to.y]}))
};
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
if side(&s) != before {
flips += 1;
}
}
flips
}
/// A drag whose held solve fails leaves the step placed for the sketch's own solve, which keeps
/// the orientation of the last solved state: an elbow pinned by both ends of its arm, dragged
/// across the shoulder-hand line in one step, stays on its side.
#[test]
fn a_failed_drag_step_keeps_the_last_solved_orientation() {
for move_point in [false, true] {
let flips = super::drag::step_cap::with(1, || pinned_elbow_flips(50, move_point));
eprintln!("pinned elbow, one-step moves across the line (move_point {move_point}): {flips}/50 flips");
assert_eq!(flips, 0, "move_point {move_point}");
}
}
/// After a failed step the drag follows as far as the geometry can, and `reached` says how far:
/// an arm's hand (reach 50) dragged in one step from 49 to 50.5 from its shoulder gets about
/// two thirds of the way.
#[test]
fn a_drag_follows_up_to_where_its_step_fails() {
let o = v(200.0, 100.0);
let mut s = new_sketch();
// Elbow bent so the hand is 49 from the shoulder, along +x.
let c = (30.0f64 * 30.0 + 49.0 * 49.0 - 20.0 * 20.0) / (2.0 * 30.0 * 49.0);
let elbow = o + v(30.0 * c, 30.0 * (1.0 - c * c).sqrt());
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [o.x + 49.0, o.y]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
assert_eq!(drag_steps(&s, "l2.end", [o.x + 49.0, o.y], [o.x + 50.5, o.y]), 1);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 49.0, o.y], "to": [o.x + 50.5, o.y]}));
let reached = r["reached"].as_f64().unwrap_or(f64::NAN);
eprintln!("arm hand past its reach in one step: {r}");
assert!((1.0 / 1.5 - 1.0 / 16.0..=1.0 / 1.5).contains(&reached), "{r}");
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
}
/// The test-only step cap limits the steps a drag takes, not the plan tests read, and is
/// restored when the capped code panics.
#[test]
fn the_step_cap_leaves_the_plan_and_is_restored() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 0], [120, 0]], "ids": ["l1"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
let uncapped = super::drag::step_cap::get();
let plan = drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0]);
assert!(plan > 1);
assert_eq!(super::drag::step_cap::with(1, || drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0])), plan);
assert_eq!(super::drag::step_cap::get(), uncapped);
assert!(std::panic::catch_unwind(|| super::drag::step_cap::with(1, || panic!("capped"))).is_err());
assert_eq!(super::drag::step_cap::get(), uncapped);
}
/// Stored (counter-clockwise) sweep of an arc, degrees in [0, 360).
fn sweep_deg(s: &Session, arc: &str) -> f64 {
let sk = sketch(s);
let Some(solvecraft_sketch::CurveKind::Arc { c, a, b }) = sk.curve_index(arc).map(|i| sk.curves[i].kind.clone()) else { panic!("no arc {arc}") };
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU).to_degrees()
}
/// A free arc of radius 10 about a fixed centre (50, 50), from angle 0 sweeping `sweep`°.
fn free_arc(sweep: f64) -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 50], "start": [60, 50], "sweep": sweep, "id": "a1"}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["a1.center"]}));
s
}
fn on_arc(deg: f64) -> [f64; 2] {
let t = deg.to_radians();
[50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()]
}
/// An arc's end dragged through its start never wraps the sweep (15° → 0° → 345°, or
/// 330° → 360° → 30°): the arc stops short, in one call or in many.
#[test]
fn a_dragged_arc_end_never_wraps_through_its_start() {
// One call, clockwise through the start: 90° → -60°.
let mut s = free_arc(90.0);
drag(&mut s, "a1.end", on_arc(90.0), on_arc(-60.0));
let sw = sweep_deg(&s, "a1");
eprintln!("free arc, CW one call 90° → -60°: sweep {sw:.1}°");
assert!(sw > 0.0 && sw < 30.0, "CW one call: sweep {sw}°");
// One call, counter-clockwise past a full turn: 330° → 390°.
let mut s = free_arc(330.0);
drag(&mut s, "a1.end", on_arc(330.0), on_arc(390.0));
let sw = sweep_deg(&s, "a1");
eprintln!("free arc, CCW one call 330° → 390°: sweep {sw:.1}°");
assert!(sw > 330.0 && sw < 360.0, "CCW one call: sweep {sw}°");
// 30° calls, clockwise: 90 → -60.
let mut s = free_arc(90.0);
let mut last = 90.0;
let mut log = Vec::new();
for deg in [60.0, 30.0, 0.0, -30.0, -60.0] {
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
last = deg;
log.push(sweep_deg(&s, "a1"));
}
eprintln!("free arc, CW 30° calls: sweeps {log:.1?}");
assert!(log.windows(2).all(|w| w[1] <= w[0] + 1e-9) && log.iter().all(|&x| x > 0.0), "CW calls: {log:?}");
// 30° calls, counter-clockwise: 90 → 450.
let mut s = free_arc(90.0);
let mut last = 90.0;
let mut log = Vec::new();
for deg in (4..=15).map(|i| (i * 30) as f64) {
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
last = deg;
log.push(sweep_deg(&s, "a1"));
}
eprintln!("free arc, CCW 30° calls: sweeps {log:.1?}");
assert!(log.windows(2).all(|w| w[1] >= w[0] - 1e-9) && log.iter().all(|&x| x < 360.0), "CCW calls: {log:?}");
assert!(log.last().is_some_and(|&x| x > 330.0), "CCW calls: {log:?}");
}
/// A fillet (radius 5, tangent to both legs) whose second leg is dragged round the corner
/// 90° → 174° → 14° → 90° in 2° calls stays inside the corner; with a 40 mm leg the leg turns
/// over near 14° (the tangent length passes 40) and comes back on the way up.
#[test]
fn a_dragged_fillet_stays_in_its_corner() {
for (leg, strict) in [(80.0, true), (40.0, false)] {
let mut s = new_sketch();
let o = v(100.0, 100.0);
run(&mut s, "sketch.line", json!({"points": [[o.x + 40.0, o.y], [o.x, o.y], [o.x, o.y + leg]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"}));
let sk0 = sketch(&s);
let arc = sk0.curves.iter().position(|c| matches!(c.kind, solvecraft_sketch::CurveKind::Arc { .. })).expect("fillet arc");
let arc_id = sk0.curves[arc].id.clone();
let inside = |s: &Session| {
let sk = sketch(s);
let side = |p: solvecraft_geom::Vec2, a: solvecraft_geom::Vec2, b: solvecraft_geom::Vec2| (b - a).cross(p - a).signum();
let c = sk.center(arc).expect("centre");
let (p0, k1, k2, p3) = (pt(s, "l1.start"), pt(s, "l1.end"), pt(s, "l2.start"), pt(s, "l2.end"));
let r = sk.radius(arc).unwrap_or(0.0);
side(c, p0, k1) == side(p3, p0, k1) && side(c, k2, p3) == side(p0, k2, p3) && (r - 5.0).abs() < 1e-6
};
assert!(inside(&s));
let mut path: Vec<f64> = (45..=87).map(|i| (i * 2) as f64).collect();
path.extend((7..=87).rev().map(|i| (i * 2) as f64));
path.extend((8..=45).map(|i| (i * 2) as f64));
let corner = o;
let at = |deg: f64| {
let t = deg.to_radians();
[corner.x + leg * t.cos(), corner.y + leg * t.sin()]
};
let mut bad = Vec::new();
let mut last = at(90.0);
for deg in path {
let to = at(deg);
drag(&mut s, "l2.end", last, to);
last = to;
let sw = sweep_deg(&s, &arc_id);
if !(inside(&s) && sw < 180.0) {
bad.push(deg);
}
}
if strict {
assert!(bad.is_empty(), "leg {leg}: bad states at {bad:?}");
}
assert!(inside(&s) && sweep_deg(&s, &arc_id) < 180.0, "leg {leg}: did not recover (bad at {bad:?})");
}
}
/// Hostile `from` / `to` are refused and change nothing; a zero-length drag changes nothing.
#[test]
fn hostile_drags_change_nothing() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
let l0 = snap(&s, "l1");
for bad in [json!(f64::NAN), json!(f64::INFINITY), json!(1e300), json!(-1e300), json!("x"), json!([1.0]), json!(null)] {
for (from, to) in [(json!([10, 10]), json!([bad.clone(), 0])), (json!([bad.clone(), 0]), json!([10, 10]))] {
for e in ["l1", "l1.end"] {
let r = s.execute("sketch.drag", &json!({"entity": e, "from": from.clone(), "to": to.clone()}));
assert!(r.is_err(), "{e} {from} {to}: {r:?}");
assert!(same(&snap(&s, "l1"), &l0));
}
}
assert!(s.execute("sketch.move_point", &json!({"point": "l1.end", "to": [bad.clone(), 0]})).is_err());
assert!(same(&snap(&s, "l1"), &l0));
}
drag(&mut s, "l1", [20.0, 10.0], [20.0, 10.0]);
drag(&mut s, "l1.end", [40.0, 20.0], [40.0, 20.0]);
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 20]}));
assert!(same(&snap(&s, "l1"), &l0));
// A huge (but finite, in-range) jump on a constrained group is capped and never panics.
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
assert_eq!(drag_steps(&s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]), 64);
drag(&mut s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]);
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!(a.is_finite() && b.is_finite(), "{a:?} {b:?}");
}
/// The solves a drag of `entity` from `from` to `to` would take.
fn drag_steps(s: &Session, entity: &str, from: [f64; 2], to: [f64; 2]) -> usize {
let sk = sketch(s);
let e = super::ent(&sk, entity).unwrap_or_else(|| panic!("no entity {entity}"));
super::drag::drag_steps(&sk, e, v(from[0], from[1]), v(to[0], to[1]))
}
/// Continuation costs nothing on a normal mouse frame: a short move next to the geometry is one
/// held solve, as it was before; only a long one is split.
#[test]
fn a_short_drag_is_a_single_solve() {
let mut s = new_sketch();
// Ten dimensioned 20 × 10 rectangles, free to move (clear of the origin).
for i in 0..10 {
let x = 100.0 + 30.0 * i as f64;
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 20.0, 10]}));
let c = curve_ids(&r);
run(&mut s, "sketch.dimension", json!({"entities": [c[0]], "value": 20}));
run(&mut s, "sketch.dimension", json!({"entities": [c[1]], "value": 10}));
}
assert_eq!(drag_steps(&s, "l1.end", [120.0, 0.0], [120.5, 0.3]), 1);
assert_eq!(drag_steps(&s, "l1", [110.0, 0.0], [110.5, 0.5]), 1);
drag(&mut s, "l1.end", [120.0, 0.0], [120.5, 0.3]);
assert!(pt(&s, "l1.end").dist(v(120.5, 0.3)) < 1e-9);
// A long move (more than an eighth of the 10 mm side) is split into steps.
assert!(drag_steps(&s, "l1.end", [120.5, 0.3], [140.0, -20.0]) > 1);
drag(&mut s, "l1.end", [120.5, 0.3], [140.0, -20.0]);
assert!(pt(&s, "l1.end").dist(v(140.0, -20.0)) < 1e-9);
}
/// The drag group stops at fixed points, as the solve does: a tiny dimension on geometry that
/// only shares a fixed point with the dragged geometry does not shrink its steps.
#[test]
fn the_drag_step_ignores_geometry_beyond_a_fixed_point() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
let before = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.01, 100]], "ids": ["lb"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["lb"], "value": 0.01}));
assert_eq!(drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]), before);
assert_eq!(before, 1);
// A free radius couples like a point: a line tangent to a circle whose centre is fixed
// steps by the circle's radius (r 2: steps of 0.5 mm), its only link to the line.
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 2, "connect": false}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1.center"]}));
run(&mut s, "sketch.line", json!({"points": [[90, 102], [110, 102]], "ids": ["lt"], "connect": false}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "lt"}));
assert_eq!(drag_steps(&s, "lt.end", [110.0, 102.0], [110.0, 105.0]), 6);
}
/// The step counts every size the constraints hold, offsets along an axis too (the solver holds
/// their size on either side), except a length between two points the drag holds: a dimensioned
/// line slid by its middle keeps its length whatever the step.
#[test]
fn the_drag_step_counts_every_held_size_but_the_dragged_length() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.5, 100]], "ids": ["la"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 0.5}));
let steps = drag_steps(&s, "la", [100.25, 100.0], [105.25, 100.0]);
eprintln!("0.5 mm line slid 5 mm: {steps} steps");
assert_eq!(steps, 1);
// B 0.05 mm right of A (horizontal dimension); A dragged 1 mm right in one call: B stays
// right of A instead of jumping to the other side.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100, 110]], "ids": ["la"]}));
run(&mut s, "sketch.line", json!({"points": [[100.05, 120], [110, 120]], "ids": ["lb"], "connect": false}));
run(&mut s, "sketch.dimension", json!({"entities": ["la.start", "lb.start"], "type": "horizontal", "value": 0.05}));
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::DistanceX { .. })));
let steps = drag_steps(&s, "la.start", [100.0, 100.0], [101.0, 100.0]);
drag(&mut s, "la.start", [100.0, 100.0], [101.0, 100.0]);
let (a, b) = (pt(&s, "la.start"), pt(&s, "lb.start"));
eprintln!("1 mm drag with a 0.05 mm DistanceX: {steps} steps; A {a:?} B {b:?}");
assert!(steps > 1);
assert!(a.dist(v(101.0, 100.0)) < 1e-9 && (b.x - a.x - 0.05).abs() < 1e-6, "B went to the other side: {a:?} {b:?}");
}
/// A circle shares only its centre with a dragged line's end (coincident): the solve couples
/// its centre, never its radius, so a tiny circle does not shrink the step.
#[test]
fn a_circle_centred_on_a_dragged_end_does_not_set_the_step() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
run(&mut s, "sketch.circle.center", json!({"center": [120, 100], "radius": 0.01}));
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Coincident { .. })));
let steps = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
eprintln!("1 mm drag next to a 0.01 mm circle on the end: {steps} steps");
assert_eq!(steps, 1);
}
/// A one-call drag whose straight path crosses where the geometry cannot reach (an arm's hand
/// through the hole round its shoulder) still lands on a cursor it can reach.
#[test]
fn a_drag_across_an_unreachable_middle_still_reaches_the_cursor() {
let mut s = new_sketch();
let o = v(200.0, 100.0);
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y], [o.x + 30.0, o.y + 20.0]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
// From (30, 20) to (-30, -20) about the shoulder: the path passes through it.
let to = [o.x - 30.0, o.y - 20.0];
let steps = drag_steps(&s, "l2.end", [o.x + 30.0, o.y + 20.0], to);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 30.0, o.y + 20.0], "to": to}));
eprintln!("arm hand across the hole: {steps} steps, {r}");
assert!(steps > 1);
assert!(pt(&s, "l2.end").dist(v(to[0], to[1])) < 1e-6, "{:?}", pt(&s, "l2.end"));
assert_eq!(r["reached"], json!(1.0));
}
/// Past the unreachable stretch round its shoulder, an arm's drag keeps stepping (no one jump to
/// the cursor), so a thin triangle hanging off the hand (it can turn over) keeps its side.
#[test]
fn a_drag_past_an_unreachable_stretch_keeps_stepping() {
let mut s = new_sketch();
let o = v(200.0, 100.0);
let elbow = o + v(19.702247520273062, 22.62347105662377);
let hand = o + v(39.654744430607906, 21.245840103473398);
let to = o + v(-28.82686238241851, -31.24580379872583);
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [hand.x, hand.y]], "ids": ["l1", "l2"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
// A triangle with sides 3.9, 2 and 2 on the hand.
let hand = pt(&s, "l2.end");
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
}
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
let before = side(&s);
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [hand.x, hand.y], "to": [to.x, to.y]}));
eprintln!("arm with a thin triangle past the hole: {r}, side {before} -> {}", side(&s));
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?}", pt(&s, "l2.end"));
assert_eq!(side(&s), before, "the triangle turned over");
assert_eq!(r["reached"], json!(1.0));
}
/// The sweep guard stops an arc's end crossing its start, never mere closeness: an arc at
/// 359.99999° or 1e-7 rad still lets its own end and geometry joined to it be dragged.
#[test]
fn arcs_near_a_full_turn_or_nothing_can_still_be_dragged() {
// (sweep, a nudge of the end towards the inside of the sweep that keeps it within 1e-6 rad
// of the limit).
for (sweep, nudge) in [(359.99999, -5e-7), (1e-7_f64.to_degrees(), 5e-7)] {
let mut s = free_arc(sweep);
// A dimensioned line hangs off the arc's end.
let end = pt(&s, "a1.end");
run(&mut s, "sketch.line", json!({"points": [[end.x, end.y], [end.x + 10.0, end.y + 10.0]], "ids": ["lc"], "connect": false}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "a1.end", "b": "lc.start"}));
run(&mut s, "sketch.dimension", json!({"entities": ["lc"], "value": 200f64.sqrt()}));
let sw0 = sweep_deg(&s, "a1");
// The arc's own end.
let t = (end - v(50.0, 50.0)).angle() + nudge;
let to = [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()];
drag(&mut s, "a1.end", [end.x, end.y], to);
assert!(pt(&s, "a1.end").dist(v(to[0], to[1])) < 1e-9, "sweep {sweep}°: the arc's end did not move");
// The joined line's far end, which pulls the arc's end along (inwards).
let far = pt(&s, "lc.end");
let target = v(far.x, far.y + 1e-5 * nudge.signum());
drag(&mut s, "lc.end", [far.x, far.y], [target.x, target.y]);
assert!(pt(&s, "lc.end").dist(target) < 1e-9, "sweep {sweep}°: the joined line did not move");
assert!((sweep_deg(&s, "a1") - sw0).abs() < 1.0, "sweep {sweep}° became {}°", sweep_deg(&s, "a1"));
}
}
#[test]
fn fully_constrained_feedback_is_per_entity() {
let mut s = new_sketch();
@@ -669,3 +1247,84 @@ fn pipe_and_sweep_follow_3d_sketch_curves() {
assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && within(&hi[0], 50.0, 52.0) && within(&hi[2], 50.0, 52.0), "{p} {body}");
assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}");
}
/// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster
/// decomposition places: pinned at F by two of its corners and held at G by the third. Every
/// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on.
fn hung_arm() -> Session {
let mut s = new_sketch();
let o = v(200.0, 100.0);
let at = |x: f64, y: f64| json!([o.x + x, o.y + y]);
run(&mut s, "sketch.line", json!({"points": [at(0.0, 0.0), at(5.0, 15.0), at(20.0, 25.0), at(15.0, 8.0)], "ids": ["fa", "ab", "bc"]}));
run(&mut s, "sketch.line", json!({"points": ["bc.end", "ab.start"], "ids": ["ca"]}));
run(&mut s, "sketch.line", json!({"points": ["fa.start", "bc.end"], "ids": ["fc"]}));
run(&mut s, "sketch.line", json!({"points": [at(40.0, 0.0), "ab.end"], "ids": ["gb"]}));
run(&mut s, "sketch.line", json!({"points": ["ab.end", at(35.0, 40.0), at(50.0, 30.0)], "ids": ["be", "eh"]}));
for id in ["fa", "ab", "bc", "ca", "fc", "gb", "be", "eh"] {
run(&mut s, "sketch.dimension", json!({"entities": [id]}));
}
run(&mut s, "sketch.constraint.fix", json!({"entities": ["fa.start", "gb.start"]}));
s
}
/// The elbow's side of the shoulder–hand line.
fn hung_elbow_side(s: &Session) -> f64 {
let b = pt(s, "ab.end");
(pt(s, "eh.end") - b).cross(pt(s, "be.end") - b).signum()
}
/// The hand of `hung_arm` swung by `angle` about the shoulder.
fn hung_hand_target(s: &Session, angle: f64, scale: f64) -> solvecraft_geom::Vec2 {
let (b, h) = (pt(s, "ab.end"), pt(s, "eh.end"));
let r = h - b;
let (c, si) = (angle.cos(), angle.sin());
b + v(r.x * c - r.y * si, r.x * si + r.y * c) * scale
}
/// A live drag whose seed runs out of decomposition budget takes the rest of the way in short
/// steps (continuation keeps the elbow the seed no longer places).
#[test]
fn a_drag_out_of_decomposition_budget_takes_short_steps() {
let start = hung_arm();
let before = hung_elbow_side(&start);
let h = pt(&start, "eh.end");
let mut flips = 0;
for k in [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0] {
let mut s = hung_arm();
let to = hung_hand_target(&s, k * 0.7, 1.0);
let planned = drag_steps(&s, "eh.end", [h.x, h.y], [to.x, to.y]);
let r = super::drag::last_drag::with_frame_work(0, || {
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [to.x, to.y], "live": true}))
});
let d = super::drag::last_drag::get();
assert!(d.cut, "{d:?}");
assert!(d.count > planned, "{planned} steps planned, {d:?}: the rest in short steps");
assert!(pt(&s, "eh.end").dist(to) < 1e-6, "{r}");
if hung_elbow_side(&s) != before {
flips += 1;
}
}
assert_eq!(flips, 0);
}
/// All the held solves of one live frame, halvings included, share one decomposition budget:
/// together they do at most a frame's work, and one solve's more.
#[test]
fn a_live_drag_shares_one_decomposition_budget() {
let mut s = hung_arm();
let h = pt(&s, "eh.end");
let near = hung_hand_target(&s, 0.01, 1.0);
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [near.x, near.y], "live": true}));
let one = super::drag::last_drag::get();
assert!(one.count == 1 && one.work > 0 && !one.cut, "{one:?}");
// Swung round and out past the arm's reach: many steps, then halvings.
let mut s = hung_arm();
let far = hung_hand_target(&s, 2.0, 1.5);
let budget = 3 * one.work;
super::drag::last_drag::with_frame_work(budget, || {
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [far.x, far.y], "live": true}))
});
let d = super::drag::last_drag::get();
assert!(d.count > 6, "{d:?}");
assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}");
}
+109 -3
View File
@@ -190,6 +190,9 @@ pub struct Session {
pub active_sketch: Option<u64>,
pub selection: Vec<Sel>,
pub path: Option<String>,
/// One-shot display-only notification during doc.open, before authoritative evaluation.
/// Interactive loaders may show it provisionally; it never becomes model state.
pub open_preview: Option<Box<dyn Fn(solvecraft_io::DesignPreview) + Send + Sync>>,
saved: Arc<Document>,
/// Messages for the command line / log.
pub log: Vec<String>,
@@ -217,6 +220,19 @@ pub struct Session {
/// commands are undone however long the undo history is.
pub(crate) checkpoints: Vec<Checkpoint>,
next_checkpoint: u64,
/// Full model evaluations (`refresh`), for tests.
#[cfg(test)]
pub(crate) evaluations: usize,
}
/// A sketch changed by one frame of an interactive gesture (`live: true`).
#[derive(Clone, Debug)]
pub struct LiveEdit {
pub sketch: u64,
/// What the frame's solve found: a full solve's report, or, when the sketch was solved
/// without a rank analysis, its last report's degrees of freedom and determined flags (a
/// drag does not change them; the evaluation at the gesture's end brings them up to date).
pub report: solvecraft_sketch::SolveReport,
}
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
@@ -288,6 +304,7 @@ impl Session {
active_sketch: None,
selection: Vec::new(),
path: None,
open_preview: None,
log: Vec::new(),
revision: 1,
auto_project: true,
@@ -302,6 +319,8 @@ impl Session {
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
#[cfg(test)]
evaluations: 0,
}
}
@@ -329,6 +348,15 @@ impl Session {
Arc::make_mut(&mut self.doc)
}
/// Prepared placements only: optional save caches must not compute a cold world.
pub(crate) fn cached_world_state(&self) -> Option<Arc<solvecraft_doc::ModelState>> {
let state = self.model.state();
if self.doc.occurrences.is_empty() {
return Some(state);
}
self.world_cache.lock().ok()?.as_ref().filter(|cache| cache.matches(self, &state)).map(|cache| cache.world.clone())
}
/// Re-evaluate the model after a document change.
/// The model placed in the world through component occurrences (and uncaptured moves): what
/// to display, measure, pick and export. The same as `model.state()` without components.
@@ -360,6 +388,7 @@ impl Session {
if let Some(inverse) = solvecraft_doc::mat_inverse(&first) {
let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse);
for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) {
let sketch = Arc::make_mut(sketch);
let plane = sketch.plane;
if let Some(mapped) = solvecraft_geom::Plane::new(
solvecraft_doc::apply_point(&delta, plane.origin),
@@ -394,6 +423,10 @@ impl Session {
pub fn refresh(&mut self) {
self.model.evaluate(&self.doc);
#[cfg(test)]
{
self.evaluations += 1;
}
self.revision += 1;
self.prune_occurrence_state();
// Joints follow the geometry their origins snap to.
@@ -407,10 +440,65 @@ impl Session {
}
}
/// Show the sketch a live frame changed, leaving the features after it as they were last
/// evaluated (marked stale); a full `refresh` when the model cannot show it that way.
fn refresh_live(&mut self, edit: LiveEdit) {
let sketch = self.doc.sketch(edit.sketch).ok().cloned();
if sketch.is_some_and(|sketch| self.model.show_live_sketch(edit.sketch, sketch, edit.report)) {
self.revision += 1;
} else {
self.refresh();
}
}
/// The sketches whose last evaluation solved, each with its own parameter-driven values
/// (taken before a value edit, for [`Session::settle_value_edit`]).
pub(crate) fn solved_sketches(&self) -> SketchValues {
self.model
.results
.iter()
.filter_map(|r| r.state.sketch(r.id).filter(|k| k.report.keeps_orientation()).map(|k| (r.id, own_values(&k.sketch))))
.collect()
}
/// After the document's values changed (parameters, a configuration, the units): evaluate,
/// and refuse the edit when a sketch that solved before no longer does, or turns over,
/// because its own parameter-driven values changed (the command fails, so `execute`
/// restores the document's values and geometry). A sketch broken by a change upstream
/// (a projected or linked edge moved) is not the edit's to refuse: it carries a warning,
/// as before. Otherwise store each sketch whose values changed as solved, so the next edit
/// continues from it (see `Model::adopt_solved_sketches`).
pub(crate) fn settle_value_edit(&mut self, solved_before: &SketchValues) -> Result<()> {
self.refresh();
for r in &self.model.results {
let (Some(before), Some(k)) = (solved_before.get(&r.id), r.state.sketch(r.id)) else { continue };
if !k.report.keeps_orientation() && own_values(&k.sketch) != *before {
let why = k.report.failure(|f| format!("its constraints would conflict ({f})"));
return Err(EngineError::Other(format!("sketch `{}`: {why}; the values were not changed", r.name)));
}
}
// Checked first: storing makes the document unique (a copy when the undo step shares it).
if self.model.edited_sketches(&self.doc).next().is_some() {
self.model.adopt_solved_sketches(Arc::make_mut(&mut self.doc));
}
Ok(())
}
/// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step
/// when they change the document; a failing or panicking command leaves it unchanged.
///
/// A command that takes `live` ([`CommandSpec::live`]) run with `live: true` is one frame of
/// an interactive gesture: the model shows its sketch's new solution and keeps every later
/// feature's last result (stale) instead of evaluating them. Any other command that reads
/// the model ([`CommandSpec::reads_model`]) first evaluates a stale model in full, so the
/// gesture's end (`sketch.drag_end`, or whatever runs next after an abandoned gesture) brings
/// the model up to date before anything reads it.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
if live.is_none() && spec.reads_model && self.model.is_stale() {
self.refresh();
}
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let before = Snapshot {
label: spec.label.to_string(),
@@ -425,7 +513,10 @@ impl Session {
let (_nested, outermost) = frames::Nested::enter(spec.id);
let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None };
let params = mapped.as_ref().unwrap_or(params);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match live {
Some(run) => run(self, params),
None => (spec.run)(self, params).map(|v| (v, None)),
}));
let r = match r {
Ok(r) => r,
Err(p) => {
@@ -435,7 +526,7 @@ impl Session {
};
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
match r {
Ok(v) => {
Ok((v, live_edit)) => {
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
if changed {
if spec.undoable {
@@ -445,7 +536,10 @@ impl Session {
}
self.redo.clear();
}
self.refresh();
match live_edit {
Some(edit) => self.refresh_live(edit),
None => self.refresh(),
}
}
Ok(v)
}
@@ -533,6 +627,7 @@ impl Session {
active_sketch: self.active_sketch,
selection: self.selection.clone(),
path: None,
open_preview: None,
saved: self.saved.clone(),
log: Vec::new(),
revision: self.revision,
@@ -548,6 +643,8 @@ impl Session {
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: self.visibility.clone(),
#[cfg(test)]
evaluations: 0,
}
}
@@ -686,3 +783,12 @@ mod tests;
mod thread_tests;
#[cfg(test)]
mod world_placement_cache_tests;
/// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]).
pub(crate) type SketchValues = std::collections::BTreeMap<u64, std::collections::BTreeMap<String, f64>>;
/// The values of a sketch's driving dimensions that come from parameters: what a value edit
/// changes in it (as opposed to geometry it projects or links from upstream).
fn own_values(sk: &solvecraft_sketch::Sketch) -> std::collections::BTreeMap<String, f64> {
sk.constraints.iter().filter(|c| c.param.is_some() && !c.driven).filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect()
}
+93 -2
View File
@@ -22,6 +22,17 @@ fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn round_as_tall_as_a_disc_uses_up_its_wall() {
// #190: a disc 13 in radius and 2 high, its top edge rounded by 2.
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 13, "height": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 13, 2], [0, -13, 2]], "radius": "2 mm"}));
let (a, c) = ((1.0 - PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let expect = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c);
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
}
#[test]
fn fillet_as_wide_as_a_face_rounds_it_off() {
// #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces).
@@ -198,6 +209,42 @@ fn fillet_the_edges_between_two_overlapping_cylinders() {
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
}
/// The top outline of two overlapping cylinders whose concave edges were rounded: the walls of
/// the rounds meet the cylinders along the rolling-ball contact lines, straight but not lines.
#[test]
fn fillet_and_chamfer_the_top_loop_of_rounded_overlapping_cylinders() {
use std::f64::consts::{PI, TAU};
let (big, round, h) = (25.0_f64, 5.0_f64, 10.0);
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": big}));
run(&mut s, "sketch.circle.center", json!({"center": [-big, 0], "radius": big}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": h, "profiles": [0, 1, 2]}));
// Plan view: ball centres at x = −12.5, |y| = yb; each round's arc turns by th, each big arc by
// TAU − 2·a1, and the loop turns by TAU in all.
let half = big / 2.0;
let yb = ((big + round).powi(2) - half * half).sqrt();
run(&mut s, "solid.fillet", json!({"edges": [[-half, half * 3f64.sqrt(), 5], [-half, -half * 3f64.sqrt(), 5]], "radius": round}));
let (a1, th) = (yb.atan2(half), 2.0 * (half / (big + round)).asin());
let base = volume(&mut s);
let top = [[big, 0.0, h], [-2.0 * big, 0.0, h], [-half, yb - round, h], [-half, round - yb, h]];
// Removed volume by Pappus: the corner's section swept along the loop at its centroid's offset.
let removed = |area: f64, xb: f64| area * (2.0 * (TAU - 2.0 * a1) * (big - xb) + 2.0 * th * (round + xb));
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 2}));
let r: f64 = 2.0;
let expect = base - removed(r * r * (1.0 - PI / 4.0), (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)) * r);
let v = volume(&mut s);
assert!((v - expect).abs() < 1.5, "{v} vs {expect}");
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["kernel"]["faces"].as_u64(), Some(10), "{m}");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.chamfer", json!({"edges": top, "distance": 2}));
let expect = base - removed(2.0, 2.0 / 3.0);
let v = volume(&mut s);
assert!((v - expect).abs() < 1.5, "{v} vs {expect}");
}
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
#[test]
fn box_fillet_cut_part() {
@@ -1630,8 +1677,10 @@ fn cached_evaluation_matches_from_scratch() {
let before = hits(&s);
run(&mut s, id, p.clone());
same(&s, &format!("step {step}: {id} {p}"));
if step == 1 {
assert!(hits(&s) > before, "setting the width back reuses the earlier results");
// Setting the width back solves the sketch again from its current state; the
// features after it see the same geometry and reuse their results. So does undo.
if step == 1 || step == 3 {
assert!(hits(&s) > before, "step {step} reuses the earlier results");
}
}
}
@@ -2500,6 +2549,48 @@ fn special_files_are_refused_by_every_file_command() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A cut targeted at one body leaves a non-target body alone, even one the tool covers completely (#260).
#[test]
fn targeted_cut_keeps_a_covered_body_that_is_not_a_target() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "Frame"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "body_name": "Other"}));
// Exactly Other's extent; it does not touch Frame.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "cut", "targets": ["Frame"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Frame", "Other"]);
assert!(rel(volume(&mut s), 2000.0) < 1e-6);
}
/// A frame's opening cut over a separate mesh body that sits in it: the mesh survives (#260).
#[test]
fn targeted_cut_keeps_an_overlapping_body_that_is_not_a_target() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 50, "height": 5, "operation": "new_body", "body_name": "Frame"}));
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 1, "corner": [10, 10, 2], "operation": "new_body", "body_name": "Mesh"}));
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "corner": [10, 10, 0], "operation": "cut", "targets": ["Frame"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Frame", "Mesh"]);
assert!(rel(volume(&mut s), 50.0 * 50.0 * 5.0 - 30.0 * 30.0 * 5.0 + 900.0) < 1e-6);
}
/// A join with explicit `targets` merges into those bodies only; a touched non-target stays (#259).
#[test]
fn join_with_targets_leaves_other_touched_bodies_alone() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B"}));
// Straddles the face between A and B; only A is a target.
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [8, 3, 3], "operation": "join", "targets": ["A"]}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["A", "B"], "B is not a target and must stay a body");
let b = run(&mut s, "inspect.measure", json!({"bodies": ["B"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(b, 1000.0) < 1e-6, "B is unchanged: {b}");
// A gains the half of the tool outside it.
let a = run(&mut s, "inspect.measure", json!({"bodies": ["A"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(a, 1032.0) < 1e-6, "A: {a}");
}
/// Deleting a sketch takes its extrude with it; a later combine that names the extrude's body must
/// not be left failing silently: it is deleted too, or the reply reports it (#267).
#[test]
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+812
View File
@@ -0,0 +1,812 @@
//! B-spline machinery shared by NURBS and T-spline surfaces: homogeneous points, the
//! univariate basis (Cox–de Boor on half-open intervals, with derivatives), Boehm knot
//! insertion, knot insertion into a single basis function, Bézier extraction, and a tensor
//! NURBS surface.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2 (basis definition, eq. 2.5),
//! §2.3 (derivatives, eq. 2.9), A2.1 (span search), §5.2 (knot insertion, eq. 5.10 / A5.1),
//! §1.4 (de Casteljau); Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline
//! simplification and local refinement", §3 (knot insertion into one blending function).
//!
//! Input is hostile: every constructor checks finiteness and sizes and returns
//! [`SplineError`] instead of panicking; no function here indexes with an unchecked number.
use std::ops::{Add, AddAssign, Mul, Sub};
use crate::Vec3;
/// Highest polynomial degree accepted in either direction.
pub const MAX_SPLINE_DEGREE: usize = 7;
/// Most knots accepted in one knot vector.
pub const MAX_KNOTS: usize = 1 << 14;
/// Most control points accepted in one tensor surface.
pub const MAX_SURFACE_POINTS: usize = 1 << 20;
/// Why a spline could not be built or used.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SplineError {
#[error("invalid spline: {0}")]
Invalid(String),
#[error("not supported yet: {0}")]
Unsupported(String),
#[error("spline too large: {0}")]
TooLarge(String),
}
pub type SplineResult<T> = Result<T, SplineError>;
pub(crate) fn invalid<T>(msg: impl Into<String>) -> SplineResult<T> {
Err(SplineError::Invalid(msg.into()))
}
/// A homogeneous control point `(x·w, y·w, z·w, w)`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct HPoint {
pub x: f64,
pub y: f64,
pub z: f64,
pub w: f64,
}
impl HPoint {
pub const ZERO: HPoint = HPoint { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
/// The point `p` with weight `w`.
pub fn weighted(p: Vec3, w: f64) -> HPoint {
HPoint { x: p.x * w, y: p.y * w, z: p.z * w, w }
}
/// The Cartesian point, if the weight is usable.
pub fn project(self) -> Option<Vec3> {
let p = Vec3::new(self.x / self.w, self.y / self.w, self.z / self.w);
(self.w.abs() > f64::MIN_POSITIVE && p.is_finite()).then_some(p)
}
/// The weighted coordinates `(x·w, y·w, z·w)`.
pub fn xyz(self) -> Vec3 {
Vec3::new(self.x, self.y, self.z)
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite() && self.w.is_finite()
}
}
impl Add for HPoint {
type Output = HPoint;
fn add(self, o: HPoint) -> HPoint {
HPoint { x: self.x + o.x, y: self.y + o.y, z: self.z + o.z, w: self.w + o.w }
}
}
impl Sub for HPoint {
type Output = HPoint;
fn sub(self, o: HPoint) -> HPoint {
HPoint { x: self.x - o.x, y: self.y - o.y, z: self.z - o.z, w: self.w - o.w }
}
}
impl Mul<f64> for HPoint {
type Output = HPoint;
fn mul(self, k: f64) -> HPoint {
HPoint { x: self.x * k, y: self.y * k, z: self.z * k, w: self.w * k }
}
}
impl AddAssign for HPoint {
fn add_assign(&mut self, o: HPoint) {
*self = *self + o;
}
}
/// A parameter direction: along s (u, the first) or along t (v, the second).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Dir {
S,
T,
}
impl Dir {
/// `s` for [`Dir::S`], `t` for [`Dir::T`].
pub fn pick<X>(self, s: X, t: X) -> X {
match self {
Dir::S => s,
Dir::T => t,
}
}
}
/// Homogeneous control points from finite points and positive weights, one each.
pub fn weighted_points(points: &[Vec3], weights: &[f64]) -> SplineResult<Vec<HPoint>> {
if points.len() != weights.len() {
return invalid("one weight per control point");
}
if points.iter().any(|p| !p.is_finite()) {
return invalid("a control point is not finite");
}
if weights.iter().any(|w| !(w.is_finite() && *w > 0.0)) {
return invalid("weights must be positive numbers");
}
Ok(points.iter().zip(weights).map(|(p, w)| HPoint::weighted(*p, *w)).collect())
}
/// Which one-sided limit a basis evaluation takes at a knot. Basis functions live on half-open
/// spans `[uᵢ, uᵢ₊₁)` ([`Side::Right`]); at the right end of a domain the left limit on
/// `(uᵢ, uᵢ₊₁]` ([`Side::Left`]) keeps the closing span.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Right,
Left,
}
/// Check a knot vector: finite, non-decreasing, not too long.
pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
if knots.len() > MAX_KNOTS {
return Err(SplineError::TooLarge(format!("{} knots (at most {MAX_KNOTS})", knots.len())));
}
if knots.iter().any(|k| !k.is_finite()) {
return invalid("a knot is not a finite number");
}
if knots.windows(2).any(|w| matches!(w, [a, b] if b < a)) {
return invalid("knots must not decrease");
}
Ok(())
}
/// One B-spline basis function of degree `p = knots.len() − 2` given by its local knot vector
/// `[ξ₀ … ξₚ₊₁]`, and its derivatives up to order `n`: element `k` is the `k`-th derivative at
/// `u`. Cox–de Boor recursion (NURBS Book eq. 2.5) with the 0/0 = 0 convention, derivatives by
/// eq. 2.9 applied repeatedly (`dᵏNᵢ,ₚ = p·(dᵏ⁻¹Nᵢ,ₚ₋₁/(uᵢ₊ₚ−uᵢ) − dᵏ⁻¹Nᵢ₊₁,ₚ₋₁/(uᵢ₊ₚ₊₁−uᵢ₊₁))`).
/// A knot vector shorter than two knots gives zeros.
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
let mut out = vec![0.0; n + 1];
let Some(p) = knots.len().checked_sub(2) else { return out };
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
return out;
}
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
let ratio = |num: f64, den: f64| if den > 0.0 { num / den } else { 0.0 };
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
const W: usize = MAX_SPLINE_DEGREE + 1;
let levels = n.min(p);
let mut table = [[0.0f64; W]; W];
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
let (a, b) = (kn(j), kn(j + 1));
let inside = match side {
Side::Right => a <= u && u < b,
Side::Left => a < u && u <= b,
};
*slot = if inside { 1.0 } else { 0.0 };
}
for q in 1..=p {
for k in (0..=levels).rev() {
for j in 0..=p - q {
let (d1, d2) = (kn(j + q) - kn(j), kn(j + q + 1) - kn(j + 1));
let at = |k: usize, j: usize| table.get(k).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let v = if k == 0 {
ratio(u - kn(j), d1) * at(0, j) + ratio(kn(j + q + 1) - u, d2) * at(0, j + 1)
} else {
q as f64 * (ratio(at(k - 1, j), d1) - ratio(at(k - 1, j + 1), d2))
};
if let Some(slot) = table.get_mut(k).and_then(|r| r.get_mut(j)) {
*slot = v;
}
}
}
}
for (k, o) in out.iter_mut().enumerate().take(levels + 1) {
*o = table.get(k).map_or(0.0, |r| r[0]);
}
out
}
/// The coefficients `(c₁, c₂)` of knot insertion into one basis function: inserting `k`
/// (with `ξ₀ ≤ k ≤ ξₚ₊₁`) into `N[ξ₀…ξₚ₊₁]` gives `c₁·N[first p+2 merged knots] +
/// c₂·N[last p+2 merged knots]` (Sederberg et al. 2004, §3; the single-function case of the
/// NURBS Book eq. 5.10). `None` when `k` is outside the support or the knots are too few.
pub fn split_basis(knots: &[f64], k: f64) -> Option<(f64, f64)> {
let p = knots.len().checked_sub(2)?;
let (x0, x1, xp, xl) = (*knots.first()?, *knots.get(1)?, *knots.get(p)?, *knots.get(p + 1)?);
if !(k >= x0 && k <= xl) {
return None;
}
let c1 = if k >= xp { 1.0 } else { (k - x0) / (xp - x0) };
let c2 = if k <= x1 { 1.0 } else { (xl - k) / (xl - x1) };
Some((c1, c2))
}
/// The two knot vectors of a split basis function (see [`split_basis`]): `k` merged into
/// `knots` in order, then the first and the last `knots.len()` entries.
pub fn split_knots<T: Copy + PartialOrd>(knots: &[T], k: T) -> [Vec<T>; 2] {
let at = knots.iter().position(|&x| x > k).unwrap_or(knots.len());
let mut merged = knots.to_vec();
merged.insert(at, k);
let n = knots.len();
[merged.get(..n).map(<[T]>::to_vec).unwrap_or_default(), merged.get(1..).map(<[T]>::to_vec).unwrap_or_default()]
}
/// The Bernstein coefficients of one basis function `N[knots]` on `[a, b]`: `N(u) =
/// Σ cᵢ Bᵢ,ₚ((u−a)/(b−a))` there. `[a, b]` must not contain a knot in its interior (the
/// function is one polynomial on it). Found by inserting `a` and `b` into the function until
/// every surviving piece is a Bernstein polynomial `N[a…a, b…b]` (Sederberg et al. 2004, §3;
/// NURBS Book §5.4, decomposition into Bézier segments).
pub fn basis_bezier(knots: &[f64], a: f64, b: f64) -> SplineResult<Vec<f64>> {
let Some(p) = knots.len().checked_sub(2) else { return invalid("a basis function needs at least two knots") };
if p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (at most {MAX_SPLINE_DEGREE})")));
}
check_knots(knots)?;
if !(a.is_finite() && b.is_finite() && a < b) {
return invalid("a Bézier interval must be finite and non-empty");
}
if knots.iter().any(|&x| x > a && x < b) {
return invalid("a knot lies inside the Bézier interval");
}
let mut out = vec![0.0; p + 1];
let mut work = vec![(knots.to_vec(), 1.0)];
let mut steps = 0usize;
while let Some((kn, c)) = work.pop() {
steps += 1;
if steps > 1 << 12 {
return Err(SplineError::TooLarge("Bézier extraction did not converge".into()));
}
let (Some(&lo), Some(&hi)) = (kn.first(), kn.last()) else { continue };
if hi <= a || lo >= b || c == 0.0 {
continue;
}
let insert = if kn.iter().any(|&x| x < a) {
Some(a)
} else if kn.iter().any(|&x| x > b) {
Some(b)
} else {
None
};
match insert {
Some(k) => {
let (c1, c2) = split_basis(&kn, k).ok_or_else(|| SplineError::Invalid("knot insertion outside the support".into()))?;
let [first, second] = split_knots(&kn, k);
work.push((first, c * c1));
work.push((second, c * c2));
}
None => {
// Knots a×(p+1−i), b×(i+1): the Bernstein polynomial Bᵢ,ₚ.
let nb = kn.iter().filter(|&&x| x == b).count();
if let Some(slot) = nb.checked_sub(1).and_then(|i| out.get_mut(i)) {
*slot += c;
}
}
}
}
Ok(out)
}
/// The span index `k` with `knots[k] ≤ u < knots[k+1]` among the valid spans `p ≤ k < n`
/// (`n` control points), the last span closed at the domain end (NURBS Book A2.1).
pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
if n <= p || knots.len() != n + p + 1 || !u.is_finite() {
return None;
}
let (lo, hi) = (*knots.get(p)?, *knots.get(n)?);
if u < lo || u > hi {
return None;
}
// The last non-empty span in [p, n−1] that starts at or before u.
let mut k = p;
for i in p..n {
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
&& a <= u
&& a < b
{
k = i;
}
}
Some(k)
}
/// Insert the knot `u` once into a B-spline with degree `p`, knots and homogeneous control
/// points (Boehm; NURBS Book eq. 5.10 / A5.1). `u` must lie in the domain `[knots[p],
/// knots[n]]`; the curve is unchanged.
pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
let n = ctrl.len();
if n <= p || knots.len() != n + p + 1 {
return invalid("knot and control point counts do not match the degree");
}
if knots.len() >= MAX_KNOTS {
return Err(SplineError::TooLarge("too many knots".into()));
}
check_knots(knots)?;
let k = find_span(knots, p, n, u).ok_or_else(|| SplineError::Invalid(format!("knot {u} is outside the domain")))?;
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
let pt = |i: usize| ctrl.get(i).copied().unwrap_or(HPoint::ZERO);
let mut q = Vec::with_capacity(n + 1);
for i in 0..=n {
q.push(if i + p <= k {
pt(i)
} else if i > k {
pt(i - 1)
} else {
let den = kn(i + p) - kn(i);
let alpha = if den > 0.0 { (u - kn(i)) / den } else { 1.0 };
pt(i) * alpha + pt(i - 1) * (1.0 - alpha)
});
}
let mut nk = knots.to_vec();
nk.insert(k + 1, u);
Ok((nk, q))
}
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
if i > p {
return 0.0;
}
let mut c = 1.0;
for m in 0..i {
c = c * (p - m) as f64 / (m + 1) as f64;
}
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
}
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
pub fn bezier_point(ctrl: &[HPoint], x: f64) -> HPoint {
subdivide(ctrl, x).0.last().copied().unwrap_or(HPoint::ZERO)
}
/// The control points of the piece of a Bézier curve over `[a, b] ⊂ [0, 1]` (two de Casteljau
/// subdivisions).
pub fn bezier_segment(ctrl: &[HPoint], a: f64, b: f64) -> SplineResult<Vec<HPoint>> {
if !(a.is_finite() && b.is_finite() && 0.0 <= a && a < b && b <= 1.0) {
return invalid("a Bézier segment must satisfy 0 ≤ a < b ≤ 1");
}
// Left part [0, b], then its right part from a/b.
let left = subdivide(ctrl, b).0;
Ok(subdivide(&left, a / b).1)
}
/// Split a Bézier curve at `x` (de Casteljau): control points of `[0, x]` and `[x, 1]`.
fn subdivide(ctrl: &[HPoint], x: f64) -> (Vec<HPoint>, Vec<HPoint>) {
let mut left = Vec::with_capacity(ctrl.len());
let mut right = Vec::with_capacity(ctrl.len());
let mut pts = ctrl.to_vec();
while let (Some(&f), Some(&l)) = (pts.first(), pts.last()) {
left.push(f);
right.push(l);
pts = pts.windows(2).map(|w| if let [a, b] = w { *a * (1.0 - x) + *b * x } else { HPoint::ZERO }).collect();
}
right.reverse();
(left, right)
}
/// A rational tensor Bézier patch over the parameter rectangle `s × t`: control point `(i, j)`
/// (i along s, j along t) at `ctrl[j·(degree[0]+1) + i]`.
#[derive(Clone, Debug, PartialEq)]
pub struct BezierPatch {
pub s: [f64; 2],
pub t: [f64; 2],
pub degree: [usize; 2],
pub ctrl: Vec<HPoint>,
}
impl BezierPatch {
/// Control point `(i, j)`.
pub fn point(&self, i: usize, j: usize) -> HPoint {
self.ctrl.get(j * (self.degree[0] + 1) + i).copied().unwrap_or(HPoint::ZERO)
}
/// Homogeneous point at global parameters `(s, t)`.
pub fn eval_h(&self, s: f64, t: f64) -> HPoint {
let x = (s - self.s[0]) / (self.s[1] - self.s[0]);
let y = (t - self.t[0]) / (self.t[1] - self.t[0]);
let mut acc = HPoint::ZERO;
for j in 0..=self.degree[1] {
let bj = bernstein(self.degree[1], j, y);
for i in 0..=self.degree[0] {
acc += self.point(i, j) * (bernstein(self.degree[0], i, x) * bj);
}
}
acc
}
/// Point at global parameters `(s, t)`.
pub fn eval(&self, s: f64, t: f64) -> Option<Vec3> {
self.eval_h(s, t).project()
}
/// The control points of one side as a Bézier curve: `0` t = t₀ (s rising), `1` s = s₁
/// (t rising), `2` t = t₁ (s rising), `3` s = s₀ (t rising).
pub fn side(&self, side: usize) -> Vec<HPoint> {
let [ps, pt] = self.degree;
match side {
0 => (0..=ps).map(|i| self.point(i, 0)).collect(),
1 => (0..=pt).map(|j| self.point(ps, j)).collect(),
2 => (0..=ps).map(|i| self.point(i, pt)).collect(),
_ => (0..=pt).map(|j| self.point(0, j)).collect(),
}
}
}
/// A rational tensor-product B-spline (NURBS) surface. Control point `(i, j)` (i along u, j
/// along v) is `ctrl[j·count_u + i]`; the domain is `[knots_u[p], knots_u[n]] × …`.
#[derive(Clone, Debug, PartialEq)]
pub struct NurbsSurface {
degree: [usize; 2],
knots: [Vec<f64>; 2],
count: [usize; 2],
ctrl: Vec<HPoint>,
}
impl NurbsSurface {
/// A surface from Cartesian points and positive weights (one per point, u fastest).
pub fn new(degree: [usize; 2], knots: [Vec<f64>; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<NurbsSurface> {
Self::from_homogeneous(degree, knots, weighted_points(points, weights)?)
}
/// A surface from homogeneous control points (u fastest).
pub fn from_homogeneous(degree: [usize; 2], knots: [Vec<f64>; 2], ctrl: Vec<HPoint>) -> SplineResult<NurbsSurface> {
let mut count = [0; 2];
for d in 0..2 {
let (p, kn) = (degree[d], &knots[d]);
if p == 0 || p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
}
check_knots(kn)?;
let n = kn.len().checked_sub(p + 1).filter(|&n| n > p).ok_or_else(|| SplineError::Invalid("too few knots for the degree".into()))?;
if !(kn.get(p) < kn.get(n)) {
return invalid("the surface's domain is empty");
}
count[d] = n;
}
if count[0].checked_mul(count[1]).is_none_or(|c| c > MAX_SURFACE_POINTS) {
return Err(SplineError::TooLarge(format!("more than {MAX_SURFACE_POINTS} control points")));
}
if ctrl.len() != count[0] * count[1] {
return invalid(format!("{} control points, expected {}", ctrl.len(), count[0] * count[1]));
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
return invalid("control points must be finite with positive weights");
}
Ok(NurbsSurface { degree, knots, count, ctrl })
}
pub fn degree(&self) -> [usize; 2] {
self.degree
}
pub fn knots(&self) -> &[Vec<f64>; 2] {
&self.knots
}
/// Control point counts along u and v.
pub fn count(&self) -> [usize; 2] {
self.count
}
pub fn homogeneous(&self) -> &[HPoint] {
&self.ctrl
}
/// Control point `(i, j)`.
pub fn point(&self, i: usize, j: usize) -> HPoint {
self.ctrl.get(j * self.count[0] + i).copied().unwrap_or(HPoint::ZERO)
}
/// The parameter domain `[[u₀, u₁], [v₀, v₁]]`.
pub fn domain(&self) -> [[f64; 2]; 2] {
let d = |k: usize| {
let (p, n, kn) = (self.degree[k], self.count[k], &self.knots[k]);
[kn.get(p).copied().unwrap_or(0.0), kn.get(n).copied().unwrap_or(0.0)]
};
[d(0), d(1)]
}
/// The point at `(u, v)`, which must lie in the domain.
pub fn eval(&self, u: f64, v: f64) -> SplineResult<Vec3> {
let dom = self.domain();
let mut spans = [0; 2];
let mut basis: [Vec<f64>; 2] = [Vec::new(), Vec::new()];
for (d, x) in [u, v].into_iter().enumerate() {
let (p, n, kn) = (self.degree[d], self.count[d], &self.knots[d]);
let span = find_span(kn, p, n, x).ok_or_else(|| SplineError::Invalid(format!("({u}, {v}) is outside the domain")))?;
let side = if x >= dom[d][1] { Side::Left } else { Side::Right };
spans[d] = span;
basis[d] = (span - p..=span).map(|i| kn.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, x, side, 0)[0])).collect();
}
let mut acc = HPoint::ZERO;
for (b, nj) in basis[1].iter().enumerate() {
for (a, ni) in basis[0].iter().enumerate() {
acc += self.point(spans[0] - self.degree[0] + a, spans[1] - self.degree[1] + b) * (ni * nj);
}
}
acc.project().ok_or_else(|| SplineError::Invalid("the surface's weights vanish there".into()))
}
/// The same surface with the knot `x` inserted once in direction `dir` ([`Dir::S`] = u).
pub fn insert_knot(&self, dir: Dir, x: f64) -> SplineResult<NurbsSurface> {
let d = dir.pick(0, 1);
let (p, kn) = (self.degree[d], &self.knots[d]);
let [nu, nv] = self.count;
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
let at = |line: usize, k: usize| if d == 0 { self.point(k, line) } else { self.point(line, k) };
let mut new_knots = Vec::new();
let mut rows: Vec<Vec<HPoint>> = Vec::with_capacity(lines);
for line in 0..lines {
let row: Vec<HPoint> = (0..len).map(|k| at(line, k)).collect();
let (nk, nr) = insert_knot(p, kn, &row, x)?;
new_knots = nk;
rows.push(nr);
}
let mut knots = self.knots.clone();
knots[d] = new_knots;
let new_len = len + 1;
let mut ctrl = Vec::with_capacity(lines * new_len);
if d == 0 {
for row in &rows {
ctrl.extend_from_slice(row);
}
} else {
for j in 0..new_len {
for row in &rows {
ctrl.push(row.get(j).copied().unwrap_or(HPoint::ZERO));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
/// each end, so the boundary curves are given by the boundary rows of control points):
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
/// does not depend on its first knot, so that knot can be moved to the domain end).
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
let mut s = self.clone();
for dir in [Dir::S, Dir::T] {
let d = dir.pick(0, 1);
let p = s.degree[d];
for end in 0..2 {
let a = s.domain()[d][end];
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
s = s.insert_knot(dir, a)?;
}
s = s.trim_end(d, end, a)?;
}
}
Ok(s)
}
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
/// at the start (`end == 0`) or the end of direction `d`.
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
let p = self.degree[d];
let kn = &self.knots[d];
let n = self.count[d];
let (first, last) = match end {
0 => {
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
(last_a.saturating_sub(p), n)
}
_ => {
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i ≥ first_a vanish on (…, a].
(0, first_a.min(n))
}
};
if last <= first + p {
return invalid("clamp: too few control points remain");
}
let mut knots = self.knots.clone();
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
match end {
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
}
knots[d] = nk;
let [nu, nv] = self.count;
let mut ctrl = Vec::new();
for j in 0..nv {
for i in 0..nu {
let k = if d == 0 { i } else { j };
if k >= first && k < last {
ctrl.push(self.point(i, j));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A small deterministic generator for test data.
pub(crate) struct Lcg(pub(crate) u64);
impl Lcg {
pub(crate) fn unit(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
}
const KNOTS: [f64; 12] = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 1.0, 1.0, 1.0, 1.0];
fn samples() -> Vec<f64> {
(0..=200).map(|k| k as f64 / 200.0).collect()
}
#[test]
fn basis_partition_of_unity_non_negative_compact_support() {
let p = 3;
let n = KNOTS.len() - p - 1;
for u in samples() {
let side = if u >= 1.0 { Side::Left } else { Side::Right };
let mut sum = 0.0;
for i in 0..n {
let local = &KNOTS[i..i + p + 2];
let b = local_basis(local, u, side, 0)[0];
assert!(b >= -1e-15, "N{i}({u}) = {b}");
if u < local[0] || u > local[p + 1] {
assert_eq!(b, 0.0, "N{i} is not zero outside its support at {u}");
}
sum += b;
}
assert!((sum - 1.0).abs() < 1e-14, "Σ N({u}) = {sum}");
}
}
#[test]
fn basis_derivatives_match_finite_differences() {
let p = 3;
let h = 1e-6;
let mut worst = 0.0f64;
for i in 0..KNOTS.len() - p - 1 {
let local = &KNOTS[i..i + p + 2];
for u in samples() {
if KNOTS.iter().any(|k| (k - u).abs() < 2.0 * h) {
continue;
}
let d = local_basis(local, u, Side::Right, 2);
let f = |x: f64| local_basis(local, x, Side::Right, 0)[0];
let fd1 = (f(u + h) - f(u - h)) / (2.0 * h);
let df = |x: f64| local_basis(local, x, Side::Right, 1)[1];
let fd2 = (df(u + h) - df(u - h)) / (2.0 * h);
worst = worst.max((d[1] - fd1).abs()).max((d[2] - fd2).abs() / 100.0);
}
}
assert!(worst < 1e-5, "derivative error {worst}");
}
#[test]
fn single_function_knot_insertion_is_exact() {
let mut rng = Lcg(7);
for _ in 0..200 {
let mut kn: Vec<f64> = (0..5).map(|_| (rng.unit() * 8.0).round() / 8.0).collect();
kn.sort_by(f64::total_cmp);
if kn[0] == kn[4] {
continue;
}
let k = kn[0] + (kn[4] - kn[0]) * (rng.unit() * 8.0).round() / 8.0;
let (c1, c2) = split_basis(&kn, k).unwrap();
let mut merged = kn.clone();
let at = merged.iter().position(|&x| x > k).unwrap_or(5);
merged.insert(at, k);
for u in samples() {
let u = kn[0] - 0.1 + u * (kn[4] - kn[0] + 0.2);
let f = |k: &[f64]| local_basis(k, u, Side::Right, 0)[0];
let lhs = f(&kn);
let rhs = c1 * f(&merged[..5]) + c2 * f(&merged[1..]);
assert!((lhs - rhs).abs() < 1e-14, "{kn:?} insert {k} at {u}: {lhs} vs {rhs}");
}
}
}
#[test]
fn basis_bezier_coefficients_reproduce_the_function() {
let p = 3;
let spans: Vec<(f64, f64)> = KNOTS.windows(2).filter(|w| w[0] < w[1]).map(|w| (w[0], w[1])).collect();
for i in 0..KNOTS.len() - p - 1 {
let local = &KNOTS[i..i + p + 2];
for &(a, b) in &spans {
let c = basis_bezier(local, a, b).unwrap();
for k in 0..=20 {
let x = k as f64 / 20.0;
let u = a + (b - a) * x;
let side = if x >= 1.0 { Side::Left } else { Side::Right };
let want = local_basis(local, u, side, 0)[0];
let got: f64 = c.iter().enumerate().map(|(j, cj)| cj * bernstein(p, j, x)).sum();
assert!((want - got).abs() < 1e-14, "N{i} on [{a}, {b}] at {u}: {want} vs {got}");
}
}
}
assert!(basis_bezier(&KNOTS[..5], 0.0, 0.5).is_err(), "a knot inside the interval is refused");
assert!(basis_bezier(&KNOTS[..5], f64::NAN, 0.5).is_err());
}
fn curve(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> Vec3 {
let n = ctrl.len();
let side = if u >= knots[n] { Side::Left } else { Side::Right };
let mut acc = HPoint::ZERO;
for (i, c) in ctrl.iter().enumerate() {
acc += *c * local_basis(&knots[i..i + p + 2], u, side, 0)[0];
}
acc.project().unwrap()
}
#[test]
fn boehm_insertion_keeps_the_curve() {
let p = 3;
let mut rng = Lcg(3);
let ctrl: Vec<HPoint> = (0..8).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0, 0.5 + rng.unit())).collect();
let (mut kn, mut pts) = (KNOTS.to_vec(), ctrl.clone());
for u in [0.0, 0.1, 0.3, 0.3, 0.77, 1.0, 1.0] {
(kn, pts) = insert_knot(p, &kn, &pts, u).unwrap();
}
let mut worst = 0.0f64;
for u in samples() {
worst = worst.max(curve(p, &KNOTS, &ctrl, u).dist(curve(p, &kn, &pts, u)));
}
assert!(worst < 1e-13, "deviation {worst}");
assert!(insert_knot(p, &KNOTS, &ctrl, 1.5).is_err());
assert!(insert_knot(p, &KNOTS, &ctrl, f64::NAN).is_err());
}
pub(crate) fn random_surface(seed: u64, ku: Vec<f64>, kv: Vec<f64>, degree: [usize; 2]) -> NurbsSurface {
let mut rng = Lcg(seed);
let n = (ku.len() - degree[0] - 1) * (kv.len() - degree[1] - 1);
let pts: Vec<Vec3> = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
let w: Vec<f64> = (0..n).map(|_| 0.5 + rng.unit()).collect();
NurbsSurface::new(degree, [ku, kv], &pts, &w).unwrap()
}
fn max_deviation(a: &NurbsSurface, b: &NurbsSurface) -> f64 {
let [du, dv] = a.domain();
let mut worst = 0.0f64;
for i in 0..=30 {
for j in 0..=30 {
let u = du[0] + (du[1] - du[0]) * i as f64 / 30.0;
let v = dv[0] + (dv[1] - dv[0]) * j as f64 / 30.0;
worst = worst.max(a.eval(u, v).unwrap().dist(b.eval(u, v).unwrap()));
}
}
worst
}
#[test]
fn surface_knot_insertion_and_clamping_keep_the_surface() {
let uniform: Vec<f64> = (0..10).map(|k| k as f64).collect();
let s = random_surface(11, uniform.clone(), (0..9).map(|k| k as f64 * 0.5).collect(), [3, 3]);
let r = s.insert_knot(Dir::S, 4.5).unwrap().insert_knot(Dir::T, 2.25).unwrap();
assert!(max_deviation(&s, &r) < 1e-12);
let c = s.clamped().unwrap();
assert_eq!(c.domain(), s.domain());
assert_eq!(&c.knots()[0][..4], &[3.0; 4]);
assert_eq!(&c.knots()[1][c.knots()[1].len() - 4..], &[2.5; 4]);
let dev = max_deviation(&s, &c);
assert!(dev < 1e-12, "clamping moved the surface by {dev}");
// A clamped surface interpolates its corner control points.
let corner = c.eval(3.0, 1.5).unwrap();
assert!(corner.dist(c.point(0, 0).project().unwrap()) < 1e-12);
}
#[test]
fn bezier_segments_lie_on_the_curve() {
let mut rng = Lcg(5);
let ctrl: Vec<HPoint> = (0..4).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), 0.0), 0.5 + rng.unit())).collect();
let seg = bezier_segment(&ctrl, 0.2, 0.7).unwrap();
for k in 0..=10 {
let x = k as f64 / 10.0;
let want = bezier_point(&ctrl, 0.2 + 0.5 * x).project().unwrap();
assert!(bezier_point(&seg, x).project().unwrap().dist(want) < 1e-14);
}
assert!(bezier_segment(&ctrl, 0.7, 0.2).is_err());
}
#[test]
fn hostile_spline_input_is_refused() {
let pts = [Vec3::ZERO; 16];
let w = [1.0; 16];
let k = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &w).is_ok());
assert!(NurbsSurface::new([3, 3], [vec![0.0, f64::NAN, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &[0.0; 16]).is_err());
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts[..15], &w[..15]).is_err());
assert!(NurbsSurface::new([0, 3], [k.clone(), k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([9, 3], [k.clone(), k.clone()], &pts, &w).is_err());
assert!(NurbsSurface::new([3, 3], [vec![1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
assert!(check_knots(&vec![0.0; MAX_KNOTS + 1]).is_err());
let s = NurbsSurface::new([3, 3], [k.clone(), k], &pts, &w).unwrap();
assert!(s.eval(2.0, 0.5).is_err());
assert!(s.eval(f64::NAN, 0.5).is_err());
assert_eq!(local_basis(&[], 0.5, Side::Right, 1), vec![0.0, 0.0]);
}
}
+6 -2
View File
@@ -1,14 +1,18 @@
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes and
//! triangle meshes with their measures (volume, area, centroid). Units are millimetres.
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
//! millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod bspline;
mod mesh;
mod plane;
mod profile;
pub mod tspline;
mod vec;
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
+64 -1
View File
@@ -527,11 +527,23 @@ impl Region2 {
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
// Sweep upward: an edge joins the slabs once its lower end is passed and leaves after
// its upper end, so each slab sorts only the edges crossing it (a finely sampled ring
// has hundreds of slabs and edges, but four edges in each slab).
edges.sort_by(|a, b| a.0.y.total_cmp(&b.0.y));
let mut next = 0;
let mut span: Vec<&(Vec2, Vec2)> = Vec::new();
let mut out = Vec::new();
for w in ys.windows(2) {
let (y0, y1) = (w[0], w[1]);
let ym = (y0 + y1) * 0.5;
let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
while let Some(e) = edges.get(next)
&& e.0.y <= ym
{
span.push(e);
next += 1;
}
span.retain(|e| e.1.y >= ym);
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
for pair in span.chunks(2) {
let [l, r] = pair else { continue };
@@ -630,6 +642,57 @@ mod tests {
}
}
/// The sweep visits only the edges crossing each slab; it must give the triangles that
/// testing every edge against every slab gives, for a finely sampled ring too (#164).
#[test]
fn triangulation_sweep_matches_every_edge_against_every_slab() {
fn every_edge(r: &Region2, tol: f64) -> Vec<[Vec2; 3]> {
let mut edges = Vec::new();
for l in std::iter::once(&r.outer).chain(&r.holes) {
let pl = l.polyline(tol);
for i in 0..pl.len() {
let (a, b) = (pl[i], pl[(i + 1) % pl.len()]);
if (a.y - b.y).abs() > 1e-12 {
edges.push(if a.y < b.y { (a, b) } else { (b, a) });
}
}
}
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b): &(Vec2, Vec2)| [a.y, b.y]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
let mut out = Vec::new();
for w in ys.windows(2) {
let ym = (w[0] + w[1]) * 0.5;
let mut span: Vec<_> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
for pair in span.chunks(2) {
let [l, r] = pair else { continue };
let (a, b) = (Vec2::new(x_at(l, w[0]), w[0]), Vec2::new(x_at(r, w[0]), w[0]));
let (c, d) = (Vec2::new(x_at(r, w[1]), w[1]), Vec2::new(x_at(l, w[1]), w[1]));
out.extend([[a, b, c], [a, c, d]]);
}
}
out
}
let comb = Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(9.0, 0.0),
Vec2::new(9.0, 6.0),
Vec2::new(7.0, 2.0),
Vec2::new(5.0, 7.0),
Vec2::new(3.0, 2.5),
Vec2::new(1.0, 6.0),
]);
let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 45.0), holes: vec![Loop2::circle(Vec2::ZERO, 40.0).reversed()] };
let regions = [Region2 { outer: comb, holes: vec![Loop2::circle(Vec2::new(4.5, 1.2), 0.6).reversed()] }, ring];
for (r, tol) in regions.iter().zip([1e-3, 1e-4]) {
let tris = r.triangulate(tol);
assert!(tris.len() > 100);
assert_eq!(tris, every_edge(r, tol));
}
}
#[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)]);
+148
View File
@@ -0,0 +1,148 @@
//! Conversions: a tensor NURBS surface as a T-spline and back (a T-mesh without T-junctions is
//! a tensor B-spline; 2003 §3 and §5), and Bézier extraction — the surface as rational Bézier
//! patches over the rectangles where every blending function is one polynomial.
use super::mesh::{Grid, TMesh};
use super::{Blend, TSpline, frame};
use crate::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, SplineResult, basis_bezier, invalid};
/// Most unit-edge marks and coefficient products in one Bézier extraction.
const MAX_EXTRACT_WORK: usize = 1 << 26;
impl TSpline {
/// A tensor NURBS surface of odd bi-degree as a T-spline on the full grid: column `i` of the
/// mesh is knot `i + (p+1)/2` of the knot vector, so the blending function at a vertex is
/// the B-spline basis function of that control point (rays that leave the mesh repeat the
/// boundary line, 2003 §3). Knot vectors whose first and last `(p+1)/2 + 1` knots are not
/// equal are clamped first (same surface on the same domain).
pub fn from_nurbs(n: &NurbsSurface) -> SplineResult<TSpline> {
let degree = n.degree();
if degree.iter().any(|p| p % 2 == 0) {
return Err(SplineError::Unsupported("a NURBS surface of even degree as a T-spline".into()));
}
let ends_ok = |k: &[f64], p: usize| {
let h = p.div_ceil(2);
let same = |w: Option<&[f64]>| w.is_some_and(|w| w.windows(2).all(|x| matches!(x, [a, b] if a == b)));
same(k.get(..=h)) && same(k.len().checked_sub(h + 1).and_then(|s| k.get(s..)))
};
let n = if (0..2).all(|d| ends_ok(&n.knots()[d], degree[d])) { n.clone() } else { n.clamped()? };
let lines = |d: usize| -> Vec<f64> {
let h = degree[d].div_ceil(2);
let k = &n.knots()[d];
k.get(h..k.len().saturating_sub(h)).map(<[f64]>::to_vec).unwrap_or_default()
};
let mesh = TMesh::tensor(lines(0), lines(1))?;
TSpline::from_homogeneous(mesh, degree, n.homogeneous().to_vec())
}
/// The T-spline as a tensor NURBS surface, when its mesh has no T-junctions.
pub fn to_nurbs(&self) -> SplineResult<NurbsSurface> {
if !self.mesh.is_regular() {
return Err(SplineError::Unsupported("a T-mesh with T-junctions as one tensor surface".into()));
}
let pad = |k: &[f64], p: usize| -> Vec<f64> {
let h = p.div_ceil(2);
let (first, last) = (k.first().copied().unwrap_or(0.0), k.last().copied().unwrap_or(0.0));
std::iter::repeat_n(first, h).chain(k.iter().copied()).chain(std::iter::repeat_n(last, h)).collect()
};
let g = &self.mesh.grid;
NurbsSurface::from_homogeneous(self.degree, [pad(&g.s, self.degree[0]), pad(&g.t, self.degree[1])], self.ctrl.clone())
}
/// The surface as rational Bézier patches over rectangles of the domain, inside each of
/// which every blending function is one polynomial: the knot lines of all blending
/// functions (each function's s-knots across its t-support and t-knots across its
/// s-support) are marked on the index grid and the domain's cells are gathered into
/// rectangles that no marked line crosses. Each function's Bernstein coefficients on a
/// rectangle come from knot insertion ([`basis_bezier`]). Patches tile the domain and meet
/// at T-points where their corners land on a neighbour's side.
pub fn bezier_elements(&self) -> SplineResult<Vec<BezierPatch>> {
let g = &self.mesh.grid;
let marks = knot_lines(g, &self.blends)?;
let [cs, ct] = frame(self.degree);
let (ns, nt) = (g.ns(), g.nt());
let (ci_end, cj_end) = (ns - 1 - cs, nt - 1 - ct);
let mut taken = vec![false; ns * nt];
let is_taken = |taken: &[bool], i: usize, j: usize| taken.get(j * ns + i).copied().unwrap_or(true);
let mut rects = Vec::new();
for cj in ct..cj_end {
for ci in cs..ci_end {
if is_taken(&taken, ci, cj) {
continue;
}
let mut i1 = ci + 1;
while i1 < ci_end && !is_taken(&taken, i1, cj) && !marks.v(i1, cj) {
i1 += 1;
}
let mut j1 = cj + 1;
while j1 < cj_end && (ci..i1).all(|x| !is_taken(&taken, x, j1) && !marks.h(x, j1)) && (ci + 1..i1).all(|x| !marks.v(x, j1)) {
j1 += 1;
}
for y in cj..j1 {
for x in ci..i1 {
if let Some(c) = taken.get_mut(y * ns + x) {
*c = true;
}
}
}
rects.push((ci, i1, cj, j1));
}
}
let val = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
let [ps, pt] = self.degree;
let mut work = 0usize;
let mut out = Vec::new();
for (i0, i1, j0, j1) in rects {
let (s0, s1, t0, t1) = (val(&g.s, i0), val(&g.s, i1), val(&g.t, j0), val(&g.t, j1));
if !(s0 < s1 && t0 < t1) {
continue;
}
let mut ctrl = vec![HPoint::ZERO; (ps + 1) * (pt + 1)];
for (b, c) in self.blends.iter().zip(&self.ctrl) {
let [[bs0, bs1], [bt0, bt1]] = b.support();
if !(s1.min(bs1) > s0.max(bs0) && t1.min(bt1) > t0.max(bt0)) {
continue;
}
work += ctrl.len();
if work > MAX_EXTRACT_WORK {
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
}
let cs = basis_bezier(&b.sv, s0, s1)?;
let ct = basis_bezier(&b.tv, t0, t1)?;
for (j, bj) in ct.iter().enumerate() {
for (i, bi) in cs.iter().enumerate() {
if let Some(slot) = ctrl.get_mut(j * (ps + 1) + i) {
*slot += *c * (bi * bj);
}
}
}
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
return invalid("a Bézier patch has a non-positive weight");
}
out.push(BezierPatch { s: [s0, s1], t: [t0, t1], degree: self.degree, ctrl });
}
Ok(out)
}
}
/// The unit edges of the index grid that carry a knot line of some blending function: each
/// function's s-knots across its t-support and its t-knots across its s-support.
fn knot_lines(g: &Grid, blends: &[Blend]) -> SplineResult<Grid> {
let mut m = Grid::empty(g.s.clone(), g.t.clone())?;
let mut work = 0usize;
for b in blends {
let (Some(&s0), Some(&s1), Some(&t0), Some(&t1)) = (b.s.first(), b.s.last(), b.t.first(), b.t.last()) else { continue };
work += (b.s.len() * (t1 - t0)) + (b.t.len() * (s1 - s0));
if work > MAX_EXTRACT_WORK {
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
}
for &x in &b.s {
(t0..t1).for_each(|y| m.set_v(x, y));
}
for &y in &b.t {
(s0..s1).for_each(|x| m.set_h(x, y));
}
}
Ok(m)
}
+442
View File
@@ -0,0 +1,442 @@
//! The T-mesh in index space (Sederberg, Zheng, Bakenov, Nasri 2003, §3; Scott, Li,
//! Sederberg, Hughes 2012, §2): a rectangle of index lines — columns `0..ns` carrying the
//! non-decreasing knot values `s[i]`, rows `0..nt` carrying `t[j]` — and the unit edges of
//! that grid that are present. Equal knot values on neighbouring lines give zero-length knot
//! intervals (repeated knots), which is how clamped boundaries and multiple knots are written.
//!
//! A valid T-mesh has its outer rectangle, rectangular faces (every grid point is empty, an
//! edge passing straight through, a T-junction, a crossing, or one of the four outer
//! corners), and at least one edge on every index line. A *vertex* is a point on both a column
//! edge and a row edge; every vertex carries a control point.
use crate::bspline::{Dir, SplineError, SplineResult, check_knots, invalid};
/// Most index lines in either direction.
pub const MAX_INDEX_LINES: usize = 1 << 12;
/// Most grid points (`ns · nt`).
pub const MAX_GRID_POINTS: usize = 1 << 20;
/// A run of edges on one index line, given to [`TMesh::from_segments`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Segment {
/// Edges on column `i` (constant s) from row `j0` to row `j1`.
Column { i: usize, j0: usize, j1: usize },
/// Edges on row `j` (constant t) from column `i0` to column `i1`.
Row { j: usize, i0: usize, i1: usize },
}
/// A rectangle in index space, `[i0, i1] × [j0, j1]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IndexRect {
pub i0: usize,
pub i1: usize,
pub j0: usize,
pub j1: usize,
}
/// The index lines and the unit edges present on them; no validity implied.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Grid {
pub(crate) s: Vec<f64>,
pub(crate) t: Vec<f64>,
/// Row edge `(i, j)–(i+1, j)` at `j·(ns−1) + i`.
h: Vec<bool>,
/// Column edge `(i, j)–(i, j+1)` at `i·(nt−1) + j`.
v: Vec<bool>,
}
impl Grid {
/// An empty grid (no edges) after checking sizes and knots.
pub(crate) fn empty(s: Vec<f64>, t: Vec<f64>) -> SplineResult<Grid> {
for k in [&s, &t] {
if k.len() < 2 {
return invalid("a T-mesh needs at least two index lines each way");
}
if k.len() > MAX_INDEX_LINES {
return Err(SplineError::TooLarge(format!("{} index lines (at most {MAX_INDEX_LINES})", k.len())));
}
check_knots(k)?;
}
if s.len() * t.len() > MAX_GRID_POINTS {
return Err(SplineError::TooLarge(format!("{} grid points (at most {MAX_GRID_POINTS})", s.len() * t.len())));
}
let (ns, nt) = (s.len(), t.len());
Ok(Grid { h: vec![false; (ns - 1) * nt], v: vec![false; ns * (nt - 1)], s, t })
}
pub(crate) fn ns(&self) -> usize {
self.s.len()
}
pub(crate) fn nt(&self) -> usize {
self.t.len()
}
/// Row edge from `(i, j)` to `(i+1, j)`.
pub(crate) fn h(&self, i: usize, j: usize) -> bool {
i + 1 < self.ns() && self.h.get(j * (self.ns() - 1) + i).copied().unwrap_or(false)
}
/// Column edge from `(i, j)` to `(i, j+1)`.
pub(crate) fn v(&self, i: usize, j: usize) -> bool {
j + 1 < self.nt() && self.v.get(i * (self.nt() - 1) + j).copied().unwrap_or(false)
}
pub(crate) fn set_h(&mut self, i: usize, j: usize) {
let ns = self.ns();
if i + 1 < ns
&& let Some(e) = self.h.get_mut(j * (ns - 1) + i)
{
*e = true;
}
}
pub(crate) fn set_v(&mut self, i: usize, j: usize) {
let nt = self.nt();
if j + 1 < nt
&& let Some(e) = self.v.get_mut(i * (nt - 1) + j)
{
*e = true;
}
}
/// The point `(i, j)` lies on a column edge (passing through or ending there).
pub(crate) fn on_column(&self, i: usize, j: usize) -> bool {
(j > 0 && self.v(i, j - 1)) || self.v(i, j)
}
/// The point `(i, j)` lies on a row edge.
pub(crate) fn on_row(&self, i: usize, j: usize) -> bool {
(i > 0 && self.h(i - 1, j)) || self.h(i, j)
}
/// The first `count` index lines a ray from `(i, j)` crosses (2003 §3: the knots of a
/// blending function are read off rays cast from its anchor). Along s it crosses columns
/// carrying an edge at row `j`; along t, rows carrying an edge at column `i`. Nearest
/// first; a ray that leaves the mesh repeats the boundary line (clamped ends).
pub(crate) fn ray(&self, i: usize, j: usize, dir: Dir, forward: bool, count: usize) -> Vec<usize> {
let (start, last) = dir.pick((i, self.ns() - 1), (j, self.nt() - 1));
let hit = |x: usize| match dir {
Dir::S => self.on_column(x, j),
Dir::T => self.on_row(i, x),
};
let mut out = Vec::with_capacity(count);
let mut x = start;
while out.len() < count {
let next = if forward { (x < last).then(|| x + 1) } else { x.checked_sub(1) };
match next {
Some(n) => {
x = n;
if hit(x) {
out.push(x);
}
}
None => out.push(x),
}
}
out
}
/// The local knot vector (as line indices) of a degree-`p` blending function anchored at
/// `(i, j)` in direction `dir`: `(p+1)/2` crossings each side of the anchor's own line
/// (odd degree; 2003 §3).
pub(crate) fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
let h = p.div_ceil(2);
let own = dir.pick(i, j);
let mut k: Vec<usize> = self.ray(i, j, dir, false, h);
k.reverse();
k.push(own);
k.extend(self.ray(i, j, dir, true, h));
k
}
/// The face containing the unit cell whose lower-left corner is `(ci, cj)`.
pub(crate) fn face_of_cell(&self, ci: usize, cj: usize) -> IndexRect {
let i0 = (0..=ci).rev().find(|&x| self.v(x, cj)).unwrap_or(0);
let i1 = (ci + 1..self.ns()).find(|&x| self.v(x, cj)).unwrap_or(self.ns() - 1);
let j0 = (0..=cj).rev().find(|&y| self.h(ci, y)).unwrap_or(0);
let j1 = (cj + 1..self.nt()).find(|&y| self.h(ci, y)).unwrap_or(self.nt() - 1);
IndexRect { i0, i1, j0, j1 }
}
/// Split a face with a full-height column edge at `x` so that the point `(x, y)` lies on
/// a column edge (the face above the point, or below it on the top row).
pub(crate) fn split_column_at(&mut self, x: usize, y: usize) {
let cj = if y + 1 < self.nt() { y } else { y.saturating_sub(1) };
let f = self.face_of_cell(x.min(self.ns() - 2), cj);
for j in f.j0..f.j1 {
self.set_v(x, j);
}
}
/// Split a face with a full-width row edge at `y` so that `(x, y)` lies on a row edge (the
/// face to the right of the point, or to its left on the last column).
pub(crate) fn split_row_at(&mut self, x: usize, y: usize) {
let ci = if x + 1 < self.ns() { x } else { x.saturating_sub(1) };
let f = self.face_of_cell(ci, y.min(self.nt() - 2));
for i in f.i0..f.i1 {
self.set_h(i, y);
}
}
/// Insert a new column at index `at` (`0 < at < ns`) with knot value `value`; row edges
/// crossing it are split in two, the new column has no edges.
pub(crate) fn insert_column(&mut self, at: usize, value: f64) -> SplineResult<()> {
let (ns, nt) = (self.ns(), self.nt());
if at == 0 || at >= ns || ns + 1 > MAX_INDEX_LINES || (ns + 1) * nt > MAX_GRID_POINTS {
return Err(SplineError::TooLarge("refinement needs too many index lines".into()));
}
let mut h = Vec::with_capacity(ns * nt);
for j in 0..nt {
for i in 0..ns {
// New row edge i runs (i, i+1) in the new numbering.
let old = if i + 1 < at {
self.h(i, j)
} else if i + 1 == at || i == at {
self.h(at - 1, j)
} else {
self.h(i - 1, j)
};
h.push(old);
}
}
let mut v = Vec::with_capacity((ns + 1) * (nt - 1));
for i in 0..=ns {
for j in 0..nt - 1 {
v.push(match i.cmp(&at) {
std::cmp::Ordering::Less => self.v(i, j),
std::cmp::Ordering::Equal => false,
std::cmp::Ordering::Greater => self.v(i - 1, j),
});
}
}
self.s.insert(at, value);
self.h = h;
self.v = v;
Ok(())
}
/// The grid transposed (s ↔ t), so row operations can reuse column code.
pub(crate) fn transposed(&self) -> Grid {
let (ns, nt) = (self.ns(), self.nt());
let mut g = Grid { s: self.t.clone(), t: self.s.clone(), h: vec![false; (nt - 1) * ns], v: vec![false; nt * (ns - 1)] };
for i in 0..ns {
for j in 0..nt {
if self.h(i, j) {
g.set_v(j, i);
}
if self.v(i, j) {
g.set_h(j, i);
}
}
}
g
}
}
/// A validated T-mesh.
#[derive(Clone, Debug, PartialEq)]
pub struct TMesh {
pub(crate) grid: Grid,
vertices: Vec<(usize, usize)>,
/// Vertex number of grid point `(i, j)` at `j·ns + i`, `u32::MAX` where there is none.
vid: Vec<u32>,
}
impl TMesh {
/// The full tensor-product mesh on the given knot lines.
pub fn tensor(s: Vec<f64>, t: Vec<f64>) -> SplineResult<TMesh> {
let (ns, nt) = (s.len(), t.len());
let segs: Vec<Segment> = (0..ns)
.map(|i| Segment::Column { i, j0: 0, j1: nt.saturating_sub(1) })
.chain((0..nt).map(|j| Segment::Row { j, i0: 0, i1: ns.saturating_sub(1) }))
.collect();
TMesh::from_segments(s, t, &segs)
}
/// A T-mesh from knot lines and runs of edges (the outer rectangle must be among them).
pub fn from_segments(s: Vec<f64>, t: Vec<f64>, segments: &[Segment]) -> SplineResult<TMesh> {
let mut g = Grid::empty(s, t)?;
if segments.len() > MAX_GRID_POINTS {
return Err(SplineError::TooLarge("too many segments".into()));
}
let (ns, nt) = (g.ns(), g.nt());
for seg in segments {
match *seg {
Segment::Column { i, j0, j1 } => {
if i >= ns || j0 >= j1 || j1 >= nt {
return invalid(format!("column segment {i}: rows {j0}..{j1} are not inside the mesh"));
}
(j0..j1).for_each(|j| g.set_v(i, j));
}
Segment::Row { j, i0, i1 } => {
if j >= nt || i0 >= i1 || i1 >= ns {
return invalid(format!("row segment {j}: columns {i0}..{i1} are not inside the mesh"));
}
(i0..i1).for_each(|i| g.set_h(i, j));
}
}
}
TMesh::from_grid(g)
}
/// Validate a grid as a T-mesh.
pub(crate) fn from_grid(g: Grid) -> SplineResult<TMesh> {
let (ns, nt) = (g.ns(), g.nt());
if (0..ns - 1).any(|i| !g.h(i, 0) || !g.h(i, nt - 1)) || (0..nt - 1).any(|j| !g.v(0, j) || !g.v(ns - 1, j)) {
return invalid("the T-mesh's outer rectangle is not closed");
}
if let Some(i) = (0..ns).find(|&i| !(0..nt - 1).any(|j| g.v(i, j))) {
return invalid(format!("column {i} carries no edge"));
}
if let Some(j) = (0..nt).find(|&j| !(0..ns - 1).any(|i| g.h(i, j))) {
return invalid(format!("row {j} carries no edge"));
}
let mut vertices = Vec::new();
let mut vid = vec![u32::MAX; ns * nt];
for j in 0..nt {
for i in 0..ns {
let l = i > 0 && g.h(i - 1, j);
let r = g.h(i, j);
let d = j > 0 && g.v(i, j - 1);
let u = g.v(i, j);
let n = [l, r, d, u].iter().filter(|&&e| e).count();
let corner = (i == 0 || i == ns - 1) && (j == 0 || j == nt - 1);
match n {
1 => return invalid(format!("an edge ends without meeting another at ({i}, {j})")),
2 if (l != r) && !corner => return invalid(format!("a face is not rectangular at ({i}, {j})")),
_ => {}
}
if (l || r) && (d || u) {
if let Some(slot) = vid.get_mut(j * ns + i) {
*slot = vertices.len() as u32;
}
vertices.push((i, j));
}
}
}
Ok(TMesh { grid: g, vertices, vid })
}
/// Knot values of the columns.
pub fn s_knots(&self) -> &[f64] {
&self.grid.s
}
/// Knot values of the rows.
pub fn t_knots(&self) -> &[f64] {
&self.grid.t
}
/// The vertices `(column, row)`, row by row (column fastest). Control points follow this
/// order.
pub fn vertices(&self) -> &[(usize, usize)] {
&self.vertices
}
/// Vertex number of grid point `(i, j)`.
pub fn vertex_at(&self, i: usize, j: usize) -> Option<usize> {
if i >= self.grid.ns() {
return None;
}
self.vid.get(j * self.grid.ns() + i).filter(|&&k| k != u32::MAX).map(|&k| k as usize)
}
/// The parameter point `(s, t)` of vertex `k`.
pub fn vertex_param(&self, k: usize) -> Option<(f64, f64)> {
let &(i, j) = self.vertices.get(k)?;
Some((*self.grid.s.get(i)?, *self.grid.t.get(j)?))
}
/// Is the edge from `(i, j)` to `(i+1, j)` present?
pub fn row_edge(&self, i: usize, j: usize) -> bool {
self.grid.h(i, j)
}
/// Is the edge from `(i, j)` to `(i, j+1)` present?
pub fn column_edge(&self, i: usize, j: usize) -> bool {
self.grid.v(i, j)
}
/// Every unit edge present: a tensor-product mesh without T-junctions.
pub fn is_regular(&self) -> bool {
(0..self.grid.nt()).all(|j| (0..self.grid.ns() - 1).all(|i| self.grid.h(i, j)))
&& (0..self.grid.ns()).all(|i| (0..self.grid.nt() - 1).all(|j| self.grid.v(i, j)))
}
/// The faces, by their lower-left corner.
pub fn faces(&self) -> Vec<IndexRect> {
let g = &self.grid;
let mut out = Vec::new();
for cj in 0..g.nt() - 1 {
for ci in 0..g.ns() - 1 {
if g.v(ci, cj) && g.h(ci, cj) {
out.push(g.face_of_cell(ci, cj));
}
}
}
out
}
/// The local knot vector (line indices) of a degree-`p` blending function anchored at
/// vertex `(i, j)` in direction `dir` (2003 §3).
pub fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
self.grid.local_knots(i, j, dir, p)
}
/// The T-junction extensions for bi-degree `degree` (Scott, Li, Sederberg, Hughes 2012,
/// §2, after Li, Zheng, Sederberg, Hughes, Scott 2012): from each interior T-junction a face
/// extension runs in the direction of the missing edge across `⌈p/2⌉` perpendicular lines
/// carrying an edge there, and an edge extension the other way across `⌊p/2⌋` of them.
/// `p` is the degree along the extension.
pub fn extensions(&self, degree: [usize; 2]) -> Vec<Extension> {
let g = &self.grid;
let (ns, nt) = (g.ns(), g.nt());
let mut out = Vec::new();
for &(i, j) in &self.vertices {
if i == 0 || j == 0 || i == ns - 1 || j == nt - 1 {
continue;
}
let (l, r, d, u) = (g.h(i - 1, j), g.h(i, j), g.v(i, j - 1), g.v(i, j));
if [l, r, d, u].iter().filter(|&&e| e).count() != 3 {
continue;
}
let (dir, missing_forward) = if !l || !r { (Dir::S, !r) } else { (Dir::T, !u) };
let p = dir.pick(degree[0], degree[1]);
let reach = |forward: bool, count: usize| -> usize {
if count == 0 {
return dir.pick(i, j);
}
g.ray(i, j, dir, forward, count).last().copied().unwrap_or(0)
};
let face = reach(missing_forward, p.div_ceil(2));
let edge = reach(!missing_forward, p / 2);
let range = if face < edge { [face, edge] } else { [edge, face] };
let at = dir.pick(j, i);
out.push(Extension { dir, at, range, from: (i, j) });
}
out
}
/// Analysis-suitable for bi-degree `degree`: no extension along s meets an extension along
/// t (Scott et al. 2012, §2). Analysis-suitable T-splines have linearly independent
/// blending functions that form a partition of unity.
pub fn is_analysis_suitable(&self, degree: [usize; 2]) -> bool {
self.conflict(degree).is_none()
}
/// The first extension along s that meets an extension along t.
pub(crate) fn conflict(&self, degree: [usize; 2]) -> Option<Extension> {
let ns = self.grid.ns();
let ext = self.extensions(degree);
let mut covered: Vec<Option<usize>> = vec![None; ns * self.grid.nt()];
for (k, e) in ext.iter().enumerate().filter(|(_, e)| e.dir == Dir::S) {
for x in e.range[0]..=e.range[1] {
if let Some(c) = covered.get_mut(e.at * ns + x) {
*c = Some(k);
}
}
}
ext.iter()
.filter(|e| e.dir == Dir::T)
.find_map(|e| (e.range[0]..=e.range[1]).find_map(|y| covered.get(y * ns + e.at).copied().flatten()))
.and_then(|k| ext.get(k).copied())
}
}
/// A T-junction extension: a closed segment along `dir` on index line `at` (the row for
/// [`Dir::S`], the column for [`Dir::T`]) spanning the lines `range`, from the T-junction
/// `from`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Extension {
pub dir: Dir,
pub at: usize,
pub range: [usize; 2],
pub from: (usize, usize),
}
+219
View File
@@ -0,0 +1,219 @@
//! T-spline surfaces: control points on a T-mesh, which allows T-junctions, so a surface can
//! be refined locally and patches with different knot vectors join without gaps.
//!
//! References: Sederberg, Zheng, Bakenov, Nasri 2003, "T-splines and T-NURCCs" (ACM ToG
//! 22(3)) — §3 the T-spline equation and the local knot vectors read off the T-mesh by rays;
//! Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline simplification and local
//! refinement" (ACM ToG 23(3)) — §3 local refinement by knot insertion into blending
//! functions; Scott, Li, Sederberg, Hughes 2012, "Local refinement of analysis-suitable
//! T-splines" (CMAME 213–216) — §2 index-space T-meshes and T-junction extensions; Piegl &
//! Tiller, *The NURBS Book* — the univariate basis.
//!
//! Scope: odd degree in each direction (control points at vertices, as in the 2003 paper;
//! even degrees put anchors on faces and are refused), rectangular index domains.
//!
//! The surface is `S(s, t) = Σ wₖ Pₖ Bₖ(s, t) / Σ wₖ Bₖ(s, t)` with `Bₖ(s, t) = N[sₖ](s) ·
//! N[tₖ](t)`, the univariate B-splines of the vertex's local knot vectors (2003, eq. 2–3).
//! Near the boundary the outermost `(p−1)/2` index lines each way form a frame that must be
//! complete lines; the domain is the rectangle inside it.
mod extract;
mod mesh;
mod refine;
pub use crate::bspline::Dir;
pub use mesh::{Extension, IndexRect, MAX_GRID_POINTS, MAX_INDEX_LINES, Segment, TMesh};
pub use refine::Cut;
use crate::Vec3;
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, invalid, local_basis, weighted_points};
/// A blending function: its anchor's local knot vectors as line indices and as values, and
/// its support.
#[derive(Clone, Debug, PartialEq)]
struct Blend {
s: Vec<usize>,
t: Vec<usize>,
sv: Vec<f64>,
tv: Vec<f64>,
}
impl Blend {
fn support(&self) -> [[f64; 2]; 2] {
let ends = |v: &[f64]| [v.first().copied().unwrap_or(0.0), v.last().copied().unwrap_or(0.0)];
[ends(&self.sv), ends(&self.tv)]
}
}
/// The number of frame lines outside the domain on each side, `(p−1)/2` per direction.
fn frame(degree: [usize; 2]) -> [usize; 2] {
degree.map(|p| p.saturating_sub(1) / 2)
}
/// A T-spline surface of odd bi-degree.
#[derive(Clone, Debug, PartialEq)]
pub struct TSpline {
mesh: TMesh,
degree: [usize; 2],
ctrl: Vec<HPoint>,
blends: Vec<Blend>,
}
impl TSpline {
/// A T-spline from a mesh, a bi-degree and one point and positive weight per vertex (in
/// [`TMesh::vertices`] order).
pub fn new(mesh: TMesh, degree: [usize; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<TSpline> {
TSpline::from_homogeneous(mesh, degree, weighted_points(points, weights)?)
}
/// A T-spline from homogeneous control points (`(x·w, y·w, z·w, w)`, `w > 0`).
pub fn from_homogeneous(mesh: TMesh, degree: [usize; 2], ctrl: Vec<HPoint>) -> SplineResult<TSpline> {
for p in degree {
if p == 0 || p > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
}
if p % 2 == 0 {
return Err(SplineError::Unsupported(format!("even degree {p} (T-splines here have odd degrees)")));
}
}
let g = &mesh.grid;
let (ns, nt) = (g.ns(), g.nt());
let [cs, ct] = frame(degree);
if ns < 2 * cs + 2 || nt < 2 * ct + 2 {
return invalid("too few index lines for the degree");
}
let full_col = |i: usize| (0..nt - 1).all(|j| g.v(i, j));
let full_row = |j: usize| (0..ns - 1).all(|i| g.h(i, j));
if (0..=cs).any(|k| !full_col(k) || !full_col(ns - 1 - k)) || (0..=ct).any(|k| !full_row(k) || !full_row(nt - 1 - k)) {
return invalid("the outermost (p−1)/2 index lines must be complete");
}
if !(g.s.get(cs) < g.s.get(ns - 1 - cs)) || !(g.t.get(ct) < g.t.get(nt - 1 - ct)) {
return invalid("the T-spline's domain is empty");
}
if ctrl.len() != mesh.vertices().len() {
return invalid(format!("{} control points for {} vertices", ctrl.len(), mesh.vertices().len()));
}
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0) || h.project().is_none()) {
return invalid("control points must be finite with positive weights");
}
let mut blends = Vec::with_capacity(ctrl.len());
for &(i, j) in mesh.vertices() {
let s = g.local_knots(i, j, Dir::S, degree[0]);
let t = g.local_knots(i, j, Dir::T, degree[1]);
let sv: Vec<f64> = s.iter().map(|&x| g.s.get(x).copied().unwrap_or(0.0)).collect();
let tv: Vec<f64> = t.iter().map(|&y| g.t.get(y).copied().unwrap_or(0.0)).collect();
let b = Blend { s, t, sv, tv };
let [[s0, s1], [t0, t1]] = b.support();
if !(s0 < s1 && t0 < t1) {
return invalid(format!("repeated knots leave the blending function at ({i}, {j}) empty"));
}
blends.push(b);
}
Ok(TSpline { mesh, degree, ctrl, blends })
}
pub fn mesh(&self) -> &TMesh {
&self.mesh
}
pub fn degree(&self) -> [usize; 2] {
self.degree
}
/// Homogeneous control points, one per vertex.
pub fn homogeneous(&self) -> &[HPoint] {
&self.ctrl
}
/// Cartesian control points, one per vertex.
pub fn points(&self) -> Vec<Vec3> {
self.ctrl.iter().map(|h| h.project().unwrap_or(Vec3::ZERO)).collect()
}
/// Weights, one per vertex.
pub fn weights(&self) -> Vec<f64> {
self.ctrl.iter().map(|h| h.w).collect()
}
/// The local knot vectors (values) of vertex `k`'s blending function.
pub fn local_knots(&self, k: usize) -> Option<(&[f64], &[f64])> {
self.blends.get(k).map(|b| (b.sv.as_slice(), b.tv.as_slice()))
}
/// The parameter domain `[[s₀, s₁], [t₀, t₁]]`.
pub fn domain(&self) -> [[f64; 2]; 2] {
let g = &self.mesh.grid;
let [cs, ct] = frame(self.degree);
let at = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
[[at(&g.s, cs), at(&g.s, g.ns() - 1 - cs)], [at(&g.t, ct), at(&g.t, g.nt() - 1 - ct)]]
}
/// Analysis-suitable (no s-extension meets a t-extension; Scott et al. 2012, §2).
pub fn is_analysis_suitable(&self) -> bool {
self.mesh.is_analysis_suitable(self.degree)
}
/// Which side of the knots to evaluate on, after checking `(s, t)` lies in the domain.
fn sides(&self, s: f64, t: f64) -> SplineResult<[Side; 2]> {
let [ds, dt] = self.domain();
if !(s >= ds[0] && s <= ds[1] && t >= dt[0] && t <= dt[1]) {
return invalid(format!("({s}, {t}) is outside the T-spline's domain"));
}
let side = |x: f64, hi: f64| if x >= hi { Side::Left } else { Side::Right };
Ok([side(s, ds[1]), side(t, dt[1])])
}
/// Blending function `k` at `(s, t)`, with its first derivatives `[B, ∂B/∂s, ∂B/∂t]`.
fn blend_at(&self, b: &Blend, s: f64, t: f64, sides: [Side; 2], n: usize) -> [f64; 3] {
let [[s0, s1], [t0, t1]] = b.support();
if s < s0 || s > s1 || t < t0 || t > t1 {
return [0.0; 3];
}
let ns = local_basis(&b.sv, s, sides[0], n);
let nt = local_basis(&b.tv, t, sides[1], n);
let (n0, n1) = (ns.first().copied().unwrap_or(0.0), ns.get(1).copied().unwrap_or(0.0));
let (m0, m1) = (nt.first().copied().unwrap_or(0.0), nt.get(1).copied().unwrap_or(0.0));
[n0 * m0, n1 * m0, n0 * m1]
}
/// The value of vertex `k`'s blending function `Bₖ(s, t)` (no weight).
pub fn blending(&self, k: usize, s: f64, t: f64) -> SplineResult<f64> {
let sides = self.sides(s, t)?;
let b = self.blends.get(k).ok_or_else(|| SplineError::Invalid(format!("no vertex {k}")))?;
Ok(self.blend_at(b, s, t, sides, 0)[0])
}
/// Homogeneous sums `[A, ∂A/∂s, ∂A/∂t]` at `(s, t)`.
fn sums(&self, s: f64, t: f64, n: usize) -> SplineResult<[HPoint; 3]> {
let sides = self.sides(s, t)?;
let mut acc = [HPoint::ZERO; 3];
for (b, c) in self.blends.iter().zip(&self.ctrl) {
let v = self.blend_at(b, s, t, sides, n);
for (a, x) in acc.iter_mut().zip(v) {
if x != 0.0 {
*a += *c * x;
}
}
}
Ok(acc)
}
/// The point at `(s, t)`.
pub fn eval(&self, s: f64, t: f64) -> SplineResult<Vec3> {
let [a, _, _] = self.sums(s, t, 0)?;
a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))
}
/// The point and the first partial derivatives `[S, ∂S/∂s, ∂S/∂t]` at `(s, t)` (quotient
/// rule on the homogeneous sums).
pub fn derivs(&self, s: f64, t: f64) -> SplineResult<[Vec3; 3]> {
let [a, a_s, a_t] = self.sums(s, t, 1)?;
let p = a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))?;
let d = |h: HPoint| (h.xyz() - p * h.w) / a.w;
Ok([p, d(a_s), d(a_t)])
}
/// The unit normal `∂S/∂s × ∂S/∂t` at `(s, t)`, if the surface is regular there.
pub fn normal(&self, s: f64, t: f64) -> SplineResult<Option<Vec3>> {
let [_, ds, dt] = self.derivs(s, t)?;
Ok(ds.cross(dt).normalized())
}
}
#[cfg(test)]
mod tests;
+317
View File
@@ -0,0 +1,317 @@
//! Local refinement that keeps the surface exactly (Sederberg, Cardon, Finnigan, North,
//! Zheng, Lyche 2004, §3).
//!
//! The requested knot lines are cut into the T-mesh first. Then every blending function of
//! the original surface is carried as a record — its local knot vectors (as index lines) and
//! its homogeneous coefficient — and the records are made to agree with the refined mesh:
//!
//! * **Violation 1** — the mesh's ray from a record's anchor crosses a line inside the
//! record's support that the record lacks: insert that knot into the record, which splits
//! it into two records (knot insertion into one blending function, 2004 §3).
//! * **Violation 2** — the record has a knot where the ray crosses nothing: split the face
//! there so the mesh dictates that knot.
//! * **Violation 3** — a record's anchor is not a vertex: split a face so it becomes one.
//!
//! Records with the same knot vectors merge. When nothing is violated, each record is exactly
//! the blending function of the vertex at its anchor, so summing the coefficients per vertex
//! gives control points of the refined T-spline, and the surface is unchanged. All knots are
//! handled as line indices, so every comparison is exact; no new index line appears after
//! the cuts, so the work is bounded by the grid.
use std::collections::BTreeMap;
use super::TSpline;
use super::mesh::{Grid, TMesh};
use crate::bspline::{Dir, HPoint, SplineError, SplineResult, invalid, split_basis, split_knots};
/// Most cuts in one refinement.
const MAX_CUTS: usize = 1 << 12;
/// Most record checks and mesh changes in one refinement.
const MAX_REFINE_STEPS: usize = 1 << 22;
/// A knot line to cut into a T-spline.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Cut {
/// The line `s = s` from `t = t0` to `t = t1`: every face it crosses there is split.
S { s: f64, t0: f64, t1: f64 },
/// The line `t = t` from `s = s0` to `s = s1`.
T { t: f64, s0: f64, s1: f64 },
}
impl Cut {
/// The direction the cut line is constant in: [`Dir::S`] for a line `s = …`.
fn dir(&self) -> Dir {
match self {
Cut::S { .. } => Dir::S,
Cut::T { .. } => Dir::T,
}
}
}
/// A record's knot vectors (s, t) as line indices.
type Key = (Vec<usize>, Vec<usize>);
enum Fix {
/// Violation 1: insert line `k` into the record along the direction.
Split(Dir, usize),
/// Violations 2 and 3: the point (column, row) must lie on a column edge.
Column(usize, usize),
/// … on a row edge.
Row(usize, usize),
}
impl TSpline {
/// The same surface on a refined T-mesh with the given knot lines cut in (2004 §3; see the
/// module notes). The cut lines must lie strictly inside the domain; a cut at the value
/// of an existing knot line extends that line. Further faces may be split to keep the
/// surface exact and, when this T-spline is analysis-suitable, to keep the result
/// analysis-suitable (T-junctions are carried across faces until no s-extension meets a
/// t-extension; Scott et al. 2012).
pub fn refine(&self, cuts: &[Cut]) -> SplineResult<TSpline> {
if cuts.len() > MAX_CUTS {
return Err(SplineError::TooLarge(format!("{} cuts (at most {MAX_CUTS})", cuts.len())));
}
let mut g = self.mesh.grid.clone();
let mut smap: Vec<usize> = (0..g.ns()).collect();
let mut tmap: Vec<usize> = (0..g.nt()).collect();
let [ds, dt] = self.domain();
// Row cuts run as column cuts on the transposed grid; consecutive cuts along one
// direction share a transposition.
for run in cuts.chunk_by(|a, b| a.dir() == b.dir()) {
let along_t = run.first().is_some_and(|c| c.dir() == Dir::T);
if along_t {
g = g.transposed();
}
for cut in run {
match *cut {
Cut::S { s, t0, t1 } => cut_column(&mut g, &mut smap, s, [t0, t1], ds)?,
Cut::T { t, s0, s1 } => cut_column(&mut g, &mut tmap, t, [s0, s1], dt)?,
}
}
if along_t {
g = g.transposed();
}
}
let remap = |idx: &[usize], map: &[usize]| -> Vec<usize> { idx.iter().map(|&x| map.get(x).copied().unwrap_or(x)).collect() };
let mut rec: BTreeMap<Key, HPoint> = BTreeMap::new();
for (b, c) in self.blends.iter().zip(&self.ctrl) {
*rec.entry((remap(&b.s, &smap), remap(&b.t, &tmap))).or_insert(HPoint::ZERO) += *c;
}
// Keep analysis-suitability (Scott et al. 2012): while an s-extension meets a
// t-extension, carry the s-extension's T-junction across its face, then make the
// records agree with the mesh again. Every round adds edges, so this ends.
let keep_suitable = self.is_analysis_suitable();
let mesh = loop {
close(&mut g, &mut rec, self.degree)?;
let mesh = TMesh::from_grid(g.clone())?;
match mesh.conflict(self.degree).filter(|_| keep_suitable) {
Some(e) => extend_t_junction(&mut g, e.from),
None => break mesh,
}
};
let mut ctrl = vec![HPoint::ZERO; mesh.vertices().len()];
let mut seen = vec![false; ctrl.len()];
for ((s, t), c) in &rec {
let (ci, cj) = (centre(s), centre(t));
let k = mesh.vertex_at(ci, cj).ok_or_else(|| SplineError::Invalid("refinement left a blending function without a vertex".into()))?;
if let (Some(slot), Some(f)) = (ctrl.get_mut(k), seen.get_mut(k)) {
*slot += *c;
*f = true;
}
}
if let Some(k) = seen.iter().position(|&f| !f) {
let (i, j) = mesh.vertices().get(k).copied().unwrap_or_default();
return Err(SplineError::Unsupported(format!(
"this refinement leaves the control point at ({i}, {j}) without a blending function (the refined T-mesh is not analysis-suitable)"
)));
}
let out = TSpline::from_homogeneous(mesh, self.degree, ctrl)?;
// The records are the refined blending functions, one per vertex.
for (s, t) in rec.keys() {
let k = out.mesh.vertex_at(centre(s), centre(t));
if k.and_then(|k| out.blends.get(k)).is_none_or(|b| &b.s != s || &b.t != t) {
return Err(SplineError::Invalid("refinement did not converge to the refined mesh's blending functions".into()));
}
}
Ok(out)
}
}
/// Carry a T-junction's missing edge across the face beyond it.
fn extend_t_junction(g: &mut Grid, (i, j): (usize, usize)) {
let (l, r, d, u) = (i > 0 && g.h(i - 1, j), g.h(i, j), j > 0 && g.v(i, j - 1), g.v(i, j));
if !r {
g.split_row_at(i, j);
} else if !l {
g.split_row_at(i.saturating_sub(1), j);
} else if !u {
g.split_column_at(i, j);
} else if !d {
g.split_column_at(i, j.saturating_sub(1));
}
}
/// The anchor line of a local knot vector.
fn centre(k: &[usize]) -> usize {
k.get(k.len() / 2).copied().unwrap_or(0)
}
/// Cut the column line `value` into every face it crosses between `range[0]` and `range[1]`,
/// adding the column if needed.
fn cut_column(g: &mut Grid, map: &mut [usize], value: f64, range: [f64; 2], dom: [f64; 2]) -> SplineResult<()> {
let [a, b] = range;
if !(value.is_finite() && a.is_finite() && b.is_finite()) {
return invalid("a cut needs finite numbers");
}
if !(a < b) {
return invalid("a cut needs a non-empty range");
}
if !(value > dom[0] && value < dom[1]) {
return invalid(format!("the cut at {value} is not inside the domain"));
}
let same: Vec<usize> = (0..g.ns()).filter(|&x| g.s.get(x) == Some(&value)).collect();
let (x, new) = match same.as_slice() {
[] => {
let at = g.s.iter().position(|&k| k > value).unwrap_or(g.ns() - 1);
g.insert_column(at, value)?;
map.iter_mut().filter(|m| **m >= at).for_each(|m| *m += 1);
(at, true)
}
[x] => (*x, false),
_ => return Err(SplineError::Unsupported(format!("cutting along the repeated knot {value}"))),
};
let mut added = false;
for y in 0..g.nt() - 1 {
if g.v(x, y) {
continue;
}
let f = g.face_of_cell(x, y);
let (fa, fb) = (g.t.get(f.j0).copied().unwrap_or(0.0), g.t.get(f.j1).copied().unwrap_or(0.0));
// Faces overlapping the range, and faces of zero height inside it (so a cut from a
// clamped domain end runs on through the zero-height frame to the boundary).
if fb.min(b) - fa.max(a) > 0.0 || (fa == fb && fa >= a && fb <= b) {
(f.j0..f.j1).for_each(|j| g.set_v(x, j));
added = true;
}
}
if new && !added {
return invalid(format!("the cut at {value} crosses no face"));
}
Ok(())
}
/// Resolve the violations until every record is a blending function of the mesh.
fn close(g: &mut Grid, rec: &mut BTreeMap<Key, HPoint>, degree: [usize; 2]) -> SplineResult<()> {
let mut steps = 0usize;
let mut tick = |n: usize| -> SplineResult<()> {
steps += n;
if steps > MAX_REFINE_STEPS { Err(SplineError::TooLarge("the refinement needs too much work".into())) } else { Ok(()) }
};
loop {
// Violation 1 first (knot insertion into records), until none is left.
loop {
let keys: Vec<Key> = rec.keys().cloned().collect();
tick(keys.len())?;
let mut changed = false;
for key in keys {
if let Some(Fix::Split(dir, k)) = check(g, &key, degree)
&& let Some(c) = rec.remove(&key)
{
for (piece, f) in split(g, &key, dir, k)? {
if f != 0.0 {
*rec.entry(piece).or_insert(HPoint::ZERO) += c * f;
}
}
changed = true;
}
}
if !changed {
break;
}
}
// Then one mesh change for the first record that needs one.
tick(rec.len())?;
let fix = rec.keys().find_map(|k| match check(g, k, degree) {
Some(Fix::Split(..)) | None => None,
other => other,
});
match fix {
Some(Fix::Column(x, y)) => g.split_column_at(x, y),
Some(Fix::Row(x, y)) => g.split_row_at(x, y),
_ => return Ok(()),
}
}
}
/// The first violation of a record against the mesh, if any.
fn check(g: &Grid, key: &Key, degree: [usize; 2]) -> Option<Fix> {
let (s, t) = key;
let (ci, cj) = (centre(s), centre(t));
let ms = g.local_knots(ci, cj, Dir::S, degree[0]);
match compare(s, &ms) {
Some(Ok(k)) => return Some(Fix::Split(Dir::S, k)),
Some(Err(x)) => return Some(Fix::Column(x, cj)),
None => {}
}
let mt = g.local_knots(ci, cj, Dir::T, degree[1]);
match compare(t, &mt) {
Some(Ok(k)) => return Some(Fix::Split(Dir::T, k)),
Some(Err(y)) => return Some(Fix::Row(ci, y)),
None => {}
}
if !g.on_column(ci, cj) {
return Some(Fix::Column(ci, cj));
}
if !g.on_row(ci, cj) {
return Some(Fix::Row(ci, cj));
}
None
}
/// Compare a record's knots with the mesh's, outward from the anchor: `Ok(k)` the mesh
/// crosses line `k` first (insert it into the record), `Err(x)` the record has line `x` where
/// the mesh crosses nothing (the mesh must gain it).
fn compare(rec: &[usize], mesh: &[usize]) -> Option<Result<usize, usize>> {
let mid = rec.len() / 2;
for k in mid + 1..rec.len() {
let (r, m) = (rec.get(k)?, mesh.get(k)?);
if m < r {
return Some(Ok(*m));
}
if m > r {
return Some(Err(*r));
}
}
for k in (0..mid).rev() {
let (r, m) = (rec.get(k)?, mesh.get(k)?);
if m > r {
return Some(Ok(*m));
}
if m < r {
return Some(Err(*r));
}
}
None
}
/// Insert line `k` into a record along `dir`: the two pieces and their factors (2004 §3).
/// Pieces whose knots all have one value are the zero function and are dropped.
fn split(g: &Grid, key: &Key, dir: Dir, k: usize) -> SplineResult<Vec<(Key, f64)>> {
let (idx, vals) = dir.pick((&key.0, &g.s), (&key.1, &g.t));
let value = |x: usize| vals.get(x).copied().unwrap_or(0.0);
let values: Vec<f64> = idx.iter().map(|&x| value(x)).collect();
let (c1, c2) = split_basis(&values, value(k)).ok_or_else(|| SplineError::Invalid("refinement inserted a knot outside a support".into()))?;
let mut out = Vec::with_capacity(2);
for (piece, c) in split_knots(idx, k).into_iter().zip([c1, c2]) {
let (Some(&lo), Some(&hi)) = (piece.first(), piece.last()) else { continue };
if value(lo) == value(hi) {
continue;
}
let key = match dir {
Dir::S => (piece, key.1.clone()),
Dir::T => (key.0.clone(), piece),
};
out.push((key, c));
}
Ok(out)
}
+350
View File
@@ -0,0 +1,350 @@
use super::*;
use crate::bspline::tests::{Lcg, random_surface};
use crate::bspline::{BezierPatch, NurbsSurface, SplineError};
fn uniform(n: usize) -> Vec<f64> {
(0..n).map(|k| k as f64).collect()
}
fn col(i: usize, j0: usize, j1: usize) -> Segment {
Segment::Column { i, j0, j1 }
}
fn row(j: usize, i0: usize, i1: usize) -> Segment {
Segment::Row { j, i0, i1 }
}
/// 7 × 7 lines, row 3 only from column 0 to column 3: one T-junction at (3, 3) whose
/// extension runs along s.
fn t_mesh() -> TMesh {
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
segs.extend([0, 1, 2, 4, 5, 6].map(|j| row(j, 0, 6)));
segs.push(row(3, 0, 3));
TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap()
}
fn random_points(seed: u64, n: usize) -> (Vec<Vec3>, Vec<f64>) {
let mut rng = Lcg(seed);
let pts = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
let w = (0..n).map(|_| 0.5 + rng.unit()).collect();
(pts, w)
}
fn t_spline(seed: u64) -> TSpline {
let mesh = t_mesh();
let (pts, w) = random_points(seed, mesh.vertices().len());
TSpline::new(mesh, [3, 3], &pts, &w).unwrap()
}
/// A clamped bicubic NURBS with uneven interior knots, as a T-spline.
fn tensor_spline(seed: u64) -> (NurbsSurface, TSpline) {
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
let n = random_surface(seed, ku, kv, [3, 3]);
let ts = TSpline::from_nurbs(&n).unwrap();
(n, ts)
}
fn grid_params(dom: [[f64; 2]; 2], k: usize) -> Vec<(f64, f64)> {
let mut out = Vec::new();
for i in 0..=k {
for j in 0..=k {
let s = dom[0][0] + (dom[0][1] - dom[0][0]) * i as f64 / k as f64;
let t = dom[1][0] + (dom[1][1] - dom[1][0]) * j as f64 / k as f64;
out.push((s, t));
}
}
out
}
fn max_deviation(a: &TSpline, b: &TSpline) -> f64 {
assert_eq!(a.domain(), b.domain());
grid_params(a.domain(), 60).into_iter().map(|(s, t)| a.eval(s, t).unwrap().dist(b.eval(s, t).unwrap())).fold(0.0, f64::max)
}
#[test]
fn tmesh_validation_refuses_malformed_meshes() {
let frame = |extra: &[Segment]| -> Vec<Segment> {
let mut s = vec![col(0, 0, 4), col(4, 0, 4), row(0, 0, 4), row(4, 0, 4)];
s.extend_from_slice(extra);
s
};
let full: Vec<Segment> = (1..4).flat_map(|k| [col(k, 0, 4), row(k, 0, 4)]).collect();
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&full)).is_ok());
// A dangling edge, an L-shaped corner, an open boundary, an empty line.
let mut dangling = full.clone();
dangling.push(col(2, 0, 4));
dangling.retain(|s| *s != row(2, 0, 4));
dangling.push(row(2, 0, 1));
dangling.push(row(2, 3, 4));
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&dangling)).is_ok(), "T-junctions on both sides are fine");
let dangling_edge = frame(&[col(1, 0, 4), col(3, 0, 4), row(1, 0, 4), row(2, 0, 4), row(3, 0, 1), col(2, 0, 3)]);
assert!(TMesh::from_segments(uniform(5), uniform(5), &dangling_edge).is_err(), "edge ending inside a face");
let mut l_shape = frame(&[col(1, 0, 4), col(2, 0, 4), col(3, 0, 4), row(1, 0, 4), row(3, 0, 4)]);
l_shape.push(row(2, 0, 2));
l_shape.retain(|s| *s != col(2, 0, 4));
l_shape.extend([col(2, 0, 2), col(2, 3, 4)]);
assert!(TMesh::from_segments(uniform(5), uniform(5), &l_shape).is_err(), "L-shaped face corner");
assert!(TMesh::from_segments(uniform(5), uniform(5), &full).is_err(), "no outer rectangle");
let mut empty = frame(&full);
empty.retain(|s| *s != col(2, 0, 4));
assert!(TMesh::from_segments(uniform(5), uniform(5), &empty).is_err(), "a column without edges");
assert!(TMesh::from_segments(vec![0.0, 1.0, f64::NAN, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
assert!(TMesh::from_segments(vec![0.0, 2.0, 1.0, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(9, 0, 4)]).is_err());
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(1, 3, 3)]).is_err());
assert!(matches!(TMesh::tensor(uniform(MAX_INDEX_LINES + 1), uniform(2)), Err(SplineError::TooLarge(_))));
assert!(matches!(TMesh::tensor(uniform(2048), uniform(2048)), Err(SplineError::TooLarge(_))));
assert!(TMesh::tensor(uniform(1), uniform(3)).is_err());
}
#[test]
fn local_knot_vectors_are_read_off_rays() {
let ts = t_spline(1);
let m = ts.mesh();
assert!(m.vertex_at(4, 3).is_none(), "(4, 3) lies on a column edge only");
assert!(m.vertex_at(3, 3).is_some(), "the T-junction is a vertex");
let knots = |i, j| {
let k = m.vertex_at(i, j).unwrap();
let (s, t) = ts.local_knots(k).unwrap();
(s.to_vec(), t.to_vec())
};
assert_eq!(knots(2, 3), (vec![0.0, 1.0, 2.0, 3.0, 4.0], vec![1.0, 2.0, 3.0, 4.0, 5.0]));
assert_eq!(knots(3, 4).1, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
// The ray down column 4 passes (4, 3), which carries no row edge.
assert_eq!(knots(4, 4).1, vec![1.0, 2.0, 4.0, 5.0, 6.0]);
assert_eq!(knots(4, 2).1, vec![0.0, 1.0, 2.0, 4.0, 5.0]);
// Rays leaving the mesh repeat the boundary line.
assert_eq!(knots(0, 0), (vec![0.0, 0.0, 0.0, 1.0, 2.0], vec![0.0, 0.0, 0.0, 1.0, 2.0]));
assert_eq!(knots(6, 5).1, vec![2.0, 4.0, 5.0, 6.0, 6.0]);
}
#[test]
fn regular_tspline_is_the_tensor_nurbs_surface() {
for (seed, degree, ku, kv) in [
(2, [3, 3], vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0]),
(3, [3, 1], vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.4, 1.0, 1.0]),
(4, [5, 3], vec![0.0; 6].into_iter().chain([0.3, 0.7]).chain([1.0; 6]).collect(), vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]),
] {
let n = random_surface(seed, ku, kv, degree);
let ts = TSpline::from_nurbs(&n).unwrap();
assert!(ts.mesh().is_regular() && ts.is_analysis_suitable());
assert_eq!(ts.domain(), n.domain());
let worst = grid_params(n.domain(), 40).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
assert!(worst < 1e-12, "degree {degree:?}: T-spline differs from NURBS by {worst}");
assert_eq!(ts.to_nurbs().unwrap(), n, "NURBS → T-spline → NURBS is exact");
}
// An unclamped NURBS is clamped on the way in (same surface).
let n = random_surface(5, uniform(10), uniform(9), [3, 3]);
let ts = TSpline::from_nurbs(&n).unwrap();
let worst = grid_params(n.domain(), 30).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
assert!(worst < 1e-12, "unclamped: {worst}");
assert!(TSpline::from_nurbs(&random_surface(6, uniform(9), uniform(9), [2, 3])).is_err(), "even degree");
}
#[test]
fn blending_functions_sum_to_one_with_t_junctions() {
let ts = t_spline(1);
assert!(ts.is_analysis_suitable());
let n = ts.mesh().vertices().len();
let mut worst = 0.0f64;
for (s, t) in grid_params(ts.domain(), 64) {
let sum: f64 = (0..n).map(|k| ts.blending(k, s, t).unwrap()).sum();
worst = worst.max((sum - 1.0).abs());
}
assert!(worst < 1e-14, "partition of unity off by {worst}");
}
#[test]
fn derivatives_match_finite_differences() {
let ts = t_spline(9);
let h = 1e-6;
let mut worst = 0.0f64;
for (s, t) in grid_params([[1.05, 4.95], [1.05, 4.95]], 17) {
let [_, ds, dt] = ts.derivs(s, t).unwrap();
let fs = (ts.eval(s + h, t).unwrap() - ts.eval(s - h, t).unwrap()) / (2.0 * h);
let ft = (ts.eval(s, t + h).unwrap() - ts.eval(s, t - h).unwrap()) / (2.0 * h);
worst = worst.max((ds - fs).len()).max((dt - ft).len());
}
assert!(worst < 1e-6, "derivative error {worst}");
assert!(ts.normal(3.0, 3.0).unwrap().is_some());
}
#[test]
fn analysis_suitability_follows_t_junction_extensions() {
assert!(tensor_spline(1).1.is_analysis_suitable());
let ext = t_mesh().extensions([3, 3]);
assert_eq!(ext, vec![Extension { dir: Dir::S, at: 3, range: [2, 5], from: (3, 3) }]);
assert!(t_mesh().is_analysis_suitable([3, 3]));
// Row 4 stops at column 4 (extension along s over columns 3..7 on row 4: column 5 has no
// edge there) and column 5 stops at row 3 (extension along t over rows 2..6 on column 5):
// they cross at (5, 4).
let mut segs: Vec<Segment> = [0, 1, 2, 3, 4, 6, 7, 8].iter().map(|&i| col(i, 0, 8)).collect();
segs.push(col(5, 0, 3));
segs.extend([0, 1, 2, 3, 5, 6, 7, 8].map(|j| row(j, 0, 8)));
segs.push(row(4, 0, 4));
let m = TMesh::from_segments(uniform(9), uniform(9), &segs).unwrap();
let ext = m.extensions([3, 3]);
assert!(ext.contains(&Extension { dir: Dir::S, at: 4, range: [3, 7], from: (4, 4) }));
assert!(ext.contains(&Extension { dir: Dir::T, at: 5, range: [2, 6], from: (5, 3) }));
assert!(!m.is_analysis_suitable([3, 3]));
// Refining it does not repair it (only suitable T-splines are kept suitable), but stays exact.
let (pts, w) = random_points(4, m.vertices().len());
let ts = TSpline::new(m, [3, 3], &pts, &w).unwrap();
let r = ts.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 3.0 }]).unwrap();
assert!(max_deviation(&ts, &r) < 1e-12);
}
#[test]
fn a_cut_across_the_domain_is_boehm_knot_insertion() {
let (n, ts) = tensor_spline(4);
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.0, t1: 1.0 }, Cut::T { t: 0.75, s0: 0.0, s1: 1.0 }]).unwrap();
assert!(r.mesh().is_regular());
let want = n.insert_knot(Dir::S, 0.33).unwrap().insert_knot(Dir::T, 0.75).unwrap();
let got = r.to_nurbs().unwrap();
assert_eq!(got.knots(), want.knots());
let worst = got.homogeneous().iter().zip(want.homogeneous()).map(|(a, b)| (*a - *b).xyz().len() + (a.w - b.w).abs()).fold(0.0, f64::max);
assert!(worst < 1e-12, "control points differ by {worst}");
}
#[test]
fn local_refinement_keeps_the_surface() {
let (_, ts) = tensor_spline(7);
let before = ts.mesh().vertices().len();
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }]).unwrap();
let dev = max_deviation(&ts, &r);
eprintln!("local cut: {} → {} vertices, AS {}, max deviation {dev:e}", before, r.mesh().vertices().len(), r.is_analysis_suitable());
assert!(dev < 1e-12, "deviation {dev}");
assert!(r.mesh().vertices().len() <= before + 4, "a one-face cut stays local");
assert!(r.is_analysis_suitable());
// Refining again near the new T-junctions, and across them.
let r2 = r.refine(&[Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }, Cut::S { s: 0.39, t0: 0.3, t1: 0.45 }]).unwrap();
let dev2 = max_deviation(&ts, &r2);
eprintln!("second refinement: {} vertices, max deviation {dev2:e}", r2.mesh().vertices().len());
assert!(dev2 < 1e-12, "deviation {dev2}");
assert!(r2.is_analysis_suitable(), "refinement keeps analysis-suitability");
let n2 = r2.mesh().vertices().len();
let pu = grid_params(r2.domain(), 50)
.into_iter()
.map(|(s, t)| ((0..n2).map(|k| r2.blending(k, s, t).unwrap()).sum::<f64>() - 1.0).abs())
.fold(0.0, f64::max);
assert!(pu < 1e-14, "partition of unity after refinement off by {pu}");
// A rational T-spline with T-junctions.
let t = t_spline(3);
let r3 = t.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 4.0 }, Cut::T { t: 3.5, s0: 3.0, s1: 5.0 }]).unwrap();
let dev3 = max_deviation(&t, &r3);
eprintln!("rational T-spline: {} → {} vertices, max deviation {dev3:e}", t.mesh().vertices().len(), r3.mesh().vertices().len());
assert!(dev3 < 1e-12, "deviation {dev3}");
}
#[test]
fn random_local_refinements_keep_the_surface() {
let mut rng = Lcg(42);
let mut worst = 0.0f64;
let mut vertices = 0;
for round in 0..40 {
let (_, ts) = tensor_spline(round);
let mut cur = ts.clone();
for _ in 0..1 + (rng.unit() * 3.0) as usize {
let v = 0.05 + 0.9 * (rng.unit() * 40.0).round() / 40.0;
let a = (rng.unit() * 8.0).round() / 10.0;
let b = (a + 0.1 + (rng.unit() * 5.0).round() / 10.0).min(1.0);
let cut = if rng.unit() < 0.5 { Cut::S { s: v, t0: a, t1: b } } else { Cut::T { t: v, s0: a, s1: b } };
cur = cur.refine(&[cut]).unwrap_or_else(|e| panic!("round {round}: {cut:?}: {e}"));
}
assert!(cur.is_analysis_suitable(), "round {round}: refinement keeps analysis-suitability");
vertices += cur.mesh().vertices().len() - ts.mesh().vertices().len();
worst = worst.max(max_deviation(&ts, &cur));
}
eprintln!("random refinements: max deviation {worst:e}, {vertices} vertices added over 40 rounds");
assert!(worst < 1e-12, "deviation {worst}");
}
fn check_patches(ts: &TSpline, patches: &[BezierPatch]) -> f64 {
let [ds, dt] = ts.domain();
let area: f64 = patches.iter().map(|p| (p.s[1] - p.s[0]) * (p.t[1] - p.t[0])).sum();
assert!((area - (ds[1] - ds[0]) * (dt[1] - dt[0])).abs() < 1e-12, "the patches tile the domain");
let mut worst = 0.0f64;
for p in patches {
for (s, t) in grid_params([p.s, p.t], 6) {
worst = worst.max(p.eval(s, t).unwrap().dist(ts.eval(s, t).unwrap()));
}
}
worst
}
#[test]
fn bezier_extraction_reproduces_the_surface() {
let ts = t_spline(5);
let patches = ts.bezier_elements().unwrap();
let dev = check_patches(&ts, &patches);
eprintln!("Bézier extraction, T-junction mesh: {} patches, max deviation {dev:e}", patches.len());
assert!(dev < 1e-12);
let (n, tensor) = tensor_spline(8);
let patches = tensor.bezier_elements().unwrap();
assert_eq!(patches.len(), 5 * 3, "one patch per pair of knot spans");
assert!(check_patches(&tensor, &patches) < 1e-12);
let _ = n;
let refined = tensor.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
let patches = refined.bezier_elements().unwrap();
let dev = check_patches(&refined, &patches);
eprintln!("Bézier extraction, refined: {} patches, max deviation {dev:e}", patches.len());
assert!(dev < 1e-12);
}
#[test]
fn hostile_tspline_input_is_refused() {
let mesh = t_mesh();
let n = mesh.vertices().len();
let (pts, w) = random_points(1, n);
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &w).is_ok());
let mut bad = pts.clone();
bad[3] = Vec3::new(f64::NAN, 0.0, 0.0);
assert!(TSpline::new(mesh.clone(), [3, 3], &bad, &w).is_err());
let mut bw = w.clone();
bw[0] = 0.0;
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
bw[0] = f64::INFINITY;
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
assert!(TSpline::new(mesh.clone(), [3, 3], &pts[1..], &w[1..]).is_err());
assert!(matches!(TSpline::new(mesh.clone(), [2, 3], &pts, &w), Err(SplineError::Unsupported(_))));
assert!(TSpline::new(mesh.clone(), [0, 3], &pts, &w).is_err());
assert!(TSpline::new(mesh.clone(), [9, 3], &pts, &w).is_err());
// Degree 5 needs the three outermost lines each way complete; row 3 is not, so a mesh
// whose row 2 stops short is refused.
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
segs.extend([0, 1, 3, 4, 5, 6].map(|j| row(j, 0, 6)));
segs.push(row(2, 0, 3));
let short_frame = TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap();
let (p6, w6) = random_points(3, short_frame.vertices().len());
assert!(TSpline::new(short_frame.clone(), [3, 3], &p6, &w6).is_ok());
assert!(TSpline::new(short_frame, [5, 5], &p6, &w6).is_err());
// A row of knots repeated so often that a blending function is empty.
let m = TMesh::tensor(vec![0.0, 0.0, 0.0, 1.0, 2.0], uniform(5)).unwrap();
let (p, w5) = random_points(2, 25);
assert!(TSpline::new(m, [3, 3], &p, &w5).is_err());
// An empty domain.
let m = TMesh::tensor(vec![0.0, 1.0, 1.0, 2.0], uniform(4)).unwrap();
assert!(TSpline::new(m, [3, 3], &p[..16], &w5[..16]).is_err());
let ts = TSpline::new(mesh, [3, 3], &pts, &w).unwrap();
for (s, t) in [(f64::NAN, 2.0), (2.0, f64::INFINITY), (0.5, 2.0), (2.0, 5.5)] {
assert!(ts.eval(s, t).is_err() && ts.derivs(s, t).is_err() && ts.blending(0, s, t).is_err());
}
assert!(ts.blending(n + 5, 2.0, 2.0).is_err());
for cut in [
Cut::S { s: f64::NAN, t0: 1.0, t1: 2.0 },
Cut::S { s: 2.5, t0: 2.0, t1: 1.0 },
Cut::S { s: 2.5, t0: 2.0, t1: f64::INFINITY },
Cut::S { s: 0.5, t0: 1.0, t1: 2.0 },
Cut::T { t: 5.0, s0: 1.0, s1: 2.0 },
Cut::T { t: 9.0, s0: 1.0, s1: 2.0 },
Cut::S { s: 2.5, t0: 8.0, t1: 9.0 },
] {
assert!(ts.refine(&[cut]).is_err(), "{cut:?}");
}
assert!(matches!(ts.refine(&vec![Cut::S { s: 2.5, t0: 1.0, t1: 2.0 }; 5000]), Err(SplineError::TooLarge(_))));
// Refining the same line twice is a no-op the second time.
let a = ts.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
let b = a.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
assert_eq!(a, b);
}
+3
View File
@@ -18,3 +18,6 @@ quick-xml = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
[build-dependencies]
sha2 = { workspace = true }
+43
View File
@@ -0,0 +1,43 @@
//! Content fingerprint: cached pictures must not survive geometry/placement changes.
use sha2::{Digest, Sha256};
use std::{io, path::Path};
fn sources(path: &Path, files: &mut Vec<std::path::PathBuf>) -> io::Result<()> {
println!("cargo:rerun-if-changed={}", path.display());
for entry in std::fs::read_dir(path)? {
let path = entry?.path();
if path.is_dir() {
sources(&path, files)?;
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
Ok(())
}
fn main() -> io::Result<()> {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| io::Error::other("missing manifest directory"))?).join("../..");
let mut files = Vec::new();
for name in ["geom", "sketch", "kernel", "doc", "io", "engine", "render"] {
sources(&root.join("crates").join(name).join("src"), &mut files)?;
}
for name in ["truck-meshalgo", "truck-stepio", "truck-shapeops", "matext4cgmath"] {
sources(&root.join("vendor").join(name).join("src"), &mut files)?;
}
files.push(root.join("Cargo.lock"));
files.push(root.join("Cargo.toml"));
files.push(root.join("crates/io/build.rs"));
files.sort();
let mut hash = Sha256::new();
hash.update(b"solvecraft-provisional-display/1;millimetres;Z-up");
for path in files {
println!("cargo:rerun-if-changed={}", path.display());
hash.update(path.strip_prefix(&root).map_err(io::Error::other)?.to_string_lossy().replace('\\', "/").as_bytes());
let bytes = std::fs::read(path)?;
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(bytes);
}
println!("cargo:rustc-env=SOLVECRAFT_DISPLAY_BUILD={:x}", hash.finalize());
Ok(())
}
+177 -4
View File
@@ -13,6 +13,14 @@ const VERSION: &str = "solvecraft-container/1";
const MAX_ENTRIES: usize = 10_000;
const MAX_EXPANDED: usize = 512 << 20;
const MESH_MAGIC: &[u8; 8] = b"SCMESH01";
const MAX_PREVIEW_BYTES: usize = 8 << 20;
const MAX_PREVIEW_TRIANGLES: usize = 100_000;
/// Untrusted, display-only evaluated meshes. Never used for editing, picking or measurement.
#[derive(Default)]
pub struct DesignPreview {
pub meshes: Vec<MeshData>,
}
/// Fast favors interactive file operations; Compact trades CPU time for space.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -81,6 +89,11 @@ pub fn write_design_container(doc: &Document) -> Result<Vec<u8>> {
/// Write a container with an explicit payload compression policy.
pub fn write_design_container_with_compression(doc: &Document, compression: DesignCompression) -> Result<Vec<u8>> {
write_design_container_with_preview(doc, compression, None)
}
/// Optional display cache failure never prevents saving the authoritative design.
pub fn write_design_container_with_preview(doc: &Document, compression: DesignCompression, preview: Option<&DesignPreview>) -> Result<Vec<u8>> {
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
let mut features = Vec::with_capacity(doc.features.len());
for feature in &doc.features {
@@ -125,8 +138,22 @@ pub fn write_design_container_with_compression(doc: &Document, compression: Desi
canvases.push(value);
}
let metadata = replace(doc, fields([("features", Value::Array(features)), ("canvases", Value::Array(canvases))])).map_err(invalid)?;
writer.part("document.json", &serde_json::to_vec(&metadata).map_err(invalid)?, true)?;
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
writer.part("document.json", &metadata, true)?;
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
.expanded
.saturating_add(manifest.len())
.saturating_add(data.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
<= MAX_DESIGN_BYTES
{
writer.part("cache/preview.json", &manifest, false)?;
writer.part("cache/preview.bin", &data, false)?;
}
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
if bytes.len() > MAX_DESIGN_BYTES {
return Err(invalid("compressed file too large"));
@@ -241,14 +268,17 @@ impl<'a> PackageReader<'a> {
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0 })
}
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
self.limited_part(name, MAX_DESIGN_BYTES)
}
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
let expected = part.size();
if expected > MAX_DESIGN_BYTES as u64 {
if expected > limit as u64 {
return Err(invalid("part too large"));
}
let mut bytes = Vec::with_capacity(expected as usize);
part.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
if bytes.len() > MAX_DESIGN_BYTES || bytes.len() as u64 != expected {
part.take(limit as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
if bytes.len() > limit || bytes.len() as u64 != expected {
return Err(invalid("part too large or length mismatch"));
}
Ok(bytes)
@@ -276,6 +306,96 @@ impl<'a> PackageReader<'a> {
}
}
fn encode_preview(preview: &DesignPreview, document: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
if preview.meshes.is_empty() || preview.meshes.len() > 1024 {
return Err(invalid("preview body count"));
}
let mut data = Vec::new();
let mut entries = Vec::new();
let mut triangles = 0usize;
for mesh in &preview.meshes {
triangles = triangles.saturating_add(mesh.triangles.len() / 3);
let size = mesh.positions.len().saturating_mul(8).saturating_add(mesh.triangles.len().saturating_mul(4)).saturating_add(24);
if data.len().saturating_add(size) > MAX_PREVIEW_BYTES || triangles > MAX_PREVIEW_TRIANGLES || mesh.name.len() > 1024 {
return Err(invalid("preview budget"));
}
let bytes = encode_mesh(mesh)?;
entries.push(json!({"name":mesh.name,"color":mesh.color,"offset":data.len(),"length":bytes.len()}));
data.extend_from_slice(&bytes);
}
let manifest = serde_json::to_vec(&json!({
"format":"solvecraft-preview/1", "build":env!("SOLVECRAFT_DISPLAY_BUILD"),
"document":digest(document), "payload":digest(&data), "meshes":entries
}))
.map_err(invalid)?;
if manifest.len() > 65_536 {
return Err(invalid("preview manifest budget"));
}
Ok((manifest, data))
}
/// Read only an optional provisional display cache; any corruption/incompatibility is a miss.
/// The normal design reader must still validate and rebuild before enabling the design.
pub fn read_design_preview(bytes: &[u8]) -> Option<DesignPreview> {
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
return None;
}
read_preview(bytes).ok()
}
fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
let mut reader = PackageReader::new(bytes)?;
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("unsupported container version"));
}
let manifest: Value = serde_json::from_slice(&reader.limited_part("cache/preview.json", 65_536)?).map_err(invalid)?;
if manifest.get("format").and_then(Value::as_str) != Some("solvecraft-preview/1")
|| manifest.get("build").and_then(Value::as_str) != Some(env!("SOLVECRAFT_DISPLAY_BUILD"))
{
return Err(invalid("incompatible preview"));
}
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.part("document.json")?).as_str()) {
return Err(invalid("stale preview"));
}
let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?;
if manifest.get("payload").and_then(Value::as_str) != Some(digest(&data).as_str()) {
return Err(invalid("damaged preview"));
}
let entries =
manifest.get("meshes").and_then(Value::as_array).filter(|m| !m.is_empty() && m.len() <= 1024).ok_or_else(|| invalid("preview bodies"))?;
let mut preview = DesignPreview::default();
let mut end = 0usize;
let mut triangles = 0usize;
for entry in entries {
let offset = entry.get("offset").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("preview offset"))?;
let length = entry.get("length").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("preview length"))?;
if offset != end {
return Err(invalid("overlapping preview bodies"));
}
end = end.checked_add(length).ok_or_else(|| invalid("preview overflow"))?;
let mut mesh = MeshData { name: String::new(), positions: Vec::new(), triangles: Vec::new(), color: None };
decode_mesh(data.get(offset..end).ok_or_else(|| invalid("truncated preview"))?, &mut mesh)?;
triangles = triangles.saturating_add(mesh.triangles.len() / 3);
if triangles > MAX_PREVIEW_TRIANGLES {
return Err(invalid("preview triangles"));
}
mesh.name = entry.get("name").and_then(Value::as_str).filter(|n| n.len() <= 1024).ok_or_else(|| invalid("preview name"))?.into();
if !entry.get("color").is_none_or(Value::is_null) {
let color: [f32; 3] = serde_json::from_value(entry.get("color").cloned().unwrap_or(Value::Null)).map_err(invalid)?;
if color.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
return Err(invalid("preview color"));
}
mesh.color = Some(color);
}
preview.meshes.push(mesh);
}
if end != data.len() {
return Err(invalid("trailing preview data"));
}
Ok(preview)
}
pub(super) fn read(bytes: &[u8]) -> Result<Document> {
let mut reader = PackageReader::new(bytes)?;
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
@@ -390,6 +510,59 @@ mod tests {
zip.finish().unwrap().into_inner()
}
#[test]
fn optional_preview_is_bounded_keyed_and_never_authoritative() {
let doc = Document::from_json(&document().to_json()).unwrap();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let loaded = read_design_preview(&bytes).unwrap();
assert_eq!(loaded.meshes, preview.meshes);
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
let original = parts(&bytes);
for field in ["format", "build", "document", "payload"] {
let mut entries = original.clone();
let mut manifest: Value = serde_json::from_slice(&entries["cache/preview.json"]).unwrap();
manifest[field] = json!("obsolete");
entries.insert("cache/preview.json".into(), serde_json::to_vec(&manifest).unwrap());
let changed = package(&entries);
assert!(read_design_preview(&changed).is_none(), "{field}");
assert_eq!(crate::read_design(&changed).unwrap(), doc);
}
let mut entries = original.clone();
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
metadata["name"] = json!("Changed document");
entries.insert("document.json".into(), serde_json::to_vec(&metadata).unwrap());
assert!(read_design_preview(&package(&entries)).is_none());
for damaged in [vec![], vec![0; 128], vec![0; MAX_PREVIEW_BYTES + 1]] {
let mut entries = original.clone();
entries.insert("cache/preview.bin".into(), damaged);
let changed = package(&entries);
assert!(read_design_preview(&changed).is_none());
assert_eq!(crate::read_design(&changed).unwrap(), doc);
}
// Even a matching checksum does not excuse invalid indices or overlapping slices.
for overlap in [false, true] {
let mut entries = original.clone();
let mut manifest: Value = serde_json::from_slice(&entries["cache/preview.json"]).unwrap();
if overlap {
manifest["meshes"][0]["offset"] = json!(1);
} else {
let data = entries.get_mut("cache/preview.bin").unwrap();
let end = data.len();
data[end - 4..].copy_from_slice(&u32::MAX.to_le_bytes());
manifest["payload"] = json!(digest(data));
}
entries.insert("cache/preview.json".into(), serde_json::to_vec(&manifest).unwrap());
assert!(read_design_preview(&package(&entries)).is_none());
}
let mut too_large = mesh();
too_large.triangles = vec![0; (MAX_PREVIEW_TRIANGLES + 1) * 3];
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&DesignPreview { meshes: vec![too_large] })).unwrap();
assert!(read_design_preview(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
assert!(read_design_preview(&crate::write_design(&doc)).is_none());
}
#[test]
fn round_trip_deduplicates_and_preserves_binary_precision() {
let source = Document::from_json(&document().to_json()).unwrap();
+7 -3
View File
@@ -1,5 +1,6 @@
//! File formats. Designs are saved as JSON (`.solvecraft`): the parametric document only, the
//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ, STEP and 3MF; STEP
//! File formats. Designs use JSON or versioned containers (`.solvecraft`); the authoritative
//! document rebuilds on load and optional display caches remain provisional. Bodies export
//! as STL (binary or ASCII), OBJ, STEP and 3MF; STEP
//! files import as a base feature (see [`step_import_feature`]), 3MF and STL files as mesh bodies
//! (see [`mesh_import_feature`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -8,7 +9,10 @@
mod design;
mod design_metadata;
mod dxf;
pub use design::{DesignCompression, is_design_container, write_design_container, write_design_container_with_compression};
pub use design::{
DesignCompression, DesignPreview, is_design_container, read_design_preview, write_design_container, write_design_container_with_compression,
write_design_container_with_preview,
};
pub mod safe_write;
mod sketch2d;
mod svg;
+1 -1
View File
@@ -698,7 +698,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
Some(ids)
}
fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
pub(crate) fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
+2
View File
@@ -64,6 +64,7 @@ mod sweep_path;
#[cfg(test)]
mod thread_tests;
mod topo;
mod tspline;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use blend_corner::fillet_radii;
@@ -96,6 +97,7 @@ pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicke
pub use sweep_path::sweep_path;
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
+82 -13
View File
@@ -97,6 +97,22 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
Some(Seg::Arc { a, c: centre, axis, angle, mid: m })
}
/// Whether an edge is a straight segment: a line, or a curve whose points all lie on the chord
/// (the contact lines of a rolling-ball blend between parallel walls are such curves).
fn is_straight(e: &mt::Edge, tol: f64) -> bool {
use mt::{BoundedCurve, ParametricCurve};
if matches!(e.curve(), mt::Curve::Line(_)) {
return true;
}
let (a, b) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
if a.dist(b) < tol {
return false;
}
let c = e.curve();
let (t0, t1) = c.range_tuple();
(0..=16).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)).dist_to_segment(a, b) < tol)
}
/// `p` turned about the line (c, axis) by `ang`.
fn turn(p: Vec3, c: Vec3, axis: Vec3, ang: f64) -> Vec3 {
let k = axis.normalized().unwrap_or(Vec3::Z);
@@ -372,7 +388,40 @@ fn build(
*slot = v;
}
}
let vb: Vec<mt::Vertex> = pb.iter().map(|p| builder::vertex(p3(*p))).collect();
// Model vertices at the joints (the second half of a circle starts at none).
let mut joint_vertex: Vec<Option<mt::Vertex>> = (0..k)
.map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None })
.collect();
if open {
joint_vertex.push(edges.last().map(|e| e.back().clone()));
}
let loop_ids: Vec<mt::EdgeID> = edges.iter().map(|e| e.id()).collect();
let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id());
// A blend as tall as its walls uses them up: each wall seam ends exactly where the
// wall-side rail would, at a vertex of the walls' far boundary, which becomes the rail.
let wall_end: Vec<Option<mt::Vertex>> = joint_vertex
.iter()
.zip(&pb)
.map(|(v, target)| {
let v = v.as_ref()?;
faces.iter().flat_map(|g| g.edge_iter()).find_map(|e| {
if loop_ids.contains(&e.id()) || on_face(&e) || !(e.front() == v || e.back() == v) || !matches!(e.curve(), mt::Curve::Line(_)) {
return None;
}
let other = if e.front() == v { e.back().clone() } else { e.front().clone() };
(from_p3(other.point()).dist(*target) <= tol * 10.0).then_some(other)
})
})
.collect();
let used_up = wall_end.iter().filter(|x| x.is_some()).count();
if used_up > 0 && (used_up != nj || open) {
return Err(unsupported("the blend uses up only part of the walls"));
}
let walls_used_up = used_up > 0;
let vb: Vec<mt::Vertex> = match walls_used_up {
true => wall_end.into_iter().flatten().collect(),
false => pb.iter().map(|p| builder::vertex(p3(*p))).collect(),
};
let centre = |i: usize| centres.get(i).copied().unwrap_or_default();
let profile_mid = |i: usize| {
let (Some(p), c) = (joint_point(i), centre(i)) else { return Vec3::ZERO };
@@ -442,21 +491,34 @@ fn build(
if face_rails.iter().zip(&collapsed).any(|(r, c)| r.is_none() && !c) {
return Err(KernelError::Failed("rail".into()));
}
let wall_rails: Vec<mt::Edge> =
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?;
let wall_rails: Vec<mt::Edge> = if walls_used_up {
// Each piece's wall rail is the wall's far edge between the joints, where the offset
// rail lies (checked at the piece's middle).
(0..k)
.map(|i| -> Result<mt::Edge> {
let (Some(a), Some(b), Some(s), Some(wf)) = (vb.get(i), vb.get(nxt(i)), segs.get(i), walls.get(i)) else {
return Err(KernelError::Failed("rail".into()));
};
let mid = match *s {
Seg::Line { a, b } => (a + b) * 0.5,
Seg::Arc { mid, .. } => mid,
};
let want = wall_off(mid, s);
let g = faces.get(*wf).ok_or_else(|| KernelError::Failed("wall".into()))?;
g.edge_iter()
.find(|e| (e.front() == a && e.back() == b) || (e.front() == b && e.back() == a))
.filter(|e| crate::blend::edge_mid(e).is_some_and(|m| m.dist(want) <= tol * 10.0))
.map(|e| if e.front() == a { e.clone() } else { e.inverse() })
.ok_or_else(|| unsupported("the blend uses up walls whose far edge is not where the blend ends"))
})
.collect::<Result<_>>()?
} else {
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?
};
// Seams between neighbouring walls at the joints get shorter.
// Model vertices at the joints (the second half of a circle starts at none).
let mut joint_vertex: Vec<Option<mt::Vertex>> = (0..k)
.map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None })
.collect();
if open {
joint_vertex.push(edges.last().map(|e| e.back().clone()));
}
// At a run's ends the face's next loop edges stop at the face-side point.
let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id());
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut seam_ends: HashMap<mt::EdgeID, (mt::Edge, Option<mt::Vertex>, Option<mt::Vertex>)> = HashMap::new();
let loop_ids: Vec<mt::EdgeID> = edges.iter().map(|e| e.id()).collect();
for (i, v) in joint_vertex.iter().enumerate() {
let Some(v) = v else { continue };
for g in faces {
@@ -472,10 +534,14 @@ fn build(
}
let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone());
let other = if &fa == v { &fb } else { &fa };
if !matches!(e.curve(), mt::Curve::Line(_)) {
if !is_straight(&e, (size * 1e-5).max(1e-6)) {
return Err(unsupported("the wall seams must be straight"));
}
let face_side = on_face(&e);
// A used-up wall goes, seams and all.
if walls_used_up && !face_side {
continue;
}
let target = if face_side { pa.get(i) } else { pb.get(i) }.copied().unwrap_or_default();
let along = (from_p3(other.point()) - from_p3(v.point())).dot(target - from_p3(v.point()));
let reach = (target - from_p3(v.point())).len2();
@@ -558,6 +624,9 @@ fn build(
let rebuilt = if gi == fi {
rebuild(g, &face_rails)?
} else if walls.contains(&gi) {
if walls_used_up {
continue;
}
let wr: Vec<Option<mt::Edge>> = wall_rails.iter().cloned().map(Some).collect();
rebuild(g, &wr)?
} else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) {
+69 -17
View File
@@ -192,7 +192,7 @@ fn cavity_history(poly: &Body, on: &[usize], groups: &[Vec<usize>]) -> Option<Hi
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed. The
/// result's history is over `[b]`: the inner walls are offsets of the faces they line.
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)
guard("shell", || shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open))
}
fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
@@ -480,8 +480,16 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
if opened.is_empty() {
return Err(KernelError::Invalid("select at least one face to remove".into()));
}
// A successful Boolean is not evidence that a tangential rim has wall thickness.
// Refuse only when the entire planar opening loses its rim. A single collapsed
// round can still end in a valid sharp edge (the tutorial plate, for example).
if let Some(r) = rim_without_thickness(&healed, &opened, thickness, true) {
return Err(unsupported_shell(&format!(
"the rim would have no thickness: an open face meets a round of radius {r:.4} mm tangentially, and the shell is {thickness:.4} mm thick (make the round smaller than the thickness)"
)));
}
let margin = thickness + size * 0.05;
shell_opened(b, &healed, open, &opened, thickness, margin).map_err(|e| match rim_without_thickness(&healed, &opened, thickness) {
shell_opened(b, &healed, open, &opened, thickness, margin).map_err(|e| match rim_without_thickness(&healed, &opened, thickness, false) {
Some(r) => unsupported_shell(&format!(
"the rim would have no thickness: an open face meets a round of radius {r:.4} mm tangentially, and the shell is {thickness:.4} mm thick (make the round smaller than the thickness)"
)),
@@ -492,25 +500,53 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
/// The radius of a convex round (cylinder or torus) that meets an open face tangentially and
/// is at least `thickness`: the cavity's wall then passes through the rim where the round meets
/// the open face, leaving nothing between them.
fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64) -> Option<f64> {
fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64, whole_rim: bool) -> Option<f64> {
use mt::{BoundedCurve, ParametricCurve};
let faces: Vec<&mt::Face> = b.solid.face_iter().collect();
let faces: Vec<&mt::Face> = b.solid.face_iter().take(if whole_rim { 4097 } else { usize::MAX }).collect();
let tol = (b.size() * 1e-7).max(1e-9) * 100.0;
// A distant shell can make the body's tolerance larger than this wall.
// Such a diagnostic candidate is not sufficient to refuse a valid opening.
if whole_rim && tol > thickness * 1e-3 {
return None;
}
opened.iter().filter_map(|fi| faces.get(*fi)).find_map(|open| {
let plane = crate::offset::surf_of(open, tol)?;
faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| {
let round = crate::offset::surf_of(g, tol)?;
let r = match round {
crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r,
_ => return None,
};
let shared = g.edge_iter().find(|e| open.edge_iter().any(|x| x.id() == e.id()))?;
let c = shared.curve();
let (t0, t1) = c.range_tuple();
let mid = from_p3(c.subs((t0 + t1) / 2.0));
let tangent = plane.normal(mid)?.dot(round.normal(mid)?) > 1.0 - 1e-6;
(tangent && r >= thickness - tol).then_some(r)
})
if whole_rim && (!matches!(plane, crate::offset::Surf::Plane { .. }) || faces.len() > 4096 || open.edge_iter().take(1025).count() > 1024) {
return None;
}
let radius = |edge: &mt::Edge| {
faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| {
if !g.edge_iter().any(|e| e.id() == edge.id()) {
return None;
}
let round = crate::offset::surf_of(g, tol)?;
let r = match round {
crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r,
_ => return None,
};
let c = edge.curve();
let (t0, t1) = c.range_tuple();
if !t0.is_finite() || !t1.is_finite() || t0 >= t1 {
return None;
}
let parameter = t0 * 0.5 + t1 * 0.5;
if !parameter.is_finite() || parameter < t0 || parameter > t1 {
return None;
}
let mid = from_p3(c.subs(parameter));
let tangent = plane.normal(mid)?.dot(round.normal(mid)?) > 1.0 - 1e-6;
(tangent && r >= thickness - tol).then_some(r)
})
};
if whole_rim {
let mut found = None;
for edge in open.edge_iter() {
found = Some(radius(&edge)?);
}
found
} else {
open.edge_iter().find_map(|edge| radius(&edge))
}
})
}
@@ -683,3 +719,19 @@ fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result<Body> {
}
Ok(cur.with_split_lines(b.splits.clone()).without_history())
}
#[cfg(test)]
mod rim_preflight_tests {
use super::*;
#[test]
fn a_distant_shell_does_not_turn_a_smaller_local_round_into_a_zero_rim() {
let block = crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let edges = [Vec3::new(20.0, 0.0, 0.0), Vec3::new(40.0, 15.0, 0.0), Vec3::new(20.0, 30.0, 0.0), Vec3::new(0.0, 15.0, 0.0)];
let rounded = crate::fillet(&block, &edges, 1.5).unwrap();
let distant = crate::box_solid(Vec3::new(1_000_000.0, 0.0, 0.0), Vec3::new(1_000_010.0, 10.0, 10.0)).unwrap();
let combined = crate::boolean(&rounded, &distant, crate::BoolOp::Union).unwrap().unwrap();
let faces: Vec<_> = (0..combined.solid.face_iter().count()).collect();
assert!(rim_without_thickness(&combined, &faces, 2.0, true).is_none());
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ impl Body {
}
/// A body of faces as they are (no orientation fix: a surface has no inside).
fn surface_body(faces: Vec<mt::Face>) -> Body {
pub(crate) fn surface_body(faces: Vec<mt::Face>) -> Body {
surface_of(mt::Shell::from(faces))
}
+31
View File
@@ -181,6 +181,37 @@ fn fillets_on_several_edges_and_angles() {
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
}
#[test]
fn loop_fillet_as_tall_as_its_wall_consumes_it() {
// The removed cross-section (a square of side r less a quarter disc) and its centroid's
// distance from the wall: r(10 − 3π) / (3(4 − π)).
let spandrel = |r: f64| ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
// #190: a disc 13 in radius and 2 high, its top circle rounded by 2. Pappus for the ring.
let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap();
let f = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], 2.0).unwrap();
let (a, c) = spandrel(2.0);
let want = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c);
let m = measure(&f).unwrap();
assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume);
// Top, round and bottom: the wall is gone.
assert_eq!(m.merged.faces, 3, "{:?}", m.merged);
assert!(f.validity().is_empty(), "{:?}", f.validity());
// A 40 x 30 x 5 plate, its whole top outline rounded by 5 (mitred at the corners).
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 5.0)).unwrap();
let top = [Vec3::new(20.0, 0.0, 5.0), Vec3::new(40.0, 15.0, 5.0), Vec3::new(20.0, 30.0, 5.0), Vec3::new(0.0, 15.0, 5.0)];
let f = fillet(&plate, &top, 5.0).unwrap();
let (a, c) = spandrel(5.0);
let want = 6000.0 - a * 2.0 * ((40.0 - 2.0 * c) + (30.0 - 2.0 * c));
let m = measure(&f).unwrap();
assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume);
assert!(f.validity().is_empty(), "{:?}", f.validity());
// Taller than the wall: still refused, the body unchanged.
for r in [2.0 + 1e-3, 2.5] {
let e = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], r).unwrap_err();
assert!(e.to_string().contains("taller than the walls"), "{r}: {e}");
}
}
#[test]
fn fillet_consuming_a_side_face_makes_a_full_round() {
// 14 x 6 x 10: r = 3 on the four vertical edges uses up both 6 mm end faces (#199).
+267
View File
@@ -0,0 +1,267 @@
//! T-spline sheets: a T-spline surface (`solvecraft_geom::tspline`) as a surface body of NURBS
//! faces. A T-mesh without T-junctions becomes one clamped tensor face; otherwise each Bézier
//! element is a face, and the faces share their edges and vertices — a side that meets two
//! neighbours is split at the T-point between them — so the sheet is one sewn open shell.
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use solvecraft_geom::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, bezier_segment};
use solvecraft_geom::tspline::TSpline;
use truck_modeling as mt;
use crate::body::Body;
use crate::{KernelError, Result, guard};
/// A T-spline as a surface body (an open shell of NURBS faces whose normals follow
/// `∂S/∂s × ∂S/∂t`).
pub fn tspline_sheet(ts: &TSpline) -> Result<Body> {
let faces = if ts.mesh().is_regular() {
let n = ts.to_nurbs().and_then(|n| n.clamped()).map_err(spline_err)?;
guard("t-spline sheet", || tensor_face(&n).map(|f| vec![f]))?
} else {
let patches = ts.bezier_elements().map_err(spline_err)?;
guard("t-spline sheet", || patch_faces(&patches))?
};
Ok(crate::surfaces::surface_body(faces))
}
fn spline_err(e: SplineError) -> KernelError {
KernelError::Invalid(format!("t-spline: {e}"))
}
fn fail(m: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("t-spline sheet: {m}"))
}
fn h4(h: HPoint) -> mt::Vector4 {
mt::Vector4::new(h.x, h.y, h.z, h.w)
}
fn vertex(h: HPoint) -> Result<mt::Vertex> {
let p = h.project().ok_or_else(|| fail("a corner has no position"))?;
Ok(mt::builder::vertex(crate::body::p3(p)))
}
fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
}
fn surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
}
/// One face from a clamped tensor surface, bounded by its four boundary curves.
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
let [nu, nv] = n.count();
let [ku, kv] = n.knots();
let (ku, kv) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
let corners = [n.point(0, 0), n.point(nu - 1, 0), n.point(nu - 1, nv - 1), n.point(0, nv - 1)];
let v: Vec<mt::Vertex> = corners.into_iter().map(vertex).collect::<Result<_>>()?;
let row = |j: usize| -> Vec<HPoint> { (0..nu).map(|i| n.point(i, j)).collect() };
let column = |i: usize| -> Vec<HPoint> { (0..nv).map(|j| n.point(i, j)).collect() };
let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) };
let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail);
let bottom = edge(v0, v1, nurbs_curve(ku.clone(), &row(0))?)?;
let right = edge(v1, v2, nurbs_curve(kv.clone(), &column(nu - 1))?)?;
let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?;
let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?;
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(n.point(i, j))).collect()).collect();
mt::Face::try_new(vec![wire], surface((ku, kv), rows)?).map_err(fail)
}
/// A parameter point, exact (knot values are shared, so equal points have equal bits).
type Key = (u64, u64);
fn key(s: f64, t: f64) -> Key {
((s + 0.0).to_bits(), (t + 0.0).to_bits())
}
/// One face per Bézier patch, sharing vertices and edges; sides are split where a
/// neighbour's corner lands on them.
fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
// Corner parameters on each line of constant t (their s values) and of constant s.
let mut on_t: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
let mut on_s: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
for p in patches {
for s in p.s {
for t in p.t {
let (ks, kt) = key(s, t);
on_t.entry(kt).or_default().insert(ks);
on_s.entry(ks).or_default().insert(kt);
}
}
}
let mut verts: HashMap<Key, mt::Vertex> = HashMap::new();
let mut edges: HashMap<(Key, Key), mt::Edge> = HashMap::new();
let mut faces = Vec::with_capacity(patches.len());
for p in patches {
let mut wire: Vec<mt::Edge> = Vec::new();
for side in 0..4 {
// Side 0: t = t₀, 1: s = s₁, 2: t = t₁, 3: s = s₀; each runs with its parameter
// rising, sides 2 and 3 are walked backwards.
let (along_s, fixed, [lo, hi]) = match side {
0 => (true, p.t[0], p.s),
1 => (false, p.s[1], p.t),
2 => (true, p.t[1], p.s),
_ => (false, p.s[0], p.t),
};
let line = if along_s { on_t.get(&key(0.0, fixed).1) } else { on_s.get(&key(fixed, 0.0).0) };
let mut breaks: Vec<f64> = line.into_iter().flatten().map(|&b| f64::from_bits(b)).filter(|&x| x > lo && x < hi).collect();
breaks.insert(0, lo);
breaks.push(hi);
let ctrl = p.side(side);
let at = |x: f64| if along_s { key(x, fixed) } else { key(fixed, x) };
let mut pieces = Vec::with_capacity(breaks.len());
for w in breaks.windows(2) {
let &[x0, x1] = w else { continue };
let (ka, kb) = (at(x0), at(x1));
let e = if let Some(e) = edges.get(&(ka, kb)) {
e.clone()
} else if let Some(e) = edges.get(&(kb, ka)) {
e.inverse()
} else {
let seg = bezier_segment(&ctrl, (x0 - lo) / (hi - lo), (x1 - lo) / (hi - lo)).map_err(spline_err)?;
let (Some(&first), Some(&last)) = (seg.first(), seg.last()) else { return Err(fail("an empty side")) };
let mut corner = |k: Key, h: HPoint| -> Result<mt::Vertex> {
Ok(match verts.entry(k) {
Entry::Occupied(e) => e.get().clone(),
Entry::Vacant(e) => e.insert(vertex(h)?).clone(),
})
};
let (va, vb) = (corner(ka, first)?, corner(kb, last)?);
let curve = nurbs_curve(mt::KnotVec::bezier_knot(seg.len() - 1), &seg)?;
let e = mt::Edge::try_new(&va, &vb, curve).map_err(fail)?;
edges.insert((ka, kb), e.clone());
e
};
pieces.push(e);
}
if side >= 2 {
pieces.reverse();
pieces.iter_mut().for_each(|e| *e = e.inverse());
}
wire.extend(pieces);
}
let [ps, pt] = p.degree;
let rows: Vec<Vec<mt::Vector4>> = (0..=ps).map(|i| (0..=pt).map(|j| h4(p.point(i, j))).collect()).collect();
let surf = surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
faces.push(mt::Face::try_new(vec![wire.into()], surf).map_err(fail)?);
}
Ok(faces)
}
#[cfg(test)]
mod tests {
use solvecraft_geom::Vec3;
use solvecraft_geom::bspline::NurbsSurface;
use solvecraft_geom::tspline::{Cut, TSpline};
use truck_modeling as mt;
use super::tspline_sheet;
use crate::fuzz::Rng;
/// A rational bicubic with uneven knots over a 10 × 8 mm area, bulging up to ~3 mm.
fn surface(seed: u64) -> TSpline {
let mut rng = Rng::new(seed);
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
let (nu, nv) = (ku.len() - 4, kv.len() - 4);
let mut pts = Vec::new();
for j in 0..nv {
for i in 0..nu {
pts.push(Vec3::new(10.0 * i as f64 / (nu - 1) as f64, 8.0 * j as f64 / (nv - 1) as f64, 3.0 * rng.unit()));
}
}
let w: Vec<f64> = (0..pts.len()).map(|_| 0.7 + 0.6 * rng.unit()).collect();
TSpline::from_nurbs(&NurbsSurface::new([3, 3], [ku, kv], &pts, &w).unwrap()).unwrap()
}
/// Distance from `p` to the T-spline: nearest of a sample grid, then Gauss–Newton.
fn distance(ts: &TSpline, p: Vec3) -> f64 {
let [ds, dt] = ts.domain();
let n = 24;
let mut best = (f64::MAX, 0.0, 0.0);
for i in 0..=n {
for j in 0..=n {
let (s, t) = (ds[0] + (ds[1] - ds[0]) * i as f64 / n as f64, dt[0] + (dt[1] - dt[0]) * j as f64 / n as f64);
let d = ts.eval(s, t).unwrap().dist(p);
if d < best.0 {
best = (d, s, t);
}
}
}
let (_, mut s, mut t) = best;
for _ in 0..60 {
let [q, a, b] = ts.derivs(s, t).unwrap();
let r = q - p;
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
let det = aa * bb - ab * ab;
if det.abs() < 1e-30 {
break;
}
let (ga, gb) = (a.dot(r), b.dot(r));
s = (s - (bb * ga - ab * gb) / det).clamp(ds[0], ds[1]);
t = (t - (aa * gb - ab * ga) / det).clamp(dt[0], dt[1]);
}
ts.eval(s, t).unwrap().dist(p)
}
fn check_sheet(ts: &TSpline, faces: usize) {
let body = tspline_sheet(ts).unwrap();
assert!(body.is_surface());
assert_eq!(body.shell_count(), 1);
assert_eq!(body.face_count(), faces);
let shell = &body.solid.boundaries()[0];
assert_eq!(shell.shell_condition(), mt::ShellCondition::Oriented, "neighbours share edges, run opposite ways");
let euler = body.vertex_count() as i64 - body.edge_count() as i64 + body.face_count() as i64;
assert_eq!(euler, 1, "one sewn sheet (a disc)");
let mesh = body.tessellate(0.01).unwrap();
assert!(!mesh.triangles.is_empty());
// Mesh points lie on the surface (not merely within the tessellation tolerance).
let step = (mesh.positions.len() / 300).max(1);
let worst = mesh.positions.iter().step_by(step).map(|p| distance(ts, *p)).fold(0.0, f64::max);
eprintln!("t-spline sheet: {faces} faces, {} mesh points, farthest checked {worst:e} mm from the surface", mesh.positions.len());
assert!(worst < 1e-6, "a mesh point is {worst} mm off the T-spline");
}
#[test]
fn regular_tspline_becomes_one_nurbs_face() {
check_sheet(&surface(1), 1);
}
#[test]
fn tspline_with_t_junctions_becomes_sewn_bezier_faces() {
let ts = surface(2).refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
assert!(!ts.mesh().is_regular());
let n = ts.bezier_elements().unwrap().len();
check_sheet(&ts, n);
}
#[test]
fn hostile_tsplines_never_panic() {
let base = surface(3);
let n = base.mesh().vertices().len();
let mesh = base.mesh().clone();
// Every control point at one place, enormous coordinates, a needle-thin patch.
let cases: Vec<Vec<Vec3>> = vec![
vec![Vec3::new(1.0, 2.0, 3.0); n],
(0..n).map(|k| Vec3::new(1e300, -1e300, k as f64 * 1e299)).collect(),
(0..n).map(|k| Vec3::new(k as f64 * 1e-12, 0.0, 0.0)).collect(),
];
for pts in cases {
let Ok(ts) = TSpline::new(mesh.clone(), [3, 3], &pts, &vec![1.0; n]) else { continue };
if let Ok(body) = tspline_sheet(&ts) {
let _ = body.tessellate(0.01);
}
if let Ok(r) = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }])
&& let Ok(body) = tspline_sheet(&r)
{
let _ = body.tessellate(0.01);
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Solve times of an n×n grid (origin corner fixed, every row and column edge horizontal or
//! vertical with a distance), started at the solution and ±1 mm off it, and of one held drag
//! frame on a strip of rectangles. Run with
//! `cargo run --release -p solvecraft-sketch --example solve_bench`; results go in docs/perf.md.
use std::time::Instant;
use solvecraft_geom::Vec2;
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, Hold, Sketch, SolveReport, solve, solve_with};
/// A small deterministic generator for the start offsets.
struct Lcg(u64);
impl Lcg {
/// In [-1, 1).
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((self.0 >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
}
fn grid(n: usize, jitter: f64) -> Result<Sketch, Box<dyn std::error::Error>> {
let mut sk = Sketch::new();
let mut rnd = Lcg(12345);
let mut id = vec![vec![0; n]; n];
for (r, row) in id.iter_mut().enumerate() {
for (c, slot) in row.iter_mut().enumerate() {
let j = if r == 0 && c == 0 { Vec2::ZERO } else { Vec2::new(rnd.next(), rnd.next()) * jitter };
*slot = sk.add_point(Vec2::new(c as f64 * 10.0, r as f64 * 10.0) + j, None)?;
}
}
sk.add_constraint(ConstraintKind::Fix { p: id[0][0] }, None)?;
for r in 0..n {
for c in 0..n {
for (dr, dc, horizontal) in [(0, 1, true), (1, 0, false)] {
let (Some(&p), Some(&q)) = (id.get(r).and_then(|x| x.get(c)), id.get(r + dr).and_then(|x| x.get(c + dc))) else { continue };
let l = sk.add_line_pts(p, q, None)?;
sk.add_constraint(if horizontal { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }, None)?;
sk.add_constraint(ConstraintKind::Distance { p, q, value: 10.0 }, None)?;
}
}
}
Ok(sk)
}
/// `n` rectangles side by side sharing their vertical edges: horizontal and vertical sides, every
/// width dimensioned, the first height too. Returns the sketch and its first corner.
fn strip(n: usize) -> Result<(Sketch, usize), Box<dyn std::error::Error>> {
let mut sk = Sketch::new();
let bottom: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 10.0), None)).collect::<Result<_, _>>()?;
let top: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 25.0), None)).collect::<Result<_, _>>()?;
for i in 0..=n {
let l = sk.add_line_pts(bottom[i], top[i], None)?;
sk.add_constraint(ConstraintKind::Vertical { l }, None)?;
if i == 0 {
sk.add_constraint(ConstraintKind::Length { l, value: 15.0 }, None)?;
}
}
for i in 0..n {
let b = sk.add_line_pts(bottom[i], bottom[i + 1], None)?;
let t = sk.add_line_pts(top[i], top[i + 1], None)?;
sk.add_constraint(ConstraintKind::Horizontal { l: b }, None)?;
sk.add_constraint(ConstraintKind::Horizontal { l: t }, None)?;
sk.add_constraint(ConstraintKind::Length { l: b, value: 20.0 }, None)?;
}
Ok((sk, bottom[0]))
}
/// Best of `reps` solves of a copy of `sk` (ms) and the last report.
fn time(sk: &Sketch, reps: usize, f: impl Fn(&mut Sketch) -> SolveReport) -> (f64, SolveReport) {
let mut best = f64::MAX;
let mut rep = None;
for _ in 0..reps {
let mut k = sk.clone();
let t = Instant::now();
let r = f(&mut k);
best = best.min(t.elapsed().as_secs_f64() * 1e3);
rep = Some(r);
}
(best, rep.unwrap_or_else(|| solve(&mut sk.clone())))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("| grid | points | start | ms | status | dof | iterations |");
println!("|---|---|---|---|---|---|---|");
for n in [10usize, 15, 20] {
for jitter in [0.0, 1.0] {
let sk = grid(n, jitter)?;
let (ms, rep) = time(&sk, 5, solve);
let start = if jitter == 0.0 { "at solution" } else { "±1 mm" };
println!("| {n}×{n} | {} | {start} | {ms:.2} | {:?} | {} | {} |", n * n, rep.status, rep.dof, rep.iterations);
}
}
println!();
println!("| strip | points | held drag frame ms | full solve ms |");
println!("|---|---|---|---|");
for n in [30usize, 100] {
let (mut sk, corner) = strip(n)?;
if let Some(p) = sk.points.get_mut(corner) {
p.pos -= Vec2::new(0.5, 0.0);
}
let hold = Hold { points: vec![corner], radii: vec![] };
let (frame, rep) = time(&sk, 5, |k| solve_with(k, &hold, None, Analysis::StatusOnly, Some(&mut Budget::frame())));
let (full, _) = time(&sk, 5, solve);
println!("| {n} rectangles | {} | {frame:.2} ({:?}) | {full:.2} |", sk.points.len(), rep.status);
}
Ok(())
}
+352
View File
@@ -0,0 +1,352 @@
//! Constructive seed for the solver.
//!
//! Before Levenberg–Marquardt runs, every point the constraints pin down relative to the given
//! points is placed exactly (Owen 1991, §3 and §5; Fudos and Hoffmann 1997), in two
//! interleaved steps repeated until neither places anything:
//!
//! - **propagation**: a point with two loci anchored on already placed points goes to their
//! intersection;
//! - **decomposition** ([`crate::decompose`]): points no point-by-point order reaches but that
//! are rigid relative to the placed ones (Owen's articulation-pair reduction) are placed by
//! cluster merging, with the placed points as the ground.
//!
//! Then a rigid cluster that is free or pinned at a single placed point (nothing decides its
//! pose, and LM alone may land it mirrored) is placed by its reference pose
//! ([`Decomposition::floats`]). That is a guess at free motion ([`Placement::Float`]): the
//! solver starts from it, nothing is built on it.
//!
//! Of two mirror-image intersections, the one keeping the orientation the points had before
//! the solve is kept, so a dimension edit keeps each triangle's orientation, each arc on its
//! side of its tangent lines and each line's direction instead of letting the iteration fall
//! into a mirrored solution.
//!
//! The seed gives the iteration its start. Constructed values the constraints pin down one
//! after another, and whose constraints hold there, stay constants while LM solves the rest
//! (`solver::pinned`); every constraint is still verified, and the rank analysis of the solved
//! Jacobian still decides the degrees of freedom. A point with only one locus on placed points keeps its own position (projecting
//! it onto that locus would freeze a point that still has a degree of freedom). What neither
//! step places (triconnected remainders, under-constrained freedom, constraints read by
//! neither) is left to LM.
//!
//! What the constraints say about a point, and where two of them put it, is read by
//! [`crate::loci`] (the same reading drives the decomposition): coincident points; circles
//! (distances, line lengths, arc radii, points on circles of known radius, tangent circles);
//! rows and columns (horizontal and vertical lines and points, and offsets from them on the
//! side the point was on); lines through placed points along a known direction (a point on a
//! line, a line at a known angle, parallel or perpendicular to a placed line); lines at a
//! distance from a placed line (a point at a distance, a tangent circle's centre), on the side
//! the point was on; the radius to a touch point, normal to the line; midpoints and mirrored
//! points. A circle's radius set by its dimensions is seeded too.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use solvecraft_geom::Vec2;
use crate::decompose::{Budget, Decomposition, enqueue};
use crate::loci::{MAX_LOCI, Reading, Rel, View, place, radii, read};
use crate::model::{Sketch, in_range};
/// Rounds of decomposition then propagation per seed. Each round after the first must follow a
/// propagation that placed something, so on real sketches the loop ends after a few; this
/// bounds a hostile chain that alternates.
const MAX_ROUNDS: usize = 16;
/// Start positions for the solver: `pos[i]` is the position of point `i` when it is given or
/// constructed, `placed_by[i]` how it was placed; `radius[k]` the radius of circle or arc `k`
/// when its dimensions set it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Seed {
pub pos: Vec<Option<Vec2>>,
pub placed_by: Vec<Option<Placement>>,
pub radius: Vec<Option<f64>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Placement {
/// Fixed, fixed by a constraint, a point of a fixed curve, or held for this solve.
Given,
/// At the intersection of two loci anchored on the placed points `a` ≤ `b` (the same point
/// when both loci come from it, or for a coincident point). `transversal` when the loci
/// cross (the point is pinned down well); false when they touch.
TwoLoci { a: usize, b: usize, transversal: bool },
/// By cluster decomposition: rigid relative to the placed points, built from crossing
/// intersections only.
Cluster,
/// A point of a rigid cluster whose pose nothing decides (free, or pinned at a single
/// placed point), placed by the cluster's reference pose: a guess at free motion, so the
/// solver starts from it but nothing is built on it.
Float,
}
impl Placement {
/// The solver starts from it. A touching placement is recorded but the solver starts the
/// point where it is.
pub(crate) fn anchors(self) -> bool {
!matches!(self, Placement::TwoLoci { transversal: false, .. })
}
/// Later points may be built on it, and the solver may keep it (`solver::pinned`): it is
/// pinned down well and not a guess.
pub(crate) fn builds(self) -> bool {
self.anchors() && self != Placement::Float
}
}
/// Place the points the constraints pin down; `given[i]` marks the points that are constants
/// for this solve. Branches are chosen to match the orientation of `reference` (positions
/// before an interactive move) where it covers a point, of the sketch's current positions
/// otherwise; a reference longer than the point list is ignored. `on_curve` holds the sketch's
/// point-on-curve pairs ([`crate::solver::points_on_curves`]); the decomposition works within
/// what is left of `budget`, and charges it.
pub(crate) fn seed(sk: &Sketch, given: &[bool], reference: Option<&[Vec2]>, on_curve: &[(usize, usize)], budget: &mut Budget) -> Seed {
let n = sk.points.len();
let reference = reference.filter(|r| r.len() <= n);
let before = |i: usize| match reference.and_then(|r| r.get(i)) {
Some(p) => Some(*p),
None => sk.points.get(i).map(|p| p.pos),
};
let is_given = |i: usize| given.get(i).copied().unwrap_or(false);
let pos: Vec<Option<Vec2>> = sk.points.iter().enumerate().map(|(i, p)| (is_given(i) && in_range(p.pos)).then_some(p.pos)).collect();
let placed_by = pos.iter().map(|p| p.map(|_| Placement::Given)).collect();
let mut out = Seed { pos, placed_by, radius: radii(sk) };
let reading = read(sk, &before, on_curve, &out.radius);
let mut prop = Propagation {
reading: &reading,
free: (0..n).map(|i| !is_given(i)).collect(),
anchors: out.pos.clone(),
placed_near: vec![0; n],
queued: vec![false; n],
queue: VecDeque::new(),
};
for i in 0..n {
if prop.anchors.get(i).is_some_and(Option::is_some) {
prop.anchored(i);
}
}
// Every free point is examined once, and again when a neighbour placed later can complete
// a pair of loci (see `Propagation::anchored`); there is nothing to examine when no free
// point is related to a given one.
if prop.next_to_free() {
for i in (0..n).filter(|i| !is_given(*i)) {
enqueue(&mut prop.queued, &mut prop.queue, i);
}
}
let mut steps = n.saturating_add(reading.tie_count()).saturating_mul(2 * (MAX_LOCI + 1));
prop.run(&mut out, &mut steps);
// Where propagation stalls, the decomposition places what is rigid relative to the
// anchors; propagation goes on from its placements, and the graph takes in what
// propagation placed.
if !switch::decompose_on() {
return out;
}
// A drag step solves against its reference and moves only held points; any other solve
// applies edits.
let edits = reference.is_none();
let mut clusters = Decomposition::new(&reading, &prop.anchors, edits, budget.left());
if !clusters.built() {
return out;
}
for _ in 0..MAX_ROUNDS {
let candidates: Vec<(usize, Vec2)> = (0..n).filter(|i| prop.is_free(*i)).filter_map(|i| Some((i, clusters.pos(i)?))).collect();
let accepted = prop.consistent(&candidates);
if accepted.is_empty() {
break;
}
for (i, at) in accepted {
prop.place(&mut out, i, at, Placement::Cluster);
}
let anchored = prop.run(&mut out, &mut steps);
if anchored.is_empty() || clusters.exhausted() {
break;
}
clusters.know(&anchored);
}
// What the constraints decide is placed. A rigid cluster they leave free to move goes where
// its reference pose puts it, if that holds every relation to the anchors and no point of
// it is placed already: a guess, so it anchors nothing and the next is checked against the
// anchors alone.
for float in clusters.floats(&|i| prop.anchors.get(i).copied().flatten()) {
let open = float.iter().all(|(i, _)| out.pos.get(*i).is_some_and(Option::is_none));
if open && prop.consistent(&float).len() == float.len() {
for (i, at) in float {
prop.place(&mut out, i, at, Placement::Float);
}
}
}
let (work, cut) = clusters.work();
budget.charge(work, cut);
out
}
/// Point-by-point placement state, kept across the decomposition rounds.
struct Propagation<'a> {
reading: &'a Reading,
/// Not given: may be placed.
free: Vec<bool>,
/// Points later points may be built on (see [`Placement::anchors`]).
anchors: Vec<Option<Vec2>>,
/// Anchoring neighbours (by a relation between two points) per point.
placed_near: Vec<usize>,
queued: Vec<bool>,
queue: VecDeque<usize>,
}
impl Propagation<'_> {
fn is_free(&self, i: usize) -> bool {
self.free.get(i).copied().unwrap_or(false) && self.anchors.get(i).is_some_and(Option::is_none)
}
/// Some free point is related to an anchor.
fn next_to_free(&self) -> bool {
let anchored = |o: usize| self.anchors.get(o).is_some_and(Option::is_some);
(0..self.free.len()).any(|i| self.is_free(i) && self.reading.neighbours(i).any(anchored))
}
/// The candidate positions of free points, in range, that hold every relation to the
/// anchors and to the other candidates, as a propagated point must. One that misses is not
/// placed, nor is any candidate related to it (the decomposition may have built it on the
/// miss).
fn consistent(&self, candidates: &[(usize, Vec2)]) -> Vec<(usize, Vec2)> {
let by_point: BTreeMap<usize, Vec2> = candidates.iter().copied().filter(|(i, at)| self.is_free(*i) && in_range(*at)).collect();
let at = |i: usize| self.anchors.get(i).copied().flatten().or(by_point.get(&i).copied());
let view = View { at: &at, oriented: true };
let mut dropped: BTreeSet<usize> = by_point.iter().filter(|(i, x)| !self.reading.holds(**i, **x, &view)).map(|(i, _)| *i).collect();
let mut cascade: Vec<usize> = dropped.iter().copied().collect();
while let Some(i) = cascade.pop() {
for o in self.reading.neighbours(i) {
if by_point.contains_key(&o) && dropped.insert(o) {
cascade.push(o);
}
}
}
by_point.into_iter().filter(|(i, _)| !dropped.contains(i)).collect()
}
/// Record `p` at `at`; an anchoring placement also anchors it and requeues the neighbours it
/// may complete (true then). A given point is never placed.
fn place(&mut self, out: &mut Seed, p: usize, at: Vec2, how: Placement) -> bool {
if !self.is_free(p) {
return false;
}
if let (Some(slot), Some(by)) = (out.pos.get_mut(p), out.placed_by.get_mut(p)) {
*slot = Some(at);
*by = Some(how);
}
if how.builds()
&& let Some(slot) = self.anchors.get_mut(p)
{
*slot = Some(at);
self.anchored(p);
return true;
}
false
}
/// Count the new anchor `p` at each neighbour, and requeue a free neighbour it can complete a
/// pair of loci for (up to the MAX_LOCI-th anchor): a coincident neighbour, the second
/// anchor, the first of a point with ties (they may give its other locus), and the points
/// of the ties `p` may complete.
fn anchored(&mut self, p: usize) {
let Propagation { reading, free, anchors, placed_near, queued, queue } = self;
let open = |o: usize| free.get(o).copied().unwrap_or(false) && anchors.get(o).is_some_and(Option::is_none);
for e in reading.adj(p) {
let o = e.other(p);
let Some(count) = placed_near.get_mut(o) else { continue };
*count += 1;
if *count <= MAX_LOCI && (matches!(e.rel, Rel::Same) || *count >= 2 || reading.tie_count_of(o) > 0) && open(o) {
enqueue(queued, queue, o);
}
}
for o in reading.woken(p) {
if open(o) {
enqueue(queued, queue, o);
}
}
}
/// Place queued points until the queue or `steps` runs out; the points that became anchors.
fn run(&mut self, out: &mut Seed, steps: &mut usize) -> Vec<(usize, Vec2)> {
let mut anchored = Vec::new();
while *steps > 0
&& let Some(p) = self.queue.pop_front()
{
*steps -= 1;
if let Some(q) = self.queued.get_mut(p) {
*q = false;
}
if !self.is_free(p) {
continue;
}
let anchors = &self.anchors;
let at = |i: usize| anchors.get(i).copied().flatten();
let items = self.reading.items(p, &View { at: &at, oriented: true });
let Some(fix) = place(&items, self.reading.before(p)) else { continue };
let how = Placement::TwoLoci { a: fix.a, b: fix.b, transversal: fix.transversal };
if self.place(out, p, fix.at, how) {
anchored.push((p, fix.at));
}
}
anchored
}
}
#[cfg(test)]
pub(crate) mod switch {
//! Test-only switches: the seed, or only its decomposition step, can be turned off, and
//! every solve in this crate's tests is repeated without the seed to check that the
//! outcome is the same.
use std::cell::Cell;
use std::thread::LocalKey;
thread_local! {
static SEED: Cell<bool> = const { Cell::new(true) };
static DECOMPOSE: Cell<bool> = const { Cell::new(true) };
static DIFFERENTIAL: Cell<bool> = const { Cell::new(true) };
}
pub(crate) fn seed_on() -> bool {
SEED.with(Cell::get)
}
pub(crate) fn decompose_on() -> bool {
DECOMPOSE.with(Cell::get)
}
pub(crate) fn differential_on() -> bool {
DIFFERENTIAL.with(Cell::get)
}
/// Restores a switch when dropped, also when the test panics.
struct Restore(&'static LocalKey<Cell<bool>>, bool);
impl Drop for Restore {
fn drop(&mut self) {
self.0.with(|s| s.set(self.1));
}
}
fn off<T>(key: &'static LocalKey<Cell<bool>>, f: impl FnOnce() -> T) -> T {
let _restore = Restore(key, key.with(|s| s.replace(false)));
f()
}
/// Run `f` with the seed off.
pub(crate) fn without_seed<T>(f: impl FnOnce() -> T) -> T {
off(&SEED, f)
}
/// Run `f` with the seed's propagation only (no decomposition).
pub(crate) fn without_decomposition<T>(f: impl FnOnce() -> T) -> T {
off(&DECOMPOSE, f)
}
/// Run `f` without the seed-on/seed-off comparison (for tests that measure the difference).
pub(crate) fn without_differential<T>(f: impl FnOnce() -> T) -> T {
off(&DIFFERENTIAL, f)
}
}
#[cfg(not(test))]
pub(crate) mod switch {
pub(crate) const fn seed_on() -> bool {
true
}
pub(crate) const fn decompose_on() -> bool {
true
}
}
+813
View File
@@ -0,0 +1,813 @@
//! Harness for the constructive seed on lines, arcs and tangency: a filleted rectangle, a slot,
//! a tangent line–arc–line chain (also at axis-aligned line angles), a bolt circle, a mirrored
//! point, collinear lines, points at a distance from a line, equal lines and points on an arc
//! of unknown radius, under scaled dimension edits. Every arc must keep its side of its lines
//! and its sweep, every line its direction, every point its side of its lines.
//! Deterministic (fixed LCG seeds), no timing.
use solvecraft_geom::Vec2;
use crate::construct::{Placement, seed};
use crate::decompose::Budget;
use crate::solver::points_on_curves;
use crate::tests::{Lcg, given, seed_of, seeded, v};
use crate::*;
/// What an edit must keep.
#[derive(Clone, Copy, Debug)]
enum Check {
/// `p` stays on its side of the line through `a` and `b`.
Side { p: usize, a: usize, b: usize },
/// The line from `a` to `b` points within a quarter turn of `d` (where the edit turns it).
Toward { a: usize, b: usize, d: Vec2 },
/// `p` stays ahead of or behind `a` along the line from `a` to `b`.
Along { p: usize, a: usize, b: usize },
/// The counter-clockwise sweep of the arc `curve` stays in `lo..hi` (radians).
Sweep { curve: usize, lo: f64, hi: f64 },
/// `p` ends at `at`.
At { p: usize, at: Vec2 },
}
/// A family member: the solved sketch, the dimension edits (constraint index, new value) and
/// what they must keep.
struct Case {
sk: Sketch,
edits: Vec<(usize, f64)>,
checks: Vec<Check>,
}
fn sweep(sk: &Sketch, curve: usize) -> f64 {
let CurveKind::Arc { c, a, b } = sk.curves[curve].kind else { return f64::NAN };
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU)
}
/// Does `sk` keep every check (sides as in `before`)?
fn holds(sk: &Sketch, checks: &[Check], before: &Sketch) -> bool {
let p = |i: usize| sk.points[i].pos;
checks.iter().all(|c| match *c {
Check::Side { p: x, a, b } => {
let side = |s: &Sketch| (s.points[b].pos - s.points[a].pos).cross(s.points[x].pos - s.points[a].pos).signum();
side(sk) == side(before)
}
Check::Toward { a, b, d } => (p(b) - p(a)).dot(d) > 0.0,
Check::Along { p: x, a, b } => {
let along = |s: &Sketch| (s.points[b].pos - s.points[a].pos).dot(s.points[x].pos - s.points[a].pos).signum();
along(sk) == along(before)
}
Check::Sweep { curve, lo, hi } => (lo..hi).contains(&sweep(sk, curve)),
Check::At { p: x, at } => p(x).dist(at) < 1e-6,
})
}
/// The member's sketch with its edits applied.
fn edited(case: &Case) -> Sketch {
let mut sk = case.sk.clone();
for (i, value) in &case.edits {
sk.constraints[*i].kind.set_value(*value);
}
sk
}
/// Apply the edits and solve; (failed, a check broke).
fn run(case: &Case, on: bool) -> (bool, bool) {
let mut sk = edited(case);
let rep = seeded(on, || solve(&mut sk));
if !rep.ok() {
return (true, false);
}
(false, !holds(&sk, &case.checks, &case.sk))
}
fn push(sk: &mut Sketch, kind: ConstraintKind) -> usize {
sk.add_constraint(kind, None).unwrap();
sk.constraints.len() - 1
}
/// The four arcs' ends and centres of a rounded rectangle, CCW: bottom, right, top and left
/// lines between quarter arcs at the corners, every line tangent to both its arcs. The bottom
/// line's start is fixed; lengths of the bottom and left lines and the four radii are
/// dimensions, scaled by `lo..hi`. Only edits where the top and right lines keep a length are
/// kept.
fn filleted_rectangle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let o = v(rnd.range(-50.0, 50.0), rnd.range(-50.0, 50.0));
let (w, h) = (rnd.range(60.0, 100.0), rnd.range(40.0, 70.0));
let r: [f64; 4] = std::array::from_fn(|_| rnd.range(4.0, 12.0)); // BR, TR, TL, BL
let mut sk = Sketch::new();
let at = |x: f64, y: f64| o + v(x, y);
let bottom = sk.add_line(at(r[3], 0.0), at(w - r[0], 0.0), None, None, None).unwrap();
let right = sk.add_line(at(w, r[0]), at(w, h - r[1]), None, None, None).unwrap();
let top = sk.add_line(at(w - r[1], h), at(r[2], h), None, None, None).unwrap();
let left = sk.add_line(at(0.0, h - r[2]), at(0.0, r[3]), None, None, None).unwrap();
let ends = |sk: &Sketch, l: usize| sk.curves[l].kind.ends().unwrap();
let ((b0, b1), (r0, r1), (t0, t1), (l0, l1)) = (ends(&sk, bottom), ends(&sk, right), ends(&sk, top), ends(&sk, left));
let corners = [
(at(w - r[0], r[0]), b1, r0, bottom, right),
(at(w - r[1], h - r[1]), r1, t0, right, top),
(at(r[2], h - r[2]), t1, l0, top, left),
(at(r[3], r[3]), l1, b0, left, bottom),
];
let mut checks = Vec::new();
let mut radius = Vec::new();
for (k, (c, a, b, la, lb)) in corners.into_iter().enumerate() {
let (pa, pb) = (sk.points[a].pos, sk.points[b].pos);
let arc = sk.add_arc(c, pa, pb, [None, Some(a), Some(b)], None).unwrap();
let CurveKind::Arc { c: centre, .. } = sk.curves[arc].kind else { panic!() };
push(&mut sk, ConstraintKind::Tangent { a: la, b: arc });
push(&mut sk, ConstraintKind::Tangent { a: lb, b: arc });
radius.push((push(&mut sk, ConstraintKind::Radius { c: arc, value: r[k] }), r[k]));
for l in [la, lb] {
let (a, b) = ends(&sk, l);
checks.push(Check::Side { p: centre, a, b });
}
checks.push(Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI });
}
for l in [bottom, right, top, left] {
let (a, b) = ends(&sk, l);
checks.push(Check::Toward { a, b, d: sk.points[b].pos - sk.points[a].pos });
}
push(&mut sk, ConstraintKind::Fix { p: b0 });
push(&mut sk, ConstraintKind::Horizontal { l: bottom });
push(&mut sk, ConstraintKind::Horizontal { l: top });
push(&mut sk, ConstraintKind::Vertical { l: left });
push(&mut sk, ConstraintKind::Vertical { l: right });
let (lb, ll) = (w - r[3] - r[0], h - r[2] - r[3]);
let len_b = push(&mut sk, ConstraintKind::Length { l: bottom, value: lb });
let len_l = push(&mut sk, ConstraintKind::Length { l: left, value: ll });
let mut s = || rnd.range(lo, hi);
let r2: Vec<f64> = radius.iter().map(|(_, r)| r * s()).collect();
let (lb2, ll2) = (lb * s(), ll * s());
// Width and height after the edit; the top and right lines must keep a length.
let (w2, h2) = (lb2 + r2[3] + r2[0], ll2 + r2[2] + r2[3]);
if w2 - r2[1] - r2[2] < 2.0 || h2 - r2[0] - r2[1] < 2.0 {
return None;
}
let mut edits: Vec<(usize, f64)> = radius.iter().zip(&r2).map(|((i, _), r)| (*i, *r)).collect();
edits.extend([(len_b, lb2), (len_l, ll2)]);
Some(Case { sk, edits, checks })
}
/// A slot at an angle: two semicircular caps of equal radius joined by two lines tangent to
/// both, the bottom line at a line angle and the top one parallel to it, cap A's centre fixed.
/// The angle turns by up to ±145°, the radius and the cap distance (centre distance, or the
/// overall length) scale by `lo..hi`.
fn slot(rnd: &mut Lcg, lo: f64, hi: f64, overall: bool) -> Option<Case> {
let th = rnd.range(-3.1, 3.1);
let (len, r) = (rnd.range(30.0, 60.0), rnd.range(5.0, 13.0));
let (u, n) = (Vec2::from_angle(th), Vec2::from_angle(th).perp());
let mut sk = Sketch::new();
let ca = v(rnd.range(-40.0, 40.0), rnd.range(-40.0, 40.0));
let cb = ca + u * len;
let bottom = sk.add_line(ca - n * r, cb - n * r, None, None, None).unwrap();
let top = sk.add_line(cb + n * r, ca + n * r, None, None, None).unwrap();
let (ba, bb) = sk.curves[bottom].kind.ends().unwrap();
let (tb, ta) = sk.curves[top].kind.ends().unwrap();
let arc_b = sk.add_arc(cb, cb - n * r, cb + n * r, [None, Some(bb), Some(tb)], None).unwrap();
let arc_a = sk.add_arc(ca, ca + n * r, ca - n * r, [None, Some(ta), Some(ba)], None).unwrap();
let centre = |sk: &Sketch, a: usize| match sk.curves[a].kind {
CurveKind::Arc { c, .. } => c,
_ => panic!(),
};
let (pa, pb) = (centre(&sk, arc_a), centre(&sk, arc_b));
push(&mut sk, ConstraintKind::Fix { p: pa });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: bottom, value: th });
push(&mut sk, ConstraintKind::Parallel { a: top, b: bottom });
for (l, a) in [(bottom, arc_a), (bottom, arc_b), (top, arc_a), (top, arc_b)] {
push(&mut sk, ConstraintKind::Tangent { a: l, b: a });
}
let radius = push(&mut sk, ConstraintKind::Radius { c: arc_a, value: r });
push(&mut sk, ConstraintKind::Equal { a: arc_a, b: arc_b });
let length = if overall {
push(&mut sk, ConstraintKind::SlotOverallLength { a: arc_a, b: arc_b, value: len + 2.0 * r })
} else {
push(&mut sk, ConstraintKind::Distance { p: pa, q: pb, value: len })
};
let (r2, len2) = (r * rnd.range(lo, hi), len * rnd.range(lo, hi));
let th2 = th + rnd.range(-2.5, 2.5);
let half = std::f64::consts::FRAC_PI_2;
let checks = vec![
Check::Side { p: pa, a: ba, b: bb },
Check::Side { p: pb, a: ba, b: bb },
Check::Side { p: pa, a: tb, b: ta },
Check::Side { p: pb, a: tb, b: ta },
Check::Sweep { curve: arc_a, lo: half, hi: 3.0 * half },
Check::Sweep { curve: arc_b, lo: half, hi: 3.0 * half },
Check::Toward { a: pa, b: pb, d: Vec2::from_angle(th2) },
];
let edits = vec![(angle, th2), (radius, r2), (length, if overall { len2 + 2.0 * r2 } else { len2 })];
Some(Case { sk, edits, checks })
}
/// Line – arc – line, tangent at shared ends: L1 from a fixed point at a line angle with a
/// length, the arc turning left or right by β with a radius, L2 at β from L1 with a length.
/// Lengths and radius scale by `lo..hi`, the line angle turns by up to ±115°, β scales within
/// 15°–165°.
fn tangent_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let alpha = rnd.range(-3.1, 3.1);
let alpha2 = alpha + rnd.range(-2.0, 2.0);
chain(rnd, lo, hi, alpha, alpha2)
}
/// [`tangent_chain`] with L1 along an axis, turned to another axis (a quarter or half turn
/// either way).
fn axis_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let quarter = std::f64::consts::FRAC_PI_2;
let alpha = quarter * (rnd.next() * 4.0).floor() - std::f64::consts::PI;
let turn = [-2.0, -1.0, 1.0, 2.0][(rnd.next() * 4.0) as usize % 4];
chain(rnd, lo, hi, alpha, alpha + quarter * turn)
}
/// The chain of [`tangent_chain`] with L1 at `alpha`, edited to `alpha2`.
fn chain(rnd: &mut Lcg, lo: f64, hi: f64, alpha: f64, alpha2: f64) -> Option<Case> {
let (a, b, r) = (rnd.range(20.0, 40.0), rnd.range(20.0, 40.0), rnd.range(5.0, 15.0));
let beta = rnd.range(30f64.to_radians(), 150f64.to_radians());
let turn = rnd.sign();
let p0 = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
let u1 = Vec2::from_angle(alpha);
let u2 = Vec2::from_angle(alpha + turn * beta);
let e1 = p0 + u1 * a;
let c = e1 + u1.perp() * (turn * r);
let e2 = c - u2.perp() * (turn * r);
let p3 = e2 + u2 * b;
let mut sk = Sketch::new();
let l1 = sk.add_line(p0, e1, None, None, None).unwrap();
let (i0, i1) = sk.curves[l1].kind.ends().unwrap();
let l2 = sk.add_line(e2, p3, None, None, None).unwrap();
let (i2, i3) = sk.curves[l2].kind.ends().unwrap();
let arc = if turn > 0.0 {
sk.add_arc(c, e1, e2, [None, Some(i1), Some(i2)], None).unwrap()
} else {
sk.add_arc(c, e2, e1, [None, Some(i2), Some(i1)], None).unwrap()
};
let CurveKind::Arc { c: ic, .. } = sk.curves[arc].kind else { panic!() };
push(&mut sk, ConstraintKind::Fix { p: i0 });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: alpha });
let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a });
let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: turn * beta, flip: false });
let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b });
let radius = push(&mut sk, ConstraintKind::Radius { c: arc, value: r });
push(&mut sk, ConstraintKind::Tangent { a: l1, b: arc });
push(&mut sk, ConstraintKind::Tangent { a: l2, b: arc });
let mut s = || rnd.range(lo, hi);
let beta2 = (beta * s()).clamp(15f64.to_radians(), 165f64.to_radians());
let edits = vec![(len1, a * s()), (len2, b * s()), (radius, r * s()), (bend, turn * beta2), (angle, alpha2)];
let checks = vec![
Check::Side { p: ic, a: i0, b: i1 },
Check::Side { p: ic, a: i2, b: i3 },
Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI },
Check::Toward { a: i0, b: i1, d: Vec2::from_angle(alpha2) },
Check::Toward { a: i2, b: i3, d: Vec2::from_angle(alpha2 + turn * beta2) },
];
Some(Case { sk, edits, checks })
}
/// Bolt circle: `k` points on a circle (diameter dimension, centre fixed), each on a line from
/// the centre; the first line at a line angle, each next at 360°/k from the one before. The
/// diameter scales by `lo..hi`, the first angle turns by up to ±170°; every point must end where
/// the dimensions put it.
fn bolt_circle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let k = 3 + (rnd.next() * 6.0) as usize;
let r = rnd.range(15.0, 40.0);
let phi = rnd.range(-3.1, 3.1);
let step = std::f64::consts::TAU / k as f64;
let mut sk = Sketch::new();
let centre = sk.add_point(v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)), None).unwrap();
let c = sk.points[centre].pos;
let circle = sk.add_circle(c, r, Some(centre), None).unwrap();
push(&mut sk, ConstraintKind::Fix { p: centre });
let diameter = push(&mut sk, ConstraintKind::Diameter { c: circle, value: 2.0 * r });
let mut lines = Vec::new();
let mut holes = Vec::new();
for i in 0..k {
let h = sk.add_point(c + Vec2::from_angle(phi + i as f64 * step) * r, None).unwrap();
lines.push(sk.add_line_pts(centre, h, None).unwrap());
holes.push(h);
push(&mut sk, ConstraintKind::PointOnCurve { p: h, c: circle });
}
let angle = push(&mut sk, ConstraintKind::LineAngle { l: lines[0], value: phi });
for w in lines.windows(2) {
push(&mut sk, ConstraintKind::Angle { a: w[0], b: w[1], value: step, flip: false });
}
let (r2, phi2) = (r * rnd.range(lo, hi), phi + rnd.range(-3.0, 3.0));
let checks = holes.iter().enumerate().map(|(i, h)| Check::At { p: *h, at: c + Vec2::from_angle(phi2 + i as f64 * step) * r2 }).collect();
Some(Case { sk, edits: vec![(diameter, 2.0 * r2), (angle, phi2)], checks })
}
/// A point mirrored across a line: the line from a fixed point at a line angle with a length,
/// `p` at two distances from its ends (one side), `q` its mirror image. The angle turns by up
/// to ±115°, the dimensions scale by `lo..hi` (only edits where the triangle still exists).
fn mirrored(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let len = rnd.range(30.0, 60.0);
let (u, a) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
let (t, h) = (rnd.range(0.2, 0.8) * len, rnd.range(5.0, 20.0) * rnd.sign());
let (p, q) = (a + u * t + u.perp() * h, a + u * t - u.perp() * h);
let mut sk = Sketch::new();
let axis = sk.add_line(a, a + u * len, None, None, None).unwrap();
let (ia, ib) = sk.curves[axis].kind.ends().unwrap();
let (ip, iq) = (sk.add_point(p, None).unwrap(), sk.add_point(q, None).unwrap());
push(&mut sk, ConstraintKind::Fix { p: ia });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: axis, value: theta });
let length = push(&mut sk, ConstraintKind::Length { l: axis, value: len });
let (d1, d2) = (p.dist(a), p.dist(a + u * len));
let k1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 });
let k2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ip, value: d2 });
push(&mut sk, ConstraintKind::Symmetric { p: ip, q: iq, l: axis });
let theta2 = theta + rnd.range(-2.0, 2.0);
let mut s = || rnd.range(lo, hi);
let (len2, e1, e2) = (len * s(), d1 * s(), d2 * s());
if e1 + e2 < len2 * 1.02 || (e1 - e2).abs() > len2 * 0.98 {
return None;
}
let checks =
vec![Check::Side { p: ip, a: ia, b: ib }, Check::Side { p: iq, a: ia, b: ib }, Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) }];
Some(Case { sk, edits: vec![(angle, theta2), (length, len2), (k1, e1), (k2, e2)], checks })
}
/// Two collinear lines: L1 from a fixed point at a line angle with a length, L2 collinear with
/// it, its start a distance past L1's end, with a length. The angle turns by up to ±115°, the
/// dimensions scale by `lo..hi`; both lines and the gap keep their direction.
fn collinear(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let (a, g, b) = (rnd.range(20.0, 40.0), rnd.range(5.0, 30.0), rnd.range(20.0, 40.0));
let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
let mut sk = Sketch::new();
let l1 = sk.add_line(o, o + u * a, None, None, None).unwrap();
let l2 = sk.add_line(o + u * (a + g), o + u * (a + g + b), None, None, None).unwrap();
let ((i0, i1), (i2, i3)) = (sk.curves[l1].kind.ends().unwrap(), sk.curves[l2].kind.ends().unwrap());
push(&mut sk, ConstraintKind::Fix { p: i0 });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta });
let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a });
push(&mut sk, ConstraintKind::Collinear { a: l1, b: l2 });
let gap = push(&mut sk, ConstraintKind::Distance { p: i1, q: i2, value: g });
let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b });
let theta2 = theta + rnd.range(-2.0, 2.0);
let u2 = Vec2::from_angle(theta2);
let mut s = || rnd.range(lo, hi);
let edits = vec![(angle, theta2), (len1, a * s()), (gap, g * s()), (len2, b * s())];
let checks = vec![Check::Toward { a: i0, b: i1, d: u2 }, Check::Toward { a: i1, b: i2, d: u2 }, Check::Toward { a: i2, b: i3, d: u2 }];
Some(Case { sk, edits, checks })
}
/// Points at a distance from a line: the line from a fixed point at a line angle with a
/// length; `p` at a distance from it (left) and from its start, `r` at half a linear diameter
/// from it (right) and at a distance from its end. The angle turns by up to ±115°, the
/// dimensions scale by `lo..hi` (only edits where both points still exist).
fn line_distance(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let len = rnd.range(30.0, 60.0);
let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
let n = u.perp();
let (h1, h2) = (rnd.range(5.0, 20.0), rnd.range(5.0, 20.0));
let p = o + u * rnd.range(5.0, 25.0) + n * h1;
let r = o + u * (len - rnd.range(5.0, 25.0)) - n * h2;
let mut sk = Sketch::new();
let l = sk.add_line(o, o + u * len, None, None, None).unwrap();
let (ia, ib) = sk.curves[l].kind.ends().unwrap();
let (ip, ir) = (sk.add_point(p, None).unwrap(), sk.add_point(r, None).unwrap());
push(&mut sk, ConstraintKind::Fix { p: ia });
let angle = push(&mut sk, ConstraintKind::LineAngle { l, value: theta });
let length = push(&mut sk, ConstraintKind::Length { l, value: len });
let kh1 = push(&mut sk, ConstraintKind::PointLineDistance { p: ip, l, value: h1 });
let (d1, d2) = (p.dist(o), r.dist(o + u * len));
let kd1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 });
let kh2 = push(&mut sk, ConstraintKind::LinearDiameter { p: ir, l, value: 2.0 * h2 });
let kd2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ir, value: d2 });
let theta2 = theta + rnd.range(-2.0, 2.0);
let mut s = || rnd.range(lo, hi);
let (len2, g1, e1, g2, e2) = (len * s(), h1 * s(), d1 * s(), h2 * s(), d2 * s());
if e1 < g1 * 1.1 || e2 < g2 * 1.1 {
return None;
}
let edits = vec![(angle, theta2), (length, len2), (kh1, g1), (kd1, e1), (kh2, 2.0 * g2), (kd2, e2)];
let checks = vec![
Check::Side { p: ip, a: ia, b: ib },
Check::Side { p: ir, a: ia, b: ib },
Check::Along { p: ip, a: ia, b: ib },
Check::Along { p: ir, a: ib, b: ia },
Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) },
];
Some(Case { sk, edits, checks })
}
/// Equal lines: L1 from a fixed point at a line angle, its end at a distance (no length
/// dimension), L2 from L1's end at an angle β from it and equal to it. The angle turns by up
/// to ±115°, the distance scales by `lo..hi`, β within 15°–165° either way.
fn equal_lines(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let theta = rnd.range(-3.1, 3.1);
let a = rnd.range(20.0, 40.0);
let beta = rnd.range(30f64.to_radians(), 150f64.to_radians()) * rnd.sign();
let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
let b = o + Vec2::from_angle(theta) * a;
let mut sk = Sketch::new();
let l1 = sk.add_line(o, b, None, None, None).unwrap();
let (i0, i1) = sk.curves[l1].kind.ends().unwrap();
let l2 = sk.add_line(b, b + Vec2::from_angle(theta + beta) * a, Some(i1), None, None).unwrap();
let (_, i2) = sk.curves[l2].kind.ends().unwrap();
push(&mut sk, ConstraintKind::Fix { p: i0 });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta });
let dist = push(&mut sk, ConstraintKind::Distance { p: i0, q: i1, value: a });
let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: beta, flip: false });
push(&mut sk, ConstraintKind::Equal { a: l1, b: l2 });
let theta2 = theta + rnd.range(-2.0, 2.0);
let beta2 = beta.signum() * (beta.abs() * rnd.range(lo, hi)).clamp(15f64.to_radians(), 165f64.to_radians());
let edits = vec![(angle, theta2), (dist, a * rnd.range(lo, hi)), (bend, beta2)];
let checks = vec![
Check::Toward { a: i0, b: i1, d: Vec2::from_angle(theta2) },
Check::Toward { a: i1, b: i2, d: Vec2::from_angle(theta2 + beta2) },
Check::Side { p: i2, a: i0, b: i1 },
];
Some(Case { sk, edits, checks })
}
/// Points on an arc of unknown radius: the arc's centre fixed, its start on a line from the
/// centre at a line angle and a distance (no radius dimension), its end and a point on it on
/// lines from the centre at angles σ and τ from the first. The angle turns by up to ±115°, the
/// distance scales by `lo..hi`, σ within 20°–170° (τ keeps its share of σ).
fn on_arc(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
let phi = rnd.range(-3.1, 3.1);
let r = rnd.range(15.0, 40.0);
let sigma = rnd.range(40f64.to_radians(), 150f64.to_radians());
let share = rnd.range(0.2, 0.8);
let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
let at = |a: f64| o + Vec2::from_angle(a) * r;
let mut sk = Sketch::new();
let arc = sk.add_arc(o, at(phi), at(phi + sigma), [None, None, None], None).unwrap();
let CurveKind::Arc { c, a: s0, b: e0 } = sk.curves[arc].kind else { panic!() };
let p = sk.add_point(at(phi + share * sigma), None).unwrap();
let (ls, le, lp) = (sk.add_line_pts(c, s0, None).unwrap(), sk.add_line_pts(c, e0, None).unwrap(), sk.add_line_pts(c, p, None).unwrap());
push(&mut sk, ConstraintKind::Fix { p: c });
let angle = push(&mut sk, ConstraintKind::LineAngle { l: ls, value: phi });
let dist = push(&mut sk, ConstraintKind::Distance { p: c, q: s0, value: r });
let ke = push(&mut sk, ConstraintKind::Angle { a: ls, b: le, value: sigma, flip: false });
let kp = push(&mut sk, ConstraintKind::Angle { a: ls, b: lp, value: share * sigma, flip: false });
push(&mut sk, ConstraintKind::PointOnCurve { p, c: arc });
let phi2 = phi + rnd.range(-2.0, 2.0);
let sigma2 = (sigma * rnd.range(lo, hi)).clamp(20f64.to_radians(), 170f64.to_radians());
let edits = vec![(angle, phi2), (dist, r * rnd.range(lo, hi)), (ke, sigma2), (kp, share * sigma2)];
let checks = vec![
Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI },
Check::Toward { a: c, b: e0, d: Vec2::from_angle(phi2 + sigma2) },
Check::Toward { a: c, b: p, d: Vec2::from_angle(phi2 + share * sigma2) },
];
Some(Case { sk, edits, checks })
}
type Family = fn(&mut Lcg, f64, f64) -> Option<Case>;
fn families() -> Vec<(&'static str, Family)> {
vec![
("filleted rectangle", filleted_rectangle),
("slot (centre distance)", |r, lo, hi| slot(r, lo, hi, false)),
("slot (overall length)", |r, lo, hi| slot(r, lo, hi, true)),
("tangent chain", tangent_chain),
("axis-aligned chain", axis_chain),
("bolt circle", bolt_circle),
("mirrored point", mirrored),
("collinear lines", collinear),
("distance from a line", line_distance),
("equal lines", equal_lines),
("points on an arc", on_arc),
]
}
const EDITS: usize = 200;
/// `n` feasible members of `family` at scales `lo..hi`; fails the test when the family yields
/// fewer than one in fifty.
fn sample(rnd: &mut Lcg, family: Family, lo: f64, hi: f64, n: usize) -> Vec<Case> {
let mut out = Vec::new();
for _ in 0..n.saturating_mul(50) {
if out.len() == n {
break;
}
out.extend(family(rnd, lo, hi));
}
assert_eq!(out.len(), n, "the family yields too few feasible members at {lo}–{hi}×");
out
}
/// (failed, broke a check) over `EDITS` kept edits of `family` at scales `lo..hi`.
fn tally(family: Family, lo: f64, hi: f64, on: bool) -> (usize, usize) {
let mut rnd = Lcg(7);
let (mut failed, mut broke) = (0, 0);
for case in sample(&mut rnd, family, lo, hi, EDITS) {
let (f, b) = run(&case, on);
failed += f as usize;
broke += b as usize;
}
(failed, broke)
}
fn table(on: bool) -> Vec<(&'static str, f64, f64, usize, usize)> {
let mut out = Vec::new();
for (name, family) in families() {
for (lo, hi) in [(0.8, 1.25), (0.5, 2.0), (0.3, 3.0)] {
let (failed, broke) = tally(family, lo, hi, on);
eprintln!("seed {} {name:<24} {lo}–{hi}×: {failed} failed, {broke}/{EDITS} lost a side", if on { "on " } else { "off" });
out.push((name, lo, hi, failed, broke));
}
}
out
}
#[test]
fn rounds_and_tangents_keep_their_sides_on_dimension_edits() {
let rows = table(true);
assert!(rows.iter().all(|r| r.3 == 0 && r.4 == 0), "{rows:?}");
}
/// The measurement the seed is judged against: without it, LM folds arcs and lines over.
#[test]
#[ignore = "measurement: run with --ignored --nocapture"]
fn rounds_and_tangents_without_the_seed() {
table(false);
}
/// The solved members of every family are left exactly where they are.
#[test]
fn solved_members_stay_put() {
let mut rnd = Lcg(3);
for (name, family) in families() {
for case in sample(&mut rnd, family, 1.0, 1.0, 10) {
let mut sk = case.sk.clone();
let rep = solve(&mut sk);
assert!(rep.ok() && rep.dof == 0, "{name}: {rep:?}");
for (p, q) in sk.points.iter().zip(&case.sk.points) {
assert!(p.pos.dist(q.pos) < 1e-7, "{name}: {} moved {:?} → {:?}", p.id, q.pos, p.pos);
}
}
}
}
/// The seed only moves the start: through the crate-wide seed-on/seed-off comparison (`solve`
/// in tests), the edits give the same status, degrees of freedom and determined flags.
#[test]
fn seed_differential_on_every_family() {
let mut rnd = Lcg(5);
for (_, family) in families() {
for case in sample(&mut rnd, family, 0.5, 2.0, 15) {
let mut sk = edited(&case);
solve(&mut sk);
}
}
}
/// Every point of every family is placed by the seed (not left to LM), so edits solve in
/// few iterations.
#[test]
fn every_point_of_every_family_is_constructed() {
let mut rnd = Lcg(9);
for (name, family) in families() {
for case in sample(&mut rnd, family, 0.5, 2.0, 10) {
let sk = edited(&case);
let s = seed_of(&sk);
let used: Vec<usize> = sk.curves.iter().flat_map(|c| c.kind.point_ids()).collect();
for p in used {
assert!(s.placed_by[p].is_some_and(Placement::anchors), "{name}: point {p} ({}) not placed: {s:?}", sk.points[p].id);
}
}
}
}
/// The seed does not depend on the order of the constraints.
#[test]
fn seed_on_lines_and_rounds_is_independent_of_constraint_order() {
let mut rnd = Lcg(13);
for (name, family) in families() {
let case = sample(&mut rnd, family, 0.5, 2.0, 1).remove(0);
let mut sk = edited(&case);
let forward = seed_of(&sk);
sk.constraints.reverse();
assert_eq!(seed_of(&sk), forward, "{name}");
let n = sk.constraints.len();
sk.constraints.rotate_left(n / 3);
assert_eq!(seed_of(&sk), forward, "{name}");
}
}
/// The canonical bracket with the third dimension on a point the rigid triangle carries
/// through a constraint the decomposition reads from the shared loci: the midpoint of P R, or
/// the centre of a circle tangent to P R at a known distance from Q. No propagation order
/// reaches P, Q, R; the decomposition grows the triangle by that point and places all four.
#[test]
fn decomposition_grows_clusters_through_lines_and_tangency() {
for tangent in [false, true] {
let truth = [v(0.0, 0.0), v(60.0, 0.0), v(10.0, 30.0), v(45.0, 35.0), v(25.0, 55.0)];
let mut sk = Sketch::new();
let ids: Vec<usize> = truth
.iter()
.enumerate()
.map(|(i, t)| sk.add_point(*t + if i < 2 { v(0.0, 0.0) } else { v(0.7 * i as f64, -0.4 * i as f64) }, None).unwrap())
.collect();
let [o1, o2, p, q, r] = [ids[0], ids[1], ids[2], ids[3], ids[4]];
for x in [o1, o2] {
push(&mut sk, ConstraintKind::Fix { p: x });
}
let pr = sk.add_line_pts(p, r, None).unwrap();
for (a, b, ta, tb) in [(p, q, 2, 3), (q, r, 3, 4), (o1, p, 0, 2), (o1, r, 0, 4)] {
push(&mut sk, ConstraintKind::Distance { p: a, q: b, value: truth[ta].dist(truth[tb]) });
}
push(&mut sk, ConstraintKind::Distance { p, q: r, value: truth[2].dist(truth[4]) });
let m = if tangent {
// Circle of radius 6 tangent to P R on Q's side, 25 from Q.
let (a, b) = (truth[2], truth[4]);
let d = (b - a).normalized().unwrap();
let n = d.perp() * (d.cross(truth[3] - a).signum());
let foot = a + d * 6.0 + n * 6.0;
let k_at = {
// On the offset line, 25 from Q.
let rel = foot - truth[3];
let t = -rel.dot(d) + (625.0 - rel.cross(d).powi(2)).sqrt();
foot + d * t
};
let circle = sk.add_circle(k_at + v(0.5, 0.3), 6.0, None, None).unwrap();
let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() };
push(&mut sk, ConstraintKind::Radius { c: circle, value: 6.0 });
push(&mut sk, ConstraintKind::Tangent { a: pr, b: circle });
push(&mut sk, ConstraintKind::Distance { p: q, q: c, value: 25.0 });
push(&mut sk, ConstraintKind::Distance { p: o2, q: c, value: truth[1].dist(k_at) });
c
} else {
let m = sk.add_point((truth[2] + truth[4]) * 0.5 + v(0.4, 0.2), None).unwrap();
push(&mut sk, ConstraintKind::Midpoint { p: m, l: pr });
push(&mut sk, ConstraintKind::Distance { p: o2, q: m, value: truth[1].dist((truth[2] + truth[4]) * 0.5) });
m
};
let out = seed_of(&sk);
for x in [p, q, r, m] {
assert_eq!(out.placed_by[x], Some(Placement::Cluster), "tangent {tangent}, point {x}: {out:?}");
}
let rep = solve(&mut sk);
assert!(rep.ok() && rep.dof == 0 && rep.iterations == 0, "tangent {tangent}: {rep:?}");
for (i, t) in [(p, 2), (q, 3), (r, 4)] {
assert!(sk.points[i].pos.dist(truth[t]) < 1e-9, "tangent {tangent}: {:?}", sk.points[i].pos);
}
}
}
/// A circle's radius known from a dimension is part of the seed: the circle's radius starts
/// there.
#[test]
fn a_dimensioned_radius_is_seeded() {
let mut rnd = Lcg(17);
let case = bolt_circle(&mut rnd, 2.0, 2.0).unwrap();
let sk = edited(&case);
let s = seed_of(&sk);
let ConstraintKind::Diameter { c, value } = sk.constraints[1].kind else { panic!() };
assert_eq!(s.radius[c], Some(value / 2.0));
}
/// Hostile curve references and values in the constraints the new loci read neither panic
/// nor change the report (every solve runs through the seed-on/seed-off comparison).
#[test]
fn hostile_lines_and_rounds_neither_panic_nor_change_the_report() {
let base = || tangent_chain(&mut Lcg(21), 0.5, 2.0).unwrap();
let raw = |sk: &mut Sketch, kind: ConstraintKind| {
sk.constraints.push(Constraint { id: format!("h{}", sk.constraints.len()), kind, param: None, driven: false, text: None })
};
let cases: Vec<Box<dyn Fn(&mut Sketch)>> = vec![
Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: 1, l: 999 })),
Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: usize::MAX, l: 0 })),
Box::new(|sk| raw(sk, ConstraintKind::Symmetric { p: 1, q: 2, l: usize::MAX })),
Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 0, b: 0 })),
Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 2, b: 77 })),
Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: f64::NAN })),
Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: -3.0 })),
Box::new(|sk| raw(sk, ConstraintKind::Diameter { c: 0, value: 1e300 })),
Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: usize::MAX })),
Box::new(|sk| raw(sk, ConstraintKind::PointLineDistance { p: 1, l: 1, value: f64::INFINITY })),
Box::new(|sk| raw(sk, ConstraintKind::LineAngle { l: 0, value: f64::NAN })),
Box::new(|sk| raw(sk, ConstraintKind::Angle { a: 0, b: 1, value: 1e300, flip: true })),
Box::new(|sk| raw(sk, ConstraintKind::Parallel { a: 0, b: 0 })),
Box::new(|sk| raw(sk, ConstraintKind::Perpendicular { a: 0, b: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::Equal { a: 0, b: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::Concentric { a: 2, b: 2 })),
Box::new(|sk| raw(sk, ConstraintKind::SlotOverallLength { a: 2, b: 2, value: 5.0 })),
Box::new(|sk| raw(sk, ConstraintKind::LinearDiameter { p: 3, l: 0, value: -1.0 })),
Box::new(|sk| sk.points[2].pos = v(f64::NAN, 0.0)),
Box::new(|sk| sk.points[3].pos = v(1e300, -1e300)),
];
for (i, case) in cases.iter().enumerate() {
let mut c = base();
case(&mut c.sk);
let sk = edited(&c);
let s = seed(&sk, &given(&sk), Some(&[v(f64::NAN, 0.0)]), &points_on_curves(&sk), &mut Budget::full());
assert!(s.pos.iter().flatten().all(|p| p.is_finite()), "case {i}: {s:?}");
assert!(s.radius.iter().flatten().all(|r| r.is_finite() && *r > 0.0), "case {i}: {s:?}");
let mut t = c.sk.clone();
solve(&mut t);
}
}
/// An axis-aligned line angle turned half a turn (0° → 180°) with a line at an angle to it and
/// a tangent arc: the explicit angle decides the direction, not the reference's axis sign, so
/// the seed builds the chain forward along the new angle and nothing folds.
#[test]
fn an_axis_aligned_line_angle_turned_half_a_turn_is_built_along_it() {
let case = chain(&mut Lcg(31), 1.0, 1.0, 0.0, std::f64::consts::PI).unwrap();
let sk = edited(&case);
let (i0, i1) = sk.curves[0].kind.ends().unwrap();
let s = seed_of(&sk);
let (p0, e1) = (sk.points[i0].pos, s.pos[i1].unwrap_or_else(|| panic!("L1's end unplaced: {s:?}")));
assert!((e1 - p0).dot(Vec2::X) < 0.0, "L1 built along the reference's axis, not its angle: {p0:?} → {e1:?}");
assert_eq!(run(&case, true), (false, false));
}
/// Horizontal and vertical lines tied by a contradictory parallel (or a perpendicular between
/// two horizontal lines) are still read as horizontal and vertical one by one: their rows and
/// columns are seeded.
#[test]
fn contradictory_levels_still_read_each_line_on_its_own() {
for perpendicular in [false, true] {
let mut sk = Sketch::new();
let l1 = sk.add_line(v(0.0, 0.0), v(40.0, 0.5), None, None, None).unwrap();
let (a, b) = sk.curves[l1].kind.ends().unwrap();
let l2 = if perpendicular {
sk.add_line(v(0.0, 30.0), v(30.0, 30.4), None, None, None).unwrap()
} else {
sk.add_line(v(0.0, 30.0), v(0.3, 60.0), None, None, None).unwrap()
};
let (c, d) = sk.curves[l2].kind.ends().unwrap();
for p in [a, c] {
push(&mut sk, ConstraintKind::Fix { p });
}
push(&mut sk, ConstraintKind::Horizontal { l: l1 });
push(&mut sk, ConstraintKind::Length { l: l1, value: 40.0 });
push(&mut sk, if perpendicular { ConstraintKind::Horizontal { l: l2 } } else { ConstraintKind::Vertical { l: l2 } });
push(&mut sk, ConstraintKind::Length { l: l2, value: 30.0 });
push(&mut sk, if perpendicular { ConstraintKind::Perpendicular { a: l1, b: l2 } } else { ConstraintKind::Parallel { a: l1, b: l2 } });
let s = seed_of(&sk);
assert!(s.pos[b].is_some_and(|p| p.dist(v(40.0, 0.0)) < 1e-9), "perpendicular {perpendicular}: {s:?}");
let want = if perpendicular { v(30.0, 30.0) } else { v(0.0, 60.0) };
assert!(s.pos[d].is_some_and(|p| p.dist(want) < 1e-9), "perpendicular {perpendicular}: {s:?}");
}
}
/// A dimensioned circle's radius is seeded also when nothing is given to build on.
#[test]
fn a_dimensioned_radius_is_seeded_with_nothing_given() {
let mut sk = Sketch::new();
let circle = sk.add_circle(v(10.0, 5.0), 10.0, None, None).unwrap();
push(&mut sk, ConstraintKind::Diameter { c: circle, value: 30.0 });
let s = seed(&sk, &vec![false; sk.points.len()], None, &[], &mut Budget::full());
assert_eq!(s.radius[circle], Some(15.0));
}
/// A line's end constrained onto a circle is the tangency's touch point only through the
/// solve's point-on-curve index: with it the circle's centre is placed on the normal there,
/// without it only a touching pair of loci is left.
#[test]
fn the_touch_point_comes_from_the_point_on_curve_index() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(40.0, 12.0), None, None, None).unwrap();
let (a, b) = sk.curves[l].kind.ends().unwrap();
let d = (v(40.0, 12.0) - v(0.0, 0.0)).normalized().unwrap();
let circle = sk.add_circle(v(40.0, 12.0) + d.perp() * 8.5, 8.0, None, None).unwrap();
let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() };
push(&mut sk, ConstraintKind::Fix { p: a });
push(&mut sk, ConstraintKind::LineAngle { l, value: 12f64.atan2(40.0) });
push(&mut sk, ConstraintKind::Length { l, value: v(40.0, 12.0).len() });
push(&mut sk, ConstraintKind::Radius { c: circle, value: 8.0 });
push(&mut sk, ConstraintKind::Tangent { a: l, b: circle });
push(&mut sk, ConstraintKind::PointOnCurve { p: b, c: circle });
let on_curve = crate::solver::points_on_curves(&sk);
let with = seed(&sk, &given(&sk), None, &on_curve, &mut Budget::full());
assert!(with.placed_by[c].is_some_and(Placement::anchors), "{with:?}");
assert!(with.pos[c].unwrap().dist(v(40.0, 12.0) + d.perp() * 8.0) < 1e-9, "{with:?}");
let without = seed(&sk, &given(&sk), None, &[], &mut Budget::full());
assert!(!without.placed_by[c].is_some_and(Placement::anchors), "{without:?}");
}
/// Many pair clusters each grown by one shared point: a fixed point on every line from another
/// fixed point to a point at a distance from it. Each cluster grows the shared point and then
/// merges with the ground, and every growth requeues the clusters holding that point, so the
/// steps outnumber any multiple of the starting clusters: the merge loop is bounded by its
/// work budget, not by a count of steps, and places every point.
#[test]
fn long_growth_chains_are_not_starved() {
let (o, u) = (v(5.0, -3.0), Vec2::from_angle(0.4));
let mut sk = Sketch::new();
let p = sk.add_point(o, None).unwrap();
let z = sk.add_point(o + u * 12.0, None).unwrap();
for x in [p, z] {
push(&mut sk, ConstraintKind::Fix { p: x });
}
push(&mut sk, ConstraintKind::Distance { p, q: z, value: 12.0 });
let qs: Vec<usize> = (0..40)
.map(|j| {
let d = 20.0 + 3.0 * j as f64;
let q = sk.add_point(o + u * d + v(0.5, -0.4), None).unwrap();
let l = sk.add_line_pts(p, q, None).unwrap();
push(&mut sk, ConstraintKind::Distance { p, q, value: d });
push(&mut sk, ConstraintKind::PointOnCurve { p: z, c: l });
q
})
.collect();
let s = seed_of(&sk);
for (j, q) in qs.iter().enumerate() {
assert_eq!(s.placed_by[*q], Some(Placement::Cluster), "point {j}: {s:?}");
assert!(s.pos[*q].is_some_and(|at| at.dist(o + u * (20.0 + 3.0 * j as f64)) < 1e-9), "point {j}: {s:?}");
}
}
File diff suppressed because it is too large Load Diff
+343
View File
@@ -0,0 +1,343 @@
//! Dimension continuation (`solve_after_value_edit`): the triconnected linkage family (two
//! triangles joined by three links, left to the iteration by the construction) under random
//! dimension edits from its previous solved state, with every failure that remains proven
//! infeasible; and the solve without continuation when nothing it could help with changed.
use solvecraft_geom::Vec2;
use crate::construct::switch;
use crate::model::{ConstraintKind, Sketch};
use crate::solver::{DrivingValues, solve, solve_after_value_edit};
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next() * (hi - lo)
}
}
/// The linkage's solved layout: base triangle 0 1 2 (0 fixed, 0–1 horizontal), top triangle
/// 3 4 5, links 0–3, 1–4, 2–5.
const LAYOUT: [(f64, f64); 6] = [(0.0, 0.0), (40.0, 0.0), (18.0, 30.0), (8.0, 55.0), (52.0, 48.0), (30.0, 80.0)];
const PAIRS: [(usize, usize); 9] = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
/// The linkage in its solved layout with every distance edited by `lo..hi`×, and the values
/// the layout was solved for (per constraint). With `lines`, each triangle side is a line of
/// its own (end points coincident with the corners) with a length dimension.
fn linkage(rng: &mut Lcg, lo: f64, hi: f64, lines: bool) -> (Sketch, DrivingValues) {
let mut sk = Sketch::new();
let ids: Vec<usize> = LAYOUT.iter().map(|p| sk.add_point(Vec2::new(p.0, p.1), None).unwrap()).collect();
sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).unwrap();
let l = sk.add_line_pts(ids[0], ids[1], None).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
for (k, (a, b)) in PAIRS.into_iter().enumerate() {
let (pa, pb) = (sk.points[ids[a]].pos, sk.points[ids[b]].pos);
if lines && k < 6 {
let l = sk.add_line(pa, pb, None, None, None).unwrap();
let (s, e) = sk.curves[l].kind.ends().unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: s, q: ids[a] }, None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: e, q: ids[b] }, None).unwrap();
sk.add_constraint(ConstraintKind::Length { l, value: pa.dist(pb) }, None).unwrap();
} else {
sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: pa.dist(pb) }, None).unwrap();
}
}
let old = sk.driving_values();
for c in &mut sk.constraints {
if let Some(v) = c.kind.value() {
c.kind.set_value(v * rng.range(lo, hi));
}
}
(sk, old)
}
/// The nine distances of a linkage sketch, in `PAIRS` order.
fn distances(sk: &Sketch) -> Vec<f64> {
sk.constraints.iter().filter_map(|c| c.kind.value()).collect()
}
/// Turn of both triangles (layout point `i` is sketch point `i + 1`, after the origin).
fn turns(sk: &Sketch) -> [bool; 2] {
let p = |i: usize| sk.points[i + 1].pos;
[(p(1) - p(0)).cross(p(2) - p(0)) > 0.0, (p(4) - p(3)).cross(p(5) - p(3)) > 0.0]
}
/// Both intersections of two circles; `None` when they miss by more than rounding.
fn circles(c0: Vec2, r0: f64, c1: Vec2, r1: f64) -> Option<[Vec2; 2]> {
let d = c0.dist(c1);
let a = (r0 * r0 - r1 * r1 + d * d) / (2.0 * d);
let h2 = r0 * r0 - a * a;
if h2 < -1e-9 * r0 * r0 {
return None;
}
let u = (c1 - c0) / d;
let m = c0 + u * a;
let h = h2.max(0.0).sqrt();
Some([m + u.perp() * h, m - u.perp() * h])
}
/// Whether the linkage with distances `d` (`PAIRS` order) has a real solution, decided by
/// branch and bound. With the base triangle placed (one of its four mirror images) and the
/// top triangle's shape fixed (one of two), a solution is an angle φ of link 0–3 and a turn θ
/// of the top triangle where the links 1–4 and 2–5 have their lengths. Over a cell of
/// half-width w in (φ, θ) the points move at most (d03 + top radius)·w, so a cell whose centre
/// misses both lengths by more than that holds no solution: when every cell is discarded so,
/// none exists. A cell that cannot be discarded down to w ≈ 1e-10 holds one within rounding.
/// `keep`: only solutions with both triangles turning so. `near`: only solutions with every
/// point within the distance of the given layout (discarded the same way).
fn feasible(d: &[f64], keep: Option<[bool; 2]>, near: Option<(&[Vec2], f64)>) -> bool {
let [d01, d12, d20, d34, d45, d53, d03, d14, d25] = d.try_into().unwrap();
let Some(tops) = circles(Vec2::ZERO, d53, Vec2::new(d34, 0.0), d45) else { return false };
let q4 = Vec2::new(d34, 0.0);
let lip = d03 + d34.max(d53);
let far = |i: usize, at: Vec2, slack: f64| near.is_some_and(|(layout, r)| layout[i].dist(at) > r + slack);
for s1 in [1.0, -1.0] {
let p1 = Vec2::new(s1 * d01, 0.0);
let Some(p2s) = circles(Vec2::ZERO, d20, p1, d12) else { return false };
for p2 in p2s.into_iter().filter(|p2| keep.is_none_or(|k| (p1.cross(*p2) > 0.0) == k[0])) {
if far(1, p1, 0.0) || far(2, p2, 0.0) {
continue;
}
for q5 in tops.into_iter().filter(|q5| keep.is_none_or(|k| (q4.cross(*q5) > 0.0) == k[1])) {
let miss = |phi: f64, theta: f64, w: f64| {
let rot = |q: Vec2| Vec2::new(q.x * theta.cos() - q.y * theta.sin(), q.x * theta.sin() + q.y * theta.cos());
let p3 = Vec2::from_angle(phi) * d03;
let (p4, p5) = (p3 + rot(q4), p3 + rot(q5));
if far(3, p3, lip * w) || far(4, p4, lip * w) || far(5, p5, lip * w) {
return f64::INFINITY;
}
(p1.dist(p4) - d14).abs().max((p2.dist(p5) - d25).abs())
};
let n = 64;
let w0 = std::f64::consts::PI / n as f64;
let mut stack: Vec<(f64, f64, f64)> = Vec::new();
for i in 0..n {
for j in 0..n {
stack.push(((2 * i + 1) as f64 * w0, (2 * j + 1) as f64 * w0, w0));
}
}
while let Some((phi, theta, w)) = stack.pop() {
if miss(phi, theta, w) > lip * w * (1.0 + 1e-9) + 1e-12 {
continue;
}
if w < 1e-10 {
return true;
}
let h = w / 2.0;
for (a, b) in [(-h, -h), (-h, h), (h, -h), (h, h)] {
stack.push((phi + a, theta + b, h));
}
}
}
}
}
false
}
/// The edit's distances `t` of the way from old to new.
fn along(old: &[f64], new: &[f64], t: f64) -> Vec<f64> {
old.iter().zip(new).map(|(o, n)| o + t * (n - o)).collect()
}
/// Outcome counts over the family.
#[derive(Debug, Default)]
struct Count {
one_jump_failed: usize,
one_jump_folded: usize,
/// Turned a triangle over (named in the report, for a value edit to refuse).
refused: usize,
/// Failed otherwise.
failed: usize,
/// Of the refused and failed: the new values have no solution.
infeasible: usize,
/// The new values are solved only with a triangle mirrored.
only_mirrored: usize,
/// The sketch's own solution ends on the straight value path: followed in 1/4096 steps
/// it fails or folds at some t < 1, and no orientation-keeping solution for the values at
/// t lies within 1 mm of where it was one step before (a fold of the path, or no solution).
branch_ends: usize,
/// Refused or failed cases none of the above explains: old and new distances.
unexplained: Vec<(Vec<f64>, Vec<f64>)>,
}
/// The linkage's distances set to `d`.
fn set_distances(sk: &mut Sketch, d: &[f64]) {
for (c, v) in sk.constraints.iter_mut().filter(|c| c.kind.value().is_some()).zip(d) {
c.kind.set_value(*v);
}
}
/// The value path from `old` to `new` in 1/4096 steps, each solved by the iteration alone
/// from the previous solution: where it fails or folds a triangle (t and the layout one step
/// before), or `None` when it reaches the new values.
fn fine_path(sk: &Sketch, old: &[f64], new: &[f64]) -> Option<(f64, Vec<Vec2>)> {
let mut s = sk.clone();
for (p, xy) in s.points.iter_mut().skip(1).zip(LAYOUT) {
p.pos = Vec2::new(xy.0, xy.1);
}
let before = turns(&s);
let n = 4096;
for k in 1..=n {
let t = k as f64 / n as f64;
let last: Vec<Vec2> = s.points.iter().skip(1).map(|p| p.pos).collect();
set_distances(&mut s, &along(old, new, t));
let rep = switch::without_differential(|| switch::without_seed(|| solve(&mut s)));
if !rep.ok() || turns(&s) != before {
return Some((t, last));
}
}
None
}
/// `n` edits of the family: the one-jump solve (LM from the layout) against
/// `solve_after_value_edit`, which must solve keeping both triangles' turn or refuse with the
/// layout kept; every refusal and failure is explained (see [`Count`]).
fn run_family(seed: u64, n: usize, lo: f64, hi: f64, lines: bool) -> Count {
let mut rng = Lcg(seed);
let mut count = Count::default();
for _ in 0..n {
let (sk, old) = linkage(&mut rng, lo, hi, lines);
let before = turns(&sk);
let (mut jump, mut cont) = (sk.clone(), sk.clone());
let (a, b) = switch::without_differential(|| (solve(&mut jump), solve_after_value_edit(&mut cont, &old)));
count.one_jump_failed += usize::from(!a.ok());
count.one_jump_folded += usize::from(a.ok() && turns(&jump) != before);
if a.ok() && turns(&jump) == before {
assert_eq!(jump.points, cont.points, "a one-jump solve that keeps its triangles is the result");
}
if b.keeps_orientation() {
assert_eq!(turns(&cont), before, "an accepted edit keeps every triangle's turn");
continue;
}
if b.folded.is_empty() {
count.failed += 1;
assert_eq!(cont.points, sk.points, "a failed solve keeps the layout");
} else {
// Solved, turned over: kept as the best result, named for a command to refuse.
count.refused += 1;
assert!(b.ok() && turns(&cont) != before, "{b:?}");
let dims: Vec<&str> = sk.constraints.iter().filter(|c| c.kind.value().is_some()).map(|c| c.id.as_str()).collect();
assert!(b.folded.iter().all(|id| dims.contains(&id.as_str()) && b.failing.contains(id)), "{b:?}");
}
let new = distances(&sk);
let old: Vec<f64> = sk.constraints.iter().filter_map(|c| old.get(&c.id).copied()).collect();
if !feasible(&new, None, None) {
count.infeasible += 1;
} else if !feasible(&new, Some(before), None) {
count.only_mirrored += 1;
} else if let Some((t, last)) = fine_path(&sk, &old, &new)
&& !feasible(&along(&old, &new, t), Some(before), Some((&last, 1.0)))
{
count.branch_ends += 1;
} else {
count.unexplained.push((old, new));
}
}
count
}
fn report(name: &str, c: &Count) {
eprintln!(
"{name}: one jump failed {} folded {}; continuation refused {} failed {} (infeasible {}, only mirrored {}, branch ends {}, unexplained {})",
c.one_jump_failed,
c.one_jump_folded,
c.refused,
c.failed,
c.infeasible,
c.only_mirrored,
c.branch_ends,
c.unexplained.len()
);
for u in c.unexplained.iter().take(3) {
eprintln!(" {u:?}");
}
}
#[test]
fn feasibility_check_agrees_with_the_layout() {
let d: Vec<f64> = PAIRS.iter().map(|&(a, b)| Vec2::new(LAYOUT[a].0, LAYOUT[a].1).dist(Vec2::new(LAYOUT[b].0, LAYOUT[b].1))).collect();
assert!(feasible(&d, None, None));
let mut long = d.clone();
long[7] = 500.0;
assert!(!feasible(&long, None, None));
let mut flat = d;
flat[0] = flat[1] + flat[2] + 1.0;
assert!(!feasible(&flat, None, None));
}
#[test]
fn linkage_edits_are_solved_by_continuation_or_refused_with_reason() {
// The first 40 edits of the two families of `linkage_family_counter`, with every refusal
// and failure explained.
let c = run_family(2026, 40, 0.8, 1.25, false);
report("linkage 0.8–1.25× (40)", &c);
assert!(c.unexplained.is_empty() && c.branch_ends > 0 && c.failed + c.refused < c.one_jump_failed + c.one_jump_folded, "{c:?}");
let c = run_family(7, 40, 0.5, 2.5, true);
report("linkage 0.5–2.5× lines (40)", &c);
assert!(c.unexplained.is_empty() && c.refused > 0, "{c:?}");
}
/// Measurement, not a gate: the two families, 500 edits each, every refusal and failure
/// explained (run with `--ignored --nocapture`).
#[test]
#[ignore]
fn linkage_family_counter() {
for (seed, lo, hi, lines) in [(2026, 0.8, 1.25, false), (7, 0.5, 2.5, true)] {
let c = run_family(seed, 500, lo, hi, lines);
report(&format!("linkage {lo}–{hi}× lines {lines} (500)"), &c);
assert!(c.unexplained.is_empty(), "{c:?}");
}
}
#[test]
fn edits_from_an_unsolved_state_are_not_continued() {
// Old values the layout is not solved for: no solved state to step from, nor orientations
// to keep; the one-jump result stands.
let mut rng = Lcg(2026);
let mut tried = 0;
for _ in 0..200 {
let (sk, old) = linkage(&mut rng, 0.8, 1.25, false);
let wrong: DrivingValues = old.iter().map(|(k, v)| (k.clone(), v * 1.1)).collect();
let (mut a, mut b) = (sk.clone(), sk.clone());
let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, &wrong));
if ra.ok() {
continue;
}
tried += 1;
assert_eq!(rb.continuations, 0, "{rb:?}");
assert!(rb.folded.is_empty());
assert_eq!(a.points, b.points);
}
assert!(tried > 0);
}
#[test]
fn unchanged_values_solve_as_without_old_values() {
let mut rng = Lcg(1);
for _ in 0..20 {
let (sk, _) = linkage(&mut rng, 0.8, 1.25, true);
let current = sk.driving_values();
let (mut a, mut b) = (sk.clone(), sk.clone());
let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, &current));
assert_eq!((ra.status, ra.iterations, ra.dof), (rb.status, rb.iterations, rb.dof));
assert_eq!(rb.continuations, 0);
assert_eq!(a.points, b.points);
}
}
#[test]
fn hostile_old_values_never_panic() {
let mut rng = Lcg(3);
let (sk, old) = linkage(&mut rng, 0.5, 2.5, false);
let all = |v: f64| -> DrivingValues { old.keys().map(|k| (k.clone(), v)).collect() };
let unknown: DrivingValues = (0..20).map(|i| (format!("nope{i}"), 1.0)).collect();
for bad in [DrivingValues::new(), all(f64::NAN), all(f64::INFINITY), all(-1e300), all(0.0), unknown, old.clone()] {
let mut s = sk.clone();
let rep = solve_after_value_edit(&mut s, &bad);
assert!(rep.max_residual.is_finite());
assert!(s.points.iter().all(|p| p.pos.x.is_finite() && p.pos.y.is_finite()));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -18
View File
@@ -11,6 +11,7 @@
use solvecraft_geom::Vec2;
use crate::model::{ConstraintKind, CurveKind, Sketch};
use crate::union_find::{find, union};
/// What a dimension measures, in a form that can be drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -138,30 +139,14 @@ pub fn encode_text(f: &DimFrame, at: Vec2) -> Vec2 {
pub fn chain_centres(sk: &Sketch) -> Vec<Vec2> {
let n = sk.points.len();
let mut parent: Vec<usize> = (0..n).collect();
fn root(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
let g = p.get(q).copied().unwrap_or(q);
if let Some(s) = p.get_mut(i) {
*s = g;
}
i = q;
}
i
}
for c in &sk.curves {
let ids = c.kind.point_ids();
for w in ids.windows(2) {
let (a, b) = (root(&mut parent, w[0]), root(&mut parent, w[1]));
if let Some(s) = parent.get_mut(a) {
*s = b;
}
union(&mut parent, w[0], w[1]);
}
}
let mut sum = vec![(Vec2::ZERO, 0usize); n];
let roots: Vec<usize> = (0..n).map(|i| root(&mut parent, i)).collect();
let roots: Vec<usize> = (0..n).map(|i| find(&mut parent, i)).collect();
for (i, r) in roots.iter().enumerate() {
if let (Some(s), Some(p)) = (sum.get_mut(*r), sk.points.get(i)) {
s.0 += p.pos;
+22 -1
View File
@@ -5,14 +5,25 @@
//! The solver is our own clean-room implementation (residual functions per constraint, local
//! finite-difference Jacobians, damped Gauss–Newton per connected component, rank analysis by
//! Gaussian elimination). See `plan/architecture.md` §sketch.
//!
//! Before iterating, a constructive seed places every point that two loci on already placed
//! points pin down (circles, rows and columns, lines through placed points, offsets from
//! placed lines, tangency, midpoints, mirrored points, coincidence), on the branch that keeps
//! the orientation the point had before the solve, so dimension edits do not fold the sketch
//! into a mirrored solution or flip an arc out of its corner. The seed only moves the starting
//! point: the iteration still verifies every constraint and the rank analysis still decides
//! the degrees of freedom.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod construct;
mod curves;
mod decompose;
mod dims;
mod freeform_offset;
mod linalg;
mod link;
mod loci;
mod model;
mod offset;
mod offset_topology;
@@ -21,12 +32,14 @@ mod profiles;
mod shape;
mod solver;
mod text;
mod union_find;
mod wire;
pub use curves::{
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent,
spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode,
};
pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
@@ -36,10 +49,18 @@ pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRole
pub use periodic::periodic_beziers;
pub use profiles::{CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves};
pub use shape::{Shape, intersections};
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
pub use solver::{
Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, text_geometry};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
#[cfg(test)]
mod construct_rounds_tests;
#[cfg(test)]
mod construct_tests;
#[cfg(test)]
mod continuation_tests;
#[cfg(test)]
mod tests;
+4 -6
View File
@@ -9,7 +9,7 @@
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Vec2, Vec3};
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError};
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError, in_range, radius_in_range};
/// Points of one link closer than this are merged into one point.
const MERGE_TOL: f64 = 1e-7;
@@ -154,16 +154,14 @@ pub enum LinkGeom {
impl LinkGeom {
fn finite(&self) -> bool {
let ok = |p: &Vec2| p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
let ok = |p: &Vec2| in_range(*p);
match self {
LinkGeom::Point(p) => ok(p),
LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL,
LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9,
LinkGeom::Circle(c, r) => ok(c) && radius_in_range(*r),
LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL,
LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6,
LinkGeom::Ellipse { c, major, minor } => {
ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9
}
LinkGeom::Ellipse { c, major, minor } => ok(c) && ok(major) && c.dist(*major) > 1e-9 && radius_in_range(*minor),
LinkGeom::Spline { pts, control, degree, periodic } => {
(!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok()))
&& (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len())
File diff suppressed because it is too large Load Diff
+101 -21
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Seg2, Vec2};
use solvecraft_geom::{Seg2, Vec2, Vec3};
use crate::union_find::{find, union};
/// Hard caps that keep hostile input from exhausting memory or time.
pub const MAX_POINTS: usize = 20_000;
@@ -7,6 +9,24 @@ pub const MAX_CONSTRAINTS: usize = 40_000;
/// Curves need points, so this follows the point cap.
pub const MAX_CURVES: usize = MAX_POINTS;
/// Largest coordinate or dimension value (mm) a sketch accepts.
pub(crate) const MAX_COORD: f64 = 1e9;
/// A position a sketch accepts: finite and within [`MAX_COORD`].
pub(crate) fn in_range(p: Vec2) -> bool {
p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD
}
/// [`in_range`] for a 3D position.
pub(crate) fn in_range3(p: Vec3) -> bool {
p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD && p.z.abs() < MAX_COORD
}
/// A radius a sketch accepts: finite, positive and within [`MAX_COORD`].
pub(crate) fn radius_in_range(r: f64) -> bool {
r.is_finite() && r > 1e-9 && r < MAX_COORD
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SketchError {
#[error("unknown sketch entity `{0}`")]
@@ -367,7 +387,8 @@ impl ConstraintKind {
pub fn refers_to_point(&self, p: usize) -> bool {
self.indices().0.contains(&p)
}
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
/// The points and the curves the constraint refers to.
pub fn indices(&self) -> (Vec<usize>, Vec<usize>) {
use ConstraintKind::*;
match *self {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
@@ -607,7 +628,7 @@ impl Sketch {
}
fn check_pos(p: Vec2) -> Result<Vec2> {
if p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
if in_range(p) { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
}
/// Add a free point (or reuse an existing one within `merge_tol` of `p` when `merge_tol > 0`).
@@ -679,7 +700,7 @@ impl Sketch {
}
pub fn add_circle(&mut self, center: Vec2, r: f64, c: Option<usize>, id: Option<&str>) -> Result<usize> {
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
if !radius_in_range(r) {
return Err(SketchError::Invalid(format!("radius {r}")));
}
let id = self.curve_id(id, "c")?;
@@ -703,7 +724,7 @@ impl Sketch {
/// the circle through `p0`).
pub fn add_arc(&mut self, center: Vec2, p0: Vec2, p1: Vec2, pts: [Option<usize>; 3], id: Option<&str>) -> Result<usize> {
let r = center.dist(p0);
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
if !radius_in_range(r) {
return Err(SketchError::Invalid("degenerate arc".into()));
}
let p1 = center + (p1 - center).normalized().ok_or_else(|| SketchError::Invalid("degenerate arc".into()))? * r;
@@ -838,7 +859,7 @@ impl Sketch {
_ => {}
}
if let Some(v) = k.value()
&& (!v.is_finite() || v.abs() > 1e9 || (v < 0.0 && !k.is_angle()))
&& (!v.is_finite() || v.abs() > MAX_COORD || (v < 0.0 && !k.is_angle()))
{
return Err(SketchError::Invalid(format!("dimension value {v}")));
}
@@ -858,7 +879,7 @@ impl Sketch {
|| self.curves.iter().any(|c| c.fixed && c.kind.uses(i))
}
/// [`Sketch::point_locked`] for every point at once.
/// [`Sketch::point_locked`] for every point at once: the points every solve keeps as they are.
pub fn locked_points(&self) -> Vec<bool> {
let mut out: Vec<bool> = self.points.iter().map(|p| p.fixed || p.link.is_some()).collect();
let mut set = |i: usize| {
@@ -887,21 +908,9 @@ impl Sketch {
}
// Points made one by coincident constraints.
let mut root: Vec<usize> = (0..self.points.len()).collect();
fn find(r: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = r.get(i) {
if p == i {
break;
}
i = p;
}
i
}
for c in &self.constraints {
if let ConstraintKind::Coincident { p, q } = c.kind {
let (a, b) = (find(&mut root, p), find(&mut root, q));
if let Some(x) = root.get_mut(a) {
*x = b;
}
union(&mut root, p, q);
}
}
let ends: Vec<Vec<usize>> =
@@ -1007,6 +1016,77 @@ impl Sketch {
out
}
/// Every point a constraint touches: its points and the points of its curves.
pub fn constraint_points(&self, k: &ConstraintKind) -> Vec<usize> {
let (mut pts, curves) = k.indices();
pts.extend(curves.into_iter().flat_map(|c| self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default()));
pts
}
/// What a solve holding `points` and the radii of `radii` moves with them, as the solver
/// couples its variables: (per point, per curve radius). The held points, and the free
/// points and free circle/ellipse radii joined to them through curves and driving
/// constraints without passing through a constant (a held, fixed or projected point or
/// radius couples nothing).
pub fn coupled(&self, points: &[usize], radii: &[usize]) -> (Vec<bool>, Vec<bool>) {
let (n, m) = (self.points.len(), self.curves.len());
// Nodes: points, then curve radii, then one node standing for everything held.
let held = n + m;
let mut root: Vec<usize> = (0..=held).collect();
let mut seed = vec![false; n + m];
for v in points.iter().copied().chain(radii.iter().map(|c| n.saturating_add(*c))) {
if let Some(s) = seed.get_mut(v) {
*s = true;
}
}
let locked = self.locked_points();
let free = |v: usize| {
if seed.get(v).copied().unwrap_or(true) {
return false;
}
match v.checked_sub(n) {
None => !locked.get(v).copied().unwrap_or(true),
Some(c) => self
.curves
.get(c)
.is_some_and(|x| matches!(x.kind, CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) && x.link.is_none() && !x.fixed),
}
};
let curve_nodes = |c: usize| {
let mut v = self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default();
v.push(n + c);
v
};
// What the solver's equations join (see `solve_with`): an arc's centre and ends, a
// periodic spline's first and last points, and each driving constraint's entities. A
// curve alone joins nothing (a circle's radius and centre move independently).
let own = self.curves.iter().filter_map(|x| match &x.kind {
CurveKind::Arc { c, a, b } => Some(vec![*c, *a, *b]),
CurveKind::Spline { pts, periodic: true, .. } => match (pts.first(), pts.last()) {
(Some(a), Some(b)) if a != b => Some(vec![*a, *b]),
_ => None,
},
_ => None,
});
let edges = own.chain(self.constraints.iter().filter(|c| !c.driven).map(|c| {
let (mut v, curves) = c.kind.indices();
v.extend(curves.into_iter().flat_map(curve_nodes));
v
}));
for e in edges {
let mut join = e.iter().copied().filter(|&v| free(v));
let first = if e.iter().any(|v| seed.get(*v).copied().unwrap_or(false)) { Some(held) } else { join.next() };
if let Some(first) = first {
for v in join {
union(&mut root, first, v);
}
}
}
let r = find(&mut root, held);
let mut coupled = |v: usize| seed.get(v).copied().unwrap_or(false) || (free(v) && find(&mut root, v) == r);
((0..n).map(&mut coupled).collect(), (n..n + m).map(&mut coupled).collect())
}
/// Is curve `c` held in place: fixed or projected (linked).
pub fn curve_locked(&self, c: usize) -> bool {
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id))
@@ -1139,7 +1219,7 @@ impl Sketch {
CurveKind::Circle { .. } => "c",
CurveKind::Arc { .. } => "a",
CurveKind::Ellipse { m, c, r } => {
if m == c || !(r.is_finite() && *r > 1e-9 && *r < 1e9) {
if m == c || !radius_in_range(*r) {
return Err(SketchError::Invalid("degenerate ellipse".into()));
}
"e"
+5 -4
View File
@@ -1,4 +1,5 @@
//! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation.
use crate::model::in_range;
use crate::{MAX_SPLINE_POINTS, Sketch, SketchError};
use solvecraft_geom::Vec2;
@@ -11,7 +12,7 @@ pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> {
if points.len() < 4
|| points.len() > MAX_SPLINE_POINTS
|| points.first() != points.last()
|| points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9)
|| points.iter().any(|p| !in_range(*p))
|| points.windows(2).any(|p| {
let h = p[0].dist(p[1]);
!h.is_finite() || h <= 1e-9
@@ -89,7 +90,7 @@ pub fn periodic_beziers(points: &[Vec2]) -> Result<Vec<[Vec2; 4]>> {
let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0);
let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0);
let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]];
if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) {
if cubic.iter().any(|p| !in_range(*p)) {
return Err(bad());
}
out.push(cubic);
@@ -162,7 +163,7 @@ mod tests {
sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap();
sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap();
sk.points[first].pos = Vec2::new(0.2, 0.1);
assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok());
assert!(crate::solve_with(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }, None, crate::Analysis::Full, None).ok());
assert_eq!(sk.point(first), sk.point(seam));
assert_eq!(sk.point(first), sk.point(last));
sk.validate_periodic_splines().unwrap();
@@ -176,7 +177,7 @@ mod tests {
let mut collapsed = sk;
collapsed.points[a].pos = collapsed.points[first].pos;
let before = collapsed.clone();
assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok());
assert!(!crate::solve_with(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }, None, crate::Analysis::Full, None).ok());
assert_eq!(collapsed, before);
}
+2 -16
View File
@@ -14,6 +14,7 @@ use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
use crate::model::{CurveKind, Sketch};
use crate::shape::{Shape, intersections};
use crate::union_find::{find, union};
const MERGE_TOL: f64 = 1e-6;
const MAX_EDGES: usize = 50_000;
@@ -399,16 +400,6 @@ impl Grid {
}
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Signed curvature at the start of a segment (left turns positive).
fn curvature(s: &Seg2) -> f64 {
match *s {
@@ -756,12 +747,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
let mut comp: Vec<usize> = (0..n).collect();
for (e, a) in edges.iter().zip(&alive) {
if *a {
let (ru, rv) = (find(&mut comp, e.u), find(&mut comp, e.v));
if ru != rv
&& let Some(s) = comp.get_mut(rv)
{
*s = ru;
}
union(&mut comp, e.v, e.u);
}
}
+1227 -195
View File
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -4,10 +4,44 @@ use solvecraft_geom::Vec2;
use crate::*;
fn v(x: f64, y: f64) -> Vec2 {
pub(crate) fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// Deterministic pseudo-random numbers for the solver harnesses.
pub(crate) struct Lcg(pub u64);
impl Lcg {
pub(crate) fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
pub(crate) fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next() * (hi - lo)
}
/// ±1, evenly.
pub(crate) fn sign(&mut self) -> f64 {
if self.next() < 0.5 { 1.0 } else { -1.0 }
}
}
/// Run `f` with the constructive seed on or off, without the seed-on/seed-off comparison (for
/// tests that measure the difference).
pub(crate) fn seeded<T>(on: bool, f: impl FnOnce() -> T) -> T {
use crate::construct::switch;
switch::without_differential(|| if on { f() } else { switch::without_seed(f) })
}
/// The points that are constants for a plain solve.
pub(crate) fn given(sk: &Sketch) -> Vec<bool> {
crate::solver::held_points(sk, &Hold::default())
}
/// The constructive seed of a plain solve.
pub(crate) fn seed_of(sk: &Sketch) -> crate::construct::Seed {
crate::construct::seed(sk, &given(sk), None, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full())
}
/// Rough rectangle from four lines sharing corner points.
fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] {
let a = sk.add_point(p[0], None).unwrap();
+43
View File
@@ -0,0 +1,43 @@
//! Disjoint sets over indices (`parent[i]` is `i` for a root), shared by the solver's
//! components, profile regions, the model's point groups and dimension placement's chains.
/// The root of `i`'s set, halving the path on the way. An index outside `parent` is its own
/// root.
pub(crate) fn find(parent: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = parent.get(i) {
if p == i {
break;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = gp;
}
i = p;
}
i
}
/// Join the sets of `a` and `b`.
pub(crate) fn union(parent: &mut [usize], a: usize, b: usize) {
let (ra, rb) = (find(parent, a), find(parent, b));
if let Some(slot) = parent.get_mut(ra) {
*slot = rb;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joins_and_halves_paths() {
let mut p: Vec<usize> = (0..6).collect();
union(&mut p, 0, 1);
union(&mut p, 1, 2);
union(&mut p, 4, 5);
assert_eq!(find(&mut p, 0), find(&mut p, 2));
assert_ne!(find(&mut p, 0), find(&mut p, 4));
assert_eq!(find(&mut p, 3), 3);
assert_eq!(find(&mut p, 99), 99);
}
}
+6 -6
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec3;
use crate::link::{Link, LinkGeom, LinkKind, LinkSource};
use crate::model::{Result, Sketch, SketchError};
use crate::model::{Result, Sketch, SketchError, in_range3};
/// Most points one wire may have.
pub const MAX_WIRE_POINTS: usize = 100_000;
@@ -97,7 +97,7 @@ fn clean(w: &[Vec3]) -> Option<Vec<Vec3>> {
}
let mut out: Vec<Vec3> = Vec::new();
for p in w {
if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) {
if !in_range3(*p) {
return None;
}
if out.last().is_none_or(|l| l.dist(*p) > 1e-9) {
@@ -118,14 +118,14 @@ fn drawn_polyline(fit: &[Vec3]) -> Result<Vec<Vec3>> {
if fit.is_empty() || fit.len() > MAX_FIT_POINTS {
return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points")));
}
if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
if fit.iter().any(|p| !in_range3(*p)) {
return Err(SketchError::Invalid("3D point out of range".into()));
}
if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) {
return Err(SketchError::Invalid("the points of a 3D curve must differ".into()));
}
let pts = fit_polyline(fit);
if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
if pts.iter().any(|p| !in_range3(*p)) {
return Err(SketchError::Invalid("3D spline samples out of range".into()));
}
Ok(pts)
@@ -163,7 +163,7 @@ impl Sketch {
|| w.pts.len() > MAX_WIRE_POINTS
|| w.fit.len() > MAX_FIT_POINTS
|| (w.pts.len() == 1 && w.fit.len() != 1)
|| w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9))
|| w.pts.iter().chain(&w.fit).any(|p| !in_range3(*p))
{
return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into()));
}
@@ -241,7 +241,7 @@ impl Sketch {
/// Move a fit point of a drawn 3D curve, and every other drawn point at the same place
/// (curves drawn end to end stay joined). Returns how many points moved.
pub fn move_drawn_point(&mut self, wire: &str, index: usize, to: Vec3) -> Result<usize> {
if !(to.is_finite() && to.x.abs() < 1e9 && to.y.abs() < 1e9 && to.z.abs() < 1e9) {
if !in_range3(to) {
return Err(SketchError::Invalid("3D point out of range".into()));
}
let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?;
+2 -1
View File
@@ -193,7 +193,8 @@ pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
// A profile feature without picks takes the profiles of the active (else the last) sketch:
// point at them.
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
let ss =
app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()).map(|s| &**s));
if let Some(ss) = ss {
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
}
+5 -5
View File
@@ -69,9 +69,9 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? };
(at + n * (from + offset), Some(n))
}
Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
// Shell thickness grows into the body.
Kind::Shell { .. } => (at, normal.map(|n| -n)),
Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
// Fillet radii, chamfer distances and shell thickness grow into the body.
Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)),
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
Kind::Plastic(_) => (at, normal),
Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)),
@@ -98,8 +98,8 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_
Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
}
/// The outward direction between the two faces at an edge (where a fillet's radius arrow
/// points).
/// The outward direction between the two faces at an edge (a fillet's radius arrow points the
/// other way, into the body).
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
let faces = m.edge_faces.get(index)?;
let mut sum = Vec3::ZERO;
+1 -1
View File
@@ -1326,7 +1326,7 @@ fn track_hold(app: &mut SolveApp, ctx: &egui::Context) {
i.time,
)
});
let free = app.tool.is_none() && app.dialog.is_none();
let free = app.tool.is_none() && app.dialog.is_none() && app.preferences.nav != crate::prefs::OPENSCAD_NAV;
if pressed && free && app.viewport.context_menu.is_none() && app.viewport.mouse.is_some() {
app.menu.press = pos.map(|p| (p, time));
}
+54 -4
View File
@@ -2243,15 +2243,30 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
let id = egui::Id::new(("pexpr", &p.name));
let mut e = ui.data(|dd| dd.get_temp::<String>(id)).unwrap_or_else(|| p.expr.clone());
let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0));
let refused_id = egui::Id::new(("prefused", &p.name));
if r.changed() {
ui.data_mut(|dd| dd.insert_temp(id, e.clone()));
// Typing again clears the reason a previous change was refused.
ui.data_mut(|dd| {
dd.insert_temp(id, e.clone());
dd.remove::<String>(refused_id);
});
}
if r.lost_focus() && e != p.expr {
let _ = app.run("parameters.change", json!({"name": p.name, "expression": e}));
ui.data_mut(|dd| dd.remove::<String>(id));
match change_param(app, &p.name, &e) {
Ok(()) => ui.data_mut(|dd| {
dd.remove::<String>(id);
dd.remove::<String>(refused_id);
}),
// Refused (it would leave a sketch unsolved, say): the typed expression
// stays in the field with the reason beside it, nothing silently reverts.
Err(why) => ui.data_mut(|dd| {
dd.insert_temp(refused_id, why);
}),
}
}
let refused = ui.data(|dd| dd.get_temp::<String>(refused_id));
let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) });
match (v, errs.get(&p.name)) {
match (v, errs.get(&p.name).or(refused.as_ref())) {
(_, Some(e)) => ui.label(RichText::new(e).color(t.error)),
(Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)),
_ => ui.label(""),
@@ -2273,6 +2288,14 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
ui.end_row();
}
/// Set a parameter's expression from the parameters table: `Err` with the message to show
/// beside the field when the change is refused (the document keeps its values).
pub(crate) fn change_param(app: &mut SolveApp, name: &str, expression: &str) -> Result<(), String> {
app.run("parameters.change", json!({"name": name, "expression": expression}))
.map(|_| ())
.map_err(|e| crate::friendly_error("parameters.change", &e))
}
/// Start a sketch on a picked plane or planar face and turn the view to face it.
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
let plane = match sel {
@@ -3751,3 +3774,30 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.error = None;
Some(d)
}
#[cfg(test)]
mod param_refusal_tests {
use super::*;
use solvecraft_engine::Session;
#[test]
fn a_refused_parameter_change_shows_why() {
// A triangle whose sides are parameters: a side longer than the other two together
// has no solution, so the change is refused and the table shows the reason.
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY", "name": "Tri"})).unwrap();
s.execute("sketch.line", &json!({"points": [[0, 0], [40, 0], [20, 30]], "closed": true})).unwrap();
for l in ["l1", "l2", "l3"] {
s.execute("sketch.dimension", &json!({"entities": [l]})).unwrap();
}
s.execute("sketch.finish", &json!({})).unwrap();
let name = s.doc.params.iter().find(|p| p.model).map(|p| p.name.clone()).unwrap();
let before = s.doc.param(&name).map(|p| p.expr.clone());
let mut app = SolveApp::new(s, Default::default());
let why = change_param(&mut app, &name, "500 mm").unwrap_err();
assert!(why.contains("Tri") && why.contains("conflict"), "{why}");
assert_eq!(app.session.doc.param(&name).map(|p| p.expr.clone()), before);
assert!(app.status.as_ref().is_some_and(|(m, _, error)| *error && m.contains("Tri")), "{:?}", app.status);
assert!(change_param(&mut app, &name, "41 mm").is_ok());
}
}
+3 -2
View File
@@ -258,9 +258,10 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
}
/// Start dragging the text of the dimension under `press` (true: a dimension took the drag).
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool {
/// Its lines take it only off sketch geometry (`on_geometry`: a point or curve is under `press`).
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2, on_geometry: bool) -> bool {
let Some(d) = DRAWN.with(|d| {
let id = hit(press)?;
let id = hit_text(press).or_else(|| if on_geometry { None } else { hit(press) })?;
d.borrow().iter().find(|x| x.id == id).cloned()
}) else {
return false;
+7 -7
View File
@@ -7,7 +7,7 @@
use std::cell::{Cell, RefCell};
use egui::{Align2, FontId, Shape, Stroke};
use serde_json::json;
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec2;
use solvecraft_engine::sketch::{CurveKind, Sketch};
@@ -195,18 +195,18 @@ pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec<usize>, Vec<usize>) {
(own, ex)
}
/// The drag let go: the constraint of the snap it ended on (none for lining up).
pub fn release(app: &mut SolveApp) {
let Some(s) = current() else { return };
/// The drag let go: the constraint of the snap it ended on (none for lining up), as
/// `sketch.drag_end` takes it.
pub fn release() -> Option<Value> {
let s = current()?;
clear();
let (cmd, params) = match &s.target {
Target::Point(t) => ("sketch.constraint.coincident", json!({"a": s.point, "b": t})),
Target::Mid(l) => ("sketch.constraint.midpoint", json!({"point": s.point, "line": l})),
Target::On(c) => ("sketch.constraint.coincident", json!({"a": s.point, "b": c})),
Target::Align => return,
Target::Align => return None,
};
// A snap that would over-constrain the sketch is simply not kept.
let _ = app.session.execute(cmd, &params);
Some(json!({"command": cmd, "params": params}))
}
/// The snap of the drag in progress: its dashed guides, a ring on the target and its label.
+206 -3
View File
@@ -28,10 +28,19 @@ type Job = Result<Made, String>;
struct Running {
request: Request,
base: Arc<Document>,
base_revision: u64,
before: Vec<String>,
rx: Receiver<Job>,
phase: Arc<AtomicU8>,
cancelled: Arc<AtomicBool>,
preview_rx: Option<Receiver<egui::ColorImage>>,
preview: Option<egui::TextureHandle>,
}
impl Running {
fn matches(&self, session: &Session) -> bool {
Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision
}
}
#[derive(Default)]
@@ -82,24 +91,134 @@ fn start(app: &mut SolveApp) {
scratch.selection = app.session.selection.clone();
}
let base = app.session.doc.clone();
let base_revision = app.session.revision;
let phase = Arc::new(AtomicU8::new(0));
let worker_phase = phase.clone();
let cancelled = Arc::new(AtomicBool::new(false));
let worker_cancelled = cancelled.clone();
let (tx, rx) = std::sync::mpsc::channel();
let (preview_tx, preview_rx) = std::sync::mpsc::channel();
let path = request.path.clone();
let insert = request.insert;
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
let preview_began = std::time::Instant::now();
if !insert {
let cancelled = worker_cancelled.clone();
scratch.open_preview = Some(Box::new(move |preview| {
if !cancelled.load(Ordering::Relaxed) {
// Render on the file worker, never the window thread.
let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| saved_picture(preview))).ok().flatten();
if let Some(picture) = picture.filter(|_| !cancelled.load(Ordering::Relaxed)) {
let _ = preview_tx.send(picture);
if std::env::var_os("SOLVECRAFT_PROFILE_OPEN").is_some() {
eprintln!("SolidCraft saved preview: ready_ms={:.3}", preview_began.elapsed().as_secs_f64() * 1000.0);
}
}
}
}));
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, &path, insert, &worker_phase, &worker_cancelled)))
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
let _ = tx.send(result);
});
match spawned {
Ok(_) => app.file_load.running = Some(Running { request, base, before, rx, phase, cancelled }),
Ok(_) => {
app.file_load.running =
Some(Running { request, base, base_revision, before, rx, phase, cancelled, preview_rx: Some(preview_rx), preview: None })
}
Err(e) => app.set_status(format!("could not start file operation: {e}"), true),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn saved_picture(preview: solvecraft_engine::io::DesignPreview) -> Option<egui::ColorImage> {
use solvecraft_engine::{
geom::{Aabb3, Mesh, Vec3},
render::{Camera, Rgb, Scene, SceneMesh, StandardView},
};
let mut scene = Scene::default();
let mut bounds = Aabb3::EMPTY;
for cached in preview.meshes {
let mut mesh = Mesh {
positions: cached.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect(),
triangles: cached.triangles.as_chunks::<3>().0.to_vec(),
..Default::default()
};
mesh.normals.resize(mesh.positions.len(), Vec3::ZERO);
for triangle in &mesh.triangles {
let a = *mesh.positions.get(*triangle.first()? as usize)?;
let b = *mesh.positions.get(*triangle.get(1)? as usize)?;
let c = *mesh.positions.get(*triangle.get(2)? as usize)?;
let normal = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z);
for index in triangle {
if let Some(n) = mesh.normals.get_mut(*index as usize) {
*n += normal;
}
}
}
for normal in &mut mesh.normals {
*normal = normal.normalized().unwrap_or(Vec3::Z);
}
bounds = bounds.union(&mesh.bounds());
let [r, g, b] = cached.color.unwrap_or([0.69, 0.73, 0.78]).map(|c| (c * 255.0).round() as u8);
scene.meshes.push(SceneMesh { mesh: Arc::new(mesh), color: Rgb(r, g, b), face_colors: Vec::new() });
}
if bounds.is_empty() {
return None;
}
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
scene.radius = bounds.diagonal();
if !preview_work_fits(&scene, &camera) {
return None;
}
let canvas = solvecraft_engine::render::render(&scene, &camera, 320, 200);
Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba))
}
/// Bound raster work as well as file size: repeated full-screen triangles must
/// not monopolize the file worker. Match the rasterizer's 2x sampling rectangles.
#[cfg(not(target_arch = "wasm32"))]
fn preview_work_fits(scene: &solvecraft_engine::render::Scene, camera: &solvecraft_engine::render::Camera) -> bool {
let mut pixels = 0.0;
let projection = camera.view_proj(640.0 / 400.0, scene.radius.max(1.0));
for body in &scene.meshes {
let points: Vec<_> = body
.mesh
.positions
.iter()
.map(|p| {
let q = projection.apply(*p);
(q[3] > 1e-9).then(|| ((q[0] / q[3] + 1.0) * 320.0, (1.0 - q[1] / q[3]) * 200.0, q[2] / q[3]))
})
.collect();
for triangle in &body.mesh.triangles {
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
let mut visible = true;
for index in triangle {
if let Some(Some((x, y, _))) = points.get(*index as usize) {
lo = [lo[0].min(*x), lo[1].min(*y)];
hi = [hi[0].max(*x), hi[1].max(*y)];
} else {
visible = false;
break;
}
}
if visible {
let width = (hi[0].ceil().min(639.0) - lo[0].floor().max(0.0) + 1.0).max(0.0);
let height = (hi[1].ceil().min(399.0) - lo[1].floor().max(0.0) + 1.0).max(0.0);
pixels += width * height;
if !pixels.is_finite() || pixels > 16_000_000.0 {
return false;
}
}
}
}
true
}
#[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();
@@ -144,6 +263,15 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can
}
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(running) = &mut app.file_load.running {
if !running.cancelled.load(Ordering::Relaxed) && running.matches(&app.session) {
if let Some(image) = running.preview_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR));
}
} else {
running.preview = None;
}
}
let result = app.file_load.running.as_ref().and_then(|r| match r.rx.try_recv() {
Ok(job) => Some(job),
Err(TryRecvError::Empty) => None,
@@ -154,7 +282,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
{
match job {
_ if running.cancelled.load(Ordering::Relaxed) => {}
Ok(made) if !running.request.insert || Arc::ptr_eq(&app.session.doc, &running.base) => {
Ok(made) if running.matches(&app.session) => {
if running.request.insert {
app.session.accept_computed(made.session);
app.big_operation = true;
@@ -176,7 +304,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
false,
);
}
Ok(_) => app.set_status("the active design changed while importing; the import was discarded", true),
Ok(_) => app.set_status("the active design changed while loading; the file operation was discarded", true),
Err(e) => app.set_status(e, true),
}
}
@@ -205,6 +333,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
"Opening design"
});
ui.label(name.as_ref());
if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) {
ui.image((preview.id(), egui::vec2(320.0, 200.0)));
ui.label("Saved preview. Geometry is being checked before editing.");
}
ui.horizontal(|ui| {
ui.spinner();
ui.label(if cancelled {
@@ -233,6 +365,71 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opening_result_and_preview_cannot_overwrite_a_newer_design() {
for (insert, change_document) in [(false, true), (true, true), (false, false)] {
let mut app = SolveApp::new(Session::default(), Default::default());
let base = app.session.doc.clone();
let (tx, rx) = std::sync::mpsc::channel();
let (preview_tx, preview_rx) = std::sync::mpsc::channel();
preview_tx.send(egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4])).unwrap();
app.file_load.running = Some(Running {
request: Request { path: "old.solvecraft".into(), insert },
base,
base_revision: app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
cancelled: Arc::new(AtomicBool::new(false)),
preview_rx: Some(preview_rx),
preview: None,
});
if change_document {
app.session.execute("file.new", &serde_json::json!({"name":"Newer design"})).unwrap();
} else {
// An edit and undo can return to the original Arc; the generation still changed.
app.session.revision += 1;
}
let newer = app.session.doc.clone();
tick(&mut app, &egui::Context::default());
assert!(app.file_load.running.as_ref().unwrap().preview.is_none());
tx.send(Ok(Made { session: Session::default(), result: Value::Null, image: None })).unwrap();
tick(&mut app, &egui::Context::default());
assert!(Arc::ptr_eq(&newer, &app.session.doc));
assert!(app.status.as_ref().unwrap().0.contains("discarded"));
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn provisional_picture_renders_without_adopting_cached_meshes() {
let preview = solvecraft_engine::io::DesignPreview {
meshes: vec![solvecraft_engine::doc::MeshData {
name: "Saved triangle".into(),
positions: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 10.0, 0.0],
triangles: vec![0, 1, 2],
color: Some([0.8, 0.2, 0.1]),
}],
};
let picture = saved_picture(preview).unwrap();
assert_eq!(picture.size, [320, 200]);
assert!(picture.pixels.iter().any(|p| u16::from(p.r()) > u16::from(p.g()) * 2));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn repeated_large_preview_triangles_are_a_cache_miss_before_rasterization() {
let preview = solvecraft_engine::io::DesignPreview {
meshes: vec![solvecraft_engine::doc::MeshData {
name: "Hostile overdraw".into(),
positions: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 10.0, 0.0],
triangles: (0..10_000).flat_map(|_| [0, 1, 2]).collect(),
color: None,
}],
};
assert!(saved_picture(preview).is_none());
}
#[test]
fn failed_import_keeps_the_current_document() {
let mut app = SolveApp::new(Session::default(), Default::default());
@@ -242,10 +439,13 @@ mod tests {
app.file_load.running = Some(Running {
request: Request { path: "bad.step".into(), insert: true },
base: old.clone(),
base_revision: app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
cancelled: Arc::new(AtomicBool::new(false)),
preview_rx: None,
preview: None,
});
tick(&mut app, &egui::Context::default());
assert!(Arc::ptr_eq(&app.session.doc, &old));
@@ -261,10 +461,13 @@ mod tests {
app.file_load.running = Some(Running {
request: Request { path: "cancelled.step".into(), insert: true },
base: old.clone(),
base_revision: app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
cancelled,
preview_rx: None,
preview: None,
});
assert!(!app.file_load.busy());
assert!(app.file_load.running.is_some());
+15 -1
View File
@@ -93,6 +93,8 @@ pub(crate) mod sketch_display;
#[cfg(test)]
mod sketch_display_ui_tests;
#[cfg(test)]
mod sketch_drag_ui_tests;
#[cfg(test)]
mod sketch_edit_tests;
pub mod sketch_palette;
pub(crate) mod sketch_text;
@@ -237,6 +239,11 @@ pub struct SolveApp {
pub recoverable: Vec<solvecraft_engine::recovery::Entry>,
/// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`).
pub stand_up: Option<Vec<String>>,
/// The anti-aliasing sample counts the graphics device supports (empty without a GPU).
pub msaa_supported: Vec<u32>,
/// Settings put back at start because they kept SolidCraft from starting, one line each,
/// shown until dismissed (`prefs::show_reset_notice`).
pub settings_reset: Vec<prefs::SettingReset>,
/// The last command was a big operation: autosave after it.
pub big_operation: bool,
/// Autosave interval (minutes; 0 = only after big operations). A preference.
@@ -298,6 +305,8 @@ impl SolveApp {
autosave: None,
recoverable: Vec::new(),
stand_up: None,
msaa_supported: Vec::new(),
settings_reset: Vec::new(),
big_operation: false,
autosave_minutes: 5.0,
custom_titlebar: false,
@@ -333,6 +342,7 @@ impl SolveApp {
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples));
self.msaa_supported = rs.msaa_supported.clone();
let info = rs.adapter.get_info();
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
}
@@ -410,7 +420,10 @@ impl SolveApp {
if let Some(t) = v.get("toolbar") {
self.toolbar_custom.load(t);
}
if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
if let Some(mut p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
if !prefs::MSAA.contains(&p.msaa) {
p.msaa = prefs::Prefs::default().msaa;
}
self.preferences = p;
}
// Saved before the theme was a preference: the Dark/Light flag says which.
@@ -1002,6 +1015,7 @@ impl SolveApp {
params_dialog::show(self, ui.ctx());
recovery_ui::show(self, ui.ctx());
stand_up::show(self, ui.ctx());
prefs::show_reset_notice(self, ui.ctx());
context_menu::show(self, ui.ctx());
delete::show(self, ui.ctx());
if self.custom_titlebar {
+7
View File
@@ -11,6 +11,11 @@
"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.",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
"settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).",
"settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt",
"settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von SolidCraft verhindern. Sie wurden daher geändert:",
"settings.reset_title": "Einstellungen wurden geändert",
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
"sketch.draw_curves": "{linetype}-Kurven zeichnen",
"sketch.make_selection": "Auswahl als {linetype} markieren",
@@ -27,6 +32,7 @@
"ui.animate_view_changes": "Ansichtsänderungen animieren",
"ui.animation": "Animation",
"ui.animation_speed": "Animationsgeschwindigkeit",
"ui.anti_aliasing": "Kantenglättung",
"ui.apply": "Anwenden",
"ui.arc": "Kreisbogen",
"ui.assemble": "Zusammenbau",
@@ -199,6 +205,7 @@
"ui.one_side": "Einseitig",
"ui.open": "Öffnen",
"ui.open_ellipsis": "Öffnen...",
"ui.open_preferences": "Einstellungen öffnen",
"ui.open_recent": "Zuletzt verwendet",
"ui.operation": "Operation",
"ui.options": "Optionen",
+7
View File
@@ -11,6 +11,11 @@
"dialog.selected_count": "↖ {count} selected",
"document.save_changes": "Save changes to {name}?",
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
"settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).",
"settings.msaa_unsupported": "Not supported by this graphics device",
"settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:",
"settings.reset_title": "Settings were changed",
"sketch.degrees_of_freedom": "{count} degrees of freedom",
"sketch.draw_curves": "Draw {linetype} curves",
"sketch.make_selection": "Make the selection {linetype}",
@@ -27,6 +32,7 @@
"ui.animate_view_changes": "Animate view changes",
"ui.animation": "Animation",
"ui.animation_speed": "Animation speed",
"ui.anti_aliasing": "Anti-aliasing",
"ui.apply": "Apply",
"ui.arc": "Arc",
"ui.assemble": "Assemble",
@@ -199,6 +205,7 @@
"ui.one_side": "One Side",
"ui.open": "Open",
"ui.open_ellipsis": "Open...",
"ui.open_preferences": "Open Preferences",
"ui.open_recent": "Open Recent",
"ui.operation": "Operation",
"ui.options": "Options",
+7
View File
@@ -11,6 +11,11 @@
"dialog.selected_count": "↖ {count} 個選択",
"document.save_changes": "{name} への変更を保存しますか?",
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
"settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。",
"settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません",
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:",
"settings.reset_title": "設定が変更されました",
"sketch.degrees_of_freedom": "自由度 {count}",
"sketch.draw_curves": "{linetype}の曲線を描く",
"sketch.make_selection": "選択を{linetype}にする",
@@ -27,6 +32,7 @@
"ui.animate_view_changes": "視点変更をアニメーション表示",
"ui.animation": "アニメーション",
"ui.animation_speed": "アニメーション速度",
"ui.anti_aliasing": "アンチエイリアス",
"ui.apply": "適用",
"ui.arc": "円弧",
"ui.assemble": "アセンブリ",
@@ -199,6 +205,7 @@
"ui.one_side": "片側",
"ui.open": "開く",
"ui.open_ellipsis": "開く...",
"ui.open_preferences": "環境設定を開く",
"ui.open_recent": "最近使った項目を開く",
"ui.operation": "操作",
"ui.options": "オプション",
+7
View File
@@ -11,6 +11,11 @@
"dialog.selected_count": "↖ {count}개 선택됨",
"document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?",
"document.unsaved_changes": "{name}에 저장되지 않은 변경 사항이 있습니다. 저장하지 않으면 변경 사항이 사라집니다.",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
"settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).",
"settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음",
"settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 SolidCraft를 시작할 수 없게 하므로 변경되었습니다:",
"settings.reset_title": "설정이 변경되었습니다",
"sketch.degrees_of_freedom": "자유도 {count}개",
"sketch.draw_curves": "{linetype} 곡선 그리기",
"sketch.make_selection": "선택 항목을 {linetype}(으)로 변경",
@@ -27,6 +32,7 @@
"ui.animate_view_changes": "뷰 변경 애니메이션",
"ui.animation": "애니메이션",
"ui.animation_speed": "애니메이션 속도",
"ui.anti_aliasing": "안티앨리어싱",
"ui.apply": "적용",
"ui.arc": "호",
"ui.assemble": "조립",
@@ -199,6 +205,7 @@
"ui.one_side": "한 방향",
"ui.open": "열기",
"ui.open_ellipsis": "열기...",
"ui.open_preferences": "환경 설정 열기",
"ui.open_recent": "최근 항목 열기",
"ui.operation": "작업",
"ui.options": "옵션",
+168 -8
View File
@@ -18,6 +18,7 @@ use crate::theme::Tokens;
use crate::viewport::NavMode;
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
pub const OPENSCAD_NAV: &str = "openscad";
/// A mouse button that can drag the camera.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavButton {
@@ -81,7 +82,7 @@ use NavButton::{Left, Middle, Right};
use NavMode::{Orbit, Pan, Zoom};
/// The mouse mappings. The first is the default.
pub const NAV_PRESETS: [NavPreset; 5] = [
pub const NAV_PRESETS: [NavPreset; 6] = [
NavPreset {
id: "fusion",
label: "Fusion",
@@ -134,6 +135,19 @@ pub const NAV_PRESETS: [NavPreset; 5] = [
reverse_zoom: false,
bindings: &[bind(Right, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Orbit), bind(Middle, NavKeys::NONE, Pan)],
},
NavPreset {
id: OPENSCAD_NAV,
label: "OpenSCAD",
help: "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms",
reverse_zoom: false,
bindings: &[
bind(Left, NavKeys::NONE, Orbit),
bind(Right, NavKeys::SHIFT, Zoom),
bind(Middle, NavKeys::SHIFT, Zoom),
bind(Right, NavKeys::NONE, Pan),
bind(Middle, NavKeys::NONE, Pan),
],
},
];
/// The preset with this id (the default for an unknown one).
@@ -144,6 +158,7 @@ pub fn nav_preset(id: &str) -> &'static NavPreset {
pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"];
pub const PAGE_SHORTCUTS: usize = 2;
pub const PAGE_DESIGN: usize = 3;
pub const PAGE_GRAPHICS: usize = 4;
pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")];
pub const MSAA: [u32; 4] = [1, 2, 4, 8];
@@ -318,7 +333,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.up_axis = d.up_axis;
p.offer_stand_up = d.offer_stand_up;
}
4 => {
PAGE_GRAPHICS => {
let msaa = p.msaa != d.msaa;
p.msaa = d.msaa;
app.ui.visual_style = 0;
@@ -506,6 +521,77 @@ fn save_graphics(app: &SolveApp) {
}
}
/// At start, with the graphics device known: the saved anti-aliasing (`requested` samples, as
/// the host read it) is more than the device supports. The window would not have opened with it,
/// so the renderer fell back; put the setting back to its default (or the largest supported
/// count below it) and tell the user what changed.
pub fn check_msaa(app: &mut SolveApp, requested: u32) {
let supported = &app.msaa_supported;
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
return;
}
let default = Prefs::default().msaa;
let reset = supported.iter().copied().filter(|&n| n <= default).max().unwrap_or(1);
let supported = supported.clone();
app.preferences.msaa = reset;
save_graphics(app);
app.settings_reset.push(SettingReset::Msaa { requested, reset, supported });
}
/// A setting put back at start because it would keep SolidCraft from starting.
#[derive(Clone, Debug, PartialEq)]
pub enum SettingReset {
/// Anti-aliasing samples the graphics device can't render with.
Msaa { requested: u32, reset: u32, supported: Vec<u32> },
}
impl SettingReset {
/// The line the notice shows, in the current interface language.
pub fn message(&self) -> String {
match self {
SettingReset::Msaa { requested, reset, supported } => {
let setting = format!("{} › {}", crate::i18n::tr("ui.graphics"), crate::i18n::tr("ui.anti_aliasing"));
let supported = supported.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join("×, ") + "×";
crate::i18n::format(
"settings.msaa_reset",
&[("setting", &setting), ("requested", &requested.to_string()), ("reset", &reset.to_string()), ("supported", &supported)],
)
}
}
}
}
/// Settings put back at start (`SolveApp::settings_reset`), until dismissed.
pub fn show_reset_notice(app: &mut SolveApp, ctx: &egui::Context) {
if app.settings_reset.is_empty() {
return;
}
let mut ok = false;
let mut prefs = false;
crate::frame::window(ctx, app.tr("settings.reset_title"), crate::frame::Width::Normal)
.id(egui::Id::new("sc_settings_reset"))
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.label(app.tr("settings.reset_intro"));
ui.add_space(4.0);
for reset in &app.settings_reset {
ui.label(format!("• {}", reset.message()));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ok = ui.button(app.tr("ui.heading_ok")).clicked();
prefs = ui.button(app.tr("ui.open_preferences")).clicked();
});
});
if ok || prefs {
app.settings_reset.clear();
}
if prefs {
open_at(app, PAGE_GRAPHICS);
}
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page.
if app.keymap.open {
@@ -632,7 +718,6 @@ const PREFS_NAV_W: f32 = 128.0;
/// One page of the dialog.
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
let _language = crate::i18n::scope(&app.preferences.language);
let note = |ui: &mut egui::Ui, s: &str| {
ui.label("");
// Descriptions wrap within the page instead of widening it.
@@ -714,7 +799,8 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
ui.end_row();
note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_preset(&app.preferences.nav).help));
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
note(ui, &format!("{}{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help));
ui.label("Context menu");
ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu");
ui.end_row();
@@ -786,14 +872,18 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
ui.label("Perspective view");
ui.checkbox(&mut app.ui.perspective, "");
ui.end_row();
ui.label("Anti-aliasing");
ui.label(app.tr("ui.anti_aliasing"));
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
for m in MSAA {
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
// Counts the graphics device can't do would keep the window from opening.
let ok = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
ui.add_enabled_ui(ok, |ui| ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×")))
.inner
.on_disabled_hover_text(app.tr("settings.msaa_unsupported"));
}
});
ui.end_row();
note(ui, "Anti-aliasing applies the next time SolidCraft starts");
note(ui, app.tr("settings.msaa_applies_on_restart"));
ui.label("Renderer");
ui.label(match (&app.viewport.gpu, &app.help.gpu) {
(Some(_), Some(g)) => format!("GPU: {g}"),
@@ -871,7 +961,10 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
return Err(format!("units must be one of {}", UNITS.join(", ")));
}
if !NAV_PRESETS.iter().any(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) {
return Err("`nav` must be fusion, solidworks, inventor, alias or tinkercad and `msaa` 1, 2, 4 or 8".into());
return Err("`nav` must be fusion, solidworks, inventor, alias, tinkercad or openscad and `msaa` 1, 2, 4 or 8".into());
}
if new.msaa != app.preferences.msaa && !app.msaa_supported.is_empty() && !app.msaa_supported.contains(&new.msaa) {
return Err(format!("this graphics device doesn't support `msaa` {}; it supports {:?}", new.msaa, app.msaa_supported));
}
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
@@ -901,6 +994,59 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
mod tests {
use super::*;
#[test]
fn unsupported_saved_msaa_is_reset_to_the_default_with_a_notice() {
let dir = std::env::temp_dir().join(format!("sc_msaa_reset_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("graphics.json");
let services = crate::Services { graphics_path: Some(path.to_string_lossy().to_string()), ..Default::default() };
let mut app = SolveApp::new(solvecraft_engine::Session::default(), services);
// WebGPU's guarantee for the surface format: 8x saved, the device can only do 1x and 4x.
app.msaa_supported = vec![1, 4];
app.preferences.msaa = 8;
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 4);
let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default();
assert_eq!(saved["msaa"], 4);
assert_eq!(app.settings_reset, [SettingReset::Msaa { requested: 8, reset: 4, supported: vec![1, 4] }]);
assert_eq!(
app.settings_reset[0].message(),
"Graphics › Anti-aliasing: 8× isn't supported by this graphics device; it is now 4× (supported: 1×, 4×)."
);
{
let _de = crate::i18n::scope("de");
assert_eq!(
app.settings_reset[0].message(),
"Grafik › Kantenglättung: 8× wird von diesem Grafikgerät nicht unterstützt; jetzt 4× (unterstützt: 1×, 4×)."
);
}
// Below the default when the device can't do that either.
app.settings_reset.clear();
app.msaa_supported = vec![1, 2];
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 2);
// Supported, off, or no GPU: nothing changes (OpenGL's 1x policy isn't a reset either).
for (supported, requested) in [(vec![1, 2, 4, 8], 8), (vec![1, 4], 1), (vec![], 8), (vec![1, 4], 4)] {
app.settings_reset.clear();
app.preferences.msaa = requested;
app.msaa_supported = supported;
check_msaa(&mut app, requested);
assert!(app.settings_reset.is_empty());
assert_eq!(app.preferences.msaa, requested);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn control_channel_rejects_msaa_the_device_cannot_do_and_bad_saved_msaa_loads_as_default() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.msaa_supported = vec![1, 4];
assert!(set(&mut app, &serde_json::json!({ "msaa": 8 })).is_err());
assert!(set(&mut app, &serde_json::json!({ "msaa": 1 })).is_ok());
app.load_prefs(&serde_json::json!({ "preferences": { "msaa": 16 } }).to_string());
assert_eq!(app.preferences.msaa, Prefs::default().msaa);
}
#[test]
fn length_fields_read_bare_numbers_in_design_units_and_honour_explicit_units() {
let inch = unit_scale("in").0;
@@ -959,6 +1105,20 @@ mod tests {
assert!(!app.preferences.smooth_wheel_zoom);
}
#[test]
fn openscad_preset_maps_navigation_and_persists() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
set(&mut app, &json!({"nav": OPENSCAD_NAV})).unwrap();
for button in [NavButton::Middle, NavButton::Right] {
assert_eq!(nav_mode(&app, button, Modifiers::default()), Some(NavMode::Pan));
assert_eq!(nav_mode(&app, button, Modifiers { shift: true, ..Default::default() }), Some(NavMode::Zoom));
}
assert_eq!(nav_mode(&app, NavButton::Left, Modifiers::default()), Some(NavMode::Orbit));
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.nav, OPENSCAD_NAV);
}
#[test]
fn presets_map_the_mouse_and_prefs_round_trip() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
+13
View File
@@ -470,6 +470,19 @@ fn a_hole_is_mirrored_as_a_feature() {
assert_eq!(cmds[0].1["features"], json!(["Hole1"]));
}
/// The radius arrow points into the body: dragging toward the material grows the round.
#[test]
fn fillet_and_chamfer_arrows_point_into_the_body() {
for id in ["solid.fillet", "solid.chamfer"] {
let mut app = boxed();
let e = edge(&app, Vec3::new(20.0, 0.0, 20.0));
let d = start(&mut app, vec![e], id);
let (_, dir) = crate::canvas::anchor(&app, &d).unwrap();
let dir = dir.unwrap();
assert!(dir.y > 0.5 && dir.z < -0.5, "{id}: {dir:?}");
}
}
#[test]
fn chamfer_types_send_their_values() {
let mut app = boxed();
+10 -7
View File
@@ -114,23 +114,26 @@ pub fn region_triangles(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32)
display_region(region, plane, proj, ppp).map_or_else(Vec::new, |display| fill_triangles(&display, &mut 65_536))
}
/// Bound the slab triangulator's allocation before calling it. Every slab can emit
/// at most one triangle per edge; complex fills are omitted rather than cut into holes.
/// Bound the slab triangulator's allocation before calling it. Every slab emits one
/// triangle per edge crossing it; complex fills are omitted rather than cut into holes.
pub fn fill_triangles(display: &Region2, budget: &mut usize) -> Vec<[Vec2; 3]> {
let mut heights = Vec::new();
let mut edges = 0usize;
let mut spans = Vec::new();
for loop_ in std::iter::once(&display.outer).chain(&display.holes) {
for segment in &loop_.segs {
let (a, b) = (segment.start(), segment.end());
if (a.y - b.y).abs() > 1e-12 {
edges += 1;
heights.extend([a.y, b.y]);
spans.push((a.y.min(b.y), a.y.max(b.y)));
}
}
}
let mut heights: Vec<f64> = spans.iter().flat_map(|&(lo, hi)| [lo, hi]).collect();
heights.sort_by(f64::total_cmp);
heights.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
if edges.saturating_mul(heights.len().saturating_sub(1)) > *budget {
// An edge crosses the slabs between its end heights. Counting every edge in every slab
// overestimated a zoomed-in ring a hundredfold and dropped its hover fill (#164).
let slab = |y: f64| heights.partition_point(|h| *h < y - 1e-12);
let crossings = spans.iter().fold(0usize, |n, &(lo, hi)| n.saturating_add(slab(hi).saturating_sub(slab(lo))));
if crossings > *budget {
return Vec::new();
}
let triangles = display.triangulate(1.0);
@@ -289,3 +289,37 @@ fn pixel_frame_budget_does_not_cache_unbounded_empty_curve_results() {
assert!(h.app.viewport.display.lock().unwrap().len() <= 4096);
assert_eq!(h.app.session.doc.to_json(), baseline);
}
/// A zoomed-in ring is sampled finely, but each slab of the fill still crosses only its
/// four boundary edges: its fill must fit the hover highlight's budget (#164).
#[test]
fn zoomed_in_ring_fill_fits_the_hover_highlight_budget() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.ui.perspective = true;
h.app.run("sketch.create", json!({"plane":"XY","name":"Ring"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":40})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":45})).unwrap();
h.settle();
h.step(&json!({"call":"ui.view","params":{"view":"iso"}})).unwrap();
h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap();
h.app.cam.zoom_at(0.15, Some(Vec3::new(42.5, 0.0, 0.0)));
h.settle();
let baseline = h.app.session.doc.to_json();
let sid = h.app.session.active_sketch.unwrap();
let state = h.app.session.world_state();
let ss = state.sketch(sid).unwrap();
let proj = projection(&h.app, h.app.viewport.rect.unwrap());
let ppp = h.app.viewport.pixels_per_point();
let ring = ss.profiles.iter().find(|p| p.region.holes.len() == 1).unwrap();
let display = crate::sketch_display::display_region(&ring.region, &ss.plane, &proj, ppp).unwrap();
let edges = std::iter::once(&display.outer).chain(&display.holes).map(|l| l.segs.len()).sum::<usize>();
assert!(edges * edges / 2 > 65_536, "fixture must sample the ring finely enough to exceed the old estimate: {edges} edges");
let triangles = crate::sketch_display::region_triangles(&ring.region, &ss.plane, &proj, ppp);
assert!(!triangles.is_empty(), "the ring's fill was dropped at this zoom");
for t in &triangles {
let r = ((t[0] + t[1] + t[2]) / 3.0).len();
assert!((39.9..=45.1).contains(&r), "fill triangle outside the ring: r = {r}");
}
assert_eq!(h.app.session.doc.to_json(), baseline);
}
+107
View File
@@ -0,0 +1,107 @@
//! Dragging sketch geometry in the viewport: live frames re-solve the sketch only, letting go
//! brings the model up to date in the drag's one undo step, and the grab stays on the geometry.
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec2;
use crate::scenario::Harness;
fn run(h: &mut Harness, id: &str, p: Value) {
if let Err(e) = h.app.run(id, p.clone()) {
panic!("{id} {p}: {e}");
}
}
fn screen(h: &mut Harness, x: f64, y: f64) -> egui::Pos2 {
let [sx, sy] = h.at(&json!({"sketch": [x, y]})).expect("on screen");
egui::pos2(sx as f32, sy as f32)
}
fn point(h: &Harness, id: &str) -> Vec2 {
let sid = h.app.session.active_sketch.expect("sketch mode");
let st = h.app.session.world_state();
let sk = &st.sketch(sid).expect("the sketch").sketch;
sk.resolve_point(id).and_then(|i| sk.point(i)).unwrap_or_else(|| panic!("no point {id}"))
}
/// Press at `path[0]` and move through the rest, one frame per event.
fn press_and_move(h: &mut Harness, path: &[egui::Pos2]) {
let Some(&a) = path.first() else { return };
h.app.synthetic.push(egui::Event::PointerMoved(a));
h.app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.app.synthetic.extend(path.iter().skip(1).map(|&p| egui::Event::PointerMoved(p)));
play(h);
}
fn release(h: &mut Harness, at: egui::Pos2) {
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
play(h);
}
fn play(h: &mut Harness) {
while !h.app.synthetic.is_empty() {
h.frame();
}
h.frames(2);
}
/// Points from `a` to `b` in `n` equal moves (without `a`).
fn steps(a: egui::Pos2, b: egui::Pos2, n: usize) -> Vec<egui::Pos2> {
(1..=n).map(|k| a + (b - a) * (k as f32 / n as f32)).collect()
}
#[test]
fn a_viewport_drag_solves_the_sketch_live_and_rebuilds_the_model_on_release() {
let mut h = Harness::new();
run(&mut h, "sketch.create", json!({"plane": "XY"}));
run(&mut h, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
run(&mut h, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
run(&mut h, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
run(&mut h, "sketch.finish", json!({}));
run(&mut h, "solid.extrude", json!({"distance": 5}));
let sketch = h.app.session.doc.features.first().map(|f| f.id).expect("the sketch");
h.app.edit_sketch(sketch);
h.settle();
let undo = h.app.session.undo.len();
let a = screen(&mut h, 5.0, 5.0);
let b = screen(&mut h, -3.0, -6.0);
let mut path = vec![a];
path.extend(steps(a, b, 8));
press_and_move(&mut h, &path);
assert!(h.app.viewport.point_drag.is_some(), "the drag is under way");
assert!(h.app.session.model.is_stale(), "the frames updated the sketch only");
assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1, "{:?}", point(&h, "l1.start"));
release(&mut h, b);
assert!(h.app.viewport.point_drag.is_none());
assert!(!h.app.session.model.is_stale(), "letting go evaluated the model");
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step for the drag");
assert!(point(&h, "l1.start").dist(Vec2::new(-3.0, -6.0)) < 0.1);
run(&mut h, "edit.undo", json!({}));
assert!(point(&h, "l1.start").dist(Vec2::new(5.0, 5.0)) < 1e-9);
}
#[test]
fn a_dragged_line_that_stops_short_stays_under_the_cursor_when_it_can_follow_again() {
let mut h = Harness::new();
run(&mut h, "sketch.create", json!({"plane": "XY"}));
// Two 10 mm links from the origin hold the start of a horizontal 10 mm line: the line can
// slide right until its start is 20 mm from the origin (3 mm).
let (x, y) = (15.0, 75f64.sqrt());
run(&mut h, "sketch.line", json!({"points": [[0, 0], [10, 0], [x, y], [x + 10.0, y]]}));
for l in ["l1", "l2", "l3"] {
run(&mut h, "sketch.dimension", json!({"entities": [l], "value": 10}));
}
run(&mut h, "sketch.constraint.horizontal_vertical", json!({"line": "l3"}));
let grab = screen(&mut h, x + 5.0, y);
let far = screen(&mut h, x + 11.0, y);
let back = screen(&mut h, x + 7.0, y);
let mut path = vec![grab];
path.extend(steps(grab, far, 8));
path.extend(steps(far, back, 4));
press_and_move(&mut h, &path);
release(&mut h, back);
// The cursor went 6 mm right, the line only 3; back at 2 mm right it is under it again.
let start = point(&h, "l3.start");
assert!(start.dist(Vec2::new(x + 2.0, y)) < 0.1, "{start:?}");
assert!(!h.app.session.model.is_stale());
}
+141 -17
View File
@@ -24,7 +24,8 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
}
}
/// How many commands of a panel get a big button.
/// How many commands of a panel get a button on a fresh (or reset) toolbar. The user's own
/// layout (`toolbar_custom`) has no cap: every pinned command gets a button.
pub fn promoted(tab: &str, panel: &str) -> usize {
match (tab, panel) {
("SOLID", "CREATE") => 4,
@@ -36,6 +37,78 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
}
}
/// How a panel is drawn on a toolbar that may be too narrow for every button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fit {
/// Every button of the panel.
Full,
/// One button that opens the panel's drop-down (which lists every command, pinned or not).
Collapsed,
/// In the » menu at the end of the toolbar.
More,
}
/// A panel's width with `buttons` buttons.
pub fn panel_width(buttons: usize) -> f32 {
(buttons.max(1) as f32 * 40.0).max(64.0) + 8.0
}
/// A collapsed panel's width.
pub const COLLAPSED_WIDTH: f32 = 72.0;
/// The gap after each panel (its separator).
const PANEL_GAP: f32 = 2.0;
/// Fit panels of these full widths into `room`: all of them in full when they fit; otherwise
/// panels collapse into their drop-down from the right, then (all collapsed and still too wide)
/// the last ones go into the » menu. Nothing is dropped.
pub fn fit(widths: &[f32], room: f32) -> Vec<Fit> {
let mut fits = vec![Fit::Full; widths.len()];
let total = |fits: &[Fit]| -> f32 {
widths
.iter()
.zip(fits)
.map(|(w, f)| match f {
Fit::Full => w + PANEL_GAP,
Fit::Collapsed => COLLAPSED_WIDTH.min(*w) + PANEL_GAP,
Fit::More => 0.0,
})
.sum()
};
for i in (0..widths.len()).rev() {
if total(&fits) <= room {
return fits;
}
if let Some(f) = fits.get_mut(i) {
*f = Fit::Collapsed;
}
}
for i in (0..widths.len()).rev() {
if total(&fits) <= room {
return fits;
}
if let Some(f) = fits.get_mut(i) {
*f = Fit::More;
}
}
fits
}
/// The commands a panel's drop-down (or its » submenu) lists: its own, then any button moved
/// into it from another panel.
fn menu_commands(
cmds: &[&'static solvecraft_engine::CommandSpec],
shown: &[&'static solvecraft_engine::CommandSpec],
) -> Vec<&'static solvecraft_engine::CommandSpec> {
let mut out = cmds.to_vec();
for c in shown {
if !out.iter().any(|o| o.id == c.id) {
out.push(*c);
}
}
out
}
fn report_rect(bar: Rect, captions: f32, tabs_start: f32) -> (Rect, bool) {
let right = bar.right() - captions - 90.0;
let labeled = right - tabs_start >= 340.0;
@@ -468,29 +541,61 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
// The open drop-down (a button dropped on it leaves the toolbar).
let mut popup_rect: Option<Rect> = None;
let mut overflow = Vec::new();
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
// Every panel with all its buttons (the user's pins have no cap); a toolbar too narrow
// for them collapses panels into their drop-down, then into the » menu.
let lists: Vec<_> = crate::toolbar_custom::panel_lists(&tab)
.into_iter()
.map(|(panel, cmds, promote)| {
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
(panel, cmds, shown)
})
.collect();
let widths: Vec<f32> = lists.iter().map(|(_, _, shown)| panel_width(shown.len())).collect();
let fits = fit(&widths, r.right() - 40.0 - px);
crate::scenario::publish_count("toolbar_collapsed", fits.iter().filter(|f| **f == Fit::Collapsed).count() as f64);
crate::scenario::publish_count("toolbar_more", fits.iter().filter(|f| **f == Fit::More).count() as f64);
for ((panel, cmds, shown), fit) in lists.into_iter().zip(fits) {
let panel: &&str = &panel;
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
let n = shown.len().max(1);
let width = (n as f32 * 40.0).max(64.0) + 8.0;
if !overflow.is_empty() || px + width > r.right() - 40.0 {
overflow.push((*panel, cmds));
if fit == Fit::More {
overflow.push((*panel, menu_commands(&cmds, &shown)));
continue;
}
let collapsed = fit == Fit::Collapsed;
let width = if collapsed { COLLAPSED_WIDTH.min(panel_width(shown.len())) } else { panel_width(shown.len()) };
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
// Dropped on the panel's free space: the button goes to its end.
let free = Rect::from_min_size(
pos2(px + 4.0 + shown.len() as f32 * 40.0, top + 2.0),
vec2((width - 8.0 - shown.len() as f32 * 40.0).max(8.0), 38.0),
);
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), Sense::hover());
let popup_id = ui.id().with(("panel_menu", panel));
// Dropped on the panel's free space (all of a collapsed panel): the button goes to
// its end.
let drawn = if collapsed { 0 } else { shown.len() };
let free = Rect::from_min_size(pos2(px + 4.0 + drawn as f32 * 40.0, top + 2.0), vec2((width - 8.0 - drawn as f32 * 40.0).max(8.0), 38.0));
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), if collapsed { Sense::click() } else { Sense::hover() });
if fresp.dnd_hover_payload::<ToolbarDrag>().is_some() {
painter.line_segment([pos2(free.left(), free.top() + 2.0), pos2(free.left(), free.bottom() - 2.0)], Stroke::new(2.0, t.accent));
}
if let Some(d) = fresp.dnd_release_payload::<ToolbarDrag>() {
drop = Some((d.id, *panel, None));
}
for (i, c) in shown.iter().enumerate() {
let mut opened = false;
if collapsed {
// One button: the panel's first command's icon over a caret; it opens the
// drop-down, which lists every command of the panel.
let br = Rect::from_center_size(free.center(), vec2(38.0, 38.0));
crate::scenario::publish_handle(&format!("collapsed:{tab}:{panel}"), br.center());
if fresp.hovered() && enabled_panel {
painter.rect_filled(br, 4.0, t.hover);
}
if let Some(c) = shown.first().or(cmds.first()) {
let acc = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc);
}
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
let tip = format!("{} ({})", app.tr_label(panel), shown.len().max(cmds.len()));
if fresp.on_hover_text(tip).clicked() && enabled_panel {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id));
opened = true;
}
}
for (i, c) in shown.iter().enumerate().take(drawn) {
let br = Rect::from_min_size(pos2(px + 4.0 + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0));
let info = c.info(&app.session);
let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click_and_drag());
@@ -546,7 +651,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
app.start(id);
}
}
if shown.is_empty() {
if shown.is_empty() && !collapsed {
let br = Rect::from_min_size(pos2(px + 4.0, top + 2.0), vec2(width - 8.0, 38.0));
painter.text(br.center(), Align2::CENTER_CENTER, "—", FontId::proportional(14.0), t.border);
}
@@ -571,12 +676,12 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let gw = g.size().x;
painter.galley(pos2(gx, gy), g, lc);
caret(&painter, pos2(gx + gw + 6.0, lr.center().y), lc);
let popup_id = ui.id().with(("panel_menu", panel));
if lresp.clicked() {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id));
}
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
if open {
let cmds = if collapsed { menu_commands(&cmds, &shown) } else { cmds.clone() };
let area = crate::frame::area(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
@@ -625,7 +730,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::scenario::publish_rect("toolbar_dropdown", area.response.rect);
// A drag out of the drop-down ends with a release elsewhere: not a click elsewhere.
let dragging = egui::DragAndDrop::has_payload_of_type::<ToolbarDrag>(ui.ctx());
if (area.response.clicked_elsewhere() && !lresp.clicked() && !dragging) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if (area.response.clicked_elsewhere() && !lresp.clicked() && !opened && !dragging) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
}
}
@@ -952,6 +1057,25 @@ mod header_report_tests {
assert!(!solvecraft_engine::io::is_design_container(&std::fs::read(saved).unwrap()), "subsequent Save keeps a .json choice");
}
#[test]
fn panels_fit_collapse_then_move_to_more_and_none_is_dropped() {
// A panel with 14 buttons and three default ones.
let widths = [panel_width(14), panel_width(4), panel_width(2), panel_width(2)];
let full: f32 = widths.iter().map(|w| w + 2.0).sum();
assert_eq!(fit(&widths, full), vec![Fit::Full; 4]);
// A little short: the last panel collapses first.
assert_eq!(fit(&widths, full - 10.0), vec![Fit::Full, Fit::Full, Fit::Full, Fit::Collapsed]);
// Every panel collapsed fits.
let collapsed = 4.0 * (COLLAPSED_WIDTH + 2.0);
assert_eq!(fit(&widths, collapsed), vec![Fit::Collapsed; 4]);
// Narrower: the last ones go into the » menu; then all of them.
assert_eq!(fit(&widths, collapsed - 1.0), vec![Fit::Collapsed, Fit::Collapsed, Fit::Collapsed, Fit::More]);
assert_eq!(fit(&widths, 0.0), vec![Fit::More; 4]);
assert_eq!(fit(&widths, f32::NAN).len(), 4);
// No cap: 40 px a button however many there are.
assert_eq!(panel_width(30) - panel_width(20), 400.0);
}
fn captured_frame(h: &mut crate::scenario::Harness) {
let token = h.app.shot_token;
crate::issue_report::receive(&mut h.app, token, std::sync::Arc::new(egui::ColorImage::new([2, 2], vec![Color32::GREEN; 4])));
+167 -19
View File
@@ -1622,7 +1622,20 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let
// go (kept through the release frame, so the end of the drag doesn't select either).
if primary_pressed && inside {
app.viewport.primary_nav = crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods);
let openscad_orbit = app.preferences.nav == crate::prefs::OPENSCAD_NAV
&& app.tool.is_none()
&& app.dialog.is_none()
&& app.viewport.context_menu.is_none()
&& !mods.shift
&& hover.is_some_and(|p| {
let proj = projection(app, rect);
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
});
app.viewport.primary_nav = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods)
} else {
None
};
} else if !primary_down && !primary_released {
app.viewport.primary_nav = None;
}
@@ -1651,7 +1664,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
// Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt),
// rather than an additional request for snapping. Only an extra modifier snaps.
let without_shift = egui::Modifiers { shift: false, ..mods };
let snap = mods.shift
let snap = app.preferences.nav != crate::prefs::OPENSCAD_NAV
&& mods.shift
&& match button {
Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit),
None => app.viewport.nav.is_some(),
@@ -1723,6 +1737,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|| middle
|| secondary_down
|| (primary_nav.is_some() && primary_down)
|| mode.is_some()
|| app.viewport.axis_drag.is_some()
|| app.viewport.spin
|| app.viewport.orbit_snap.is_some()
@@ -1881,19 +1896,25 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
// Dragging a sketch point of the active sketch moves it; the solver keeps the constraints.
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && app.session.active_sketch.is_some() {
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() {
let on_plane = |app: &SolveApp, p: Pos2| {
let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?;
let (o, d) = proj.ray(p);
ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w))
};
// The 3D triad, then a dimension's text, follow a drag that starts on them.
if resp.drag_started_by(egui::PointerButton::Primary)
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
&& !crate::sketch3d::drag_start(app, &proj, a)
&& let Some(q) = on_plane(app, a)
// What a drag starts on: the 3D triad, then a dimension's text, then sketch geometry
// (before the dimension's lines, which end on the geometry they measure), as for clicks.
let grabbed = resp
.drag_started_by(egui::PointerButton::Primary)
.then(|| ui.input(|i| i.pointer.press_origin()))
.flatten()
.filter(|a| !crate::sketch3d::drag_start(app, &proj, *a))
.map(|a| (a, pick(app, &proj, a).into_iter().next()));
if let Some((a, hit)) = &grabbed
&& let Some(q) = on_plane(app, *a)
{
crate::dim_view::drag_start(app, a, q);
let on_geometry = matches!(hit, Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. }));
crate::dim_view::drag_start(app, *a, q, on_geometry);
}
if crate::sketch3d::dragging()
&& (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped())
@@ -1908,13 +1929,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::dim_view::drag_to(app, q);
}
// A point or a curve of the sketch being edited follows a drag that starts on it.
if resp.drag_started_by(egui::PointerButton::Primary)
if let Some((a, hit)) = grabbed
&& !crate::dim_view::dragging()
&& !crate::sketch3d::dragging()
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
{
let active = app.session.active_sketch;
match pick(app, &proj, a).into_iter().next() {
match hit {
Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => {
app.viewport.point_drag = Some((id, app.session.undo.len(), None))
}
@@ -1950,17 +1970,25 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if let Some(s) = snap {
q += s.offset;
}
if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() {
app.session.undo.truncate(depth + 1);
}
// Each frame re-solves the sketch only (`live`); the model follows when the drag ends.
let at = match app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y], "live": true})) {
Ok(r) => {
app.session.undo.truncate(depth + 1);
r.get("at").and_then(solvecraft_engine::params::vec2).unwrap_or(q)
}
Err(_) => from,
};
// The grab stays on the geometry: what it could not follow this frame it catches up
// with on the next one instead of lagging the cursor for the rest of the drag.
if grab.is_some() {
app.viewport.point_drag = Some((id, depth, Some(q)));
app.viewport.point_drag = Some((id, depth, Some(at)));
}
}
if resp.drag_stopped() {
if let Some((_, depth, _)) = app.viewport.point_drag.clone() {
// Let go on a point, midpoint or curve: the matching constraint, in the drag's undo step.
crate::drag_snap::release(app);
// Letting go on a point, midpoint or curve adds the matching constraint, then the
// whole model follows the drag, all in the drag's undo step.
let _ = app.session.execute("sketch.drag_end", &json!({ "constraint": crate::drag_snap::release() }));
app.session.undo.truncate(depth + 1);
}
crate::drag_snap::clear();
@@ -1969,9 +1997,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::sketch3d::drag_end();
}
}
// A sketch drag whose release never came (focus lost, a tool or dialog took over) ends here,
// so the model does not stay behind its sketch until the next command. Not while CAD input
// is held back (an issue report's capture): a release then belongs to the report, and the
// drag continues afterwards.
if app.viewport.point_drag.is_some() && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) {
app.viewport.point_drag = None;
crate::drag_snap::clear();
let _ = app.session.execute("sketch.drag_end", &json!({}));
}
// Box selection: a primary drag on the model when no navigation mode is on.
if app.tool.is_none()
&& primary_nav.is_none()
&& mode.is_none()
&& app.viewport.point_drag.is_none()
&& !crate::dim_view::dragging()
&& !crate::sketch3d::dragging()
@@ -2685,7 +2723,14 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
}
let t = Tokens::get();
let items: [(&str, &str); 10] = [
("orbit", "Orbit (right drag; hold Shift to snap)"),
(
"orbit",
if app.preferences.nav == crate::prefs::OPENSCAD_NAV {
"Orbit (left drag on empty space; Shift+left drag box-selects)"
} else {
"Orbit (right drag; hold Shift to snap)"
},
),
("spin", "Continuous orbit (stops at the next click or key)"),
("pan", "Pan (drag; or middle drag)"),
("zoom", "Zoom (drag; or wheel)"),
@@ -2943,6 +2988,109 @@ mod tests {
app
}
#[test]
fn openscad_left_drag_preserves_editing_and_selection() {
for action in ["point", "box", "tool"] {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = crate::prefs::OPENSCAD_NAV.into();
app.ui.show_origin = false;
hover_frame(&mut app, &ctx, vec![]);
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
let (start, end) =
if action == "point" { (point, point + vec2(30.0, 20.0)) } else { (point - vec2(40.0, 40.0), point + vec2(40.0, 40.0)) };
if action == "tool" {
app.start("sketch.line");
assert!(app.tool.is_some());
}
let before = app.cam;
let undo = app.session.undo.len();
let modifiers = egui::Modifiers { shift: action == "box", ..Default::default() };
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
events.insert(0, egui::Event::ModifiersChanged(modifiers));
let mut out = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| show(app, ui),
);
out.textures_delta.clear();
};
frame(
&mut app,
vec![
egui::Event::PointerMoved(start),
egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers },
],
);
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
frame(&mut app, vec![]);
assert_eq!(app.cam, before, "{action} drag must not orbit");
if action == "point" {
assert!(app.viewport.point_drag.is_some());
assert_eq!(app.session.undo.len(), undo + 1);
} else if action == "box" {
assert!(app.viewport.boxsel.is_some());
} else {
assert!(app.tool.is_some());
assert!(app.viewport.primary_nav.is_none());
}
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]);
if action == "box" {
assert!(!app.session.selection.is_empty());
}
}
}
#[test]
fn openscad_empty_space_drags_navigate_without_editing_or_box_selecting() {
for (button, shift) in [
(egui::PointerButton::Primary, false),
(egui::PointerButton::Secondary, false),
(egui::PointerButton::Middle, false),
(egui::PointerButton::Secondary, true),
(egui::PointerButton::Middle, true),
] {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = crate::prefs::OPENSCAD_NAV.into();
hover_frame(&mut app, &ctx, vec![]);
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
let p = if button == egui::PointerButton::Primary { point + vec2(80.0, 80.0) } else { point };
let before = app.cam;
let undo = app.session.undo.len();
let modifiers = egui::Modifiers { shift, ..Default::default() };
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
events.insert(0, egui::Event::ModifiersChanged(modifiers));
let _ = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| show(app, ui),
);
};
frame(&mut app, vec![egui::Event::PointerMoved(p), egui::Event::PointerButton { pos: p, button, pressed: true, modifiers }]);
let end = p + vec2(30.0, 20.0);
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
frame(&mut app, vec![]);
assert!(app.viewport.hover.is_none());
assert!(app.viewport.boxsel.is_none() && app.viewport.point_drag.is_none());
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }]);
assert_eq!(app.session.undo.len(), undo);
assert!(app.session.selection.is_empty());
assert!(app.viewport.context_menu.is_none());
if button == egui::PointerButton::Primary {
assert_ne!(app.cam.yaw, before.yaw);
assert_eq!(app.cam.target, before.target);
assert_eq!(app.cam.distance, before.distance);
} else if shift {
assert_ne!(app.cam.distance, before.distance);
assert_eq!(app.cam.target, before.target);
assert_eq!(app.cam.yaw, before.yaw);
} else {
assert_ne!(app.cam.target, before.target);
assert_eq!(app.cam.distance, before.distance);
assert_eq!(app.cam.yaw, before.yaw);
}
}
}
#[test]
fn hover_stays_off_between_navigation_events_and_returns_on_release() {
for gpu in [false, true] {
+8 -1
View File
@@ -118,8 +118,15 @@ fn narrow_toolbar_reaches_collapsed_commands() {
use serde_json::json;
let mut h = Harness::new();
h.run(&json!([
// Too narrow for every button: the last panels collapse into their drop-down.
{"call":"ui.resize","params":{"width":640,"height":800}},
{"expect":{"shown_handles":["toolbar:overflow"]}},
{"expect":{"shown_handles":["collapsed:SOLID:CONSTRUCT"],"hidden_handles":["button:construct.plane.offset"]}},
{"click":{"handle":"collapsed:SOLID:CONSTRUCT"}},
{"expect":{"shown_handles":["dropdown:construct.plane.offset"]}},
{"key":"Escape"},
// Narrower: they go into the » menu.
{"call":"ui.resize","params":{"width":480,"height":800}},
{"expect":{"shown_handles":["toolbar:overflow"],"hidden_handles":["collapsed:SOLID:CONSTRUCT"]}},
{"click":{"handle":"toolbar:overflow"}},
{"widget":"CONSTRUCT"},
{"expect":{"shown_handles":["toolbar:construct.plane.offset"]}},
@@ -0,0 +1,427 @@
[
{
"note": "#323: every pinned command gets a toolbar button, with no fixed cap. Ten commands pinned to SOLID > CREATE all show as buttons at a wide window; at a narrow one the panel collapses into its drop-down, then into the » menu, and none is dropped."
},
{
"call": "ui.resize",
"params": {
"width": 2000,
"height": 1000
}
},
{
"expect": {
"hidden_handles": [
"button:solid.box",
"button:solid.cylinder",
"button:solid.sphere",
"button:solid.torus",
"button:solid.coil",
"button:solid.loft",
"button:solid.mirror",
"button:solid.hole",
"button:solid.pipe",
"button:solid.thread"
]
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.box"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.cylinder"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.sphere"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.torus"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.coil"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.loft"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.mirror"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.hole"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.pipe"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"call": "ui.menu",
"params": {
"x": 300,
"y": 60,
"target": {
"type": "toolbar_command",
"id": "solid.thread"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Pin to Toolbar"
}
},
{
"expect": {
"shown_handles": [
"button:solid.box",
"button:solid.cylinder",
"button:solid.sphere",
"button:solid.torus",
"button:solid.coil",
"button:solid.loft",
"button:solid.mirror",
"button:solid.hole",
"button:solid.pipe",
"button:solid.thread"
],
"handle_order": [
"button:solid.extrude",
"button:solid.box",
"button:solid.cylinder",
"button:solid.sphere",
"button:solid.torus",
"button:solid.coil",
"button:solid.loft",
"button:solid.mirror",
"button:solid.hole",
"button:solid.pipe",
"button:solid.thread"
],
"count": {
"name": "toolbar_collapsed",
"value": 0
}
}
},
{
"note": "A narrow window: CREATE collapses into one button that opens its drop-down, which lists every pinned command."
},
{
"call": "ui.resize",
"params": {
"width": 900,
"height": 800
}
},
{
"expect": {
"hidden_handles": [
"button:solid.box",
"button:solid.cylinder",
"button:solid.sphere",
"button:solid.torus",
"button:solid.coil",
"button:solid.loft",
"button:solid.mirror",
"button:solid.hole",
"button:solid.pipe",
"button:solid.thread"
],
"shown_handles": [
"collapsed:SOLID:CREATE",
"panel:SOLID:CREATE"
]
}
},
{
"click": {
"handle": "collapsed:SOLID:CREATE"
}
},
{
"expect": {
"shown_handles": [
"dropdown:solid.box",
"dropdown:solid.cylinder",
"dropdown:solid.sphere",
"dropdown:solid.torus",
"dropdown:solid.coil",
"dropdown:solid.loft",
"dropdown:solid.mirror",
"dropdown:solid.hole",
"dropdown:solid.pipe",
"dropdown:solid.thread"
]
}
},
{
"key": "Escape"
},
{
"expect": {
"hidden_handles": [
"dropdown:solid.box",
"dropdown:solid.cylinder",
"dropdown:solid.sphere",
"dropdown:solid.torus",
"dropdown:solid.coil",
"dropdown:solid.loft",
"dropdown:solid.mirror",
"dropdown:solid.hole",
"dropdown:solid.pipe",
"dropdown:solid.thread"
]
}
},
{
"note": "Narrower still: the panels move into the » menu, and CREATE's submenu has every command."
},
{
"call": "ui.resize",
"params": {
"width": 200,
"height": 800
}
},
{
"expect": {
"hidden_handles": [
"panel:SOLID:CREATE",
"collapsed:SOLID:CREATE",
"button:solid.box",
"button:solid.cylinder",
"button:solid.sphere",
"button:solid.torus",
"button:solid.coil",
"button:solid.loft",
"button:solid.mirror",
"button:solid.hole",
"button:solid.pipe",
"button:solid.thread"
],
"shown_handles": [
"toolbar:overflow"
]
}
},
{
"click": {
"handle": "toolbar:overflow"
}
},
{
"widget": "CREATE"
},
{
"expect": {
"shown_handles": [
"toolbar:solid.box",
"toolbar:solid.cylinder",
"toolbar:solid.sphere",
"toolbar:solid.torus",
"toolbar:solid.coil",
"toolbar:solid.loft",
"toolbar:solid.mirror",
"toolbar:solid.hole",
"toolbar:solid.pipe",
"toolbar:solid.thread"
]
}
},
{
"key": "Escape"
},
{
"key": "Escape"
},
{
"note": "Wide again: all ten buttons are back; Reset Toolbar puts the defaults back."
},
{
"call": "ui.resize",
"params": {
"width": 2000,
"height": 1000
}
},
{
"expect": {
"shown_handles": [
"button:solid.box",
"button:solid.cylinder",
"button:solid.sphere",
"button:solid.torus",
"button:solid.coil",
"button:solid.loft",
"button:solid.mirror",
"button:solid.hole",
"button:solid.pipe",
"button:solid.thread"
],
"count": {
"name": "toolbar_more",
"value": 0
}
}
},
{
"call": "ui.menu",
"params": {
"x": 1900,
"y": 60,
"target": {
"type": "toolbar"
}
}
},
{
"call": "ui.menuPick",
"params": {
"item": "Reset Toolbar"
}
},
{
"expect": {
"hidden_handles": [
"button:solid.box",
"button:solid.cylinder",
"button:solid.sphere",
"button:solid.torus",
"button:solid.coil",
"button:solid.loft",
"button:solid.mirror",
"button:solid.hole",
"button:solid.pipe",
"button:solid.thread"
],
"shown_handles": [
"button:solid.extrude"
]
}
}
]
+3 -1
View File
@@ -45,7 +45,9 @@ Containers favor fast reading and saving by default. Scripts can request
Containers deduplicate identical STEP, mesh and image payloads. Mesh coordinates retain
their original double precision; this is not a conversion to STL. Geometry still rebuilds
from the timeline when opening; these files do not yet contain evaluated geometry caches.
from the timeline when opening. When a saved display preview is available, desktop Open
shows it while checking geometry; editing starts after the complete rebuild. Cancelling
keeps the current design. Previews cannot be edited or measured.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 14, including persistent names from exact face history,
+2 -2
View File
@@ -53,7 +53,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command |
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input |
| `ui.move` | `x`, `y` | |
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) |
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) |
| `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) |
| `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does |
| `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened |
@@ -76,7 +76,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `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`, `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.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `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 |
+30 -3
View File
@@ -55,6 +55,33 @@ Saving retains the existing temporary-file/flush/rename protocol and `.bak` back
Recovery reads both older JSON and new containers. Browser OPFS, downloads and recovery
use the same container reader/writer; JSON export and Save As remain available.
`cache/` entries are currently ignored. No evaluated B-rep is deserialized or adopted,
and the authoritative document always rebuilds. Evaluated cache validation, provisional
display and background rebuilds remain phase two of issue #303.
## Provisional saved display
Engine saves can include `cache/preview.json` and `cache/preview.bin` when the evaluated
world display meshes are already warm. Saving never triangulates a cold body for this
cache. Pending occurrence moves, active sketch edits, feature errors and oversized
previews omit it. The cache is bounded to 8 MiB, 1,024 bodies and 100,000 triangles;
its manifest is at most 64 KiB. Body names/colours and contiguous binary mesh slices
use the same lossless encoding as imported meshes. Extra cache size is optional;
it is omitted if it would put the container near the file-size cap.
The manifest includes SHA-256 of the exact document metadata (whose blob references
contain content hashes), the preview payload and a build fingerprint. The latter
hashes the geometry, document, engine, rendering, IO and vendored kernel source plus
dependency lockfile and workspace configuration. Changes invalidate the display cache.
Missing, obsolete, oversized or malformed preview data is a cache miss, not a failed
design open. All archive-wide safety budgets still apply.
Desktop Open can render these meshes on its existing file worker and show a clearly
labelled saved picture in the opening dialog before timeline reconstruction finishes.
It uses only the normal command's file snapshot, without a second file read. The picture
is approximate shading without face paint or picking. A conservative 16-million-pixel
raster budget rejects excessive overdraw before rendering, including repeated large
triangles in a small hostile cache. The picture is never adopted as editable
geometry, used for measurements, or used to seed evaluated model state. The original
design remains intact until the complete rebuilt session is ready. Cancellation or
any change of active document/revision discards both preview and completion.
Normal design reading, CLI and browser opening still rebuild authoritatively; no
evaluated B-rep is deserialized or adopted. Portable evaluated B-rep/state restoration
and interactive cached viewport navigation remain further work in #303.
+85 -2
View File
@@ -109,8 +109,43 @@ See [the versioned container format](design-container.md) for layout and bounds.
This phase does not restore evaluated B-reps, skip STEP import, or make cold geometry
rebuilds instant. A matching cache key is not enough to trust unvalidated geometry.
Evaluated-cache validation, provisional display and safe background replacement remain
phase two of #303 and must be measured independently of #287's import parallelism.
Evaluated B-rep/state restoration remains further work in #303. The provisional saved
display described below is separate from #287's import parallelism.
## Provisional saved display (#303)
Desktop Open can show a saved picture while the existing file worker rebuilds geometry.
The cache contains only already warm evaluated display meshes, capped at 8 MiB, 1,024
bodies and 100,000 triangles. Larger or cold models save without it. Cached geometry is
never used for editing, measurements or picking. Source/document fingerprints and
payload validation reject stale or malformed caches; cancellation and document/revision
checks prevent a background result from replacing newer work.
The cached picture is prepared on the file worker from the same file snapshot as the
normal Open command. It adds work before reconstruction, rather than bypassing it, so
measure preview readiness and full editing readiness separately. Cached display also
adds file size and save work; the storage-only results above exclude that optional data.
For an engine-level save benchmark, first generate the original 512-box STEP fixture
with the ignored IO benchmark, then run the warmed-session benchmark:
```sh
cargo test --workspace --profile ci benchmark_step_storage -- --ignored --nocapture --test-threads=1
cargo test --workspace --profile ci benchmark_warm_design_save_with_preview -- --ignored --nocapture --test-threads=1
```
Set `CARGO_TARGET_DIR` to the workspace's absolute target directory in both commands.
The engine benchmark alternates nine JSON/container atomic saves, includes the optional
display cache, and verifies both preview decoding and authoritative document identity.
Three runs on Windows, with no concurrent build or test suite, measured median atomic
JSON saves of 7.679/7.610/7.630 ms and container-with-preview saves of
10.319/10.633/10.541 ms. Preview decoding took 0.964/0.961/1.130 ms; the container
was 2,510,490 bytes. This optional preview costs save time on this STEP fixture.
An actual desktop sensor-enclosure opening prepared its saved picture in 24.320 ms;
full geometry readiness is still measured separately and requires reconstruction.
For actual desktop opening phases, launch with `SOLVECRAFT_PROFILE_OPEN=1`: `ready_ms`
reports saved-picture preparation, while the normal phase log reports reconstruction,
display warmup and thumbnail completion. Neither log measures the exact GPU first frame.
## Analytic extrusion source preparation
@@ -345,3 +380,51 @@ Pi STEP (27 bodies) into the case in 279 ms; undo restored the original two bodi
full 77 MB board STEP (2689 solid definitions, 45,689 faces) still had not completed after
300 s. Its window continued handling inspection and Cancel, and cancelling preserved the
case. Complex STEP construction remains an outstanding kernel performance problem.
# Sketch solver and drags (2026-10-11)
Unknowns the constraints pin down one after another from the constants (each one's own rows
of full, well-conditioned rank, reading nothing but constants and unknowns pinned before it)
are eliminated exactly: values the constructive seed placed and whose constraints hold there
stay constants while Levenberg–Marquardt solves the rest, and the rank analysis (degrees of
freedom, determined geometry) runs on the remainder only. A component tied to no constant takes
one point as a constant first (its translation). All the held solves of one live drag frame
share a 1M decomposition work budget (about 16 ms; a solve's own, 20M, is about 0.3 s), and a
drag's steps are a quarter of the smallest held size instead of an eighth, except once a seed
ran out of budget: the rest of that drag goes in eighths, as before, and continuation keeps
the branch the seed no longer places.
`cargo run --release -p solvecraft-sketch --example solve_bench`, macOS arm64, load average
45–75 (best of 5):
| Grid (origin corner fixed, rows horizontal, columns vertical, every edge a distance) | Start | Before | After |
|---|---|---:|---:|
| 10 × 10 | at solution | 0.36 ms | 0.21 ms |
| 10 × 10 | ±1 mm | 0.34 ms | 0.21 ms |
| 15 × 15 | at solution | 1.41 ms | 0.41 ms |
| 15 × 15 | ±1 mm | 1.39 ms | 0.40 ms |
| 20 × 20 | at solution | 4.06 ms | 0.72 ms |
| 20 × 20 | ±1 mm | 4.44 ms | 0.73 ms |
The seed constructs every grid point, so LM gets no variable (a test counts them). Before, the
time was the dense rank analysis of the whole grid. A 100-rectangle strip with nothing fixed,
solved whole from a moved corner, still costs 20–27 ms: nothing is constructed there and LM
iterates on every point with dense normal equations (a sparse solve would be the next step).
Drag frames, `sketch.drag` + `world_state()` per frame (60 frames of 0.5 mm, best of 5; the
"before" column is one run of the same bench on the previous code):
| Sketch | Points | Scripted ms/frame before → after | Live ms/frame before → after | `drag_end` ms before → after |
|---|---:|---:|---:|---:|
| 100 rectangles | 401 | 2.77 → 2.37 | 1.13 → 1.02 | 1.44 → 1.37 |
| 100 rectangles, rolled back under an extrude | 401 | 2.49 → 2.33 | 1.13 → 0.97 | 1.42 → 1.28 |
| 100-rectangle connected strip | 401 | 100.9 → 2.32 | 1.32 → 1.20 | 53.6 → 1.41 |
| 100-rectangle strip, rolled back under an extrude | 401 | 203.8 → 2.23 | 1.31 → 1.18 | 54.0 → 1.42 |
| rigid truss of 40 triangles, pinned at one end, dragged at the other | 42 | — → 6.8 | — → 6.3 | — → 0.59 |
The strip floats (nothing fixed), so its full solves (scripted frames, `drag_end`) ran the
dense rank analysis on 800 variables; with one corner taken as a constant all of it is
eliminated. A live frame's held solve was already cheap: the seed places the strip and LM
returns at once. Of a live strip frame, the seed reads the constraints in about 0.15 ms, so a
per-gesture cache of that reading is not worth its invalidation (its signs depend on the
reference positions, which change every step).
+3 -2
View File
@@ -52,8 +52,9 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
Documentation-only changes normally wait for the next release. State the reason for the
chosen bump in the release notes. Follow any version or release scope specified by the owner.
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.
`cargo update --workspace --offline` to refresh the workspace packages' versions in
`Cargo.lock` (it changes only our own crates, never dependency versions, and needs no
network). Inspect the diff: only `version = ` lines of workspace crates should change.
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`.
+6
View File
@@ -87,3 +87,9 @@ SolidCraft changes:
exposing the effective sample count so host paint callbacks can match it.
- `src/winit.rs`: use that same effective count for depth and resolve attachments.
- Other backends retain requested MSAA, without another adapter or device probe.
- `src/lib.rs`: clamp MSAA to the largest sample count the device supports for the surface and
depth formats (`supported_msaa_samples`, `RenderState::msaa_supported`): a saved 8x on a
device without it failed pipeline validation, a panic at start.
- `src/setup.rs`: the default device requests `TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES` when
the adapter has it, so 2x and 8x work where the adapter supports them (WebGPU only
guarantees 1x and 4x).
+88 -1
View File
@@ -131,6 +131,10 @@ pub struct RenderState {
/// Custom paint callbacks must use this, rather than the requested setting.
pub msaa_samples: u32,
/// SolidCraft: the MSAA sample counts this device can render the target (and depth) format
/// with, ascending, always starting with 1.
pub msaa_supported: Vec<u32>,
/// Egui renderer responsible for drawing the UI.
pub renderer: Arc<RwLock<Renderer>>,
@@ -284,8 +288,19 @@ impl RenderState {
// SolidCraft: the OpenGL resolve path can present an entirely black frame with MSAA
// (notably Mesa/llvmpipe). Decide after selecting the real surface adapter,
// before creating either pipelines or attachments; no second GPU probe.
// A sample count the device doesn't support for the target or depth format fails
// pipeline validation (a panic at start): fall back to the largest supported one.
let msaa_supported =
supported_msaa_samples(&adapter, &device, target_format, options.depth_stencil_format);
let wanted = effective_msaa_samples(adapter.get_info().backend, options.msaa_samples);
let msaa_samples = clamp_msaa_samples(&msaa_supported, wanted);
if msaa_samples != wanted {
log::warn!(
"MSAA {wanted}x is not supported for {target_format:?} on this device (supported: {msaa_supported:?}); using {msaa_samples}x"
);
}
let options = RendererOptions {
msaa_samples: effective_msaa_samples(adapter.get_info().backend, options.msaa_samples),
msaa_samples,
..options
};
let renderer = Renderer::new(&device, target_format, options);
@@ -302,6 +317,7 @@ impl RenderState {
queue,
target_format,
msaa_samples: options.msaa_samples,
msaa_supported,
renderer: Arc::new(RwLock::new(renderer)),
surface_config: config.surface,
})
@@ -571,6 +587,58 @@ pub fn effective_msaa_samples(backend: wgpu::Backend, requested: u32) -> u32 {
if backend == wgpu::Backend::Gl { 1 } else { requested.max(1) }
}
/// SolidCraft: the MSAA sample counts `device` can render `target_format` and
/// `depth_stencil_format` with (ascending, always starting with 1). Without
/// [`wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES`] the device allows only the counts
/// WebGPU guarantees for a format (1 and 4), whatever the adapter could do.
pub fn supported_msaa_samples(
adapter: &wgpu::Adapter,
device: &wgpu::Device,
target_format: wgpu::TextureFormat,
depth_stencil_format: Option<wgpu::TextureFormat>,
) -> Vec<u32> {
let features = device.features();
let flags = |format: wgpu::TextureFormat| {
if features.contains(wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES) {
adapter.get_texture_format_features(format).flags
} else {
format.guaranteed_format_features(features).flags
}
};
let target = flags(target_format);
let depth = depth_stencil_format.map(flags);
msaa_counts(target, depth)
}
/// The sample counts both the colour target and the depth buffer support. A multisampled colour
/// target must also resolve into the single-sampled surface.
fn msaa_counts(
target: wgpu::TextureFormatFeatureFlags,
depth: Option<wgpu::TextureFormatFeatureFlags>,
) -> Vec<u32> {
let resolves = target.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE);
[1, 2, 4, 8, 16]
.into_iter()
.filter(|&n| {
n == 1
|| (resolves
&& target.sample_count_supported(n)
&& depth.is_none_or(|d| d.sample_count_supported(n)))
})
.collect()
}
/// SolidCraft: the largest of the `supported` sample counts that is at most `requested` (1 when
/// none is).
pub fn clamp_msaa_samples(supported: &[u32], requested: u32) -> u32 {
supported
.iter()
.copied()
.filter(|&n| n <= requested.max(1))
.max()
.unwrap_or(1)
}
#[cfg(test)]
mod msaa_tests {
#[test]
@@ -582,4 +650,23 @@ mod msaa_tests {
}
}
}
#[test]
fn unsupported_sample_counts_fall_back_to_the_largest_supported_one() {
use wgpu::TextureFormatFeatureFlags as F;
// WebGPU's guarantee for Bgra8Unorm without adapter-specific features: 1 and 4 (a
// saved 8x failed pipeline validation at start).
let guaranteed = F::MULTISAMPLE_X4 | F::MULTISAMPLE_RESOLVE;
let supported = super::msaa_counts(guaranteed, Some(F::MULTISAMPLE_X4));
assert_eq!(supported, [1, 4]);
assert_eq!(super::clamp_msaa_samples(&supported, 8), 4);
assert_eq!(super::clamp_msaa_samples(&supported, 2), 1);
assert_eq!(super::clamp_msaa_samples(&supported, 0), 1);
// The depth buffer limits it too, and a target that can't resolve can't multisample.
let adapter = F::MULTISAMPLE_X2 | F::MULTISAMPLE_X4 | F::MULTISAMPLE_X8 | F::MULTISAMPLE_RESOLVE;
assert_eq!(super::msaa_counts(adapter, None), [1, 2, 4, 8]);
assert_eq!(super::msaa_counts(adapter, Some(F::MULTISAMPLE_X4)), [1, 4]);
assert_eq!(super::msaa_counts(F::MULTISAMPLE_X4, None), [1]);
assert_eq!(super::clamp_msaa_samples(&[], 8), 1);
}
}
+4
View File
@@ -272,6 +272,10 @@ impl WgpuSetupCreateNew {
max_texture_dimension_2d: 8192,
..base_limits
},
// SolidCraft: without it only the sample counts WebGPU guarantees (1x, 4x)
// are allowed, even where the adapter supports 2x and 8x.
required_features: adapter.features()
& wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
..Default::default()
}
}),