forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
503 lines
17 KiB
Rust
503 lines
17 KiB
Rust
use codec::entities::Circle;
|
|
use crate::t;
|
|
|
|
use crate::command::EntityTransform;
|
|
use crate::entities::common::{
|
|
center_grip, edit_prop as edit, parse_f64, square_grip,
|
|
};
|
|
use crate::entities::traits::RenderConvertible;
|
|
use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject};
|
|
use crate::scene::model::object::{GripApply, GripDef, PropSection};
|
|
use crate::scene::model::wire_model::TangentGeom;
|
|
|
|
/// 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;
|
|
let r = circle.radius;
|
|
let normal = (circle.normal.x, circle.normal.y, circle.normal.z);
|
|
|
|
let (ax, ay) = crate::scene::view::transform::ocs_axes(normal);
|
|
let (cwx, cwy, cwz) = crate::scene::view::transform::ocs_point_to_wcs((cx, cy, cz), normal);
|
|
|
|
// Centre and quadrants come from the entity's own curve, so the same
|
|
// definition answers here, in the tessellation and in a trim.
|
|
let curve = crate::entities::curve::circle_curve(circle);
|
|
let snap_pts = crate::entities::curve::snap_from(&curve).snap_pts;
|
|
let tangent = TangentGeom::PlanarCircle {
|
|
center: [cwx, cwy, cwz],
|
|
axis_x: [ax.0, ax.1, ax.2],
|
|
axis_y: [ay.0, ay.1, ay.2],
|
|
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 = n.max(64);
|
|
let tau = std::f64::consts::TAU;
|
|
let circ_pt = |a: f64| -> (f64, f64, f64) {
|
|
let (c, s) = (a.cos(), a.sin());
|
|
(
|
|
cwx + r * (c * ax.0 + s * ay.0),
|
|
cwy + r * (c * ax.1 + s * ay.1),
|
|
cwz + r * (c * ax.2 + s * ay.2),
|
|
)
|
|
};
|
|
let base: Vec<[f64; 3]> = (0..=n)
|
|
.map(|i| {
|
|
let (x, y, z) = circ_pt(i as f64 * tau / n as f64);
|
|
[x, y, z]
|
|
})
|
|
.collect();
|
|
let mut pts: Vec<[f64; 3]> = Vec::with_capacity((n + 1) * 2 + 4 * 3);
|
|
pts.extend_from_slice(&base);
|
|
pts.push([f64::NAN; 3]);
|
|
for &[x, y, z] in &base {
|
|
pts.push([x + t * nx, y + t * ny, z + t * nz]);
|
|
}
|
|
pts.push([f64::NAN; 3]);
|
|
for i in 0..4usize {
|
|
let (x, y, z) = circ_pt(i as f64 * std::f64::consts::FRAC_PI_2);
|
|
pts.push([x, y, z]);
|
|
pts.push([x + t * nx, y + t * ny, z + t * nz]);
|
|
if i < 3 {
|
|
pts.push([f64::NAN; 3]);
|
|
}
|
|
}
|
|
return RenderEntity {
|
|
pick_tris: extrusion_wall_tris(&base, [t * nx, t * ny, t * nz]),
|
|
object: RenderObject::Lines(pts),
|
|
snap_pts,
|
|
tangent_geoms: vec![tangent],
|
|
key_vertices: vec![],
|
|
fill_tris: vec![],
|
|
};
|
|
}
|
|
|
|
// Keep tessellation on the entity curve for large-coordinate precision.
|
|
let max_angle = std::f64::consts::TAU / (n as f64);
|
|
let pts = curve.tessellate_angle(max_angle);
|
|
|
|
RenderEntity {
|
|
pick_tris: Vec::new(),
|
|
object: RenderObject::Lines(pts),
|
|
snap_pts,
|
|
tangent_geoms: vec![tangent],
|
|
key_vertices: vec![],
|
|
fill_tris: vec![],
|
|
}
|
|
}
|
|
|
|
fn to_render(circle: &Circle) -> RenderEntity {
|
|
to_render_with_wpp(circle, None)
|
|
}
|
|
|
|
/// Zoom-aware render converter for EntityType::Circle.
|
|
pub fn relative_render(
|
|
entity: &codec::EntityType,
|
|
_document: &codec::CadDocument,
|
|
wpp: Option<f32>,
|
|
) -> Option<RenderEntity> {
|
|
let codec::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();
|
|
let ctr = glam::DVec3::new(center.x, center.y, center.z);
|
|
let axis_x = glam::DVec3::new(axis_x.x, axis_x.y, axis_x.z);
|
|
let axis_y = glam::DVec3::new(axis_y.x, axis_y.y, axis_y.z);
|
|
let r = circle.radius;
|
|
vec![
|
|
center_grip(0, ctr),
|
|
square_grip(1, ctr + axis_x * r),
|
|
square_grip(2, ctr + axis_y * r),
|
|
square_grip(3, ctr - axis_x * r),
|
|
square_grip(4, ctr - axis_y * r),
|
|
]
|
|
}
|
|
|
|
fn properties(circle: &Circle) -> Vec<PropSection> {
|
|
use std::f64::consts::PI;
|
|
let r = circle.radius;
|
|
let center = circle.center_wcs();
|
|
vec![PropSection {
|
|
title: t!("Geometry").into_owned(),
|
|
props: vec![
|
|
edit(t!("Center X").as_ref(), "center_x", center.x),
|
|
edit(t!("Center Y").as_ref(), "center_y", center.y),
|
|
edit(t!("Center Z").as_ref(), "center_z", center.z),
|
|
edit(t!("Radius").as_ref(), "radius", r),
|
|
edit(t!("Diameter").as_ref(), "diameter", r * 2.0),
|
|
edit(t!("Circumference").as_ref(), "circumference", 2.0 * PI * r),
|
|
edit(t!("Area").as_ref(), "area", PI * r * r),
|
|
edit(t!("Normal X").as_ref(), "normal_x", circle.normal.x),
|
|
edit(t!("Normal Y").as_ref(), "normal_y", circle.normal.y),
|
|
edit(t!("Normal Z").as_ref(), "normal_z", circle.normal.z),
|
|
],
|
|
}]
|
|
}
|
|
|
|
fn apply_geom_prop(circle: &mut Circle, field: &str, value: &str) {
|
|
use std::f64::consts::PI;
|
|
let Some(v) = parse_f64(value) else {
|
|
return;
|
|
};
|
|
match field {
|
|
"center_x" | "center_y" | "center_z" => {
|
|
let normal = (circle.normal.x, circle.normal.y, circle.normal.z);
|
|
let center = circle.center_wcs();
|
|
let (mut x, mut y, mut z) = (center.x, center.y, center.z);
|
|
match field {
|
|
"center_x" => x = v,
|
|
"center_y" => y = v,
|
|
"center_z" => z = v,
|
|
_ => {}
|
|
}
|
|
let (ox, oy, oz) =
|
|
crate::scene::view::transform::wcs_point_to_ocs((x, y, z), normal);
|
|
circle.center.x = ox;
|
|
circle.center.y = oy;
|
|
circle.center.z = oz;
|
|
}
|
|
"radius" if v > 0.0 => circle.radius = v,
|
|
"diameter" if v > 0.0 => circle.radius = v / 2.0,
|
|
"circumference" if v > 0.0 => circle.radius = v / (2.0 * PI),
|
|
"area" if v > 0.0 => circle.radius = (v / PI).sqrt(),
|
|
"normal_x" | "normal_y" | "normal_z" => {
|
|
let center = circle.center_wcs();
|
|
let mut normal = kernel::space::Vec3::new(
|
|
circle.normal.x,
|
|
circle.normal.y,
|
|
circle.normal.z,
|
|
);
|
|
match field {
|
|
"normal_x" => normal.x = v,
|
|
"normal_y" => normal.y = v,
|
|
"normal_z" => normal.z = v,
|
|
_ => {}
|
|
}
|
|
if let Some(normal) = normal.normalize() {
|
|
circle.normal = codec::types::Vector3::new(normal.x, normal.y, normal.z);
|
|
let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
|
|
(center.x, center.y, center.z),
|
|
(normal.x, normal.y, normal.z),
|
|
);
|
|
circle.center = codec::types::Vector3::new(x, y, z);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn apply_grip(circle: &mut Circle, grip_id: usize, apply: GripApply) {
|
|
match (grip_id, apply) {
|
|
(0, GripApply::Absolute(p)) => {
|
|
let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
|
|
(p.x, p.y, p.z),
|
|
(circle.normal.x, circle.normal.y, circle.normal.z),
|
|
);
|
|
circle.center.x = x;
|
|
circle.center.y = y;
|
|
circle.center.z = z;
|
|
}
|
|
(0, GripApply::Translate(d)) => {
|
|
let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
|
|
(d.x, d.y, d.z),
|
|
(circle.normal.x, circle.normal.y, circle.normal.z),
|
|
);
|
|
circle.center.x += x;
|
|
circle.center.y += y;
|
|
circle.center.z += z;
|
|
}
|
|
(1..=4, GripApply::Absolute(p)) => {
|
|
let plane = crate::entities::curve::circle_curve(circle).plane;
|
|
if let Some(point) = plane.project([p.x, p.y, p.z]) {
|
|
let radius = (point[0] - circle.center.x).hypot(point[1] - circle.center.y);
|
|
if radius > 1.0e-9 {
|
|
circle.radius = radius;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn apply_transform(circle: &mut Circle, t: &EntityTransform) {
|
|
crate::scene::view::transform::apply_standard_entity_transform(circle, t, |entity, p1, p2| {
|
|
crate::scene::view::transform::reflect_xy_point(
|
|
&mut entity.center.x,
|
|
&mut entity.center.y,
|
|
p1,
|
|
p2,
|
|
);
|
|
});
|
|
}
|
|
|
|
impl RenderConvertible for Circle {
|
|
fn to_render(&self, _document: &codec::CadDocument) -> Option<RenderEntity> {
|
|
Some(to_render(self))
|
|
}
|
|
}
|
|
|
|
impl crate::entities::traits::Grippable for Circle {
|
|
fn grips(&self) -> Vec<GripDef> {
|
|
grips(self)
|
|
}
|
|
|
|
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
|
|
apply_grip(self, grip_id, apply);
|
|
}
|
|
|
|
fn grip_menu(
|
|
&self,
|
|
grip_id: usize,
|
|
) -> Vec<crate::scene::model::object::GripMenuItem> {
|
|
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
|
|
let mut items = vec![GripMenuItem {
|
|
label: "Stretch",
|
|
action: GripMenuAction::Stretch,
|
|
}];
|
|
if (1..=4).contains(&grip_id) {
|
|
items.push(GripMenuItem {
|
|
label: "Radius",
|
|
action: GripMenuAction::Radius,
|
|
});
|
|
}
|
|
items
|
|
}
|
|
|
|
fn grip_menu_value_prompt(
|
|
&self,
|
|
_grip_id: usize,
|
|
action: crate::scene::model::object::GripMenuAction,
|
|
) -> Option<&'static str> {
|
|
matches!(action, crate::scene::model::object::GripMenuAction::Radius)
|
|
.then_some("New radius")
|
|
}
|
|
|
|
fn grip_menu_point_value(
|
|
&self,
|
|
grip_id: usize,
|
|
action: crate::scene::model::object::GripMenuAction,
|
|
point: glam::DVec3,
|
|
) -> Option<f64> {
|
|
if !matches!(action, crate::scene::model::object::GripMenuAction::Radius)
|
|
|| !(1..=4).contains(&grip_id)
|
|
{
|
|
return None;
|
|
}
|
|
let plane = crate::entities::curve::circle_curve(self).plane;
|
|
let point = plane.project(point.to_array())?;
|
|
let radius = (point[0] - self.center.x).hypot(point[1] - self.center.y);
|
|
(radius > 1.0e-9).then_some(radius)
|
|
}
|
|
|
|
fn apply_grip_menu_value(
|
|
&mut self,
|
|
grip_id: usize,
|
|
action: crate::scene::model::object::GripMenuAction,
|
|
value: f64,
|
|
) {
|
|
if matches!(action, crate::scene::model::object::GripMenuAction::Radius)
|
|
&& (1..=4).contains(&grip_id)
|
|
&& value > 1.0e-9
|
|
{
|
|
self.radius = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl crate::entities::traits::PropertyEditable for Circle {
|
|
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
|
|
properties(self)
|
|
}
|
|
|
|
fn apply_geom_prop(&mut self, field: &str, value: &str) {
|
|
apply_geom_prop(self, field, value);
|
|
}
|
|
}
|
|
|
|
impl crate::entities::traits::Transformable for Circle {
|
|
fn apply_transform(&mut self, transform: &EntityTransform) {
|
|
apply_transform(self, transform);
|
|
}
|
|
}
|
|
|
|
impl crate::entities::traits::MassPropsCalc for Circle {
|
|
fn mass_props(&self) -> crate::entities::traits::MassProps {
|
|
use std::f64::consts::{PI, TAU};
|
|
let r = self.radius;
|
|
let center = self.center_wcs();
|
|
crate::entities::traits::MassProps {
|
|
area: PI * r * r,
|
|
perimeter: TAU * r,
|
|
cx: center.x,
|
|
cy: center.y,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn editing_normal_preserves_world_center() {
|
|
let mut circle = Circle::from_coords(2.0, 3.0, 4.0, 5.0);
|
|
let center = circle.center_wcs();
|
|
|
|
apply_geom_prop(&mut circle, "normal_x", "1");
|
|
|
|
assert!((circle.center_wcs() - center).length() < 1.0e-9);
|
|
let normal_length =
|
|
(circle.normal.x.powi(2) + circle.normal.y.powi(2) + circle.normal.z.powi(2)).sqrt();
|
|
assert!((normal_length - 1.0).abs() < 1.0e-12);
|
|
}
|
|
|
|
#[test]
|
|
fn quadrant_grips_offer_interactive_radius() {
|
|
use crate::entities::traits::Grippable;
|
|
use crate::scene::model::object::GripMenuAction;
|
|
|
|
let mut circle = Circle::default();
|
|
circle.radius = 2.0;
|
|
|
|
assert_eq!(circle.grip_menu(0).len(), 1);
|
|
assert!(circle
|
|
.grip_menu(1)
|
|
.iter()
|
|
.any(|item| item.action == GripMenuAction::Radius));
|
|
assert_eq!(
|
|
circle.grip_menu_value_prompt(1, GripMenuAction::Radius),
|
|
Some("New radius")
|
|
);
|
|
|
|
circle.apply_grip_menu_value(1, GripMenuAction::Radius, 5.0);
|
|
assert_eq!(circle.radius, 5.0);
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
}
|