Merge branch 'main' into feature/parametric-constraint-system

# Conflicts:
#	src/app/automation.rs
#	src/app/mod.rs
#	src/ui/ribbon/widgets.rs
This commit is contained in:
Felix Riestra committed 2026-09-09 13:35:07 +02:00
commit e2d39b049d
114 files changed
+13641 -1685

No files matched your search

+145 -3
View File
@@ -10,7 +10,39 @@ use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, R
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::TangentGeom;
fn to_render(circle: &Circle) -> RenderEntity {
/// The number of segments to tessellate a circle into, based on its radius and the
/// current viewport's world units per pixel (`wpp`).
///
/// Targets a maximum chord sagitta of ~0.25 pixels on screen so circles stay visually
/// round at any zoom level, while bounded between a minimum of 48 (preventing
/// coarse polygons when small or far away) and a maximum of 4096 (capping buffer size
/// on extreme zoom).
pub fn circle_segments(radius: f64, wpp: Option<f32>) -> usize {
const MIN_SEGMENTS: usize = 48;
const MAX_SEGMENTS: usize = 4096;
let Some(w) = wpp.filter(|&w| w.is_finite() && w > 0.0) else {
return MIN_SEGMENTS;
};
let r = radius.abs();
if !r.is_finite() || r <= 0.0 {
return MIN_SEGMENTS;
}
// Target chord sagitta of ~0.25 px on screen:
// sag = r * (1 - cos(theta / 2)) <= tolerance
let tolerance = (w as f64) * 0.25;
if r <= tolerance {
return MIN_SEGMENTS;
}
let cos_half = (1.0 - tolerance / r).clamp(-1.0, 1.0);
let step = 2.0 * cos_half.acos();
if step <= 0.0 {
return MAX_SEGMENTS;
}
let n = (std::f64::consts::TAU / step).ceil() as usize;
n.clamp(MIN_SEGMENTS, MAX_SEGMENTS)
}
pub fn to_render_with_wpp(circle: &Circle, wpp: Option<f32>) -> RenderEntity {
let cx = circle.center.x;
let cy = circle.center.y;
let cz = circle.center.z;
@@ -31,10 +63,12 @@ fn to_render(circle: &Circle) -> RenderEntity {
radius: r,
};
let n = circle_segments(r, wpp);
if circle.thickness.abs() > 1e-10 {
let t = circle.thickness;
let (nx, ny, nz) = normal;
let n = 64usize;
let n = n.max(64);
let tau = std::f64::consts::TAU;
let circ_pt = |a: f64| -> (f64, f64, f64) {
let (c, s) = (a.cos(), a.sin());
@@ -76,7 +110,8 @@ fn to_render(circle: &Circle) -> RenderEntity {
}
// Keep tessellation on the entity curve for large-coordinate precision.
let pts = crate::entities::curve::curve_points(&curve);
let max_angle = std::f64::consts::TAU / (n as f64);
let pts = curve.tessellate_angle(max_angle);
RenderEntity {
pick_tris: Vec::new(),
@@ -88,6 +123,22 @@ fn to_render(circle: &Circle) -> RenderEntity {
}
}
fn to_render(circle: &Circle) -> RenderEntity {
to_render_with_wpp(circle, None)
}
/// Zoom-aware render converter for EntityType::Circle.
pub fn relative_render(
entity: &acadrust::EntityType,
_document: &acadrust::CadDocument,
wpp: Option<f32>,
) -> Option<RenderEntity> {
let acadrust::EntityType::Circle(circle) = entity else {
return None;
};
Some(to_render_with_wpp(circle, wpp))
}
fn grips(circle: &Circle) -> Vec<GripDef> {
let center = circle.center_wcs();
let (axis_x, axis_y) = circle.axes_wcs();
@@ -220,3 +271,94 @@ impl crate::entities::traits::MassPropsCalc for Circle {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_circle_segments_scales_with_zoom() {
let r = 10.0;
// Default / invalid cases return baseline floor of 48.
assert_eq!(circle_segments(r, None), 48);
assert_eq!(circle_segments(r, Some(-1.0)), 48);
assert_eq!(circle_segments(r, Some(0.0)), 48);
assert_eq!(circle_segments(r, Some(f32::NAN)), 48);
assert_eq!(circle_segments(r, Some(f32::INFINITY)), 48);
// Zoomed far out: screen radius is tiny, capped at floor 48.
assert_eq!(circle_segments(r, Some(10.0)), 48);
assert_eq!(circle_segments(r, Some(2.0)), 48);
// Zooming closer increases segments monotonically.
let seg_0_1 = circle_segments(r, Some(0.1));
let seg_0_01 = circle_segments(r, Some(0.01));
let seg_0_001 = circle_segments(r, Some(0.001));
let seg_0_0001 = circle_segments(r, Some(0.0001));
assert!(seg_0_1 >= 48, "seg_0_1 was {seg_0_1}");
assert!(seg_0_01 > seg_0_1, "seg_0_01 ({seg_0_01}) <= seg_0_1 ({seg_0_1})");
assert!(seg_0_001 > seg_0_01, "seg_0_001 ({seg_0_001}) <= seg_0_01 ({seg_0_01})");
assert!(seg_0_0001 > seg_0_001, "seg_0_0001 ({seg_0_0001}) <= seg_0_001 ({seg_0_001})");
// Extreme zoom in hits maximum cap of 4096.
assert_eq!(circle_segments(r, Some(1e-9)), 4096);
}
#[test]
fn test_circle_segments_scales_with_radius() {
let wpp = Some(0.1);
let seg_small = circle_segments(1.0, wpp);
let seg_med = circle_segments(20.0, wpp);
let seg_large = circle_segments(100.0, wpp);
let seg_huge = circle_segments(500.0, wpp);
assert_eq!(seg_small, 48);
assert!(seg_med > seg_small, "{seg_med} <= {seg_small}");
assert!(seg_large > seg_med, "{seg_large} <= {seg_med}");
assert!(seg_huge > seg_large, "{seg_huge} <= {seg_large}");
}
#[test]
fn test_circle_to_render_points_increase_with_zoom() {
let mut circle = Circle::default();
circle.radius = 10.0;
let far_render = to_render_with_wpp(&circle, Some(1.0));
let close_render = to_render_with_wpp(&circle, Some(0.01));
let far_count = match far_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
let close_count = match close_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
assert_eq!(far_count, 49); // 48 segments + 1 closing vertex
assert!(close_count > far_count, "close_count was {close_count} vs far_count {far_count}");
}
#[test]
fn test_thick_circle_to_render_points_increase_with_zoom() {
let mut circle = Circle::default();
circle.radius = 10.0;
circle.thickness = 2.0;
let far_render = to_render_with_wpp(&circle, Some(1.0));
let close_render = to_render_with_wpp(&circle, Some(0.01));
let far_count = match far_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
let close_count = match close_render.object {
RenderObject::Lines(pts) => pts.len(),
_ => panic!("Expected RenderObject::Lines"),
};
assert!(close_count > far_count, "{close_count} <= {far_count}");
}
}
+38 -3
View File
@@ -114,7 +114,11 @@ pub fn format_length(value: f64) -> String {
let per_foot = 12.0 * scale;
let feet = (total / per_foot).trunc();
let rem = (total - feet * per_foot) / scale;
format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem)
if feet == 0.0 {
format!("{}{:.*}\"", sign, prec, rem)
} else {
format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem)
}
}
4 | 5 => {
// Architectural and fractional formats use 1/64-inch resolution.
@@ -142,6 +146,9 @@ pub fn format_length(value: f64) -> String {
};
let unit_suffix = if ctx.lunits == 4 { "\"" } else { "" };
match feet {
Some(f) if f == 0.0 => {
format!("{}{:.0}{}{}", sign, whole, frac_str, unit_suffix)
}
Some(f) => format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix),
None => format!("{}{:.0}{}", sign, whole, frac_str),
}
@@ -702,6 +709,30 @@ pub fn parse_angle_deg(value: &str) -> Option<f64> {
/// there is one obvious place to see that the maths moved out.
pub use cadkernel::geom2d::BulgeArc;
/// Convert a 2D BulgeArc into a 3D TangentGeom::Arc with its world center,
/// plane axes, radius, and counter-clockwise start/end sweep angles.
pub fn bulge_arc_to_tangent(
arc: &BulgeArc,
to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64),
normal: (f64, f64, f64),
) -> crate::scene::model::wire_model::TangentGeom {
let (cwx, cwy, cwz) = to_wcs(arc.center[0], arc.center[1]);
let (ax, ay) = crate::scene::view::transform::ocs_axes(normal);
let (sa, ea) = if arc.sweep >= 0.0 {
(arc.start_angle, arc.end_angle)
} else {
(arc.end_angle, arc.start_angle)
};
crate::scene::model::wire_model::TangentGeom::Arc {
center: [cwx, cwy, cwz],
axis_x: [ax.0, ax.1, ax.2],
axis_y: [ay.0, ay.1, ay.2],
radius: arc.radius,
start_angle: sa,
end_angle: ea,
}
}
/// Triangulate the solid bands a `wide_fills` returns into the flat WCS f64
/// triangle list `RenderEntity::pick_tris` carries, so a wide polyline is
/// selectable across the band it draws and not just along its centreline.
@@ -1078,7 +1109,9 @@ mod length_format_tests {
#[test]
fn architectural_carries_into_feet() {
assert_eq!(with_units(4, 4, 11.99), "0'-11 63/64\"");
assert_eq!(with_units(4, 4, 11.99), "11 63/64\"");
assert_eq!(with_units(4, 4, 0.5), "0 1/2\"");
assert_eq!(with_units(4, 4, -9.25), "-9 1/4\"");
assert_eq!(with_units(4, 4, 11.999), "1'-0\"");
assert_eq!(with_units(4, 4, 23.999), "2'-0\"");
assert_eq!(with_units(4, 4, 66.5), "5'-6 1/2\"");
@@ -1087,7 +1120,9 @@ mod length_format_tests {
#[test]
fn engineering_carries_into_feet() {
assert_eq!(with_units(3, 1, 11.94), "0'-11.9\"");
assert_eq!(with_units(3, 1, 11.94), "11.9\"");
assert_eq!(with_units(3, 1, 0.5), "0.5\"");
assert_eq!(with_units(3, 1, -9.25), "-9.3\"");
assert_eq!(with_units(3, 1, 11.99), "1'-0.0\"");
assert_eq!(with_units(3, 1, 23.99), "2'-0.0\"");
assert_eq!(with_units(3, 2, 11.999), "1'-0.00\"");
+7 -7
View File
@@ -294,16 +294,16 @@ fn apply_axis_grip(ell: &mut Ellipse, grip_id: usize, point: glam::DVec3) {
fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) {
match (grip_id, apply) {
(0, GripApply::Translate(d)) => {
ell.center.x += d.x as f64;
ell.center.y += d.y as f64;
ell.center.z += d.z as f64;
ell.center.x += d.x;
ell.center.y += d.y;
ell.center.z += d.z;
}
(0, GripApply::Absolute(p)) => {
ell.center.x = p.x as f64;
ell.center.y = p.y as f64;
ell.center.z = p.z as f64;
ell.center.x = p.x;
ell.center.y = p.y;
ell.center.z = p.z;
}
(1 | 2 | 3 | 4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p),
(1..=4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p),
(5 | 6, GripApply::Absolute(p)) => {
let Some(parameter) = parameter_at_point(ell, p) else {
return;
+5 -20
View File
@@ -351,11 +351,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity {
} else if let Some(arc) =
crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge)
{
let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]);
tgs.push(TangentGeom::Circle {
center: [wcx as f32, wcy as f32, wcz as f32],
radius: arc.radius as f32,
});
tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal));
for s in arc
.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
.into_iter()
@@ -407,11 +403,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity {
[v1.location.x, v1.location.y],
v0.bulge,
) {
let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]);
tgs.push(TangentGeom::Circle {
center: [wcx as f32, wcy as f32, wcz as f32],
radius: arc.radius as f32,
});
tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal));
}
}
let (pts, widths) = crate::entities::common::tapered_band_points(
@@ -470,11 +462,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity {
}
let (wx1, wy1, wz1) = to_wcs(ox1, oy1);
kv.push([wx1, wy1, wz1]);
let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]);
tgs.push(TangentGeom::Circle {
center: [wcx as f32, wcy as f32, wcz as f32],
radius: arc.radius as f32,
});
tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal));
}
if i + 1 < seg_count {
pts.push([f64::NAN; 3]);
@@ -565,11 +553,8 @@ fn centerline_metadata(
[end.location.x, end.location.y],
start.bulge,
) {
let center = to_wcs(arc.center[0], arc.center[1]);
tangents.push(TangentGeom::Circle {
center: [center.0 as f32, center.1 as f32, center.2 as f32],
radius: arc.radius as f32,
});
let normal = (pline.normal.x, pline.normal.y, pline.normal.z);
tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, to_wcs, normal));
}
if index == 0 {
key_vertices.push([p0.0, p0.1, p0.2]);
+61
View File
@@ -991,6 +991,67 @@ fn display_mesh(mesh: &Mesh) -> RefinedMesh {
refined
}
/// Convert a closed indexed mesh entity to an exact planar-faced B-rep.
/// Open, non-manifold, non-planar, and degenerate meshes return `None` so a
/// modelling command can leave the source untouched.
pub(crate) fn closed_mesh_body(entity: &acadrust::EntityType) -> Option<cadkernel::brep::Body> {
match entity {
acadrust::EntityType::Mesh(mesh) => {
let base = base_refined_mesh(mesh);
cadkernel::brep::make::faceted_solid(&base.vertices, &base.faces)
}
acadrust::EntityType::PolygonMesh(mesh) => {
if !mesh.is_closed_m() || !mesh.is_closed_n() {
return None;
}
let m = mesh.m_vertex_count.max(0) as usize;
let n = mesh.n_vertex_count.max(0) as usize;
let vertices = mesh
.vertices
.iter()
.take(m.saturating_mul(n))
.map(|vertex| v3(&vertex.location))
.collect::<Vec<_>>();
if m < 2 || n < 2 || vertices.len() != m.saturating_mul(n) {
return None;
}
let mut faces = Vec::with_capacity(m.saturating_mul(n));
for row in 0..m {
for column in 0..n {
faces.push(vec![
row * n + column,
((row + 1) % m) * n + column,
((row + 1) % m) * n + (column + 1) % n,
row * n + (column + 1) % n,
]);
}
}
cadkernel::brep::make::faceted_solid(&vertices, &faces)
}
acadrust::EntityType::PolyfaceMesh(mesh) => {
let vertices = mesh
.vertices
.iter()
.map(|vertex| v3(&vertex.location))
.collect::<Vec<_>>();
let faces = mesh
.faces
.iter()
.filter_map(|face| {
let indices = [face.index1, face.index2, face.index3, face.index4]
.into_iter()
.filter(|index| *index != 0)
.map(|index| (index.unsigned_abs() as usize).checked_sub(1))
.collect::<Option<Vec<_>>>()?;
(indices.len() >= 3).then_some(indices)
})
.collect::<Vec<_>>();
cadkernel::brep::make::faceted_solid(&vertices, &faces)
}
_ => None,
}
}
fn face_triangle_indices(
vertices: &[[f64; 3]],
faces: &[Vec<usize>],
+5 -16
View File
@@ -248,7 +248,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity {
);
}
let (tangent_geoms, key_vertices) =
centerline_metadata_2d(verts, pl.is_closed(), &to_wcs);
centerline_metadata_2d(verts, pl.is_closed(), &to_wcs, normal);
return RenderEntity {
pick_tris: Vec::new(),
object: RenderObject::BoundaryLines {
@@ -296,11 +296,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity {
} else if let Some(arc) =
crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge)
{
let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]);
tgs.push(TangentGeom::Circle {
center: [wcx as f32, wcy as f32, wcz as f32],
radius: arc.radius as f32,
});
tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal));
for s in arc
.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
.into_iter()
@@ -378,11 +374,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity {
[v1.location.x, v1.location.y],
bulge,
) {
let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]);
tangents.push(TangentGeom::Circle {
center: [wcx as f32, wcy as f32, wcz as f32],
radius: arc.radius as f32,
});
tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal));
}
if i == 0 {
@@ -464,6 +456,7 @@ fn centerline_metadata_2d(
verts: &[acadrust::entities::Vertex2D],
closed: bool,
to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64),
normal: (f64, f64, f64),
) -> (Vec<TangentGeom>, Vec<[f64; 3]>) {
let count = verts.len();
let segment_count = if closed {
@@ -488,11 +481,7 @@ fn centerline_metadata_2d(
[end.location.x, end.location.y],
start.bulge,
) {
let center = to_wcs(arc.center[0], arc.center[1]);
tangents.push(TangentGeom::Circle {
center: [center.0 as f32, center.1 as f32, center.2 as f32],
radius: arc.radius as f32,
});
tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, to_wcs, normal));
}
if index == 0 {
key_vertices.push([p0.0, p0.1, p0.2]);