From 0aa1f8d30f32d3fcdfeb8e059560c7871861e230 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Nov 2026 14:54:23 +0100 Subject: [PATCH 1/6] Split primitive dispatch by body type The primitive module was at 589 of 600 lines and build_primitive at 77 of 80, with seven new kinds about to land from two lanes. Solid builders move to solids.rs, wire builders and their checks to a new wires.rs, the shared check_arc to sizes.rs, and the Primitive enum with its body_type to kind.rs so the children do not import their parent. build_primitive now picks build_solid or build_wire by body type, so new solid and wire arms land in different files. No behaviour change. --- .../src/world_graph/primitive/kind.rs | 84 ++++ .../src/world_graph/primitive/mod.rs | 402 +----------------- .../src/world_graph/primitive/sizes.rs | 29 ++ .../src/world_graph/primitive/solids.rs | 133 +++++- .../src/world_graph/primitive/wires.rs | 182 ++++++++ 5 files changed, 432 insertions(+), 398 deletions(-) create mode 100644 main/opengeometry/src/world_graph/primitive/kind.rs create mode 100644 main/opengeometry/src/world_graph/primitive/wires.rs diff --git a/main/opengeometry/src/world_graph/primitive/kind.rs b/main/opengeometry/src/world_graph/primitive/kind.rs new file mode 100644 index 00000000..837f5f70 --- /dev/null +++ b/main/opengeometry/src/world_graph/primitive/kind.rs @@ -0,0 +1,84 @@ +use crate::brep::BodyType; +use crate::math::Point3; +use crate::primitives::ProfileSegment; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", deny_unknown_fields)] +pub enum Primitive { + Cuboid { + width: f64, + height: f64, + depth: f64, + }, + Cylinder { + radius: f64, + height: f64, + }, + #[serde(rename_all = "camelCase")] + CylinderSector { + radius: f64, + height: f64, + start_angle: f64, + sweep_angle: f64, + }, + Polyhedron { + points: Vec, + faces: Vec>, + }, + Sphere { + radius: f64, + }, + #[serde(rename_all = "camelCase")] + Cone { + radius_bottom: f64, + radius_top: f64, + height: f64, + }, + #[serde(rename_all = "camelCase")] + Torus { + major_radius: f64, + minor_radius: f64, + }, + Rectangle { + width: f64, + breadth: f64, + }, + Circle { + radius: f64, + }, + Polyline { + points: Vec, + closed: bool, + }, + Profile { + start: [f64; 2], + segments: Vec, + closed: bool, + }, + #[serde(rename_all = "camelCase")] + Arc { + radius: f64, + start_angle: f64, + sweep_angle: f64, + }, +} + +impl Primitive { + pub(super) fn body_type(&self) -> BodyType { + match self { + Self::Cuboid { .. } + | Self::Cylinder { .. } + | Self::CylinderSector { .. } + | Self::Polyhedron { .. } + | Self::Sphere { .. } + | Self::Cone { .. } + | Self::Torus { .. } => BodyType::Solid, + Self::Rectangle { .. } + | Self::Circle { .. } + | Self::Polyline { .. } + | Self::Profile { .. } + | Self::Arc { .. } => BodyType::Wire, + } + } +} diff --git a/main/opengeometry/src/world_graph/primitive/mod.rs b/main/opengeometry/src/world_graph/primitive/mod.rs index 2ce579f8..970918b0 100644 --- a/main/opengeometry/src/world_graph/primitive/mod.rs +++ b/main/opengeometry/src/world_graph/primitive/mod.rs @@ -1,82 +1,21 @@ +mod kind; mod sizes; mod solids; +mod wires; + +pub use kind::Primitive; use super::candidate::IdKind; use super::change_log::ChangeSet; use super::error::{ErrorCode, ErrorContext, GraphError}; use super::graph::WorldGraph; use super::node::EditScope; -use crate::brep::{ - Accuracy, BodyType, BrepEnvelope, CurveGeometry, Frame3, GeometryError, Similarity3, GROUND, -}; -use crate::math::{norm, sub, Point3}; -use crate::primitives::{self, ProfileEdge, ProfileSegment}; +use crate::brep::{Accuracy, BodyType, BrepEnvelope, Frame3, Similarity3, GROUND}; +use crate::math::Point3; use serde::{Deserialize, Serialize}; -use sizes::{parameter, required_size}; -use std::f64::consts::TAU; +use sizes::wrong_body_type; use std::sync::Arc; -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "kind", deny_unknown_fields)] -pub enum Primitive { - Cuboid { - width: f64, - height: f64, - depth: f64, - }, - Cylinder { - radius: f64, - height: f64, - }, - #[serde(rename_all = "camelCase")] - CylinderSector { - radius: f64, - height: f64, - start_angle: f64, - sweep_angle: f64, - }, - Polyhedron { - points: Vec, - faces: Vec>, - }, - Sphere { - radius: f64, - }, - #[serde(rename_all = "camelCase")] - Cone { - radius_bottom: f64, - radius_top: f64, - height: f64, - }, - #[serde(rename_all = "camelCase")] - Torus { - major_radius: f64, - minor_radius: f64, - }, - Rectangle { - width: f64, - breadth: f64, - }, - Circle { - radius: f64, - }, - Polyline { - points: Vec, - closed: bool, - }, - Profile { - start: [f64; 2], - segments: Vec, - closed: bool, - }, - #[serde(rename_all = "camelCase")] - Arc { - radius: f64, - start_angle: f64, - sweep_angle: f64, - }, -} - #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Plane { @@ -94,25 +33,6 @@ pub struct CreateOptions { pub body_type: Option, } -impl Primitive { - fn body_type(&self) -> BodyType { - match self { - Self::Cuboid { .. } - | Self::Cylinder { .. } - | Self::CylinderSector { .. } - | Self::Polyhedron { .. } - | Self::Sphere { .. } - | Self::Cone { .. } - | Self::Torus { .. } => BodyType::Solid, - Self::Rectangle { .. } - | Self::Circle { .. } - | Self::Polyline { .. } - | Self::Profile { .. } - | Self::Arc { .. } => BodyType::Wire, - } - } -} - impl WorldGraph { pub fn create_primitive( &mut self, @@ -281,309 +201,9 @@ fn build_primitive( primitive: Primitive, accuracy: Accuracy, ) -> Result<(BrepEnvelope, Vec), GraphError> { - let result = match primitive { - Primitive::Cuboid { - width, - height, - depth, - } => cuboid_body(shape_id.clone(), [width, height, depth], accuracy)?, - Primitive::Cylinder { radius, height } => { - cylinder_body(shape_id.clone(), [radius, height], accuracy)? - } - Primitive::CylinderSector { - radius, - height, - start_angle, - sweep_angle, - } => sector_body( - shape_id.clone(), - [radius, height], - [start_angle, sweep_angle], - accuracy, - )?, - Primitive::Polyhedron { points, faces } => { - polyhedron_body(shape_id.clone(), &points, &faces, accuracy)? - } - Primitive::Sphere { radius } => solids::sphere_body(shape_id.clone(), radius, accuracy)?, - Primitive::Cone { - radius_bottom, - radius_top, - height, - } => solids::cone_body( - shape_id.clone(), - [radius_bottom, radius_top], - height, - accuracy, - )?, - Primitive::Torus { - major_radius, - minor_radius, - } => solids::torus_body(shape_id.clone(), [major_radius, minor_radius], accuracy)?, - Primitive::Rectangle { width, breadth } => { - rectangle_body(shape_id.clone(), [width, breadth], accuracy)? - } - Primitive::Circle { radius } => { - required_size(radius, accuracy.geometric, "radius")?; - arc_body(shape_id.clone(), radius, [0.0, TAU], accuracy)? - } - Primitive::Arc { - radius, - start_angle, - sweep_angle, - } => { - check_arc("arc", radius, [start_angle, sweep_angle], accuracy)?; - arc_body( - shape_id.clone(), - radius, - [start_angle, sweep_angle], - accuracy, - )? - } - Primitive::Polyline { points, closed } => { - check_polyline(&points, closed, accuracy)?; - let (brep, keys) = - primitives::polyline_with_keys(shape_id.clone(), &points, closed, accuracy)?; - (brep, keys) - } - Primitive::Profile { - start, - segments, - closed, - } => profile_body(shape_id.clone(), start, &segments, closed, accuracy)?, - }; - Ok(result) -} - -fn profile_body( - shape_id: String, - start: [f64; 2], - segments: &[ProfileSegment], - closed: bool, - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - let edges = primitives::profile_segment_edges(start, segments, closed); - check_profile(start, segments, &edges, closed, accuracy.geometric)?; - Ok(primitives::profile_wire_with_keys( - shape_id, start, segments, closed, accuracy, - )?) -} - -fn cuboid_body( - shape_id: String, - [width, height, depth]: [f64; 3], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - for (name, value) in [("width", width), ("height", height), ("depth", depth)] { - required_size(value, accuracy.geometric, name)?; - } - let frame = Frame3 { - origin: [-width / 2.0, 0.0, depth / 2.0], - ..GROUND - }; - Ok(( - primitives::cuboid(shape_id, frame, [width, depth, height], accuracy)?, - Vec::new(), - )) -} - -fn cylinder_body( - shape_id: String, - [radius, height]: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - required_size(radius, accuracy.geometric, "radius")?; - required_size(height, accuracy.geometric, "height")?; - Ok(( - primitives::cylinder(shape_id, GROUND, radius, height, accuracy)?, - Vec::new(), - )) -} - -fn rectangle_body( - shape_id: String, - [width, breadth]: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - required_size(width, accuracy.geometric, "width")?; - required_size(breadth, accuracy.geometric, "breadth")?; - Ok(primitives::rectangle_with_keys( - shape_id, GROUND, width, breadth, accuracy, - )?) -} - -fn polyhedron_body( - shape_id: String, - points: &[Point3], - faces: &[Vec], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - let brep = - primitives::polyhedron(shape_id, points, faces, accuracy).map_err(|error| match error { - GeometryError::InvalidGeometry(message) - | GeometryError::UnresolvedIntersection(message) => { - GraphError::code(ErrorCode::InvalidParameter, message) - } - GeometryError::Math(error) => GraphError::code( - ErrorCode::InvalidParameter, - format!("polyhedron points are out of range: {error:?}"), - ), - other => other.into(), - })?; - Ok((brep, Vec::new())) -} - -fn sector_body( - shape_id: String, - [radius, height]: [f64; 2], - angles: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - check_arc("sector", radius, angles, accuracy)?; - required_size(height, accuracy.geometric, "height")?; - let [start_angle, sweep_angle] = angles; - Ok(( - primitives::cylinder_sector( - shape_id, - GROUND, - radius, - height, - start_angle, - sweep_angle, - accuracy, - )?, - Vec::new(), - )) -} - -fn arc_body( - shape_id: String, - radius: f64, - [start_angle, sweep_angle]: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - let curve = CurveGeometry::Circle { - frame: GROUND, - radius, - }; - Ok(( - primitives::arc_wire(shape_id, curve, start_angle, sweep_angle, accuracy)?, - vec!["edge-0".into()], - )) -} - -fn check_polyline(points: &[Point3], closed: bool, accuracy: Accuracy) -> Result<(), GraphError> { - if points.len() < if closed { 3 } else { 2 } { - return Err(GraphError::code( - ErrorCode::InvalidParameter, - "polyline has too few points", - )); - } - if points.iter().flatten().any(|value| !value.is_finite()) { - return Err(GraphError::code( - ErrorCode::InvalidParameter, - "polyline points must be finite", - )); + match primitive.body_type() { + BodyType::Solid => solids::build_solid(shape_id, primitive, accuracy), + BodyType::Wire => wires::build_wire(shape_id, primitive, accuracy), + BodyType::Sheet => Err(wrong_body_type("solid or a wire")), } - for index in 0..if closed { - points.len() - } else { - points.len() - 1 - } { - let next = (index + 1) % points.len(); - if norm(sub(points[next], points[index])) <= 4.0 * accuracy.geometric { - return Err(GraphError::code( - ErrorCode::InvalidParameter, - "polyline has a zero-length segment", - )); - } - } - Ok(()) -} - -fn check_arc( - kind: &str, - radius: f64, - [start, sweep]: [f64; 2], - accuracy: Accuracy, -) -> Result<(), GraphError> { - required_size(radius, accuracy.geometric, "radius")?; - if !start.is_finite() || !sweep.is_finite() || sweep == 0.0 || sweep.abs() >= TAU { - return Err(parameter(&format!( - "{kind} sweep must be nonzero and less than one turn" - ))); - } - if radius * sweep.abs() <= 4.0 * accuracy.geometric { - return Err(parameter(&format!( - "{kind} length must exceed four times geometric tolerance" - ))); - } - Ok(()) -} - -fn check_profile( - start: [f64; 2], - segments: &[ProfileSegment], - edges: &[ProfileEdge], - closed: bool, - g: f64, -) -> Result<(), GraphError> { - if segments.len() < if closed { 2 } else { 1 } { - return Err(parameter("profile has too few segments")); - } - let values = segments.iter().flat_map(|segment| { - segment - .to - .into_iter() - .chain(segment.arc.into_iter().flat_map(|arc| arc.center)) - }); - if !start.into_iter().chain(values).all(f64::is_finite) { - return Err(parameter("profile points must be finite")); - } - let last = segments[segments.len() - 1].to; - if closed != ((last[0] - start[0]).hypot(last[1] - start[1]) <= 4.0 * g) { - return Err(parameter(if closed { - "closed profile must end at its start" - } else { - "open profile must not end at its start" - })); - } - for (index, (edge, segment)) in edges.iter().zip(segments).enumerate() { - let to = if closed && index + 1 == segments.len() { - start - } else { - segment.to - }; - check_profile_edge(edge, to, g)?; - } - if primitives::profile_self_intersects(edges, closed, g) { - return Err(parameter("profile self-intersects")); - } - if closed && primitives::profile_area_twice(edges).abs() <= 16.0 * g * g { - return Err(parameter("profile area is below geometric resolution")); - } - Ok(()) -} - -fn check_profile_edge(edge: &ProfileEdge, to: [f64; 2], g: f64) -> Result<(), GraphError> { - let (from, _) = edge.endpoints(); - if (to[0] - from[0]).hypot(to[1] - from[1]) <= 4.0 * g { - return Err(parameter("profile has a zero-length segment")); - } - if let ProfileEdge::Arc { - center, - radius, - sweep_angle, - .. - } = *edge - { - if radius <= 4.0 * g - || ((to[0] - center[0]).hypot(to[1] - center[1]) - radius).abs() > g / 4.0 - { - return Err(parameter("profile arc does not end on its circle")); - } - if sweep_angle.abs() >= TAU || radius * sweep_angle.abs() <= 4.0 * g { - return Err(parameter("profile arc is below geometric resolution")); - } - } - Ok(()) } diff --git a/main/opengeometry/src/world_graph/primitive/sizes.rs b/main/opengeometry/src/world_graph/primitive/sizes.rs index 2290a7f6..69a590bc 100644 --- a/main/opengeometry/src/world_graph/primitive/sizes.rs +++ b/main/opengeometry/src/world_graph/primitive/sizes.rs @@ -1,4 +1,6 @@ +use crate::brep::Accuracy; use crate::world_graph::error::{ErrorCode, GraphError}; +use std::f64::consts::TAU; pub(super) fn required_size(value: f64, accuracy: f64, name: &str) -> Result<(), GraphError> { if !value.is_finite() || value <= 4.0 * accuracy { @@ -13,3 +15,30 @@ pub(super) fn required_size(value: f64, accuracy: f64, name: &str) -> Result<(), pub(super) fn parameter(message: &str) -> GraphError { GraphError::code(ErrorCode::InvalidParameter, message) } + +pub(super) fn wrong_body_type(expected: &str) -> GraphError { + GraphError::code( + ErrorCode::BodyTypeMismatch, + format!("primitive is not a {expected}"), + ) +} + +pub(super) fn check_arc( + kind: &str, + radius: f64, + [start, sweep]: [f64; 2], + accuracy: Accuracy, +) -> Result<(), GraphError> { + required_size(radius, accuracy.geometric, "radius")?; + if !start.is_finite() || !sweep.is_finite() || sweep == 0.0 || sweep.abs() >= TAU { + return Err(parameter(&format!( + "{kind} sweep must be nonzero and less than one turn" + ))); + } + if radius * sweep.abs() <= 4.0 * accuracy.geometric { + return Err(parameter(&format!( + "{kind} length must exceed four times geometric tolerance" + ))); + } + Ok(()) +} diff --git a/main/opengeometry/src/world_graph/primitive/solids.rs b/main/opengeometry/src/world_graph/primitive/solids.rs index 870075fd..057f993a 100644 --- a/main/opengeometry/src/world_graph/primitive/solids.rs +++ b/main/opengeometry/src/world_graph/primitive/solids.rs @@ -1,10 +1,129 @@ -use super::sizes::{parameter, required_size}; -use crate::brep::{Accuracy, BrepEnvelope, Frame3, GROUND}; -use crate::math::scale; +use super::kind::Primitive; +use super::sizes::{check_arc, parameter, required_size, wrong_body_type}; +use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError, GROUND}; +use crate::math::{scale, Point3}; use crate::primitives; -use crate::world_graph::error::GraphError; +use crate::world_graph::error::{ErrorCode, GraphError}; -pub(super) fn sphere_body( +pub(super) fn build_solid( + shape_id: String, + primitive: Primitive, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let result = match primitive { + Primitive::Cuboid { + width, + height, + depth, + } => cuboid_body(shape_id, [width, height, depth], accuracy)?, + Primitive::Cylinder { radius, height } => { + cylinder_body(shape_id, [radius, height], accuracy)? + } + Primitive::CylinderSector { + radius, + height, + start_angle, + sweep_angle, + } => sector_body( + shape_id, + [radius, height], + [start_angle, sweep_angle], + accuracy, + )?, + Primitive::Polyhedron { points, faces } => { + polyhedron_body(shape_id, &points, &faces, accuracy)? + } + Primitive::Sphere { radius } => sphere_body(shape_id, radius, accuracy)?, + Primitive::Cone { + radius_bottom, + radius_top, + height, + } => cone_body(shape_id, [radius_bottom, radius_top], height, accuracy)?, + Primitive::Torus { + major_radius, + minor_radius, + } => torus_body(shape_id, [major_radius, minor_radius], accuracy)?, + _ => return Err(wrong_body_type("solid")), + }; + Ok(result) +} + +fn cuboid_body( + shape_id: String, + [width, height, depth]: [f64; 3], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + for (name, value) in [("width", width), ("height", height), ("depth", depth)] { + required_size(value, accuracy.geometric, name)?; + } + let frame = Frame3 { + origin: [-width / 2.0, 0.0, depth / 2.0], + ..GROUND + }; + Ok(( + primitives::cuboid(shape_id, frame, [width, depth, height], accuracy)?, + Vec::new(), + )) +} + +fn cylinder_body( + shape_id: String, + [radius, height]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + required_size(radius, accuracy.geometric, "radius")?; + required_size(height, accuracy.geometric, "height")?; + Ok(( + primitives::cylinder(shape_id, GROUND, radius, height, accuracy)?, + Vec::new(), + )) +} + +fn polyhedron_body( + shape_id: String, + points: &[Point3], + faces: &[Vec], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let brep = + primitives::polyhedron(shape_id, points, faces, accuracy).map_err(|error| match error { + GeometryError::InvalidGeometry(message) + | GeometryError::UnresolvedIntersection(message) => { + GraphError::code(ErrorCode::InvalidParameter, message) + } + GeometryError::Math(error) => GraphError::code( + ErrorCode::InvalidParameter, + format!("polyhedron points are out of range: {error:?}"), + ), + other => other.into(), + })?; + Ok((brep, Vec::new())) +} + +fn sector_body( + shape_id: String, + [radius, height]: [f64; 2], + angles: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + check_arc("sector", radius, angles, accuracy)?; + required_size(height, accuracy.geometric, "height")?; + let [start_angle, sweep_angle] = angles; + Ok(( + primitives::cylinder_sector( + shape_id, + GROUND, + radius, + height, + start_angle, + sweep_angle, + accuracy, + )?, + Vec::new(), + )) +} + +fn sphere_body( shape_id: String, radius: f64, accuracy: Accuracy, @@ -16,7 +135,7 @@ pub(super) fn sphere_body( )) } -pub(super) fn cone_body( +fn cone_body( shape_id: String, [bottom, top]: [f64; 2], height: f64, @@ -49,7 +168,7 @@ pub(super) fn cone_body( Ok((brep, Vec::new())) } -pub(super) fn torus_body( +fn torus_body( shape_id: String, [major, minor]: [f64; 2], accuracy: Accuracy, diff --git a/main/opengeometry/src/world_graph/primitive/wires.rs b/main/opengeometry/src/world_graph/primitive/wires.rs new file mode 100644 index 00000000..7fa4756a --- /dev/null +++ b/main/opengeometry/src/world_graph/primitive/wires.rs @@ -0,0 +1,182 @@ +use super::kind::Primitive; +use super::sizes::{check_arc, parameter, required_size, wrong_body_type}; +use crate::brep::{Accuracy, BrepEnvelope, CurveGeometry, GROUND}; +use crate::math::{norm, sub, Point3}; +use crate::primitives::{self, ProfileEdge, ProfileSegment}; +use crate::world_graph::error::{ErrorCode, GraphError}; +use std::f64::consts::TAU; + +pub(super) fn build_wire( + shape_id: String, + primitive: Primitive, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let result = match primitive { + Primitive::Rectangle { width, breadth } => { + rectangle_body(shape_id, [width, breadth], accuracy)? + } + Primitive::Circle { radius } => { + required_size(radius, accuracy.geometric, "radius")?; + arc_body(shape_id, radius, [0.0, TAU], accuracy)? + } + Primitive::Arc { + radius, + start_angle, + sweep_angle, + } => { + check_arc("arc", radius, [start_angle, sweep_angle], accuracy)?; + arc_body(shape_id, radius, [start_angle, sweep_angle], accuracy)? + } + Primitive::Polyline { points, closed } => { + check_polyline(&points, closed, accuracy)?; + let (brep, keys) = primitives::polyline_with_keys(shape_id, &points, closed, accuracy)?; + (brep, keys) + } + Primitive::Profile { + start, + segments, + closed, + } => profile_body(shape_id, start, &segments, closed, accuracy)?, + _ => return Err(wrong_body_type("wire")), + }; + Ok(result) +} + +fn rectangle_body( + shape_id: String, + [width, breadth]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + required_size(width, accuracy.geometric, "width")?; + required_size(breadth, accuracy.geometric, "breadth")?; + Ok(primitives::rectangle_with_keys( + shape_id, GROUND, width, breadth, accuracy, + )?) +} + +fn arc_body( + shape_id: String, + radius: f64, + [start_angle, sweep_angle]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let curve = CurveGeometry::Circle { + frame: GROUND, + radius, + }; + Ok(( + primitives::arc_wire(shape_id, curve, start_angle, sweep_angle, accuracy)?, + vec!["edge-0".into()], + )) +} + +fn check_polyline(points: &[Point3], closed: bool, accuracy: Accuracy) -> Result<(), GraphError> { + if points.len() < if closed { 3 } else { 2 } { + return Err(GraphError::code( + ErrorCode::InvalidParameter, + "polyline has too few points", + )); + } + if points.iter().flatten().any(|value| !value.is_finite()) { + return Err(GraphError::code( + ErrorCode::InvalidParameter, + "polyline points must be finite", + )); + } + for index in 0..if closed { + points.len() + } else { + points.len() - 1 + } { + let next = (index + 1) % points.len(); + if norm(sub(points[next], points[index])) <= 4.0 * accuracy.geometric { + return Err(GraphError::code( + ErrorCode::InvalidParameter, + "polyline has a zero-length segment", + )); + } + } + Ok(()) +} + +fn profile_body( + shape_id: String, + start: [f64; 2], + segments: &[ProfileSegment], + closed: bool, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let edges = primitives::profile_segment_edges(start, segments, closed); + check_profile(start, segments, &edges, closed, accuracy.geometric)?; + Ok(primitives::profile_wire_with_keys( + shape_id, start, segments, closed, accuracy, + )?) +} + +fn check_profile( + start: [f64; 2], + segments: &[ProfileSegment], + edges: &[ProfileEdge], + closed: bool, + g: f64, +) -> Result<(), GraphError> { + if segments.len() < if closed { 2 } else { 1 } { + return Err(parameter("profile has too few segments")); + } + let values = segments.iter().flat_map(|segment| { + segment + .to + .into_iter() + .chain(segment.arc.into_iter().flat_map(|arc| arc.center)) + }); + if !start.into_iter().chain(values).all(f64::is_finite) { + return Err(parameter("profile points must be finite")); + } + let last = segments[segments.len() - 1].to; + if closed != ((last[0] - start[0]).hypot(last[1] - start[1]) <= 4.0 * g) { + return Err(parameter(if closed { + "closed profile must end at its start" + } else { + "open profile must not end at its start" + })); + } + for (index, (edge, segment)) in edges.iter().zip(segments).enumerate() { + let to = if closed && index + 1 == segments.len() { + start + } else { + segment.to + }; + check_profile_edge(edge, to, g)?; + } + if primitives::profile_self_intersects(edges, closed, g) { + return Err(parameter("profile self-intersects")); + } + if closed && primitives::profile_area_twice(edges).abs() <= 16.0 * g * g { + return Err(parameter("profile area is below geometric resolution")); + } + Ok(()) +} + +fn check_profile_edge(edge: &ProfileEdge, to: [f64; 2], g: f64) -> Result<(), GraphError> { + let (from, _) = edge.endpoints(); + if (to[0] - from[0]).hypot(to[1] - from[1]) <= 4.0 * g { + return Err(parameter("profile has a zero-length segment")); + } + if let ProfileEdge::Arc { + center, + radius, + sweep_angle, + .. + } = *edge + { + if radius <= 4.0 * g + || ((to[0] - center[0]).hypot(to[1] - center[1]) - radius).abs() > g / 4.0 + { + return Err(parameter("profile arc does not end on its circle")); + } + if sweep_angle.abs() >= TAU || radius * sweep_angle.abs() <= 4.0 * g { + return Err(parameter("profile arc is below geometric resolution")); + } + } + Ok(()) +} From 59b38b4f18dacbd336c20c529f4b8cdf11281160 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Nov 2026 15:08:11 +0100 Subject: [PATCH 2/6] Name the 3.0 replacements for wedges and the 2.0 curve The documents said wedges and curves other than lines, circles and arcs have no replacement in 3.0. A wedge builds today as a five-face polyhedron or an extruded triangle, and the 2.0 Curve was a polyline through its control points, which OG_PRIMITIVE_POLYLINE makes. The elliptical arc has no replacement, and 3.0 has no spline, ellipse or elliptical arc; the text keeps saying so. The migration page gains a checked example for each replacement, the 2.5 guide moves Wedge and Curve to "Partly replaced", and the variable holding a cylinder sector is no longer called wedge. --- CHANGELOG.md | 6 +- MIGRATION.md | 8 ++- README.md | 4 +- docs/opengeometry/api/constants.mdx | 4 +- docs/opengeometry/changelog.mdx | 2 +- docs/opengeometry/migrate-from-2-0.mdx | 75 +++++++++++++++++++++- docs/opengeometry/what-is-opengeometry.mdx | 2 +- 7 files changed, 90 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f2bac7..61142c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,11 @@ published. Follow [MIGRATION.md](./MIGRATION.md): it gives every change with cod ### Removed - **Breaking:** IFC file text, STL and PDF export, projection, loft, triangulation as a public - call, curves other than lines, circles and arcs, and wedges. They have no replacement in 3.0. + call, and curves other than lines, circles, arcs and polylines, such as the elliptical arc. + They have no replacement in 3.0. 3.0 has no spline, no ellipse and no elliptical arc. The + wedge and the 2.0 `Curve` class are gone too. Build a wedge as a five-face + `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its + points. ### Fixed diff --git a/MIGRATION.md b/MIGRATION.md index 1af10d5f..cfa55af4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -73,7 +73,7 @@ const sector = new AnalyticSolid({ height: 3, }); const arc = new Arc({ center: new Vector3(0, 0, 0), radius: 2, startAngle: 0, sweepAngle: Math.PI / 2 }); -const wedge = new AnalyticSolid({ kind: 'cylinderSector', radius: 2, height: 3, startAngle: 0, sweepAngle: Math.PI / 2 }); +const quarterCylinder = new AnalyticSolid({ kind: 'cylinderSector', radius: 2, height: 3, startAngle: 0, sweepAngle: Math.PI / 2 }); const roof = new AnalyticSolid({ kind: 'planarPolyhedron', vertices: [[0, 0, 0], [4, 0, 0], [4, 0, 2], [0, 0, 2], [1, 1, 1], [3, 1, 1]], @@ -115,7 +115,7 @@ const sector = new Solid(OG_OPERATION_EXTRUDE, { profile, distance: 3 }); const arc = new Wire(OG_PRIMITIVE_ARC, OG_PRIMITIVE_PARAMS_ARC({ radius: 2, startAngle: 0, sweepAngle: Math.PI / 2 })); -const wedge = new Solid( +const quarterCylinder = new Solid( OG_PRIMITIVE_CYLINDER_SECTOR, OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR({ radius: 2, height: 3, startAngle: 0, sweepAngle: Math.PI / 2 }), ); @@ -760,6 +760,8 @@ In the 2.5 column, a `?` after a parameter name marks it as optional, and rows t - `WorldGraph`: nodes and hierarchy become `SystemAssembly`, `addChild` or the `parent` option; `getWorldBounds(id)` becomes `body.getBounds()`, six numbers instead of a `THREE.Box3`; `removeNode(id)` becomes `parent.removeChild(body)`, which keeps the body (pass `{ keepWorld: true }` to leave it where it is), or `body.dispose()`, which frees it and everything under it; a body with no parent has only `dispose()`; `exportStep` becomes `OpenGeometry.exportStep({ nodes })` (nodes may be bodies or assemblies). Its `clash`, `clearance`, `distance`, `projectLines`, `projectViews` and `bind` are gone. - The pattern helpers (`AnalyticPattern`, `linearPattern`, `rectangularPattern`, `circularPattern`): make copies with `solid.instance()` and move each with `transform`; check the positions, since 2.0 drew a pattern around its own origin. `rebuild` and `operate` on an instanced body need `{ instances: 'all' }` or `makeUnique()` first. - `AnalyticSolid` kinds: `linearExtrusion` becomes `OG_OPERATION_EXTRUDE` with closed polyline wires for the outline and each hole, each 2.0 point `[u, v]` written `[u, 0, -v]` when the 2.0 `frame` was the default (with another `frame`, also give each wire `plane: { origin: frame.origin, normal: frame.z, xDirection: frame.x }` and check which way it extrudes); `revolvedRectangle` (a tube) becomes `OG_OPERATION_EXTRUDE` with a circle wire as `profile` and a smaller circle wire with the same centre as the one hole. +- `Wedge`: `OG_OPERATION_EXTRUDE` with a closed triangle polyline wire as `profile`; give the wire `plane: { normal: [0, 0, 1], xDirection: [1, 0, 0] }` so it extrudes along z as the 2.0 wedge did, and move it by the 2.0 `center`; from 3.0, also a five-face `OG_PRIMITIVE_POLYHEDRON`. +- `Curve`: a polyline through its control points, so `new Wire(OG_PRIMITIVE_POLYLINE, { points })` through the same points. ### Removed in 2.5 @@ -767,7 +769,7 @@ These are gone: - STL and IFC export. PDF export was never in the browser build. - Projection and 2D views, offset, loft, and public triangulation (`tessellate_brep`, `tessellateFacetedBrep`). -- `Sphere`, `Wedge`, `Curve`, `EllipticalArc`, `AnalyticCurve`, arcs that are not full circles, and cylinder sectors. +- `Sphere`, `EllipticalArc`, `AnalyticCurve`, arcs that are not full circles, and cylinder sectors. - `AnalyticSolid` kinds other than cuboid, cylinder, `linearExtrusion` and `revolvedRectangle`, `AnalyticSolid.fromBrep`, and `new Solid({ brep })`. - `WorldGraph`'s queries and projection: `clash`, `clearance`, `distance`, `projectLines`, `projectViews` and `bind`. - The older scene manager (`add*ToScene`, `replaceBrepEntityInScene`, `refreshBrepEntityInScene`), already removed in 2.0.16. diff --git a/README.md b/README.md index 68fc2992..253afe77 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ It is the geometry engine layer, not a full CAD application. Floorkit is a downs | **Queries** | Bounds, placements, the B-rep of a body, that B-rep placed in the world, the normal of a face at a point, whether a point is inside, volume and area within a stated error bound, whether a body is valid and closed, and the report of its last boolean. | | **Export** | STEP, in millimetres or metres, with Y or Z up, together with an export report. The IFC exchange body of a solid, in world metres with Z up, for an IFC writer. | -Not in 3.0, though 2.0 had them: IFC file text and STL export; projection; loft; triangulation as a public call; curves other than lines, circles and arcs; wedges; and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. +Not in 3.0, though 2.0 had them: IFC file text and STL export; projection; loft; triangulation as a public call; curves other than lines, circles, arcs and polylines, such as the elliptical arc; and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. 3.0 has no spline, no ellipse and no elliptical arc. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. ## Installation @@ -218,7 +218,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o - Call `dispose()` on bodies and assemblies you no longer need. - Catch `OGError` and branch on `code`. After a `'fatal'` event the runtime refuses every call until `OpenGeometry.reset()`. -**What does not exist.** IFC file text, STL and PDF export, projection, loft, public triangulation, curves other than lines, circles and arcs, wedges, and the 2.0 classes. Do not call them. +**What does not exist.** IFC file text, STL and PDF export, projection, loft, public triangulation, curves other than lines, circles, arcs and polylines, such as the elliptical arc, and the 2.0 classes. Do not call them. 3.0 has no spline, no ellipse and no elliptical arc. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. **Where the truth is.** `index.d.ts` in the installed package lists every export and its types. The examples in [`main/opengeometry-three/examples-vite/`](./main/opengeometry-three/examples-vite/) are working 3.0 code, except that they import the built package by path (`../../../dist/index.js`) where an app imports from `'opengeometry'`. diff --git a/docs/opengeometry/api/constants.mdx b/docs/opengeometry/api/constants.mdx index ce9fefdc..3b5a03d5 100644 --- a/docs/opengeometry/api/constants.mdx +++ b/docs/opengeometry/api/constants.mdx @@ -85,8 +85,8 @@ const params = OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR({ startAngle: 0, sweepAngle: Math.PI / 2, }); -const wedge = new Solid(OG_PRIMITIVE_CYLINDER_SECTOR, params); -console.log(wedge.validity().valid); +const sector = new Solid(OG_PRIMITIVE_CYLINDER_SECTOR, params); +console.log(sector.validity().valid); ``` ## Parameter types diff --git a/docs/opengeometry/changelog.mdx b/docs/opengeometry/changelog.mdx index 19cf3a3a..e25b4931 100644 --- a/docs/opengeometry/changelog.mdx +++ b/docs/opengeometry/changelog.mdx @@ -21,7 +21,7 @@ rss: true - **Breaking: the shell hollows in place.** Use `solid.operate(OG_OPERATION_SHELL, { thickness })`. - **Breaking: display follows drawing.** A body gets its mesh when it is drawn or walked. Its `surface` and `outline` meshes are static: call `updateMatrix()` after you move them yourself. - **Smooth edges.** Outlines leave out smooth edges, and faceted curves shade smooth. Set `appearance.smoothAngle` to `0` for the 2.0 look. -- **Removed:** IFC file text, STL and PDF export, projection, loft, public triangulation, curves other than lines, circles and arcs, and wedges. +- **Removed:** IFC file text, STL and PDF export, projection, loft, public triangulation, and curves other than lines, circles, arcs and polylines, such as the elliptical arc. 3.0 has no spline, no ellipse and no elliptical arc. The wedge and the 2.0 `Curve` class are gone too. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. ### Added diff --git a/docs/opengeometry/migrate-from-2-0.mdx b/docs/opengeometry/migrate-from-2-0.mdx index ca0f4eab..285d8b9a 100644 --- a/docs/opengeometry/migrate-from-2-0.mdx +++ b/docs/opengeometry/migrate-from-2-0.mdx @@ -23,7 +23,80 @@ The full guide is [MIGRATION.md](https://github.com/OpenGeometry-io/OpenGeometry ## Removed -These 2.0 features are not in 3.0, and have no replacement: IFC file text, STL and PDF export, projection, loft, triangulation as a public call, curves other than lines, circles and arcs, and wedges. Take that code out, or stay on 2.0. +These 2.0 features are not in 3.0, and have no replacement: IFC file text, STL and PDF export, projection, loft, triangulation as a public call, and curves other than lines, circles, arcs and polylines, such as the elliptical arc. Take that code out, or stay on 2.0. 3.0 has no spline, no ellipse and no elliptical arc. + +The 2.0 wedge and the 2.0 `Curve` class are gone too. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points, as the next section shows. + +## Wedges and the 2.0 curve + +A wedge is a five-face polyhedron: two triangles and three rectangles. Give its points and its faces to `OG_PRIMITIVE_POLYHEDRON`. Each face is a ring of point indices, counter-clockwise seen from outside. + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_PARAMS_POLYHEDRON, + OG_PRIMITIVE_POLYHEDRON, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const ramp = new Solid( + OG_PRIMITIVE_POLYHEDRON, + OG_PRIMITIVE_PARAMS_POLYHEDRON({ + points: [ + [0, 0, 0], + [2, 0, 0], + [2, 0, 1], + [0, 0, 1], + [0, 1, 0], + [0, 1, 1], + ], + faces: [ + [0, 1, 2, 3], + [0, 3, 5, 4], + [0, 4, 1], + [3, 2, 5], + [1, 4, 5, 2], + ], + }), +); +console.log(ramp.validity().valid); +``` + +This ramp is 2 m long, 1 m wide and 1 m high at its tall end. You can also extrude a closed triangle: a closed `OG_PRIMITIVE_POLYLINE` wire as the profile of `OG_OPERATION_EXTRUDE`. + +The 2.0 `Curve` was a polyline through its control points. In 3.0, make an `OG_PRIMITIVE_POLYLINE` wire through the same points. + +```ts +import { + OpenGeometry, + Wire, + OG_PRIMITIVE_PARAMS_POLYLINE, + OG_PRIMITIVE_POLYLINE, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const curve = new Wire( + OG_PRIMITIVE_POLYLINE, + OG_PRIMITIVE_PARAMS_POLYLINE({ + points: [ + [0, 0, 0], + [1, 0, 0.5], + [2, 0, 0.5], + [3, 0, 0], + ], + }), +); +console.log(curve.bodyType); +``` ## How to move diff --git a/docs/opengeometry/what-is-opengeometry.mdx b/docs/opengeometry/what-is-opengeometry.mdx index 1e8aed0f..caab7ade 100644 --- a/docs/opengeometry/what-is-opengeometry.mdx +++ b/docs/opengeometry/what-is-opengeometry.mdx @@ -30,7 +30,7 @@ It also fits AI-assisted CAD. An assistant or an agent proposes the modelling st ## What 3.0 does not have -IFC file text, STL and PDF export, projection, loft, triangulation as a public call, curves other than lines, circles and arcs, wedges, and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. Code that uses them is written for 2.0. See [Migrate from 2.0](/opengeometry/migrate-from-2-0). +IFC file text, STL and PDF export, projection, loft, triangulation as a public call, curves other than lines, circles, arcs and polylines, such as the elliptical arc, and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. Code that uses them is written for 2.0. 3.0 has no spline, no ellipse and no elliptical arc. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. See [Migrate from 2.0](/opengeometry/migrate-from-2-0). ## Next steps From 7d7de5fc1bab1fd69156b08b2d7042dd2e4e18b7 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Nov 2026 15:47:40 +0100 Subject: [PATCH 3/6] Add slot prism, rounded box and pipe segment solid primitives Each kind extrudes an outline of lines and arcs with the arc-edged extrusion, standing on its plane like the cuboid (the pipe segment placed and angled like the cylinder sector). The outlines are pure functions so the wire kinds can reuse them. Bad sizes, a corner radius at or past half the width or depth, a sweep of zero or a full turn, and an inner radius at or past the outer one return InvalidParameter naming the value. The SDK gains the three kind constants, params helpers and types; the docs, examples, changelog and README list them; the golden file gains the three new builder keys only. A slot width too narrow for its end arcs is refused as a bad width too. --- CHANGELOG.md | 8 ++ README.md | 4 +- docs/api-coverage.json | 45 +++++++ docs/opengeometry/api/constants.mdx | 31 +++-- docs/opengeometry/api/overview.mdx | 18 +-- docs/opengeometry/api/solid.mdx | 27 ++-- docs/opengeometry/changelog.mdx | 8 ++ docs/opengeometry/examples.mdx | 3 + docs/opengeometry/guides/primitives.mdx | 61 +++++++-- .../examples-vite/index.html | 3 + .../examples-vite/shapes/pipe-segment.html | 89 +++++++++++++ .../examples-vite/shapes/pipe-segment.ts | 14 ++ .../examples-vite/shapes/rounded-box.html | 78 ++++++++++++ .../examples-vite/shapes/rounded-box.ts | 8 ++ .../examples-vite/shapes/slot-prism.html | 67 ++++++++++ .../examples-vite/shapes/slot-prism.ts | 8 ++ .../examples-vite/shared/parametric-solid.ts | 83 ++++++++++++ .../examples-vite/vite.config.ts | 3 + main/opengeometry-three/index.ts | 2 +- .../src/constants/primitives.ts | 7 + main/opengeometry-three/src/dto/primitives.ts | 5 + .../tests/node/primitives.test.mjs | 49 +++++++ .../src/primitives/internal/mod.rs | 2 + .../src/primitives/internal/outlines.rs | 55 ++++++++ main/opengeometry/src/primitives/mod.rs | 3 +- .../src/world_graph/primitive/kind.rs | 24 +++- .../src/world_graph/primitive/solids.rs | 94 ++++++++++++++ .../test-support/src/scenes/bar.rs | 120 ++++++++++++++++++ .../test-support/src/scenes/mod.rs | 1 + main/opengeometry/tests/creating/main.rs | 4 + .../tests/creating/pipe_segment.rs | 91 +++++++++++++ .../tests/creating/rounded_box.rs | 87 +++++++++++++ .../opengeometry/tests/creating/slot_prism.rs | 76 +++++++++++ main/opengeometry/tests/creating/standard.rs | 83 ++++++++++++ .../fixtures/golden/aarch64-apple-darwin.json | 3 + .../tests/golden/builders_solids.rs | 18 +++ 36 files changed, 1236 insertions(+), 46 deletions(-) create mode 100644 main/opengeometry-three/examples-vite/shapes/pipe-segment.html create mode 100644 main/opengeometry-three/examples-vite/shapes/pipe-segment.ts create mode 100644 main/opengeometry-three/examples-vite/shapes/rounded-box.html create mode 100644 main/opengeometry-three/examples-vite/shapes/rounded-box.ts create mode 100644 main/opengeometry-three/examples-vite/shapes/slot-prism.html create mode 100644 main/opengeometry-three/examples-vite/shapes/slot-prism.ts create mode 100644 main/opengeometry-three/examples-vite/shared/parametric-solid.ts create mode 100644 main/opengeometry/src/primitives/internal/outlines.rs create mode 100644 main/opengeometry/test-support/src/scenes/bar.rs create mode 100644 main/opengeometry/tests/creating/pipe_segment.rs create mode 100644 main/opengeometry/tests/creating/rounded_box.rs create mode 100644 main/opengeometry/tests/creating/slot_prism.rs create mode 100644 main/opengeometry/tests/creating/standard.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 61142c81..13571a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the `opengeometry` package are in this file. The format f [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Solid primitives `OG_PRIMITIVE_SLOT_PRISM`, `OG_PRIMITIVE_ROUNDED_BOX` and + `OG_PRIMITIVE_PIPE_SEGMENT`, with their `OG_PRIMITIVE_PARAMS_*` helpers and the + `SlotPrismParams`, `RoundedBoxParams` and `PipeSegmentParams` types. + ## [3.0.0] - 2026-11-09 3.0 is a new API. Code written for 2.0 does not run without changes. Version 2.5 was not diff --git a/README.md b/README.md index 253afe77..a645a252 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ It is the geometry engine layer, not a full CAD application. Floorkit is a downs | Area | What 3.0 has | | --- | --- | -| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron. Each can be placed on any plane. | +| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment. Each can be placed on any plane. | | **Operations** | Extrude a closed wire into a solid, with holes. Sweep a profile along an open or closed path of lines and arcs, each arc an exact bend, capped into a solid, open-ended into a sheet, or closed into a ring. Revolve a closed line-and-arc profile a full turn about an axis in its plane. Shell a solid into a hollow one with walls of one thickness. Union, subtract and intersect, with one to a hundred tool solids in one call. | | **Parametric edits** | `rebuild` gives an existing body new parameters in place. `mirror` rebuilds a body as its reflection across a plane. | | **Assemblies** | Assemblies with parent and child placement; translate, rotate, scale and place; independent copies and shared instances. | @@ -167,7 +167,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | `Wire` | `new Wire(kind, params, options?)`. `OG_PRIMITIVE_RECTANGLE` `{ width, breadth }`, `OG_PRIMITIVE_CIRCLE` `{ radius }`, `OG_PRIMITIVE_POLYLINE` `{ points, closed? }`. | | Profile wire | `new Wire(OG_PRIMITIVE_PROFILE, { start, segments, closed }, options?)`. Points are `[u, v]` in `plane`; a segment is `{ to }` for a line or `{ to, arc: { center, counterClockwise } }` for an arc. A closed profile is an extrude profile or hole. | | Arc wire | `new Wire(OG_PRIMITIVE_ARC, { radius, startAngle, sweepAngle }, options?)`. Angles in radians from the `plane` x direction, centred on the `plane` origin; the sweep is nonzero and less than one turn. | -| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis); `profile`, `holes` and `path` are wires. | +| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis), `OG_PRIMITIVE_SLOT_PRISM` `{ length, width, height }` (a stadium standing on the `plane`, centred on its origin; `length` between the arc centres along the x direction), `OG_PRIMITIVE_ROUNDED_BOX` `{ width, height, depth, radius }` (a cuboid with its four upright edges rounded; `radius` below half the width and half the depth), `OG_PRIMITIVE_PIPE_SEGMENT` `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` (a ring sector placed and angled like the cylinder sector); `profile`, `holes` and `path` are wires. | | `Sheet` | `new Sheet(OG_OPERATION_SWEEP, { profile, path, caps: false })`: an open-ended sweep, faces that enclose no volume. It shows its surface, from the front only, and its outline; `OpenGeometry.exportStep` refuses it. | | Body options | `{ ogId?, parent?, appearance?, plane? }`. `plane` is `{ origin?, normal?, xDirection? }` and applies to primitives only; by default it is the ground plane through the origin, normal `[0, 1, 0]`, x direction `[1, 0, 0]`. | | Booleans | `solid.operate(kind, { tools, instances? })` with `OG_OPERATION_SUBTRACT`, `OG_OPERATION_UNION` or `OG_OPERATION_INTERSECT`; `tools` is one to a hundred solids. | diff --git a/docs/api-coverage.json b/docs/api-coverage.json index 0f545529..9841fd39 100644 --- a/docs/api-coverage.json +++ b/docs/api-coverage.json @@ -129,6 +129,21 @@ "kind": "type", "page": "opengeometry/api/constants" }, + { + "export": "SlotPrismParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "RoundedBoxParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "PipeSegmentParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, { "export": "CurvedRegion2D", "kind": "type", @@ -219,6 +234,21 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_SLOT_PRISM", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_ROUNDED_BOX", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PIPE_SEGMENT", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_PRIMITIVE_PARAMS_RECTANGLE", "kind": "value", @@ -279,6 +309,21 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_PARAMS_SLOT_PRISM", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_ROUNDED_BOX", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_PIPE_SEGMENT", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_OPERATION_EXTRUDE", "kind": "value", diff --git a/docs/opengeometry/api/constants.mdx b/docs/opengeometry/api/constants.mdx index 3b5a03d5..4f6c15fd 100644 --- a/docs/opengeometry/api/constants.mdx +++ b/docs/opengeometry/api/constants.mdx @@ -22,6 +22,9 @@ Each kind is a string constant. Pass it as the first argument of a constructor, | `OG_PRIMITIVE_SPHERE` | `'Sphere'` | Solid | `SphereParams` | | `OG_PRIMITIVE_CONE` | `'Cone'` | Solid | `ConeParams` | | `OG_PRIMITIVE_TORUS` | `'Torus'` | Solid | `TorusParams` | +| `OG_PRIMITIVE_SLOT_PRISM` | `'SlotPrism'` | Solid | `SlotPrismParams` | +| `OG_PRIMITIVE_ROUNDED_BOX` | `'RoundedBox'` | Solid | `RoundedBoxParams` | +| `OG_PRIMITIVE_PIPE_SEGMENT` | `'PipeSegment'` | Solid | `PipeSegmentParams` | ## Operations @@ -65,6 +68,9 @@ Each helper returns its argument unchanged. It only gives the object a type, so | `OG_OPERATION_PARAMS_EXTRUDE` | `{ profile, holes?, distance }` | | `OG_OPERATION_PARAMS_SWEEP` | `{ profile, path, caps? }` | | `OG_OPERATION_PARAMS_REVOLVE` | `{ profile, axis? }` | +| `OG_PRIMITIVE_PARAMS_SLOT_PRISM` | `SlotPrismParams` | +| `OG_PRIMITIVE_PARAMS_ROUNDED_BOX` | `RoundedBoxParams` | +| `OG_PRIMITIVE_PARAMS_PIPE_SEGMENT` | `PipeSegmentParams` | ```ts import { @@ -91,17 +97,20 @@ console.log(sector.validity().valid); ## Parameter types -| Type | Fields | -| ---------------------- | ------------------------------------------------------- | -| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | -| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | -| `ProfileArc` | `center: [u, v]`, `counterClockwise` | -| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | -| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | -| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | -| `SphereParams` | `radius` | -| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | -| `TorusParams` | `majorRadius`, `minorRadius` | +| Type | Fields | +| ---------------------- | ------------------------------------------------------------------ | +| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | +| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | +| `ProfileArc` | `center: [u, v]`, `counterClockwise` | +| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | +| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | +| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | +| `SphereParams` | `radius` | +| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | +| `TorusParams` | `majorRadius`, `minorRadius` | +| `SlotPrismParams` | `length`, `width`, `height` | +| `RoundedBoxParams` | `width`, `height`, `depth`, `radius` | +| `PipeSegmentParams` | `outerRadius`, `innerRadius`, `height`, `startAngle`, `sweepAngle` | ## OG_REGION_TOLERANCE diff --git a/docs/opengeometry/api/overview.mdx b/docs/opengeometry/api/overview.mdx index 02235b3b..9783d4cd 100644 --- a/docs/opengeometry/api/overview.mdx +++ b/docs/opengeometry/api/overview.mdx @@ -28,15 +28,15 @@ The methods that `Wire`, `Solid` and `Sheet` share are on [Body methods](/openge ## Types -| Type | Page | -| ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | -| `HostGroup` | [Display](/opengeometry/api/display) | -| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | -| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | -| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | -| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | -| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams` | [Constants](/opengeometry/api/constants) | +| Type | Page | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | +| `HostGroup` | [Display](/opengeometry/api/display) | +| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | +| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | +| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | +| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | +| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams`, `SlotPrismParams`, `RoundedBoxParams`, `PipeSegmentParams` | [Constants](/opengeometry/api/constants) | ## Conventions diff --git a/docs/opengeometry/api/solid.mdx b/docs/opengeometry/api/solid.mdx index a836ed71..4bdb91f5 100644 --- a/docs/opengeometry/api/solid.mdx +++ b/docs/opengeometry/api/solid.mdx @@ -34,18 +34,21 @@ console.log(base.getReport()?.report.operation); `new Solid(kind: string, params: Record, options?: BodyOptions)` -| `kind` | `params` | -| ------------------------------ | -------------------------------------------- | -| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | -| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | -| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | -| `OG_PRIMITIVE_SPHERE` | `{ radius }` | -| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | -| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | -| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | -| `OG_OPERATION_EXTRUDE` | `{ profile, holes?, distance }` | -| `OG_OPERATION_SWEEP` | `{ profile, path, caps? }` | -| `OG_OPERATION_REVOLVE` | `{ profile, axis? }` | +| `kind` | `params` | +| ------------------------------ | -------------------------------------------------------------- | +| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | +| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | +| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | +| `OG_PRIMITIVE_SPHERE` | `{ radius }` | +| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | +| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | +| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | +| `OG_OPERATION_EXTRUDE` | `{ profile, holes?, distance }` | +| `OG_OPERATION_SWEEP` | `{ profile, path, caps? }` | +| `OG_OPERATION_REVOLVE` | `{ profile, axis? }` | +| `OG_PRIMITIVE_SLOT_PRISM` | `{ length, width, height }` | +| `OG_PRIMITIVE_ROUNDED_BOX` | `{ width, height, depth, radius }` | +| `OG_PRIMITIVE_PIPE_SEGMENT` | `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` | `profile`, `holes` and `path` are wires. A sweep with `caps: false` makes a sheet, so `new Solid` throws `BodyTypeMismatch` for it. diff --git a/docs/opengeometry/changelog.mdx b/docs/opengeometry/changelog.mdx index e25b4931..dd61d49d 100644 --- a/docs/opengeometry/changelog.mdx +++ b/docs/opengeometry/changelog.mdx @@ -5,6 +5,14 @@ icon: clock-rotate-left rss: true --- + + +### Added + +- Solid primitives: slot prism, rounded box and pipe segment. See [Primitives](/opengeometry/guides/primitives). + + + 3.0 is a new API. Code written for 2.0 does not run without changes. Follow [Migrate from 2.0](/opengeometry/migrate-from-2-0). diff --git a/docs/opengeometry/examples.mdx b/docs/opengeometry/examples.mdx index 4e53cf25..4fd0bb68 100644 --- a/docs/opengeometry/examples.mdx +++ b/docs/opengeometry/examples.mdx @@ -19,6 +19,9 @@ The examples are working 3.0 code. They are in [`main/opengeometry-three/example | `shapes/cuboid.html` | A cuboid with width, height, depth and an outline toggle | | `shapes/cylinder.html` | An exact cylinder. Click a face to inspect it | | `shapes/rounded-profile.html` | A section with rounded corners, extruded from a line-and-arc profile | +| `shapes/slot-prism.html` | A slot prism with length, width and height controls | +| `shapes/rounded-box.html` | A rounded box with width, height, depth and corner radius controls | +| `shapes/pipe-segment.html` | A pipe segment with both radii, height and the two angles as controls | ## Run them diff --git a/docs/opengeometry/guides/primitives.mdx b/docs/opengeometry/guides/primitives.mdx index b714b70c..179e4997 100644 --- a/docs/opengeometry/guides/primitives.mdx +++ b/docs/opengeometry/guides/primitives.mdx @@ -58,15 +58,18 @@ A profile refuses repeated points, an arc whose end is not on its circle, segmen ## Solids -| Kind | Parameters | Notes | -| ------------------------------ | -------------------------------------------- | ---------------------------------------------------------------------------------- | -| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | Standing on the plane, centred on the origin in u and v. `height` along the normal | -| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | Standing on the plane, axis along the normal | -| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | Angles in radians from the plane's x direction | -| `OG_PRIMITIVE_SPHERE` | `{ radius }` | Centred on the plane origin | -| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | A cone or frustum. One radius may be 0. Nearly equal radii make a cylinder | -| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | A ring torus lying on the plane. The minor radius must be below the major one | -| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | Planar faces as rings of point indices, counter-clockwise seen from outside | +| Kind | Parameters | Notes | +| ------------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | Standing on the plane, centred on the origin in u and v. `height` along the normal | +| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | Standing on the plane, axis along the normal | +| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | Angles in radians from the plane's x direction | +| `OG_PRIMITIVE_SPHERE` | `{ radius }` | Centred on the plane origin | +| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | A cone or frustum. One radius may be 0. Nearly equal radii make a cylinder | +| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | A ring torus lying on the plane. The minor radius must be below the major one | +| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | Planar faces as rings of point indices, counter-clockwise seen from outside | +| `OG_PRIMITIVE_SLOT_PRISM` | `{ length, width, height }` | A stadium standing on the plane, centred in u and v. `length` between the arc centres along u, `width` along v | +| `OG_PRIMITIVE_ROUNDED_BOX` | `{ width, height, depth, radius }` | A cuboid with its four upright edges rounded. `radius` below half the width and half the depth | +| `OG_PRIMITIVE_PIPE_SEGMENT` | `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` | A ring sector, placed and angled like the cylinder sector. The sweep is below one full turn | ```ts import { @@ -101,6 +104,46 @@ A size that is not finite, or not above four times the geometric tolerance, thro Not built: horn, spindle and lemon tori, whose minor radius is equal to or above the major one. +A slot, a rounded box and a pipe segment are straight extrusions of lines and arcs, so they cut and join like any arc-edged solid: + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_PARAMS_PIPE_SEGMENT, + OG_PRIMITIVE_PARAMS_ROUNDED_BOX, + OG_PRIMITIVE_PARAMS_SLOT_PRISM, + OG_PRIMITIVE_PIPE_SEGMENT, + OG_PRIMITIVE_ROUNDED_BOX, + OG_PRIMITIVE_SLOT_PRISM, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const slot = new Solid( + OG_PRIMITIVE_SLOT_PRISM, + OG_PRIMITIVE_PARAMS_SLOT_PRISM({ length: 0.6, width: 0.2, height: 0.05 }), +); +const plinth = new Solid( + OG_PRIMITIVE_ROUNDED_BOX, + OG_PRIMITIVE_PARAMS_ROUNDED_BOX({ width: 1.2, height: 0.1, depth: 0.6, radius: 0.08 }), +); +const bend = new Solid( + OG_PRIMITIVE_PIPE_SEGMENT, + OG_PRIMITIVE_PARAMS_PIPE_SEGMENT({ + outerRadius: 0.3, + innerRadius: 0.25, + height: 0.2, + startAngle: 0, + sweepAngle: Math.PI / 2, + }), +); +console.log(slot.ogId, plinth.ogId, bend.ogId); +``` + ## Typed parameters `OG_PRIMITIVE_PARAMS_(params)` returns its argument unchanged. It only gives the object a type, so your editor checks the field names. The entry exports the types too, such as `ProfileParams`, `ArcParams`, `SphereParams`, `ConeParams` and `TorusParams`. diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index 9f6cc0e4..4e11b7b5 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -27,6 +27,9 @@

Shapes

Cuboid Cylinder Rounded section + Slot prism + Rounded box + Pipe segment

Sweeps

Sweep frames Arc path sweep diff --git a/main/opengeometry-three/examples-vite/shapes/pipe-segment.html b/main/opengeometry-three/examples-vite/shapes/pipe-segment.html new file mode 100644 index 00000000..c6c128f9 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/pipe-segment.html @@ -0,0 +1,89 @@ + + + + OpenGeometry Pipe Segment Example + + + + + + + +
+ Back + Pipe Segment +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/pipe-segment.ts b/main/opengeometry-three/examples-vite/shapes/pipe-segment.ts new file mode 100644 index 00000000..9438579e --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/pipe-segment.ts @@ -0,0 +1,14 @@ +import { startParametricSolid } from '../shared/parametric-solid'; +import { OG_PRIMITIVE_PIPE_SEGMENT, OG_PRIMITIVE_PARAMS_PIPE_SEGMENT } from '../../../dist/index.js'; + +await startParametricSolid({ + kind: OG_PRIMITIVE_PIPE_SEGMENT, + title: 'Pipe segment', + params: OG_PRIMITIVE_PARAMS_PIPE_SEGMENT({ + outerRadius: 0.8, + innerRadius: 0.6, + height: 0.5, + startAngle: 0, + sweepAngle: 1.57, + }), +}); diff --git a/main/opengeometry-three/examples-vite/shapes/rounded-box.html b/main/opengeometry-three/examples-vite/shapes/rounded-box.html new file mode 100644 index 00000000..cfdf77fe --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/rounded-box.html @@ -0,0 +1,78 @@ + + + + OpenGeometry Rounded Box Example + + + + + + + +
+ Back + Rounded Box +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/rounded-box.ts b/main/opengeometry-three/examples-vite/shapes/rounded-box.ts new file mode 100644 index 00000000..0939403d --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/rounded-box.ts @@ -0,0 +1,8 @@ +import { startParametricSolid } from '../shared/parametric-solid'; +import { OG_PRIMITIVE_ROUNDED_BOX, OG_PRIMITIVE_PARAMS_ROUNDED_BOX } from '../../../dist/index.js'; + +await startParametricSolid({ + kind: OG_PRIMITIVE_ROUNDED_BOX, + title: 'Rounded box', + params: OG_PRIMITIVE_PARAMS_ROUNDED_BOX({ width: 1.2, height: 0.6, depth: 0.8, radius: 0.15 }), +}); diff --git a/main/opengeometry-three/examples-vite/shapes/slot-prism.html b/main/opengeometry-three/examples-vite/shapes/slot-prism.html new file mode 100644 index 00000000..e834c8ef --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/slot-prism.html @@ -0,0 +1,67 @@ + + + + OpenGeometry Slot Prism Example + + + + + + + +
+ Back + Slot Prism +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/slot-prism.ts b/main/opengeometry-three/examples-vite/shapes/slot-prism.ts new file mode 100644 index 00000000..3b61e569 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/slot-prism.ts @@ -0,0 +1,8 @@ +import { startParametricSolid } from '../shared/parametric-solid'; +import { OG_PRIMITIVE_SLOT_PRISM, OG_PRIMITIVE_PARAMS_SLOT_PRISM } from '../../../dist/index.js'; + +await startParametricSolid({ + kind: OG_PRIMITIVE_SLOT_PRISM, + title: 'Slot prism', + params: OG_PRIMITIVE_PARAMS_SLOT_PRISM({ length: 1, width: 0.5, height: 0.4 }), +}); diff --git a/main/opengeometry-three/examples-vite/shared/parametric-solid.ts b/main/opengeometry-three/examples-vite/shared/parametric-solid.ts new file mode 100644 index 00000000..e81d109d --- /dev/null +++ b/main/opengeometry-three/examples-vite/shared/parametric-solid.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { numberControl, onNumberInput } from './controls'; +import { requiredElement } from './dom'; +import { startFpsMeter } from './fps'; +import { bootExample } from './kernel'; +import { releaseOnUnload } from './unload'; +import { fitRenderer } from './viewport'; +import { OpenGeometry, Solid } from '../../../dist/index.js'; + +export type ParametricSolid = { kind: string; title: string; params: Record }; + +type Stage = { scene: THREE.Scene; camera: THREE.PerspectiveCamera; renderer: THREE.WebGLRenderer }; + +function createStage(app: HTMLElement): Stage { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xeef2ff); + scene.add(new THREE.HemisphereLight(0xffffff, 0x526070, 2)); + const light = new THREE.DirectionalLight(0xffffff, 2); + light.position.set(5, 8, 5); + scene.add(light); + scene.add(new THREE.GridHelper(10, 20, 0x4460ff, 0xd5ddff)); + const camera = new THREE.PerspectiveCamera(50, 1, 0.05, 100); + camera.position.set(2.2, 1.8, 2.6); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); + app.append(renderer.domElement); + return { scene, camera, renderer }; +} + +function rebuildOnInput(page: ParametricSolid, solid: Solid, scene: THREE.Scene, status: HTMLElement): () => void { + let frame = 0; + const update = (): void => { + const started = performance.now(); + try { + solid.rebuild(page.kind, page.params); + scene.updateMatrixWorld(true); + OpenGeometry.flush({ geometry: 'sync' }); + if (solid.record?.revision !== solid.lastInfo.shapeRevision) { + throw new Error(`${page.title} geometry did not finish`); + } + status.textContent = `${page.title} ready · ${(performance.now() - started).toFixed(1)} ms`; + } catch (error) { status.textContent = `${page.title} error: ${String(error)}`; } + }; + for (const name of Object.keys(page.params)) { + onNumberInput(numberControl(name), (value) => { + page.params[name] = value; + if (frame) return; + frame = window.requestAnimationFrame(() => { frame = 0; update(); }); + }); + } + return () => { if (frame) window.cancelAnimationFrame(frame); }; +} + +export async function startParametricSolid(page: ParametricSolid): Promise { + const app = requiredElement('#app', HTMLDivElement); + const status = requiredElement('#status', HTMLElement); + await bootExample('../'); + const { scene, camera, renderer } = createStage(app); + const controls = new OrbitControls(camera, renderer.domElement); + controls.target.set(0, 0.3, 0); + controls.update(); + const solid = new Solid(page.kind, page.params, { ogId: page.kind }); + solid.setAppearance({ color: 0x0ea5e9, outline: true, deflection: 0.002 }); + scene.add(solid); + const render = (): void => { renderer.render(scene, camera); }; + const resizeObserver = new ResizeObserver(() => { fitRenderer(app, camera, renderer); render(); }); + resizeObserver.observe(app); + const cancelUpdate = rebuildOnInput(page, solid, scene, status); + controls.addEventListener('start', () => { OpenGeometry.setCameraMotion(true); }); + controls.addEventListener('end', () => { OpenGeometry.setCameraMotion(false); render(); }); + const unsubscribeError = OpenGeometry.on('error', (error) => { + status.textContent = `Geometry error: ${String(error)}`; + }); + const ready = await OpenGeometry.settled(); + if (ready.failed.length) throw new Error(`${page.title} geometry failed`); + render(); + status.textContent = `${page.title} ready`; + const stopFpsMeter = startFpsMeter(renderer, scene, camera, controls); + releaseOnUnload( + { resizeObserver, stopFpsMeter, unsubscribeError, controls, renderer }, cancelUpdate, () => { solid.dispose(); }, + ); +} diff --git a/main/opengeometry-three/examples-vite/vite.config.ts b/main/opengeometry-three/examples-vite/vite.config.ts index f7da2652..aeeafce0 100644 --- a/main/opengeometry-three/examples-vite/vite.config.ts +++ b/main/opengeometry-three/examples-vite/vite.config.ts @@ -24,6 +24,9 @@ export default defineConfig({ arcPathSweep: `${ROOT}/operations/sweep-arc-path.html`, curvedMeetsStraight: `${ROOT}/operations/curved-meets-straight.html`, stepExport: `${ROOT}/operations/step-export.html`, + slotPrism: `${ROOT}/shapes/slot-prism.html`, + roundedBox: `${ROOT}/shapes/rounded-box.html`, + pipeSegment: `${ROOT}/shapes/pipe-segment.html`, }, }, }, diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 62ea1cbf..c9ee650c 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -16,7 +16,7 @@ export type { AnalyticExchangeBodyV2, IfcPreparedValue } from './src/dto/ifc-bod export * from './src/constants/index'; export type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileArc, ProfileParams, ProfileSegment, - SphereParams, TorusParams, + SphereParams, TorusParams, SlotPrismParams, RoundedBoxParams, PipeSegmentParams, } from './src/dto/primitives'; export type { CurvedRegion2D, CurvedRegionEdge, OffsetHole, OffsetPath2D, OffsetPolylineOptions, OffsetRegion, diff --git a/main/opengeometry-three/src/constants/primitives.ts b/main/opengeometry-three/src/constants/primitives.ts index b1f9bbcf..49fa0d36 100644 --- a/main/opengeometry-three/src/constants/primitives.ts +++ b/main/opengeometry-three/src/constants/primitives.ts @@ -1,5 +1,6 @@ import type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileParams, SphereParams, TorusParams, + SlotPrismParams, RoundedBoxParams, PipeSegmentParams, } from '../dto/primitives'; export const OG_PRIMITIVE_RECTANGLE = 'Rectangle' as const; @@ -14,6 +15,9 @@ export const OG_PRIMITIVE_CONE = 'Cone' as const; export const OG_PRIMITIVE_TORUS = 'Torus' as const; export const OG_PRIMITIVE_PROFILE = 'Profile' as const; export const OG_PRIMITIVE_ARC = 'Arc' as const; +export const OG_PRIMITIVE_SLOT_PRISM = 'SlotPrism' as const; +export const OG_PRIMITIVE_ROUNDED_BOX = 'RoundedBox' as const; +export const OG_PRIMITIVE_PIPE_SEGMENT = 'PipeSegment' as const; export const OG_PRIMITIVE_PARAMS_RECTANGLE = ( value: { width: number; breadth: number }, @@ -35,3 +39,6 @@ export const OG_PRIMITIVE_PARAMS_POLYHEDRON = (value: PolyhedronParams): Polyhed export const OG_PRIMITIVE_PARAMS_SPHERE = (value: SphereParams): SphereParams => value; export const OG_PRIMITIVE_PARAMS_CONE = (value: ConeParams): ConeParams => value; export const OG_PRIMITIVE_PARAMS_TORUS = (value: TorusParams): TorusParams => value; +export const OG_PRIMITIVE_PARAMS_SLOT_PRISM = (value: SlotPrismParams): SlotPrismParams => value; +export const OG_PRIMITIVE_PARAMS_ROUNDED_BOX = (value: RoundedBoxParams): RoundedBoxParams => value; +export const OG_PRIMITIVE_PARAMS_PIPE_SEGMENT = (value: PipeSegmentParams): PipeSegmentParams => value; diff --git a/main/opengeometry-three/src/dto/primitives.ts b/main/opengeometry-three/src/dto/primitives.ts index f5f694c9..7d5e6c6d 100644 --- a/main/opengeometry-three/src/dto/primitives.ts +++ b/main/opengeometry-three/src/dto/primitives.ts @@ -7,3 +7,8 @@ export type PolyhedronParams = { points: [number, number, number][]; faces: numb export type SphereParams = { radius: number }; export type ConeParams = { radiusBottom: number; radiusTop: number; height: number }; export type TorusParams = { majorRadius: number; minorRadius: number }; +export type SlotPrismParams = { length: number; width: number; height: number }; +export type RoundedBoxParams = { width: number; height: number; depth: number; radius: number }; +export type PipeSegmentParams = { + outerRadius: number; innerRadius: number; height: number; startAngle: number; sweepAngle: number; +}; diff --git a/main/opengeometry-three/tests/node/primitives.test.mjs b/main/opengeometry-three/tests/node/primitives.test.mjs index 0e7376c8..67822c14 100644 --- a/main/opengeometry-three/tests/node/primitives.test.mjs +++ b/main/opengeometry-three/tests/node/primitives.test.mjs @@ -22,6 +22,12 @@ import { OG_PRIMITIVE_SPHERE, OG_PRIMITIVE_TORUS, OG_TRANSFORM_TRANSLATE, + OG_PRIMITIVE_SLOT_PRISM, + OG_PRIMITIVE_ROUNDED_BOX, + OG_PRIMITIVE_PIPE_SEGMENT, + OG_PRIMITIVE_PARAMS_SLOT_PRISM, + OG_PRIMITIVE_PARAMS_ROUNDED_BOX, + OG_PRIMITIVE_PARAMS_PIPE_SEGMENT, } from '../../../dist/index.js'; import { boot, failsWith } from './support.mjs'; @@ -166,3 +172,46 @@ test('an arc-edged pan minus a blind inset is one Solid built by the prismatic c const { text } = await OpenGeometry.exportStep({ nodes: [pan] }); assert.equal(text.match(/CYLINDRICAL_SURFACE/g)?.length, 2); }); + +const STANDARD_SOLIDS = [ + { + kind: OG_PRIMITIVE_SLOT_PRISM, + params: OG_PRIMITIVE_PARAMS_SLOT_PRISM({ length: 1, width: 0.5, height: 1 }), + cylinders: 2, + volume: 0.5 + Math.PI / 16, + }, + { + kind: OG_PRIMITIVE_ROUNDED_BOX, + params: OG_PRIMITIVE_PARAMS_ROUNDED_BOX({ width: 1, height: 1, depth: 1, radius: 0.1 }), + cylinders: 4, + volume: 1 - (4 - Math.PI) * 0.01, + }, + { + kind: OG_PRIMITIVE_PIPE_SEGMENT, + params: OG_PRIMITIVE_PARAMS_PIPE_SEGMENT({ + outerRadius: 0.5, innerRadius: 0.3, height: 1, startAngle: 0, sweepAngle: Math.PI / 2, + }), + cylinders: 2, + volume: Math.PI * 0.16 / 4, + }, +]; + +test('slot prism, rounded box and pipe segment Solids have exact volumes and cylindrical faces', async () => { + await boot(); + for (const { kind, params, cylinders, volume } of STANDARD_SOLIDS) { + const solid = new Solid(kind, params, { ogId: kind }); + assert.equal(solid.lastInfo.bodyType, 'Solid'); + const measured = solid.measure({ deflection: 1e-4 }); + assert.equal(Math.abs(measured.volume - volume) <= measured.errorBound, true, kind); + const { text } = await OpenGeometry.exportStep({ nodes: [solid] }); + assert.equal(text.match(/CYLINDRICAL_SURFACE/g)?.length, cylinders, kind); + } +}); + +test('a rounded box radius of half its width and an inverted pipe segment are InvalidParameter', async () => { + await boot(); + const corner = { width: 1, height: 1, depth: 2, radius: 0.5 }; + assert.throws(() => new Solid(OG_PRIMITIVE_ROUNDED_BOX, corner, { ogId: 'corner' }), failsWith('InvalidParameter')); + const pipe = { outerRadius: 0.3, innerRadius: 0.5, height: 1, startAngle: 0, sweepAngle: 1 }; + assert.throws(() => new Solid(OG_PRIMITIVE_PIPE_SEGMENT, pipe, { ogId: 'pipe' }), failsWith('InvalidParameter')); +}); diff --git a/main/opengeometry/src/primitives/internal/mod.rs b/main/opengeometry/src/primitives/internal/mod.rs index 363c3451..e9eb3305 100644 --- a/main/opengeometry/src/primitives/internal/mod.rs +++ b/main/opengeometry/src/primitives/internal/mod.rs @@ -6,6 +6,7 @@ mod cylinder_with_circular_hole; mod extruded_loop; mod frustum; mod linear_extrusion; +mod outlines; mod revolved_profile; mod sphere; mod torus; @@ -17,6 +18,7 @@ pub use cylinder_sector::cylinder_sector; pub use cylinder_with_circular_hole::cylinder_with_circular_hole; pub use frustum::frustum; pub use linear_extrusion::linear_extrusion; +pub use outlines::{ring_sector_outline, rounded_rectangle_outline, slot_outline}; pub use revolved_profile::revolved_profile; pub use sphere::sphere; pub use torus::torus; diff --git a/main/opengeometry/src/primitives/internal/outlines.rs b/main/opengeometry/src/primitives/internal/outlines.rs new file mode 100644 index 00000000..929bcb28 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/outlines.rs @@ -0,0 +1,55 @@ +use crate::primitives::ProfileEdge; +use std::f64::consts::{FRAC_PI_2, PI}; + +type ArcSpec = ([f64; 2], f64, f64, f64); + +pub fn slot_outline(length: f64, width: f64) -> Vec { + let half = length / 2.0; + let radius = width / 2.0; + arcs_joined_by_lines(&[ + ([half, 0.0], radius, -FRAC_PI_2, PI), + ([-half, 0.0], radius, FRAC_PI_2, PI), + ]) +} + +pub fn rounded_rectangle_outline(width: f64, depth: f64, radius: f64) -> Vec { + let u = width / 2.0 - radius; + let v = depth / 2.0 - radius; + arcs_joined_by_lines(&[ + ([u, -v], radius, -FRAC_PI_2, FRAC_PI_2), + ([u, v], radius, 0.0, FRAC_PI_2), + ([-u, v], radius, FRAC_PI_2, FRAC_PI_2), + ([-u, -v], radius, PI, FRAC_PI_2), + ]) +} + +pub fn ring_sector_outline([outer, inner]: [f64; 2], [start, sweep]: [f64; 2]) -> Vec { + arcs_joined_by_lines(&[ + ([0.0; 2], outer, start, sweep), + ([0.0; 2], inner, start + sweep, -sweep), + ]) +} + +fn arcs_joined_by_lines(specs: &[ArcSpec]) -> Vec { + let arcs = specs + .iter() + .map( + |&(center, radius, start_angle, sweep_angle)| ProfileEdge::Arc { + center, + radius, + start_angle, + sweep_angle, + }, + ) + .collect::>(); + let mut edges = Vec::with_capacity(2 * arcs.len()); + for (index, current) in arcs.iter().enumerate() { + let next = &arcs[(index + 1) % arcs.len()]; + edges.push(current.clone()); + edges.push(ProfileEdge::Line { + from: current.endpoints().1, + to: next.endpoints().0, + }); + } + edges +} diff --git a/main/opengeometry/src/primitives/mod.rs b/main/opengeometry/src/primitives/mod.rs index e8ea53e7..48a1dd26 100644 --- a/main/opengeometry/src/primitives/mod.rs +++ b/main/opengeometry/src/primitives/mod.rs @@ -21,7 +21,8 @@ pub use cuboid::cuboid; pub use cylinder::cylinder; pub use internal::{ annular_cylinder, arc_edged_extrusion, arc_edged_extrusion_with_holes, cone, cylinder_sector, - cylinder_with_circular_hole, frustum, linear_extrusion, revolved_profile, sphere, torus, + cylinder_with_circular_hole, frustum, linear_extrusion, revolved_profile, ring_sector_outline, + rounded_rectangle_outline, slot_outline, sphere, torus, }; pub use polyhedron::polyhedron; pub use polyline::{polyline, polyline_with_keys}; diff --git a/main/opengeometry/src/world_graph/primitive/kind.rs b/main/opengeometry/src/world_graph/primitive/kind.rs index 837f5f70..deb394b9 100644 --- a/main/opengeometry/src/world_graph/primitive/kind.rs +++ b/main/opengeometry/src/world_graph/primitive/kind.rs @@ -62,6 +62,25 @@ pub enum Primitive { start_angle: f64, sweep_angle: f64, }, + SlotPrism { + length: f64, + width: f64, + height: f64, + }, + RoundedBox { + width: f64, + height: f64, + depth: f64, + radius: f64, + }, + #[serde(rename_all = "camelCase")] + PipeSegment { + outer_radius: f64, + inner_radius: f64, + height: f64, + start_angle: f64, + sweep_angle: f64, + }, } impl Primitive { @@ -73,7 +92,10 @@ impl Primitive { | Self::Polyhedron { .. } | Self::Sphere { .. } | Self::Cone { .. } - | Self::Torus { .. } => BodyType::Solid, + | Self::Torus { .. } + | Self::SlotPrism { .. } + | Self::RoundedBox { .. } + | Self::PipeSegment { .. } => BodyType::Solid, Self::Rectangle { .. } | Self::Circle { .. } | Self::Polyline { .. } diff --git a/main/opengeometry/src/world_graph/primitive/solids.rs b/main/opengeometry/src/world_graph/primitive/solids.rs index 057f993a..a168a961 100644 --- a/main/opengeometry/src/world_graph/primitive/solids.rs +++ b/main/opengeometry/src/world_graph/primitive/solids.rs @@ -3,6 +3,7 @@ use super::sizes::{check_arc, parameter, required_size, wrong_body_type}; use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError, GROUND}; use crate::math::{scale, Point3}; use crate::primitives; +use crate::primitives::ProfileEdge; use crate::world_graph::error::{ErrorCode, GraphError}; pub(super) fn build_solid( @@ -43,6 +44,29 @@ pub(super) fn build_solid( major_radius, minor_radius, } => torus_body(shape_id, [major_radius, minor_radius], accuracy)?, + Primitive::SlotPrism { + length, + width, + height, + } => slot_body(shape_id, [length, width, height], accuracy)?, + Primitive::RoundedBox { + width, + height, + depth, + radius, + } => rounded_box_body(shape_id, [width, height, depth, radius], accuracy)?, + Primitive::PipeSegment { + outer_radius, + inner_radius, + height, + start_angle, + sweep_angle, + } => pipe_body( + shape_id, + [outer_radius, inner_radius, height], + [start_angle, sweep_angle], + accuracy, + )?, _ => return Err(wrong_body_type("solid")), }; Ok(result) @@ -189,3 +213,73 @@ fn torus_body( Vec::new(), )) } + +fn slot_body( + shape_id: String, + [length, width, height]: [f64; 3], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + for (name, value) in [("length", length), ("width", width), ("height", height)] { + required_size(value, accuracy.geometric, name)?; + } + required_size(width / 2.0, accuracy.geometric, "width")?; + let outline = primitives::slot_outline(length, width); + outline_body(shape_id, outline, height, accuracy) +} + +fn rounded_box_body( + shape_id: String, + [width, height, depth, radius]: [f64; 4], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + for (name, value) in [ + ("width", width), + ("height", height), + ("depth", depth), + ("radius", radius), + ] { + required_size(value, accuracy.geometric, name)?; + } + if width.min(depth) - 2.0 * radius <= 4.0 * accuracy.geometric { + return Err(parameter( + "rounded box radius must be below half its width and depth", + )); + } + let outline = primitives::rounded_rectangle_outline(width, depth, radius); + outline_body(shape_id, outline, height, accuracy) +} + +fn pipe_body( + shape_id: String, + [outer, inner, height]: [f64; 3], + angles: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + for (name, value) in [ + ("outerRadius", outer), + ("innerRadius", inner), + ("height", height), + ] { + required_size(value, accuracy.geometric, name)?; + } + check_arc("pipe segment", inner, angles, accuracy)?; + if outer - inner <= 4.0 * accuracy.geometric { + return Err(parameter( + "pipe segment inner radius must be below its outer radius", + )); + } + let outline = primitives::ring_sector_outline([outer, inner], angles); + outline_body(shape_id, outline, height, accuracy) +} + +fn outline_body( + shape_id: String, + outline: Vec, + height: f64, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + Ok(( + primitives::arc_edged_extrusion(shape_id, GROUND, outline, height, accuracy)?, + Vec::new(), + )) +} diff --git a/main/opengeometry/test-support/src/scenes/bar.rs b/main/opengeometry/test-support/src/scenes/bar.rs new file mode 100644 index 00000000..3b878e13 --- /dev/null +++ b/main/opengeometry/test-support/src/scenes/bar.rs @@ -0,0 +1,120 @@ +use crate::world_graph::{graph, named}; +use opengeometry::world_graph::{EditScope, ModifyingOperation, Primitive, Transform, WorldGraph}; + +type Bounds = [f64; 6]; + +struct Placement { + name: &'static str, + at: [f64; 3], + height: f64, +} + +const OPERATIONS: [(&str, ModifyingOperation); 3] = [ + ("S", ModifyingOperation::Subtract), + ("U", ModifyingOperation::Union), + ("I", ModifyingOperation::Intersect), +]; + +pub fn bar_handlers(target: &Primitive) -> Vec { + let bounds = created(target).bounds("target").unwrap().unwrap(); + let mut rows = Vec::new(); + for placement in placements(bounds) { + for (name, tool) in tools(bounds, placement.height) { + let cells = OPERATIONS.map(|(label, operation)| { + format!("{label}={}", cell(target, &tool, placement.at, operation)) + }); + rows.push(format!("{}/{name} {}", placement.name, cells.join(" "))); + } + } + rows +} + +pub fn shell_is_valid(target: &Primitive, thickness: f64) -> bool { + let mut world = created(target); + world.shell("target", thickness, EditScope::Node).unwrap(); + let validity = world.validity("target").unwrap(); + validity.valid && validity.closed +} + +fn created(target: &Primitive) -> WorldGraph { + let mut world = graph(); + world + .create_primitive(target.clone(), named("target")) + .unwrap(); + world +} + +fn placements(b: Bounds) -> [Placement; 3] { + let size = [b[3] - b[0], b[4] - b[1], b[5] - b[2]]; + let mid = [(b[0] + b[3]) / 2.0, (b[2] + b[5]) / 2.0]; + let top = b[1] + 0.75 * size[1]; + let inner = mid[0] + 0.07 * size[0]; + let back = mid[1] + 0.05 * size[2]; + [ + Placement { + name: "A", + at: [b[3] - 0.02 * size[0], top, mid[1] + 0.03 * size[2]], + height: 0.5 * size[1], + }, + Placement { + name: "B", + at: [inner, top, back], + height: 0.5 * size[1], + }, + Placement { + name: "T", + at: [inner, b[1] - 0.25 * size[1], back], + height: 1.5 * size[1], + }, + ] +} + +fn tools(b: Bounds, height: f64) -> [(&'static str, Primitive); 2] { + let width = 0.3 * (b[3] - b[0]).min(b[5] - b[2]); + [ + ( + "box", + Primitive::Cuboid { + width, + height, + depth: width, + }, + ), + ( + "cyl", + Primitive::Cylinder { + radius: width / 2.0, + height, + }, + ), + ] +} + +fn cell( + target: &Primitive, + tool: &Primitive, + at: [f64; 3], + operation: ModifyingOperation, +) -> String { + let mut world = created(target); + world.create_primitive(tool.clone(), named("tool")).unwrap(); + world + .transform("tool", Transform::Translate { offset: at }) + .unwrap(); + world + .operate("target", operation, &["tool".into()], EditScope::Node) + .unwrap_or_else(|error| panic!("{operation:?} {tool:?} at {at:?}: {error:?}")); + let validity = world.validity("target").unwrap(); + assert!( + validity.valid && validity.closed, + "{operation:?} {tool:?} at {at:?}" + ); + let report = world.report("target").unwrap().unwrap(); + report["handlers"] + .as_array() + .unwrap() + .iter() + .map(|handler| handler.as_str().unwrap()) + .collect::>() + .join("+") +} diff --git a/main/opengeometry/test-support/src/scenes/mod.rs b/main/opengeometry/test-support/src/scenes/mod.rs index a00f75a2..73e0a56c 100644 --- a/main/opengeometry/test-support/src/scenes/mod.rs +++ b/main/opengeometry/test-support/src/scenes/mod.rs @@ -1,4 +1,5 @@ pub mod acceptance; +pub mod bar; pub mod coverage_gap; pub mod prismatic; pub mod revolve; diff --git a/main/opengeometry/tests/creating/main.rs b/main/opengeometry/tests/creating/main.rs index 1354aa2c..8f88fd91 100644 --- a/main/opengeometry/tests/creating/main.rs +++ b/main/opengeometry/tests/creating/main.rs @@ -1,12 +1,16 @@ mod extrude; mod holes; mod mesh_walls; +mod pipe_segment; mod polyhedron; mod profiles; mod rebuild; mod revolve; mod revolved; +mod rounded_box; mod sectors; +mod slot_prism; +mod standard; mod support; mod sweep; mod sweep_bends; diff --git a/main/opengeometry/tests/creating/pipe_segment.rs b/main/opengeometry/tests/creating/pipe_segment.rs new file mode 100644 index 00000000..499ecf17 --- /dev/null +++ b/main/opengeometry/tests/creating/pipe_segment.rs @@ -0,0 +1,91 @@ +use super::standard::{ + assert_bar, assert_exact_volume, assert_on_plane, assert_refused, assert_step_surfaces, +}; +use opengeometry::world_graph::Primitive; +use opengeometry_test_support::scenes::bar::shell_is_valid; +use std::f64::consts::{FRAC_PI_2, PI, TAU}; + +fn pipe(radii: [f64; 2], height: f64, angles: [f64; 2]) -> Primitive { + Primitive::PipeSegment { + outer_radius: radii[0], + inner_radius: radii[1], + height, + start_angle: angles[0], + sweep_angle: angles[1], + } +} + +#[test] +fn a_pipe_segment_is_closed_with_the_exact_ring_sector_volume() { + for sweep in [FRAC_PI_2, -1.2, 5.0] { + let expected = sweep.abs() / 2.0 * (0.25 - 0.09) * 2.0; + assert_exact_volume(pipe([0.5, 0.3], 2.0, [0.3, sweep]), expected); + } +} + +#[test] +fn invalid_pipe_segments_are_invalid_parameter_naming_the_value() { + let size = |name: &str| format!("{name} must exceed four times geometric tolerance"); + let sweep = "pipe segment sweep must be nonzero and less than one turn".to_string(); + let order = "pipe segment inner radius must be below its outer radius".to_string(); + for (primitive, message) in [ + (pipe([f64::NAN, 0.3], 1.0, [0.0, 1.0]), size("outerRadius")), + (pipe([0.5, 0.0], 1.0, [0.0, 1.0]), size("innerRadius")), + (pipe([0.5, 0.3], 0.0, [0.0, 1.0]), size("height")), + (pipe([0.5, 0.3], 1.0, [0.0, 0.0]), sweep.clone()), + (pipe([0.5, 0.3], 1.0, [0.0, TAU]), sweep.clone()), + (pipe([0.5, 0.3], 1.0, [f64::INFINITY, 1.0]), sweep), + (pipe([0.3, 0.5], 1.0, [0.0, 1.0]), order.clone()), + (pipe([0.5, 0.5 - 1e-8], 1.0, [0.0, 1.0]), order), + ] { + assert_refused(primitive, &message); + } +} + +#[test] +fn pipe_segment_json_uses_camel_case_fields() { + let json = r#"{"kind":"PipeSegment","outerRadius":0.5,"innerRadius":0.3,"height":1,"startAngle":0,"sweepAngle":1}"#; + let parsed = serde_json::from_str::(json).unwrap(); + assert!(matches!( + parsed, + Primitive::PipeSegment { outer_radius, inner_radius, sweep_angle, .. } + if outer_radius == 0.5 && inner_radius == 0.3 && sweep_angle == 1.0 + )); + let json = r#"{"kind":"PipeSegment","outer_radius":0.5,"inner_radius":0.3,"height":1,"start_angle":0,"sweep_angle":1}"#; + assert!(serde_json::from_str::(json).is_err()); +} + +#[test] +fn a_pipe_segment_on_a_plane_sweeps_counter_clockwise_from_the_x_direction() { + let corners = [[1.0, 2.0, 3.5], [2.0, 1.7, 3.0], [1.0, 1.5, 3.0]]; + let expected = PI / 4.0 * (0.25 - 0.09); + assert_on_plane(pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), expected, &corners); +} + +#[test] +fn a_pipe_segment_passes_the_boolean_bar_with_a_box_and_a_cylinder() { + assert_bar( + pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), + [ + "A/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "A/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + ], + ); +} + +#[test] +fn a_pipe_segment_shells_to_a_valid_closed_solid() { + assert!(shell_is_valid( + &pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), + 0.03 + )); +} + +#[test] +fn a_pipe_segment_exports_one_solid_with_two_cylindrical_faces() { + assert_step_surfaces(pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), 2, 4); +} diff --git a/main/opengeometry/tests/creating/rounded_box.rs b/main/opengeometry/tests/creating/rounded_box.rs new file mode 100644 index 00000000..a3e48c23 --- /dev/null +++ b/main/opengeometry/tests/creating/rounded_box.rs @@ -0,0 +1,87 @@ +use super::standard::{ + assert_bar, assert_exact_volume, assert_on_plane, assert_refused, assert_step_surfaces, +}; +use opengeometry::world_graph::Primitive; +use opengeometry_test_support::scenes::bar::shell_is_valid; +use std::f64::consts::PI; + +fn rounded(size: [f64; 3], radius: f64) -> Primitive { + Primitive::RoundedBox { + width: size[0], + height: size[1], + depth: size[2], + radius, + } +} + +#[test] +fn a_rounded_box_is_closed_with_the_exact_volume_less_its_four_rounded_corners() { + assert_exact_volume(rounded([1.0, 1.0, 1.0], 0.1), 1.0 - (4.0 - PI) * 0.01); + assert_exact_volume( + rounded([2.0, 0.5, 0.6], 0.25), + (1.2 - (4.0 - PI) * 0.0625) * 0.5, + ); +} + +#[test] +fn invalid_rounded_boxes_are_invalid_parameter_naming_the_value() { + let corner = "rounded box radius must be below half its width and depth"; + let size = |name: &str| format!("{name} must exceed four times geometric tolerance"); + for (primitive, message) in [ + (rounded([f64::NAN, 1.0, 1.0], 0.1), size("width")), + (rounded([1.0, 0.0, 1.0], 0.1), size("height")), + (rounded([1.0, 1.0, -1.0], 0.1), size("depth")), + (rounded([1.0, 1.0, 1.0], 0.0), size("radius")), + (rounded([1.0, 1.0, 1.0], 0.5), corner.to_string()), + (rounded([2.0, 1.0, 0.8], 0.45), corner.to_string()), + (rounded([0.8, 1.0, 2.0], 0.4), corner.to_string()), + ] { + assert_refused(primitive, &message); + } +} + +#[test] +fn rounded_box_json_parses_its_fields() { + let json = r#"{"kind":"RoundedBox","width":1,"height":2,"depth":3,"radius":0.25}"#; + let parsed = serde_json::from_str::(json).unwrap(); + assert!(matches!( + parsed, + Primitive::RoundedBox { width, height, depth, radius } + if width == 1.0 && height == 2.0 && depth == 3.0 && radius == 0.25 + )); +} + +#[test] +fn a_rounded_box_on_a_plane_keeps_width_along_x_direction_and_height_along_the_normal() { + let corners = [[1.0, 2.4, 3.5], [2.0, 1.7, 2.4], [1.0, 1.6, 2.5]]; + assert_on_plane( + rounded([1.2, 1.0, 0.8], 0.1), + 0.96 - (4.0 - PI) * 0.01, + &corners, + ); +} + +#[test] +fn a_rounded_box_passes_the_boolean_bar_with_a_box_and_a_cylinder() { + assert_bar( + rounded([1.0, 1.0, 1.0], 0.1), + [ + "A/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "A/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/cyl S=generic_two_sided_cutter_band_subtraction U=prismatic_curved_boolean I=prismatic_curved_boolean", + ], + ); +} + +#[test] +fn a_rounded_box_shells_to_a_valid_closed_solid() { + assert!(shell_is_valid(&rounded([1.0, 1.0, 1.0], 0.1), 0.03)); +} + +#[test] +fn a_rounded_box_exports_one_solid_with_four_cylindrical_faces() { + assert_step_surfaces(rounded([1.0, 1.0, 1.0], 0.1), 4, 6); +} diff --git a/main/opengeometry/tests/creating/slot_prism.rs b/main/opengeometry/tests/creating/slot_prism.rs new file mode 100644 index 00000000..9ee9455d --- /dev/null +++ b/main/opengeometry/tests/creating/slot_prism.rs @@ -0,0 +1,76 @@ +use super::standard::{ + assert_bar, assert_exact_volume, assert_on_plane, assert_refused, assert_step_surfaces, +}; +use opengeometry::world_graph::Primitive; +use opengeometry_test_support::scenes::bar::shell_is_valid; +use std::f64::consts::PI; + +fn slot(length: f64, width: f64, height: f64) -> Primitive { + Primitive::SlotPrism { + length, + width, + height, + } +} + +#[test] +fn a_slot_prism_is_closed_with_the_exact_stadium_volume() { + assert_exact_volume(slot(1.0, 0.5, 1.0), 0.5 + PI * 0.0625); + assert_exact_volume(slot(3.0, 0.2, 0.4), (0.6 + PI * 0.01) * 0.4); +} + +#[test] +fn invalid_slot_prisms_are_invalid_parameter_naming_the_value() { + for (primitive, message) in [ + (slot(0.0, 0.5, 1.0), "length"), + (slot(f64::INFINITY, 0.5, 1.0), "length"), + (slot(1.0, f64::NAN, 1.0), "width"), + (slot(1.0, 1e-9, 1.0), "width"), + (slot(1.0, 6e-8, 1.0), "width"), + (slot(1.0, 0.5, -1.0), "height"), + ] { + let message = format!("{message} must exceed four times geometric tolerance"); + assert_refused(primitive, &message); + } +} + +#[test] +fn slot_prism_json_parses_its_fields() { + let json = r#"{"kind":"SlotPrism","length":1,"width":0.5,"height":2}"#; + let parsed = serde_json::from_str::(json).unwrap(); + assert!(matches!( + parsed, + Primitive::SlotPrism { length, width, height } if length == 1.0 && width == 0.5 && height == 2.0 + )); +} + +#[test] +fn a_slot_prism_on_a_plane_runs_its_length_along_the_x_direction() { + let corners = [[1.0, 2.25, 3.5], [2.0, 1.75, 2.5], [1.0, 1.75, 2.5]]; + assert_on_plane(slot(1.0, 0.5, 1.0), 0.5 + PI * 0.0625, &corners); +} + +#[test] +fn a_slot_prism_passes_the_boolean_bar_with_a_box_and_a_cylinder() { + assert_bar( + slot(1.0, 0.5, 1.0), + [ + "A/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "A/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/cyl S=generic_two_sided_cutter_band_subtraction U=prismatic_curved_boolean I=prismatic_curved_boolean", + ], + ); +} + +#[test] +fn a_slot_prism_shells_to_a_valid_closed_solid() { + assert!(shell_is_valid(&slot(1.0, 0.5, 1.0), 0.03)); +} + +#[test] +fn a_slot_prism_exports_one_solid_with_two_cylindrical_faces() { + assert_step_surfaces(slot(1.0, 0.5, 1.0), 2, 4); +} diff --git a/main/opengeometry/tests/creating/standard.rs b/main/opengeometry/tests/creating/standard.rs new file mode 100644 index 00000000..7d2370a9 --- /dev/null +++ b/main/opengeometry/tests/creating/standard.rs @@ -0,0 +1,83 @@ +use opengeometry::world_graph::{ + CreateOptions, ErrorCode, Plane, Primitive, StepOptions, WorldGraph, +}; +use opengeometry_test_support::part21::Document; +use opengeometry_test_support::scenes::bar::bar_handlers; +use opengeometry_test_support::world_graph::{graph, named}; + +fn created(primitive: Primitive, options: CreateOptions) -> WorldGraph { + let mut world = graph(); + world.create_primitive(primitive, options).unwrap(); + world +} + +fn assert_closed_with_volume(world: &WorldGraph, expected: f64) { + let validity = world.validity("solid").unwrap(); + assert!(validity.valid && validity.closed); + let measured = world.measure("solid", Some(1e-4)).unwrap(); + assert!( + (measured.volume - expected).abs() <= measured.error_bound, + "{} versus {expected} with bound {}", + measured.volume, + measured.error_bound + ); +} + +pub(super) fn assert_exact_volume(primitive: Primitive, expected: f64) { + assert_closed_with_volume(&created(primitive, named("solid")), expected); +} + +pub(super) fn assert_refused(primitive: Primitive, message: &str) { + let mut world = graph(); + let label = format!("{primitive:?}"); + let error = world + .create_primitive(primitive, named("solid")) + .expect_err(&label); + assert_eq!(error.error_code(), ErrorCode::InvalidParameter, "{label}"); + assert_eq!(error.message(), message, "{label}"); + assert_eq!(world.node_count(), 0); +} + +pub(super) fn assert_on_plane(primitive: Primitive, expected: f64, corners: &[[f64; 3]]) { + let options = CreateOptions { + plane: Some(Plane { + origin: Some([1.0, 2.0, 3.0]), + normal: Some([1.0, 0.0, 0.0]), + x_direction: Some([0.0, 0.0, 1.0]), + }), + ..named("solid") + }; + let world = created(primitive, options); + assert_closed_with_volume(&world, expected); + let placement = world.world_placement("solid").unwrap(); + let brep = world.brep("solid").unwrap(); + let points = brep + .topology + .vertices + .iter() + .map(|vertex| placement.apply_point(vertex.position)) + .collect::>(); + let near = |a: [f64; 3], b: [f64; 3]| (0..3).all(|axis| (a[axis] - b[axis]).abs() <= 1e-9); + for corner in corners { + assert!( + points.iter().any(|point| near(*point, *corner)), + "{corner:?} in {points:?}" + ); + } +} + +pub(super) fn assert_bar(primitive: Primitive, rows: [&str; 6]) { + assert_eq!(bar_handlers(&primitive), rows); +} + +pub(super) fn assert_step_surfaces(primitive: Primitive, cylinders: usize, planes: usize) { + let world = created(primitive, named("solid")); + let (text, _) = world + .export_step(&["solid".into()], &StepOptions::default()) + .unwrap(); + let document = Document::parse(&text).unwrap(); + assert_eq!(document.count("MANIFOLD_SOLID_BREP"), 1); + assert_eq!(document.pcurveless_edges().unwrap(), 0); + assert_eq!(document.count("CYLINDRICAL_SURFACE"), cylinders); + assert_eq!(document.count("PLANE"), planes); +} diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index 65141e71..c9cc2545 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -418,6 +418,7 @@ "builders.linear-extrusion.self-crossing": "e3a72f0afa92d3b4d9abb0c104952fb9408e2a518bbf98b907b6aa8f5e5a1a07", "builders.linear-extrusion.upright-sector": "fdbeb87035b021e04daaafbc58d80895bbae37051096986a096a50df6303d2d7", "builders.linear-extrusion.with-hole": "ce18d55e12c1cd2cea89cb3f3b4d58dbfeabef852a3c9cd10dc189f19d4aa980", + "builders.pipe-segment.ground": "bd0c70f03b5fb716595dec1e77fc6aa94a9486afed468601ba81e7ae7688cc49", "builders.placed.cuboid-scaled": "edd948c54a8736a507a6191347d561227c1c80128302b5618194d3de35d9efb5", "builders.placed.cylinder-rotated": "827f6382b3d290fcd6de612b22b63f7f56c8a1e95a8e8e96016a7c409ab98171", "builders.placed.zero-scale": "b0d66d72efc738d90870067da896fcf1c6a1fbcefc0b7e52f0ac53b1718e08d3", @@ -444,6 +445,8 @@ "builders.revolved.funnel": "615896c609dfeff094a502fd8805924a9a2813f34aff2f47ceedc9fca46f9111", "builders.revolved.ring": "e2d88493927fae7af8efdedf333309b55c286bccc8c8b7fa456623ccb90c9c5e", "builders.revolved.tube": "2caed571f40ed6d90e97ead5a7fdace4cf77e89cdbea6304480848e7223562ee", + "builders.rounded-box.ground": "e5d6c2a4c29e7b0fda0e4b285d96ea6f8dd6c8c70cbd0c7e334ff271c3f51da0", + "builders.slot-prism.ground": "4942885ae752f41c35e25edaed8023760f868542c67518d7731fa9d86c711efa", "builders.sphere.micro": "7dc9082a6cf18b309258300b8b365233a0fe5748fac9e91a953d04b1fa77bfcb", "builders.sphere.offset": "4b18f1443b10d400daddc789fabe0dc3ce606040a3f30de5ef3abf9cd8791342", "builders.sphere.oracle": "40739ef55be6a000955527fea3b14ca18e00e84fc6453b3315470683e7d45f85", diff --git a/main/opengeometry/tests/golden/builders_solids.rs b/main/opengeometry/tests/golden/builders_solids.rs index a9607c3b..7396beba 100644 --- a/main/opengeometry/tests/golden/builders_solids.rs +++ b/main/opengeometry/tests/golden/builders_solids.rs @@ -384,6 +384,23 @@ fn revolved_primitives(graph: &mut WorldGraph, record: &mut Record) -> Result<() Ok(()) } +fn standard_outlines() -> Vec { + vec![ + built("slot-prism.ground", || { + let outline = primitives::slot_outline(1.0, 0.5); + primitives::arc_edged_extrusion("slot".into(), ground(), outline, 1.0, standard()) + }), + built("rounded-box.ground", || { + let outline = primitives::rounded_rectangle_outline(1.2, 0.8, 0.1); + primitives::arc_edged_extrusion("rounded".into(), ground(), outline, 1.0, standard()) + }), + built("pipe-segment.ground", || { + let outline = primitives::ring_sector_outline([0.5, 0.3], [0.3, FRAC_PI_2]); + primitives::arc_edged_extrusion("pipe".into(), ground(), outline, 1.0, standard()) + }), + ] +} + pub(crate) fn cases() -> Vec { [ cuboids(), @@ -396,6 +413,7 @@ pub(crate) fn cases() -> Vec { revolved_lathe(), revolved_arcs(), vec![graph_case("graph.primitive.revolved", revolved_primitives)], + standard_outlines(), ] .into_iter() .flatten() From f306f28d9fb816cc36766a66b2e296c01f330842 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Nov 2026 16:25:32 +0100 Subject: [PATCH 4/6] Add the Polysolid primitive with straight legs A polysolid is a wall of constant width and height along a path in the plane. The builder strokes the path with the mitred polyline offset and extrudes the single region it gives, with the hole of a closed loop. Justify left or right shifts the path by half the width first. Arc segments, short or zero-length legs, a crossing path and a stroke that is not one region are refused with InvalidParameter, so the stroke never drops points without telling the user. The SDK gets the kind constant, its params helper and type, with docs, an example page and changelog lines. Left and right justify bevel the shifted path at the stroke's mitre limit, so a sharp corner cannot push the wall past it. Path checks return InvalidParameter; errors from the stroke and the extrusion pass through as they are. --- CHANGELOG.md | 4 + README.md | 6 +- docs/api-coverage.json | 15 + docs/opengeometry/api/constants.mdx | 31 +- docs/opengeometry/api/overview.mdx | 18 +- docs/opengeometry/api/solid.mdx | 1 + docs/opengeometry/changelog.mdx | 1 + docs/opengeometry/examples.mdx | 1 + docs/opengeometry/guides/primitives.mdx | 34 ++ .../examples-vite/index.html | 1 + .../examples-vite/shapes/polysolid.html | 77 ++++ .../examples-vite/shapes/polysolid.ts | 122 ++++++ .../examples-vite/vite.config.ts | 1 + main/opengeometry-three/index.ts | 2 +- .../src/constants/primitives.ts | 4 +- main/opengeometry-three/src/dto/primitives.ts | 3 + .../tests/node/primitives.test.mjs | 28 ++ .../src/primitives/internal/mod.rs | 2 + .../src/primitives/internal/polysolid.rs | 233 ++++++++++ main/opengeometry/src/primitives/mod.rs | 5 +- .../src/world_graph/primitive/kind.rs | 12 +- .../src/world_graph/primitive/solids.rs | 26 +- main/opengeometry/tests/creating/main.rs | 1 + main/opengeometry/tests/creating/polysolid.rs | 397 ++++++++++++++++++ .../fixtures/golden/aarch64-apple-darwin.json | 2 + .../tests/golden/builders_solids.rs | 37 ++ 26 files changed, 1030 insertions(+), 34 deletions(-) create mode 100644 main/opengeometry-three/examples-vite/shapes/polysolid.html create mode 100644 main/opengeometry-three/examples-vite/shapes/polysolid.ts create mode 100644 main/opengeometry/src/primitives/internal/polysolid.rs create mode 100644 main/opengeometry/tests/creating/polysolid.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 13571a22..4fc5b9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to the `opengeometry` package are in this file. The format f - Solid primitives `OG_PRIMITIVE_SLOT_PRISM`, `OG_PRIMITIVE_ROUNDED_BOX` and `OG_PRIMITIVE_PIPE_SEGMENT`, with their `OG_PRIMITIVE_PARAMS_*` helpers and the `SlotPrismParams`, `RoundedBoxParams` and `PipeSegmentParams` types. +- `OG_PRIMITIVE_POLYSOLID`: a solid wall of constant width and height along a path of straight + legs, with `justify` `'centre'`, `'left'` or `'right'`, mitred corners and an open or closed + path. `OG_PRIMITIVE_PARAMS_POLYSOLID` and the type `PolysolidParams` give it a + type. ## [3.0.0] - 2026-11-09 diff --git a/README.md b/README.md index a645a252..a70986a3 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ It is the geometry engine layer, not a full CAD application. Floorkit is a downs | Area | What 3.0 has | | --- | --- | -| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment. Each can be placed on any plane. | +| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment, polysolid wall along a path of straight legs. Each can be placed on any plane. | | **Operations** | Extrude a closed wire into a solid, with holes. Sweep a profile along an open or closed path of lines and arcs, each arc an exact bend, capped into a solid, open-ended into a sheet, or closed into a ring. Revolve a closed line-and-arc profile a full turn about an axis in its plane. Shell a solid into a hollow one with walls of one thickness. Union, subtract and intersect, with one to a hundred tool solids in one call. | | **Parametric edits** | `rebuild` gives an existing body new parameters in place. `mirror` rebuilds a body as its reflection across a plane. | | **Assemblies** | Assemblies with parent and child placement; translate, rotate, scale and place; independent copies and shared instances. | @@ -167,7 +167,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | `Wire` | `new Wire(kind, params, options?)`. `OG_PRIMITIVE_RECTANGLE` `{ width, breadth }`, `OG_PRIMITIVE_CIRCLE` `{ radius }`, `OG_PRIMITIVE_POLYLINE` `{ points, closed? }`. | | Profile wire | `new Wire(OG_PRIMITIVE_PROFILE, { start, segments, closed }, options?)`. Points are `[u, v]` in `plane`; a segment is `{ to }` for a line or `{ to, arc: { center, counterClockwise } }` for an arc. A closed profile is an extrude profile or hole. | | Arc wire | `new Wire(OG_PRIMITIVE_ARC, { radius, startAngle, sweepAngle }, options?)`. Angles in radians from the `plane` x direction, centred on the `plane` origin; the sweep is nonzero and less than one turn. | -| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis), `OG_PRIMITIVE_SLOT_PRISM` `{ length, width, height }` (a stadium standing on the `plane`, centred on its origin; `length` between the arc centres along the x direction), `OG_PRIMITIVE_ROUNDED_BOX` `{ width, height, depth, radius }` (a cuboid with its four upright edges rounded; `radius` below half the width and half the depth), `OG_PRIMITIVE_PIPE_SEGMENT` `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` (a ring sector placed and angled like the cylinder sector); `profile`, `holes` and `path` are wires. | +| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis), `OG_PRIMITIVE_SLOT_PRISM` `{ length, width, height }` (a stadium standing on the `plane`, centred on its origin; `length` between the arc centres along the x direction), `OG_PRIMITIVE_ROUNDED_BOX` `{ width, height, depth, radius }` (a cuboid with its four upright edges rounded; `radius` below half the width and half the depth), `OG_PRIMITIVE_PIPE_SEGMENT` `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` (a ring sector placed and angled like the cylinder sector), `OG_PRIMITIVE_POLYSOLID` `{ path, width, height, justify? }` (a wall of constant width along `path`, a `{ start, segments, closed }` of straight legs in the `plane`'s `[u, v]`, standing on the `plane` and rising along its normal; `justify` is `'centre'` (default), `'left'` or `'right'` of the path direction); `profile`, `holes` and `path` are wires. | | `Sheet` | `new Sheet(OG_OPERATION_SWEEP, { profile, path, caps: false })`: an open-ended sweep, faces that enclose no volume. It shows its surface, from the front only, and its outline; `OpenGeometry.exportStep` refuses it. | | Body options | `{ ogId?, parent?, appearance?, plane? }`. `plane` is `{ origin?, normal?, xDirection? }` and applies to primitives only; by default it is the ground plane through the origin, normal `[0, 1, 0]`, x direction `[1, 0, 0]`. | | Booleans | `solid.operate(kind, { tools, instances? })` with `OG_OPERATION_SUBTRACT`, `OG_OPERATION_UNION` or `OG_OPERATION_INTERSECT`; `tools` is one to a hundred solids. | @@ -189,7 +189,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | 2D offsets | `OpenGeometry.offsetRingVariable(ring, distances, holes?)` insets a closed ring by one distance per edge, each hole `{ ring, distances }` grown by its own; `OpenGeometry.offsetCurvedRingVariable(region, distances, holeDistances?)` insets one `CurvedRegion2D` of lines and arcs by one distance (>= 0) per edge, one list per hole, each arc kept on its centre at its own distance and refused when that distance passes the centre; `OpenGeometry.offsetPolylineRegions(path, width, closed, miterLimit?)` strokes a path. Both return `CurvedRegion2D[]` with exact arcs. `OpenGeometry.insetCurvedRegion2D(region, distance)` insets one `CurvedRegion2D` by a distance above zero, holes grown by the same, and returns `CurvedRegion2D[]` of exact lines and arcs: each arc keeps its centre and a reflex corner rounds to an arc of the distance; an inset past the half width is an empty list. `OpenGeometry.offsetPolyline(path, distance, { acuteThresholdDegrees?, bevel?, closed? })` offsets a path to one side and returns `{ edges, bevelled, closed }` (type `OffsetPath2D`). Rings and paths are flat `[x, y, z, ...]` lists; `y` is ignored. | | `OGError` | Failures throw an `OGError` with `code` (for example `InvalidParameter`, `SharedShape`, `Disposed`), `call`, `message` and `details`. | | Reset | `OpenGeometry.reset()` frees every body and leaves an empty document. | -| Typed parameters | `OG_PRIMITIVE_PARAMS_(params)`, `OG_OPERATION_PARAMS_EXTRUDE(params)`, `OG_OPERATION_PARAMS_SWEEP(params)` and `OG_OPERATION_PARAMS_REVOLVE(params)` return their argument unchanged; they only give the object a type. | +| Typed parameters | `OG_PRIMITIVE_PARAMS_(params)`, `OG_OPERATION_PARAMS_EXTRUDE(params)`, `OG_OPERATION_PARAMS_SWEEP(params)` and `OG_OPERATION_PARAMS_REVOLVE(params)` return their argument unchanged; they only give the object a type. The entry exports the parameter types too, such as `ProfileParams` and `PolysolidParams`. | **Rules that are easy to get wrong.** diff --git a/docs/api-coverage.json b/docs/api-coverage.json index 9841fd39..33b6db05 100644 --- a/docs/api-coverage.json +++ b/docs/api-coverage.json @@ -144,6 +144,11 @@ "kind": "type", "page": "opengeometry/api/constants" }, + { + "export": "PolysolidParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, { "export": "CurvedRegion2D", "kind": "type", @@ -249,6 +254,11 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_POLYSOLID", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_PRIMITIVE_PARAMS_RECTANGLE", "kind": "value", @@ -324,6 +334,11 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_PARAMS_POLYSOLID", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_OPERATION_EXTRUDE", "kind": "value", diff --git a/docs/opengeometry/api/constants.mdx b/docs/opengeometry/api/constants.mdx index 4f6c15fd..bb9a9210 100644 --- a/docs/opengeometry/api/constants.mdx +++ b/docs/opengeometry/api/constants.mdx @@ -25,6 +25,7 @@ Each kind is a string constant. Pass it as the first argument of a constructor, | `OG_PRIMITIVE_SLOT_PRISM` | `'SlotPrism'` | Solid | `SlotPrismParams` | | `OG_PRIMITIVE_ROUNDED_BOX` | `'RoundedBox'` | Solid | `RoundedBoxParams` | | `OG_PRIMITIVE_PIPE_SEGMENT` | `'PipeSegment'` | Solid | `PipeSegmentParams` | +| `OG_PRIMITIVE_POLYSOLID` | `'Polysolid'` | Solid | `PolysolidParams` | ## Operations @@ -65,6 +66,7 @@ Each helper returns its argument unchanged. It only gives the object a type, so | `OG_PRIMITIVE_PARAMS_SPHERE` | `SphereParams` | | `OG_PRIMITIVE_PARAMS_CONE` | `ConeParams` | | `OG_PRIMITIVE_PARAMS_TORUS` | `TorusParams` | +| `OG_PRIMITIVE_PARAMS_POLYSOLID` | `PolysolidParams` | | `OG_OPERATION_PARAMS_EXTRUDE` | `{ profile, holes?, distance }` | | `OG_OPERATION_PARAMS_SWEEP` | `{ profile, path, caps? }` | | `OG_OPERATION_PARAMS_REVOLVE` | `{ profile, axis? }` | @@ -97,20 +99,21 @@ console.log(sector.validity().valid); ## Parameter types -| Type | Fields | -| ---------------------- | ------------------------------------------------------------------ | -| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | -| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | -| `ProfileArc` | `center: [u, v]`, `counterClockwise` | -| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | -| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | -| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | -| `SphereParams` | `radius` | -| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | -| `TorusParams` | `majorRadius`, `minorRadius` | -| `SlotPrismParams` | `length`, `width`, `height` | -| `RoundedBoxParams` | `width`, `height`, `depth`, `radius` | -| `PipeSegmentParams` | `outerRadius`, `innerRadius`, `height`, `startAngle`, `sweepAngle` | +| Type | Fields | +| ---------------------- | --------------------------------------------------------------------------------------- | +| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | +| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | +| `ProfileArc` | `center: [u, v]`, `counterClockwise` | +| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | +| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | +| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | +| `SphereParams` | `radius` | +| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | +| `TorusParams` | `majorRadius`, `minorRadius` | +| `SlotPrismParams` | `length`, `width`, `height` | +| `RoundedBoxParams` | `width`, `height`, `depth`, `radius` | +| `PipeSegmentParams` | `outerRadius`, `innerRadius`, `height`, `startAngle`, `sweepAngle` | +| `PolysolidParams` | `path: ProfileParams`, `width`, `height`, `justify?`: `'centre'`, `'left'` or `'right'` | ## OG_REGION_TOLERANCE diff --git a/docs/opengeometry/api/overview.mdx b/docs/opengeometry/api/overview.mdx index 9783d4cd..0b74f5d2 100644 --- a/docs/opengeometry/api/overview.mdx +++ b/docs/opengeometry/api/overview.mdx @@ -28,15 +28,15 @@ The methods that `Wire`, `Solid` and `Sheet` share are on [Body methods](/openge ## Types -| Type | Page | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | -| `HostGroup` | [Display](/opengeometry/api/display) | -| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | -| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | -| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | -| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | -| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams`, `SlotPrismParams`, `RoundedBoxParams`, `PipeSegmentParams` | [Constants](/opengeometry/api/constants) | +| Type | Page | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | +| `HostGroup` | [Display](/opengeometry/api/display) | +| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | +| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | +| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | +| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | +| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams`, `SlotPrismParams`, `RoundedBoxParams`, `PipeSegmentParams`, `PolysolidParams` | [Constants](/opengeometry/api/constants) | ## Conventions diff --git a/docs/opengeometry/api/solid.mdx b/docs/opengeometry/api/solid.mdx index 4bdb91f5..2059e8cb 100644 --- a/docs/opengeometry/api/solid.mdx +++ b/docs/opengeometry/api/solid.mdx @@ -49,6 +49,7 @@ console.log(base.getReport()?.report.operation); | `OG_PRIMITIVE_SLOT_PRISM` | `{ length, width, height }` | | `OG_PRIMITIVE_ROUNDED_BOX` | `{ width, height, depth, radius }` | | `OG_PRIMITIVE_PIPE_SEGMENT` | `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` | +| `OG_PRIMITIVE_POLYSOLID` | `{ path, width, height, justify? }` | `profile`, `holes` and `path` are wires. A sweep with `caps: false` makes a sheet, so `new Solid` throws `BodyTypeMismatch` for it. diff --git a/docs/opengeometry/changelog.mdx b/docs/opengeometry/changelog.mdx index dd61d49d..6b44e82c 100644 --- a/docs/opengeometry/changelog.mdx +++ b/docs/opengeometry/changelog.mdx @@ -10,6 +10,7 @@ rss: true ### Added - Solid primitives: slot prism, rounded box and pipe segment. See [Primitives](/opengeometry/guides/primitives). +- **Polysolid.** `OG_PRIMITIVE_POLYSOLID` makes a solid wall of constant width and height along a path of straight legs, justified `'centre'`, `'left'` or `'right'`, with mitred corners. See [Primitives](/opengeometry/guides/primitives).
diff --git a/docs/opengeometry/examples.mdx b/docs/opengeometry/examples.mdx index 4fd0bb68..bf8dc0fb 100644 --- a/docs/opengeometry/examples.mdx +++ b/docs/opengeometry/examples.mdx @@ -22,6 +22,7 @@ The examples are working 3.0 code. They are in [`main/opengeometry-three/example | `shapes/slot-prism.html` | A slot prism with length, width and height controls | | `shapes/rounded-box.html` | A rounded box with width, height, depth and corner radius controls | | `shapes/pipe-segment.html` | A pipe segment with both radii, height and the two angles as controls | +| `shapes/polysolid.html` | An L-shaped polysolid wall with width, height and justify controls | ## Run them diff --git a/docs/opengeometry/guides/primitives.mdx b/docs/opengeometry/guides/primitives.mdx index 179e4997..7b797f30 100644 --- a/docs/opengeometry/guides/primitives.mdx +++ b/docs/opengeometry/guides/primitives.mdx @@ -70,6 +70,7 @@ A profile refuses repeated points, an arc whose end is not on its circle, segmen | `OG_PRIMITIVE_SLOT_PRISM` | `{ length, width, height }` | A stadium standing on the plane, centred in u and v. `length` between the arc centres along u, `width` along v | | `OG_PRIMITIVE_ROUNDED_BOX` | `{ width, height, depth, radius }` | A cuboid with its four upright edges rounded. `radius` below half the width and half the depth | | `OG_PRIMITIVE_PIPE_SEGMENT` | `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` | A ring sector, placed and angled like the cylinder sector. The sweep is below one full turn | +| `OG_PRIMITIVE_POLYSOLID` | `{ path, width, height, justify? }` | A wall of constant width along a path of straight legs. See below | ```ts import { @@ -144,6 +145,39 @@ const bend = new Solid( console.log(slot.ogId, plinth.ogId, bend.ogId); ``` +## Polysolid + +A polysolid is a wall of constant `width` and `height` along a path. The path has the fields of a profile, in plane coordinates `[u, v]`. The wall stands on the plane where the path is, and rises along the normal. It is not centred. + +`justify` is `'centre'` (the default), `'left'` or `'right'` of the path direction. Corners between two legs are mitred, and bevelled above a mitre limit of 4. `closed: true` makes a ring wall; an open path has square ends. + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const wall = new Solid( + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2] }], closed: false }, + width: 0.2, + height: 1, + justify: 'left', + }), +); +console.log(wall.measure().volume); +``` + +Only straight legs are built: a segment with `arc` throws `InvalidParameter` that names the segment. A path with fewer than two points, a leg of zero length, a leg shorter than half the width, or a path that crosses itself also throws `InvalidParameter`. + ## Typed parameters `OG_PRIMITIVE_PARAMS_(params)` returns its argument unchanged. It only gives the object a type, so your editor checks the field names. The entry exports the types too, such as `ProfileParams`, `ArcParams`, `SphereParams`, `ConeParams` and `TorusParams`. diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index 4e11b7b5..ec3c2ded 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -30,6 +30,7 @@

Shapes

Slot prism Rounded box Pipe segment + Polysolid wall

Sweeps

Sweep frames Arc path sweep diff --git a/main/opengeometry-three/examples-vite/shapes/polysolid.html b/main/opengeometry-three/examples-vite/shapes/polysolid.html new file mode 100644 index 00000000..2603b8e6 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/polysolid.html @@ -0,0 +1,77 @@ + + + + OpenGeometry Polysolid Example + + + + + + + +
+ Back + Polysolid +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/polysolid.ts b/main/opengeometry-three/examples-vite/shapes/polysolid.ts new file mode 100644 index 00000000..2f668e14 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/polysolid.ts @@ -0,0 +1,122 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { numberControl, onNumberInput } from '../shared/controls'; +import { requiredElement } from '../shared/dom'; +import { startFpsMeter } from '../shared/fps'; +import { bootExample } from '../shared/kernel'; +import { releaseOnUnload } from '../shared/unload'; +import { fitRenderer } from '../shared/viewport'; +import { + OpenGeometry, Solid, OG_PRIMITIVE_POLYSOLID, OG_PRIMITIVE_PARAMS_POLYSOLID, +} from '../../../dist/index.js'; +import type { PolysolidParams } from '../../../dist/index.js'; + +type Justify = NonNullable; + +const APP = requiredElement('#app', HTMLDivElement); +const STATUS = requiredElement('#status', HTMLElement); +await bootExample('../'); + +const SCENE = new THREE.Scene(); +SCENE.background = new THREE.Color(0xeef2ff); +SCENE.add(new THREE.HemisphereLight(0xffffff, 0x526070, 2)); +const LIGHT = new THREE.DirectionalLight(0xffffff, 2); +LIGHT.position.set(5, 8, 5); +SCENE.add(LIGHT); +SCENE.add(new THREE.GridHelper(20, 20, 0x4460ff, 0xd5ddff)); +const CAMERA = new THREE.PerspectiveCamera(55, 1, 0.1, 100); +CAMERA.position.set(5.8, 4.4, 6.8); +const RENDERER = new THREE.WebGLRenderer({ antialias: true }); +RENDERER.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); +APP.append(RENDERER.domElement); +const CONTROLS = new OrbitControls(CAMERA, RENDERER.domElement); +CONTROLS.target.set(1.5, 0.5, -1); +CONTROLS.update(); + +const CONFIG: { width: number; height: number; justify: Justify; outline: boolean } = { + width: 0.2, height: 1, justify: 'centre', outline: true, +}; +const PATH: PolysolidParams['path'] = { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2] }], closed: false }; + +function params(): PolysolidParams { + return OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: PATH, width: CONFIG.width, height: CONFIG.height, justify: CONFIG.justify, + }); +} + +const WALL = new Solid(OG_PRIMITIVE_POLYSOLID, params(), { ogId: 'polysolid' }); +WALL.setAppearance({ color: 0x0ea5e9, outline: true, deflection: 0.005 }); +SCENE.add(WALL); + +function render(): void { RENDERER.render(SCENE, CAMERA); } + +function resize(): void { + fitRenderer(APP, CAMERA, RENDERER); + render(); +} + +let updateFrame = 0; +function updateWall(): void { + const started = performance.now(); + try { + WALL.rebuild(OG_PRIMITIVE_POLYSOLID, params()); + SCENE.updateMatrixWorld(true); + OpenGeometry.flush({ geometry: 'sync' }); + if (WALL.record?.revision !== WALL.lastInfo.shapeRevision) { + throw new Error('Polysolid geometry did not finish'); + } + STATUS.textContent = `Polysolid ready · ${(performance.now() - started).toFixed(1)} ms`; + } catch (error) { STATUS.textContent = `Polysolid error: ${String(error)}`; } +} + +function scheduleUpdate(): void { + if (updateFrame) return; + updateFrame = window.requestAnimationFrame(() => { updateFrame = 0; updateWall(); }); +} + +function attachNumberControl(name: 'width' | 'height'): void { + onNumberInput(numberControl(name), (value) => { + CONFIG[name] = value; + scheduleUpdate(); + }); +} + +attachNumberControl('width'); +attachNumberControl('height'); +const JUSTIFY_OPTIONS: Justify[] = ['centre', 'left', 'right']; +const JUSTIFY_ROW = requiredElement('[data-control="justify"]', HTMLElement); +const JUSTIFY = requiredElement('[data-select]', HTMLSelectElement, JUSTIFY_ROW); +JUSTIFY.addEventListener('change', () => { + CONFIG.justify = JUSTIFY_OPTIONS.find((option) => option === JUSTIFY.value) ?? 'centre'; + scheduleUpdate(); +}); +const OUTLINE_ROW = requiredElement('[data-control="outline"]', HTMLElement); +const OUTLINE = requiredElement('[data-toggle]', HTMLInputElement, OUTLINE_ROW); +const OUTLINE_STATE = requiredElement('[data-state]', HTMLElement, OUTLINE_ROW); +OUTLINE.addEventListener('change', () => { + CONFIG.outline = OUTLINE.checked; + OUTLINE_STATE.textContent = OUTLINE.checked ? 'Enabled' : 'Disabled'; + WALL.setAppearance({ outline: OUTLINE.checked }); + render(); +}); + +const RESIZE_OBSERVER = new ResizeObserver(resize); +RESIZE_OBSERVER.observe(APP); +CONTROLS.addEventListener('start', () => { OpenGeometry.setCameraMotion(true); }); +CONTROLS.addEventListener('end', () => { OpenGeometry.setCameraMotion(false); render(); }); +const UNSUBSCRIBE_ERROR = OpenGeometry.on('error', (error) => { + STATUS.textContent = `Geometry error: ${String(error)}`; +}); +resize(); +const READY = await OpenGeometry.settled(); +if (READY.failed.length) throw new Error('Polysolid geometry failed'); +render(); +STATUS.textContent = 'Polysolid ready'; +const STOP_FPS_METER = startFpsMeter(RENDERER, SCENE, CAMERA, CONTROLS); + +releaseOnUnload({ + resizeObserver: RESIZE_OBSERVER, stopFpsMeter: STOP_FPS_METER, unsubscribeError: UNSUBSCRIBE_ERROR, + controls: CONTROLS, renderer: RENDERER, +}, () => { if (updateFrame) window.cancelAnimationFrame(updateFrame); }, () => { + WALL.dispose(); +}); diff --git a/main/opengeometry-three/examples-vite/vite.config.ts b/main/opengeometry-three/examples-vite/vite.config.ts index aeeafce0..b6a895f2 100644 --- a/main/opengeometry-three/examples-vite/vite.config.ts +++ b/main/opengeometry-three/examples-vite/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ slotPrism: `${ROOT}/shapes/slot-prism.html`, roundedBox: `${ROOT}/shapes/rounded-box.html`, pipeSegment: `${ROOT}/shapes/pipe-segment.html`, + polysolid: `${ROOT}/shapes/polysolid.html`, }, }, }, diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index c9ee650c..09e01caf 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -16,7 +16,7 @@ export type { AnalyticExchangeBodyV2, IfcPreparedValue } from './src/dto/ifc-bod export * from './src/constants/index'; export type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileArc, ProfileParams, ProfileSegment, - SphereParams, TorusParams, SlotPrismParams, RoundedBoxParams, PipeSegmentParams, + SphereParams, TorusParams, SlotPrismParams, RoundedBoxParams, PipeSegmentParams, PolysolidParams, } from './src/dto/primitives'; export type { CurvedRegion2D, CurvedRegionEdge, OffsetHole, OffsetPath2D, OffsetPolylineOptions, OffsetRegion, diff --git a/main/opengeometry-three/src/constants/primitives.ts b/main/opengeometry-three/src/constants/primitives.ts index 49fa0d36..869b1104 100644 --- a/main/opengeometry-three/src/constants/primitives.ts +++ b/main/opengeometry-three/src/constants/primitives.ts @@ -1,6 +1,6 @@ import type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileParams, SphereParams, TorusParams, - SlotPrismParams, RoundedBoxParams, PipeSegmentParams, + SlotPrismParams, RoundedBoxParams, PipeSegmentParams, PolysolidParams, } from '../dto/primitives'; export const OG_PRIMITIVE_RECTANGLE = 'Rectangle' as const; @@ -18,6 +18,7 @@ export const OG_PRIMITIVE_ARC = 'Arc' as const; export const OG_PRIMITIVE_SLOT_PRISM = 'SlotPrism' as const; export const OG_PRIMITIVE_ROUNDED_BOX = 'RoundedBox' as const; export const OG_PRIMITIVE_PIPE_SEGMENT = 'PipeSegment' as const; +export const OG_PRIMITIVE_POLYSOLID = 'Polysolid' as const; export const OG_PRIMITIVE_PARAMS_RECTANGLE = ( value: { width: number; breadth: number }, @@ -42,3 +43,4 @@ export const OG_PRIMITIVE_PARAMS_TORUS = (value: TorusParams): TorusParams => va export const OG_PRIMITIVE_PARAMS_SLOT_PRISM = (value: SlotPrismParams): SlotPrismParams => value; export const OG_PRIMITIVE_PARAMS_ROUNDED_BOX = (value: RoundedBoxParams): RoundedBoxParams => value; export const OG_PRIMITIVE_PARAMS_PIPE_SEGMENT = (value: PipeSegmentParams): PipeSegmentParams => value; +export const OG_PRIMITIVE_PARAMS_POLYSOLID = (value: PolysolidParams): PolysolidParams => value; diff --git a/main/opengeometry-three/src/dto/primitives.ts b/main/opengeometry-three/src/dto/primitives.ts index 7d5e6c6d..682b6f96 100644 --- a/main/opengeometry-three/src/dto/primitives.ts +++ b/main/opengeometry-three/src/dto/primitives.ts @@ -12,3 +12,6 @@ export type RoundedBoxParams = { width: number; height: number; depth: number; r export type PipeSegmentParams = { outerRadius: number; innerRadius: number; height: number; startAngle: number; sweepAngle: number; }; +export type PolysolidParams = { + path: ProfileParams; width: number; height: number; justify?: 'centre' | 'left' | 'right'; +}; diff --git a/main/opengeometry-three/tests/node/primitives.test.mjs b/main/opengeometry-three/tests/node/primitives.test.mjs index 67822c14..9155cffe 100644 --- a/main/opengeometry-three/tests/node/primitives.test.mjs +++ b/main/opengeometry-three/tests/node/primitives.test.mjs @@ -14,9 +14,11 @@ import { OG_PRIMITIVE_PARAMS_CONE, OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR, OG_PRIMITIVE_PARAMS_POLYHEDRON, + OG_PRIMITIVE_PARAMS_POLYSOLID, OG_PRIMITIVE_PARAMS_SPHERE, OG_PRIMITIVE_PARAMS_TORUS, OG_PRIMITIVE_POLYHEDRON, + OG_PRIMITIVE_POLYSOLID, OG_PRIMITIVE_PROFILE, OG_PRIMITIVE_RECTANGLE, OG_PRIMITIVE_SPHERE, @@ -215,3 +217,29 @@ test('a rounded box radius of half its width and an inverted pipe segment are In const pipe = { outerRadius: 0.3, innerRadius: 0.5, height: 1, startAngle: 0, sweepAngle: 1 }; assert.throws(() => new Solid(OG_PRIMITIVE_PIPE_SEGMENT, pipe, { ogId: 'pipe' }), failsWith('InvalidParameter')); }); + +function lPath(arc) { + return { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2], arc }], closed: false }; +} + +test('an L-shaped polysolid is a Solid of volume one that exports planes only to STEP', async () => { + await boot(); + const params = OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2] }], closed: false }, + width: 0.2, + height: 1, + justify: 'centre', + }); + const wall = new Solid(OG_PRIMITIVE_POLYSOLID, params, { ogId: 'l-wall' }); + assert.equal(wall.lastInfo.bodyType, 'Solid'); + assert.equal(Math.abs(wall.measure().volume - 1) <= 1e-9, true); + const { text } = await OpenGeometry.exportStep({ nodes: [wall] }); + assert.equal(text.match(/\bPLANE\(/g)?.length, wall.getBrep().topology.faces.length); + assert.equal(text.match(/CYLINDRICAL_SURFACE/g), null); +}); + +test('a polysolid with an arc segment is InvalidParameter', async () => { + await boot(); + const params = { path: lPath({ center: [3, 1], counterClockwise: true }), width: 0.2, height: 1 }; + assert.throws(() => new Solid(OG_PRIMITIVE_POLYSOLID, params, { ogId: 'arced' }), failsWith('InvalidParameter')); +}); diff --git a/main/opengeometry/src/primitives/internal/mod.rs b/main/opengeometry/src/primitives/internal/mod.rs index e9eb3305..e3164dc8 100644 --- a/main/opengeometry/src/primitives/internal/mod.rs +++ b/main/opengeometry/src/primitives/internal/mod.rs @@ -7,6 +7,7 @@ mod extruded_loop; mod frustum; mod linear_extrusion; mod outlines; +mod polysolid; mod revolved_profile; mod sphere; mod torus; @@ -19,6 +20,7 @@ pub use cylinder_with_circular_hole::cylinder_with_circular_hole; pub use frustum::frustum; pub use linear_extrusion::linear_extrusion; pub use outlines::{ring_sector_outline, rounded_rectangle_outline, slot_outline}; +pub use polysolid::{polysolid, PolysolidError, PolysolidJustify, PolysolidPath}; pub use revolved_profile::revolved_profile; pub use sphere::sphere; pub use torus::torus; diff --git a/main/opengeometry/src/primitives/internal/polysolid.rs b/main/opengeometry/src/primitives/internal/polysolid.rs new file mode 100644 index 00000000..0cff6ab3 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/polysolid.rs @@ -0,0 +1,233 @@ +use super::linear_extrusion::linear_extrusion; +use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError}; +use crate::geom2d::{ + offset_path, offset_polyline_regions, CurveEdge2, OffsetError, OffsetOptions, Pt2, +}; +use crate::primitives::profile::{ + profile_edge_intersections, profile_self_intersects, ProfileEdge, +}; +use crate::primitives::profile_wire::{profile_segment_edges, ProfileSegment}; +use serde::{Deserialize, Serialize}; + +const MITER_LIMIT: f64 = 4.0; +const STROKE_TOLERANCE: f64 = 1.0e-6; +const BEVEL_BELOW_DEGREES: f64 = 28.955_024_371_859_85; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolysolidPath { + pub start: [f64; 2], + pub segments: Vec, + pub closed: bool, +} + +#[derive(Debug)] +pub enum PolysolidError { + Parameter(String), + Geometry(GeometryError), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PolysolidJustify { + #[default] + Centre, + Left, + Right, +} + +pub fn polysolid( + id: String, + frame: Frame3, + path: &PolysolidPath, + justify: PolysolidJustify, + [width, height]: [f64; 2], + accuracy: Accuracy, +) -> Result { + let g = accuracy.geometric; + let centre = path_points(path, g)?; + check_legs(¢re, path.closed, width, g, "")?; + let edges = profile_segment_edges(path.start, &path.segments, path.closed); + check_crossing(&edges, path.closed, g)?; + let points = justified(¢re, path.closed, justify, width)?; + check_legs(&points, path.closed, width, g, " after justify")?; + let (outer, holes) = stroke(&points, path.closed, width)?; + linear_extrusion(id, frame, outer, holes, height, accuracy).map_err(PolysolidError::Geometry) +} + +fn path_points(path: &PolysolidPath, g: f64) -> Result, PolysolidError> { + if let Some(index) = path + .segments + .iter() + .position(|segment| segment.arc.is_some()) + { + return Err(parameter(format!( + "polysolid path segment {index} is an arc; only straight legs are accepted" + ))); + } + let values = path.segments.iter().flat_map(|segment| segment.to); + if !path.start.into_iter().chain(values).all(f64::is_finite) { + return Err(parameter("polysolid path points must be finite".into())); + } + if path.segments.len() < if path.closed { 3 } else { 1 } { + return Err(parameter(if path.closed { + "closed polysolid path needs at least three points".into() + } else { + "polysolid path needs at least two points".into() + })); + } + let last = path.segments[path.segments.len() - 1].to; + if path.closed != (distance(last, path.start) <= 4.0 * g) { + return Err(parameter(if path.closed { + "closed polysolid path must end at its start".into() + } else { + "open polysolid path must not end at its start".into() + })); + } + let mut points = vec![path.start]; + let count = if path.closed { + path.segments.len() - 1 + } else { + path.segments.len() + }; + points.extend(path.segments[..count].iter().map(|segment| segment.to)); + Ok(points) +} + +fn check_legs( + points: &[[f64; 2]], + closed: bool, + width: f64, + g: f64, + stage: &str, +) -> Result<(), PolysolidError> { + let legs = if closed { + points.len() + } else { + points.len().saturating_sub(1) + }; + for index in 0..legs { + let length = distance(points[index], points[(index + 1) % points.len()]); + if length <= 4.0 * g { + return Err(parameter(format!( + "polysolid path leg {index} has zero length{stage}" + ))); + } + if length < width / 2.0 { + return Err(parameter(format!( + "polysolid path leg {index} is shorter than half the width{stage}" + ))); + } + } + Ok(()) +} + +fn check_crossing(edges: &[ProfileEdge], closed: bool, g: f64) -> Result<(), PolysolidError> { + if !profile_self_intersects(edges, closed, g) { + return Ok(()); + } + let last = edges.len() - 1; + let pair = (0..edges.len()) + .flat_map(|first| (first + 2..edges.len()).map(move |second| (first, second))) + .filter(|&(first, second)| !(closed && first == 0 && second == last)) + .find(|&(first, second)| { + !profile_edge_intersections(&edges[first], &edges[second], g).is_empty() + }); + Err(parameter(match pair { + Some((first, second)) => { + format!("polysolid path crosses itself at legs {first} and {second}") + } + None => "polysolid path crosses itself where two legs overlap".into(), + })) +} + +fn justified( + points: &[[f64; 2]], + closed: bool, + justify: PolysolidJustify, + width: f64, +) -> Result, PolysolidError> { + let shift = match justify { + PolysolidJustify::Centre => return Ok(points.to_vec()), + PolysolidJustify::Left => width / 2.0, + PolysolidJustify::Right => -width / 2.0, + }; + let options = OffsetOptions { + bevel: true, + acute_threshold_degrees: BEVEL_BELOW_DEGREES, + }; + let offset = offset_path( + &plane_points(points), + shift, + Some(closed), + options, + STROKE_TOLERANCE, + ); + line_points(&offset.edges, closed).map_err(PolysolidError::Geometry) +} + +fn stroke( + points: &[[f64; 2]], + closed: bool, + width: f64, +) -> Result<(Vec<[f64; 2]>, Vec>), PolysolidError> { + let regions = offset_polyline_regions( + &plane_points(points), + width, + closed, + MITER_LIMIT, + STROKE_TOLERANCE, + ) + .map_err(|error| match error { + OffsetError::InvalidParameter(message) => parameter(message), + OffsetError::InvalidTopology(message) => { + PolysolidError::Geometry(GeometryError::InvalidTopology(message)) + } + OffsetError::Clip(error) => PolysolidError::Geometry(error), + })?; + let [region] = regions.as_slice() else { + return Err(parameter(format!( + "polysolid stroke gives {} regions instead of one", + regions.len() + ))); + }; + let holes = region + .holes + .iter() + .map(|hole| line_points(hole, true)) + .collect::, _>>() + .map_err(PolysolidError::Geometry)?; + let outer = line_points(®ion.outer, true).map_err(PolysolidError::Geometry)?; + Ok((outer, holes)) +} + +fn line_points(edges: &[CurveEdge2], closed: bool) -> Result, GeometryError> { + let mut points = Vec::with_capacity(edges.len() + 1); + for (index, edge) in edges.iter().enumerate() { + let &CurveEdge2::Line { from, to } = edge else { + return Err(GeometryError::InvalidGeometry( + "polysolid outline has an arc edge".into(), + )); + }; + points.push(from); + if !closed && index + 1 == edges.len() { + points.push(to); + } + } + Ok(points) +} + +fn plane_points(points: &[[f64; 2]]) -> Vec { + points + .iter() + .map(|point| Pt2::new(point[0], point[1])) + .collect() +} + +fn distance(a: [f64; 2], b: [f64; 2]) -> f64 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} + +fn parameter(message: String) -> PolysolidError { + PolysolidError::Parameter(message) +} diff --git a/main/opengeometry/src/primitives/mod.rs b/main/opengeometry/src/primitives/mod.rs index 48a1dd26..815d4276 100644 --- a/main/opengeometry/src/primitives/mod.rs +++ b/main/opengeometry/src/primitives/mod.rs @@ -21,8 +21,9 @@ pub use cuboid::cuboid; pub use cylinder::cylinder; pub use internal::{ annular_cylinder, arc_edged_extrusion, arc_edged_extrusion_with_holes, cone, cylinder_sector, - cylinder_with_circular_hole, frustum, linear_extrusion, revolved_profile, ring_sector_outline, - rounded_rectangle_outline, slot_outline, sphere, torus, + cylinder_with_circular_hole, frustum, linear_extrusion, polysolid, revolved_profile, + ring_sector_outline, rounded_rectangle_outline, slot_outline, sphere, torus, PolysolidError, + PolysolidJustify, PolysolidPath, }; pub use polyhedron::polyhedron; pub use polyline::{polyline, polyline_with_keys}; diff --git a/main/opengeometry/src/world_graph/primitive/kind.rs b/main/opengeometry/src/world_graph/primitive/kind.rs index deb394b9..dd6e5162 100644 --- a/main/opengeometry/src/world_graph/primitive/kind.rs +++ b/main/opengeometry/src/world_graph/primitive/kind.rs @@ -1,6 +1,6 @@ use crate::brep::BodyType; use crate::math::Point3; -use crate::primitives::ProfileSegment; +use crate::primitives::{PolysolidJustify, PolysolidPath, ProfileSegment}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -81,6 +81,13 @@ pub enum Primitive { start_angle: f64, sweep_angle: f64, }, + Polysolid { + path: PolysolidPath, + width: f64, + height: f64, + #[serde(default)] + justify: PolysolidJustify, + }, } impl Primitive { @@ -95,7 +102,8 @@ impl Primitive { | Self::Torus { .. } | Self::SlotPrism { .. } | Self::RoundedBox { .. } - | Self::PipeSegment { .. } => BodyType::Solid, + | Self::PipeSegment { .. } + | Self::Polysolid { .. } => BodyType::Solid, Self::Rectangle { .. } | Self::Circle { .. } | Self::Polyline { .. } diff --git a/main/opengeometry/src/world_graph/primitive/solids.rs b/main/opengeometry/src/world_graph/primitive/solids.rs index a168a961..715c2a64 100644 --- a/main/opengeometry/src/world_graph/primitive/solids.rs +++ b/main/opengeometry/src/world_graph/primitive/solids.rs @@ -2,8 +2,7 @@ use super::kind::Primitive; use super::sizes::{check_arc, parameter, required_size, wrong_body_type}; use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError, GROUND}; use crate::math::{scale, Point3}; -use crate::primitives; -use crate::primitives::ProfileEdge; +use crate::primitives::{self, PolysolidError, PolysolidJustify, PolysolidPath, ProfileEdge}; use crate::world_graph::error::{ErrorCode, GraphError}; pub(super) fn build_solid( @@ -67,6 +66,12 @@ pub(super) fn build_solid( [start_angle, sweep_angle], accuracy, )?, + Primitive::Polysolid { + path, + width, + height, + justify, + } => polysolid_body(shape_id, &path, justify, [width, height], accuracy)?, _ => return Err(wrong_body_type("solid")), }; Ok(result) @@ -283,3 +288,20 @@ fn outline_body( Vec::new(), )) } + +fn polysolid_body( + shape_id: String, + path: &PolysolidPath, + justify: PolysolidJustify, + [width, height]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + required_size(width, accuracy.geometric, "width")?; + required_size(height, accuracy.geometric, "height")?; + let brep = primitives::polysolid(shape_id, GROUND, path, justify, [width, height], accuracy) + .map_err(|error| match error { + PolysolidError::Parameter(message) => parameter(&message), + PolysolidError::Geometry(error) => error.into(), + })?; + Ok((brep, Vec::new())) +} diff --git a/main/opengeometry/tests/creating/main.rs b/main/opengeometry/tests/creating/main.rs index 8f88fd91..3e665681 100644 --- a/main/opengeometry/tests/creating/main.rs +++ b/main/opengeometry/tests/creating/main.rs @@ -3,6 +3,7 @@ mod holes; mod mesh_walls; mod pipe_segment; mod polyhedron; +mod polysolid; mod profiles; mod rebuild; mod revolve; diff --git a/main/opengeometry/tests/creating/polysolid.rs b/main/opengeometry/tests/creating/polysolid.rs new file mode 100644 index 00000000..82f22a70 --- /dev/null +++ b/main/opengeometry/tests/creating/polysolid.rs @@ -0,0 +1,397 @@ +use opengeometry::primitives::{PolysolidJustify, PolysolidPath, ProfileSegment, SegmentArc}; +use opengeometry::world_graph::{ + CreateOptions, EditScope, ErrorCode, ModifyingOperation, Plane, Primitive, StepOptions, + Transform, WorldGraph, +}; +use opengeometry_test_support::part21::Document; +use opengeometry_test_support::world_graph::{graph, named}; +use serde_json::json; + +fn path(start: [f64; 2], points: &[[f64; 2]], closed: bool) -> PolysolidPath { + PolysolidPath { + start, + segments: points + .iter() + .map(|&to| ProfileSegment { to, arc: None }) + .collect(), + closed, + } +} + +fn wall(path: PolysolidPath, width: f64, justify: PolysolidJustify) -> Primitive { + Primitive::Polysolid { + path, + width, + height: 1.0, + justify, + } +} + +fn l_path() -> PolysolidPath { + path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0]], false) +} + +fn loop_path() -> PolysolidPath { + path( + [0.0, 0.0], + &[[3.0, 0.0], [3.0, 2.0], [0.0, 2.0], [0.0, 0.0]], + true, + ) +} + +fn built(primitive: Primitive) -> WorldGraph { + let mut world = graph(); + world.create_primitive(primitive, named("wall")).unwrap(); + world +} + +fn assert_valid_closed(world: &WorldGraph, og_id: &str, label: &str) { + let validity = world.validity(og_id).unwrap(); + assert!(validity.valid && validity.closed, "{label}: {validity:?}"); +} + +fn assert_flat_volume(world: &WorldGraph, expected: f64) { + let measured = world.measure("wall", Some(1e-4)).unwrap(); + assert!( + (measured.volume - expected).abs() <= 1e-9, + "{} versus {expected}", + measured.volume + ); +} + +fn face_count(world: &WorldGraph) -> usize { + world.brep("wall").unwrap().topology.faces.len() +} + +fn assert_bounds(world: &WorldGraph, expected: [f64; 6]) { + let bounds = world.bounds("wall").unwrap().unwrap(); + assert!( + (0..6).all(|axis| (bounds[axis] - expected[axis]).abs() <= 1e-9), + "{bounds:?} versus {expected:?}" + ); +} + +fn refused(world: &mut WorldGraph, primitive: Primitive) -> String { + let label = format!("{primitive:?}"); + let error = world + .create_primitive(primitive, named("wall")) + .expect_err(&label); + assert_eq!(error.error_code(), ErrorCode::InvalidParameter, "{label}"); + assert_eq!(world.node_count(), 0); + error.message().to_string() +} + +fn refusal(primitive: Primitive) -> String { + refused(&mut graph(), primitive) +} + +#[test] +fn l_shaped_polysolid_is_a_closed_wall_of_volume_one_with_ten_faces() { + let world = built(wall(l_path(), 0.2, PolysolidJustify::Centre)); + assert_valid_closed(&world, "wall", "l path"); + assert_flat_volume(&world, 1.0); + assert_eq!(face_count(&world), 10); + assert_bounds(&world, [0.0, 0.0, -2.0, 3.1, 1.0, 0.1]); +} + +#[test] +fn closed_polysolid_loop_is_a_closed_ring_wall_of_volume_two() { + let world = built(wall(loop_path(), 0.2, PolysolidJustify::Centre)); + assert_valid_closed(&world, "wall", "loop"); + assert_flat_volume(&world, 2.0); + assert_eq!(face_count(&world), 18); + assert_bounds(&world, [-0.1, 0.0, -2.1, 3.1, 1.0, 0.1]); +} + +#[test] +fn left_justified_polysolid_lies_inside_the_l_corner() { + let world = built(wall(l_path(), 0.2, PolysolidJustify::Left)); + assert_valid_closed(&world, "wall", "left"); + assert_flat_volume(&world, (2.9 + 1.9) * 0.2); + assert_bounds(&world, [0.0, 0.0, -2.0, 3.0, 1.0, 0.0]); +} + +#[test] +fn right_justified_polysolid_lies_outside_the_l_corner() { + let world = built(wall(l_path(), 0.2, PolysolidJustify::Right)); + assert_valid_closed(&world, "wall", "right"); + assert_flat_volume(&world, (3.1 + 2.1) * 0.2); + assert_bounds(&world, [0.0, 0.0, -2.0, 3.2, 1.0, 0.2]); +} + +#[test] +fn polysolid_with_an_arc_segment_is_refused_naming_the_segment() { + let mut arced = l_path(); + arced.segments[1].arc = Some(SegmentArc { + center: [3.0, 1.0], + counter_clockwise: true, + }); + let message = refusal(wall(arced, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("segment 1"), "{message}"); +} + +#[test] +fn polysolid_with_fewer_than_two_points_is_refused() { + let message = refusal(wall( + path([0.0, 0.0], &[], false), + 0.2, + PolysolidJustify::Centre, + )); + assert!(message.contains("at least two points"), "{message}"); +} + +#[test] +fn polysolid_with_a_zero_length_leg_is_refused() { + let repeated = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 0.0], [3.0, 2.0]], false); + let message = refusal(wall(repeated, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("leg 1 has zero length"), "{message}"); +} + +#[test] +fn polysolid_with_a_leg_shorter_than_half_its_width_is_refused() { + let short = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 0.09], [5.0, 0.09]], false); + let message = refusal(wall(short, 0.2, PolysolidJustify::Centre)); + assert!( + message.contains("leg 1 is shorter than half the width"), + "{message}" + ); +} + +#[test] +fn polysolid_path_that_crosses_itself_is_refused() { + let crossing = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0], [1.5, -1.0]], false); + let message = refusal(wall(crossing, 0.2, PolysolidJustify::Centre)); + assert!( + message.contains("crosses itself at legs 0 and 2"), + "{message}" + ); +} + +#[test] +fn polysolid_whose_stroke_gives_no_region_is_refused() { + let message = refusal(wall(l_path(), 3e-6, PolysolidJustify::Centre)); + assert!(message.contains("0 regions"), "{message}"); +} + +#[test] +fn closed_polysolid_path_that_does_not_end_at_its_start_is_refused() { + let open_end = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0], [0.0, 2.0]], true); + let message = refusal(wall(open_end, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("must end at its start"), "{message}"); +} + +#[test] +fn polysolid_with_a_bad_width_or_height_is_refused() { + for (width, height) in [ + (0.0, 1.0), + (-0.2, 1.0), + (f64::NAN, 1.0), + (0.2, 0.0), + (0.2, f64::INFINITY), + ] { + refusal(Primitive::Polysolid { + path: l_path(), + width, + height, + justify: PolysolidJustify::Centre, + }); + } +} + +#[test] +fn polysolid_json_is_camel_case_and_justify_defaults_to_centre() { + let parsed: Primitive = serde_json::from_value(json!({ + "kind": "Polysolid", + "path": {"start": [0, 0], "segments": [{"to": [3, 0]}, {"to": [3, 2]}], "closed": false}, + "width": 0.2, + "height": 1 + })) + .unwrap(); + let Primitive::Polysolid { justify, .. } = parsed else { + panic!("{parsed:?}"); + }; + assert_eq!(justify, PolysolidJustify::Centre); + let right = json!({ + "kind": "Polysolid", + "path": {"start": [0, 0], "segments": [{"to": [3, 0]}], "closed": false}, + "width": 0.2, + "height": 1, + "justify": "right" + }); + let world = built(serde_json::from_value(right.clone()).unwrap()); + assert_valid_closed(&world, "wall", "json"); + let mut unknown = right; + unknown["path"]["isClosed"] = json!(false); + assert!(serde_json::from_value::(unknown).is_err()); +} + +#[test] +fn polysolid_on_a_plane_is_placed_and_keeps_its_volume() { + let mut world = graph(); + let options = CreateOptions { + plane: Some(Plane { + origin: Some([1.0, 2.0, 3.0]), + normal: Some([1.0, 0.0, 0.0]), + x_direction: Some([0.0, 0.0, 1.0]), + }), + ..named("wall") + }; + world + .create_primitive(wall(l_path(), 0.2, PolysolidJustify::Centre), options) + .unwrap(); + assert_valid_closed(&world, "wall", "placed"); + assert_flat_volume(&world, 1.0); + assert_bounds(&world, [1.0, 0.0, 3.0, 2.0, 2.1, 6.1]); +} + +#[test] +fn polysolid_shell_and_step_export_are_valid() { + let mut world = built(wall(l_path(), 0.2, PolysolidJustify::Centre)); + let (text, report) = world + .export_step(&["wall".into()], &StepOptions::default()) + .unwrap(); + assert_eq!(report.pcurveless_edges, 0); + let document = Document::parse(&text).unwrap(); + assert_eq!(document.count("MANIFOLD_SOLID_BREP"), 1); + assert_eq!(document.pcurveless_edges().unwrap(), 0); + assert_eq!(document.count("PLANE"), face_count(&world)); + assert_eq!(document.count("CYLINDRICAL_SURFACE"), 0); + world.shell("wall", 0.03, EditScope::Node).unwrap(); + assert_valid_closed(&world, "wall", "shell"); +} + +fn hairpin_bounds(justify: PolysolidJustify) -> [f64; 6] { + let hairpin = path([0.0, 0.0], &[[3.0, 0.0], [0.0, 0.3]], false); + let world = built(wall(hairpin, 0.2, justify)); + assert_valid_closed(&world, "wall", &format!("{justify:?}")); + world.bounds("wall").unwrap().unwrap() +} + +#[test] +fn justified_near_hairpin_polysolid_stays_within_the_mitre_limit_of_its_corner() { + for justify in [PolysolidJustify::Left, PolysolidJustify::Right] { + let bounds = hairpin_bounds(justify); + let reach = 0.2 * 4.0; + assert!(bounds[3] <= 3.0 + reach, "{justify:?} {bounds:?}"); + assert!(bounds[2] >= -0.3 - reach, "{justify:?} {bounds:?}"); + assert!(bounds[5] <= reach, "{justify:?} {bounds:?}"); + } +} + +#[test] +fn open_polysolid_path_that_ends_at_its_start_is_refused() { + let returning = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0], [0.0, 0.0]], false); + let message = refusal(wall(returning, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("must not end at its start"), "{message}"); +} + +#[test] +fn closed_polysolid_path_with_two_segments_is_refused() { + let there_and_back = path([0.0, 0.0], &[[3.0, 0.0], [0.0, 0.0]], true); + let message = refusal(wall(there_and_back, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("at least three points"), "{message}"); +} + +#[test] +fn closed_bow_tie_polysolid_path_is_refused() { + let bow_tie = path( + [0.0, 0.0], + &[[2.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]], + true, + ); + let message = refusal(wall(bow_tie, 0.2, PolysolidJustify::Centre)); + assert!( + message.contains("crosses itself at legs 1 and 3"), + "{message}" + ); +} + +#[test] +fn polysolid_with_collinear_legs_is_one_straight_wall() { + let straight = path([0.0, 0.0], &[[1.0, 0.0], [2.0, 0.0]], false); + let world = built(wall(straight, 0.2, PolysolidJustify::Centre)); + assert_valid_closed(&world, "wall", "collinear"); + assert_flat_volume(&world, 2.0 * 0.2 * 1.0); +} + +#[test] +fn polysolid_path_that_turns_straight_back_is_refused() { + let u_turn = path([0.0, 0.0], &[[3.0, 0.0], [1.0, 0.0]], false); + let message = refusal(wall(u_turn, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("crosses itself"), "{message}"); +} + +const OPERATIONS: [ModifyingOperation; 3] = [ + ModifyingOperation::Subtract, + ModifyingOperation::Union, + ModifyingOperation::Intersect, +]; + +const PLACEMENTS: [(&str, [f64; 3], f64); 3] = [ + ("A", [1.5, 0.75, -0.1], 0.5), + ("B", [1.5, 0.75, 0.0], 0.5), + ("T", [1.5, -0.25, 0.0], 1.5), +]; + +fn tools(height: f64) -> [(&'static str, Primitive); 2] { + [ + ( + "box", + Primitive::Cuboid { + width: 0.08, + height, + depth: 0.08, + }, + ), + ( + "cylinder", + Primitive::Cylinder { + radius: 0.04, + height, + }, + ), + ] +} + +fn bar_cell(tool: &Primitive, offset: [f64; 3], operation: ModifyingOperation) -> String { + let mut world = built(wall(l_path(), 0.2, PolysolidJustify::Centre)); + world.create_primitive(tool.clone(), named("tool")).unwrap(); + world + .transform("tool", Transform::Translate { offset }) + .unwrap(); + if let Err(error) = world.operate("wall", operation, &["tool".into()], EditScope::Node) { + return format!("refused {:?}", error.error_code()); + } + let validity = world.validity("wall").unwrap(); + format!( + "valid {} closed {} {}", + validity.valid, + validity.closed, + world.report("wall").unwrap().unwrap()["handlers"] + ) +} + +#[test] +fn l_shaped_polysolid_passes_the_boolean_bar_with_box_and_cylinder_tools() { + let mut seen = Vec::new(); + let mut expected = Vec::new(); + for (place, offset, height) in PLACEMENTS { + for (name, tool) in tools(height) { + for operation in OPERATIONS { + let handlers = bar_cell(&tool, offset, operation); + seen.push(format!("{place}/{name}/{operation:?} {handlers}")); + let handler = match (place, name, operation) { + (_, "box", _) => "boolean_rectilinear", + ("T", _, ModifyingOperation::Subtract) => { + "generic_two_sided_cutter_band_subtraction" + } + _ => "prismatic_curved_boolean", + }; + expected.push(format!( + "{place}/{name}/{operation:?} valid true closed true [\"{handler}\"]" + )); + } + } + } + assert_eq!(seen, expected); +} diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index c9cc2545..6d949fb2 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -431,6 +431,8 @@ "builders.polyline.noncoplanar": "de106849f9cff95a618f76b860f88e797de65c30944c0a4b081b1cb62f140456", "builders.polyline.open": "485a0b9bebbe46e69ac2f3e44b6567aa57e430857f87c8eba1dc9652f37d5ed4", "builders.polyline.repeated-point": "d608bd770ef548c6c94c9bee36a322fa486575308e1521e96c1f92bf0fad7156", + "builders.polysolid.closed-loop": "bd28c9d24de20392f82def879c5dea7735d1e8bf6cb1e94899bdc8bbc0266938", + "builders.polysolid.l-path": "8e0fbb2609ab792ec775b221cb388da198500088c0b0f5761cae0a1166d029d4", "builders.profile-keys.ring-sector": "b2ad4caf9ab117586b3c141738bd06e07bd5047d87647566a6eece7216a9f61c", "builders.rectangle-keys.ground": "12e2f757757dc4f3e3fb5cd08cb103ed86f9ea393295ed2897fff72605186ed2", "builders.rectangle.identity": "31332072019245900457cc87ce607bc825c04cddf1589c11f1e870ba7b4a78c1", diff --git a/main/opengeometry/tests/golden/builders_solids.rs b/main/opengeometry/tests/golden/builders_solids.rs index 7396beba..998e8012 100644 --- a/main/opengeometry/tests/golden/builders_solids.rs +++ b/main/opengeometry/tests/golden/builders_solids.rs @@ -401,6 +401,42 @@ fn standard_outlines() -> Vec { ] } +fn polysolid_path(points: &[[f64; 2]], closed: bool) -> primitives::PolysolidPath { + primitives::PolysolidPath { + start: points[0], + segments: points[1..] + .iter() + .map(|&to| primitives::ProfileSegment { to, arc: None }) + .collect(), + closed, + } +} + +fn polysolid(name: &str, points: &[[f64; 2]], closed: bool) -> Result { + let path = polysolid_path(points, closed); + let centre = primitives::PolysolidJustify::Centre; + primitives::polysolid(name.into(), ground(), &path, centre, [0.2, 1.0], standard()).map_err( + |error| match error { + primitives::PolysolidError::Parameter(message) => { + GeometryError::InvalidGeometry(message) + } + primitives::PolysolidError::Geometry(error) => error, + }, + ) +} + +fn polysolids() -> Vec { + vec![ + built("polysolid.l-path", || { + polysolid("l", &[[0.0, 0.0], [3.0, 0.0], [3.0, 2.0]], false) + }), + built("polysolid.closed-loop", || { + let points = [[0.0, 0.0], [3.0, 0.0], [3.0, 2.0], [0.0, 2.0], [0.0, 0.0]]; + polysolid("loop", &points, true) + }), + ] +} + pub(crate) fn cases() -> Vec { [ cuboids(), @@ -414,6 +450,7 @@ pub(crate) fn cases() -> Vec { revolved_arcs(), vec![graph_case("graph.primitive.revolved", revolved_primitives)], standard_outlines(), + polysolids(), ] .into_iter() .flatten() From e3a95281a424597e390fc82a94d57cc9e6163a95 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Nov 2026 17:13:41 +0100 Subject: [PATCH 5/6] Add regular polygon, slot and rounded rectangle wire primitives The regular polygon is a closed polyline of 3 to 1024 corners on a circle, the first on +u. The slot and rounded rectangle are closed profile wires built from the slot prism and rounded box outlines, so each extrudes into the matching solid. Bad sides, sizes and corner radii return InvalidParameter naming the value. The SDK gains the constants, helpers and types; the docs, an example page, the changelog and README list them; the golden file gains the three new wire keys only. --- CHANGELOG.md | 3 + README.md | 4 +- docs/api-coverage.json | 45 ++++ docs/opengeometry/api/constants.mdx | 120 ++++++----- docs/opengeometry/api/overview.mdx | 18 +- docs/opengeometry/api/wire.mdx | 17 +- docs/opengeometry/changelog.mdx | 1 + docs/opengeometry/examples.mdx | 1 + docs/opengeometry/guides/primitives.mdx | 52 ++++- .../examples-vite/index.html | 1 + .../examples-vite/shapes/wire-kinds.html | 34 +++ .../examples-vite/shapes/wire-kinds.ts | 79 +++++++ .../examples-vite/vite.config.ts | 1 + main/opengeometry-three/index.ts | 1 + .../src/constants/primitives.ts | 7 + main/opengeometry-three/src/dto/primitives.ts | 3 + .../tests/node/primitives.test.mjs | 35 ++++ .../src/world_graph/primitive/kind.rs | 18 +- .../src/world_graph/primitive/sizes.rs | 23 ++ .../src/world_graph/primitive/solids.rs | 26 +-- .../src/world_graph/primitive/wires.rs | 84 +++++++- main/opengeometry/tests/creating/main.rs | 1 + .../opengeometry/tests/creating/wire_kinds.rs | 197 ++++++++++++++++++ .../fixtures/golden/aarch64-apple-darwin.json | 3 + .../tests/golden/builders_profiles.rs | 55 ++++- 25 files changed, 723 insertions(+), 106 deletions(-) create mode 100644 main/opengeometry-three/examples-vite/shapes/wire-kinds.html create mode 100644 main/opengeometry-three/examples-vite/shapes/wire-kinds.ts create mode 100644 main/opengeometry/tests/creating/wire_kinds.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc5b9bb..2206418b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ All notable changes to the `opengeometry` package are in this file. The format f legs, with `justify` `'centre'`, `'left'` or `'right'`, mitred corners and an open or closed path. `OG_PRIMITIVE_PARAMS_POLYSOLID` and the type `PolysolidParams` give it a type. +- Wire primitives `OG_PRIMITIVE_REGULAR_POLYGON`, `OG_PRIMITIVE_SLOT` and + `OG_PRIMITIVE_ROUNDED_RECTANGLE`, with their `OG_PRIMITIVE_PARAMS_*` helpers and the + `RegularPolygonParams`, `SlotParams` and `RoundedRectangleParams` types. ## [3.0.0] - 2026-11-09 diff --git a/README.md b/README.md index a70986a3..700d7661 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ It is the geometry engine layer, not a full CAD application. Floorkit is a downs | Area | What 3.0 has | | --- | --- | -| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment, polysolid wall along a path of straight legs. Each can be placed on any plane. | +| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc, regular polygon, slot, rounded rectangle. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment, polysolid wall along a path of straight legs. Each can be placed on any plane. | | **Operations** | Extrude a closed wire into a solid, with holes. Sweep a profile along an open or closed path of lines and arcs, each arc an exact bend, capped into a solid, open-ended into a sheet, or closed into a ring. Revolve a closed line-and-arc profile a full turn about an axis in its plane. Shell a solid into a hollow one with walls of one thickness. Union, subtract and intersect, with one to a hundred tool solids in one call. | | **Parametric edits** | `rebuild` gives an existing body new parameters in place. `mirror` rebuilds a body as its reflection across a plane. | | **Assemblies** | Assemblies with parent and child placement; translate, rotate, scale and place; independent copies and shared instances. | @@ -164,7 +164,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | Export | Call and parameters | | --- | --- | | `OpenGeometry.create` | `await OpenGeometry.create(input?, options?)`. `input`: `{ wasmURL?, wasmModule? }`. `options`: `{ workerURL?, tessellation?: 'worker' \| 'inline' }`. | -| `Wire` | `new Wire(kind, params, options?)`. `OG_PRIMITIVE_RECTANGLE` `{ width, breadth }`, `OG_PRIMITIVE_CIRCLE` `{ radius }`, `OG_PRIMITIVE_POLYLINE` `{ points, closed? }`. | +| `Wire` | `new Wire(kind, params, options?)`. `OG_PRIMITIVE_RECTANGLE` `{ width, breadth }`, `OG_PRIMITIVE_CIRCLE` `{ radius }`, `OG_PRIMITIVE_POLYLINE` `{ points, closed? }`, `OG_PRIMITIVE_REGULAR_POLYGON` `{ sides, radius }` (3 to 1024 corners on a circle about the `plane` origin, the first on the x direction), `OG_PRIMITIVE_SLOT` `{ length, width }` (the outline of `OG_PRIMITIVE_SLOT_PRISM`), `OG_PRIMITIVE_ROUNDED_RECTANGLE` `{ width, breadth, radius }` (a rectangle with rounded corners; `radius` below half the width and half the breadth). | | Profile wire | `new Wire(OG_PRIMITIVE_PROFILE, { start, segments, closed }, options?)`. Points are `[u, v]` in `plane`; a segment is `{ to }` for a line or `{ to, arc: { center, counterClockwise } }` for an arc. A closed profile is an extrude profile or hole. | | Arc wire | `new Wire(OG_PRIMITIVE_ARC, { radius, startAngle, sweepAngle }, options?)`. Angles in radians from the `plane` x direction, centred on the `plane` origin; the sweep is nonzero and less than one turn. | | `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis), `OG_PRIMITIVE_SLOT_PRISM` `{ length, width, height }` (a stadium standing on the `plane`, centred on its origin; `length` between the arc centres along the x direction), `OG_PRIMITIVE_ROUNDED_BOX` `{ width, height, depth, radius }` (a cuboid with its four upright edges rounded; `radius` below half the width and half the depth), `OG_PRIMITIVE_PIPE_SEGMENT` `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` (a ring sector placed and angled like the cylinder sector), `OG_PRIMITIVE_POLYSOLID` `{ path, width, height, justify? }` (a wall of constant width along `path`, a `{ start, segments, closed }` of straight legs in the `plane`'s `[u, v]`, standing on the `plane` and rising along its normal; `justify` is `'centre'` (default), `'left'` or `'right'` of the path direction); `profile`, `holes` and `path` are wires. | diff --git a/docs/api-coverage.json b/docs/api-coverage.json index 33b6db05..8f0a70e8 100644 --- a/docs/api-coverage.json +++ b/docs/api-coverage.json @@ -149,6 +149,21 @@ "kind": "type", "page": "opengeometry/api/constants" }, + { + "export": "RegularPolygonParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "SlotParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "RoundedRectangleParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, { "export": "CurvedRegion2D", "kind": "type", @@ -259,6 +274,21 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_REGULAR_POLYGON", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_SLOT", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_ROUNDED_RECTANGLE", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_PRIMITIVE_PARAMS_RECTANGLE", "kind": "value", @@ -339,6 +369,21 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_PARAMS_REGULAR_POLYGON", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_SLOT", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_OPERATION_EXTRUDE", "kind": "value", diff --git a/docs/opengeometry/api/constants.mdx b/docs/opengeometry/api/constants.mdx index bb9a9210..0e3d5d56 100644 --- a/docs/opengeometry/api/constants.mdx +++ b/docs/opengeometry/api/constants.mdx @@ -8,24 +8,28 @@ Each kind is a string constant. Pass it as the first argument of a constructor, ## Primitives -| Constant | Value | Body | Parameters type | -| ------------------------------ | ------------------ | ----- | -------------------------- | -| `OG_PRIMITIVE_RECTANGLE` | `'Rectangle'` | Wire | `{ width, breadth }` | -| `OG_PRIMITIVE_CIRCLE` | `'Circle'` | Wire | `{ radius }` | -| `OG_PRIMITIVE_POLYLINE` | `'Polyline'` | Wire | `{ points, closed? }` | -| `OG_PRIMITIVE_PROFILE` | `'Profile'` | Wire | `ProfileParams` | -| `OG_PRIMITIVE_ARC` | `'Arc'` | Wire | `ArcParams` | -| `OG_PRIMITIVE_CUBOID` | `'Cuboid'` | Solid | `{ width, height, depth }` | -| `OG_PRIMITIVE_CYLINDER` | `'Cylinder'` | Solid | `{ radius, height }` | -| `OG_PRIMITIVE_CYLINDER_SECTOR` | `'CylinderSector'` | Solid | `CylinderSectorParams` | -| `OG_PRIMITIVE_POLYHEDRON` | `'Polyhedron'` | Solid | `PolyhedronParams` | -| `OG_PRIMITIVE_SPHERE` | `'Sphere'` | Solid | `SphereParams` | -| `OG_PRIMITIVE_CONE` | `'Cone'` | Solid | `ConeParams` | -| `OG_PRIMITIVE_TORUS` | `'Torus'` | Solid | `TorusParams` | -| `OG_PRIMITIVE_SLOT_PRISM` | `'SlotPrism'` | Solid | `SlotPrismParams` | -| `OG_PRIMITIVE_ROUNDED_BOX` | `'RoundedBox'` | Solid | `RoundedBoxParams` | -| `OG_PRIMITIVE_PIPE_SEGMENT` | `'PipeSegment'` | Solid | `PipeSegmentParams` | -| `OG_PRIMITIVE_POLYSOLID` | `'Polysolid'` | Solid | `PolysolidParams` | +| Constant | Value | Body | Parameters type | +| -------------------------------- | -------------------- | ----- | -------------------------- | +| `OG_PRIMITIVE_RECTANGLE` | `'Rectangle'` | Wire | `{ width, breadth }` | +| `OG_PRIMITIVE_CIRCLE` | `'Circle'` | Wire | `{ radius }` | +| `OG_PRIMITIVE_POLYLINE` | `'Polyline'` | Wire | `{ points, closed? }` | +| `OG_PRIMITIVE_PROFILE` | `'Profile'` | Wire | `ProfileParams` | +| `OG_PRIMITIVE_ARC` | `'Arc'` | Wire | `ArcParams` | +| `OG_PRIMITIVE_CUBOID` | `'Cuboid'` | Solid | `{ width, height, depth }` | +| `OG_PRIMITIVE_CYLINDER` | `'Cylinder'` | Solid | `{ radius, height }` | +| `OG_PRIMITIVE_CYLINDER_SECTOR` | `'CylinderSector'` | Solid | `CylinderSectorParams` | +| `OG_PRIMITIVE_POLYHEDRON` | `'Polyhedron'` | Solid | `PolyhedronParams` | +| `OG_PRIMITIVE_SPHERE` | `'Sphere'` | Solid | `SphereParams` | +| `OG_PRIMITIVE_CONE` | `'Cone'` | Solid | `ConeParams` | +| `OG_PRIMITIVE_TORUS` | `'Torus'` | Solid | `TorusParams` | +| `OG_PRIMITIVE_SLOT_PRISM` | `'SlotPrism'` | Solid | `SlotPrismParams` | +| `OG_PRIMITIVE_ROUNDED_BOX` | `'RoundedBox'` | Solid | `RoundedBoxParams` | +| `OG_PRIMITIVE_PIPE_SEGMENT` | `'PipeSegment'` | Solid | `PipeSegmentParams` | +| `OG_PRIMITIVE_POLYSOLID` | `'Polysolid'` | Solid | `PolysolidParams` | +| -------------------------------- | -------------------- | ----- | -------------------------- | +| `OG_PRIMITIVE_REGULAR_POLYGON` | `'RegularPolygon'` | Wire | `RegularPolygonParams` | +| `OG_PRIMITIVE_SLOT` | `'Slot'` | Wire | `SlotParams` | +| `OG_PRIMITIVE_ROUNDED_RECTANGLE` | `'RoundedRectangle'` | Wire | `RoundedRectangleParams` | ## Operations @@ -52,27 +56,31 @@ Each kind is a string constant. Pass it as the first argument of a constructor, Each helper returns its argument unchanged. It only gives the object a type, so the compiler checks the field names. -| Helper | Type | -| ------------------------------------- | ------------------------------- | -| `OG_PRIMITIVE_PARAMS_RECTANGLE` | `{ width, breadth }` | -| `OG_PRIMITIVE_PARAMS_CIRCLE` | `{ radius }` | -| `OG_PRIMITIVE_PARAMS_POLYLINE` | `{ points, closed? }` | -| `OG_PRIMITIVE_PARAMS_CUBOID` | `{ width, height, depth }` | -| `OG_PRIMITIVE_PARAMS_CYLINDER` | `{ radius, height }` | -| `OG_PRIMITIVE_PARAMS_PROFILE` | `ProfileParams` | -| `OG_PRIMITIVE_PARAMS_ARC` | `ArcParams` | -| `OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR` | `CylinderSectorParams` | -| `OG_PRIMITIVE_PARAMS_POLYHEDRON` | `PolyhedronParams` | -| `OG_PRIMITIVE_PARAMS_SPHERE` | `SphereParams` | -| `OG_PRIMITIVE_PARAMS_CONE` | `ConeParams` | -| `OG_PRIMITIVE_PARAMS_TORUS` | `TorusParams` | -| `OG_PRIMITIVE_PARAMS_POLYSOLID` | `PolysolidParams` | -| `OG_OPERATION_PARAMS_EXTRUDE` | `{ profile, holes?, distance }` | -| `OG_OPERATION_PARAMS_SWEEP` | `{ profile, path, caps? }` | -| `OG_OPERATION_PARAMS_REVOLVE` | `{ profile, axis? }` | -| `OG_PRIMITIVE_PARAMS_SLOT_PRISM` | `SlotPrismParams` | -| `OG_PRIMITIVE_PARAMS_ROUNDED_BOX` | `RoundedBoxParams` | -| `OG_PRIMITIVE_PARAMS_PIPE_SEGMENT` | `PipeSegmentParams` | +| Helper | Type | +| --------------------------------------- | ------------------------------- | +| `OG_PRIMITIVE_PARAMS_RECTANGLE` | `{ width, breadth }` | +| `OG_PRIMITIVE_PARAMS_CIRCLE` | `{ radius }` | +| `OG_PRIMITIVE_PARAMS_POLYLINE` | `{ points, closed? }` | +| `OG_PRIMITIVE_PARAMS_CUBOID` | `{ width, height, depth }` | +| `OG_PRIMITIVE_PARAMS_CYLINDER` | `{ radius, height }` | +| `OG_PRIMITIVE_PARAMS_PROFILE` | `ProfileParams` | +| `OG_PRIMITIVE_PARAMS_ARC` | `ArcParams` | +| `OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR` | `CylinderSectorParams` | +| `OG_PRIMITIVE_PARAMS_POLYHEDRON` | `PolyhedronParams` | +| `OG_PRIMITIVE_PARAMS_SPHERE` | `SphereParams` | +| `OG_PRIMITIVE_PARAMS_CONE` | `ConeParams` | +| `OG_PRIMITIVE_PARAMS_TORUS` | `TorusParams` | +| `OG_PRIMITIVE_PARAMS_POLYSOLID` | `PolysolidParams` | +| `OG_OPERATION_PARAMS_EXTRUDE` | `{ profile, holes?, distance }` | +| `OG_OPERATION_PARAMS_SWEEP` | `{ profile, path, caps? }` | +| `OG_OPERATION_PARAMS_REVOLVE` | `{ profile, axis? }` | +| `OG_PRIMITIVE_PARAMS_SLOT_PRISM` | `SlotPrismParams` | +| `OG_PRIMITIVE_PARAMS_ROUNDED_BOX` | `RoundedBoxParams` | +| `OG_PRIMITIVE_PARAMS_PIPE_SEGMENT` | `PipeSegmentParams` | +| --------------------------------------- | ------------------------------- | +| `OG_PRIMITIVE_PARAMS_REGULAR_POLYGON` | `RegularPolygonParams` | +| `OG_PRIMITIVE_PARAMS_SLOT` | `SlotParams` | +| `OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE` | `RoundedRectangleParams` | ```ts import { @@ -99,21 +107,25 @@ console.log(sector.validity().valid); ## Parameter types -| Type | Fields | -| ---------------------- | --------------------------------------------------------------------------------------- | -| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | -| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | -| `ProfileArc` | `center: [u, v]`, `counterClockwise` | -| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | -| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | -| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | -| `SphereParams` | `radius` | -| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | -| `TorusParams` | `majorRadius`, `minorRadius` | -| `SlotPrismParams` | `length`, `width`, `height` | -| `RoundedBoxParams` | `width`, `height`, `depth`, `radius` | -| `PipeSegmentParams` | `outerRadius`, `innerRadius`, `height`, `startAngle`, `sweepAngle` | -| `PolysolidParams` | `path: ProfileParams`, `width`, `height`, `justify?`: `'centre'`, `'left'` or `'right'` | +| Type | Fields | +| ------------------------ | --------------------------------------------------------------------------------------- | +| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | +| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | +| `ProfileArc` | `center: [u, v]`, `counterClockwise` | +| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | +| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | +| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | +| `SphereParams` | `radius` | +| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | +| `TorusParams` | `majorRadius`, `minorRadius` | +| `SlotPrismParams` | `length`, `width`, `height` | +| `RoundedBoxParams` | `width`, `height`, `depth`, `radius` | +| `PipeSegmentParams` | `outerRadius`, `innerRadius`, `height`, `startAngle`, `sweepAngle` | +| `PolysolidParams` | `path: ProfileParams`, `width`, `height`, `justify?`: `'centre'`, `'left'` or `'right'` | +| ------------------------ | ------------------------------------------------------------------ | +| `RegularPolygonParams` | `sides`, `radius` | +| `SlotParams` | `length`, `width` | +| `RoundedRectangleParams` | `width`, `breadth`, `radius` | ## OG_REGION_TOLERANCE diff --git a/docs/opengeometry/api/overview.mdx b/docs/opengeometry/api/overview.mdx index 0b74f5d2..b83388a8 100644 --- a/docs/opengeometry/api/overview.mdx +++ b/docs/opengeometry/api/overview.mdx @@ -28,15 +28,15 @@ The methods that `Wire`, `Solid` and `Sheet` share are on [Body methods](/openge ## Types -| Type | Page | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | -| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | -| `HostGroup` | [Display](/opengeometry/api/display) | -| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | -| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | -| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | -| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | -| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams`, `SlotPrismParams`, `RoundedBoxParams`, `PipeSegmentParams`, `PolysolidParams` | [Constants](/opengeometry/api/constants) | +| Type | Page | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | +| `HostGroup` | [Display](/opengeometry/api/display) | +| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | +| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | +| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | +| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | +| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams`, `SlotPrismParams`, `RoundedBoxParams`, `PipeSegmentParams`, `PolysolidParams`, `RegularPolygonParams`, `SlotParams`, `RoundedRectangleParams` | [Constants](/opengeometry/api/constants) | ## Conventions diff --git a/docs/opengeometry/api/wire.mdx b/docs/opengeometry/api/wire.mdx index eb8e0cf9..4d46868e 100644 --- a/docs/opengeometry/api/wire.mdx +++ b/docs/opengeometry/api/wire.mdx @@ -28,13 +28,16 @@ console.log(path.bodyType, path.validity().closed); `new Wire(kind: string, params: Record, options?: BodyOptions)` -| `kind` | `params` | -| ------------------------ | ------------------------------------ | -| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | -| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | -| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | -| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | -| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | +| `kind` | `params` | +| -------------------------------- | ------------------------------------ | +| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | +| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | +| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | +| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | +| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | +| `OG_PRIMITIVE_REGULAR_POLYGON` | `{ sides, radius }` | +| `OG_PRIMITIVE_SLOT` | `{ length, width }` | +| `OG_PRIMITIVE_ROUNDED_RECTANGLE` | `{ width, breadth, radius }` | A kind that makes another body type throws `BodyTypeMismatch`. See [Primitives](/opengeometry/guides/primitives). diff --git a/docs/opengeometry/changelog.mdx b/docs/opengeometry/changelog.mdx index 6b44e82c..17b6d9cf 100644 --- a/docs/opengeometry/changelog.mdx +++ b/docs/opengeometry/changelog.mdx @@ -11,6 +11,7 @@ rss: true - Solid primitives: slot prism, rounded box and pipe segment. See [Primitives](/opengeometry/guides/primitives). - **Polysolid.** `OG_PRIMITIVE_POLYSOLID` makes a solid wall of constant width and height along a path of straight legs, justified `'centre'`, `'left'` or `'right'`, with mitred corners. See [Primitives](/opengeometry/guides/primitives). +- Wire primitives: regular polygon, slot and rounded rectangle. See [Primitives](/opengeometry/guides/primitives). diff --git a/docs/opengeometry/examples.mdx b/docs/opengeometry/examples.mdx index bf8dc0fb..16e958c1 100644 --- a/docs/opengeometry/examples.mdx +++ b/docs/opengeometry/examples.mdx @@ -23,6 +23,7 @@ The examples are working 3.0 code. They are in [`main/opengeometry-three/example | `shapes/rounded-box.html` | A rounded box with width, height, depth and corner radius controls | | `shapes/pipe-segment.html` | A pipe segment with both radii, height and the two angles as controls | | `shapes/polysolid.html` | An L-shaped polysolid wall with width, height and justify controls | +| `shapes/wire-kinds.html` | A regular polygon, a slot and a rounded rectangle wire, each extruded into a prism | ## Run them diff --git a/docs/opengeometry/guides/primitives.mdx b/docs/opengeometry/guides/primitives.mdx index 7b797f30..bcc87fd6 100644 --- a/docs/opengeometry/guides/primitives.mdx +++ b/docs/opengeometry/guides/primitives.mdx @@ -8,13 +8,51 @@ A primitive is a body made from a few parameters. Pass the kind as the first arg ## Wires -| Kind | Parameters | Notes | -| ------------------------ | ------------------------------------ | ----------------------------------------------------------------------- | -| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | Centred on the plane origin. `width` along u, `breadth` along v | -| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | Centred on the plane origin | -| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | `points` are `[x, y, z]` in the body's own space | -| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | Lines and arcs, points as `[u, v]` in the plane | -| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | Centred on the plane origin. The sweep is not zero and less than a turn | +| Kind | Parameters | Notes | +| -------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | Centred on the plane origin. `width` along u, `breadth` along v | +| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | Centred on the plane origin | +| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | `points` are `[x, y, z]` in the body's own space | +| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | Lines and arcs, points as `[u, v]` in the plane | +| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | Centred on the plane origin. The sweep is not zero and less than a turn | +| `OG_PRIMITIVE_REGULAR_POLYGON` | `{ sides, radius }` | A closed polyline through `sides` corners (3 to 1024) on a circle of `radius`, the first on +u | +| `OG_PRIMITIVE_SLOT` | `{ length, width }` | The outline of `OG_PRIMITIVE_SLOT_PRISM`: two lines and two arcs, centred on the plane origin | +| `OG_PRIMITIVE_ROUNDED_RECTANGLE` | `{ width, breadth, radius }` | A rectangle with rounded corners, placed like the rectangle. `radius` below half the width and half the breadth | + +A regular polygon, a slot and a rounded rectangle are closed wires, so each is an extrude profile or hole: + +```ts +import { + OpenGeometry, + Solid, + Wire, + OG_OPERATION_EXTRUDE, + OG_PRIMITIVE_PARAMS_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE, + OG_PRIMITIVE_PARAMS_SLOT, + OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_ROUNDED_RECTANGLE, + OG_PRIMITIVE_SLOT, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const hexagon = new Wire( + OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_REGULAR_POLYGON({ sides: 6, radius: 0.2 }), +); +const plate = new Wire( + OG_PRIMITIVE_ROUNDED_RECTANGLE, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE({ width: 1, breadth: 0.6, radius: 0.05 }), +); +const slot = new Wire(OG_PRIMITIVE_SLOT, OG_PRIMITIVE_PARAMS_SLOT({ length: 0.3, width: 0.1 })); +const nut = new Solid(OG_OPERATION_EXTRUDE, { profile: hexagon, distance: 0.1 }); +const panel = new Solid(OG_OPERATION_EXTRUDE, { profile: plate, holes: [slot], distance: 0.02 }); +console.log(nut.ogId, panel.ogId); +``` ### Profile diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index ec3c2ded..d2efd500 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -31,6 +31,7 @@

Shapes

Rounded box Pipe segment Polysolid wall + Wire kinds

Sweeps

Sweep frames Arc path sweep diff --git a/main/opengeometry-three/examples-vite/shapes/wire-kinds.html b/main/opengeometry-three/examples-vite/shapes/wire-kinds.html new file mode 100644 index 00000000..57f45db7 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/wire-kinds.html @@ -0,0 +1,34 @@ + + + + OpenGeometry Wire Kinds Example + + + + + + + +
+ Back + Wire Kinds +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/wire-kinds.ts b/main/opengeometry-three/examples-vite/shapes/wire-kinds.ts new file mode 100644 index 00000000..6429009e --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/wire-kinds.ts @@ -0,0 +1,79 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { requiredElement } from '../shared/dom'; +import { startFpsMeter } from '../shared/fps'; +import { bootExample } from '../shared/kernel'; +import { releaseOnUnload } from '../shared/unload'; +import { fitRenderer } from '../shared/viewport'; +import { + OpenGeometry, Solid, Wire, OG_OPERATION_EXTRUDE, OG_PRIMITIVE_PARAMS_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE, OG_PRIMITIVE_PARAMS_SLOT, OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_ROUNDED_RECTANGLE, OG_PRIMITIVE_SLOT, +} from '../../../dist/index.js'; + +const APP = requiredElement('#app', HTMLDivElement); +const STATUS = requiredElement('#status', HTMLElement); +await bootExample('../'); + +const SCENE = new THREE.Scene(); +SCENE.background = new THREE.Color(0xeef2ff); +SCENE.add(new THREE.HemisphereLight(0xffffff, 0x526070, 2)); +const LIGHT = new THREE.DirectionalLight(0xffffff, 2); +LIGHT.position.set(5, 8, 5); +SCENE.add(LIGHT); +SCENE.add(new THREE.GridHelper(10, 20, 0x4460ff, 0xd5ddff)); +const CAMERA = new THREE.PerspectiveCamera(50, 1, 0.05, 100); +CAMERA.position.set(0, 2.6, 3.2); +const RENDERER = new THREE.WebGLRenderer({ antialias: true }); +RENDERER.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); +APP.append(RENDERER.domElement); +const CONTROLS = new OrbitControls(CAMERA, RENDERER.domElement); +CONTROLS.target.set(0, 0.2, 0); +CONTROLS.update(); + +const WIRES = [ + new Wire(OG_PRIMITIVE_REGULAR_POLYGON, OG_PRIMITIVE_PARAMS_REGULAR_POLYGON({ sides: 6, radius: 0.45 }), { + ogId: 'regular-polygon', plane: { origin: [-1.4, 0, 0] }, + }), + new Wire(OG_PRIMITIVE_SLOT, OG_PRIMITIVE_PARAMS_SLOT({ length: 0.6, width: 0.4 }), { ogId: 'slot' }), + new Wire(OG_PRIMITIVE_ROUNDED_RECTANGLE, OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE({ + width: 0.9, breadth: 0.6, radius: 0.12, + }), { ogId: 'rounded-rectangle', plane: { origin: [1.4, 0, 0] } }), +]; +const PRISMS = WIRES.map((profile) => new Solid( + OG_OPERATION_EXTRUDE, { profile, distance: 0.3 }, { ogId: `${profile.ogId}-prism` }, +)); +for (const prism of PRISMS) { + prism.setAppearance({ color: 0x0ea5e9, outline: true, opacity: 0.6 }); + SCENE.add(prism); +} +for (const wire of WIRES) SCENE.add(wire); + +function render(): void { RENDERER.render(SCENE, CAMERA); } + +function resize(): void { + fitRenderer(APP, CAMERA, RENDERER); + render(); +} + +const RESIZE_OBSERVER = new ResizeObserver(resize); +RESIZE_OBSERVER.observe(APP); +CONTROLS.addEventListener('start', () => { OpenGeometry.setCameraMotion(true); }); +CONTROLS.addEventListener('end', () => { OpenGeometry.setCameraMotion(false); render(); }); +const UNSUBSCRIBE_ERROR = OpenGeometry.on('error', (error) => { + STATUS.textContent = `Geometry error: ${String(error)}`; +}); +resize(); +const READY = await OpenGeometry.settled(); +if (READY.failed.length) throw new Error('Wire kinds geometry failed'); +render(); +const EDGES = WIRES.map((wire) => wire.getBrep().topology.edges.length).join(', '); +STATUS.textContent = `Wire kinds ready · ${EDGES} edges`; +const STOP_FPS_METER = startFpsMeter(RENDERER, SCENE, CAMERA, CONTROLS); + +releaseOnUnload({ + resizeObserver: RESIZE_OBSERVER, stopFpsMeter: STOP_FPS_METER, unsubscribeError: UNSUBSCRIBE_ERROR, + controls: CONTROLS, renderer: RENDERER, +}, () => undefined, () => { + for (const body of [...PRISMS, ...WIRES]) body.dispose(); +}); diff --git a/main/opengeometry-three/examples-vite/vite.config.ts b/main/opengeometry-three/examples-vite/vite.config.ts index b6a895f2..6af358a4 100644 --- a/main/opengeometry-three/examples-vite/vite.config.ts +++ b/main/opengeometry-three/examples-vite/vite.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ roundedBox: `${ROOT}/shapes/rounded-box.html`, pipeSegment: `${ROOT}/shapes/pipe-segment.html`, polysolid: `${ROOT}/shapes/polysolid.html`, + wireKinds: `${ROOT}/shapes/wire-kinds.html`, }, }, }, diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 09e01caf..9410d5d5 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -17,6 +17,7 @@ export * from './src/constants/index'; export type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileArc, ProfileParams, ProfileSegment, SphereParams, TorusParams, SlotPrismParams, RoundedBoxParams, PipeSegmentParams, PolysolidParams, + RegularPolygonParams, SlotParams, RoundedRectangleParams, } from './src/dto/primitives'; export type { CurvedRegion2D, CurvedRegionEdge, OffsetHole, OffsetPath2D, OffsetPolylineOptions, OffsetRegion, diff --git a/main/opengeometry-three/src/constants/primitives.ts b/main/opengeometry-three/src/constants/primitives.ts index 869b1104..f5933443 100644 --- a/main/opengeometry-three/src/constants/primitives.ts +++ b/main/opengeometry-three/src/constants/primitives.ts @@ -1,6 +1,7 @@ import type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileParams, SphereParams, TorusParams, SlotPrismParams, RoundedBoxParams, PipeSegmentParams, PolysolidParams, + RegularPolygonParams, SlotParams, RoundedRectangleParams, } from '../dto/primitives'; export const OG_PRIMITIVE_RECTANGLE = 'Rectangle' as const; @@ -19,6 +20,9 @@ export const OG_PRIMITIVE_SLOT_PRISM = 'SlotPrism' as const; export const OG_PRIMITIVE_ROUNDED_BOX = 'RoundedBox' as const; export const OG_PRIMITIVE_PIPE_SEGMENT = 'PipeSegment' as const; export const OG_PRIMITIVE_POLYSOLID = 'Polysolid' as const; +export const OG_PRIMITIVE_REGULAR_POLYGON = 'RegularPolygon' as const; +export const OG_PRIMITIVE_SLOT = 'Slot' as const; +export const OG_PRIMITIVE_ROUNDED_RECTANGLE = 'RoundedRectangle' as const; export const OG_PRIMITIVE_PARAMS_RECTANGLE = ( value: { width: number; breadth: number }, @@ -44,3 +48,6 @@ export const OG_PRIMITIVE_PARAMS_SLOT_PRISM = (value: SlotPrismParams): SlotPris export const OG_PRIMITIVE_PARAMS_ROUNDED_BOX = (value: RoundedBoxParams): RoundedBoxParams => value; export const OG_PRIMITIVE_PARAMS_PIPE_SEGMENT = (value: PipeSegmentParams): PipeSegmentParams => value; export const OG_PRIMITIVE_PARAMS_POLYSOLID = (value: PolysolidParams): PolysolidParams => value; +export const OG_PRIMITIVE_PARAMS_REGULAR_POLYGON = (value: RegularPolygonParams): RegularPolygonParams => value; +export const OG_PRIMITIVE_PARAMS_SLOT = (value: SlotParams): SlotParams => value; +export const OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE = (value: RoundedRectangleParams): RoundedRectangleParams => value; diff --git a/main/opengeometry-three/src/dto/primitives.ts b/main/opengeometry-three/src/dto/primitives.ts index 682b6f96..c3a2d47d 100644 --- a/main/opengeometry-three/src/dto/primitives.ts +++ b/main/opengeometry-three/src/dto/primitives.ts @@ -15,3 +15,6 @@ export type PipeSegmentParams = { export type PolysolidParams = { path: ProfileParams; width: number; height: number; justify?: 'centre' | 'left' | 'right'; }; +export type RegularPolygonParams = { sides: number; radius: number }; +export type SlotParams = { length: number; width: number }; +export type RoundedRectangleParams = { width: number; breadth: number; radius: number }; diff --git a/main/opengeometry-three/tests/node/primitives.test.mjs b/main/opengeometry-three/tests/node/primitives.test.mjs index 9155cffe..e68edc31 100644 --- a/main/opengeometry-three/tests/node/primitives.test.mjs +++ b/main/opengeometry-three/tests/node/primitives.test.mjs @@ -30,6 +30,12 @@ import { OG_PRIMITIVE_PARAMS_SLOT_PRISM, OG_PRIMITIVE_PARAMS_ROUNDED_BOX, OG_PRIMITIVE_PARAMS_PIPE_SEGMENT, + OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_SLOT, + OG_PRIMITIVE_ROUNDED_RECTANGLE, + OG_PRIMITIVE_PARAMS_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_SLOT, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE, } from '../../../dist/index.js'; import { boot, failsWith } from './support.mjs'; @@ -243,3 +249,32 @@ test('a polysolid with an arc segment is InvalidParameter', async () => { const params = { path: lPath({ center: [3, 1], counterClockwise: true }), width: 0.2, height: 1 }; assert.throws(() => new Solid(OG_PRIMITIVE_POLYSOLID, params, { ogId: 'arced' }), failsWith('InvalidParameter')); }); + +test('regular polygon, slot and rounded rectangle Wires extrude into prisms with their edges', async () => { + await boot(); + const wires = [ + { + kind: OG_PRIMITIVE_REGULAR_POLYGON, + params: OG_PRIMITIVE_PARAMS_REGULAR_POLYGON({ sides: 6, radius: 0.5 }), + edges: 6, + }, + { kind: OG_PRIMITIVE_SLOT, params: OG_PRIMITIVE_PARAMS_SLOT({ length: 1, width: 0.5 }), edges: 4 }, + { + kind: OG_PRIMITIVE_ROUNDED_RECTANGLE, + params: OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE({ width: 1.2, breadth: 0.8, radius: 0.1 }), + edges: 8, + }, + ]; + for (const { kind, params, edges } of wires) { + const profile = new Wire(kind, params, { ogId: kind }); + assert.equal(profile.lastInfo.bodyType, 'Wire'); + assert.equal(profile.getBrep().topology.edges.length, edges, kind); + const solid = new Solid(OG_OPERATION_EXTRUDE, { profile, distance: 1 }, { ogId: `${kind}-solid` }); + assert.equal(solid.getBrep().topology.faces.length, edges + 2, kind); + } + for (const sides of [2, 5.5, -3]) { + const params = { sides, radius: 1 }; + const options = { ogId: `sides-${sides}` }; + assert.throws(() => new Wire(OG_PRIMITIVE_REGULAR_POLYGON, params, options), failsWith('InvalidParameter')); + } +}); diff --git a/main/opengeometry/src/world_graph/primitive/kind.rs b/main/opengeometry/src/world_graph/primitive/kind.rs index dd6e5162..0ea2a5aa 100644 --- a/main/opengeometry/src/world_graph/primitive/kind.rs +++ b/main/opengeometry/src/world_graph/primitive/kind.rs @@ -88,6 +88,19 @@ pub enum Primitive { #[serde(default)] justify: PolysolidJustify, }, + RegularPolygon { + sides: u32, + radius: f64, + }, + Slot { + length: f64, + width: f64, + }, + RoundedRectangle { + width: f64, + breadth: f64, + radius: f64, + }, } impl Primitive { @@ -108,7 +121,10 @@ impl Primitive { | Self::Circle { .. } | Self::Polyline { .. } | Self::Profile { .. } - | Self::Arc { .. } => BodyType::Wire, + | Self::Arc { .. } + | Self::RegularPolygon { .. } + | Self::Slot { .. } + | Self::RoundedRectangle { .. } => BodyType::Wire, } } } diff --git a/main/opengeometry/src/world_graph/primitive/sizes.rs b/main/opengeometry/src/world_graph/primitive/sizes.rs index 69a590bc..118a2827 100644 --- a/main/opengeometry/src/world_graph/primitive/sizes.rs +++ b/main/opengeometry/src/world_graph/primitive/sizes.rs @@ -42,3 +42,26 @@ pub(super) fn check_arc( } Ok(()) } + +pub(super) fn check_slot(length: f64, width: f64, g: f64) -> Result<(), GraphError> { + required_size(length, g, "length")?; + required_size(width / 2.0, g, "width") +} + +pub(super) fn check_corner_radius( + kind: &str, + sides: [(&str, f64); 2], + radius: f64, + g: f64, +) -> Result<(), GraphError> { + for (name, value) in sides.into_iter().chain([("radius", radius)]) { + required_size(value, g, name)?; + } + if sides[0].1.min(sides[1].1) - 2.0 * radius <= 4.0 * g { + return Err(parameter(&format!( + "{kind} radius must be below half its {} and {}", + sides[0].0, sides[1].0 + ))); + } + Ok(()) +} diff --git a/main/opengeometry/src/world_graph/primitive/solids.rs b/main/opengeometry/src/world_graph/primitive/solids.rs index 715c2a64..0d10425c 100644 --- a/main/opengeometry/src/world_graph/primitive/solids.rs +++ b/main/opengeometry/src/world_graph/primitive/solids.rs @@ -1,5 +1,7 @@ use super::kind::Primitive; -use super::sizes::{check_arc, parameter, required_size, wrong_body_type}; +use super::sizes::{ + check_arc, check_corner_radius, check_slot, parameter, required_size, wrong_body_type, +}; use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError, GROUND}; use crate::math::{scale, Point3}; use crate::primitives::{self, PolysolidError, PolysolidJustify, PolysolidPath, ProfileEdge}; @@ -224,10 +226,8 @@ fn slot_body( [length, width, height]: [f64; 3], accuracy: Accuracy, ) -> Result<(BrepEnvelope, Vec), GraphError> { - for (name, value) in [("length", length), ("width", width), ("height", height)] { - required_size(value, accuracy.geometric, name)?; - } - required_size(width / 2.0, accuracy.geometric, "width")?; + check_slot(length, width, accuracy.geometric)?; + required_size(height, accuracy.geometric, "height")?; let outline = primitives::slot_outline(length, width); outline_body(shape_id, outline, height, accuracy) } @@ -237,19 +237,9 @@ fn rounded_box_body( [width, height, depth, radius]: [f64; 4], accuracy: Accuracy, ) -> Result<(BrepEnvelope, Vec), GraphError> { - for (name, value) in [ - ("width", width), - ("height", height), - ("depth", depth), - ("radius", radius), - ] { - required_size(value, accuracy.geometric, name)?; - } - if width.min(depth) - 2.0 * radius <= 4.0 * accuracy.geometric { - return Err(parameter( - "rounded box radius must be below half its width and depth", - )); - } + required_size(height, accuracy.geometric, "height")?; + let sides = [("width", width), ("depth", depth)]; + check_corner_radius("rounded box", sides, radius, accuracy.geometric)?; let outline = primitives::rounded_rectangle_outline(width, depth, radius); outline_body(shape_id, outline, height, accuracy) } diff --git a/main/opengeometry/src/world_graph/primitive/wires.rs b/main/opengeometry/src/world_graph/primitive/wires.rs index 7fa4756a..27f491f0 100644 --- a/main/opengeometry/src/world_graph/primitive/wires.rs +++ b/main/opengeometry/src/world_graph/primitive/wires.rs @@ -1,8 +1,10 @@ use super::kind::Primitive; -use super::sizes::{check_arc, parameter, required_size, wrong_body_type}; +use super::sizes::{ + check_arc, check_corner_radius, check_slot, parameter, required_size, wrong_body_type, +}; use crate::brep::{Accuracy, BrepEnvelope, CurveGeometry, GROUND}; use crate::math::{norm, sub, Point3}; -use crate::primitives::{self, ProfileEdge, ProfileSegment}; +use crate::primitives::{self, ProfileEdge, ProfileSegment, SegmentArc}; use crate::world_graph::error::{ErrorCode, GraphError}; use std::f64::consts::TAU; @@ -37,6 +39,18 @@ pub(super) fn build_wire( segments, closed, } => profile_body(shape_id, start, &segments, closed, accuracy)?, + Primitive::RegularPolygon { sides, radius } => { + polygon_body(shape_id, sides, radius, accuracy)? + } + Primitive::Slot { length, width } => { + check_slot(length, width, accuracy.geometric)?; + outline_wire(shape_id, primitives::slot_outline(length, width), accuracy)? + } + Primitive::RoundedRectangle { + width, + breadth, + radius, + } => rounded_rectangle_body(shape_id, [width, breadth], radius, accuracy)?, _ => return Err(wrong_body_type("wire")), }; Ok(result) @@ -180,3 +194,69 @@ fn check_profile_edge(edge: &ProfileEdge, to: [f64; 2], g: f64) -> Result<(), Gr } Ok(()) } + +fn polygon_body( + shape_id: String, + sides: u32, + radius: f64, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + if !(3..=1024).contains(&sides) { + return Err(parameter( + "regular polygon sides must be between 3 and 1024", + )); + } + required_size(radius, accuracy.geometric, "radius")?; + let step = TAU / f64::from(sides); + let side = 2.0 * radius * (step / 2.0).sin(); + required_size(side, accuracy.geometric, "regular polygon side")?; + let points = (0..sides) + .map(|index| { + let angle = step * f64::from(index); + GROUND.point([radius * angle.cos(), radius * angle.sin(), 0.0]) + }) + .collect::>(); + Ok(primitives::polyline_with_keys( + shape_id, &points, true, accuracy, + )?) +} + +fn rounded_rectangle_body( + shape_id: String, + [width, breadth]: [f64; 2], + radius: f64, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let sides = [("width", width), ("breadth", breadth)]; + check_corner_radius("rounded rectangle", sides, radius, accuracy.geometric)?; + let outline = primitives::rounded_rectangle_outline(width, breadth, radius); + outline_wire(shape_id, outline, accuracy) +} + +fn outline_wire( + shape_id: String, + edges: Vec, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let start = edges[0].endpoints().0; + let segments = edges + .iter() + .map(|edge| ProfileSegment { + to: edge.endpoints().1, + arc: match *edge { + ProfileEdge::Arc { + center, + sweep_angle, + .. + } => Some(SegmentArc { + center, + counter_clockwise: sweep_angle > 0.0, + }), + ProfileEdge::Line { .. } => None, + }, + }) + .collect::>(); + Ok(primitives::profile_wire_with_keys( + shape_id, start, &segments, true, accuracy, + )?) +} diff --git a/main/opengeometry/tests/creating/main.rs b/main/opengeometry/tests/creating/main.rs index 3e665681..3534361a 100644 --- a/main/opengeometry/tests/creating/main.rs +++ b/main/opengeometry/tests/creating/main.rs @@ -16,3 +16,4 @@ mod support; mod sweep; mod sweep_bends; mod sweep_closed; +mod wire_kinds; diff --git a/main/opengeometry/tests/creating/wire_kinds.rs b/main/opengeometry/tests/creating/wire_kinds.rs new file mode 100644 index 00000000..fb8b08a8 --- /dev/null +++ b/main/opengeometry/tests/creating/wire_kinds.rs @@ -0,0 +1,197 @@ +use super::standard::assert_refused; +use super::support::extrude; +use opengeometry::brep::BodyType; +use opengeometry::world_graph::{CreateOptions, Plane, Primitive, StepOptions, WorldGraph}; +use opengeometry_test_support::part21::Document; +use opengeometry_test_support::world_graph::{graph, named}; +use std::f64::consts::TAU; + +struct Faces { + total: usize, + planes: usize, + cylinders: usize, + volume: f64, + bounds: [f64; 6], +} + +fn polygon(sides: u32, radius: f64) -> Primitive { + Primitive::RegularPolygon { sides, radius } +} + +fn slot(length: f64, width: f64) -> Primitive { + Primitive::Slot { length, width } +} + +fn rounded(width: f64, breadth: f64, radius: f64) -> Primitive { + Primitive::RoundedRectangle { + width, + breadth, + radius, + } +} + +fn assert_closed_wire(primitive: Primitive, edges: usize) { + let mut world = graph(); + world.create_primitive(primitive, named("wire")).unwrap(); + let validity = world.validity("wire").unwrap(); + assert!(validity.valid && validity.closed); + assert_eq!(validity.body_type, Some(BodyType::Wire)); + assert_eq!(world.brep("wire").unwrap().topology.edges.len(), edges); +} + +fn faces(world: &WorldGraph, og_id: &str) -> Faces { + let (text, _) = world + .export_step(&[og_id.into()], &StepOptions::default()) + .unwrap(); + let document = Document::parse(&text).unwrap(); + let measured = world.measure(og_id, Some(1e-4)).unwrap(); + assert!(world.validity(og_id).unwrap().closed); + Faces { + total: world.brep(og_id).unwrap().topology.faces.len(), + planes: document.count("PLANE"), + cylinders: document.count("CYLINDRICAL_SURFACE"), + volume: measured.volume, + bounds: world.bounds(og_id).unwrap().unwrap(), + } +} + +fn extruded(wire: Primitive) -> (WorldGraph, Faces) { + let mut world = graph(); + world.create_primitive(wire, named("profile")).unwrap(); + world + .create_operation(extrude("profile", 1.0), named("solid")) + .unwrap(); + let found = faces(&world, "solid"); + (world, found) +} + +fn assert_extrudes_like(wire: Primitive, solid: Primitive) { + let (_, from_wire) = extruded(wire); + let mut world = graph(); + world.create_primitive(solid, named("kind")).unwrap(); + let from_kind = faces(&world, "kind"); + let bound = world.measure("kind", Some(1e-4)).unwrap().error_bound; + assert_eq!( + [from_wire.total, from_wire.planes, from_wire.cylinders], + [from_kind.total, from_kind.planes, from_kind.cylinders] + ); + assert!((from_wire.volume - from_kind.volume).abs() <= 2.0 * bound); + for axis in 0..6 { + assert!((from_wire.bounds[axis] - from_kind.bounds[axis]).abs() <= 1e-9); + } +} + +fn assert_placed_corner(primitive: Primitive, corner: [f64; 3]) { + let options = CreateOptions { + plane: Some(Plane { + origin: Some([1.0, 2.0, 3.0]), + normal: Some([1.0, 0.0, 0.0]), + x_direction: Some([0.0, 0.0, 1.0]), + }), + ..named("wire") + }; + let mut world = graph(); + world.create_primitive(primitive, options).unwrap(); + let placement = world.world_placement("wire").unwrap(); + let found = world + .brep("wire") + .unwrap() + .topology + .vertices + .iter() + .any(|vertex| { + let point = placement.apply_point(vertex.position); + (0..3).all(|axis| (point[axis] - corner[axis]).abs() <= 1e-9) + }); + assert!(found, "{corner:?}"); +} + +#[test] +fn wire_kinds_are_closed_wires_with_their_edge_counts() { + assert_closed_wire(polygon(6, 0.5), 6); + assert_closed_wire(polygon(3, 2.0), 3); + assert_closed_wire(slot(1.0, 0.5), 4); + assert_closed_wire(rounded(1.2, 0.8, 0.1), 8); +} + +#[test] +fn invalid_wire_kinds_are_invalid_parameter_naming_the_value() { + let size = |name: &str| format!("{name} must exceed four times geometric tolerance"); + let sides = "regular polygon sides must be between 3 and 1024".to_string(); + let corner = "rounded rectangle radius must be below half its width and breadth".to_string(); + for (primitive, message) in [ + (polygon(2, 1.0), sides.clone()), + (polygon(1025, 1.0), sides), + (polygon(6, f64::NAN), size("radius")), + (polygon(1024, 1e-7), size("regular polygon side")), + (slot(0.0, 0.5), size("length")), + (slot(1.0, 6e-8), size("width")), + (rounded(1.0, -1.0, 0.1), size("breadth")), + (rounded(1.0, 1.0, 0.0), size("radius")), + (rounded(1.0, 0.8, 0.4), corner), + ] { + assert_refused(primitive, &message); + } +} + +#[test] +fn wire_kind_json_parses_its_fields() { + for json in [ + r#"{"kind":"RegularPolygon","sides":6,"radius":0.5}"#, + r#"{"kind":"Slot","length":1,"width":0.5}"#, + r#"{"kind":"RoundedRectangle","width":1,"breadth":0.8,"radius":0.1}"#, + ] { + assert!(serde_json::from_str::(json).is_ok(), "{json}"); + } + let fractional = r#"{"kind":"RegularPolygon","sides":5.5,"radius":0.5}"#; + assert!(serde_json::from_str::(fractional).is_err()); +} + +#[test] +fn wire_kinds_on_a_plane_keep_u_along_the_x_direction_and_turn_counter_clockwise() { + assert_placed_corner(polygon(4, 0.5), [1.0, 2.0, 3.5]); + assert_placed_corner(polygon(4, 0.5), [1.0, 1.5, 3.0]); + assert_placed_corner(slot(1.0, 0.5), [1.0, 2.25, 3.5]); + assert_placed_corner(rounded(1.2, 0.8, 0.1), [1.0, 2.4, 3.5]); +} + +#[test] +fn an_extruded_slot_has_the_faces_volume_and_bounds_of_the_slot_prism() { + let prism = Primitive::SlotPrism { + length: 1.0, + width: 0.5, + height: 1.0, + }; + assert_extrudes_like(slot(1.0, 0.5), prism); +} + +#[test] +fn an_extruded_rounded_rectangle_has_the_faces_volume_and_bounds_of_the_rounded_box() { + let block = Primitive::RoundedBox { + width: 1.2, + height: 1.0, + depth: 0.8, + radius: 0.1, + }; + assert_extrudes_like(rounded(1.2, 0.8, 0.1), block); +} + +#[test] +fn an_extruded_regular_polygon_is_a_prism_with_the_exact_volume() { + for sides in [3_u32, 6, 7] { + let (_, prism) = extruded(polygon(sides, 0.5)); + let n = f64::from(sides); + let exact = n / 2.0 * 0.25 * (TAU / n).sin(); + let count = sides as usize + 2; + assert_eq!( + [prism.total, prism.planes, prism.cylinders], + [count, count, 0] + ); + assert!( + (prism.volume - exact).abs() <= 1e-9, + "{sides}: {}", + prism.volume + ); + assert!((prism.bounds[3] - 0.5).abs() <= 1e-12, "{sides}"); + } +} diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index 6d949fb2..31308e8e 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -532,7 +532,10 @@ "graph.operate.serial-union-and-intersection": "16bd35cf6461799233096ebb26d3e1b7ad8b1b4bcaef1c728c7cb01fc73732f0", "graph.operate.shared-target": "2ee3bc921fc2b82ae944d0d42a0f86230b71eddd1707ced075730d7927ccf109", "graph.operate.sweep-result": "81930c589ddf8723d0f515cd0426f7f1834894b8abbeaaf6dbc56e1f9109626c", + "graph.primitive.regular-polygon": "a0dab425fadc4e334643b968a9668339a5e06df6d6a8f0e4bb26d067c11d5771", "graph.primitive.revolved": "087959e66f828ca09267499853cda4ebe6bc8b382a763d07a579f144bbce0c34", + "graph.primitive.rounded-rectangle": "20ae06e9fc70dcba6ca399bad3171f4529809dadddb6dbd62c10e27dab83845f", + "graph.primitive.slot": "d862a14387cd5b8c68090c158516da4602ff974d1afbdee5b018fe9b5c3a8f86", "graph.scenario.copies-and-planes": "8cf5afbcb3e240a68aebc72a50874828d2e0d118e043dee8080d268aaf0f4a36", "graph.scenario.failed-calls": "5bd6c20c7e44aa77b5021118987d64ca4af4110568c6870baca83da9c34f1d00", "graph.scenario.instances-and-duplicates": "77e4630c9683726e7e9b6a5d254b5b97e8a632881670ff46c8cbf34984bfb5f8", diff --git a/main/opengeometry/tests/golden/builders_profiles.rs b/main/opengeometry/tests/golden/builders_profiles.rs index 3b15d489..da5deb38 100644 --- a/main/opengeometry/tests/golden/builders_profiles.rs +++ b/main/opengeometry/tests/golden/builders_profiles.rs @@ -1,7 +1,7 @@ use crate::builder_case::{built, keyed}; use crate::fixtures::{fine, ground, rectangle, standard, upright}; -use crate::graph_case::ring_segments; -use crate::kernel::{primitives, CurveGeometry, Frame3}; +use crate::graph_case::{create_options, graph_case, inspect, primitive, ring_segments}; +use crate::kernel::{primitives, CurveGeometry, Frame3, Primitive}; use crate::planar_shapes::profile_shell; use crate::profiles::{ band, line, polygon, quarter_annulus, rounded_outer, sector, split_quarter_annulus, @@ -240,9 +240,52 @@ fn curved_with_holes() -> Vec { ] } +fn wire_kind(name: &'static str, kind: Primitive) -> Case { + graph_case(&format!("graph.primitive.{name}"), move |graph, record| { + if primitive(record, graph, kind.clone(), create_options(name)).is_some() { + inspect(record, graph, name)?; + } + Ok(()) + }) +} + +fn wire_kinds() -> Vec { + vec![ + wire_kind( + "regular-polygon", + Primitive::RegularPolygon { + sides: 6, + radius: 0.5, + }, + ), + wire_kind( + "slot", + Primitive::Slot { + length: 1.0, + width: 0.5, + }, + ), + wire_kind( + "rounded-rectangle", + Primitive::RoundedRectangle { + width: 1.2, + breadth: 0.8, + radius: 0.1, + }, + ), + ] +} + pub(crate) fn cases() -> Vec { - [wires(), arcs(), linear(), curved(), curved_with_holes()] - .into_iter() - .flatten() - .collect() + [ + wires(), + arcs(), + linear(), + curved(), + curved_with_holes(), + wire_kinds(), + ] + .into_iter() + .flatten() + .collect() } From 47f39cb452a20aab3b8f14ce0feb599790c1d50c Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Nov 2026 17:58:38 +0100 Subject: [PATCH 6/6] Build polysolid paths with tangent arcs A polysolid path may now hold arc segments. Each joint that involves an arc must be tangent; a line that does not continue an arc, or an arc that does not continue the segment before it, is refused with InvalidParameter naming the joint. Paths with arcs are built from a direct offset outline: lines offset to parallel lines, arcs to concentric arcs, straight corners mitred or bevelled at the stroke's limit, then extruded with its hole for a closed path. The 2D curved union of the stroke cannot do this today. The path checks run in the builder, shared by both routes. A wall that overlaps itself, a leg folded back by its mitre and an offset radius at or below the tolerance are refused with InvalidParameter naming the segments. Straight paths keep the stroke route unchanged. The guide, the example page, the changelog and the README describe arc paths; the golden file gains two new keys only. --- CHANGELOG.md | 2 + README.md | 4 +- docs/opengeometry/changelog.mdx | 1 + docs/opengeometry/examples.mdx | 2 +- docs/opengeometry/guides/primitives.mdx | 36 +- .../examples-vite/shapes/polysolid.html | 12 +- .../examples-vite/shapes/polysolid.ts | 24 +- .../tests/node/primitives.test.mjs | 9 +- .../primitives/internal/polysolid/checks.rs | 220 +++++++++ .../src/primitives/internal/polysolid/mod.rs | 27 ++ .../primitives/internal/polysolid/outline.rs | 224 ++++++++++ .../src/primitives/internal/polysolid/path.rs | 65 +++ .../{polysolid.rs => polysolid/stroke.rs} | 77 +--- .../primitives/internal/polysolid/tests.rs | 87 ++++ main/opengeometry/tests/creating/main.rs | 1 + main/opengeometry/tests/creating/polysolid.rs | 13 +- .../tests/creating/polysolid_arcs.rs | 419 ++++++++++++++++++ main/opengeometry/tests/creating/profiles.rs | 18 +- main/opengeometry/tests/creating/support.rs | 15 + .../fixtures/golden/aarch64-apple-darwin.json | 2 + .../tests/golden/builders_solids.rs | 51 ++- 21 files changed, 1203 insertions(+), 106 deletions(-) create mode 100644 main/opengeometry/src/primitives/internal/polysolid/checks.rs create mode 100644 main/opengeometry/src/primitives/internal/polysolid/mod.rs create mode 100644 main/opengeometry/src/primitives/internal/polysolid/outline.rs create mode 100644 main/opengeometry/src/primitives/internal/polysolid/path.rs rename main/opengeometry/src/primitives/internal/{polysolid.rs => polysolid/stroke.rs} (73%) create mode 100644 main/opengeometry/src/primitives/internal/polysolid/tests.rs create mode 100644 main/opengeometry/tests/creating/polysolid_arcs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2206418b..ab9c11b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to the `opengeometry` package are in this file. The format f - Wire primitives `OG_PRIMITIVE_REGULAR_POLYGON`, `OG_PRIMITIVE_SLOT` and `OG_PRIMITIVE_ROUNDED_RECTANGLE`, with their `OG_PRIMITIVE_PARAMS_*` helpers and the `RegularPolygonParams`, `SlotParams` and `RoundedRectangleParams` types. +- `OG_PRIMITIVE_POLYSOLID` paths take arc segments that join their neighbours tangentially. A + joint where an arc meets a segment at an angle throws `InvalidParameter` that names the joint. ## [3.0.0] - 2026-11-09 diff --git a/README.md b/README.md index 700d7661..5a159d23 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ It is the geometry engine layer, not a full CAD application. Floorkit is a downs | Area | What 3.0 has | | --- | --- | -| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc, regular polygon, slot, rounded rectangle. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment, polysolid wall along a path of straight legs. Each can be placed on any plane. | +| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc, regular polygon, slot, rounded rectangle. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment, polysolid wall along a path of straight legs and tangent arcs. Each can be placed on any plane. | | **Operations** | Extrude a closed wire into a solid, with holes. Sweep a profile along an open or closed path of lines and arcs, each arc an exact bend, capped into a solid, open-ended into a sheet, or closed into a ring. Revolve a closed line-and-arc profile a full turn about an axis in its plane. Shell a solid into a hollow one with walls of one thickness. Union, subtract and intersect, with one to a hundred tool solids in one call. | | **Parametric edits** | `rebuild` gives an existing body new parameters in place. `mirror` rebuilds a body as its reflection across a plane. | | **Assemblies** | Assemblies with parent and child placement; translate, rotate, scale and place; independent copies and shared instances. | @@ -167,7 +167,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | `Wire` | `new Wire(kind, params, options?)`. `OG_PRIMITIVE_RECTANGLE` `{ width, breadth }`, `OG_PRIMITIVE_CIRCLE` `{ radius }`, `OG_PRIMITIVE_POLYLINE` `{ points, closed? }`, `OG_PRIMITIVE_REGULAR_POLYGON` `{ sides, radius }` (3 to 1024 corners on a circle about the `plane` origin, the first on the x direction), `OG_PRIMITIVE_SLOT` `{ length, width }` (the outline of `OG_PRIMITIVE_SLOT_PRISM`), `OG_PRIMITIVE_ROUNDED_RECTANGLE` `{ width, breadth, radius }` (a rectangle with rounded corners; `radius` below half the width and half the breadth). | | Profile wire | `new Wire(OG_PRIMITIVE_PROFILE, { start, segments, closed }, options?)`. Points are `[u, v]` in `plane`; a segment is `{ to }` for a line or `{ to, arc: { center, counterClockwise } }` for an arc. A closed profile is an extrude profile or hole. | | Arc wire | `new Wire(OG_PRIMITIVE_ARC, { radius, startAngle, sweepAngle }, options?)`. Angles in radians from the `plane` x direction, centred on the `plane` origin; the sweep is nonzero and less than one turn. | -| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis), `OG_PRIMITIVE_SLOT_PRISM` `{ length, width, height }` (a stadium standing on the `plane`, centred on its origin; `length` between the arc centres along the x direction), `OG_PRIMITIVE_ROUNDED_BOX` `{ width, height, depth, radius }` (a cuboid with its four upright edges rounded; `radius` below half the width and half the depth), `OG_PRIMITIVE_PIPE_SEGMENT` `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` (a ring sector placed and angled like the cylinder sector), `OG_PRIMITIVE_POLYSOLID` `{ path, width, height, justify? }` (a wall of constant width along `path`, a `{ start, segments, closed }` of straight legs in the `plane`'s `[u, v]`, standing on the `plane` and rising along its normal; `justify` is `'centre'` (default), `'left'` or `'right'` of the path direction); `profile`, `holes` and `path` are wires. | +| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis), `OG_PRIMITIVE_SLOT_PRISM` `{ length, width, height }` (a stadium standing on the `plane`, centred on its origin; `length` between the arc centres along the x direction), `OG_PRIMITIVE_ROUNDED_BOX` `{ width, height, depth, radius }` (a cuboid with its four upright edges rounded; `radius` below half the width and half the depth), `OG_PRIMITIVE_PIPE_SEGMENT` `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` (a ring sector placed and angled like the cylinder sector), `OG_PRIMITIVE_POLYSOLID` `{ path, width, height, justify? }` (a wall of constant width along `path`, a `{ start, segments, closed }` of straight legs and tangent arcs in the `plane`'s `[u, v]`, standing on the `plane` and rising along its normal; `justify` is `'centre'` (default), `'left'` or `'right'` of the path direction); `profile`, `holes` and `path` are wires. | | `Sheet` | `new Sheet(OG_OPERATION_SWEEP, { profile, path, caps: false })`: an open-ended sweep, faces that enclose no volume. It shows its surface, from the front only, and its outline; `OpenGeometry.exportStep` refuses it. | | Body options | `{ ogId?, parent?, appearance?, plane? }`. `plane` is `{ origin?, normal?, xDirection? }` and applies to primitives only; by default it is the ground plane through the origin, normal `[0, 1, 0]`, x direction `[1, 0, 0]`. | | Booleans | `solid.operate(kind, { tools, instances? })` with `OG_OPERATION_SUBTRACT`, `OG_OPERATION_UNION` or `OG_OPERATION_INTERSECT`; `tools` is one to a hundred solids. | diff --git a/docs/opengeometry/changelog.mdx b/docs/opengeometry/changelog.mdx index 17b6d9cf..6ec4a03f 100644 --- a/docs/opengeometry/changelog.mdx +++ b/docs/opengeometry/changelog.mdx @@ -12,6 +12,7 @@ rss: true - Solid primitives: slot prism, rounded box and pipe segment. See [Primitives](/opengeometry/guides/primitives). - **Polysolid.** `OG_PRIMITIVE_POLYSOLID` makes a solid wall of constant width and height along a path of straight legs, justified `'centre'`, `'left'` or `'right'`, with mitred corners. See [Primitives](/opengeometry/guides/primitives). - Wire primitives: regular polygon, slot and rounded rectangle. See [Primitives](/opengeometry/guides/primitives). +- **Polysolid arcs.** A polysolid path takes arc segments that join their neighbours tangentially. A joint where an arc meets a segment at an angle throws `InvalidParameter` that names the joint. See [Primitives](/opengeometry/guides/primitives). diff --git a/docs/opengeometry/examples.mdx b/docs/opengeometry/examples.mdx index 16e958c1..4a86ef39 100644 --- a/docs/opengeometry/examples.mdx +++ b/docs/opengeometry/examples.mdx @@ -22,7 +22,7 @@ The examples are working 3.0 code. They are in [`main/opengeometry-three/example | `shapes/slot-prism.html` | A slot prism with length, width and height controls | | `shapes/rounded-box.html` | A rounded box with width, height, depth and corner radius controls | | `shapes/pipe-segment.html` | A pipe segment with both radii, height and the two angles as controls | -| `shapes/polysolid.html` | An L-shaped polysolid wall with width, height and justify controls | +| `shapes/polysolid.html` | A polysolid wall on straight legs or a tangent arc, with width, height and justify | | `shapes/wire-kinds.html` | A regular polygon, a slot and a rounded rectangle wire, each extruded into a prism | ## Run them diff --git a/docs/opengeometry/guides/primitives.mdx b/docs/opengeometry/guides/primitives.mdx index bcc87fd6..54dc553a 100644 --- a/docs/opengeometry/guides/primitives.mdx +++ b/docs/opengeometry/guides/primitives.mdx @@ -108,7 +108,7 @@ A profile refuses repeated points, an arc whose end is not on its circle, segmen | `OG_PRIMITIVE_SLOT_PRISM` | `{ length, width, height }` | A stadium standing on the plane, centred in u and v. `length` between the arc centres along u, `width` along v | | `OG_PRIMITIVE_ROUNDED_BOX` | `{ width, height, depth, radius }` | A cuboid with its four upright edges rounded. `radius` below half the width and half the depth | | `OG_PRIMITIVE_PIPE_SEGMENT` | `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` | A ring sector, placed and angled like the cylinder sector. The sweep is below one full turn | -| `OG_PRIMITIVE_POLYSOLID` | `{ path, width, height, justify? }` | A wall of constant width along a path of straight legs. See below | +| `OG_PRIMITIVE_POLYSOLID` | `{ path, width, height, justify? }` | A wall of constant width along a path of legs and tangent arcs. See below | ```ts import { @@ -214,7 +214,39 @@ const wall = new Solid( console.log(wall.measure().volume); ``` -Only straight legs are built: a segment with `arc` throws `InvalidParameter` that names the segment. A path with fewer than two points, a leg of zero length, a leg shorter than half the width, or a path that crosses itself also throws `InvalidParameter`. +A segment with `arc` bends the wall around the arc's centre. Each arc must join the segments before and after it tangentially: the path keeps its direction through the joint. A joint where an arc meets a leg or another arc at an angle throws `InvalidParameter` that names the joint, for example `joint 1 between segment 0 and segment 1 is not tangent`. A closed path of arcs needs two segments, such as two half circles: + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const curvedWall = new Solid( + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: { + start: [0, 0], + segments: [{ to: [2, 0] }, { to: [3, 1], arc: { center: [2, 1], counterClockwise: true } }], + closed: false, + }, + width: 0.2, + height: 1, + }), +); +console.log(curvedWall.measure({ deflection: 1e-4 }).volume); +``` + +A path with fewer than two points, a segment of zero length, or a path that crosses itself throws `InvalidParameter`. A straight leg next to another straight leg must be at least half the width long. An arc needs room on the inside of its bend: with `'centre'`, its radius must be above half the width; with `'left'` or `'right'`, it must be above the full width when the wall lies on the inside of the bend. In a path with arcs, a wall that would overlap itself, for example two arms closer together than the width, throws `InvalidParameter` that names the two segments. + +With `'left'` or `'right'`, a straight corner sharper than the mitre limit is built in two ways. In a path with arcs, the wall keeps the path's corner and bevels its far side. In a path of straight legs only, the shifted centre line is bevelled, so the corner on the path side is cut too. ## Typed parameters diff --git a/main/opengeometry-three/examples-vite/shapes/polysolid.html b/main/opengeometry-three/examples-vite/shapes/polysolid.html index 2603b8e6..724a5f4b 100644 --- a/main/opengeometry-three/examples-vite/shapes/polysolid.html +++ b/main/opengeometry-three/examples-vite/shapes/polysolid.html @@ -17,7 +17,7 @@