mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Add bounded saved opening previews and integrate upstream
This commit is contained in:
Generated
+14
-13
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
let r = spec.run_nested(&mut trial, ¶ms)?;
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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], …]}] \
|
||||
|
||||
@@ -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)}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¶ms, &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, ¶ms, &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, ¶ms, &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(¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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(¶ms[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(¶ms[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(¶ms[7]).unwrap().expr.clone(), s.doc.param(¶ms[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}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¶ms).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;
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -18,3 +18,6 @@ quick-xml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
sha2 = { workspace = true }
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ¤t));
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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,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()))?;
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +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, ¶ms);
|
||||
Some(json!({"command": cmd, "params": params}))
|
||||
}
|
||||
|
||||
/// The snap of the drag in progress: its dashed guides, a ring on the target and its label.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "オプション",
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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] {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
Vendored
+6
@@ -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).
|
||||
|
||||
Vendored
+88
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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()
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user