Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eb3f8b53 | ||
|
|
db0a2a007a | ||
|
|
ed102b5393 | ||
|
|
970da30a99 | ||
|
|
113ded5fab | ||
|
|
b4491ac605 | ||
|
|
53b092d287 | ||
|
|
25ea43e89f |
No files matched your search
@@ -2,10 +2,9 @@
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; trace macro (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
|
||||
Generated
+1
@@ -3297,6 +3297,7 @@ dependencies = [
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server. Part of the storytold "craft" family.
|
||||
model through the built-in MCP server.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
@@ -304,6 +307,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
|
||||
@@ -21,6 +21,7 @@ rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -6,11 +6,14 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_ui_egui::SolveApp;
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
@@ -18,6 +21,172 @@ struct Axes {
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct AxisRule {
|
||||
output: usize,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct DriverMapping {
|
||||
axes: [AxisRule; 6],
|
||||
overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
rotation: [f64; 3],
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ProfileAxis {
|
||||
input: Option<usize>,
|
||||
output: Option<usize>,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ProfilePart {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
fn axis_index(name: &str) -> Option<usize> {
|
||||
match name.trim() {
|
||||
"HIDMultiAxis_X" => Some(0),
|
||||
"HIDMultiAxis_Y" => Some(1),
|
||||
"HIDMultiAxis_Z" => Some(2),
|
||||
"HIDMultiAxis_Rx" => Some(3),
|
||||
"HIDMultiAxis_Ry" => Some(4),
|
||||
"HIDMultiAxis_Rz" => Some(5),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
|
||||
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
|
||||
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
|
||||
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
|
||||
let mut reader = Reader::from_reader(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut mapping = DriverMapping::default();
|
||||
let mut axis: Option<ProfileAxis> = None;
|
||||
let mut part = None;
|
||||
let mut field = Vec::new();
|
||||
let mut found = false;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf).ok()? {
|
||||
Event::Start(start) => match start.local_name().as_ref() {
|
||||
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
|
||||
b"Input" => part = Some(ProfilePart::Input),
|
||||
b"Output" => part = Some(ProfilePart::Output),
|
||||
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
let value = String::from_utf8_lossy(text.as_ref());
|
||||
match field.as_slice() {
|
||||
b"ActionID" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
match part {
|
||||
Some(ProfilePart::Input) => current.input = axis_index(&value),
|
||||
Some(ProfilePart::Output) => current.output = axis_index(&value),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Enabled" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
current.enabled = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Reversed" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.reversed = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Scale" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
b"OverallScale" => {
|
||||
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
|
||||
mapping.overall_scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::End(end) => match end.local_name().as_ref() {
|
||||
b"Axis" => {
|
||||
if let Some(current) = axis.take()
|
||||
&& let (Some(input), Some(output)) = (current.input, current.output)
|
||||
{
|
||||
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
b"Input" | b"Output" => part = None,
|
||||
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
found.then_some(mapping)
|
||||
}
|
||||
|
||||
fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
|
||||
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
|
||||
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
|
||||
return None;
|
||||
}
|
||||
let xml = std::fs::read(&profile).ok()?;
|
||||
parse_driver_mapping(&xml)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
axes: Axes,
|
||||
@@ -107,6 +276,8 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -118,13 +289,21 @@ impl SpaceMouse {
|
||||
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
Self { shared, last_frame: now }
|
||||
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let now = Instant::now();
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_default();
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
if !app.preferences.space_mouse_enabled || app.home.open {
|
||||
return;
|
||||
}
|
||||
@@ -141,7 +320,14 @@ impl SpaceMouse {
|
||||
}
|
||||
app.begin_external_navigation();
|
||||
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
|
||||
navigate(&mut app.cam, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
|
||||
navigate(
|
||||
&mut app.cam,
|
||||
self.driver_mapping.apply(axes),
|
||||
seconds * speed.clamp(0.1, 5.0),
|
||||
height,
|
||||
app.session.active_sketch.is_some(),
|
||||
app.preferences.zoom_reverse,
|
||||
);
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
@@ -153,15 +339,21 @@ fn normalized(value: i16) -> f64 {
|
||||
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
|
||||
}
|
||||
|
||||
fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: bool) {
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = axes.translation.map(normalized);
|
||||
let [rx, ry, rz] = axes.rotation.map(normalized);
|
||||
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
|
||||
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile above can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
|
||||
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
|
||||
// navigation mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
@@ -171,6 +363,10 @@ fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: boo
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
@@ -192,15 +388,102 @@ mod tests {
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
let before = cam;
|
||||
navigate(&mut cam, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, false);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
|
||||
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
|
||||
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let before = Camera::default();
|
||||
|
||||
let mut physical_roll = before;
|
||||
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(physical_roll.roll, before.roll);
|
||||
|
||||
let mut physical_spin = before;
|
||||
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(physical_spin.yaw, before.yaw);
|
||||
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Regressions against the actual patched truck constructor, run by workspace CI.
|
||||
use truck_meshalgo::prelude::PolylineCurve;
|
||||
use truck_modeling::{BoundedCurve, ParametricCurve, Plane, Point3, Sphere};
|
||||
use truck_shapeops::IntersectionCurveWithParameters;
|
||||
|
||||
fn planes() -> (Plane, Plane) {
|
||||
let o = Point3::new(0.0, 0.0, 0.0);
|
||||
(Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)), Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 0.0, 1.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_intersection_polylines_return_none_without_panicking() {
|
||||
let origin = Point3::new(0.0, 0.0, 0.0);
|
||||
for points in [
|
||||
vec![],
|
||||
vec![origin],
|
||||
vec![origin, origin],
|
||||
vec![origin; 3],
|
||||
vec![origin, Point3::new(f64::NAN, 0.0, 0.0)],
|
||||
vec![origin, Point3::new(f64::INFINITY, 0.0, 0.0)],
|
||||
] {
|
||||
let (a, b) = planes();
|
||||
let result = std::panic::catch_unwind(|| IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(points.clone()), 0.01));
|
||||
assert!(result.is_ok(), "constructor panicked for {points:?}");
|
||||
assert!(result.unwrap().is_none(), "collapsed input must not create a curve: {points:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_intersection_retains_the_closure_and_spherical_supports() {
|
||||
let a = Sphere::new(Point3::new(0.0, 0.0, 1.0), 2.0_f64.sqrt());
|
||||
let b = Sphere::new(Point3::new(0.0, 0.0, -1.0), 2.0_f64.sqrt());
|
||||
let poly = PolylineCurve(vec![
|
||||
Point3::new(1.0, 0.0, 0.0),
|
||||
Point3::new(0.0, 1.0, 0.0),
|
||||
Point3::new(-1.0, 0.0, 0.0),
|
||||
Point3::new(0.0, -1.0, 0.0),
|
||||
Point3::new(1.0, 0.0, 0.0),
|
||||
]);
|
||||
let curve = IntersectionCurveWithParameters::try_new(a, b, poly, 0.01).unwrap();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
assert_eq!(curve.subs(lo), curve.subs(hi));
|
||||
for i in 0..=40 {
|
||||
let p = curve.subs(lo + (hi - lo) * i as f64 / 40.0);
|
||||
assert!(p.z.abs() < 1e-8, "{p:?}");
|
||||
assert!(((p.x * p.x + p.y * p.y).sqrt() - 1.0).abs() <= 0.01, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_intersection_keeps_endpoints_on_both_planes() {
|
||||
let (a, b) = planes();
|
||||
let start = Point3::new(1.0, 0.0, 0.0);
|
||||
let end = Point3::new(3.0, 0.0, 0.0);
|
||||
let curve = IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(vec![start, end]), 0.01).unwrap();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
assert_eq!(curve.subs(lo), start);
|
||||
assert_eq!(curve.subs(hi), end);
|
||||
assert_eq!(curve.subs((lo + hi) / 2.0), Point3::new(2.0, 0.0, 0.0));
|
||||
}
|
||||
@@ -30,6 +30,8 @@ mod heal;
|
||||
mod helix;
|
||||
mod iges_in;
|
||||
mod iges_out;
|
||||
#[cfg(test)]
|
||||
mod intersection_curve_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
|
||||
@@ -33,7 +33,8 @@ impl Catalog {
|
||||
|
||||
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
|
||||
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
|
||||
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE];
|
||||
static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!("locales/de.json"));
|
||||
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE, &GERMAN];
|
||||
|
||||
pub fn is_supported(id: &str) -> bool {
|
||||
LOCALES.iter().any(|catalog| catalog.id == id)
|
||||
|
||||
@@ -519,22 +519,6 @@ impl SolveApp {
|
||||
let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. }));
|
||||
return self.start(if edges { "solid.fillet" } else { "solid.extrude" });
|
||||
}
|
||||
// Fix/Unfix: the selected sketch points and curves change at once, otherwise pick them.
|
||||
if id == "sketch.constraint.fix" {
|
||||
let ents: Vec<String> = self
|
||||
.session
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !ents.is_empty() {
|
||||
let _ = self.run(id, json!({ "entities": ents }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Construction toggle: selected sketch curves switch at once, otherwise pick them.
|
||||
// Construction (X): convert the selection, or switch construction mode; no pick tool.
|
||||
if id == "sketch.construction" {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"browser.body_name": "Körper {number}",
|
||||
"browser.component_name": "Komponente {number}",
|
||||
"browser.parameters": "Parameter ({count})",
|
||||
"browser.precision": "Genauigkeit: {precision}",
|
||||
"browser.sketch_name": "Skizze {number}",
|
||||
"browser.units": "Einheiten: {units}",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
"dialog.selected_count": "↖ {count} ausgewählt",
|
||||
"document.save_changes": "Änderungen in {name} speichern?",
|
||||
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
|
||||
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
|
||||
"sketch.draw_curves": "{linetype}-Kurven zeichnen",
|
||||
"sketch.make_selection": "Auswahl als {linetype} markieren",
|
||||
"ui.1_degree_of_freedom": "1 Freiheitsgrad",
|
||||
"ui.3d_sketch": "3D-Skizze",
|
||||
"ui.about": "Über",
|
||||
"ui.activate": "Aktivieren",
|
||||
"ui.active_component": "Aktive Komponente",
|
||||
"ui.agent_cursor": "Agent-Cursor",
|
||||
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
|
||||
"ui.all": "Alle",
|
||||
"ui.angle": "Winkel",
|
||||
"ui.angle_decimals": "Winkel-Dezimalstellen",
|
||||
"ui.animate_view_changes": "Ansichtsänderungen animieren",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animationsgeschwindigkeit",
|
||||
"ui.apply": "Anwenden",
|
||||
"ui.arc": "Kreisbogen",
|
||||
"ui.assemble": "Zusammenbau",
|
||||
"ui.autosave_every": "Autospeichern alle",
|
||||
"ui.axes": "Achsen",
|
||||
"ui.axis": "Achse",
|
||||
"ui.bodies": "Körper",
|
||||
"ui.box": "Block",
|
||||
"ui.break": "Unterbrechen",
|
||||
"ui.cancel": "Abbrechen",
|
||||
"ui.canvases": "Kanvas",
|
||||
"ui.centerline": "Mittellinie",
|
||||
"ui.chamfer": "Abschrägung",
|
||||
"ui.change_workspace": "Arbeitsbereich wechseln",
|
||||
"ui.chord_length": "Sehnenlänge",
|
||||
"ui.circle": "Kreis",
|
||||
"ui.circular_pattern": "Radiales Muster",
|
||||
"ui.clear_recent": "Zuletzt verwendet leeren",
|
||||
"ui.clear_the_selection": "Auswahl aufheben",
|
||||
"ui.close": "Schließen",
|
||||
"ui.coil": "Schraube",
|
||||
"ui.coincident": "Koinzident",
|
||||
"ui.collapse": "Einklappen",
|
||||
"ui.collinear": "Kollinear",
|
||||
"ui.combine": "Kombinieren",
|
||||
"ui.coming_later": "bald verfügbar",
|
||||
"ui.concentric": "Konzentrisch",
|
||||
"ui.conic_curve": "Kegelschnitt",
|
||||
"ui.constant": "Konstant",
|
||||
"ui.constraints_conflict": "Bedingungen konfliktreich",
|
||||
"ui.construct": "Konstruktion",
|
||||
"ui.construction": "Konstruktionslinie",
|
||||
"ui.copy": "Kopieren",
|
||||
"ui.counterbore": "Tiefenbohrung",
|
||||
"ui.countersink": "Senkung",
|
||||
"ui.create": "Erstellen",
|
||||
"ui.create_sketch": "Skizze erstellen",
|
||||
"ui.current": "aktuell",
|
||||
"ui.curvature": "Krümmung",
|
||||
"ui.cut": "Ausschneiden",
|
||||
"ui.cylinder": "Zylinder",
|
||||
"ui.dark": "Dunkel",
|
||||
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
|
||||
"ui.degrees_of_freedom": "Freiheitsgrade",
|
||||
"ui.delete": "Löschen",
|
||||
"ui.depth": "Tiefe",
|
||||
"ui.design": "Design",
|
||||
"ui.diameter": "Durchmesser",
|
||||
"ui.dimension": "Maß",
|
||||
"ui.direction": "Richtung",
|
||||
"ui.distance": "Abstand",
|
||||
"ui.distance_1": "Abstand 1",
|
||||
"ui.distance_2": "Abstand 2",
|
||||
"ui.document_settings": "Dokumenteneinstellungen",
|
||||
"ui.don_t_save": "Nicht speichern",
|
||||
"ui.draft": "Abziehen",
|
||||
"ui.drawing": "Zeichnung",
|
||||
"ui.drill_point": "Bohrpunkt",
|
||||
"ui.edit": "Bearbeiten",
|
||||
"ui.ellipse": "Ellipse",
|
||||
"ui.end_radius": "Endradius",
|
||||
"ui.equal": "Gleich",
|
||||
"ui.expand": "Aufklappen",
|
||||
"ui.export_ellipsis": "Exportieren...",
|
||||
"ui.extend": "Verlängern",
|
||||
"ui.extent_type": "Ausdehnungstyp",
|
||||
"ui.extrude": "Extrudieren",
|
||||
"ui.features": "Funktionen",
|
||||
"ui.file": "Datei",
|
||||
"ui.fillet": "Vollrundung",
|
||||
"ui.finish_sketch": "Skizze beenden",
|
||||
"ui.fix_unfix": "Fixieren/Lösen",
|
||||
"ui.flat": "Flach",
|
||||
"ui.flip": "Umkehren",
|
||||
"ui.front": "Vorne",
|
||||
"ui.fully_constrained": "Vollständig bestimmt",
|
||||
"ui.general": "Allgemein",
|
||||
"ui.generative_design": "Generatives Design",
|
||||
"ui.graphics": "Grafik",
|
||||
"ui.heading_assemble": "ZUSAMMENBAU",
|
||||
"ui.heading_box": "BLOCK",
|
||||
"ui.heading_chamfer": "ABSCHRÄGUNG",
|
||||
"ui.heading_circular_pattern": "RADIALES MUSTER",
|
||||
"ui.heading_coil": "SCHRAUBE",
|
||||
"ui.heading_combine": "KOMBINIEREN",
|
||||
"ui.heading_construct": "KONSTRUKTION",
|
||||
"ui.heading_create": "ERSTELLEN",
|
||||
"ui.heading_create_sketch": "SKIZZE ERSTELLEN",
|
||||
"ui.heading_cylinder": "ZYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "ABZIEHEN",
|
||||
"ui.heading_edit": "BEARBEITEN",
|
||||
"ui.heading_extrude": "EXTRUDIEREN",
|
||||
"ui.heading_fillet": "VOLLRUNDUNG",
|
||||
"ui.heading_finish_sketch": "SKIZZE BEENDEN",
|
||||
"ui.heading_front": "VORNE",
|
||||
"ui.heading_hole": "BOHRUNG",
|
||||
"ui.heading_home": "START",
|
||||
"ui.heading_insert": "EINFÜGEN",
|
||||
"ui.heading_inspect": "PRÜFEN",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_measure": "Messen",
|
||||
"ui.heading_mesh": "MESH",
|
||||
"ui.heading_mirror": "SPIEGELN",
|
||||
"ui.heading_modify": "ÄNDERN",
|
||||
"ui.heading_move": "VERSCHIEBEN",
|
||||
"ui.heading_offset_plane": "ABSTANDSEBENE",
|
||||
"ui.heading_ok": "OK",
|
||||
"ui.heading_parameters": "PARAMETER",
|
||||
"ui.heading_pattern_on_path": "PFADMUSTER",
|
||||
"ui.heading_pipe": "ROHR",
|
||||
"ui.heading_plane_at_angle": "EBENE IM WINKEL",
|
||||
"ui.heading_plastic": "PLASTIK",
|
||||
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
|
||||
"ui.heading_revolve": "DREHEN",
|
||||
"ui.heading_right": "RECHTS",
|
||||
"ui.heading_scale": "SKALIEREN",
|
||||
"ui.heading_section_analysis": "QUERSCHNITTSANALYSE",
|
||||
"ui.heading_select": "AUSWÄHLEN",
|
||||
"ui.heading_sheet_metal": "BLECH",
|
||||
"ui.heading_shell": "HÜLLE",
|
||||
"ui.heading_sketch": "SKIZZE",
|
||||
"ui.heading_sketch_palette": "SKIZZENPALETTE",
|
||||
"ui.heading_solid": "KÖRPER",
|
||||
"ui.heading_sphere": "SPHÄRE",
|
||||
"ui.heading_split_body": "KÖRPER TRENNEN",
|
||||
"ui.heading_surface": "FLÄCHE",
|
||||
"ui.heading_sweep": "FÜHREN",
|
||||
"ui.heading_thread": "GEWINDE",
|
||||
"ui.heading_top": "OBEN",
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.help": "Hilfe",
|
||||
"ui.hide": "Ausblenden",
|
||||
"ui.hole": "Bohrung",
|
||||
"ui.hole_tap_type": "Gewindetyp",
|
||||
"ui.hole_type": "Bohrungstyp",
|
||||
"ui.home": "Start",
|
||||
"ui.horizontal_vertical": "Horizontal/Vertikal",
|
||||
"ui.insert": "Einfügen",
|
||||
"ui.inspect": "Prüfen",
|
||||
"ui.intersect": "Schnitt",
|
||||
"ui.join": "Verbinden",
|
||||
"ui.joints": "Gelenke",
|
||||
"ui.keyboard_shortcuts": "Tastenkürzel",
|
||||
"ui.language": "Sprache",
|
||||
"ui.length_decimals": "Längen-Dezimalstellen",
|
||||
"ui.light": "Hell",
|
||||
"ui.line": "Linie",
|
||||
"ui.linetype": "Linientyp",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Ansehen",
|
||||
"ui.manufacture": "Fertigung",
|
||||
"ui.measure": "Messen",
|
||||
"ui.midpoint": "Mittelpunkt",
|
||||
"ui.mirror": "Spiegeln",
|
||||
"ui.modify": "Ändern",
|
||||
"ui.move": "Verschieben",
|
||||
"ui.multiple": "Mehrfach",
|
||||
"ui.named_views": "Benannte Ansichten",
|
||||
"ui.navigation": "Navigation",
|
||||
"ui.new_body": "Neuer Körper",
|
||||
"ui.new_design": "Neues Design",
|
||||
"ui.normal": "Normal",
|
||||
"ui.object_type": "Objekttyp",
|
||||
"ui.objects": "Objekte",
|
||||
"ui.offer_to_stand_up_y_up_files": "Y-hoch-Dateien aufrecht anbieten",
|
||||
"ui.offset": "Abstand",
|
||||
"ui.offset_plane": "Abstandsebene",
|
||||
"ui.one_side": "Einseitig",
|
||||
"ui.open": "Öffnen",
|
||||
"ui.open_ellipsis": "Öffnen...",
|
||||
"ui.open_recent": "Zuletzt verwendet",
|
||||
"ui.operation": "Operation",
|
||||
"ui.options": "Optionen",
|
||||
"ui.orbit_around": "Umkreisen um",
|
||||
"ui.origin": "Ursprung",
|
||||
"ui.pan_zoom_orbit": "Verschieben, zoomen, umkreisen",
|
||||
"ui.parallel": "Parallel",
|
||||
"ui.parameters": "Parameter",
|
||||
"ui.paste": "Einfügen",
|
||||
"ui.path": "Pfad",
|
||||
"ui.pattern_on_path": "Pfadmuster",
|
||||
"ui.perpendicular": "Senkrecht",
|
||||
"ui.pipe": "Rohr",
|
||||
"ui.placement": "Platzierung",
|
||||
"ui.plane": "Ebene",
|
||||
"ui.plane_at_angle": "Ebene im Winkel",
|
||||
"ui.point": "Punkt",
|
||||
"ui.point_under_the_cursor": "Punkt unter dem Cursor",
|
||||
"ui.points": "Punkte",
|
||||
"ui.polygon": "Polygon",
|
||||
"ui.preferences": "Einstellungen",
|
||||
"ui.profile": "Profil",
|
||||
"ui.profile_plane": "Profilebene",
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.quit": "Beenden",
|
||||
"ui.radius": "Radius",
|
||||
"ui.radius_type": "Radius-Typ",
|
||||
"ui.rectangle": "Rechteck",
|
||||
"ui.rectangular_pattern": "Rechteckmuster",
|
||||
"ui.redo": "Wiederholen",
|
||||
"ui.render": "Rendern",
|
||||
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
|
||||
"ui.restore_defaults": "Standardeinstellungen wiederherstellen",
|
||||
"ui.reverse_zoom_direction": "Zoomrichtung umkehren",
|
||||
"ui.revolve": "Drehen",
|
||||
"ui.right": "Rechts",
|
||||
"ui.save": "Speichern",
|
||||
"ui.save_as": "Speichern unter",
|
||||
"ui.save_as_ellipsis": "Speichern unter...",
|
||||
"ui.scale": "Skalieren",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
"ui.select": "Auswählen",
|
||||
"ui.selection": "Auswahl",
|
||||
"ui.shell": "Hülle",
|
||||
"ui.show": "Einblenden",
|
||||
"ui.show_constraints": "Bedingungen anzeigen",
|
||||
"ui.show_dimensions": "Maße anzeigen",
|
||||
"ui.show_points": "Punkte anzeigen",
|
||||
"ui.show_profile": "Profil anzeigen",
|
||||
"ui.show_projected_geometries": "Projizierte Geometrien anzeigen",
|
||||
"ui.simple": "Einfach",
|
||||
"ui.simulation": "Simulation",
|
||||
"ui.single": "Einzel",
|
||||
"ui.sketch": "Skizze",
|
||||
"ui.sketch_dimension": "Skizzenmaß",
|
||||
"ui.sketch_grid": "Skizzenraster",
|
||||
"ui.sketch_scale": "Skizzenskalierung",
|
||||
"ui.sketches": "Skizzen",
|
||||
"ui.slice": "Scheibe",
|
||||
"ui.slot": "Langloch",
|
||||
"ui.smooth": "Glatt",
|
||||
"ui.snap": "Einrasten",
|
||||
"ui.sphere": "Sphäre",
|
||||
"ui.spline": "Spline",
|
||||
"ui.split_body": "Körper trennen",
|
||||
"ui.start": "Start",
|
||||
"ui.start_radius": "Startradius",
|
||||
"ui.sweep": "Führen",
|
||||
"ui.symmetric": "Symmetrisch",
|
||||
"ui.symmetry": "Symmetrie",
|
||||
"ui.system": "System",
|
||||
"ui.tangent": "Tangential",
|
||||
"ui.taper_angle": "Kegelwinkel",
|
||||
"ui.tapped": "Gewindebohrung",
|
||||
"ui.text": "Text",
|
||||
"ui.theme": "Design",
|
||||
"ui.thickness": "Dicke",
|
||||
"ui.thread": "Gewinde",
|
||||
"ui.tip_angle": "Spitzenwinkel",
|
||||
"ui.to_object": "Bis Objekt",
|
||||
"ui.top": "Oben",
|
||||
"ui.torus": "Torus",
|
||||
"ui.trim": "Kürzen",
|
||||
"ui.two_sides": "Beidseitig",
|
||||
"ui.ui_scale": "UI-Skalierung",
|
||||
"ui.undo": "Rückgängig",
|
||||
"ui.units": "Einheiten",
|
||||
"ui.units_of_new_designs": "Einheiten für neue Designs",
|
||||
"ui.untitled": "Ohne Titel",
|
||||
"ui.variable": "Variabel",
|
||||
"ui.view_centre": "Ansichtszentrum",
|
||||
"ui.visual_style": "Ansichtsstil",
|
||||
"ui.workspace": "Arbeitsbereich",
|
||||
"ui.zoom_to_the_cursor": "Zum Cursor zoomen"
|
||||
}
|
||||
@@ -542,29 +542,41 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new("Not available yet").color(t.text_dim));
|
||||
}
|
||||
let row_w = dropdown_width(app, ui, &cmds);
|
||||
// Up and Down move the keyboard focus through the enabled rows (from none
|
||||
// focused: the first or the last), instead of egui's own spatial move.
|
||||
let (down, up) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp)));
|
||||
if down || up {
|
||||
let enabled: Vec<&str> = cmds.iter().filter(|c| c.info(&app.session).enabled).map(|c| c.id).collect();
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let at = enabled.iter().position(|id| focused == Some(dropdown_row_id(ui, id)));
|
||||
let n = enabled.len();
|
||||
let next = match (at, down) {
|
||||
(None, true) => Some(0),
|
||||
(None, false) => n.checked_sub(1),
|
||||
(Some(i), true) => Some((i + 1) % n.max(1)),
|
||||
(Some(i), false) => Some((i + n.max(1) - 1) % n.max(1)),
|
||||
};
|
||||
if let Some(id) = next.and_then(|i| enabled.get(i)) {
|
||||
ui.ctx().memory_mut(|m| {
|
||||
m.request_focus(dropdown_row_id(ui, id));
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
for c in &cmds {
|
||||
let info = c.info(&app.session);
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(
|
||||
info.enabled,
|
||||
egui::Button::new(app.tr_label(c.label)).frame(false).shortcut_text(c.shortcut.unwrap_or("")),
|
||||
);
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
|
||||
if b.secondary_clicked() {
|
||||
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
|
||||
}
|
||||
if b.clicked() {
|
||||
let id = c.id;
|
||||
app.start(id);
|
||||
match dropdown_row(app, ui, c, row_w) {
|
||||
RowAction::None => {}
|
||||
RowAction::Menu(at) => menu = Some((at, c.id)),
|
||||
RowAction::Start => {
|
||||
app.start(c.id);
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if area.response.clicked_elsewhere() && !lresp.clicked() {
|
||||
if (area.response.clicked_elsewhere() && !lresp.clicked()) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
}
|
||||
@@ -608,6 +620,72 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What a click on a panel drop-down row asks for.
|
||||
enum RowAction {
|
||||
None,
|
||||
Start,
|
||||
Menu(egui::Pos2),
|
||||
}
|
||||
|
||||
const ROW_H: f32 = 24.0;
|
||||
|
||||
/// The id of a drop-down row (for keyboard focus).
|
||||
fn dropdown_row_id(ui: &egui::Ui, id: &str) -> egui::Id {
|
||||
ui.id().with(("dropdown_row", id))
|
||||
}
|
||||
|
||||
/// How wide a panel drop-down's rows are: the widest label and shortcut, at least 200.
|
||||
fn dropdown_width(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solvecraft_engine::CommandSpec]) -> f32 {
|
||||
let t = Tokens::get();
|
||||
let font = FontId::proportional(13.0);
|
||||
cmds.iter()
|
||||
.map(|c| {
|
||||
let label = ui.painter().layout_no_wrap(app.tr_label(c.label).to_string(), font.clone(), t.text).size().x;
|
||||
let key = c.shortcut.map(|k| ui.painter().layout_no_wrap(k.to_string(), font.clone(), t.text).size().x + 24.0).unwrap_or(0.0);
|
||||
6.0 + 18.0 + 8.0 + label + key + 30.0
|
||||
})
|
||||
.fold(200.0, f32::max)
|
||||
}
|
||||
|
||||
/// One command row of a panel drop-down: icon, label and shortcut, with the hover background
|
||||
/// under the pointer or keyboard focus (like every menu).
|
||||
fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solvecraft_engine::CommandSpec, width: f32) -> RowAction {
|
||||
let t = Tokens::get();
|
||||
let info = c.info(&app.session);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let sense = if info.enabled { Sense::click() } else { Sense::hover() };
|
||||
let resp = ui.interact(rect, dropdown_row_id(ui, c.id), sense);
|
||||
let label = app.tr_label(c.label).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
|
||||
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
|
||||
let hot = info.enabled && (resp.hovered() || resp.has_focus());
|
||||
let p = ui.painter();
|
||||
if hot {
|
||||
p.rect_filled(rect, 3.0, t.hover);
|
||||
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
|
||||
}
|
||||
let (ink, fill, acc, text) =
|
||||
if info.enabled { (t.icon, t.icon_fill, t.accent, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) };
|
||||
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(p, ir, c.icon, ink, fill, acc);
|
||||
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
|
||||
if let Some(k) = c.shortcut {
|
||||
p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, k, FontId::proportional(13.0), t.text_dim);
|
||||
}
|
||||
let resp = match &info.disabled_reason {
|
||||
Some(why) if !info.enabled => resp.on_hover_text(why),
|
||||
_ => resp,
|
||||
};
|
||||
if resp.secondary_clicked() {
|
||||
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
|
||||
}
|
||||
if resp.clicked() && info.enabled {
|
||||
return RowAction::Start;
|
||||
}
|
||||
RowAction::None
|
||||
}
|
||||
|
||||
/// A toolbar button being dragged (its command id).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ToolbarDrag(&'static str);
|
||||
|
||||
@@ -326,7 +326,25 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
|
||||
/// Only consume a complete, unambiguous selection; command validation owns geometry.
|
||||
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
|
||||
use solvecraft_engine::Sel::{SketchCurve, SketchPoint};
|
||||
let selected_ids = |curves_only: bool, minimum: usize| -> Option<Vec<&str>> {
|
||||
if !(minimum..=200).contains(&selection.len()) {
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<&str> = selection
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
SketchCurve { id } => Some(id.as_str()),
|
||||
SketchPoint { id } if !curves_only => Some(id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect();
|
||||
(unique.len() == ids.len()).then_some(ids)
|
||||
};
|
||||
match (command, selection) {
|
||||
("sketch.constraint.fix", _) => selected_ids(false, 1).map(|ids| json!({"entities":ids})),
|
||||
("sketch.constraint.polygon", _) => selected_ids(true, 3).map(|ids| json!({"lines":ids})),
|
||||
("sketch.constraint.curvature", [SketchCurve { id: a }, SketchCurve { id: b }]) if a != b => Some(json!({"a":a,"b":b})),
|
||||
("sketch.constraint.horizontal_vertical", [SketchCurve { id }]) => Some(json!({"line":id})),
|
||||
("sketch.constraint.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})),
|
||||
("sketch.constraint.coincident", [a, b]) => {
|
||||
|
||||
@@ -146,3 +146,106 @@ fn rejected_preselection_preserves_document_and_error() {
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_polygon_and_fix_consume_clicked_operands_atomically() {
|
||||
for command in ["fix", "curvature", "polygon"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
let picks = match command {
|
||||
"curvature" => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.spline.control_point", json!({"points":["l1.end",[40,12],[50,16],[60,25]]})).unwrap();
|
||||
vec![[20., 10.], [45., 14.875]]
|
||||
}
|
||||
"polygon" => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[40,10],[40,30],[10,30]],"closed":true,"infer":false})).unwrap();
|
||||
vec![[25., 10.], [40., 20.], [25., 30.], [10., 20.]]
|
||||
}
|
||||
_ => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[40,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[20,25],"id":"p"})).unwrap();
|
||||
vec![[25., 10.], [20., 25.]]
|
||||
}
|
||||
};
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 80.0;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(30.0, 15.0, 0.0);
|
||||
h.settle();
|
||||
for (i, p) in picks.iter().enumerate() {
|
||||
h.step(&json!({"click":{"sketch":p},"shift":i>0})).unwrap();
|
||||
}
|
||||
assert_eq!(h.app.session.selection.len(), picks.len(), "{command}: {:?}", h.app.session.selection);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status);
|
||||
assert!(h.app.tool.is_none());
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let st = h.app.session.world_state();
|
||||
let sk = &st.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert!(sk.constraints.iter().any(|c| match command {
|
||||
"fix" => matches!(c.kind, ConstraintKind::Fix { .. }),
|
||||
"curvature" => matches!(c.kind, ConstraintKind::Smooth { .. }),
|
||||
_ => matches!(c.kind, ConstraintKind::Equal { .. }),
|
||||
}));
|
||||
match command {
|
||||
"fix" => {
|
||||
assert!(sk.curve_locked(sk.curve_index("l1").unwrap()));
|
||||
assert!(sk.point_locked(sk.resolve_point("p").unwrap()));
|
||||
}
|
||||
"curvature" => {
|
||||
let spline = sk.curves.iter().find(|c| c.id != "l1").unwrap();
|
||||
let pts: Vec<_> = spline.kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
|
||||
let line = sk.polyline(sk.curve_index("l1").unwrap());
|
||||
let direction = (line[1] - line[0]).normalized().unwrap();
|
||||
assert!((pts[1] - pts[0]).cross(direction).abs() < 1e-5);
|
||||
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3);
|
||||
}
|
||||
_ => {
|
||||
let lengths: Vec<_> = ["l1", "l2", "l3", "l4"]
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let pts = sk.polyline(sk.curve_index(id).unwrap());
|
||||
pts[0].dist(pts[1])
|
||||
})
|
||||
.collect();
|
||||
assert!(lengths[0] > 1.0);
|
||||
assert!(lengths.iter().all(|l| (l - lengths[0]).abs() < 1e-6));
|
||||
}
|
||||
}
|
||||
assert!(st.sketch(h.app.session.active_sketch.unwrap()).unwrap().report.ok());
|
||||
let after = h.app.session.doc.to_json();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_constraints_preserve_mixed_selections_and_rejected_geometry() {
|
||||
for command in ["fix", "curvature", "polygon"] {
|
||||
let mut h = lines();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let mixed = vec![Sel::SketchCurve { id: "l1".into() }, Sel::Body { name: "missing".into() }];
|
||||
h.app.session.selection = mixed.clone();
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(h.app.tool.is_some());
|
||||
assert_eq!(h.app.session.selection, mixed);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
let mut h = lines();
|
||||
let selection = vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "missing".into() }];
|
||||
h.app.session.selection = selection.clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.constraint.curvature"})).unwrap();
|
||||
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
[
|
||||
{
|
||||
"note": "#288: a panel drop-down highlights the row under the pointer with the hover background, and the row with keyboard focus. Drop-down rows publish `dropdown:<command id>`, the highlighted one also `dropdown_hot:<command id>`."
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "panel:SOLID:CREATE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box", "dropdown:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box", "dropdown_hot:solid.sphere"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.box"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.cylinder"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.sphere"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": [700, 600]
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The keyboard: Down focuses the first row, Down again the next; the highlight follows."
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.extrude"],
|
||||
"hidden_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["dropdown:solid.box"]
|
||||
}
|
||||
}
|
||||
]
|
||||
+1
-1
@@ -54,7 +54,7 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
|
||||
2. Update `[workspace.package] version` in root `Cargo.toml`. Then run
|
||||
`cargo metadata --format-version 1 --no-deps > /dev/null` to refresh workspace package
|
||||
versions in `Cargo.lock`. Inspect the diff and keep unrelated dependency changes out.
|
||||
SolidCraft does not currently implement the siblings' `cargo xtask version set` command.
|
||||
SolidCraft does not currently implement `cargo xtask version set`.
|
||||
3. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
|
||||
Commit the version files through the normal review flow and land them on `main`.
|
||||
Do not use `[skip ci]`. Record the exact commit SHA to release. If it changes, revalidate it.
|
||||
|
||||
Vendored
+3
@@ -7,6 +7,9 @@ are marked `SolveCraft:` in the source.
|
||||
## truck-shapeops 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck)
|
||||
|
||||
Changes:
|
||||
- `transversal/intersection_curve/mod.rs`: reject empty/single-point polylines and
|
||||
collapsed or non-finite segments before endpoint indexing and normalization. A hidden
|
||||
constructor re-export lets the kernel workspace tests exercise the actual vendored code.
|
||||
- `transversal/integrate/mod.rs`: mesh both shells with `robust_triangulation` (faces made by
|
||||
earlier booleans have boundaries that lie on their surface only within tolerance).
|
||||
- `transversal/divide_face/mod.rs`: when projecting a loop onto a face's surface, fall back to
|
||||
|
||||
Vendored
+3
@@ -34,4 +34,7 @@ mod healing;
|
||||
pub use healing::{RobustSplitClosedEdgesAndFaces, SplitClosedEdgesAndFaces};
|
||||
mod transversal;
|
||||
pub use transversal::{and, or, ShapeOpsCurve, ShapeOpsSurface};
|
||||
// SolveCraft: exercised by kernel regressions in the workspace CI.
|
||||
#[doc(hidden)]
|
||||
pub use transversal::IntersectionCurveWithParameters;
|
||||
mod alternative;
|
||||
@@ -19,12 +19,21 @@ impl<S> From<IntersectionCurveWithParameters<S>> for IntersectionCurve<PolylineC
|
||||
impl<S> IntersectionCurveWithParameters<S>
|
||||
where S: ParametricSurface3D + SearchNearestParameter<D2, Point = Point3>
|
||||
{
|
||||
/// SolveCraft: construct a projected curve, returning `None` for degenerate input.
|
||||
pub fn try_new(
|
||||
surface0: S,
|
||||
surface1: S,
|
||||
poly: PolylineCurve<Point3>,
|
||||
tol: f64,
|
||||
) -> Option<Self> {
|
||||
// SolveCraft: a point contact is not a curve. Reject it before indexing the
|
||||
// endpoints, and reject collapsed/non-finite segments before normalizing.
|
||||
if poly.len() < 2 || poly.windows(2).any(|p| {
|
||||
let length2 = (p[1] - p[0]).magnitude2();
|
||||
!length2.is_finite() || length2 <= 0.0
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let mut polyline = PolylineCurve(Vec::new());
|
||||
let mut params0 = PolylineCurve(Vec::new());
|
||||
let mut params1 = PolylineCurve(Vec::new());
|
||||
|
||||
@@ -2,6 +2,9 @@ mod divide_face;
|
||||
mod faces_classification;
|
||||
mod integrate;
|
||||
mod intersection_curve;
|
||||
// SolveCraft: expose the constructor for workspace regression coverage of the vendored fix.
|
||||
#[doc(hidden)]
|
||||
pub use intersection_curve::IntersectionCurveWithParameters;
|
||||
mod loops_store;
|
||||
mod polyline_construction;
|
||||
pub use integrate::{and, or, ShapeOpsCurve, ShapeOpsSurface};
|
||||
Reference in new issue
Block a user