From c616e38643474f301c700b1a62245ecf8a1c71c4 Mon Sep 17 00:00:00 2001 From: blackboots <47943405+aka-blackboots@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:10:33 +0100 Subject: [PATCH 01/10] brep structure --- ...6-03-04-halfedge-brep-migration-handoff.md | 99 +++ main/opengeometry-three/src/shapes/sphere.ts | 18 +- .../examples/offset_primitives.rs | 60 +- .../examples/pdf_camera_projection.rs | 4 +- .../examples/pdf_camera_projection_views.rs | 20 +- .../examples/pdf_primitives_all.rs | 74 +- .../examples/scenegraph_projection.rs | 23 +- .../scenegraph_projection_dump_json.rs | 9 +- .../examples/sphere_projection.rs | 4 +- .../examples/sweep_path_profile.rs | 13 +- .../examples/wall_from_polyline_offsets.rs | 28 +- main/opengeometry/src/brep/builder.rs | 509 +++++++++++ main/opengeometry/src/brep/edge.rs | 16 +- main/opengeometry/src/brep/error.rs | 39 + main/opengeometry/src/brep/face.rs | 40 +- main/opengeometry/src/brep/halfedge.rs | 45 +- main/opengeometry/src/brep/loop.rs | 20 + main/opengeometry/src/brep/mod.rs | 789 ++++++++++++++++-- main/opengeometry/src/brep/shell.rs | 18 + main/opengeometry/src/brep/vertex.rs | 13 +- main/opengeometry/src/brep/wire.rs | 18 + main/opengeometry/src/export/projection.rs | 139 +-- main/opengeometry/src/lib.rs | 10 - main/opengeometry/src/operations/extrude.rs | 249 +++--- main/opengeometry/src/operations/sweep.rs | 60 +- main/opengeometry/src/primitives/arc.rs | 113 ++- main/opengeometry/src/primitives/cuboid.rs | 172 ++-- main/opengeometry/src/primitives/curve.rs | 61 +- main/opengeometry/src/primitives/cylinder.rs | 150 ++-- .../src/primitives/cylinderOld.rs | 204 ----- main/opengeometry/src/primitives/line.rs | 53 +- main/opengeometry/src/primitives/polygon.rs | 425 ++-------- main/opengeometry/src/primitives/polyline.rs | 160 ++-- main/opengeometry/src/primitives/rectangle.rs | 107 +-- main/opengeometry/src/primitives/sphere.rs | 156 ++-- main/opengeometry/src/primitives/sweep.rs | 71 +- main/opengeometry/src/primitives/wedge.rs | 102 +-- main/opengeometry/src/scenegraph.rs | 13 +- main/opengeometry/src/utility/geometry.rs | 149 ---- main/opengeometry/tests/primitives_smoke.rs | 29 +- 40 files changed, 2448 insertions(+), 1834 deletions(-) create mode 100644 AI-DOCs/opengeometry/2026-03-04-halfedge-brep-migration-handoff.md create mode 100644 main/opengeometry/src/brep/builder.rs create mode 100644 main/opengeometry/src/brep/error.rs create mode 100644 main/opengeometry/src/brep/loop.rs create mode 100644 main/opengeometry/src/brep/shell.rs create mode 100644 main/opengeometry/src/brep/wire.rs delete mode 100644 main/opengeometry/src/primitives/cylinderOld.rs delete mode 100644 main/opengeometry/src/utility/geometry.rs diff --git a/AI-DOCs/opengeometry/2026-03-04-halfedge-brep-migration-handoff.md b/AI-DOCs/opengeometry/2026-03-04-halfedge-brep-migration-handoff.md new file mode 100644 index 0000000..82fe344 --- /dev/null +++ b/AI-DOCs/opengeometry/2026-03-04-halfedge-brep-migration-handoff.md @@ -0,0 +1,99 @@ +# Half-Edge BREP Migration Handoff + +## What Changed + +- Replaced the BREP core model with a half-edge-first topology schema. +- Added canonical topology entities: + - `Vertex { outgoing_halfedge }` + - `HalfEdge` + - `Edge { halfedge, twin_halfedge }` + - `Loop` + - `Face { outer_loop, inner_loops, shell_ref }` + - `Wire` + - `Shell` +- Added `BrepBuilder` for deterministic topology construction. +- Added `Brep::validate_topology() -> Result<(), BrepError>` with manifold and linkage checks. +- Reworked projection/HLR to derive visibility from half-edge adjacency. +- Migrated primitives and sweep/extrude operations to builder-based half-edge output. +- Updated wasm mutating methods to checked APIs (`Result<(), JsValue>`) for invalid topology/input states. +- Updated three integration (`shapes/sphere.ts`) to consume outline buffers from kernel instead of legacy edge endpoint fields. + +## Breaking Changes + +- `get_brep_serialized()` now emits the new half-edge schema. +- Removed legacy fields from serialized BREP: + - `edges[].v1` + - `edges[].v2` + - `faces[].face_indices` + - root `holes` + - root `hole_edges` +- Mutating methods such as `set_config` / `generate_geometry` now return `Result<(), JsValue>` at wasm boundaries. + +## Legacy Cleanup + +- Removed: + - `main/opengeometry/src/utility/geometry.rs` + - `main/opengeometry/src/primitives/cylinderOld.rs` +- Removed stale commented legacy BREP scaffolding from `main/opengeometry/src/lib.rs`. +- Replaced old extrude geometry dependency with local `Geometry` in `operations/extrude.rs`. + +## Implementation Notes + +- `BrepBuilder` enforces: + - canonical undirected edge mapping + - twin linking of opposite directed halfedges + - non-manifold rejection (`> 2` halfedges per edge) + - loop closure and link consistency +- Face triangulation inputs now come from `outer_loop` and `inner_loops` via `Brep::get_vertices_and_holes_by_face_id`. +- Wire primitives (`line`, `curve`, `polyline`, `arc`) are represented as `wires` and edge-backed halfedges. +- Solid primitives (`cuboid`, `cylinder`, `wedge`, `sphere`, `sweep`) assign shell topology. + +## Files Added + +- `main/opengeometry/src/brep/error.rs` +- `main/opengeometry/src/brep/builder.rs` +- `main/opengeometry/src/brep/loop.rs` +- `main/opengeometry/src/brep/wire.rs` +- `main/opengeometry/src/brep/shell.rs` + +## Files Reworked (Core) + +- `main/opengeometry/src/brep/mod.rs` +- `main/opengeometry/src/brep/vertex.rs` +- `main/opengeometry/src/brep/halfedge.rs` +- `main/opengeometry/src/brep/edge.rs` +- `main/opengeometry/src/brep/face.rs` +- `main/opengeometry/src/export/projection.rs` +- `main/opengeometry/src/operations/extrude.rs` +- `main/opengeometry/src/operations/sweep.rs` + +## Files Reworked (Primitives) + +- `main/opengeometry/src/primitives/line.rs` +- `main/opengeometry/src/primitives/curve.rs` +- `main/opengeometry/src/primitives/polyline.rs` +- `main/opengeometry/src/primitives/arc.rs` +- `main/opengeometry/src/primitives/rectangle.rs` +- `main/opengeometry/src/primitives/polygon.rs` +- `main/opengeometry/src/primitives/cuboid.rs` +- `main/opengeometry/src/primitives/cylinder.rs` +- `main/opengeometry/src/primitives/wedge.rs` +- `main/opengeometry/src/primitives/sphere.rs` +- `main/opengeometry/src/primitives/sweep.rs` + +## Validation Run + +- `cargo fmt --manifest-path main/opengeometry/Cargo.toml` +- `cargo check --manifest-path main/opengeometry/Cargo.toml` +- `cargo test --manifest-path main/opengeometry/Cargo.toml` +- `cargo test --examples --manifest-path main/opengeometry/Cargo.toml` +- `npm run build-three` + +All commands above completed successfully. + +## Known Caveats / Follow-ups + +- Existing non-critical warnings still present in unrelated legacy code: + - `operations/windingsort.rs` (`ccw_and_flag` naming) + - `geometry/triangle.rs` (`crso` unused variable) +- Consumers that deserialize old BREP JSON must migrate to the new half-edge schema. diff --git a/main/opengeometry-three/src/shapes/sphere.ts b/main/opengeometry-three/src/shapes/sphere.ts index 3119768..aff39d9 100644 --- a/main/opengeometry-three/src/shapes/sphere.ts +++ b/main/opengeometry-three/src/shapes/sphere.ts @@ -19,6 +19,7 @@ interface ISphereKernelInstance { ) => void; get_geometry_serialized(): string; get_brep_serialized(): string; + get_outline_geometry_serialized(): string; } type SphereKernelConstructor = new (..._args: [string]) => ISphereKernelInstance; @@ -148,21 +149,8 @@ export class Sphere extends THREE.Mesh { } if (enable) { - const brep = this.getBrep(); - const lineBuffer: number[] = []; - - for (const edge of brep.edges ?? []) { - const start = brep.vertices?.[edge.v1]?.position; - const end = brep.vertices?.[edge.v2]?.position; - if (!start || !end) { - continue; - } - - lineBuffer.push( - start.x, start.y, start.z, - end.x, end.y, end.z - ); - } + const outlineBuffer = this.sphere.get_outline_geometry_serialized(); + const lineBuffer = JSON.parse(outlineBuffer) as number[]; const outlineGeometry = new THREE.BufferGeometry(); outlineGeometry.setAttribute( diff --git a/main/opengeometry/examples/offset_primitives.rs b/main/opengeometry/examples/offset_primitives.rs index 400988a..3ec1c71 100644 --- a/main/opengeometry/examples/offset_primitives.rs +++ b/main/opengeometry/examples/offset_primitives.rs @@ -24,24 +24,30 @@ fn main() -> Result<(), Box> { .unwrap_or_else(|| "./offset_primitives.pdf".to_string()); let mut base_line = OGLine::new("base-line".to_string()); - base_line.set_config(Vector3::new(-3.2, 0.0, -2.2), Vector3::new(-0.8, 0.0, -0.9)); - base_line.generate_geometry(); + base_line + .set_config(Vector3::new(-3.2, 0.0, -2.2), Vector3::new(-0.8, 0.0, -0.9)) + .unwrap(); + base_line.generate_geometry().unwrap(); let line_offset_points = base_line.get_offset_points(0.35, 35.0, true); let mut offset_line = OGLine::new("offset-line".to_string()); if line_offset_points.len() == 2 { - offset_line.set_config(line_offset_points[0], line_offset_points[1]); - offset_line.generate_geometry(); + offset_line + .set_config(line_offset_points[0], line_offset_points[1]) + .unwrap(); + offset_line.generate_geometry().unwrap(); } let mut base_polyline = OGPolyline::new("base-polyline".to_string()); - base_polyline.set_config(vec![ - Vector3::new(-1.2, 0.0, -2.4), - Vector3::new(0.2, 0.0, -1.7), - Vector3::new(0.8, 0.0, -0.6), - Vector3::new(0.0, 0.0, -0.3), - Vector3::new(2.4, 0.0, 1.2), - ]); + base_polyline + .set_config(vec![ + Vector3::new(-1.2, 0.0, -2.4), + Vector3::new(0.2, 0.0, -1.7), + Vector3::new(0.8, 0.0, -0.6), + Vector3::new(0.0, 0.0, -0.3), + Vector3::new(2.4, 0.0, 1.2), + ]) + .unwrap(); let polyline_offset = base_polyline.get_offset_result(0.45, 90.0, true); println!( @@ -50,11 +56,15 @@ fn main() -> Result<(), Box> { ); let mut offset_polyline = OGPolyline::new("offset-polyline".to_string()); - offset_polyline.set_config(polyline_offset.points.clone()); + offset_polyline + .set_config(polyline_offset.points.clone()) + .unwrap(); let mut base_rectangle = OGRectangle::new("base-rectangle".to_string()); - base_rectangle.set_config(Vector3::new(1.4, 0.0, -2.0), 1.6, 1.0); - base_rectangle.generate_geometry(); + base_rectangle + .set_config(Vector3::new(1.4, 0.0, -2.0), 1.6, 1.0) + .unwrap(); + base_rectangle.generate_geometry().unwrap(); let rectangle_offset = base_rectangle.get_offset_result(0.25, 40.0, true); let mut rectangle_offset_outline = rectangle_offset.points.clone(); @@ -69,19 +79,25 @@ fn main() -> Result<(), Box> { } let mut offset_rectangle_polyline = OGPolyline::new("offset-rectangle".to_string()); - offset_rectangle_polyline.set_config(rectangle_offset_outline); + offset_rectangle_polyline + .set_config(rectangle_offset_outline) + .unwrap(); let mut base_curve = OGCurve::new("base-curve".to_string()); - base_curve.set_config(vec![ - Vector3::new(-3.0, 0.0, 1.2), - Vector3::new(-2.0, 0.0, 1.7), - Vector3::new(-1.1, 0.0, 1.6), - Vector3::new(-0.4, 0.0, 2.0), - ]); + base_curve + .set_config(vec![ + Vector3::new(-3.0, 0.0, 1.2), + Vector3::new(-2.0, 0.0, 1.7), + Vector3::new(-1.1, 0.0, 1.6), + Vector3::new(-0.4, 0.0, 2.0), + ]) + .unwrap(); let curve_offset = base_curve.get_offset_result(0.3, 45.0, true); let mut curve_offset_polyline = OGPolyline::new("offset-curve".to_string()); - curve_offset_polyline.set_config(curve_offset.points.clone()); + curve_offset_polyline + .set_config(curve_offset.points.clone()) + .unwrap(); let mut manager = OGSceneManager::new(); let scene_id = manager.create_scene_internal("offset-primitives"); diff --git a/main/opengeometry/examples/pdf_camera_projection.rs b/main/opengeometry/examples/pdf_camera_projection.rs index 3d0ae61..2162b64 100644 --- a/main/opengeometry/examples/pdf_camera_projection.rs +++ b/main/opengeometry/examples/pdf_camera_projection.rs @@ -43,7 +43,9 @@ fn main() -> Result<(), Box> { }; let mut cuboid = OGCuboid::new("perspective-cuboid".to_string()); - cuboid.set_config(Vector3::new(0.0, 0.0, 0.0), 2.2, 1.4, 1.8); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.2, 1.4, 1.8) + .unwrap(); let mut config = PdfExportConfig::a4_landscape(); config.title = Some("Perspective Camera Projection".to_string()); diff --git a/main/opengeometry/examples/pdf_camera_projection_views.rs b/main/opengeometry/examples/pdf_camera_projection_views.rs index 59c7ad1..bbcf5c0 100644 --- a/main/opengeometry/examples/pdf_camera_projection_views.rs +++ b/main/opengeometry/examples/pdf_camera_projection_views.rs @@ -22,16 +22,20 @@ fn main() -> Result<(), Box> { }; let mut cuboid = OGCuboid::new("ortho-cuboid".to_string()); - cuboid.set_config(Vector3::new(-1.8, 0.0, 0.0), 2.0, 1.5, 1.5); + cuboid + .set_config(Vector3::new(-1.8, 0.0, 0.0), 2.0, 1.5, 1.5) + .unwrap(); let mut cylinder = OGCylinder::new("ortho-cylinder".to_string()); - cylinder.set_config( - Vector3::new(1.8, 0.0, 0.0), - 0.9, - 1.8, - 2.0 * std::f64::consts::PI, - 32, - ); + cylinder + .set_config( + Vector3::new(1.8, 0.0, 0.0), + 0.9, + 1.8, + 2.0 * std::f64::consts::PI, + 32, + ) + .unwrap(); let mut scene_hlr_on = cuboid.to_projected_scene2d( &camera, diff --git a/main/opengeometry/examples/pdf_primitives_all.rs b/main/opengeometry/examples/pdf_primitives_all.rs index 269d78d..a31d7dc 100644 --- a/main/opengeometry/examples/pdf_primitives_all.rs +++ b/main/opengeometry/examples/pdf_primitives_all.rs @@ -45,16 +45,19 @@ fn main() -> Result<(), Box> { }; let mut line = OGLine::new("line".to_string()); - line.set_config(Vector3::new(-1.2, 0.0, 0.0), Vector3::new(1.2, 0.0, 0.5)); - line.generate_geometry(); + line.set_config(Vector3::new(-1.2, 0.0, 0.0), Vector3::new(1.2, 0.0, 0.5)) + .unwrap(); + line.generate_geometry().unwrap(); let mut polyline = OGPolyline::new("polyline".to_string()); - polyline.set_config(vec![ - Vector3::new(-1.6, 0.0, -0.8), - Vector3::new(-0.8, 0.2, 0.4), - Vector3::new(0.0, 0.0, -0.2), - Vector3::new(0.9, 0.4, 0.8), - ]); + polyline + .set_config(vec![ + Vector3::new(-1.6, 0.0, -0.8), + Vector3::new(-0.8, 0.2, 0.4), + Vector3::new(0.0, 0.0, -0.2), + Vector3::new(0.9, 0.4, 0.8), + ]) + .unwrap(); let mut arc = OGArc::new("arc".to_string()); arc.set_config( @@ -63,39 +66,52 @@ fn main() -> Result<(), Box> { 0.0, 1.25 * std::f64::consts::PI, 40, - ); - arc.generate_geometry(); + ) + .unwrap(); + arc.generate_geometry().unwrap(); let mut rectangle = OGRectangle::new("rectangle".to_string()); - rectangle.set_config(Vector3::new(0.0, 0.0, 0.0), 2.4, 1.4); - rectangle.generate_geometry(); + rectangle + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.4, 1.4) + .unwrap(); + rectangle.generate_geometry().unwrap(); let mut polygon = OGPolygon::new("polygon".to_string()); - polygon.set_config(vec![ - Vector3::new(-1.1, 0.0, -0.6), - Vector3::new(0.6, 0.0, -1.0), - Vector3::new(1.3, 0.0, 0.0), - Vector3::new(0.4, 0.0, 1.1), - Vector3::new(-1.0, 0.0, 0.8), - ]); + polygon + .set_config(vec![ + Vector3::new(-1.1, 0.0, -0.6), + Vector3::new(0.6, 0.0, -1.0), + Vector3::new(1.3, 0.0, 0.0), + Vector3::new(0.4, 0.0, 1.1), + Vector3::new(-1.0, 0.0, 0.8), + ]) + .unwrap(); let mut cuboid = OGCuboid::new("cuboid".to_string()); - cuboid.set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 1.5, 1.2); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 1.5, 1.2) + .unwrap(); let mut cylinder = OGCylinder::new("cylinder".to_string()); - cylinder.set_config( - Vector3::new(0.0, 0.0, 0.0), - 0.9, - 1.8, - 2.0 * std::f64::consts::PI, - 40, - ); + cylinder + .set_config( + Vector3::new(0.0, 0.0, 0.0), + 0.9, + 1.8, + 2.0 * std::f64::consts::PI, + 40, + ) + .unwrap(); let mut wedge = OGWedge::new("wedge".to_string()); - wedge.set_config(Vector3::new(0.0, 0.0, 0.0), 2.4, 1.6, 1.2); + wedge + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.4, 1.6, 1.2) + .unwrap(); let mut sphere = OGSphere::new("sphere".to_string()); - sphere.set_config(Vector3::new(0.0, 0.0, 0.0), 1.2, 32, 18); + sphere + .set_config(Vector3::new(0.0, 0.0, 0.0), 1.2, 32, 18) + .unwrap(); export_named_scene( &format!("{}_line.pdf", output_prefix), diff --git a/main/opengeometry/examples/scenegraph_projection.rs b/main/opengeometry/examples/scenegraph_projection.rs index 89d9415..2f2b918 100644 --- a/main/opengeometry/examples/scenegraph_projection.rs +++ b/main/opengeometry/examples/scenegraph_projection.rs @@ -18,19 +18,24 @@ fn main() -> Result<(), Box> { let scene_id = manager.create_scene_internal("Main Scene"); let mut line = OGLine::new("line-01".to_string()); - line.set_config(Vector3::new(-1.4, 0.0, -0.2), Vector3::new(1.4, 0.4, 0.5)); - line.generate_geometry(); + line.set_config(Vector3::new(-1.4, 0.0, -0.2), Vector3::new(1.4, 0.4, 0.5)) + .unwrap(); + line.generate_geometry().unwrap(); let mut polyline = OGPolyline::new("polyline-01".to_string()); - polyline.set_config(vec![ - Vector3::new(-1.8, 0.0, -1.2), - Vector3::new(-0.8, 0.1, -0.2), - Vector3::new(0.1, 0.0, -0.8), - Vector3::new(1.1, 0.3, -0.1), - ]); + polyline + .set_config(vec![ + Vector3::new(-1.8, 0.0, -1.2), + Vector3::new(-0.8, 0.1, -0.2), + Vector3::new(0.1, 0.0, -0.8), + Vector3::new(1.1, 0.3, -0.1), + ]) + .unwrap(); let mut cuboid = OGCuboid::new("cuboid-01".to_string()); - cuboid.set_config(Vector3::new(0.0, 0.0, 0.5), 1.8, 1.2, 1.2); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.5), 1.8, 1.2, 1.2) + .unwrap(); manager.add_line_to_scene_internal(&scene_id, "entity-line", &line)?; manager.add_polyline_to_scene_internal(&scene_id, "entity-polyline", &polyline)?; diff --git a/main/opengeometry/examples/scenegraph_projection_dump_json.rs b/main/opengeometry/examples/scenegraph_projection_dump_json.rs index 6900a43..5cf25f1 100644 --- a/main/opengeometry/examples/scenegraph_projection_dump_json.rs +++ b/main/opengeometry/examples/scenegraph_projection_dump_json.rs @@ -17,11 +17,14 @@ fn main() -> Result<(), Box> { let scene_id = manager.create_scene_internal("JSON Inspect Scene"); let mut line = OGLine::new("line-json".to_string()); - line.set_config(Vector3::new(-1.0, 0.0, 0.0), Vector3::new(1.0, 0.4, 0.6)); - line.generate_geometry(); + line.set_config(Vector3::new(-1.0, 0.0, 0.0), Vector3::new(1.0, 0.4, 0.6)) + .unwrap(); + line.generate_geometry().unwrap(); let mut cuboid = OGCuboid::new("box-json".to_string()); - cuboid.set_config(Vector3::new(0.0, 0.0, 0.0), 1.8, 1.2, 1.2); + cuboid + .set_config(Vector3::new(0.0, 0.0, 0.0), 1.8, 1.2, 1.2) + .unwrap(); manager.add_line_to_scene_internal(&scene_id, "line-entity", &line)?; manager.add_cuboid_to_scene_internal(&scene_id, "box-entity", &cuboid)?; diff --git a/main/opengeometry/examples/sphere_projection.rs b/main/opengeometry/examples/sphere_projection.rs index 402e376..458458a 100644 --- a/main/opengeometry/examples/sphere_projection.rs +++ b/main/opengeometry/examples/sphere_projection.rs @@ -13,7 +13,9 @@ fn main() -> Result<(), Box> { .unwrap_or_else(|| "sphere_projection.pdf".to_string()); let mut sphere = OGSphere::new("sphere-projection".to_string()); - sphere.set_config(Vector3::new(0.0, 0.0, 0.0), 1.4, 36, 20); + sphere + .set_config(Vector3::new(0.0, 0.0, 0.0), 1.4, 36, 20) + .unwrap(); println!("Sphere vertices: {}", sphere.brep().vertices.len()); println!("Sphere edges: {}", sphere.brep().edges.len()); diff --git a/main/opengeometry/examples/sweep_path_profile.rs b/main/opengeometry/examples/sweep_path_profile.rs index eb74424..6316b84 100644 --- a/main/opengeometry/examples/sweep_path_profile.rs +++ b/main/opengeometry/examples/sweep_path_profile.rs @@ -21,11 +21,14 @@ fn main() -> Result<(), Box> { Vector3::new(0.2, 1.2, 0.9), Vector3::new(1.2, 1.8, 0.2), Vector3::new(2.0, 2.2, -0.8), - ]); + ]) + .unwrap(); let mut rectangle_profile = OGRectangle::new("sweep-profile".to_string()); - rectangle_profile.set_config(Vector3::new(0.0, 0.0, 0.0), 0.7, 0.4); - rectangle_profile.generate_geometry(); + rectangle_profile + .set_config(Vector3::new(0.0, 0.0, 0.0), 0.7, 0.4) + .unwrap(); + rectangle_profile.generate_geometry().unwrap(); let profile_points: Vec = rectangle_profile .brep() @@ -35,7 +38,9 @@ fn main() -> Result<(), Box> { .collect(); let mut sweep = OGSweep::new("sweep".to_string()); - sweep.set_config_with_caps(path.get_raw_points(), profile_points, true, true); + sweep + .set_config_with_caps(path.get_raw_points(), profile_points, true, true) + .unwrap(); println!("Sweep vertices: {}", sweep.brep().vertices.len()); println!("Sweep edges: {}", sweep.brep().edges.len()); diff --git a/main/opengeometry/examples/wall_from_polyline_offsets.rs b/main/opengeometry/examples/wall_from_polyline_offsets.rs index feaad63..1794b15 100644 --- a/main/opengeometry/examples/wall_from_polyline_offsets.rs +++ b/main/opengeometry/examples/wall_from_polyline_offsets.rs @@ -42,14 +42,16 @@ fn main() -> Result<(), Box> { .unwrap_or_else(|| "./wall_from_polyline_offsets.pdf".to_string()); let mut centerline = OGPolyline::new("wall-centerline".to_string()); - centerline.set_config(vec![ - Vector3::new(-2.6, 0.0, -1.9), - Vector3::new(-1.2, 0.0, -1.0), - Vector3::new(-0.2, 0.0, 0.1), - Vector3::new(0.6, 0.0, 0.2), - Vector3::new(0.1, 0.0, 1.0), - Vector3::new(2.5, 0.0, 2.0), - ]); + centerline + .set_config(vec![ + Vector3::new(-2.6, 0.0, -1.9), + Vector3::new(-1.2, 0.0, -1.0), + Vector3::new(-0.2, 0.0, 0.1), + Vector3::new(0.6, 0.0, 0.2), + Vector3::new(0.1, 0.0, 1.0), + Vector3::new(2.5, 0.0, 2.0), + ]) + .unwrap(); let wall_thickness = 0.45; let half = wall_thickness * 0.5; @@ -69,10 +71,14 @@ fn main() -> Result<(), Box> { ); let mut left_polyline = OGPolyline::new("wall-left-offset".to_string()); - left_polyline.set_config(left_offset.points.clone()); + left_polyline + .set_config(left_offset.points.clone()) + .unwrap(); let mut right_polyline = OGPolyline::new("wall-right-offset".to_string()); - right_polyline.set_config(right_offset.points.clone()); + right_polyline + .set_config(right_offset.points.clone()) + .unwrap(); let wall_outline = build_wall_outline(&left_offset.points, &right_offset.points); if wall_outline.len() < 3 { @@ -80,7 +86,7 @@ fn main() -> Result<(), Box> { } let mut wall_polygon = OGPolygon::new("wall-polygon".to_string()); - wall_polygon.set_config(wall_outline.clone()); + wall_polygon.set_config(wall_outline.clone()).unwrap(); let mut manager = OGSceneManager::new(); let scene_id = manager.create_scene_internal("wall-from-offsets"); diff --git a/main/opengeometry/src/brep/builder.rs b/main/opengeometry/src/brep/builder.rs new file mode 100644 index 0000000..00a44b4 --- /dev/null +++ b/main/opengeometry/src/brep/builder.rs @@ -0,0 +1,509 @@ +use std::collections::HashMap; + +use openmaths::Vector3; +use uuid::Uuid; + +use super::error::{BrepError, BrepErrorKind}; +use super::{Brep, Edge, Face, HalfEdge, Loop, Shell, Vertex, Wire}; + +const EPSILON: f64 = 1.0e-9; + +#[derive(Clone)] +pub struct BrepBuilder { + brep: Brep, + undirected_edge_map: HashMap<(u32, u32), u32>, + directed_halfedge_map: HashMap<(u32, u32), u32>, + edge_halfedge_count: HashMap, +} + +impl BrepBuilder { + pub fn new(id: Uuid) -> Self { + Self { + brep: Brep::new(id), + undirected_edge_map: HashMap::new(), + directed_halfedge_map: HashMap::new(), + edge_halfedge_count: HashMap::new(), + } + } + + pub fn add_vertex(&mut self, position: Vector3) -> u32 { + let vertex_id = self.brep.vertices.len() as u32; + self.brep.vertices.push(Vertex::new(vertex_id, position)); + vertex_id + } + + pub fn add_vertices(&mut self, positions: &[Vector3]) -> Vec { + positions + .iter() + .map(|position| self.add_vertex(*position)) + .collect() + } + + pub fn add_face(&mut self, outer: &[u32], holes: &[Vec]) -> Result { + let mut staging = self.clone(); + let face_id = staging.add_face_internal(outer, holes)?; + *self = staging; + Ok(face_id) + } + + pub fn add_wire(&mut self, vertex_indices: &[u32], is_closed: bool) -> Result { + let mut staging = self.clone(); + let wire_id = staging.add_wire_internal(vertex_indices, is_closed)?; + *self = staging; + Ok(wire_id) + } + + pub fn add_shell(&mut self, face_ids: &[u32], is_closed: bool) -> Result { + let mut staging = self.clone(); + let shell_id = staging.add_shell_internal(face_ids, is_closed)?; + *self = staging; + Ok(shell_id) + } + + pub fn add_shell_from_all_faces(&mut self, is_closed: bool) -> Result { + if self.brep.faces.is_empty() { + return Err(BrepError::new( + BrepErrorKind::InvalidShell, + "Cannot create shell from an empty face set", + )); + } + + let face_ids: Vec = (0..self.brep.faces.len() as u32).collect(); + self.add_shell(&face_ids, is_closed) + } + + pub fn build(self) -> Result { + self.brep.validate_topology()?; + Ok(self.brep) + } + + fn add_face_internal(&mut self, outer: &[u32], holes: &[Vec]) -> Result { + let outer = sanitize_indices(outer, true, 3, "outer loop")?; + if !is_loop_non_degenerate(&outer) { + return Err(BrepError::new( + BrepErrorKind::DegenerateLoop, + "Outer loop is degenerate", + )); + } + + let mut hole_loops = Vec::new(); + for hole in holes { + let cleaned = sanitize_indices(hole, true, 3, "inner loop")?; + if !is_loop_non_degenerate(&cleaned) { + return Err(BrepError::new( + BrepErrorKind::DegenerateLoop, + "Inner loop is degenerate", + )); + } + hole_loops.push(cleaned); + } + + let face_id = self.brep.faces.len() as u32; + self.brep.faces.push(Face::new( + face_id, + Vector3::new(0.0, 0.0, 0.0), + 0, + Vec::new(), + None, + )); + + let outer_loop_id = self.add_face_loop(face_id, &outer, false)?; + self.brep.faces[face_id as usize].outer_loop = outer_loop_id; + + for hole in &hole_loops { + let loop_id = self.add_face_loop(face_id, hole, true)?; + self.brep.faces[face_id as usize].inner_loops.push(loop_id); + } + + let normal = self.compute_normal_from_loop(&outer)?; + self.brep.faces[face_id as usize].set_normal(normal); + + Ok(face_id) + } + + fn add_wire_internal( + &mut self, + vertex_indices: &[u32], + is_closed: bool, + ) -> Result { + let min_vertices = if is_closed { 3 } else { 2 }; + let vertices = sanitize_indices(vertex_indices, is_closed, min_vertices, "wire")?; + + let wire_id = self.brep.wires.len() as u32; + self.brep + .wires + .push(Wire::new(wire_id, Vec::new(), is_closed)); + + let halfedges = if is_closed { + self.create_closed_halfedge_cycle(&vertices, None, None, Some(wire_id))? + } else { + self.create_open_halfedge_chain(&vertices, None, None, Some(wire_id))? + }; + + self.brep.wires[wire_id as usize].halfedges = halfedges; + + Ok(wire_id) + } + + fn add_shell_internal(&mut self, face_ids: &[u32], is_closed: bool) -> Result { + if face_ids.is_empty() { + return Err(BrepError::new( + BrepErrorKind::InvalidShell, + "Shell must reference at least one face", + )); + } + + for face_id in face_ids { + let idx = *face_id as usize; + let Some(face) = self.brep.faces.get(idx) else { + return Err(BrepError::new( + BrepErrorKind::InvalidFace, + format!("Face {} does not exist", face_id), + )); + }; + + if face.shell_ref.is_some() { + return Err(BrepError::new( + BrepErrorKind::InvalidShell, + format!("Face {} already belongs to a shell", face_id), + )); + } + } + + let shell_id = self.brep.shells.len() as u32; + self.brep + .shells + .push(Shell::new(shell_id, face_ids.to_vec(), is_closed)); + + for face_id in face_ids { + self.brep.faces[*face_id as usize].shell_ref = Some(shell_id); + } + + Ok(shell_id) + } + + fn add_face_loop( + &mut self, + face_id: u32, + vertex_indices: &[u32], + is_hole: bool, + ) -> Result { + let loop_id = self.brep.loops.len() as u32; + self.brep + .loops + .push(Loop::new(loop_id, 0, face_id, is_hole)); + + let halfedges = + self.create_closed_halfedge_cycle(vertex_indices, Some(face_id), Some(loop_id), None)?; + let Some(start_halfedge) = halfedges.first().copied() else { + return Err(BrepError::new( + BrepErrorKind::DegenerateLoop, + "Loop generation produced no halfedges", + )); + }; + + self.brep.loops[loop_id as usize].halfedge = start_halfedge; + Ok(loop_id) + } + + fn create_closed_halfedge_cycle( + &mut self, + vertex_indices: &[u32], + face_ref: Option, + loop_ref: Option, + wire_ref: Option, + ) -> Result, BrepError> { + let mut halfedges = Vec::with_capacity(vertex_indices.len()); + + for index in 0..vertex_indices.len() { + let from = vertex_indices[index]; + let to = vertex_indices[(index + 1) % vertex_indices.len()]; + let halfedge_id = self.create_halfedge(from, to, face_ref, loop_ref, wire_ref)?; + halfedges.push(halfedge_id); + } + + for index in 0..halfedges.len() { + let current = halfedges[index] as usize; + let next = halfedges[(index + 1) % halfedges.len()]; + let prev = halfedges[(index + halfedges.len() - 1) % halfedges.len()]; + + self.brep.halfedges[current].next = Some(next); + self.brep.halfedges[current].prev = Some(prev); + } + + Ok(halfedges) + } + + fn create_open_halfedge_chain( + &mut self, + vertex_indices: &[u32], + face_ref: Option, + loop_ref: Option, + wire_ref: Option, + ) -> Result, BrepError> { + let mut halfedges = Vec::with_capacity(vertex_indices.len().saturating_sub(1)); + + for index in 0..(vertex_indices.len() - 1) { + let from = vertex_indices[index]; + let to = vertex_indices[index + 1]; + let halfedge_id = self.create_halfedge(from, to, face_ref, loop_ref, wire_ref)?; + halfedges.push(halfedge_id); + } + + for index in 0..halfedges.len() { + let current = halfedges[index] as usize; + let next = if index + 1 < halfedges.len() { + Some(halfedges[index + 1]) + } else { + None + }; + let prev = if index > 0 { + Some(halfedges[index - 1]) + } else { + None + }; + + self.brep.halfedges[current].next = next; + self.brep.halfedges[current].prev = prev; + } + + Ok(halfedges) + } + + fn create_halfedge( + &mut self, + from: u32, + to: u32, + face_ref: Option, + loop_ref: Option, + wire_ref: Option, + ) -> Result { + self.ensure_vertex_exists(from)?; + self.ensure_vertex_exists(to)?; + + if from == to { + return Err(BrepError::new( + BrepErrorKind::DegenerateEdge, + "Halfedge endpoints must differ", + )); + } + + if self.directed_halfedge_map.contains_key(&(from, to)) { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!("Duplicate directed halfedge {} -> {}", from, to), + )); + } + + let undirected = undirected_key(from, to); + let halfedge_id = self.brep.halfedges.len() as u32; + + let edge_id = + if let Some(existing_edge_id) = self.undirected_edge_map.get(&undirected).copied() { + let incidence = self + .edge_halfedge_count + .get(&existing_edge_id) + .copied() + .unwrap_or(0); + + if incidence >= 2 { + return Err(BrepError::new( + BrepErrorKind::NonManifoldEdge, + format!( + "Edge ({}, {}) would become non-manifold with more than two halfedges", + undirected.0, undirected.1 + ), + )); + } + + existing_edge_id + } else { + let new_edge_id = self.brep.edges.len() as u32; + self.undirected_edge_map.insert(undirected, new_edge_id); + self.edge_halfedge_count.insert(new_edge_id, 0); + self.brep + .edges + .push(Edge::new(new_edge_id, halfedge_id, None)); + new_edge_id + }; + + let twin = self.directed_halfedge_map.get(&(to, from)).copied(); + + self.brep.halfedges.push(HalfEdge::new( + halfedge_id, + from, + to, + edge_id, + face_ref, + loop_ref, + wire_ref, + )); + + if let Some(twin_id) = twin { + self.brep.halfedges[halfedge_id as usize].twin = Some(twin_id); + self.brep.halfedges[twin_id as usize].twin = Some(halfedge_id); + self.brep.edges[edge_id as usize].twin_halfedge = Some(halfedge_id); + } + + self.directed_halfedge_map.insert((from, to), halfedge_id); + let incidence = self.edge_halfedge_count.entry(edge_id).or_insert(0); + *incidence += 1; + + if self.brep.vertices[from as usize] + .outgoing_halfedge + .is_none() + { + self.brep.vertices[from as usize].outgoing_halfedge = Some(halfedge_id); + } + + Ok(halfedge_id) + } + + fn ensure_vertex_exists(&self, vertex_id: u32) -> Result<(), BrepError> { + if self.brep.vertices.get(vertex_id as usize).is_none() { + return Err(BrepError::new( + BrepErrorKind::InvalidVertex, + format!("Vertex {} does not exist", vertex_id), + )); + } + + Ok(()) + } + + fn compute_normal_from_loop(&self, loop_indices: &[u32]) -> Result { + if loop_indices.len() < 3 { + return Err(BrepError::new( + BrepErrorKind::DegenerateLoop, + "Loop must have at least three vertices to compute a normal", + )); + } + + let p0 = self.brep.vertices[loop_indices[0] as usize].position; + for index in 1..(loop_indices.len() - 1) { + let p1 = self.brep.vertices[loop_indices[index] as usize].position; + let p2 = self.brep.vertices[loop_indices[index + 1] as usize].position; + + let v1 = [p1.x - p0.x, p1.y - p0.y, p1.z - p0.z]; + let v2 = [p2.x - p0.x, p2.y - p0.y, p2.z - p0.z]; + + let cross = [ + v1[1] * v2[2] - v1[2] * v2[1], + v1[2] * v2[0] - v1[0] * v2[2], + v1[0] * v2[1] - v1[1] * v2[0], + ]; + let length_sq = cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]; + + if length_sq > EPSILON * EPSILON { + let inv = length_sq.sqrt().recip(); + return Ok(Vector3::new(cross[0] * inv, cross[1] * inv, cross[2] * inv)); + } + } + + Err(BrepError::new( + BrepErrorKind::DegenerateLoop, + "Failed to compute a stable face normal from loop vertices", + )) + } +} + +fn sanitize_indices( + indices: &[u32], + is_closed: bool, + min_vertices: usize, + label: &str, +) -> Result, BrepError> { + let mut cleaned: Vec = Vec::with_capacity(indices.len()); + for index in indices { + if cleaned.last().copied() == Some(*index) { + continue; + } + cleaned.push(*index); + } + + if is_closed && cleaned.len() >= 2 && cleaned.first() == cleaned.last() { + cleaned.pop(); + } + + if cleaned.len() < min_vertices { + return Err(BrepError::new( + BrepErrorKind::InvalidLoop, + format!( + "{} must have at least {} unique vertices", + label, min_vertices + ), + )); + } + + Ok(cleaned) +} + +fn is_loop_non_degenerate(loop_indices: &[u32]) -> bool { + if loop_indices.len() < 3 { + return false; + } + + let mut unique = HashMap::new(); + for index in loop_indices { + unique.insert(*index, true); + } + + unique.len() >= 3 +} + +fn undirected_key(a: u32, b: u32) -> (u32, u32) { + if a < b { + (a, b) + } else { + (b, a) + } +} + +#[cfg(test)] +mod tests { + use super::BrepBuilder; + use openmaths::Vector3; + use uuid::Uuid; + + #[test] + fn builder_creates_closed_face_with_twin_halfedges() { + let mut builder = BrepBuilder::new(Uuid::new_v4()); + let vertices = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 1.0), + Vector3::new(0.0, 0.0, 1.0), + ]; + builder.add_vertices(&vertices); + + builder.add_face(&[0, 1, 2, 3], &[]).unwrap(); + builder.add_face(&[0, 3, 2, 1], &[]).unwrap(); + + let brep = builder.build().unwrap(); + + assert_eq!(brep.faces.len(), 2); + assert!(!brep.halfedges.is_empty()); + assert!(brep + .halfedges + .iter() + .any(|halfedge| halfedge.twin.is_some())); + } + + #[test] + fn builder_rejects_non_manifold_edge() { + let mut builder = BrepBuilder::new(Uuid::new_v4()); + let vertices = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 1.0), + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(0.5, 1.0, 0.5), + ]; + builder.add_vertices(&vertices); + + builder.add_face(&[0, 1, 2], &[]).unwrap(); + builder.add_face(&[1, 0, 3], &[]).unwrap(); + let third_face = builder.add_face(&[0, 1, 4], &[]); + + assert!(third_face.is_err()); + } +} diff --git a/main/opengeometry/src/brep/edge.rs b/main/opengeometry/src/brep/edge.rs index 95a3767..7d99c72 100644 --- a/main/opengeometry/src/brep/edge.rs +++ b/main/opengeometry/src/brep/edge.rs @@ -1,18 +1,18 @@ -// use crate::brep::halfedge::HalfEdge; use serde::{Deserialize, Serialize}; #[derive(Clone, Serialize, Deserialize)] pub struct Edge { pub id: u32, - pub v1: u32, - pub v2: u32, - // TODO: Add support for halfedges - // pub halfedges: Vec, - // halfedge: HalfEdge, + pub halfedge: u32, + pub twin_halfedge: Option, } impl Edge { - pub fn new(id: u32, v1: u32, v2: u32) -> Self { - Edge { id, v1, v2 } + pub fn new(id: u32, halfedge: u32, twin_halfedge: Option) -> Self { + Self { + id, + halfedge, + twin_halfedge, + } } } diff --git a/main/opengeometry/src/brep/error.rs b/main/opengeometry/src/brep/error.rs new file mode 100644 index 0000000..769cddf --- /dev/null +++ b/main/opengeometry/src/brep/error.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum BrepErrorKind { + InvalidVertex, + InvalidEdge, + InvalidHalfEdge, + InvalidLoop, + InvalidWire, + InvalidFace, + InvalidShell, + DegenerateEdge, + DegenerateLoop, + NonManifoldEdge, + BrokenTopology, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrepError { + pub kind: BrepErrorKind, + pub message: String, +} + +impl BrepError { + pub fn new(kind: BrepErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl core::fmt::Display for BrepError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for BrepError {} diff --git a/main/opengeometry/src/brep/face.rs b/main/opengeometry/src/brep/face.rs index 1f28cb9..fd57f0f 100644 --- a/main/opengeometry/src/brep/face.rs +++ b/main/opengeometry/src/brep/face.rs @@ -1,43 +1,33 @@ use openmaths::Vector3; -// use crate::brep_ds::halfedge::HalfEdge; use serde::{Deserialize, Serialize}; -// Reference - https://15362.courses.cs.cmu.edu/spring2025content/lectures/12_rec3/12_rec3_slides.pdf -// Reference - https://15462.courses.cs.cmu.edu/spring2021content/lectures/11_meshes/11_meshes_slides.pdf -// Helpers can be added as needed - #[derive(Clone, Serialize, Deserialize)] pub struct Face { pub id: u32, pub normal: Vector3, - pub face_indices: Vec, - // TODO: Add support for halfedges - // pub halfedge: HalfEdge, + pub outer_loop: u32, + pub inner_loops: Vec, + pub shell_ref: Option, } impl Face { - pub fn new(id: u32, face_indices: Vec) -> Self { - Face { + pub fn new( + id: u32, + normal: Vector3, + outer_loop: u32, + inner_loops: Vec, + shell_ref: Option, + ) -> Self { + Self { id, - normal: Vector3::new(0.0, 0.0, 0.0), // Default normal, should be calculated later - face_indices, - // halfedge: HalfEdge::new(0, 0, 0), // Placeholder for halfedge + normal, + outer_loop, + inner_loops, + shell_ref, } } pub fn set_normal(&mut self, normal: Vector3) { self.normal = normal; } - - pub fn get_face_indices(&self) -> &Vec { - &self.face_indices - } - - pub fn get_indices_count(&self) -> u32 { - self.face_indices.len() as u32 - } - - pub fn insert_vertex(&mut self, vertex_id: u32) { - self.face_indices.push(vertex_id); - } } diff --git a/main/opengeometry/src/brep/halfedge.rs b/main/opengeometry/src/brep/halfedge.rs index 40e5f56..8152570 100644 --- a/main/opengeometry/src/brep/halfedge.rs +++ b/main/opengeometry/src/brep/halfedge.rs @@ -1,13 +1,40 @@ -// Reference - https://15362.courses.cs.cmu.edu/spring2025content/lectures/12_rec3/12_rec3_slides.pdf -// I wish Rust had pointers +use serde::{Deserialize, Serialize}; +#[derive(Clone, Serialize, Deserialize)] pub struct HalfEdge { - pub id: u32, - - pub twin_ref: u32, // ID of the twin halfedge - pub next_ref: u32, // ID of the next halfedge in the loop + pub id: u32, + pub from: u32, + pub to: u32, + pub twin: Option, + pub next: Option, + pub prev: Option, + pub edge: u32, + pub face: Option, + pub loop_ref: Option, + pub wire_ref: Option, +} - pub edge_ref: u32, // ID of edge this halfedge belongs to - pub vertex_ref: u32, // ID of the vertex this halfedge points to - pub face_ref: u32, // ID of the face this halfedge belongs to +impl HalfEdge { + pub fn new( + id: u32, + from: u32, + to: u32, + edge: u32, + face: Option, + loop_ref: Option, + wire_ref: Option, + ) -> Self { + Self { + id, + from, + to, + twin: None, + next: None, + prev: None, + edge, + face, + loop_ref, + wire_ref, + } + } } diff --git a/main/opengeometry/src/brep/loop.rs b/main/opengeometry/src/brep/loop.rs new file mode 100644 index 0000000..e323ede --- /dev/null +++ b/main/opengeometry/src/brep/loop.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Loop { + pub id: u32, + pub halfedge: u32, + pub face: u32, + pub is_hole: bool, +} + +impl Loop { + pub fn new(id: u32, halfedge: u32, face: u32, is_hole: bool) -> Self { + Self { + id, + halfedge, + face, + is_hole, + } + } +} diff --git a/main/opengeometry/src/brep/mod.rs b/main/opengeometry/src/brep/mod.rs index 6ec6e4c..3833d52 100644 --- a/main/opengeometry/src/brep/mod.rs +++ b/main/opengeometry/src/brep/mod.rs @@ -1,149 +1,776 @@ +pub mod builder; pub mod edge; -/** - * BRep Module/Structure - * References - https://en.wikipedia.org/wiki/Boundary_representation - * References - https://en.wikipedia.org/wiki/Doubly_connected_edge_list - * References - https://en.wikipedia.org/wiki/Polygon_mesh - * References - https://www.cs.cmu.edu/~./quake/robust.html - */ -pub mod vertex; -// pub mod halfedge; +pub mod error; pub mod face; +pub mod halfedge; +pub mod r#loop; +pub mod shell; +pub mod vertex; +pub mod wire; + +use std::collections::{HashMap, HashSet}; use openmaths::Vector3; use serde::{Deserialize, Serialize}; use uuid::Uuid; -// Import and re-export types +pub use builder::BrepBuilder; pub use edge::Edge; +pub use error::{BrepError, BrepErrorKind}; pub use face::Face; +pub use halfedge::HalfEdge; +pub use r#loop::Loop; +pub use shell::Shell; pub use vertex::Vertex; +pub use wire::Wire; + +const VALIDATION_GUARD_FACTOR: usize = 4; #[derive(Clone, Serialize, Deserialize)] pub struct Brep { pub id: Uuid, pub vertices: Vec, + pub halfedges: Vec, pub edges: Vec, - // pub halfedges: Vec, + pub loops: Vec, pub faces: Vec, - pub holes: Vec, - pub hole_edges: Vec, + pub wires: Vec, + pub shells: Vec, } impl Brep { pub fn new(id: Uuid) -> Self { - Brep { + Self { id, vertices: Vec::new(), + halfedges: Vec::new(), edges: Vec::new(), - // halfedges: Vec::new(), + loops: Vec::new(), faces: Vec::new(), - holes: Vec::new(), - hole_edges: Vec::new(), + wires: Vec::new(), + shells: Vec::new(), } } pub fn clear(&mut self) { self.vertices.clear(); + self.halfedges.clear(); self.edges.clear(); - // self.halfedges.clear(); + self.loops.clear(); self.faces.clear(); + self.wires.clear(); + self.shells.clear(); } pub fn get_vertex_count(&self) -> u32 { self.vertices.len() as u32 } + pub fn get_halfedge_count(&self) -> u32 { + self.halfedges.len() as u32 + } + pub fn get_edge_count(&self) -> u32 { self.edges.len() as u32 } + pub fn get_loop_count(&self) -> u32 { + self.loops.len() as u32 + } + pub fn get_face_count(&self) -> u32 { self.faces.len() as u32 } - pub fn get_hole_edge_count(&self) -> u32 { - self.hole_edges.len() as u32 + pub fn get_wire_count(&self) -> u32 { + self.wires.len() as u32 } - /** - * Get vertices by face ID - * @returns Vec - A vector of Vector3 representing the vertices of the face - * Use this when we need vertices not just indices - */ - pub fn get_vertices_by_face_id(&self, face_id: u32) -> Vec { - let face = self.faces[face_id as usize].clone(); - let mut vertices = Vec::new(); - let face_index_count = face.get_indices_count(); - for index in 0..face_index_count { - let vertex_id = face.face_indices[index as usize]; - let vertex = self.vertices[vertex_id as usize].clone(); - vertices.push(vertex.position); + pub fn get_shell_count(&self) -> u32 { + self.shells.len() as u32 + } + + pub fn get_flattened_vertices(&self) -> Vec { + self.vertices.iter().map(|vertex| vertex.position).collect() + } + + pub fn get_edge_endpoints(&self, edge_id: u32) -> Option<(u32, u32)> { + let edge = self.edges.get(edge_id as usize)?; + let halfedge = self.halfedges.get(edge.halfedge as usize)?; + Some((halfedge.from, halfedge.to)) + } + + pub fn collect_outline_segments(&self) -> Vec<(u32, u32)> { + let mut segments = Vec::new(); + for edge in &self.edges { + if let Some((from, to)) = self.get_edge_endpoints(edge.id) { + segments.push((from, to)); + } } - vertices + segments } - pub fn insert_vertex_at_face_by_id(&mut self, face_id: u32, vertex_id: u32) { - if let Some(face) = self.faces.iter_mut().find(|f| f.id == face_id) { - face.insert_vertex(vertex_id); - } else { - eprintln!("Face with id {} not found", face_id); + pub fn get_loop_halfedges(&self, loop_id: u32) -> Result, BrepError> { + let loop_ref = self.loops.get(loop_id as usize).ok_or_else(|| { + BrepError::new( + BrepErrorKind::InvalidLoop, + format!("Loop {} does not exist", loop_id), + ) + })?; + + let start = loop_ref.halfedge; + if self.halfedges.get(start as usize).is_none() { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!("Loop {} points to invalid halfedge {}", loop_id, start), + )); + } + + let mut result = Vec::new(); + let mut visited = HashSet::new(); + let mut current = start; + let guard_limit = self.halfedges.len().saturating_mul(VALIDATION_GUARD_FACTOR); + + for _ in 0..guard_limit { + if !visited.insert(current) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Loop {} revisits halfedge {} before closure", + loop_id, current + ), + )); + } + + result.push(current); + + let halfedge = &self.halfedges[current as usize]; + let Some(next) = halfedge.next else { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Loop {} contains halfedge {} without next link", + loop_id, current + ), + )); + }; + + if next == start { + return Ok(result); + } + + if self.halfedges.get(next as usize).is_none() { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!("Loop {} references invalid next halfedge {}", loop_id, next), + )); + } + + current = next; } + + Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Loop {} exceeded traversal guard", loop_id), + )) } - /** - * Get flattened vertices from the BREP object - */ - pub fn get_flattened_vertices(&self) -> Vec { - self.vertices.iter().map(|v| v.position).collect() + pub fn get_loop_vertex_indices(&self, loop_id: u32) -> Vec { + self.get_loop_halfedges(loop_id) + .map(|halfedges| { + halfedges + .iter() + .filter_map(|halfedge_id| self.halfedges.get(*halfedge_id as usize)) + .map(|halfedge| halfedge.from) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_wire_vertex_indices(&self, wire_id: u32) -> Vec { + let Some(wire) = self.wires.get(wire_id as usize) else { + return Vec::new(); + }; + + if wire.halfedges.is_empty() { + return Vec::new(); + } + + let mut vertices = Vec::with_capacity(wire.halfedges.len() + 1); + for halfedge_id in &wire.halfedges { + if let Some(halfedge) = self.halfedges.get(*halfedge_id as usize) { + vertices.push(halfedge.from); + } + } + + if !wire.is_closed { + if let Some(last_halfedge_id) = wire.halfedges.last() { + if let Some(last_halfedge) = self.halfedges.get(*last_halfedge_id as usize) { + vertices.push(last_halfedge.to); + } + } + } + + vertices + } + + pub fn get_vertices_by_face_id(&self, face_id: u32) -> Vec { + let Some(face) = self.faces.iter().find(|face| face.id == face_id) else { + return Vec::new(); + }; + + self.get_loop_vertex_indices(face.outer_loop) + .into_iter() + .filter_map(|vertex_id| self.vertices.get(vertex_id as usize)) + .map(|vertex| vertex.position) + .collect() } - /** - * Get vertices and holes by face ID - * @param face_id - The ID of the face to get vertices and holes for - * @returns (Vec, Vec>) - A tuple containing face vertices and hole vertices - */ pub fn get_vertices_and_holes_by_face_id( &self, face_id: u32, ) -> (Vec, Vec>) { - // Find the face by ID - if let Some(face) = self.faces.iter().find(|f| f.id == face_id) { - // Get the main face vertices - let mut face_vertices = Vec::new(); - for &vertex_index in &face.face_indices { - if let Some(vertex) = self.vertices.get(vertex_index as usize) { - face_vertices.push(vertex.position); - } - } - - let mut holes_vertices = Vec::new(); - - if self.holes.len() > 0 { - for &hole_start_index in &self.holes { - let mut hole_vertices = Vec::new(); - let next_hole_start_index = self - .holes - .iter() - .filter(|&&idx| idx > hole_start_index) - .min() - .cloned() - .unwrap_or(self.vertices.len() as u32); - - for i in hole_start_index..next_hole_start_index { - if let Some(vertex) = self.vertices.get(i as usize) { - hole_vertices.push(vertex.position); + let Some(face) = self.faces.iter().find(|face| face.id == face_id) else { + return (Vec::new(), Vec::new()); + }; + + let face_vertices = self + .get_loop_vertex_indices(face.outer_loop) + .into_iter() + .filter_map(|vertex_id| self.vertices.get(vertex_id as usize)) + .map(|vertex| vertex.position) + .collect(); + + let mut holes_vertices = Vec::new(); + for loop_id in &face.inner_loops { + let hole_vertices: Vec = self + .get_loop_vertex_indices(*loop_id) + .into_iter() + .filter_map(|vertex_id| self.vertices.get(vertex_id as usize)) + .map(|vertex| vertex.position) + .collect(); + + holes_vertices.push(hole_vertices); + } + + (face_vertices, holes_vertices) + } + + pub fn validate_topology(&self) -> Result<(), BrepError> { + for (index, vertex) in self.vertices.iter().enumerate() { + if vertex.id as usize != index { + return Err(BrepError::new( + BrepErrorKind::InvalidVertex, + format!("Vertex id mismatch at index {} (id={})", index, vertex.id), + )); + } + + if let Some(outgoing) = vertex.outgoing_halfedge { + let Some(halfedge) = self.halfedges.get(outgoing as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!( + "Vertex {} references missing outgoing halfedge {}", + vertex.id, outgoing + ), + )); + }; + + if halfedge.from != vertex.id { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Vertex {} outgoing halfedge {} starts at {}", + vertex.id, outgoing, halfedge.from + ), + )); + } + } + } + + let mut edge_to_halfedges: HashMap> = HashMap::new(); + let mut edge_to_faces: HashMap> = HashMap::new(); + + for (index, halfedge) in self.halfedges.iter().enumerate() { + if halfedge.id as usize != index { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!( + "Halfedge id mismatch at index {} (id={})", + index, halfedge.id + ), + )); + } + + if self.vertices.get(halfedge.from as usize).is_none() + || self.vertices.get(halfedge.to as usize).is_none() + { + return Err(BrepError::new( + BrepErrorKind::InvalidVertex, + format!( + "Halfedge {} has invalid vertex references ({} -> {})", + halfedge.id, halfedge.from, halfedge.to + ), + )); + } + + let Some(edge) = self.edges.get(halfedge.edge as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidEdge, + format!( + "Halfedge {} references missing edge {}", + halfedge.id, halfedge.edge + ), + )); + }; + + if edge.id != halfedge.edge { + return Err(BrepError::new( + BrepErrorKind::InvalidEdge, + format!( + "Halfedge {} edge reference mismatch (edge id={}, ref={})", + halfedge.id, edge.id, halfedge.edge + ), + )); + } + + edge_to_halfedges + .entry(halfedge.edge) + .or_default() + .push(halfedge.id); + + if let Some(face_id) = halfedge.face { + if self.faces.get(face_id as usize).is_none() { + return Err(BrepError::new( + BrepErrorKind::InvalidFace, + format!( + "Halfedge {} references missing face {}", + halfedge.id, face_id + ), + )); + } + edge_to_faces + .entry(halfedge.edge) + .or_default() + .insert(face_id); + } + + if let Some(twin_id) = halfedge.twin { + let Some(twin) = self.halfedges.get(twin_id as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!( + "Halfedge {} references missing twin {}", + halfedge.id, twin_id + ), + )); + }; + + if twin.twin != Some(halfedge.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Halfedge {} twin symmetry broken with {}", + halfedge.id, twin_id + ), + )); + } + + if !(halfedge.from == twin.to && halfedge.to == twin.from) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Halfedge {} twin {} does not reverse endpoints", + halfedge.id, twin_id + ), + )); + } + } + + if let Some(next_id) = halfedge.next { + let Some(next) = self.halfedges.get(next_id as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!("Halfedge {} has missing next {}", halfedge.id, next_id), + )); + }; + + if next.prev != Some(halfedge.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Halfedge {} next/prev symmetry is broken", halfedge.id), + )); + } + } + + if let Some(prev_id) = halfedge.prev { + let Some(prev) = self.halfedges.get(prev_id as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!("Halfedge {} has missing prev {}", halfedge.id, prev_id), + )); + }; + + if prev.next != Some(halfedge.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Halfedge {} prev/next symmetry is broken", halfedge.id), + )); + } + } + + if let Some(loop_id) = halfedge.loop_ref { + if self.loops.get(loop_id as usize).is_none() { + return Err(BrepError::new( + BrepErrorKind::InvalidLoop, + format!( + "Halfedge {} references missing loop {}", + halfedge.id, loop_id + ), + )); + } + } + + if let Some(wire_id) = halfedge.wire_ref { + if self.wires.get(wire_id as usize).is_none() { + return Err(BrepError::new( + BrepErrorKind::InvalidWire, + format!( + "Halfedge {} references missing wire {}", + halfedge.id, wire_id + ), + )); + } + } + } + + for (index, edge) in self.edges.iter().enumerate() { + if edge.id as usize != index { + return Err(BrepError::new( + BrepErrorKind::InvalidEdge, + format!("Edge id mismatch at index {} (id={})", index, edge.id), + )); + } + + let Some(halfedge_list) = edge_to_halfedges.get(&edge.id) else { + return Err(BrepError::new( + BrepErrorKind::InvalidEdge, + format!("Edge {} has no halfedges", edge.id), + )); + }; + + if halfedge_list.is_empty() || halfedge_list.len() > 2 { + return Err(BrepError::new( + BrepErrorKind::NonManifoldEdge, + format!( + "Edge {} has invalid incidence count {}", + edge.id, + halfedge_list.len() + ), + )); + } + + if !halfedge_list.contains(&edge.halfedge) { + return Err(BrepError::new( + BrepErrorKind::InvalidEdge, + format!( + "Edge {} primary halfedge {} is not linked to edge", + edge.id, edge.halfedge + ), + )); + } + + if let Some(twin_halfedge) = edge.twin_halfedge { + if !halfedge_list.contains(&twin_halfedge) { + return Err(BrepError::new( + BrepErrorKind::InvalidEdge, + format!( + "Edge {} twin halfedge {} is not linked to edge", + edge.id, twin_halfedge + ), + )); + } + } + + if let Some(face_set) = edge_to_faces.get(&edge.id) { + if face_set.len() > 2 { + return Err(BrepError::new( + BrepErrorKind::NonManifoldEdge, + format!("Edge {} touches more than two faces", edge.id), + )); + } + } + } + + for loop_ref in &self.loops { + if loop_ref.id as usize >= self.loops.len() { + return Err(BrepError::new( + BrepErrorKind::InvalidLoop, + format!("Loop {} has invalid id", loop_ref.id), + )); + } + + let Some(face) = self.faces.get(loop_ref.face as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidFace, + format!( + "Loop {} references missing face {}", + loop_ref.id, loop_ref.face + ), + )); + }; + + if loop_ref.is_hole { + if !face.inner_loops.contains(&loop_ref.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Hole loop {} is not listed in face {}", + loop_ref.id, face.id + ), + )); + } + } else if face.outer_loop != loop_ref.id { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Face {} outer loop mismatch", face.id), + )); + } + + let loop_halfedges = self.get_loop_halfedges(loop_ref.id)?; + if loop_halfedges.len() < 3 { + return Err(BrepError::new( + BrepErrorKind::DegenerateLoop, + format!("Loop {} has fewer than three halfedges", loop_ref.id), + )); + } + + for halfedge_id in loop_halfedges { + let halfedge = &self.halfedges[halfedge_id as usize]; + if halfedge.loop_ref != Some(loop_ref.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Halfedge {} loop reference mismatch", halfedge_id), + )); + } + + if halfedge.face != Some(loop_ref.face) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Halfedge {} face reference mismatch", halfedge_id), + )); + } + } + } + + for wire in &self.wires { + if wire.id as usize >= self.wires.len() { + return Err(BrepError::new( + BrepErrorKind::InvalidWire, + format!("Wire {} has invalid id", wire.id), + )); + } + + if wire.halfedges.is_empty() { + return Err(BrepError::new( + BrepErrorKind::InvalidWire, + format!("Wire {} is empty", wire.id), + )); + } + + for (index, halfedge_id) in wire.halfedges.iter().enumerate() { + let Some(halfedge) = self.halfedges.get(*halfedge_id as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidHalfEdge, + format!( + "Wire {} references missing halfedge {}", + wire.id, halfedge_id + ), + )); + }; + + if halfedge.wire_ref != Some(wire.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Wire {} halfedge {} wire_ref mismatch", + wire.id, halfedge_id + ), + )); + } + + if wire.is_closed { + let expected_next = wire.halfedges[(index + 1) % wire.halfedges.len()]; + let expected_prev = + wire.halfedges[(index + wire.halfedges.len() - 1) % wire.halfedges.len()]; + + if halfedge.next != Some(expected_next) || halfedge.prev != Some(expected_prev) + { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Closed wire {} has broken halfedge links", wire.id), + )); + } + } else { + if index == 0 && halfedge.prev.is_some() { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Open wire {} first halfedge must not have prev", wire.id), + )); + } + + if index + 1 == wire.halfedges.len() && halfedge.next.is_some() { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Open wire {} last halfedge must not have next", wire.id), + )); + } + + if index + 1 < wire.halfedges.len() { + let expected_next = wire.halfedges[index + 1]; + if halfedge.next != Some(expected_next) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Open wire {} has broken next link", wire.id), + )); } } - holes_vertices.push(hole_vertices); + } + } + } + + for (index, face) in self.faces.iter().enumerate() { + if face.id as usize != index { + return Err(BrepError::new( + BrepErrorKind::InvalidFace, + format!("Face id mismatch at index {} (id={})", index, face.id), + )); + } + + let Some(outer_loop) = self.loops.get(face.outer_loop as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidLoop, + format!( + "Face {} references missing outer loop {}", + face.id, face.outer_loop + ), + )); + }; + + if outer_loop.is_hole { + return Err(BrepError::new( + BrepErrorKind::InvalidLoop, + format!( + "Face {} outer loop {} marked as hole", + face.id, face.outer_loop + ), + )); + } + + for inner_loop_id in &face.inner_loops { + let Some(inner_loop) = self.loops.get(*inner_loop_id as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidLoop, + format!( + "Face {} references missing inner loop {}", + face.id, inner_loop_id + ), + )); + }; + + if !inner_loop.is_hole { + return Err(BrepError::new( + BrepErrorKind::InvalidLoop, + format!( + "Face {} inner loop {} is not marked as hole", + face.id, inner_loop_id + ), + )); + } + } + + if let Some(shell_id) = face.shell_ref { + let Some(shell) = self.shells.get(shell_id as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidShell, + format!("Face {} references missing shell {}", face.id, shell_id), + )); + }; + + if !shell.faces.contains(&face.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!("Face {} shell linkage is inconsistent", face.id), + )); + } + } + } + + for shell in &self.shells { + if shell.id as usize >= self.shells.len() { + return Err(BrepError::new( + BrepErrorKind::InvalidShell, + format!("Shell {} has invalid id", shell.id), + )); + } + + if shell.faces.is_empty() { + return Err(BrepError::new( + BrepErrorKind::InvalidShell, + format!("Shell {} is empty", shell.id), + )); + } + + let mut shell_edge_faces: HashMap> = HashMap::new(); + for face_id in &shell.faces { + let Some(face) = self.faces.get(*face_id as usize) else { + return Err(BrepError::new( + BrepErrorKind::InvalidFace, + format!("Shell {} references missing face {}", shell.id, face_id), + )); + }; + + if face.shell_ref != Some(shell.id) { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Shell {} face linkage mismatch for face {}", + shell.id, face.id + ), + )); + } + + let mut loop_ids = vec![face.outer_loop]; + loop_ids.extend(face.inner_loops.iter().copied()); + + for loop_id in loop_ids { + for halfedge_id in self.get_loop_halfedges(loop_id)? { + let halfedge = &self.halfedges[halfedge_id as usize]; + shell_edge_faces + .entry(halfedge.edge) + .or_default() + .insert(face.id); + } } } - (face_vertices, holes_vertices) - } else { - // Face not found, return empty vectors - eprintln!("Face with id {} not found", face_id); - (Vec::new(), Vec::new()) + if shell.is_closed { + for (edge_id, faces) in shell_edge_faces { + if faces.len() != 2 { + return Err(BrepError::new( + BrepErrorKind::BrokenTopology, + format!( + "Closed shell {} has boundary edge {} with {} incident faces", + shell.id, + edge_id, + faces.len() + ), + )); + } + } + } } + + Ok(()) } } diff --git a/main/opengeometry/src/brep/shell.rs b/main/opengeometry/src/brep/shell.rs new file mode 100644 index 0000000..d970065 --- /dev/null +++ b/main/opengeometry/src/brep/shell.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Shell { + pub id: u32, + pub faces: Vec, + pub is_closed: bool, +} + +impl Shell { + pub fn new(id: u32, faces: Vec, is_closed: bool) -> Self { + Self { + id, + faces, + is_closed, + } + } +} diff --git a/main/opengeometry/src/brep/vertex.rs b/main/opengeometry/src/brep/vertex.rs index ab91c3f..2ca3151 100644 --- a/main/opengeometry/src/brep/vertex.rs +++ b/main/opengeometry/src/brep/vertex.rs @@ -1,24 +1,19 @@ use openmaths::Vector3; -use serde::{de, Deserialize, Serialize}; -// Reference - https://15362.courses.cs.cmu.edu/spring2025content/lectures/12_rec3/12_rec3_slides.pdf -// Helpers can be added as needed +use serde::{Deserialize, Serialize}; #[derive(Clone, Serialize, Deserialize)] pub struct Vertex { pub id: u32, pub position: Vector3, - // TODO: Add support for halfedges - // pub edges: Vec, - // pub halfedges: Vec, + pub outgoing_halfedge: Option, } impl Vertex { pub fn new(id: u32, position: Vector3) -> Self { - Vertex { + Self { id, position, - // edges: Vec::new(), - // halfedges: Vec::new(), + outgoing_halfedge: None, } } } diff --git a/main/opengeometry/src/brep/wire.rs b/main/opengeometry/src/brep/wire.rs new file mode 100644 index 0000000..e76cc5f --- /dev/null +++ b/main/opengeometry/src/brep/wire.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Wire { + pub id: u32, + pub halfedges: Vec, + pub is_closed: bool, +} + +impl Wire { + pub fn new(id: u32, halfedges: Vec, is_closed: bool) -> Self { + Self { + id, + halfedges, + is_closed, + } + } +} diff --git a/main/opengeometry/src/export/projection.rs b/main/opengeometry/src/export/projection.rs index f4c7719..d158655 100644 --- a/main/opengeometry/src/export/projection.rs +++ b/main/opengeometry/src/export/projection.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use openmaths::Vector3; use serde::{Deserialize, Serialize}; @@ -230,6 +230,13 @@ impl EdgeKey { } } +#[derive(Clone, Copy, Debug)] +struct EdgeCandidate { + id: u32, + a: u32, + b: u32, +} + #[derive(Clone, Copy, Debug)] struct FaceInfo { front_facing: bool, @@ -248,15 +255,15 @@ pub fn project_brep_to_scene(brep: &Brep, camera: &CameraParameters, hlr: &HlrOp let face_info = compute_face_info(brep, &frame); let adjacency = build_edge_adjacency(brep); - let candidates = collect_candidate_edges(brep, &adjacency); + let candidates = collect_candidate_edges(brep); let mut path = Path2D::new(); for edge in candidates { - if !is_edge_vertex_index_valid(edge, brep.vertices.len()) { + if !is_edge_vertex_index_valid(edge.a, edge.b, brep.vertices.len()) { continue; } - if hlr.hide_hidden_edges && !is_edge_visible(edge, &adjacency, &face_info) { + if hlr.hide_hidden_edges && !is_edge_visible(edge.id, &adjacency, &face_info) { continue; } @@ -365,54 +372,76 @@ fn project_view_point(point: ViewPoint, mode: ProjectionMode) -> Option { } } -fn build_edge_adjacency(brep: &Brep) -> HashMap> { - let mut adjacency: HashMap> = HashMap::new(); +fn build_edge_adjacency(brep: &Brep) -> HashMap> { + let face_index_by_id: HashMap = brep + .faces + .iter() + .enumerate() + .map(|(index, face)| (face.id, index)) + .collect(); - for (face_index, face) in brep.faces.iter().enumerate() { - if face.face_indices.len() < 2 { - continue; - } + let mut adjacency: HashMap> = HashMap::new(); - let count = face.face_indices.len(); - for i in 0..count { - let v1 = face.face_indices[i]; - let v2 = face.face_indices[(i + 1) % count]; + for edge in &brep.edges { + let mut adjacent_faces: Vec = Vec::new(); - let Some(edge) = EdgeKey::new(v1, v2) else { + let mut candidate_halfedges = vec![edge.halfedge]; + if let Some(twin_halfedge) = edge.twin_halfedge { + candidate_halfedges.push(twin_halfedge); + } + + for halfedge_id in candidate_halfedges { + let Some(halfedge) = brep.halfedges.get(halfedge_id as usize) else { continue; }; - let faces = adjacency.entry(edge).or_default(); - if !faces.contains(&face_index) { - faces.push(face_index); + if let Some(face_id) = halfedge.face { + if let Some(face_index) = face_index_by_id.get(&face_id) { + adjacent_faces.push(*face_index); + } + } + + if let Some(twin_id) = halfedge.twin { + if let Some(twin) = brep.halfedges.get(twin_id as usize) { + if let Some(face_id) = twin.face { + if let Some(face_index) = face_index_by_id.get(&face_id) { + adjacent_faces.push(*face_index); + } + } + } } } + + adjacent_faces.sort_unstable(); + adjacent_faces.dedup(); + adjacency.insert(edge.id, adjacent_faces); } adjacency } -fn collect_candidate_edges(brep: &Brep, adjacency: &HashMap>) -> Vec { - let mut keys: HashSet = HashSet::new(); - for key in adjacency.keys() { - keys.insert(*key); - } +fn collect_candidate_edges(brep: &Brep) -> Vec { + let mut candidates = Vec::new(); for edge in &brep.edges { - if let Some(key) = EdgeKey::new(edge.v1, edge.v2) { - keys.insert(key); - } - } + let Some((v1, v2)) = brep.get_edge_endpoints(edge.id) else { + continue; + }; - for edge in &brep.hole_edges { - if let Some(key) = EdgeKey::new(edge.v1, edge.v2) { - keys.insert(key); - } + let Some(key) = EdgeKey::new(v1, v2) else { + continue; + }; + + candidates.push(EdgeCandidate { + id: edge.id, + a: key.a, + b: key.b, + }); } - let mut edges: Vec = keys.into_iter().collect(); - edges.sort_by_key(|key| (key.a, key.b)); - edges + candidates.sort_by_key(|edge| (edge.a, edge.b, edge.id)); + candidates.dedup_by_key(|edge| edge.id); + candidates } fn compute_face_info(brep: &Brep, frame: &CameraFrame) -> Vec { @@ -440,13 +469,8 @@ fn compute_face_normal_and_center( brep: &Brep, face: &crate::brep::Face, ) -> Option<([f64; 3], [f64; 3])> { - let mut points: Vec<[f64; 3]> = Vec::new(); - for vertex_id in &face.face_indices { - let idx = *vertex_id as usize; - if let Some(vertex) = brep.vertices.get(idx) { - points.push(vec3_to_arr(&vertex.position)); - } - } + let (face_vertices, _) = brep.get_vertices_and_holes_by_face_id(face.id); + let points: Vec<[f64; 3]> = face_vertices.iter().map(vec3_to_arr).collect(); if points.len() < 3 { return None; @@ -477,21 +501,18 @@ fn compute_face_normal_and_center( } fn is_edge_visible( - edge: EdgeKey, - adjacency: &HashMap>, + edge_id: u32, + adjacency: &HashMap>, face_info: &[FaceInfo], ) -> bool { - let Some(adjacent_faces) = adjacency.get(&edge) else { - return true; - }; - + let adjacent_faces = adjacency.get(&edge_id).cloned().unwrap_or_default(); if adjacent_faces.is_empty() { return true; } let mut front_faces = Vec::new(); for face_index in adjacent_faces { - if let Some(face) = face_info.get(*face_index) { + if let Some(face) = face_info.get(face_index) { if face.front_facing { front_faces.push(*face); } @@ -502,11 +523,7 @@ fn is_edge_visible( return false; } - if front_faces.len() < adjacent_faces.len() { - return true; - } - - if adjacent_faces.len() == 1 { + if front_faces.len() == 1 { return true; } @@ -526,8 +543,8 @@ fn has_crease(front_faces: &[FaceInfo]) -> bool { false } -fn is_edge_vertex_index_valid(edge: EdgeKey, vertex_count: usize) -> bool { - (edge.a as usize) < vertex_count && (edge.b as usize) < vertex_count +fn is_edge_vertex_index_valid(a: u32, b: u32, vertex_count: usize) -> bool { + (a as usize) < vertex_count && (b as usize) < vertex_count } fn is_zero_length_2d(start: Vec2, end: Vec2) -> bool { @@ -584,7 +601,7 @@ fn normalize(v: [f64; 3]) -> Option<[f64; 3]> { #[cfg(test)] mod tests { use super::*; - use crate::brep::{Brep, Edge, Vertex}; + use crate::brep::{Brep, BrepBuilder}; use uuid::Uuid; #[test] @@ -619,12 +636,10 @@ mod tests { #[test] fn test_project_edge_only_brep() { - let mut brep = Brep::new(Uuid::new_v4()); - brep.vertices - .push(Vertex::new(0, Vector3::new(-1.0, 0.0, 0.0))); - brep.vertices - .push(Vertex::new(1, Vector3::new(1.0, 0.0, 0.0))); - brep.edges.push(Edge::new(0, 0, 1)); + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[Vector3::new(-1.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)]); + builder.add_wire(&[0, 1], false).unwrap(); + let brep: Brep = builder.build().unwrap(); let camera = CameraParameters { position: Vector3::new(0.0, 0.0, 5.0), diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs index d33ebb4..4cdbe1e 100644 --- a/main/opengeometry/src/lib.rs +++ b/main/opengeometry/src/lib.rs @@ -13,7 +13,6 @@ pub mod operations { pub mod utility { pub mod bgeometry; - pub mod geometry; } pub mod primitives { @@ -33,12 +32,3 @@ pub mod primitives { pub mod brep; pub mod export; pub mod scenegraph; - -// v0.3.0 -// mod brep_ds { -// mod vertex; -// // mod halfedge; -// mod edge; -// mod face; -// pub mod brep; -// } diff --git a/main/opengeometry/src/operations/extrude.rs b/main/opengeometry/src/operations/extrude.rs index a4d508e..eea5888 100644 --- a/main/opengeometry/src/operations/extrude.rs +++ b/main/opengeometry/src/operations/extrude.rs @@ -1,189 +1,134 @@ -use super::{triangulate, windingsort}; +use super::windingsort; use crate::{ - brep::{edge, Brep, Edge, Face, Vertex}, + brep::{Brep, BrepBuilder}, geometry::basegeometry::BaseGeometry, - utility::geometry::{Geometry, Geometry_Holes}, }; use openmaths::Vector3; +#[derive(Clone)] +pub struct Geometry { + pub vertices: Vec, + pub edges: Vec>, + pub faces: Vec>, +} + pub fn extrude_polygon_by_buffer_geometry(geom_buf: BaseGeometry, height: f64) -> Geometry { - if (geom_buf.get_vertices().len() < 3) { - // return String::from("Polygon should have atleast 3 vertices"); + let base = windingsort::ccw_test(geom_buf.get_vertices()); + if base.len() < 3 { + return Geometry { + vertices: Vec::new(), + edges: Vec::new(), + faces: Vec::new(), + }; } - let ccw_vertices = windingsort::ccw_test(geom_buf.get_vertices()); + let mut vertices = base.clone(); + for point in &base { + vertices.push(Vector3::new(point.x, point.y + height, point.z)); + } + + let mut edges = Vec::new(); + let n = base.len() as u32; - let mut buf_vertices = ccw_vertices.clone(); - let mut buf_edges: Vec> = Vec::new(); - let mut buf_faces: Vec> = Vec::new(); + for i in 0..n { + edges.push(vec![i, (i + 1) % n]); + edges.push(vec![i, i + n]); + edges.push(vec![i + n, ((i + 1) % n) + n]); + } - let current_length = buf_vertices.len(); + let mut faces = Vec::new(); + faces.push((0..n).collect()); - // Bottom Face - for i in 0..current_length { - let edge = { vec![i as u8, ((i + 1) % ccw_vertices.len()) as u8] }; - buf_edges.push(edge); + for i in 0..n { + let next = (i + 1) % n; + faces.push(vec![i, next, next + n, i + n]); } - let mut face: Vec = Vec::new(); - for i in 0..ccw_vertices.len() { - face.push(i as u8); + let mut top_face: Vec = (0..n).map(|i| i + n).collect(); + top_face.reverse(); + faces.push(top_face); + + Geometry { + vertices, + edges, + faces, } - // face.reverse(); - buf_faces.push(face); +} - for index in 0..ccw_vertices.len() { - let up_vertex = Vector3::new(0.0, height, 0.0); - let new_vertex = ccw_vertices[index].clone().add(&up_vertex); - buf_vertices.push(new_vertex); +pub fn extrude_brep_face(brep_face: Brep, height: f64) -> Brep { + let base_points = if let Some(face) = brep_face.faces.first() { + brep_face + .get_vertices_by_face_id(face.id) + .into_iter() + .collect::>() + } else if let Some(wire) = brep_face.wires.first() { + brep_face + .get_wire_vertex_indices(wire.id) + .into_iter() + .filter_map(|vertex_id| brep_face.vertices.get(vertex_id as usize)) + .map(|vertex| vertex.position) + .collect::>() + } else { + brep_face.get_flattened_vertices() + }; - let edge = { vec![index as u8, buf_vertices.len() as u8 - 1] }; + if base_points.len() < 3 { + return Brep::new(brep_face.id); + } - buf_edges.push(edge); + let mut base = windingsort::ccw_test(base_points); + if base.len() < 3 { + return Brep::new(brep_face.id); } - for i in current_length..buf_vertices.len() { - if i < buf_vertices.len() - 1 { - let edge = { vec![i as u8, (i + 1) as u8] }; - buf_edges.push(edge); - } else { - let edge = { vec![i as u8, (current_length) as u8] }; - buf_edges.push(edge); + if let (Some(first), Some(last)) = (base.first().copied(), base.last().copied()) { + let dx = first.x - last.x; + let dy = first.y - last.y; + let dz = first.z - last.z; + if dx * dx + dy * dy + dz * dz <= 1.0e-12 { + base.pop(); } } - // Side Faces - for i in 0..current_length { - let next = (i + 1) % current_length; - let mut face: Vec = vec![ - i as u8, - next as u8, - (next + current_length) as u8, - i as u8 + current_length as u8, - ]; - face.reverse(); - buf_faces.push(face); + if base.len() < 3 { + return Brep::new(brep_face.id); } - // Top Face - let mut face: Vec = Vec::new(); - for i in 0..current_length { - face.push(i as u8 + current_length as u8); - } - face.reverse(); - buf_faces.push(face); + let mut builder = BrepBuilder::new(brep_face.id); - let geometry = Geometry { - vertices: buf_vertices, - edges: buf_edges, - faces: buf_faces, - }; - geometry -} + let mut all_vertices = base.clone(); + all_vertices.extend( + base.iter() + .map(|point| Vector3::new(point.x, point.y + height, point.z)), + ); -/** - * Extrude a BREF face and return a new BREP Object - */ -pub fn extrude_brep_face(brep_face: Brep, height: f64) -> Brep { - let mut extruded_brep = Brep::new(brep_face.id); - // This function will take a BREP face and extrude it to create a new face - // It will return the new face as a Geometry object + builder.add_vertices(&all_vertices); - if (brep_face.get_vertex_count() < 3) { - // return String::from("Polygon should have atleast 3 vertices"); - } + let n = base.len() as u32; - let ccw_vertices = windingsort::ccw_test(brep_face.get_flattened_vertices()); - - // Bottom Face - extruded_brep - .faces - .push(Face::new(extruded_brep.get_face_count() as u32, Vec::new())); - for i in 0..ccw_vertices.len() { - let vertex = ccw_vertices[i].clone(); - extruded_brep - .vertices - .push(Vertex::new(extruded_brep.get_vertex_count() as u32, vertex)); - - let edge = { - vec![ - extruded_brep.get_vertex_count() - 1, - ((extruded_brep.get_vertex_count()) % ccw_vertices.len() as u32), - ] - }; - extruded_brep.edges.push(Edge::new( - extruded_brep.get_edge_count() as u32, - edge[0], - edge[1], - )); - - // Push Vertex Index to the face - extruded_brep.insert_vertex_at_face_by_id(0, extruded_brep.get_vertex_count() as u32 - 1); + // Bottom face should be flipped for outward shell orientation. + let mut bottom: Vec = (0..n).collect(); + bottom.reverse(); + if builder.add_face(&bottom, &[]).is_err() { + return Brep::new(brep_face.id); } - // Create Top Face - Index will be 1 - extruded_brep - .faces - .push(Face::new(extruded_brep.get_face_count() as u32, Vec::new())); - - // Create Top Vertices - let current_length = extruded_brep.get_vertex_count(); - for index in 0..ccw_vertices.len() { - // TODO: Find a way to extrude in give direction - let up_vertex = Vector3::new(0.0, height, 0.0); - let new_vertex = ccw_vertices[index].clone().add(&up_vertex); - extruded_brep.vertices.push(Vertex::new( - extruded_brep.get_vertex_count() as u32, - new_vertex, - )); - - let edge = { - vec![ - index as usize + current_length as usize, - ((index + 1) % ccw_vertices.len()) as usize + current_length as usize, - ] - }; - - // Create Edge for Top Face - extruded_brep.edges.push(Edge::new( - extruded_brep.get_edge_count(), - edge[0] as u32, - edge[1] as u32, - )); - - // Push Vertex Index to the face - extruded_brep.insert_vertex_at_face_by_id(1, extruded_brep.get_vertex_count() as u32 - 1); + let top: Vec = (0..n).map(|i| i + n).collect(); + if builder.add_face(&top, &[]).is_err() { + return Brep::new(brep_face.id); } - // Side Faces, since vertices are already added we can just create edges and faces - for i in 0..current_length { - let next = (i + 1) % current_length; - let mut face: Vec = vec![ - i as u32, - next as u32, - (next as u32 + current_length as u32), - i as u32 + current_length as u32, - ]; - face.reverse(); - extruded_brep.faces.push(Face::new( - extruded_brep.get_face_count() as u32, - face.clone(), - )); - - // Create Edges for Side Faces - for j in 0..face.len() { - let edge = { vec![face[j], face[(j + 1) % face.len()]] }; - extruded_brep.edges.push(Edge::new( - extruded_brep.get_edge_count() as u32, - edge[0], - edge[1], - )); + for i in 0..n { + let next = (i + 1) % n; + let side = vec![i, next, next + n, i + n]; + if builder.add_face(&side, &[]).is_err() { + return Brep::new(brep_face.id); } } - // Reverse the vertices for the top face - since top face is at 1st index - // TODO: Find if this has any effect on the geometry - extruded_brep.faces[1].face_indices.reverse(); + if builder.add_shell_from_all_faces(true).is_err() { + return Brep::new(brep_face.id); + } - extruded_brep + builder.build().unwrap_or_else(|_| Brep::new(brep_face.id)) } diff --git a/main/opengeometry/src/operations/sweep.rs b/main/opengeometry/src/operations/sweep.rs index f07c420..68f29f8 100644 --- a/main/opengeometry/src/operations/sweep.rs +++ b/main/opengeometry/src/operations/sweep.rs @@ -1,4 +1,4 @@ -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use openmaths::Vector3; use uuid::Uuid; @@ -103,22 +103,23 @@ pub fn sweep_profile_along_path( let (clean_path, path_closed) = sanitize_path(path_points); let clean_profile = sanitize_profile(profile_points); - let mut brep = Brep::new(Uuid::new_v4()); - if clean_path.len() < 2 || clean_profile.len() < 3 { - return brep; + return Brep::new(Uuid::new_v4()); } let frames = build_path_frames(&clean_path, path_closed); if frames.len() != clean_path.len() { - return brep; + return Brep::new(Uuid::new_v4()); } let local_profile = build_local_profile(&clean_profile); if local_profile.len() != clean_profile.len() { - return brep; + return Brep::new(Uuid::new_v4()); } + let mut vertices = Vec::new(); + let mut faces: Vec> = Vec::new(); + let section_count = clean_path.len(); let ring_size = local_profile.len(); @@ -132,10 +133,7 @@ pub fn sweep_profile_along_path( .add(frame.binormal.scale(local.v)) .add(frame.tangent.scale(local.w)); - brep.vertices.push(Vertex::new( - brep.get_vertex_count(), - world_point.to_vector3(), - )); + vertices.push(world_point.to_vector3()); } } @@ -156,7 +154,7 @@ pub fn sweep_profile_along_path( let c = (next_section * ring_size + next_profile) as u32; let d = (next_section * ring_size + profile_index) as u32; - add_face_with_edges(&mut brep, vec![a, b, c, d]); + faces.push(vec![a, b, c, d]); } } @@ -164,7 +162,7 @@ pub fn sweep_profile_along_path( if options.cap_start { let mut start_face: Vec = (0..ring_size as u32).collect(); start_face.reverse(); - add_face_with_edges(&mut brep, start_face); + faces.push(start_face); } if options.cap_end { @@ -172,11 +170,29 @@ pub fn sweep_profile_along_path( let end_face: Vec = (0..ring_size as u32) .map(|index| end_start + index) .collect(); - add_face_with_edges(&mut brep, end_face); + faces.push(end_face); + } + } + + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&vertices); + + for face in &faces { + if builder.add_face(face, &[]).is_err() { + return Brep::new(Uuid::new_v4()); } } - brep + if !faces.is_empty() { + let shell_closed = path_closed || (options.cap_start && options.cap_end); + if builder.add_shell_from_all_faces(shell_closed).is_err() { + return Brep::new(Uuid::new_v4()); + } + } + + builder + .build() + .unwrap_or_else(|_| Brep::new(Uuid::new_v4())) } fn sanitize_path(path: &[Vector3]) -> (Vec, bool) { @@ -414,22 +430,6 @@ fn rotate_around_axis(vector: Vec3f, axis: Vec3f, angle: f64) -> Vec3f { .add(axis.scale(axis.dot(vector) * (1.0 - cos_theta))) } -fn add_face_with_edges(brep: &mut Brep, face_indices: Vec) { - if face_indices.len() < 3 { - return; - } - - let face_id = brep.get_face_count(); - brep.faces.push(Face::new(face_id, face_indices.clone())); - - for index in 0..face_indices.len() { - let v1 = face_indices[index]; - let v2 = face_indices[(index + 1) % face_indices.len()]; - let edge_id = brep.get_edge_count(); - brep.edges.push(Edge::new(edge_id, v1, v2)); - } -} - #[cfg(test)] mod tests { use super::{sweep_profile_along_path, SweepOptions}; diff --git a/main/opengeometry/src/primitives/arc.rs b/main/opengeometry/src/primitives/arc.rs index 9e92a40..11073a7 100644 --- a/main/opengeometry/src/primitives/arc.rs +++ b/main/opengeometry/src/primitives/arc.rs @@ -1,16 +1,7 @@ use serde::{Deserialize, Serialize}; -/** - * Copyright (c) 2025, OpenGeometry. All rights reserved. - * Arc Primitive for OpenGeometry. - * - * An Arc is a segment of a circle defined by a center, radius, start angle, end angle, and number of segments. - * It can be used to create circular arcs in 3D space. - * Created with a center, radius, start angle, end angle, and number of segments. - **/ -// TODO: What if we create the Circle using the Formula for Angles. use wasm_bindgen::prelude::*; -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use openmaths::Vector3; use uuid::Uuid; @@ -62,80 +53,72 @@ impl OGArc { start_angle: f64, end_angle: f64, segments: u32, - ) { + ) -> Result<(), JsValue> { self.center = center; self.radius = radius; self.start_angle = start_angle; self.end_angle = end_angle; - self.segments = segments; + self.segments = segments.max(1); + Ok(()) } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - self.dispose_points(); - + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { let segment_count = self.segments.max(1); - let mut angle = self.start_angle; - let angle_diff = (self.end_angle - self.start_angle) / segment_count as f64; + let angle_step = (self.end_angle - self.start_angle) / segment_count as f64; - for _ in 0..segment_count + 1 { + let mut points = Vec::with_capacity((segment_count + 1) as usize); + let mut angle = self.start_angle; + for _ in 0..=segment_count { let x = self.center.x + self.radius * angle.cos(); let y = self.center.y; let z = self.center.z + self.radius * angle.sin(); - self.brep.vertices.push(Vertex::new( - self.brep.get_vertex_count() as u32, - Vector3::new(x, y, z), - )); - angle += angle_diff; + points.push(Vector3::new(x, y, z)); + angle += angle_step; } let is_closed = (self.end_angle - self.start_angle).abs() >= 2.0 * std::f64::consts::PI - 1.0e-9; - let mut edge_vertex_count = self.brep.vertices.len(); - if is_closed && edge_vertex_count > 2 { - let first = self.brep.vertices[0].position; - let last = self.brep.vertices[edge_vertex_count - 1].position; + if is_closed && points.len() > 2 { + let first = points[0]; + let last = *points.last().unwrap(); let dx = first.x - last.x; let dy = first.y - last.y; let dz = first.z - last.z; - let duplicate_end = dx * dx + dy * dy + dz * dz <= 1.0e-12; - if duplicate_end { - edge_vertex_count -= 1; + if dx * dx + dy * dy + dz * dz <= 1.0e-12 { + points.pop(); } } - if edge_vertex_count < 2 { - return; - } + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&points); + + if points.len() >= 2 { + let indices: Vec = (0..points.len() as u32).collect(); + builder + .add_wire(&indices, is_closed && points.len() > 2) + .map_err(|err| JsValue::from_str(&format!("Failed to build arc wire: {}", err)))?; - for i in 0..(edge_vertex_count - 1) { - self.brep.edges.push(Edge::new( - self.brep.get_edge_count(), - i as u32, - (i + 1) as u32, - )); + if is_closed && points.len() > 2 { + builder.add_face(&indices, &[]).map_err(|err| { + JsValue::from_str(&format!("Failed to build arc face: {}", err)) + })?; + } } - if is_closed && edge_vertex_count > 2 { - self.brep.edges.push(Edge::new( - self.brep.get_edge_count(), - (edge_vertex_count - 1) as u32, - 0, - )); + self.brep = builder + .build() + .map_err(|err| JsValue::from_str(&format!("Failed to finalize arc BREP: {}", err)))?; - let face_indices: Vec = (0..edge_vertex_count as u32).collect(); - self.brep.faces.push(Face::new(0, face_indices)); - } + Ok(()) } - // Dispose #[wasm_bindgen] pub fn dispose_points(&mut self) { self.brep.clear(); } - // Destroy and Free memory #[wasm_bindgen] pub fn destroy(&mut self) { self.brep.clear(); @@ -144,25 +127,31 @@ impl OGArc { #[wasm_bindgen] pub fn get_brep_serialized(&self) -> String { - let serialized = serde_json::to_string(&self.brep).unwrap(); - serialized + serde_json::to_string(&self.brep).unwrap() } - // TODO: For Line based primitives we are iterating just vertices - // Figure out if it's benefical to create a edges and faces for Arc as well - Technically it's not needed #[wasm_bindgen] - pub fn get_geometry_serialized(&mut self) -> String { + pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let vertices = self.brep.vertices.clone(); - for vertex in vertices { - vertex_buffer.push(vertex.position.x); - vertex_buffer.push(vertex.position.y); - vertex_buffer.push(vertex.position.z); + if let Some(wire) = self.brep.wires.first() { + let mut wire_vertices = self.brep.get_wire_vertex_indices(wire.id); + if wire.is_closed { + if let Some(first) = wire_vertices.first().copied() { + wire_vertices.push(first); + } + } + + for vertex_id in wire_vertices { + if let Some(vertex) = self.brep.vertices.get(vertex_id as usize) { + vertex_buffer.push(vertex.position.x); + vertex_buffer.push(vertex.position.y); + vertex_buffer.push(vertex.position.z); + } + } } - let vertex_serialized = serde_json::to_string(&vertex_buffer).unwrap(); - vertex_serialized + serde_json::to_string(&vertex_buffer).unwrap() } } diff --git a/main/opengeometry/src/primitives/cuboid.rs b/main/opengeometry/src/primitives/cuboid.rs index ff24d2b..d97568c 100644 --- a/main/opengeometry/src/primitives/cuboid.rs +++ b/main/opengeometry/src/primitives/cuboid.rs @@ -1,21 +1,8 @@ use serde::{Deserialize, Serialize}; -/** - * Copyright (c) 2025, OpenGeometry. All rights reserved. - * Box primitive for OpenGeometry. - * - * Base created by default on XZ plane and extruded along Y axis. - * - * There are two ways to create a box: - * 1. By creating a box with a rectangle face, create a Rectangle Poly Face and then extrude by a given height - * 2. By creating a box primitive with given width, height, and depth - * - * This class is used to create a box primitive(2) using width, height, and depth. - */ use wasm_bindgen::prelude::*; -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; -use crate::operations::extrude::extrude_brep_face; use crate::operations::triangulate::triangulate_polygon_with_holes; use openmaths::Vector3; use uuid::Uuid; @@ -48,7 +35,7 @@ impl OGCuboid { let internal_id = Uuid::new_v4(); OGCuboid { - id: id.clone(), + id, center: Vector3::new(0.0, 0.0, 0.0), width: 1.0, height: 1.0, @@ -58,18 +45,23 @@ impl OGCuboid { } #[wasm_bindgen] - pub fn set_config(&mut self, center: Vector3, width: f64, height: f64, depth: f64) { + pub fn set_config( + &mut self, + center: Vector3, + width: f64, + height: f64, + depth: f64, + ) -> Result<(), JsValue> { self.center = center; self.width = width; self.height = height; self.depth = depth; - - self.generate_brep(); + self.generate_brep() } - pub fn generate_brep(&mut self) { + pub fn generate_brep(&mut self) -> Result<(), JsValue> { self.clean_geometry(); - self.generate_geometry(); + self.generate_geometry() } pub fn clean_geometry(&mut self) { @@ -77,88 +69,75 @@ impl OGCuboid { } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - let half_width = self.width / 2.0; - let half_height = self.height / 2.0; - let half_depth = self.depth / 2.0; - - let mut bottom_face_brep = Brep::new(Uuid::new_v4()); - bottom_face_brep.vertices.push(Vertex::new( - 0, - Vector3::new( - self.center.x - half_width, - self.center.y - half_height, - self.center.z - half_depth, - ), - )); - bottom_face_brep.vertices.push(Vertex::new( - 1, - Vector3::new( - self.center.x + half_width, - self.center.y - half_height, - self.center.z - half_depth, - ), - )); - bottom_face_brep.vertices.push(Vertex::new( - 2, - Vector3::new( - self.center.x + half_width, - self.center.y - half_height, - self.center.z + half_depth, - ), - )); - bottom_face_brep.vertices.push(Vertex::new( - 3, - Vector3::new( - self.center.x - half_width, - self.center.y - half_height, - self.center.z + half_depth, - ), - )); - - bottom_face_brep.edges.push(Edge::new(0, 0, 1)); - bottom_face_brep.edges.push(Edge::new(1, 1, 2)); - bottom_face_brep.edges.push(Edge::new(2, 2, 3)); - bottom_face_brep.edges.push(Edge::new(3, 3, 0)); - - bottom_face_brep.faces.push(Face::new(0, vec![0, 1, 2, 3])); - - // Extrude the bottom face to create the box - let extruded_brep = extrude_brep_face(bottom_face_brep, self.height); - self.brep = extruded_brep.clone(); + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { + let hw = self.width / 2.0; + let hh = self.height / 2.0; + let hd = self.depth / 2.0; + + let vertices = vec![ + Vector3::new(self.center.x - hw, self.center.y - hh, self.center.z - hd), + Vector3::new(self.center.x + hw, self.center.y - hh, self.center.z - hd), + Vector3::new(self.center.x + hw, self.center.y - hh, self.center.z + hd), + Vector3::new(self.center.x - hw, self.center.y - hh, self.center.z + hd), + Vector3::new(self.center.x - hw, self.center.y + hh, self.center.z - hd), + Vector3::new(self.center.x + hw, self.center.y + hh, self.center.z - hd), + Vector3::new(self.center.x + hw, self.center.y + hh, self.center.z + hd), + Vector3::new(self.center.x - hw, self.center.y + hh, self.center.z + hd), + ]; + + let faces: Vec> = vec![ + vec![0, 1, 2, 3], + vec![4, 7, 6, 5], + vec![0, 4, 5, 1], + vec![3, 2, 6, 7], + vec![0, 3, 7, 4], + vec![1, 5, 6, 2], + ]; + + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&vertices); + + for face in &faces { + builder.add_face(face, &[]).map_err(|err| { + JsValue::from_str(&format!("Failed to build cuboid face: {}", err)) + })?; + } + + builder + .add_shell_from_all_faces(true) + .map_err(|err| JsValue::from_str(&format!("Failed to build cuboid shell: {}", err)))?; + + self.brep = builder.build().map_err(|err| { + JsValue::from_str(&format!("Failed to finalize cuboid BREP: {}", err)) + })?; + + Ok(()) } #[wasm_bindgen] pub fn get_brep_serialized(&self) -> String { - let serialized = serde_json::to_string(&self.brep).unwrap(); - serialized + serde_json::to_string(&self.brep).unwrap() } #[wasm_bindgen] pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let faces = self.brep.faces.clone(); - for face in &faces { + for face in &self.brep.faces { let (face_vertices, holes_vertices) = self.brep.get_vertices_and_holes_by_face_id(face.id); - if face_vertices.len() < 3 { continue; } let triangles = triangulate_polygon_with_holes(&face_vertices, &holes_vertices); - - // Combine outer and hole vertices into a single list for easy lookup let all_vertices: Vec = face_vertices .into_iter() .chain(holes_vertices.into_iter().flatten()) .collect(); - // Build the final vertex buffer for rendering for triangle in triangles { for vertex_index in triangle { - // The indices from earcutr correspond to our combined `all_vertices` list let vertex = &all_vertices[vertex_index]; vertex_buffer.push(vertex.x); vertex_buffer.push(vertex.y); @@ -171,16 +150,16 @@ impl OGCuboid { } #[wasm_bindgen] - pub fn get_outline_geometry_serialized(&mut self) -> String { + pub fn get_outline_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let edges = self.brep.edges.clone(); - for edge in edges { - let start_index = edge.v1 as usize; - let end_index = edge.v2 as usize; - - let start_vertex = self.brep.vertices[start_index].clone(); - let end_vertex = self.brep.vertices[end_index].clone(); + for (start_id, end_id) in self.brep.collect_outline_segments() { + let Some(start_vertex) = self.brep.vertices.get(start_id as usize) else { + continue; + }; + let Some(end_vertex) = self.brep.vertices.get(end_id as usize) else { + continue; + }; vertex_buffer.push(start_vertex.position.x); vertex_buffer.push(start_vertex.position.y); @@ -191,26 +170,7 @@ impl OGCuboid { vertex_buffer.push(end_vertex.position.z); } - if self.brep.hole_edges.len() > 0 { - for edge in self.brep.hole_edges.clone() { - let start_index = edge.v1 as usize; - let end_index = edge.v2 as usize; - - let start_vertex = self.brep.vertices[start_index].clone(); - let end_vertex = self.brep.vertices[end_index].clone(); - - vertex_buffer.push(start_vertex.position.x); - vertex_buffer.push(start_vertex.position.y); - vertex_buffer.push(start_vertex.position.z); - - vertex_buffer.push(end_vertex.position.x); - vertex_buffer.push(end_vertex.position.y); - vertex_buffer.push(end_vertex.position.z); - } - } - - let vertex_serialized = serde_json::to_string(&vertex_buffer).unwrap(); - vertex_serialized + serde_json::to_string(&vertex_buffer).unwrap() } } diff --git a/main/opengeometry/src/primitives/curve.rs b/main/opengeometry/src/primitives/curve.rs index 1be8c11..95660df 100644 --- a/main/opengeometry/src/primitives/curve.rs +++ b/main/opengeometry/src/primitives/curve.rs @@ -1,10 +1,8 @@ /** * Copyright (c) 2025, OpenGeometry. All rights reserved. * Curve Primitive for OpenGeometry. - * - * A Curve is represented as a polyline through control points for now. - **/ -use crate::brep::{Brep, Edge, Vertex}; + */ +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use crate::operations::offset::{offset_path, OffsetOptions, OffsetResult}; use openmaths::Vector3; @@ -42,36 +40,33 @@ impl OGCurve { } #[wasm_bindgen] - pub fn set_config(&mut self, control_points: Vec) { + pub fn set_config(&mut self, control_points: Vec) -> Result<(), JsValue> { self.control_points = control_points; - self.generate_geometry(); + self.generate_geometry() } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - self.brep.clear(); - + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { if self.control_points.is_empty() { - return; + self.brep.clear(); + return Ok(()); } - for point in &self.control_points { - self.brep - .vertices - .push(Vertex::new(self.brep.get_vertex_count(), *point)); - } + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&self.control_points); - if self.control_points.len() < 2 { - return; + if self.control_points.len() >= 2 { + let indices: Vec = (0..self.control_points.len() as u32).collect(); + builder.add_wire(&indices, false).map_err(|err| { + JsValue::from_str(&format!("Failed to build curve wire: {}", err)) + })?; } - for i in 0..(self.control_points.len() - 1) { - self.brep.edges.push(Edge::new( - self.brep.get_edge_count(), - i as u32, - (i + 1) as u32, - )); - } + self.brep = builder + .build() + .map_err(|err| JsValue::from_str(&format!("Failed to finalize curve BREP: {}", err)))?; + + Ok(()) } #[wasm_bindgen] @@ -83,10 +78,20 @@ impl OGCurve { pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - for vertex in &self.brep.vertices { - vertex_buffer.push(vertex.position.x); - vertex_buffer.push(vertex.position.y); - vertex_buffer.push(vertex.position.z); + if let Some(wire) = self.brep.wires.first() { + for vertex_id in self.brep.get_wire_vertex_indices(wire.id) { + if let Some(vertex) = self.brep.vertices.get(vertex_id as usize) { + vertex_buffer.push(vertex.position.x); + vertex_buffer.push(vertex.position.y); + vertex_buffer.push(vertex.position.z); + } + } + } else { + for vertex in &self.brep.vertices { + vertex_buffer.push(vertex.position.x); + vertex_buffer.push(vertex.position.y); + vertex_buffer.push(vertex.position.z); + } } serde_json::to_string(&vertex_buffer).unwrap() diff --git a/main/opengeometry/src/primitives/cylinder.rs b/main/opengeometry/src/primitives/cylinder.rs index 5a9d589..24b361b 100644 --- a/main/opengeometry/src/primitives/cylinder.rs +++ b/main/opengeometry/src/primitives/cylinder.rs @@ -1,19 +1,7 @@ use serde::{Deserialize, Serialize}; -/** - * Copyright (c) 2025, OpenGeometry. All rights reserved. - * Cylinder Primitive for OpenGeometry. - * - * Base created by default on XZ plane and etruded along Y axis. - * - * There are two ways to create a cylinder: - * 1. By creating a cylinder with a circle arc, create a Circle Poly Face and then extrude by a given height - * 2. By creating a cylinder primitive with a given radius and height - * - * This class is used to create a cylinder primitive(2) using radius and height. - **/ use wasm_bindgen::prelude::*; -use crate::brep::{Brep, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use crate::operations::extrude::extrude_brep_face; use crate::operations::triangulate::triangulate_polygon_with_holes; @@ -49,7 +37,7 @@ impl OGCylinder { let internal_id = Uuid::new_v4(); OGCylinder { - id: id.clone(), + id, center: Vector3::new(0.0, 0.0, 0.0), radius: 1.0, height: 1.0, @@ -67,19 +55,19 @@ impl OGCylinder { height: f64, angle: f64, segments: u32, - ) { + ) -> Result<(), JsValue> { self.center = center; - self.radius = radius; + self.radius = radius.max(1.0e-6); self.height = height; self.angle = angle; - self.segments = segments; + self.segments = segments.max(3); - self.generate_brep(); + self.generate_brep() } - pub fn generate_brep(&mut self) { + pub fn generate_brep(&mut self) -> Result<(), JsValue> { self.clean_geometry(); - self.generate_geometry(); + self.generate_geometry() } pub fn clean_geometry(&mut self) { @@ -87,84 +75,93 @@ impl OGCylinder { } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { let half_height = self.height / 2.0; - let mut segment_count: u32 = self.segments; - - // Create Bottom BREP Circle - // A good idea create BREP library for corresponding Primitives, e.g. like Below - // let bottom_circle_brep = Brep::new_circle( - // self.center.x, - // self.center.y - half_height, - // self.center.z, - // self.radius, - // segment_count, - // 0.0, - // 2.0 * std::f64::consts::PI - // ); - let mut bottom_circle_brep = Brep::new(Uuid::new_v4()); - - // If the end angle makes a full circle then we don't need to add a center point - if self.angle < 2.0 * std::f64::consts::PI { - // TODO: Not sure if I should push edges and faces when creating temporary BREP - bottom_circle_brep.vertices.push(Vertex::new( - bottom_circle_brep.get_vertex_count() as u32, - Vector3::new(self.center.x, self.center.y - half_height, self.center.z), + let mut base_points = Vec::new(); + let full_circle = self.angle >= 2.0 * std::f64::consts::PI - 1.0e-9; + + if !full_circle { + base_points.push(Vector3::new( + self.center.x, + self.center.y - half_height, + self.center.z, )); - segment_count += 1; } - let mut start_angle: f64 = 0.0; - let angle_step = self.angle / self.segments as f64; - for _ in 0..segment_count { - let x = self.center.x + self.radius * start_angle.cos(); + let segment_count = self.segments.max(3); + let steps = if full_circle { + segment_count + } else { + segment_count + 1 + }; + let angle_step = if full_circle { + self.angle / segment_count as f64 + } else { + self.angle / (steps - 1) as f64 + }; + + let mut angle: f64 = 0.0; + for _ in 0..steps { + let x = self.center.x + self.radius * angle.cos(); let y = self.center.y - half_height; - let z = self.center.z + self.radius * start_angle.sin(); + let z = self.center.z + self.radius * angle.sin(); + base_points.push(Vector3::new(x, y, z)); + angle += angle_step; + } - bottom_circle_brep.vertices.push(Vertex::new( - bottom_circle_brep.get_vertex_count() as u32, - Vector3::new(x, y, z), - )); - start_angle += angle_step; + if full_circle && base_points.len() > 3 { + let first = base_points[0]; + let last = *base_points.last().unwrap(); + let dx = first.x - last.x; + let dy = first.y - last.y; + let dz = first.z - last.z; + if dx * dx + dy * dy + dz * dz <= 1.0e-12 { + base_points.pop(); + } } - // Extrude the points to create the top circle - let brep_data = extrude_brep_face(bottom_circle_brep, self.height); - self.brep = brep_data.clone(); + let mut base_builder = BrepBuilder::new(Uuid::new_v4()); + base_builder.add_vertices(&base_points); + let base_loop_indices: Vec = (0..base_points.len() as u32).collect(); + base_builder + .add_face(&base_loop_indices, &[]) + .map_err(|err| { + JsValue::from_str(&format!("Failed to build cylinder base face: {}", err)) + })?; + + let base_brep = base_builder.build().map_err(|err| { + JsValue::from_str(&format!("Failed to finalize cylinder base: {}", err)) + })?; + + self.brep = extrude_brep_face(base_brep, self.height); + + Ok(()) } #[wasm_bindgen] pub fn get_brep_serialized(&self) -> String { - // Serialize the BREP geometry to JSON - let serialized = serde_json::to_string(&self.brep).unwrap(); - serialized + serde_json::to_string(&self.brep).unwrap() } #[wasm_bindgen] - pub fn get_geometry_serialized(&mut self) -> String { + pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let faces = self.brep.faces.clone(); - for face in &faces { + for face in &self.brep.faces { let (face_vertices, holes_vertices) = self.brep.get_vertices_and_holes_by_face_id(face.id); - if face_vertices.len() < 3 { continue; } let triangles = triangulate_polygon_with_holes(&face_vertices, &holes_vertices); - - // Combine outer and hole vertices into a single list for easy lookup let all_vertices: Vec = face_vertices .into_iter() .chain(holes_vertices.into_iter().flatten()) .collect(); - // Build the final vertex buffer for rendering for triangle in triangles { for vertex_index in triangle { - // The indices from earcutr correspond to our combined `all_vertices` list let vertex = &all_vertices[vertex_index]; vertex_buffer.push(vertex.x); vertex_buffer.push(vertex.y); @@ -177,16 +174,16 @@ impl OGCylinder { } #[wasm_bindgen] - pub fn get_outline_geometry_serialized(&mut self) -> String { + pub fn get_outline_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let edges = self.brep.edges.clone(); - for edge in edges { - let start_index = edge.v1 as usize; - let end_index = edge.v2 as usize; - - let start_vertex = self.brep.vertices[start_index].clone(); - let end_vertex = self.brep.vertices[end_index].clone(); + for (start_id, end_id) in self.brep.collect_outline_segments() { + let Some(start_vertex) = self.brep.vertices.get(start_id as usize) else { + continue; + }; + let Some(end_vertex) = self.brep.vertices.get(end_id as usize) else { + continue; + }; vertex_buffer.push(start_vertex.position.x); vertex_buffer.push(start_vertex.position.y); @@ -197,8 +194,7 @@ impl OGCylinder { vertex_buffer.push(end_vertex.position.z); } - let vertex_serialized = serde_json::to_string(&vertex_buffer).unwrap(); - vertex_serialized + serde_json::to_string(&vertex_buffer).unwrap() } } diff --git a/main/opengeometry/src/primitives/cylinderOld.rs b/main/opengeometry/src/primitives/cylinderOld.rs deleted file mode 100644 index 3f9aff2..0000000 --- a/main/opengeometry/src/primitives/cylinderOld.rs +++ /dev/null @@ -1,204 +0,0 @@ -// use core::str; -// use std::clone; - -// use crate::operations::extrude::{self, extrude_polygon_by_buffer_geometry}; -// use crate::operations::triangulate::triangulate_polygon_by_face; -// use crate::operations::windingsort; -// use crate::utility::geometry::{Geometry}; -// use openmaths::Vector3; - -// /** -// * Copyright (c) 2025, OpenGeometry. All rights reserved. -// * Cylinder primitive for OpenGeometry. -// * -// * Base created by default on XZ plane and etruded along Y axis. -// * -// * There are two ways to create a cylinder: -// * 1. By creating a cylinder with a circle arc, create a Circle Poly Face and then extrude by a given height -// * 2. By creating a cylinder primitive with a given radius and height -// * -// * This class is used to create a cylinder primitive(2) using radius and height. -// * */ -// use crate::geometry::basegeometry; -// use wasm_bindgen::prelude::*; -// use serde::{Serialize, Deserialize}; - -// #[wasm_bindgen] -// #[derive(Clone, Serialize, Deserialize)] -// pub struct OGCylinderOld { -// id: String, -// center: Vector3, -// radius: f64, -// height: f64, -// angle: f64, -// segments: u32, -// geometry: basegeometry::BaseGeometry, -// buffer: Vec, -// brep: Geometry, -// } - -// #[wasm_bindgen] -// impl OGCylinderOld { -// #[wasm_bindgen(setter)] -// pub fn set_id(&mut self, id: String) { -// self.id = id; -// } - -// #[wasm_bindgen(getter)] -// pub fn id(&self) -> String { -// self.id.clone() -// } - -// #[wasm_bindgen(constructor)] -// pub fn new(id: String) -> OGCylinderOld { -// OGCylinderOld { -// id: id.clone(), -// center: Vector3::new(0.0, 0.0, 0.0), -// radius: 1.0, -// height: 1.0, -// angle: 2.0 * std::f64::consts::PI, -// segments: 32, -// geometry: basegeometry::BaseGeometry::new(id.clone()), -// buffer: Vec::new(), -// brep: Geometry::new(), -// } -// } - -// #[wasm_bindgen] -// pub fn set_config(&mut self, center: Vector3, radius: f64, height: f64, angle: f64, segments: u32) { -// self.center = center; -// self.radius = radius; -// self.height = height; -// self.angle = angle; -// self.segments = segments; -// } - -// #[wasm_bindgen] -// pub fn generate_geometry(&mut self) { -// let mut points: Vec = Vec::new(); -// let mut normals: Vec = Vec::new(); -// // let mut uvs: Vec = Vec::new(); -// let mut indices: Vec = Vec::new(); - - -// let half_height = self.height / 2.0; -// let mut actual_segments: u32 = self.segments; - -// // If the end angle makes a full circle then we don't need to add a center point -// if self.angle < 2.0 * std::f64::consts::PI { -// // Add center point -// points.push(Vector3::new(self.center.x, self.center.y - half_height, self.center.z)); -// actual_segments += 1; -// } - -// let mut start_angle: f64 = 0.0; -// let angle_step = self.angle / self.segments as f64; -// for _ in 0..actual_segments { -// let x = self.center.x + self.radius * start_angle.cos(); -// let y = self.center.y - half_height; -// let z = self.center.z + self.radius * start_angle.sin(); -// points.push(Vector3::new(x, y, z)); - -// // // Indices for the top circle -// // if i < self.segments - 1 { -// // indices.push(i); -// // indices.push(i + 1); -// // indices.push(self.segments); -// // } else { -// // indices.push(i); -// // indices.push(0); -// // indices.push(self.segments); -// // } - -// start_angle += angle_step; -// } - -// // Side Faces Indices - -// // let ccw_points = windingsort::ccw_test(points.clone()); -// // self.geometry.add_vertices(ccw_points.clone()); -// let mut clonedpoints = points.clone(); -// clonedpoints.reverse(); -// self.geometry.add_vertices(clonedpoints); -// self.geometry.add_indices(indices); -// } - -// fn generate_brep(&mut self) -> Geometry { -// let extrude_data = extrude_polygon_by_buffer_geometry(self.geometry.clone(), self.height); -// self.brep = extrude_data.clone(); -// extrude_data -// } - -// #[wasm_bindgen] -// pub fn get_geometry(&mut self) -> String { -// let geometry = self.geometry.get_geometry(); -// // geometry -// let extrude_data = self.generate_brep(); - -// let mut local_geometry = Vec::new(); - -// // let face = extrude_data.faces[0].clone(); -// for face in extrude_data.faces.clone() { -// let mut face_vertices: Vec = Vec::new(); -// for index in face.clone() { -// face_vertices.push(extrude_data.vertices[index as usize].clone()); -// } - -// let triangulated_face = triangulate_polygon_by_face(face_vertices.clone()); -// // let ccw_vertices = windingsort::ccw_test(face_vertices.clone()); -// for index in triangulated_face { -// for i in index { -// let vertex = face_vertices[i as usize].clone(); -// // let vertex = ccw_vertices[i as usize]; -// local_geometry.push(vertex.x); -// local_geometry.push(vertex.y); -// local_geometry.push(vertex.z); -// } -// } -// } - -// // let face_data_string = serde_json::to_string(&face).unwrap(); // Serialize face_data -// // face_data_string - -// // let extrude_data_string = serde_json::to_string(&extrude_data).unwrap(); // Serialize extrude_data -// // extrude_data_string - -// let string_data = serde_json::to_string(&local_geometry).unwrap(); -// string_data -// } - -// #[wasm_bindgen] -// pub fn discard_geometry(&mut self) { -// // self.geometry.discard_geometry(); -// } - -// #[wasm_bindgen] -// pub fn outline_edges(&mut self) -> String { -// let mut outline_points: Vec = Vec::new(); - -// for edge in self.brep.edges.clone() { -// let start_index = edge[0] as usize; -// let end_index = edge[1] as usize; - -// let start_point = self.brep.vertices[start_index].clone(); -// let end_point = self.brep.vertices[end_index].clone(); - -// outline_points.push(start_point.x); -// outline_points.push(start_point.y); -// outline_points.push(start_point.z); - -// outline_points.push(end_point.x); -// outline_points.push(end_point.y); -// outline_points.push(end_point.z); -// } - -// let outline_data_string = serde_json::to_string(&outline_points).unwrap(); -// outline_data_string -// } - -// #[wasm_bindgen] -// pub fn get_brep_dump(&mut self) -> String { -// let brep_data_string = serde_json::to_string(&self.brep).unwrap(); -// brep_data_string -// } -// } \ No newline at end of file diff --git a/main/opengeometry/src/primitives/line.rs b/main/opengeometry/src/primitives/line.rs index 7d6b9e3..848a605 100644 --- a/main/opengeometry/src/primitives/line.rs +++ b/main/opengeometry/src/primitives/line.rs @@ -1,16 +1,10 @@ /** * Copyright (c) 2025, OpenGeometry. All rights reserved. * Line Primitive for OpenGeometry. - * - * A Line is defined by two points. - * This line would only have two points, else it becomes a polyline. - * Created with two arbitrary points, start and end. */ -use crate::brep::{Brep, Edge, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use crate::operations::offset::{offset_path, OffsetOptions, OffsetResult}; -use dxf::entities::*; -use dxf::Drawing; use openmaths::Vector3; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -27,9 +21,6 @@ pub struct OGLine { impl Drop for OGLine { fn drop(&mut self) { - // TODO: Add dispose for Vector3 in OpenMaths - // self.start.dispose(); - // self.end.dispose(); self.brep.clear(); self.id.clear(); } @@ -58,31 +49,32 @@ impl OGLine { } #[wasm_bindgen] - pub fn set_config(&mut self, start: Vector3, end: Vector3) { - self.brep.clear(); + pub fn set_config(&mut self, start: Vector3, end: Vector3) -> Result<(), JsValue> { self.start = start; self.end = end; + self.generate_geometry() } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - self.brep.clear(); + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&[self.start, self.end]); + builder + .add_wire(&[0, 1], false) + .map_err(|err| JsValue::from_str(&format!("Failed to build line wire: {}", err)))?; - let start_vertex = Vertex::new(0, self.start); - let end_vertex = Vertex::new(1, self.end); + self.brep = builder + .build() + .map_err(|err| JsValue::from_str(&format!("Failed to finalize line BREP: {}", err)))?; - self.brep.vertices.push(start_vertex); - self.brep.vertices.push(end_vertex); - self.brep.edges.push(Edge::new(0, 0, 1)); + Ok(()) } - // Dispose #[wasm_bindgen] pub fn dispose_points(&mut self) { self.brep.clear(); } - // Destroy and Free memory #[wasm_bindgen] pub fn destroy(&mut self) { self.brep.clear(); @@ -91,24 +83,22 @@ impl OGLine { #[wasm_bindgen] pub fn get_brep_serialized(&self) -> String { - // Serialize the BREP geometry - let serialized = serde_json::to_string(&self.brep).unwrap(); - serialized + serde_json::to_string(&self.brep).unwrap() } #[wasm_bindgen] pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let vertices = self.brep.vertices.clone(); - for vertex in vertices { - vertex_buffer.push(vertex.position.x); - vertex_buffer.push(vertex.position.y); - vertex_buffer.push(vertex.position.z); + for vertex_id in self.brep.get_wire_vertex_indices(0) { + if let Some(vertex) = self.brep.vertices.get(vertex_id as usize) { + vertex_buffer.push(vertex.position.x); + vertex_buffer.push(vertex.position.y); + vertex_buffer.push(vertex.position.z); + } } - let vertex_serialized = serde_json::to_string(&vertex_buffer).unwrap(); - vertex_serialized + serde_json::to_string(&vertex_buffer).unwrap() } #[wasm_bindgen] @@ -123,7 +113,6 @@ impl OGLine { } pub fn get_dxf_serialized(&self) -> String { - // TODO: Implement DXF serialization for line String::new() } } diff --git a/main/opengeometry/src/primitives/polygon.rs b/main/opengeometry/src/primitives/polygon.rs index ee88a8f..7a01aa7 100644 --- a/main/opengeometry/src/primitives/polygon.rs +++ b/main/opengeometry/src/primitives/polygon.rs @@ -1,15 +1,7 @@ use serde::{Deserialize, Serialize}; -/** - * Copyright (c) 2025, OpenGeometry. All rights reserved. - * Polygon Primitive for OpenGeometry. - * - * Base polygon created by default on XY plane with no vertices. - * Polygon Points should be in CCW order based on viewing Axis - * Holes should be in CW order - */ use wasm_bindgen::prelude::*; -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use crate::operations::triangulate::triangulate_polygon_with_holes; use crate::utility::bgeometry::BufferGeometry; @@ -20,14 +12,8 @@ use uuid::Uuid; #[derive(Clone, Serialize, Deserialize)] pub struct OGPolygon { id: String, - // geometry: basegeometry::BaseGeometry, - // pub extruded: bool, - // pub extruded_height: f64, - // pub is_polygon: bool, - // pub position: Vector3, - // pub rotation: Vector3, - // pub scale: Vector3, points: Vec, + holes: Vec>, geometry: BufferGeometry, brep: Brep, } @@ -42,20 +28,8 @@ impl OGPolygon { } } -// TODO: Implement Drop for all Primitives -// impl Drop for OGPolygon { -// fn drop(&mut self) { -// self.buffer.clear(); -// self.geometry.reset_geometry(); -// self.variable_geometry.reset_geometry(); -// self.brep.clear(); -// web_sys::console::log_1(&format!("Clearing Polygon with ID: {}", self.id).into()); -// } -// } - #[wasm_bindgen] impl OGPolygon { - // Why Getter and Setter - https://github.com/rustwasm/wasm-bindgen/issues/1775 #[wasm_bindgen(setter)] pub fn set_id(&mut self, id: String) { self.id = id; @@ -66,35 +40,34 @@ impl OGPolygon { self.id.clone() } - // Add the ability to create polygon with list of verticies passed in constructor itself - // as of now use add_vertices method to push all vertices at once #[wasm_bindgen(constructor)] pub fn new(id: String) -> OGPolygon { let internal_id = Uuid::new_v4(); OGPolygon { - id: id.clone(), + id, points: Vec::new(), + holes: Vec::new(), geometry: BufferGeometry::new(internal_id), brep: Brep::new(internal_id), } } #[wasm_bindgen] - pub fn set_config(&mut self, points: Vec) { + pub fn set_config(&mut self, points: Vec) -> Result<(), JsValue> { self.points = points; - - self.generate_brep(); + self.holes.clear(); + self.generate_brep() } #[wasm_bindgen] - pub fn set_transformation(&mut self, transformation: Vec) { + pub fn set_transformation(&mut self, transformation: Vec) -> Result<(), JsValue> { if transformation.len() != 16 { - web_sys::console::log_1(&"Transformation matrix must have 16 elements.".into()); - return; + return Err(JsValue::from_str( + "Transformation matrix must have exactly 16 elements", + )); } - // Set the transformation matrix in the geometry let transformation_matrix: Matrix4 = Matrix4::set( transformation[0], transformation[4], @@ -114,102 +87,30 @@ impl OGPolygon { transformation[15], ); - for i in 0..self.points.len() { - let mut point = self.points[i].clone(); + for point in &mut self.points { point.apply_matrix4(transformation_matrix.clone()); - self.points[i] = point; } - // Clean BREP data structure - // self.brep.clear(); - // self.generate_geometry(); + for hole in &mut self.holes { + for point in hole { + point.apply_matrix4(transformation_matrix.clone()); + } + } + + self.generate_brep() } - // TODO: Implement Translate, Rotate, Scale methods - // #[wasm_bindgen] - // pub fn translate(&mut self, translation: Vector3) { - // self.position.x += translation.x; - // self.position.y += translation.y; - // self.position.z += translation.z; - - // self.geometry.translate(translation); - - // // TODO: Variable Geometry is used for triangulation with holes - Later - // // self.variable_geometry.translate(translation); - // } - - // #[wasm_bindgen] - // pub fn set_position(&mut self, position: openmath::Vector3D) { - // self.position = position; - // self.geometry.set_position(position); - // } - - // #[wasm_bindgen] - // pub fn new_with_circle(circle_arc: primitives::circle::CircleArc) -> OGPolygon { - // let mut polygon = OGPolygon::new(circle_arc.id()); - // // discard the last point as it is same as the first point - // let mut circle_arc_points = circle_arc.get_raw_points(); - // circle_arc_points.pop(); - // circle_arc_points.reverse(); - // polygon.add_vertices(circle_arc_points); - // polygon.triangulate(); - // polygon - // } - - // #[wasm_bindgen] - // pub fn new_with_rectangle(rectangle: primitives::rectangle::OGRectangle) -> OGPolygon { - // let mut polygon = OGPolygon::new(rectangle.id()); - // // discard the last point as it is same as the first point - // let mut rectangle_points = rectangle.get_raw_points(); - // rectangle_points.pop(); - // polygon.add_vertices(rectangle_points); - // polygon.triangulate(); - // polygon - // } - - // Add Set of new Vertices to the polygon #[wasm_bindgen] - pub fn add_vertices(&mut self, vertices: Vec) { - self.points.clear(); - self.brep.clear(); - - self.set_config(vertices.clone()); + pub fn add_vertices(&mut self, vertices: Vec) -> Result<(), JsValue> { + self.points = vertices; + self.holes.clear(); + self.generate_brep() } - // #[wasm_bindgen] - // pub fn add_vertex(&mut self, vertex: Vector3) { - // self.geometry.add_vertex(vertex); - - // // If more than 3 vertices are added, then the polygon is created - // if self.geometry.get_vertices().len() > 2 { - // self.is_polygon = true; - // } - // } - #[wasm_bindgen] - pub fn add_holes(&mut self, holes: Vec) { - let current_index = self.brep.get_vertex_count(); - self.brep.holes.push(current_index); - - for (i, point) in holes.iter().enumerate() { - let vertex = Vertex::new((current_index + i as u32) as u32, point.clone()); - self.brep.vertices.push(vertex); - - // TODO - Can we merge Face Edges with Hole Edges, would it be wise to do so? - // For now, keep it separate - let edge = { - let holes_len_u32 = holes.len() as u32; - vec![ - current_index + i as u32, - current_index + ((i as u32 + 1) % holes_len_u32), - ] - }; - self.brep.hole_edges.push(Edge::new( - self.brep.get_hole_edge_count() as u32, - edge[0], - edge[1], - )); - } + pub fn add_holes(&mut self, hole: Vec) -> Result<(), JsValue> { + self.holes.push(hole); + self.generate_brep() } #[wasm_bindgen] @@ -219,80 +120,75 @@ impl OGPolygon { } #[wasm_bindgen] - pub fn generate_brep(&mut self) { + pub fn generate_brep(&mut self) -> Result<(), JsValue> { if self.points.len() < 3 { - web_sys::console::log_1( - &"Polygon must have at least 3 points to generate geometry.".into(), - ); - return; + self.clean_geometry(); + return Ok(()); } - // Clear the BREP structure before generating new geometry self.clean_geometry(); - // Create Face for the polygon - self.brep - .faces - .push(Face::new(self.brep.get_face_count() as u32, Vec::new())); + let mut builder = BrepBuilder::new(self.brep.id); - // Add vertices, edge indices and face indices to the BREP - for (i, point) in self.points.iter().enumerate() { - let vertex = Vertex::new(i as u32, point.clone()); - self.brep.vertices.push(vertex.clone()); + let mut all_vertices = self.points.clone(); + let mut hole_index_sets: Vec> = Vec::new(); - let edge = { vec![i as u32, ((i + 1) % self.points.len()) as u32] }; - - self.brep.edges.push(Edge::new( - self.brep.get_edge_count() as u32, - edge[0], - edge[1], - )); + for hole in &self.holes { + if hole.len() < 3 { + continue; + } - self.brep.insert_vertex_at_face_by_id(0, vertex.id); + let start = all_vertices.len() as u32; + all_vertices.extend(hole.iter().copied()); + let indices: Vec = (0..hole.len() as u32) + .map(|offset| start + offset) + .collect(); + hole_index_sets.push(indices); } + + builder.add_vertices(&all_vertices); + let outer_indices: Vec = (0..self.points.len() as u32).collect(); + + builder + .add_face(&outer_indices, &hole_index_sets) + .map_err(|err| JsValue::from_str(&format!("Failed to build polygon face: {}", err)))?; + + self.brep = builder.build().map_err(|err| { + JsValue::from_str(&format!("Failed to finalize polygon BREP: {}", err)) + })?; + + Ok(()) } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - if self.points.len() < 3 { - web_sys::console::log_1( - &"Polygon must have at least 3 points to generate geometry.".into(), - ); - return; - } + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { + self.generate_brep() } #[wasm_bindgen] pub fn get_brep_serialized(&self) -> String { - let serialized = serde_json::to_string(&self.brep).unwrap(); - serialized + serde_json::to_string(&self.brep).unwrap() } #[wasm_bindgen] - pub fn get_geometry_serialized(&mut self) -> String { + pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let faces = self.brep.faces.clone(); - for face in &faces { + for face in &self.brep.faces { let (face_vertices, holes_vertices) = self.brep.get_vertices_and_holes_by_face_id(face.id); - if face_vertices.len() < 3 { continue; } let triangles = triangulate_polygon_with_holes(&face_vertices, &holes_vertices); - - // Combine outer and hole vertices into a single list for easy lookup let all_vertices: Vec = face_vertices .into_iter() .chain(holes_vertices.into_iter().flatten()) .collect(); - // Build the final vertex buffer for rendering for triangle in triangles { for vertex_index in triangle { - // The indices from earcutr correspond to our combined `all_vertices` list let vertex = &all_vertices[vertex_index]; vertex_buffer.push(vertex.x); vertex_buffer.push(vertex.y); @@ -305,16 +201,16 @@ impl OGPolygon { } #[wasm_bindgen] - pub fn get_outline_geometry_serialized(&mut self) -> String { + pub fn get_outline_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let edges = self.brep.edges.clone(); - for edge in edges { - let start_index = edge.v1 as usize; - let end_index = edge.v2 as usize; - - let start_vertex = self.brep.vertices[start_index].clone(); - let end_vertex = self.brep.vertices[end_index].clone(); + for (start_id, end_id) in self.brep.collect_outline_segments() { + let Some(start_vertex) = self.brep.vertices.get(start_id as usize) else { + continue; + }; + let Some(end_vertex) = self.brep.vertices.get(end_id as usize) else { + continue; + }; vertex_buffer.push(start_vertex.position.x); vertex_buffer.push(start_vertex.position.y); @@ -325,189 +221,6 @@ impl OGPolygon { vertex_buffer.push(end_vertex.position.z); } - if self.brep.hole_edges.len() > 0 { - for edge in self.brep.hole_edges.clone() { - let start_index = edge.v1 as usize; - let end_index = edge.v2 as usize; - - let start_vertex = self.brep.vertices[start_index].clone(); - let end_vertex = self.brep.vertices[end_index].clone(); - - vertex_buffer.push(start_vertex.position.x); - vertex_buffer.push(start_vertex.position.y); - vertex_buffer.push(start_vertex.position.z); - - vertex_buffer.push(end_vertex.position.x); - vertex_buffer.push(end_vertex.position.y); - vertex_buffer.push(end_vertex.position.z); - } - } - - let vertex_serialized = serde_json::to_string(&vertex_buffer).unwrap(); - vertex_serialized + serde_json::to_string(&vertex_buffer).unwrap() } - - // pub fn extrude_by_height_with_holes(&mut self, height: f64) -> String { - // // Create a new buffer geometry - // // Loop through the extruded faces with holes - // // Create Variable Geometry - // // Call Triangulate with Holes with Custom Geometry method - // // push to the buffer geometry - // // Return the buffer geometry - - // self.extruded = true; - // self.extruded_height = height; - - // let mut extrude_data = extrude_polygon_with_holes(self.geometry.clone(), height); - - // let mut local_geometry: Vec = Vec::new(); - // let faces = extrude_data.faces.clone(); - // let all_vertices_raw = extrude_data.vertices.clone(); - // let holes = extrude_data.holes.clone(); - // let face_holes_map = extrude_data.face_holes_map.clone(); - - // let face_length = face_holes_map.keys().len(); - // // extrude_data.face_length = face_length; - // let mut face_current_index: usize = 0; - - // let mut face_map: Vec<_> = face_holes_map.keys().cloned().collect(); - // face_map.sort(); - - // for face_index in face_map { - // let mut variable_geometry: BaseGeometry = BaseGeometry::new("variable_geometry".to_string()); - // let face = faces[face_index as usize].clone(); - // let mut face_vertices: Vec = Vec::new(); - - // for index in face.clone() { - // let v_face = all_vertices_raw[index as usize].clone(); - // face_vertices.push(v_face); - // } - // face_vertices.reverse(); - // variable_geometry.add_vertices(face_vertices.clone()); - - // let mut is_ccw = false; - // if (face_current_index > face_length - 2) { - // is_ccw = true; - // extrude_data.is_ccw_last_face = true; - // } - - // // get holes for given face - // let holes_for_face = face_holes_map.get(&face_index).unwrap(); - // if holes_for_face.len() > 0 { - // for hole_index in holes_for_face { - // let hole = holes[*hole_index as usize].clone(); - // variable_geometry.add_holes(hole.clone()); - // } - // self.variable_geometry = variable_geometry.clone(); - - // // last most processed face - // extrude_data.face_length = face_index as usize; - - // let triangle_data = self.triangulate_with_holes_variable_geometry(is_ccw); - // let triangulated_data: HashMap> = serde_json::from_str(&triangle_data).unwrap(); - // let vertices = triangulated_data.get("new_buffer").unwrap(); - // for i in vertices { - // local_geometry.push(*i); - // } - // } - // else { - // // If no holes, then just triangulate the face - // let triangulated_face = triangulate::triangulate_polygon_by_face(face_vertices.clone()); - // for index in triangulated_face { - // for i in index { - // let vertex = face_vertices[i as usize].clone(); - // local_geometry.push(vertex.x); - // local_geometry.push(vertex.y); - // local_geometry.push(vertex.z); - // } - // } - // } - - // // Destroy the variable geometry - // variable_geometry.reset_geometry(); - - // face_current_index += 1; - // } - - // // Add BREP - // let brep = Geometry { - // vertices: extrude_data.vertices.clone(), - // faces: extrude_data.faces.clone(), - // edges: extrude_data.edges.clone() - // }; - // self.brep = brep.clone(); - - // // let extrude_data_string = serde_json::to_string(&extrude_data).unwrap(); - // // extrude_data_string - - // serde_json::to_string(&local_geometry).unwrap() - // } - - // #[wasm_bindgen] - // pub fn extrude_by_height(&mut self, height: f64) -> String { - // self.extruded = true; - // self.extruded_height = height; - - // // If Polygon has holes, use other extrude method - // if (self.geometry.get_holes().len() > 0) { - // return self.extrude_by_height_with_holes(height); - // } - - // let extrude_data = extrude_polygon_by_buffer_geometry(self.geometry.clone(), height); - - // let mut local_geometry = Vec::new(); - - // // let face = extrude_data.faces[0].clone(); - // for face in extrude_data.faces.clone() { - // let mut face_vertices: Vec = Vec::new(); - // for index in face.clone() { - // face_vertices.push(extrude_data.vertices[index as usize].clone()); - // } - - // let triangulated_face = triangulate::triangulate_polygon_by_face(face_vertices.clone()); - // // let ccw_vertices = windingsort::ccw_test(face_vertices.clone()); - // for index in triangulated_face { - // for i in index { - // // let vertex = ccw_vertices[i as usize]; - // let vertex = face_vertices[i as usize].clone(); - // local_geometry.push(vertex.x); - // local_geometry.push(vertex.y); - // local_geometry.push(vertex.z); - // } - // } - // } - - // // let face_data_string = serde_json::to_string(&face).unwrap(); // Serialize face_data - // // face_data_string - - // // let extrude_data_string = serde_json::to_string(&extrude_data).unwrap(); // Serialize extrude_data - // // extrude_data_string - - // let string_data = serde_json::to_string(&local_geometry).unwrap(); - // string_data - - // // ABOVE LINE WORKING - - // // // TESTING EDGES OUTLINE - // // let mut outline_data: Vec> = Vec::new(); - - // // // for edge in extruded_raw.edges { - // // // let start = vertices[edge[0] as usize].clone(); - // // // let end = vertices[edge[1] as usize].clone(); - - // // // let edge_vertices = vec![start, end]; - // // // outline_data.push(edge_vertices); - // // // } - - // // for face in faces { - // // let mut face_vertices: Vec = Vec::new(); - // // for index in face { - // // let v_face = vertices[index as usize].clone(); - // // face_vertices.push(v_face); - // // } - // // outline_data.push(face_vertices); - // // } - - // // serde_json::to_string(&outline_data).unwrap() - // } } diff --git a/main/opengeometry/src/primitives/polyline.rs b/main/opengeometry/src/primitives/polyline.rs index d02d431..35813b1 100644 --- a/main/opengeometry/src/primitives/polyline.rs +++ b/main/opengeometry/src/primitives/polyline.rs @@ -1,11 +1,8 @@ /** * Copyright (c) 2025, OpenGeometry. All rights reserved. * Polyline Primitive for OpenGeometry. - * - * A Polyline is a connected sequence of line segments. - * It can be open or closed, and is defined by a series of points. */ -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use crate::operations::offset::{offset_path, OffsetOptions, OffsetResult}; use openmaths::Vector3; @@ -17,7 +14,6 @@ use wasm_bindgen::prelude::*; #[derive(Clone, Serialize, Deserialize)] pub struct OGPolyline { id: String, - // TODO: Figure out if we can solely rely on Brep for points points: Vec, is_closed: bool, brep: Brep, @@ -33,7 +29,6 @@ impl OGPolyline { } } -// TODO: Implement Drop for all Primitives impl Drop for OGPolyline { fn drop(&mut self) { self.points.clear(); @@ -75,111 +70,70 @@ impl OGPolyline { } } - // #[wasm_bindgen] - // pub fn translate(&mut self, translation: Vector3) { - - // self.points.clear(); - - // for i in 0..self.backup_points.len() { - // let point = &mut self.backup_points[i].clone(); - // point.x += translation.x; - // point.y += translation.y; - // point.z += translation.z; - - // self.points.push(point.clone()); - // self.brep.vertices.push(point.clone()); - // } - - // self.check_closed_test(); - // self.generate_brep(); - // } - - // #[wasm_bindgen] - // pub fn set_position(&mut self, position: Vector3) { - // self.position = position; - // } - - pub fn set_config(&mut self, points: Vec) { - self.points.clear(); - - for point in points { - self.points.push(point); - } - + pub fn set_config(&mut self, points: Vec) -> Result<(), JsValue> { + self.points = points; self.check_closed_test(); - self.generate_geometry(); + self.generate_geometry() } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - self.brep.clear(); + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { if self.points.is_empty() { - return; + self.brep.clear(); + return Ok(()); } - let mut effective_len = self.points.len(); - if self.is_closed && self.points.len() > 2 { - let first = self.points[0]; - let last = self.points[self.points.len() - 1]; + let mut effective_points = self.points.clone(); + if self.is_closed && effective_points.len() > 2 { + let first = effective_points[0]; + let last = *effective_points.last().unwrap(); let dx = first.x - last.x; let dy = first.y - last.y; let dz = first.z - last.z; let duplicate_end = dx * dx + dy * dy + dz * dz <= 1.0e-12; if duplicate_end { - effective_len -= 1; + effective_points.pop(); } } - for i in 0..effective_len { - self.brep - .vertices - .push(Vertex::new(i as u32, self.points[i])); - } + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&effective_points); - if effective_len < 2 { - return; - } + if effective_points.len() >= 2 { + let indices: Vec = (0..effective_points.len() as u32).collect(); + let closed_wire = self.is_closed && effective_points.len() > 2; + builder.add_wire(&indices, closed_wire).map_err(|err| { + JsValue::from_str(&format!("Failed to build polyline wire: {}", err)) + })?; - for i in 0..(effective_len - 1) { - self.brep.edges.push(Edge::new( - self.brep.get_edge_count(), - i as u32, - (i + 1) as u32, - )); + if closed_wire { + builder.add_face(&indices, &[]).map_err(|err| { + JsValue::from_str(&format!("Failed to build polyline face: {}", err)) + })?; + } } - if self.is_closed && effective_len > 2 { - self.brep.edges.push(Edge::new( - self.brep.get_edge_count(), - (effective_len - 1) as u32, - 0, - )); + self.brep = builder.build().map_err(|err| { + JsValue::from_str(&format!("Failed to finalize polyline BREP: {}", err)) + })?; - let face_indices: Vec = (0..effective_len as u32).collect(); - self.brep.faces.push(Face::new(0, face_indices)); - } + Ok(()) } #[wasm_bindgen] - pub fn add_multiple_points(&mut self, points: Vec) { - self.points.clear(); - - for point in points { - self.points.push(point); - } - + pub fn add_multiple_points(&mut self, points: Vec) -> Result<(), JsValue> { + self.points = points; self.check_closed_test(); - self.generate_geometry(); + self.generate_geometry() } #[wasm_bindgen] - pub fn add_point(&mut self, point: Vector3) { + pub fn add_point(&mut self, point: Vector3) -> Result<(), JsValue> { self.points.push(point); self.check_closed_test(); - self.generate_geometry(); + self.generate_geometry() } - // Get Points for the Circle #[wasm_bindgen] pub fn get_points(&self) -> String { serde_json::to_string(&self.points).unwrap() @@ -205,15 +159,12 @@ impl OGPolyline { serde_json::to_string(&result).unwrap() } - // Simple Check to see if the Polyline is closed - // This can be made better pub fn check_closed_test(&mut self) { self.is_closed = false; if self.points.len() > 2 { - if self.points[0].x == self.points[self.points.len() - 1].x - && self.points[0].y == self.points[self.points.len() - 1].y - && self.points[0].z == self.points[self.points.len() - 1].z - { + let first = self.points[0]; + let last = self.points[self.points.len() - 1]; + if first.x == last.x && first.y == last.y && first.z == last.z { self.is_closed = true; } } @@ -221,32 +172,35 @@ impl OGPolyline { #[wasm_bindgen] pub fn get_brep_serialized(&self) -> String { - let serialized = serde_json::to_string(&self.brep).unwrap(); - serialized + serde_json::to_string(&self.brep).unwrap() } #[wasm_bindgen] pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let vertices = self.brep.vertices.clone(); - for vertex in vertices { - vertex_buffer.push(vertex.position.x); - vertex_buffer.push(vertex.position.y); - vertex_buffer.push(vertex.position.z); - } + if let Some(wire) = self.brep.wires.first() { + let wire_vertices = self.brep.get_wire_vertex_indices(wire.id); + for vertex_id in &wire_vertices { + if let Some(vertex) = self.brep.vertices.get(*vertex_id as usize) { + vertex_buffer.push(vertex.position.x); + vertex_buffer.push(vertex.position.y); + vertex_buffer.push(vertex.position.z); + } + } - // For closed polylines, line rendering needs the first vertex repeated - // at the end to draw the closing segment from last->first. - if self.is_closed && !self.brep.vertices.is_empty() { - let first = self.brep.vertices[0].position; - vertex_buffer.push(first.x); - vertex_buffer.push(first.y); - vertex_buffer.push(first.z); + if self.is_closed && !wire_vertices.is_empty() { + if let Some(first_id) = wire_vertices.first() { + if let Some(first_vertex) = self.brep.vertices.get(*first_id as usize) { + vertex_buffer.push(first_vertex.position.x); + vertex_buffer.push(first_vertex.position.y); + vertex_buffer.push(first_vertex.position.z); + } + } + } } - let vertex_serialized = serde_json::to_string(&vertex_buffer).unwrap(); - vertex_serialized + serde_json::to_string(&vertex_buffer).unwrap() } } diff --git a/main/opengeometry/src/primitives/rectangle.rs b/main/opengeometry/src/primitives/rectangle.rs index 22406f5..104ccc3 100644 --- a/main/opengeometry/src/primitives/rectangle.rs +++ b/main/opengeometry/src/primitives/rectangle.rs @@ -1,15 +1,7 @@ use serde::{Deserialize, Serialize}; -/** - * Copyright (c) 2025, OpenGeometry. All rights reserved. - * Rectangle Primitive for OpenGeometry. - * - * A Rectangle is defined by its center, width, and breadth. - * It can be used to create rectangular shapes in 3D space. - * Created with a center, width, and breadth. - */ use wasm_bindgen::prelude::*; -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use crate::operations::offset::{offset_path, OffsetOptions, OffsetResult}; use crate::utility::bgeometry::BufferGeometry; @@ -53,75 +45,56 @@ impl OGRectangle { } } - // TODO: Implement clone method if needed - // #[wasm_bindgen] - // pub fn clone(&self) -> OGRectangle { - // OGRectangle { - // id: self.id.clone(), - // center: self.center.clone(), - // width: self.width, - // breadth: self.breadth, - // points: self.points.clone() - // } - // } - #[wasm_bindgen] - pub fn set_config(&mut self, center: Vector3, width: f64, breadth: f64) { + pub fn set_config(&mut self, center: Vector3, width: f64, breadth: f64) -> Result<(), JsValue> { self.center = center; self.width = width; self.breadth = breadth; + Ok(()) } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - self.brep.clear(); + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { + let points = self.get_raw_points(); - let half_width = self.width / 2.0; - let half_breadth = self.breadth / 2.0; - let center = self.center.clone(); - - let p1 = Vector3::new(-half_width, 0.0, -half_breadth).add(¢er); - let p2 = Vector3::new(half_width, 0.0, -half_breadth).add(¢er); - let p3 = Vector3::new(half_width, 0.0, half_breadth).add(¢er); - let p4 = Vector3::new(-half_width, 0.0, half_breadth).add(¢er); - - self.brep.vertices.push(Vertex::new(0, p1)); - self.brep.vertices.push(Vertex::new(1, p2)); - self.brep.vertices.push(Vertex::new(2, p3)); - self.brep.vertices.push(Vertex::new(3, p4)); - - self.brep.edges.push(Edge::new(0, 0, 1)); - self.brep.edges.push(Edge::new(1, 1, 2)); - self.brep.edges.push(Edge::new(2, 2, 3)); - self.brep.edges.push(Edge::new(3, 3, 0)); - self.brep.faces.push(Face::new(0, vec![0, 1, 2, 3])); + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&points); + + let indices = vec![0, 1, 2, 3]; + builder.add_face(&indices, &[]).map_err(|err| { + JsValue::from_str(&format!("Failed to build rectangle face: {}", err)) + })?; + + self.brep = builder.build().map_err(|err| { + JsValue::from_str(&format!("Failed to finalize rectangle BREP: {}", err)) + })?; + + Ok(()) } #[wasm_bindgen] pub fn get_brep_serialized(&self) -> String { - let serialized = serde_json::to_string(&self.brep).unwrap(); - serialized + serde_json::to_string(&self.brep).unwrap() } #[wasm_bindgen] pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let vertices = self.brep.vertices.clone(); - for vertex in vertices { - vertex_buffer.push(vertex.position.x); - vertex_buffer.push(vertex.position.y); - vertex_buffer.push(vertex.position.z); + if let Some(face) = self.brep.faces.first() { + let mut vertices = self.brep.get_vertices_by_face_id(face.id); + if let Some(first) = vertices.first().copied() { + vertices.push(first); + } + + for vertex in vertices { + vertex_buffer.push(vertex.x); + vertex_buffer.push(vertex.y); + vertex_buffer.push(vertex.z); + } } - // Last point is the first point to close the rectangle - let first_vertex = self.brep.vertices.first().unwrap(); - vertex_buffer.push(first_vertex.position.x); - vertex_buffer.push(first_vertex.position.y); - vertex_buffer.push(first_vertex.position.z); - - let vertex_serialized = serde_json::to_string(&vertex_buffer).unwrap(); - vertex_serialized + serde_json::to_string(&vertex_buffer).unwrap() } #[wasm_bindgen] @@ -134,24 +107,6 @@ impl OGRectangle { let result = self.get_offset_result(distance, acute_threshold_degrees, bevel); serde_json::to_string(&result).unwrap() } - - // TODO: Implement properties and destroy methods - // #[wasm_bindgen] - // pub fn update_width(&mut self, width: f64) { - // self.destroy(); - // self.width = width; - // } - - // #[wasm_bindgen] - // pub fn update_breadth(&mut self, breadth: f64) { - // self.destroy(); - // self.breadth = breadth; - // } - - // #[wasm_bindgen] - // pub fn destroy(&mut self) { - // self.points.clear(); - // } } impl OGRectangle { diff --git a/main/opengeometry/src/primitives/sphere.rs b/main/opengeometry/src/primitives/sphere.rs index 7a637ea..96ccf93 100644 --- a/main/opengeometry/src/primitives/sphere.rs +++ b/main/opengeometry/src/primitives/sphere.rs @@ -1,9 +1,7 @@ -use std::collections::HashSet; - use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use openmaths::Vector3; use uuid::Uuid; @@ -52,88 +50,123 @@ impl OGSphere { radius: f64, width_segments: u32, height_segments: u32, - ) { + ) -> Result<(), JsValue> { self.center = center; self.radius = radius.max(1.0e-6); self.width_segments = width_segments.max(3); self.height_segments = height_segments.max(2); - self.generate_brep(); + self.generate_brep() } - pub fn generate_brep(&mut self) { + pub fn generate_brep(&mut self) -> Result<(), JsValue> { self.clean_geometry(); - self.generate_geometry(); + self.generate_geometry() } pub fn clean_geometry(&mut self) { self.brep.clear(); - self.brep.holes.clear(); - self.brep.hole_edges.clear(); } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { - self.clean_geometry(); - + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { let width = self.width_segments.max(3) as usize; let height = self.height_segments.max(2) as usize; - let mut vertex_indices = vec![vec![0u32; width + 1]; height + 1]; + let mut vertices = Vec::new(); + // Top pole. + vertices.push(Vector3::new( + self.center.x, + self.center.y + self.radius, + self.center.z, + )); - for iy in 0..=height { + // Intermediate rings. + for iy in 1..height { let v = iy as f64 / height as f64; let theta = v * std::f64::consts::PI; let sin_theta = theta.sin(); let cos_theta = theta.cos(); - for ix in 0..=width { + for ix in 0..width { let u = ix as f64 / width as f64; let phi = u * std::f64::consts::PI * 2.0; let sin_phi = phi.sin(); let cos_phi = phi.cos(); - let x = self.center.x + self.radius * sin_theta * cos_phi; - let y = self.center.y + self.radius * cos_theta; - let z = self.center.z + self.radius * sin_theta * sin_phi; - - let id = self.brep.get_vertex_count(); - self.brep - .vertices - .push(Vertex::new(id, Vector3::new(x, y, z))); - vertex_indices[iy][ix] = id; + vertices.push(Vector3::new( + self.center.x + self.radius * sin_theta * cos_phi, + self.center.y + self.radius * cos_theta, + self.center.z + self.radius * sin_theta * sin_phi, + )); } } - let mut edge_set: HashSet<(u32, u32)> = HashSet::new(); - let mut next_face_id: u32 = 0; + let bottom_id = vertices.len() as u32; + vertices.push(Vector3::new( + self.center.x, + self.center.y - self.radius, + self.center.z, + )); - for iy in 0..height { - for ix in 0..width { - let a = vertex_indices[iy][ix]; - let b = vertex_indices[iy][ix + 1]; - let c = vertex_indices[iy + 1][ix]; - let d = vertex_indices[iy + 1][ix + 1]; - - if iy != 0 { - self.brep.faces.push(Face::new(next_face_id, vec![a, c, b])); - next_face_id += 1; - - Self::push_edge_if_new(&mut self.brep, &mut edge_set, a, c); - Self::push_edge_if_new(&mut self.brep, &mut edge_set, c, b); - Self::push_edge_if_new(&mut self.brep, &mut edge_set, b, a); - } + let ring_vertex = |ring: usize, ix: usize| -> u32 { + // ring is 1..height-1 + 1 + ((ring - 1) * width + ix) as u32 + }; + + let mut faces: Vec> = Vec::new(); - if iy != (height - 1) { - self.brep.faces.push(Face::new(next_face_id, vec![b, c, d])); - next_face_id += 1; + // Top cap. + for ix in 0..width { + let next = (ix + 1) % width; + faces.push(vec![0, ring_vertex(1, next), ring_vertex(1, ix)]); + } - Self::push_edge_if_new(&mut self.brep, &mut edge_set, b, c); - Self::push_edge_if_new(&mut self.brep, &mut edge_set, c, d); - Self::push_edge_if_new(&mut self.brep, &mut edge_set, d, b); + // Body quads split into triangles. + if height > 2 { + for ring in 1..(height - 1) { + for ix in 0..width { + let next = (ix + 1) % width; + let a = ring_vertex(ring, ix); + let b = ring_vertex(ring, next); + let c = ring_vertex(ring + 1, next); + let d = ring_vertex(ring + 1, ix); + + faces.push(vec![a, b, c]); + faces.push(vec![a, c, d]); } } } + + // Bottom cap. + let last_ring = height - 1; + for ix in 0..width { + let next = (ix + 1) % width; + faces.push(vec![ + bottom_id, + ring_vertex(last_ring, ix), + ring_vertex(last_ring, next), + ]); + } + + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&vertices); + + for face in &faces { + builder.add_face(face, &[]).map_err(|err| { + JsValue::from_str(&format!("Failed to build sphere face: {}", err)) + })?; + } + + builder + .add_shell_from_all_faces(true) + .map_err(|err| JsValue::from_str(&format!("Failed to build sphere shell: {}", err)))?; + + self.brep = builder.build().map_err(|err| { + JsValue::from_str(&format!("Failed to finalize sphere BREP: {}", err)) + })?; + + Ok(()) } #[wasm_bindgen] @@ -146,15 +179,15 @@ impl OGSphere { let mut vertex_buffer: Vec = Vec::new(); for face in &self.brep.faces { - if face.face_indices.len() != 3 { + let face_vertices = self.brep.get_vertices_by_face_id(face.id); + if face_vertices.len() != 3 { continue; } - for vertex_index in &face.face_indices { - let vertex = &self.brep.vertices[*vertex_index as usize]; - vertex_buffer.push(vertex.position.x); - vertex_buffer.push(vertex.position.y); - vertex_buffer.push(vertex.position.z); + for vertex in face_vertices { + vertex_buffer.push(vertex.x); + vertex_buffer.push(vertex.y); + vertex_buffer.push(vertex.z); } } @@ -165,9 +198,13 @@ impl OGSphere { pub fn get_outline_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - for edge in &self.brep.edges { - let start_vertex = self.brep.vertices[edge.v1 as usize].clone(); - let end_vertex = self.brep.vertices[edge.v2 as usize].clone(); + for (start_id, end_id) in self.brep.collect_outline_segments() { + let Some(start_vertex) = self.brep.vertices.get(start_id as usize) else { + continue; + }; + let Some(end_vertex) = self.brep.vertices.get(end_id as usize) else { + continue; + }; vertex_buffer.push(start_vertex.position.x); vertex_buffer.push(start_vertex.position.y); @@ -182,13 +219,6 @@ impl OGSphere { } impl OGSphere { - fn push_edge_if_new(brep: &mut Brep, edge_set: &mut HashSet<(u32, u32)>, v1: u32, v2: u32) { - let key = if v1 < v2 { (v1, v2) } else { (v2, v1) }; - if edge_set.insert(key) { - brep.edges.push(Edge::new(brep.get_edge_count(), v1, v2)); - } - } - pub fn brep(&self) -> &Brep { &self.brep } diff --git a/main/opengeometry/src/primitives/sweep.rs b/main/opengeometry/src/primitives/sweep.rs index 19bf2a1..dfa9526 100644 --- a/main/opengeometry/src/primitives/sweep.rs +++ b/main/opengeometry/src/primitives/sweep.rs @@ -49,12 +49,16 @@ impl OGSweep { } #[wasm_bindgen] - pub fn set_config(&mut self, path_points: Vec, profile_points: Vec) { + pub fn set_config( + &mut self, + path_points: Vec, + profile_points: Vec, + ) -> Result<(), JsValue> { self.path_points = path_points; self.profile_points = profile_points; self.cap_start = true; self.cap_end = true; - self.generate_brep(); + self.generate_brep() } #[wasm_bindgen] @@ -64,24 +68,24 @@ impl OGSweep { profile_points: Vec, cap_start: bool, cap_end: bool, - ) { + ) -> Result<(), JsValue> { self.path_points = path_points; self.profile_points = profile_points; self.cap_start = cap_start; self.cap_end = cap_end; - self.generate_brep(); + self.generate_brep() } #[wasm_bindgen] - pub fn set_caps(&mut self, cap_start: bool, cap_end: bool) { + pub fn set_caps(&mut self, cap_start: bool, cap_end: bool) -> Result<(), JsValue> { self.cap_start = cap_start; self.cap_end = cap_end; - self.generate_brep(); + self.generate_brep() } - pub fn generate_brep(&mut self) { + pub fn generate_brep(&mut self) -> Result<(), JsValue> { self.clean_geometry(); - self.generate_geometry(); + self.generate_geometry() } pub fn clean_geometry(&mut self) { @@ -89,13 +93,18 @@ impl OGSweep { } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { let options = SweepOptions { cap_start: self.cap_start, cap_end: self.cap_end, }; self.brep = sweep_profile_along_path(&self.path_points, &self.profile_points, options); + self.brep + .validate_topology() + .map_err(|err| JsValue::from_str(&format!("Invalid sweep topology: {}", err)))?; + + Ok(()) } #[wasm_bindgen] @@ -138,20 +147,20 @@ impl OGSweep { pub fn get_outline_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - for edge in &self.brep.edges { - let start_index = edge.v1 as usize; - let end_index = edge.v2 as usize; - - let start_vertex = self.brep.vertices[start_index].position; - let end_vertex = self.brep.vertices[end_index].position; - - vertex_buffer.push(start_vertex.x); - vertex_buffer.push(start_vertex.y); - vertex_buffer.push(start_vertex.z); - - vertex_buffer.push(end_vertex.x); - vertex_buffer.push(end_vertex.y); - vertex_buffer.push(end_vertex.z); + for (start_id, end_id) in self.brep.collect_outline_segments() { + let Some(start_vertex) = self.brep.vertices.get(start_id as usize) else { + continue; + }; + let Some(end_vertex) = self.brep.vertices.get(end_id as usize) else { + continue; + }; + + vertex_buffer.push(start_vertex.position.x); + vertex_buffer.push(start_vertex.position.y); + vertex_buffer.push(start_vertex.position.z); + vertex_buffer.push(end_vertex.position.x); + vertex_buffer.push(end_vertex.position.y); + vertex_buffer.push(end_vertex.position.z); } serde_json::to_string(&vertex_buffer).unwrap() @@ -159,34 +168,34 @@ impl OGSweep { } impl OGSweep { - pub fn set_path_from_polyline(&mut self, polyline: &OGPolyline) { + pub fn set_path_from_polyline(&mut self, polyline: &OGPolyline) -> Result<(), JsValue> { self.path_points = polyline.get_raw_points(); - self.generate_brep(); + self.generate_brep() } - pub fn set_path_from_line(&mut self, line: &OGLine) { + pub fn set_path_from_line(&mut self, line: &OGLine) -> Result<(), JsValue> { self.path_points = line .brep() .vertices .iter() .map(|vertex| vertex.position) .collect(); - self.generate_brep(); + self.generate_brep() } - pub fn set_profile_from_polyline(&mut self, profile: &OGPolyline) { + pub fn set_profile_from_polyline(&mut self, profile: &OGPolyline) -> Result<(), JsValue> { self.profile_points = profile.get_raw_points(); - self.generate_brep(); + self.generate_brep() } - pub fn set_profile_from_rectangle(&mut self, rectangle: &OGRectangle) { + pub fn set_profile_from_rectangle(&mut self, rectangle: &OGRectangle) -> Result<(), JsValue> { self.profile_points = rectangle .brep() .vertices .iter() .map(|vertex| vertex.position) .collect(); - self.generate_brep(); + self.generate_brep() } pub fn path_points(&self) -> Vec { diff --git a/main/opengeometry/src/primitives/wedge.rs b/main/opengeometry/src/primitives/wedge.rs index 9978aa8..566dda2 100644 --- a/main/opengeometry/src/primitives/wedge.rs +++ b/main/opengeometry/src/primitives/wedge.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; -use crate::brep::{Brep, Edge, Face, Vertex}; +use crate::brep::{Brep, BrepBuilder}; use crate::export::projection::{project_brep_to_scene, CameraParameters, HlrOptions, Scene2D}; use crate::operations::triangulate::triangulate_polygon_with_holes; use openmaths::Vector3; @@ -45,18 +45,23 @@ impl OGWedge { } #[wasm_bindgen] - pub fn set_config(&mut self, center: Vector3, width: f64, height: f64, depth: f64) { + pub fn set_config( + &mut self, + center: Vector3, + width: f64, + height: f64, + depth: f64, + ) -> Result<(), JsValue> { self.center = center; self.width = width; self.height = height; self.depth = depth; - - self.generate_brep(); + self.generate_brep() } - pub fn generate_brep(&mut self) { + pub fn generate_brep(&mut self) -> Result<(), JsValue> { self.clean_geometry(); - self.generate_geometry(); + self.generate_geometry() } pub fn clean_geometry(&mut self) { @@ -64,7 +69,7 @@ impl OGWedge { } #[wasm_bindgen] - pub fn generate_geometry(&mut self) { + pub fn generate_geometry(&mut self) -> Result<(), JsValue> { let half_width = self.width / 2.0; let half_height = self.height / 2.0; let half_depth = self.depth / 2.0; @@ -76,40 +81,41 @@ impl OGWedge { let z_min = self.center.z - half_depth; let z_max = self.center.z + half_depth; - self.brep - .vertices - .push(Vertex::new(0, Vector3::new(x_min, y_min, z_min))); - self.brep - .vertices - .push(Vertex::new(1, Vector3::new(x_max, y_min, z_min))); - self.brep - .vertices - .push(Vertex::new(2, Vector3::new(x_min, y_max, z_min))); - self.brep - .vertices - .push(Vertex::new(3, Vector3::new(x_min, y_min, z_max))); - self.brep - .vertices - .push(Vertex::new(4, Vector3::new(x_max, y_min, z_max))); - self.brep - .vertices - .push(Vertex::new(5, Vector3::new(x_min, y_max, z_max))); - - self.brep.edges.push(Edge::new(0, 0, 1)); - self.brep.edges.push(Edge::new(1, 1, 4)); - self.brep.edges.push(Edge::new(2, 4, 3)); - self.brep.edges.push(Edge::new(3, 3, 0)); - self.brep.edges.push(Edge::new(4, 0, 2)); - self.brep.edges.push(Edge::new(5, 2, 5)); - self.brep.edges.push(Edge::new(6, 5, 3)); - self.brep.edges.push(Edge::new(7, 1, 2)); - self.brep.edges.push(Edge::new(8, 4, 5)); - - self.brep.faces.push(Face::new(0, vec![0, 1, 4, 3])); - self.brep.faces.push(Face::new(1, vec![0, 2, 1])); - self.brep.faces.push(Face::new(2, vec![3, 4, 5])); - self.brep.faces.push(Face::new(3, vec![0, 3, 5, 2])); - self.brep.faces.push(Face::new(4, vec![1, 2, 5, 4])); + let vertices = vec![ + Vector3::new(x_min, y_min, z_min), + Vector3::new(x_max, y_min, z_min), + Vector3::new(x_min, y_max, z_min), + Vector3::new(x_min, y_min, z_max), + Vector3::new(x_max, y_min, z_max), + Vector3::new(x_min, y_max, z_max), + ]; + + let faces = vec![ + vec![0, 1, 4, 3], + vec![0, 2, 1], + vec![3, 4, 5], + vec![0, 3, 5, 2], + vec![1, 2, 5, 4], + ]; + + let mut builder = BrepBuilder::new(self.brep.id); + builder.add_vertices(&vertices); + + for face in &faces { + builder.add_face(face, &[]).map_err(|err| { + JsValue::from_str(&format!("Failed to build wedge face: {}", err)) + })?; + } + + builder + .add_shell_from_all_faces(true) + .map_err(|err| JsValue::from_str(&format!("Failed to build wedge shell: {}", err)))?; + + self.brep = builder + .build() + .map_err(|err| JsValue::from_str(&format!("Failed to finalize wedge BREP: {}", err)))?; + + Ok(()) } #[wasm_bindgen] @@ -120,12 +126,10 @@ impl OGWedge { #[wasm_bindgen] pub fn get_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - let faces = self.brep.faces.clone(); - for face in &faces { + for face in &self.brep.faces { let (face_vertices, holes_vertices) = self.brep.get_vertices_and_holes_by_face_id(face.id); - if face_vertices.len() < 3 { continue; } @@ -153,9 +157,13 @@ impl OGWedge { pub fn get_outline_geometry_serialized(&self) -> String { let mut vertex_buffer: Vec = Vec::new(); - for edge in self.brep.edges.clone() { - let start_vertex = self.brep.vertices[edge.v1 as usize].clone(); - let end_vertex = self.brep.vertices[edge.v2 as usize].clone(); + for (start_id, end_id) in self.brep.collect_outline_segments() { + let Some(start_vertex) = self.brep.vertices.get(start_id as usize) else { + continue; + }; + let Some(end_vertex) = self.brep.vertices.get(end_id as usize) else { + continue; + }; vertex_buffer.push(start_vertex.position.x); vertex_buffer.push(start_vertex.position.y); diff --git a/main/opengeometry/src/scenegraph.rs b/main/opengeometry/src/scenegraph.rs index add3b5e..5ab8959 100644 --- a/main/opengeometry/src/scenegraph.rs +++ b/main/opengeometry/src/scenegraph.rs @@ -744,20 +744,19 @@ impl OGSceneManager { #[cfg(test)] mod tests { use super::*; - use crate::brep::{Edge, Vertex}; + use crate::brep::{Brep, BrepBuilder}; use openmaths::Vector3; + use uuid::Uuid; #[test] fn test_scene_projection_from_edge_entity() { let mut manager = OGSceneManager::new(); let scene_id = manager.create_scene_internal("test-scene"); - let mut brep = Brep::new(Uuid::new_v4()); - brep.vertices - .push(Vertex::new(0, Vector3::new(-1.0, 0.0, 0.0))); - brep.vertices - .push(Vertex::new(1, Vector3::new(1.0, 0.0, 0.0))); - brep.edges.push(Edge::new(0, 0, 1)); + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[Vector3::new(-1.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)]); + builder.add_wire(&[0, 1], false).unwrap(); + let brep: Brep = builder.build().unwrap(); manager .add_brep_entity_to_scene_internal(&scene_id, "edge-1", "Edge", &brep) diff --git a/main/opengeometry/src/utility/geometry.rs b/main/opengeometry/src/utility/geometry.rs deleted file mode 100644 index 2614741..0000000 --- a/main/opengeometry/src/utility/geometry.rs +++ /dev/null @@ -1,149 +0,0 @@ -use core::num; -use std::{collections::HashMap, hash::Hash, ptr::null}; - -use serde::{Deserialize, Serialize}; -use wasm_bindgen::prelude::*; - -use openmaths::Vector3; - -// pub fn add_extrude_in_up(&self, height: f64, up_vector: Vector3D) -> Vector3D { -// Vector3D { -// x: self.x + up_vector.x * height, -// y: self.y + up_vector.y * height, -// z: self.z + up_vector.z * height -// } -// } - -// pub fn cross(&self, other: &Vector3D) -> Vector3D { -// Vector3D { -// x: self.y * other.z - self.z * other.y, -// y: self.z * other.x - self.x * other.z, -// z: self.x * other.y - self.y * other.x -// } -// } - -// pub fn dot(&self, other: &Vector3D) -> f64 { -// self.x * other.x + self.y * other.y + self.z * other.z -// } - -#[wasm_bindgen] -#[derive(Copy, Clone, Serialize, Deserialize)] -pub struct ColorRGB { - pub r: u8, - pub g: u8, - pub b: u8, -} - -#[wasm_bindgen] -impl ColorRGB { - #[wasm_bindgen(constructor)] - pub fn new(r: u8, g: u8, b: u8) -> ColorRGB { - ColorRGB { r, g, b } - } - - pub fn to_hex(&self) -> String { - format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b) - } -} - -#[wasm_bindgen] -pub struct Color { - hex: String, -} - -#[wasm_bindgen] -impl Color { - #[wasm_bindgen(constructor)] - pub fn new(hex: String) -> Color { - Color { hex } - } - - #[wasm_bindgen] - pub fn to_rgba(&self) -> Result { - let hex = self.hex.trim_start_matches('#'); - let len = hex.len(); - - if len != 6 && len != 8 { - return Err("Hex string must be in the format #RRGGBB or #RRGGBBAA".to_string()); - } - - let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid red component")?; - let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid green component")?; - let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid blue component")?; - - Ok(ColorRGB { r, g, b }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct Geometry { - pub vertices: Vec, - pub edges: Vec>, - pub faces: Vec>, -} - -impl Geometry { - pub fn new() -> Geometry { - Geometry { - vertices: Vec::new(), - edges: Vec::new(), - faces: Vec::new(), - } - } - - pub fn get_geometry(&self) -> String { - // serialize geometry - let serialized = serde_json::to_string(&self).unwrap(); - serialized - } - - pub fn get_geometry_raw(&self) -> Geometry { - self.clone() - } - - pub fn get_faces(&self) -> Vec> { - self.faces.clone() - } - - pub fn add_edge(&mut self, edge: Vec) { - self.edges.push(edge); - } - - pub fn clear(&mut self) { - self.vertices.clear(); - self.edges.clear(); - self.faces.clear(); - } -} - -// Brep Geometry with Holes -#[derive(Clone, Serialize, Deserialize)] -pub struct Geometry_Holes { - pub vertices: Vec, - pub edges: Vec>, - pub faces: Vec>, - pub holes: Vec>, - pub face_holes_map: HashMap>, - pub is_ccw_last_face: bool, - pub face_length: usize, -} - -impl Geometry_Holes { - pub fn new() -> Geometry_Holes { - Geometry_Holes { - vertices: Vec::new(), - edges: Vec::new(), - faces: Vec::new(), - holes: Vec::new(), - face_holes_map: HashMap::new(), - is_ccw_last_face: false, - face_length: 0, - } - } - - pub fn get_geometry(&self) -> String { - // serialize geometry - let serialized = serde_json::to_string(&self).unwrap(); - serialized - } -} diff --git a/main/opengeometry/tests/primitives_smoke.rs b/main/opengeometry/tests/primitives_smoke.rs index 7684993..f41ba89 100644 --- a/main/opengeometry/tests/primitives_smoke.rs +++ b/main/opengeometry/tests/primitives_smoke.rs @@ -1,11 +1,14 @@ use opengeometry::primitives::line::OGLine; +use opengeometry::primitives::rectangle::OGRectangle; use opengeometry::primitives::sphere::OGSphere; use openmaths::Vector3; #[test] fn sphere_geometry_and_outline_are_non_empty() { let mut sphere = OGSphere::new("sphere-smoke".to_string()); - sphere.set_config(Vector3::new(0.0, 0.0, 0.0), 1.0, 16, 10); + sphere + .set_config(Vector3::new(0.0, 0.0, 0.0), 1.0, 16, 10) + .unwrap(); assert!(!sphere.brep().vertices.is_empty()); assert!(!sphere.brep().edges.is_empty()); @@ -24,7 +27,9 @@ fn sphere_geometry_and_outline_are_non_empty() { #[test] fn sphere_segment_inputs_are_clamped() { let mut sphere = OGSphere::new("sphere-clamp".to_string()); - sphere.set_config(Vector3::new(0.0, 0.0, 0.0), 1.0, 1, 1); + sphere + .set_config(Vector3::new(0.0, 0.0, 0.0), 1.0, 1, 1) + .unwrap(); assert!(!sphere.brep().vertices.is_empty()); assert!(!sphere.brep().faces.is_empty()); @@ -33,9 +38,25 @@ fn sphere_segment_inputs_are_clamped() { #[test] fn line_offset_smoke() { let mut line = OGLine::new("line-smoke".to_string()); - line.set_config(Vector3::new(-1.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)); - line.generate_geometry(); + line.set_config(Vector3::new(-1.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)) + .unwrap(); + line.generate_geometry().unwrap(); let result = line.get_offset_result(0.25, 35.0, true); assert_eq!(result.points.len(), 2); } + +#[test] +fn rectangle_generates_face_loop_without_duplicate_halfedges() { + let mut rectangle = OGRectangle::new("rectangle-smoke".to_string()); + rectangle + .set_config(Vector3::new(0.0, 0.0, 0.0), 2.0, 1.0) + .unwrap(); + rectangle.generate_geometry().unwrap(); + + assert_eq!(rectangle.brep().faces.len(), 1); + assert!(rectangle.brep().wires.is_empty()); + + let geometry: Vec = serde_json::from_str(&rectangle.get_geometry_serialized()).unwrap(); + assert_eq!(geometry.len(), 15); +} From f837de8fc86c55e9cce89f0a49f10cfffb53c075 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sun, 8 Mar 2026 12:06:16 +0100 Subject: [PATCH 02/10] updates --- .../pages/operations-sweep-path-profile.ts | 7 +- .../src/pages/operations-wall-from-offsets.ts | 12 +- .../examples-vite/src/pages/shapes-cuboid.ts | 4 + .../src/pages/shapes-cylinder.ts | 4 + .../examples-vite/src/pages/shapes-opening.ts | 4 + .../examples-vite/src/pages/shapes-polygon.ts | 4 + .../examples-vite/src/pages/shapes-sphere.ts | 4 + .../examples-vite/src/pages/shapes-sweep.ts | 4 + .../examples-vite/src/pages/shapes-wedge.ts | 4 + .../examples/offset-bug.png | Bin 66628 -> 0 bytes main/opengeometry-three/examples/sweep.html | 2 + main/opengeometry-three/index.ts | 574 ------------------ .../opengeometry-three/src/examples/shapes.ts | 17 + main/opengeometry-three/src/examples/sweep.ts | 2 + .../opengeometry-three/src/primitives/line.ts | 1 - .../src/primitives/rectangle.ts | 54 ++ main/opengeometry-three/src/shapes/cuboid.ts | 93 ++- .../opengeometry-three/src/shapes/cylinder.ts | 90 ++- main/opengeometry-three/src/shapes/opening.ts | 90 ++- .../src/shapes/outline-utils.ts | 106 ++++ main/opengeometry-three/src/shapes/polygon.ts | 125 ++-- main/opengeometry-three/src/shapes/sphere.ts | 85 ++- main/opengeometry-three/src/shapes/sweep.ts | 87 ++- main/opengeometry-three/src/shapes/wedge.ts | 85 ++- 24 files changed, 694 insertions(+), 764 deletions(-) delete mode 100644 main/opengeometry-three/examples/offset-bug.png create mode 100644 main/opengeometry-three/src/shapes/outline-utils.ts diff --git a/main/opengeometry-three/examples-vite/src/pages/operations-sweep-path-profile.ts b/main/opengeometry-three/examples-vite/src/pages/operations-sweep-path-profile.ts index 309f579..942a6cf 100644 --- a/main/opengeometry-three/examples-vite/src/pages/operations-sweep-path-profile.ts +++ b/main/opengeometry-three/examples-vite/src/pages/operations-sweep-path-profile.ts @@ -49,6 +49,9 @@ bootstrapExample({ { type: "number", key: "profileDepth", label: "Profile Depth", min: 0.1, max: 1.5, step: 0.05, value: 0.4 }, { type: "boolean", key: "capStart", label: "Cap Start", value: true }, { type: "boolean", key: "capEnd", label: "Cap End", value: false }, + { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const pathPrimitive = new Polyline({ @@ -72,8 +75,10 @@ bootstrapExample({ color: 0x2a9d8f, capStart: state.capStart as boolean, capEnd: state.capEnd as boolean, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); - sweep.outline = true; + sweep.outline = state.outline as boolean; pathPrimitive.position.y += 0.01; profilePrimitive.position.set(-3.0, 0.0, -2.2); diff --git a/main/opengeometry-three/examples-vite/src/pages/operations-wall-from-offsets.ts b/main/opengeometry-three/examples-vite/src/pages/operations-wall-from-offsets.ts index 86f6db0..39f00cb 100644 --- a/main/opengeometry-three/examples-vite/src/pages/operations-wall-from-offsets.ts +++ b/main/opengeometry-three/examples-vite/src/pages/operations-wall-from-offsets.ts @@ -38,6 +38,9 @@ bootstrapExample({ { type: "number", key: "acute", label: "Acute Threshold", min: 1, max: 179, step: 1, value: 90 }, { type: "number", key: "curveBias", label: "Curve Bias", min: -0.8, max: 0.8, step: 0.05, value: 0.0 }, { type: "boolean", key: "bevel", label: "Bevel", value: true }, + { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const centerline = new Polyline({ @@ -57,9 +60,14 @@ bootstrapExample({ return; } - const polygon = new Polygon({ vertices: outline, color: 0x3b82f6 }); + const polygon = new Polygon({ + vertices: outline, + color: 0x3b82f6, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, + }); polygon.position.y = 0.01; - polygon.outline = true; + polygon.outline = state.outline as boolean; const group = new THREE.Group(); group.add(centerline); diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-cuboid.ts b/main/opengeometry-three/examples-vite/src/pages/shapes-cuboid.ts index f1faf25..cb74765 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-cuboid.ts +++ b/main/opengeometry-three/examples-vite/src/pages/shapes-cuboid.ts @@ -18,6 +18,8 @@ bootstrapExample({ { type: "number", key: "height", label: "Height", min: 0.2, max: 4, step: 0.05, value: 1.6 }, { type: "number", key: "depth", label: "Depth", min: 0.2, max: 4, step: 0.05, value: 1.2 }, { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const cuboid = new Cuboid({ @@ -26,6 +28,8 @@ bootstrapExample({ height: state.height as number, depth: state.depth as number, color: 0x10b981, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); cuboid.outline = state.outline as boolean; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-cylinder.ts b/main/opengeometry-three/examples-vite/src/pages/shapes-cylinder.ts index 72c4c82..df3c611 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-cylinder.ts +++ b/main/opengeometry-three/examples-vite/src/pages/shapes-cylinder.ts @@ -19,6 +19,8 @@ bootstrapExample({ { type: "number", key: "segments", label: "Segments", min: 6, max: 96, step: 1, value: 36 }, { type: "number", key: "angleDeg", label: "Angle (deg)", min: 20, max: 360, step: 1, value: 360 }, { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const cylinder = new Cylinder({ @@ -28,6 +30,8 @@ bootstrapExample({ segments: Math.floor(state.segments as number), angle: ((state.angleDeg as number) * Math.PI) / 180, color: 0xf59e0b, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); cylinder.outline = state.outline as boolean; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-opening.ts b/main/opengeometry-three/examples-vite/src/pages/shapes-opening.ts index f1371d5..f843843 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-opening.ts +++ b/main/opengeometry-three/examples-vite/src/pages/shapes-opening.ts @@ -18,6 +18,8 @@ bootstrapExample({ { type: "number", key: "height", label: "Height", min: 0.2, max: 4, step: 0.05, value: 2.0 }, { type: "number", key: "depth", label: "Depth", min: 0.1, max: 2, step: 0.05, value: 0.35 }, { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const opening = new Opening({ @@ -26,6 +28,8 @@ bootstrapExample({ height: state.height as number, depth: state.depth as number, color: 0x94a3b8, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); opening.outline = state.outline as boolean; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-polygon.ts b/main/opengeometry-three/examples-vite/src/pages/shapes-polygon.ts index f684b45..ef1131d 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-polygon.ts +++ b/main/opengeometry-three/examples-vite/src/pages/shapes-polygon.ts @@ -30,11 +30,15 @@ bootstrapExample({ { type: "number", key: "sides", label: "Sides", min: 3, max: 12, step: 1, value: 5 }, { type: "number", key: "radius", label: "Radius", min: 0.4, max: 3, step: 0.05, value: 1.8 }, { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const polygon = new Polygon({ vertices: buildPolygonVertices(state.sides as number, state.radius as number), color: 0x2563eb, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); polygon.outline = state.outline as boolean; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-sphere.ts b/main/opengeometry-three/examples-vite/src/pages/shapes-sphere.ts index 3a58316..680cc98 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-sphere.ts +++ b/main/opengeometry-three/examples-vite/src/pages/shapes-sphere.ts @@ -18,6 +18,8 @@ bootstrapExample({ { type: "number", key: "widthSegments", label: "Width Segments", min: 3, max: 96, step: 1, value: 32 }, { type: "number", key: "heightSegments", label: "Height Segments", min: 2, max: 64, step: 1, value: 20 }, { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const radius = state.radius as number; @@ -27,6 +29,8 @@ bootstrapExample({ widthSegments: Math.floor(state.widthSegments as number), heightSegments: Math.floor(state.heightSegments as number), color: 0x0891b2, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); sphere.outline = state.outline as boolean; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-sweep.ts b/main/opengeometry-three/examples-vite/src/pages/shapes-sweep.ts index 96a3edb..0ded1c9 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-sweep.ts +++ b/main/opengeometry-three/examples-vite/src/pages/shapes-sweep.ts @@ -40,6 +40,8 @@ bootstrapExample({ { type: "boolean", key: "capStart", label: "Cap Start", value: true }, { type: "boolean", key: "capEnd", label: "Cap End", value: true }, { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const sweep = new Sweep({ @@ -48,6 +50,8 @@ bootstrapExample({ color: 0x0ea5e9, capStart: state.capStart as boolean, capEnd: state.capEnd as boolean, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); sweep.outline = state.outline as boolean; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-wedge.ts b/main/opengeometry-three/examples-vite/src/pages/shapes-wedge.ts index d19f7cf..8f4a7eb 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-wedge.ts +++ b/main/opengeometry-three/examples-vite/src/pages/shapes-wedge.ts @@ -18,6 +18,8 @@ bootstrapExample({ { type: "number", key: "height", label: "Height", min: 0.2, max: 4, step: 0.05, value: 1.8 }, { type: "number", key: "depth", label: "Depth", min: 0.2, max: 4, step: 0.05, value: 1.4 }, { type: "boolean", key: "outline", label: "Outline", value: true }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, ], (state) => { const wedge = new Wedge({ @@ -26,6 +28,8 @@ bootstrapExample({ height: state.height as number, depth: state.depth as number, color: 0x7c3aed, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, }); wedge.outline = state.outline as boolean; diff --git a/main/opengeometry-three/examples/offset-bug.png b/main/opengeometry-three/examples/offset-bug.png deleted file mode 100644 index 3ced2d9aadc351ba9a852ef6dd7fed8546136b91..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66628 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 diff --git a/main/opengeometry-three/examples/sweep.html b/main/opengeometry-three/examples/sweep.html index 9b9caf8..4c298c0 100644 --- a/main/opengeometry-three/examples/sweep.html +++ b/main/opengeometry-three/examples/sweep.html @@ -103,6 +103,8 @@ const { pathPrimitive, profilePrimitive, sweep } = createSweepExample(scene); pathPrimitive.position.y += 0.01; profilePrimitive.position.set(-3.2, 0.0, -2.4); + sweep.fatOutlines = true; + sweep.outlineWidth = 6; sweep.outline = true; function onResize() { diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 01a6e71..2a61930 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -75,580 +75,6 @@ export class OpenGeometry { } } -// export class BasePoly extends THREE.Mesh { -// ogid: string; -// layerVertices: Vector3[] = []; -// layerBackVertices: Vector3[] = []; - -// polygon: BasePolygon | null = null; -// isTriangulated: boolean = false; - -// constructor(vertices?: Vector3[]) { -// super(); -// this.ogid = getUUID(); -// this.polygon = new BasePolygon(this.ogid); - -// if (vertices) { -// this.polygon.add_vertices(vertices); - -// // Triangulate the polygon -// this.polygon?.triangulate(); - -// const bufFlush = this.polygon?.get_buffer_flush(); -// this.addFlushBufferToScene(bufFlush); -// } -// } - -// addVertices(vertices: Vector3[]) { -// if (!this.polygon) return; -// this.polygon.add_vertices(vertices); -// this.polygon?.triangulate(); -// const bufFlush = this.polygon?.get_buffer_flush(); -// this.addFlushBufferToScene(bufFlush); -// } - -// resetVertices() { -// if (!this.polygon) return; -// this.layerVertices = []; -// this.geometry.dispose(); -// this.polygon?.reset_polygon(); -// this.isTriangulated = false; -// } - -// addVertex(threeVertex: Vector3) { -// if (this.isTriangulated) { -// this.layerVertices = []; -// this.geometry.dispose(); -// this.polygon?.reset_polygon(); -// this.isTriangulated = false; - -// for (const vertex of this.layerBackVertices) { -// this.layerVertices.push(vertex.clone()); -// } - -// }; - -// const backupVertex = new Vector3( -// parseFloat(threeVertex.x.toFixed(2)), -// 0, -// parseFloat(threeVertex.z.toFixed(2)) -// ); -// this.layerBackVertices.push(backupVertex); - -// const vertex = new Vector3( -// parseFloat(threeVertex.x.toFixed(2)), -// // when doing the parse operation getting -0 instead of 0 -// 0, -// parseFloat(threeVertex.z.toFixed(2)) -// ); -// this.layerVertices.push(vertex); - -// if (this.layerVertices.length > 3) { -// this.polygon?.add_vertices(this.layerVertices); -// const bufFlush = this.polygon?.triangulate(); - -// if (!bufFlush) { -// return; -// } -// this.addFlushBufferToScene(bufFlush); - -// this.isTriangulated = true; -// } -// } - -// addHole(holeVertices: Vector3[]) { -// if (!this.polygon) return; -// this.polygon.add_holes(holeVertices); -// const triResult = JSON.parse(this.polygon.new_triangulate()); -// const newBufferFlush = triResult.new_buffer; -// const geometry = new THREE.BufferGeometry(); -// geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(newBufferFlush), 3)); -// this.geometry = geometry; - -// // const bufFlush = this.polygon.get_buffer_flush(); -// // this.addFlushBufferToScene(bufFlush); -// } - -// addFlushBufferToScene(flush: string) { -// const flushBuffer = JSON.parse(flush); -// const geometry = new THREE.BufferGeometry(); -// geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flushBuffer), 3)); -// // geometry.computeVertexNormals(); - -// const material = new THREE.MeshStandardMaterial({ -// color: 0x00ff00, -// // side: THREE.DoubleSide, -// transparent: true, -// opacity: 0.5, -// // wireframe: true -// }); - -// this.geometry = geometry; -// this.material = material; -// } - -// extrude(height: number) { -// if (!this.polygon) return; -// const extruded_buff = this.polygon.extrude_by_height(height); -// this.generateExtrudedGeometry(extruded_buff); -// } - -// generateExtrudedGeometry(extruded_buff: string) { -// // THIS WORKS -// const flushBuffer = JSON.parse(extruded_buff); -// console.log(flushBuffer); - -// // const geometry = new THREE.BufferGeometry(); -// // geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flushBuffer), 3)); -// // // geometry.computeVertexNormals(); - -// // // const material = new THREE.MeshPhongMaterial({ -// // // color: 0x3a86ff, -// // // }); -// // // material.side = THREE.DoubleSide; - -// // this.geometry = geometry; -// // this.material = material; -// } -// } - -// interface IBaseCircleOptions { -// radius: number; -// segments: number; -// position: Vector3; -// startAngle: number; -// endAngle: number; -// } - -// export class CirclePoly extends THREE.Mesh { -// ogid: string; -// polygon: OGPolygon | null = null; -// baseCircle: Arc; -// isExtruded: boolean = false; - -// constructor(baseCircle: Arc) { -// super(); -// this.ogid = getUUID(); - -// if (!baseCircle.circleArc) { -// throw new Error("CircleArc is not defined"); -// } -// // baseCircle.nodeChild = this; -// baseCircle.nodeOperation = "polygon"; -// this.baseCircle = baseCircle; - -// this.generateGeometry(); -// this.addFlushBufferToScene(); -// } - -// update() { -// this.geometry.dispose(); - -// this.polygon?.clear_vertices(); -// this.polygon?.add_vertices(this.baseCircle.circleArc.get_raw_points()); - -// this.generateGeometry(); -// this.addFlushBufferToScene(); -// } - -// generateGeometry() { -// if (!this.baseCircle.circleArc) return; -// this.polygon = OGPolygon.new_with_circle(this.baseCircle.circleArc.clone()); -// } - -// addFlushBufferToScene() { -// if (!this.polygon) return; -// const bufFlush = this.polygon.get_buffer_flush(); -// const flushBuffer = JSON.parse(bufFlush); -// const geometry = new THREE.BufferGeometry(); -// geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flushBuffer), 3)); - -// // TODO: Do this using a set method, poly.visualizeTriangles = true -// // different colors for each triangle in the polygon dont interolate -// // const colors = new Float32Array(flushBuffer.length); -// // for (let i = 0; i < colors.length; i += 9) { -// // const r = Math.random(); -// // const g = Math.random(); -// // const b = Math.random(); -// // colors[i] = r; -// // colors[i + 1] = g; -// // colors[i + 2] = b; -// // colors[i + 3] = r; -// // colors[i + 4] = g; -// // colors[i + 5] = b; -// // colors[i + 6] = r; -// // colors[i + 7] = g; -// // colors[i + 8] = b; -// // } - -// // geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - -// const material = new THREE.MeshStandardMaterial({ -// color: 0x00ff00, -// // side: THREE.DoubleSide, -// transparent: true, -// opacity: 0.5, -// // wireframe: true -// }); - -// this.geometry = geometry; -// this.material = material; -// } - -// clearGeometry() { -// this.geometry.dispose(); -// } - -// extrude(height: number) { -// if (!this.polygon) return; -// const extruded_buff = this.polygon.extrude_by_height(height); -// console.log(JSON.parse(extruded_buff)); -// this.isExtruded = true; - -// this.generateExtrudedGeometry(extruded_buff); -// } - -// getBrepData() { -// if (!this.polygon) return; -// const brepData = this.polygon.get_brep_data(); -// const parsedData = JSON.parse(brepData); -// console.log(parsedData); -// } - -// getOutline(type: OUTLINE_TYPE) { -// if (!this.polygon) return; -// const outlines = this.polygon.get_outlines(); -// const outlineBuffer = JSON.parse(outlines); - -// // TODO: move this logic to Kernel -// const faces = []; -// for (const data of outlineBuffer) { -// const vertices = []; -// for (const vertex of data) { -// const x_float = type === "side" ? 0 : parseFloat(vertex.x.toFixed(5)); -// const y_float = type === "top" ? 0 : parseFloat(vertex.y.toFixed(5)); -// const z_float = type === "front" ? 0 : parseFloat(vertex.z.toFixed(5)); -// vertices.push(new THREE.Vector3(x_float, y_float, z_float)); -// } -// faces.push(vertices); -// } - -// const clonedFaces = faces.map((face) => { -// return face.map((vertex) => { -// return new THREE.Vector3(vertex.x, vertex.y, vertex.z); -// }); -// } -// ); - -// // remove duplicates inside the faces -// const uniqueFaces = clonedFaces.map((face) => { -// return face.filter((vertex, index, self) => -// index === self.findIndex((v) => ( -// v.x === vertex.x && v.y === vertex.y && v.z === vertex.z -// )) -// ); -// }); - -// // Picking unique vertices from all faces -// const uniqueVertices = []; -// const vertexSet = new Set(); -// for (const face of uniqueFaces) { -// for (const vertex of face) { -// const key = `${vertex.x},${vertex.y},${vertex.z}`; -// if (!vertexSet.has(key)) { -// vertexSet.add(key); -// uniqueVertices.push(vertex); -// } -// } -// } - -// // arrange the vertices in a clockwise manner -// const center = new THREE.Vector3(); -// for (const vertex of uniqueVertices) { -// center.add(vertex); -// } -// center.divideScalar(uniqueVertices.length); -// uniqueVertices.sort((a, b) => { -// if (type === "side") { -// const angleA = Math.atan2(a.y - center.y, a.z - center.z); -// const angleB = Math.atan2(b.y - center.y, b.z - center.z); -// return angleA - angleB; -// } else if (type === "top") { -// const angleA = Math.atan2(a.x - center.x, a.z - center.z); -// const angleB = Math.atan2(b.x - center.x, b.z - center.z); -// return angleA - angleB; -// } -// const angleA = Math.atan2(a.x - center.x, a.y - center.y); -// const angleB = Math.atan2(b.x - center.x, b.y - center.y); -// return angleA - angleB; -// } -// ); - -// // merge collinear vertices -// const mergedVertices = []; -// for (let i = 0; i < uniqueVertices.length; i++) { -// const current = uniqueVertices[i]; -// const next = uniqueVertices[(i + 1) % uniqueVertices.length]; -// const prev = uniqueVertices[(i - 1 + uniqueVertices.length) % uniqueVertices.length]; - -// const v1 = new THREE.Vector3().subVectors(current, prev); -// const v2 = new THREE.Vector3().subVectors(next, current); - -// if (v1.angleTo(v2) > 0.01) { -// mergedVertices.push(current); -// } -// } - -// mergedVertices.push(mergedVertices[0]); -// // TODO: move logic until here to Kernel - -// // Create a new geometry with the merged vertices -// const mergedGeometry = new THREE.BufferGeometry().setFromPoints(mergedVertices); -// const mergedMaterial = new THREE.MeshBasicMaterial({ color: 0x000000, side: THREE.DoubleSide }); -// const mergedMesh = new THREE.Line(mergedGeometry, mergedMaterial); -// return mergedMesh; -// } - -// generateExtrudedGeometry(extruded_buff: string) { -// // THIS WORKS -// const flushBuffer = JSON.parse(extruded_buff); -// const geometry = new THREE.BufferGeometry(); -// geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flushBuffer), 3)); - -// // To Test If Triangulation is working -// // const colors = new Float32Array(flushBuffer.length); -// // for (let i = 0; i < colors.length; i += 9) { -// // const r = Math.random(); -// // const g = Math.random(); -// // const b = Math.random(); -// // colors[i] = r; -// // colors[i + 1] = g; -// // colors[i + 2] = b; -// // colors[i + 3] = r; -// // colors[i + 4] = g; -// // colors[i + 5] = b; -// // colors[i + 6] = r; -// // colors[i + 7] = g; -// // colors[i + 8] = b; -// // } - -// // geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - -// // const material = new THREE.MeshPhongMaterial( { -// // color: 0xffffff, -// // flatShading: true, -// // vertexColors: true, -// // shininess: 0, -// // side: THREE.DoubleSide -// // }); - -// // const material = new THREE.MeshPhongMaterial({ -// // color: 0x3a86ff, -// // }); -// // material.side = THREE.DoubleSide; - -// this.geometry = geometry; -// // this.material = material; -// } - -// dispose() { -// if (!this.polygon) return; -// this.geometry.dispose(); -// this.polygon?.clear_vertices(); -// this.polygon = null; -// this.isExtruded = false; -// } -// } - -// export class RectanglePoly extends THREE.Mesh { -// ogid: string; -// polygon: BasePolygon | null = null; -// baseRectangle: Rectangle; -// isExtruded: boolean = false; -// constructor(baseRectangle: Rectangle) { -// super(); -// this.ogid = getUUID(); - -// // if (!baseRectangle.polyLineRectangle) { -// // throw new Error("BaseRectangle is not defined"); -// // } -// // // baseRectangle.nodeChild = this; -// // baseRectangle.nodeOperation = "polygon"; -// this.baseRectangle = baseRectangle; - -// this.generateGeometry(); -// this.addFlushBufferToScene(); -// } - -// update() { -// this.geometry.dispose(); -// this.polygon?.clear_vertices(); -// // this.polygon?.add_vertices(this.baseRectangle.polyLineRectangle.get_raw_points()); -// this.generateGeometry(); -// this.addFlushBufferToScene(); -// } -// generateGeometry() { -// // if (!this.baseRectangle.polyLineRectangle) return; -// // this.polygon = BasePolygon.new_with_rectangle(this.baseRectangle.polyLineRectangle.clone()); -// } - -// addFlushBufferToScene() { -// if (!this.polygon) return; -// const bufFlush = this.polygon.get_buffer_flush(); -// const flushBuffer = JSON.parse(bufFlush); -// const geometry = new THREE.BufferGeometry(); -// geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flushBuffer), 3)); -// const material = new THREE.MeshStandardMaterial({ color: 0x3a86ff, transparent: true, opacity: 0.5 }); -// this.geometry = geometry; -// this.material = material; -// } - -// clearGeometry() { -// this.geometry.dispose(); -// } - -// extrude(height: number) { -// if (!this.polygon) return; -// const extruded_buff = this.polygon.extrude_by_height(height); -// this.isExtruded = true; -// this.generateExtrudedGeometry(extruded_buff); -// } - -// generateExtrudedGeometry(extruded_buff: string) { -// // THIS WORKS -// const flushBuffer = JSON.parse(extruded_buff); -// const geometry = new THREE.BufferGeometry(); -// geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flushBuffer), 3)); -// geometry.computeVertexNormals(); - -// // const colors = new Float32Array(flushBuffer.length); -// // for (let i = 0; i < colors.length; i += 9) { -// // const r = Math.random(); -// // const g = Math.random(); -// // const b = Math.random(); -// // colors[i] = r; -// // colors[i + 1] = g; -// // colors[i + 2] = b; -// // colors[i + 3] = r; -// // colors[i + 4] = g; -// // colors[i + 5] = b; -// // colors[i + 6] = r; -// // colors[i + 7] = g; -// // colors[i + 8] = b; -// // } -// // geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); -// // const material = new THREE.MeshPhongMaterial( { -// // color: 0xffffff, -// // flatShading: true, -// // vertexColors: true, -// // shininess: 0, -// // side: THREE.DoubleSide -// // }); - -// const material = new THREE.MeshPhongMaterial({ -// color: 0x3a86ff, -// }); -// material.side = THREE.DoubleSide; - -// this.geometry = geometry; -// this.material = material; -// } - -// getOutline(type: OUTLINE_TYPE) { -// if (!this.polygon) return; -// const outlines = this.polygon.get_outlines(); -// const outlineBuffer = JSON.parse(outlines); - -// // TODO: move this logic to Kernel -// const faces = []; -// for (const data of outlineBuffer) { -// const vertices = []; -// for (const vertex of data) { -// const x_float = type === "side" ? 0 : parseFloat(vertex.x.toFixed(5)); -// const y_float = type === "top" ? 0 : parseFloat(vertex.y.toFixed(5)); -// const z_float = type === "front" ? 0 : parseFloat(vertex.z.toFixed(5)); -// vertices.push(new THREE.Vector3(x_float, y_float, z_float)); -// } -// faces.push(vertices); -// } - -// const clonedFaces = faces.map((face) => { -// return face.map((vertex) => { -// return new THREE.Vector3(vertex.x, vertex.y, vertex.z); -// }); -// } -// ); - -// // remove duplicates inside the faces -// const uniqueFaces = clonedFaces.map((face) => { -// return face.filter((vertex, index, self) => -// index === self.findIndex((v) => ( -// v.x === vertex.x && v.y === vertex.y && v.z === vertex.z -// )) -// ); -// }); - -// // Picking unique vertices from all faces -// const uniqueVertices = []; -// const vertexSet = new Set(); -// for (const face of uniqueFaces) { -// for (const vertex of face) { -// const key = `${vertex.x},${vertex.y},${vertex.z}`; -// if (!vertexSet.has(key)) { -// vertexSet.add(key); -// uniqueVertices.push(vertex); -// } -// } -// } - -// // arrange the vertices in a clockwise manner -// const center = new THREE.Vector3(); -// for (const vertex of uniqueVertices) { -// center.add(vertex); -// } -// center.divideScalar(uniqueVertices.length); -// uniqueVertices.sort((a, b) => { -// if (type === "side") { -// const angleA = Math.atan2(a.y - center.y, a.z - center.z); -// const angleB = Math.atan2(b.y - center.y, b.z - center.z); -// return angleA - angleB; -// } else if (type === "top") { -// const angleA = Math.atan2(a.x - center.x, a.z - center.z); -// const angleB = Math.atan2(b.x - center.x, b.z - center.z); -// return angleA - angleB; -// } -// const angleA = Math.atan2(a.x - center.x, a.y - center.y); -// const angleB = Math.atan2(b.x - center.x, b.y - center.y); -// return angleA - angleB; -// } -// ); - -// // merge collinear vertices -// const mergedVertices = []; -// for (let i = 0; i < uniqueVertices.length; i++) { -// const current = uniqueVertices[i]; -// const next = uniqueVertices[(i + 1) % uniqueVertices.length]; -// const prev = uniqueVertices[(i - 1 + uniqueVertices.length) % uniqueVertices.length]; - -// const v1 = new THREE.Vector3().subVectors(current, prev); -// const v2 = new THREE.Vector3().subVectors(next, current); - -// if (v1.angleTo(v2) > 0.01) { -// mergedVertices.push(current); -// } -// } - -// mergedVertices.push(mergedVertices[0]); -// // TODO: move logic until here to Kernel - -// // Create a new geometry with the merged vertices -// const mergedGeometry = new THREE.BufferGeometry().setFromPoints(mergedVertices); -// const mergedMaterial = new THREE.MeshBasicMaterial({ color: 0x000000, side: THREE.DoubleSide }); -// const mergedMesh = new THREE.Line(mergedGeometry, mergedMaterial); -// return mergedMesh; -// } -// } - export { Vector3, SpotLabel, diff --git a/main/opengeometry-three/src/examples/shapes.ts b/main/opengeometry-three/src/examples/shapes.ts index b2006c1..d55d61d 100644 --- a/main/opengeometry-three/src/examples/shapes.ts +++ b/main/opengeometry-three/src/examples/shapes.ts @@ -12,6 +12,8 @@ import { Wedge } from "../shapes/wedge"; * Adds a basic shapes showcase to the provided scene. */ export function createShapesExample(scene: THREE.Scene) { + const fatOutlineWidth = 4; + const polygon = new Polygon({ vertices: [ new Vector3(-3.0, 0.0, -0.5), @@ -20,7 +22,10 @@ export function createShapesExample(scene: THREE.Scene) { new Vector3(-1.5, 0.0, -0.8), ], color: 0x2563eb, + fatOutlines: true, + outlineWidth: fatOutlineWidth, }); + polygon.outline = true; const cuboid = new Cuboid({ center: new Vector3(0.0, 0.8, -1.2), @@ -28,6 +33,8 @@ export function createShapesExample(scene: THREE.Scene) { height: 1.6, depth: 1.0, color: 0x10b981, + fatOutlines: true, + outlineWidth: fatOutlineWidth, }); cuboid.outline = true; @@ -38,6 +45,8 @@ export function createShapesExample(scene: THREE.Scene) { segments: 28, angle: Math.PI * 2, color: 0xf97316, + fatOutlines: true, + outlineWidth: fatOutlineWidth, }); cylinder.outline = true; @@ -47,6 +56,8 @@ export function createShapesExample(scene: THREE.Scene) { height: 1.4, depth: 1.0, color: 0x7c3aed, + fatOutlines: true, + outlineWidth: fatOutlineWidth, }); wedge.outline = true; @@ -56,6 +67,8 @@ export function createShapesExample(scene: THREE.Scene) { widthSegments: 28, heightSegments: 18, color: 0x0ea5e9, + fatOutlines: true, + outlineWidth: fatOutlineWidth, }); sphere.outline = true; @@ -65,6 +78,8 @@ export function createShapesExample(scene: THREE.Scene) { height: 1.8, depth: 0.3, color: 0x9ca3af, + fatOutlines: true, + outlineWidth: fatOutlineWidth, }); opening.outline = true; @@ -84,6 +99,8 @@ export function createShapesExample(scene: THREE.Scene) { color: 0x14b8a6, capStart: true, capEnd: true, + fatOutlines: true, + outlineWidth: fatOutlineWidth, }); sweep.outline = true; diff --git a/main/opengeometry-three/src/examples/sweep.ts b/main/opengeometry-three/src/examples/sweep.ts index 63bff96..ac51e6c 100644 --- a/main/opengeometry-three/src/examples/sweep.ts +++ b/main/opengeometry-three/src/examples/sweep.ts @@ -53,6 +53,8 @@ export function createSweepExample(scene: THREE.Scene) { color: 0x2a9d8f, capStart: true, capEnd: false, + fatOutlines: true, + outlineWidth: 4, }); scene.add(pathPrimitive); diff --git a/main/opengeometry-three/src/primitives/line.ts b/main/opengeometry-three/src/primitives/line.ts index 108a135..56a09b6 100644 --- a/main/opengeometry-three/src/primitives/line.ts +++ b/main/opengeometry-three/src/primitives/line.ts @@ -134,7 +134,6 @@ export class Line extends THREE.Line { new THREE.Float32BufferAttribute(bufferData, 3) ); - this.geometry = geometry; this.geometry = geometry; this.material = new THREE.LineBasicMaterial({ color: this.options.color }); diff --git a/main/opengeometry-three/src/primitives/rectangle.ts b/main/opengeometry-three/src/primitives/rectangle.ts index 6d36a11..4ef8275 100644 --- a/main/opengeometry-three/src/primitives/rectangle.ts +++ b/main/opengeometry-three/src/primitives/rectangle.ts @@ -1,6 +1,9 @@ import { OGRectangle, Vector3 } from "./../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { getUUID } from "../utils/randomizer"; +import { Line2 } from 'three/examples/jsm/lines/Line2.js'; +import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; +import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; export interface IRectangleOptions { ogid?: string; @@ -8,6 +11,8 @@ export interface IRectangleOptions { width: number; breadth: number; color: number; + fatLines?: boolean; + lineWidth?: number; } export interface IRectangleOffsetResult { @@ -40,6 +45,7 @@ export class Rectangle extends THREE.Line { }; private polyLineRectangle: OGRectangle; + private fatLine: Line2 | null = null; // set width(width: number) { // this.options.width = width; @@ -64,8 +70,18 @@ export class Rectangle extends THREE.Line { if (this.material instanceof THREE.LineBasicMaterial) { this.material.color.set(color); } + if (this.fatLine && this.fatLine.material instanceof LineMaterial) { + this.fatLine.material.color.set(color); + } } + // set lineWidth(lineWidth: number) { + // this.options.lineWidth = lineWidth; + // if (this.material instanceof THREE.LineBasicMaterial) { + // (this.material as THREE.LineBasicMaterial).linewidth = lineWidth; + // } + // } + // FINAL: This flow should be used for other primitives constructor(options?: IRectangleOptions) { super(); @@ -87,6 +103,13 @@ export class Rectangle extends THREE.Line { setConfig(options: IRectangleOptions) { this.validateOptions(); + // Render Config Update + // Note: For properties that directly impact rendering (like color), we can update them immediately without regenerating geometry. + this.options = { ...this.options, ...options }; + + console.log("Updated Rectangle Config:", this.options); + + // Kernel Config Update const { width, breadth, center } = options; this.polyLineRectangle.set_config( center.clone(), @@ -116,6 +139,37 @@ export class Rectangle extends THREE.Line { this.geometry = geometry; this.material = new THREE.LineBasicMaterial({ color: this.options.color }); + + if (this.options.fatLines) { + this.material.visible = false; + this.handleFatLines(bufferData); + } else { + this.material.visible = true; + if (this.fatLine) { + this.fatLine.visible = false; + } + } + } + + private handleFatLines(bufferData: number[]) { + if (!this.fatLine) { + this.fatLine = new Line2(new LineGeometry(), new LineMaterial({ color: this.options.color, linewidth: this.options.lineWidth, resolution: new THREE.Vector2(window.innerWidth, window.innerHeight) })); + this.add(this.fatLine); + } + + const positions = []; + for (let i = 0; i < bufferData.length; i += 3) { + positions.push(bufferData[i], bufferData[i + 1], bufferData[i + 2]); + } + + this.fatLine.geometry.setPositions(positions); + (this.fatLine.material as LineMaterial).color.set(this.options.color); + (this.fatLine.material as LineMaterial).linewidth = this.options.lineWidth ?? 1; + (this.fatLine.material as LineMaterial).resolution.set(window.innerWidth, window.innerHeight); + + this.fatLine.visible = true; + + console.log("Fat lines enabled for Rectangle"); } getBrep() { diff --git a/main/opengeometry-three/src/shapes/cuboid.ts b/main/opengeometry-three/src/shapes/cuboid.ts index 28b2bbb..8118c3e 100644 --- a/main/opengeometry-three/src/shapes/cuboid.ts +++ b/main/opengeometry-three/src/shapes/cuboid.ts @@ -1,6 +1,12 @@ import { OGCuboid, Vector3 } from "../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { getUUID } from "../utils/randomizer"; +import { + createShapeOutlineMesh, + disposeShapeOutlineMesh, + sanitizeOutlineWidth, + ShapeOutlineMesh, +} from "./outline-utils"; export interface ICuboidOptions { ogid?: string; @@ -9,6 +15,8 @@ export interface ICuboidOptions { height: number; depth: number; color: number; + fatOutlines?: boolean; + outlineWidth?: number; } export class Cuboid extends THREE.Mesh { @@ -19,10 +27,15 @@ export class Cuboid extends THREE.Mesh { height: 1, depth: 1, color: 0x00ff00, + fatOutlines: false, + outlineWidth: 1, }; private cuboid: OGCuboid; - #outlineMesh: THREE.Line | null = null; + #outlineMesh: ShapeOutlineMesh | null = null; + private _outlineEnabled = false; + private _fatOutlines = false; + private _outlineWidth = 1; // Store local center offset to align outlines // TODO: Can this be moved to Engine? It can increase performance | Needs to be used in other shapes too @@ -60,7 +73,13 @@ export class Cuboid extends THREE.Mesh { setConfig(options: ICuboidOptions) { this.validateOptions(); - const { width, height, depth, center, color } = options; + this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; + this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); + this.options.fatOutlines = this._fatOutlines; + this.options.outlineWidth = this._outlineWidth; + + const { width, height, depth, center, color } = this.options; this.cuboid.set_config( center.clone(), width, @@ -86,8 +105,7 @@ export class Cuboid extends THREE.Mesh { this.cleanGeometry(); // Kernel Geometry - // Since geometry is already generated in set_config, we don't need to call it again - // this.cuboid.generate_geometry(); + // Since Kernel Generates Geometry if we do set_config, we don't need to generate Geometry separately. We can directly get the geometry data and create Three.js geometry. const geometryData = this.cuboid.get_geometry_serialized(); const bufferData = JSON.parse(geometryData); @@ -110,7 +128,7 @@ export class Cuboid extends THREE.Mesh { this.material = material; // outline - if (this.#outlineMesh) { + if (this._outlineEnabled) { this.outline = true; } } @@ -125,42 +143,63 @@ export class Cuboid extends THREE.Mesh { } set outline(enable: boolean) { - if (this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; - } - + this._outlineEnabled = enable; + this.clearOutlineMesh(); if (enable) { const outline_buff = this.cuboid.get_outline_geometry_serialized(); - const outline_buf = JSON.parse(outline_buff); + const outline_buf = JSON.parse(outline_buff) as number[]; + this.#outlineMesh = createShapeOutlineMesh({ + positions: outline_buf, + color: 0x000000, + fatOutlines: this._fatOutlines, + outlineWidth: this._outlineWidth, + }); - const outlineGeometry = new THREE.BufferGeometry(); - outlineGeometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(outline_buf, 3) - ); + this.add(this.#outlineMesh); + } + } - const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 }); - this.#outlineMesh = new THREE.LineSegments( - outlineGeometry, - outlineMaterial - ); + get outline() { + return this._outlineEnabled; + } - this.add(this.#outlineMesh); + set fatOutlines(value: boolean) { + this._fatOutlines = value; + this.options.fatOutlines = value; + if (this._outlineEnabled) { + this.outline = true; } + } + + get fatOutlines() { + return this._fatOutlines; + } - if (!enable && this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; + set outlineWidth(value: number) { + this._outlineWidth = sanitizeOutlineWidth(value); + this.options.outlineWidth = this._outlineWidth; + if (this._outlineEnabled) { + this.outline = true; } } + get outlineWidth() { + return this._outlineWidth; + } + get outlineMesh() { return this.#outlineMesh; } + private clearOutlineMesh() { + if (!this.#outlineMesh) { + return; + } + this.remove(this.#outlineMesh); + disposeShapeOutlineMesh(this.#outlineMesh); + this.#outlineMesh = null; + } + discardGeometry() { this.geometry.dispose(); } diff --git a/main/opengeometry-three/src/shapes/cylinder.ts b/main/opengeometry-three/src/shapes/cylinder.ts index d1d5ad2..67489f5 100644 --- a/main/opengeometry-three/src/shapes/cylinder.ts +++ b/main/opengeometry-three/src/shapes/cylinder.ts @@ -1,6 +1,12 @@ import { OGCylinder, Vector3 } from "../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { getUUID } from "../utils/randomizer"; +import { + createShapeOutlineMesh, + disposeShapeOutlineMesh, + sanitizeOutlineWidth, + ShapeOutlineMesh, +} from "./outline-utils"; export interface ICylinderOptions { ogid?: string; @@ -10,6 +16,8 @@ export interface ICylinderOptions { segments: number; angle: number; color: number; + fatOutlines?: boolean; + outlineWidth?: number; } export class Cylinder extends THREE.Mesh { @@ -21,10 +29,15 @@ export class Cylinder extends THREE.Mesh { segments: 32, angle: 2 * Math.PI, color: 0x00ff00, + fatOutlines: false, + outlineWidth: 1, }; private cylinder: OGCylinder; - #outlineMesh: THREE.Line | null = null; + #outlineMesh: ShapeOutlineMesh | null = null; + private _outlineEnabled = false; + private _fatOutlines = false; + private _outlineWidth = 1; set radius(value: number) { this.options.radius = value; @@ -58,7 +71,13 @@ export class Cylinder extends THREE.Mesh { setConfig(options: ICylinderOptions) { this.validateOptions(); - const { radius, height, segments, angle, center } = options; + this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; + this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); + this.options.fatOutlines = this._fatOutlines; + this.options.outlineWidth = this._outlineWidth; + + const { radius, height, segments, angle, center } = this.options; this.cylinder.set_config( center?.clone(), radius, @@ -108,7 +127,7 @@ export class Cylinder extends THREE.Mesh { this.material = material; // outline - if (this.#outlineMesh) { + if (this._outlineEnabled) { this.outline = true; } } @@ -123,36 +142,57 @@ export class Cylinder extends THREE.Mesh { } set outline(enable: boolean) { - if (this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; - } - + this._outlineEnabled = enable; + this.clearOutlineMesh(); if (enable) { const outline_buff = this.cylinder.get_outline_geometry_serialized(); - const outline_buf = JSON.parse(outline_buff); + const outline_buf = JSON.parse(outline_buff) as number[]; + this.#outlineMesh = createShapeOutlineMesh({ + positions: outline_buf, + color: 0x000000, + fatOutlines: this._fatOutlines, + outlineWidth: this._outlineWidth, + }); - const outlineGeometry = new THREE.BufferGeometry(); - outlineGeometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(outline_buf, 3) - ); + this.add(this.#outlineMesh); + } + } - const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 }); - this.#outlineMesh = new THREE.LineSegments( - outlineGeometry, - outlineMaterial - ); + get outline() { + return this._outlineEnabled; + } - this.add(this.#outlineMesh); + set fatOutlines(value: boolean) { + this._fatOutlines = value; + this.options.fatOutlines = value; + if (this._outlineEnabled) { + this.outline = true; } + } + + get fatOutlines() { + return this._fatOutlines; + } + + set outlineWidth(value: number) { + this._outlineWidth = sanitizeOutlineWidth(value); + this.options.outlineWidth = this._outlineWidth; + if (this._outlineEnabled) { + this.outline = true; + } + } + + get outlineWidth() { + return this._outlineWidth; + } - if (!enable && this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; + private clearOutlineMesh() { + if (!this.#outlineMesh) { + return; } + this.remove(this.#outlineMesh); + disposeShapeOutlineMesh(this.#outlineMesh); + this.#outlineMesh = null; } discardGeometry() { diff --git a/main/opengeometry-three/src/shapes/opening.ts b/main/opengeometry-three/src/shapes/opening.ts index 1f0ffb6..0803959 100644 --- a/main/opengeometry-three/src/shapes/opening.ts +++ b/main/opengeometry-three/src/shapes/opening.ts @@ -1,6 +1,12 @@ import { OGCuboid, Vector3 } from "./../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { getUUID } from "../utils/randomizer"; +import { + createShapeOutlineMesh, + disposeShapeOutlineMesh, + sanitizeOutlineWidth, + ShapeOutlineMesh, +} from "./outline-utils"; export interface IOpeningOptions { ogid?: string; @@ -9,6 +15,8 @@ export interface IOpeningOptions { height: number; depth: number; color: number; + fatOutlines?: boolean; + outlineWidth?: number; } export class Opening extends THREE.Mesh { @@ -19,10 +27,15 @@ export class Opening extends THREE.Mesh { height: 1, depth: 0.2, color: 0xdad7cd, + fatOutlines: false, + outlineWidth: 1, } private opening: OGCuboid; - #outlineMesh: THREE.Line | null = null; + #outlineMesh: ShapeOutlineMesh | null = null; + private _outlineEnabled = false; + private _fatOutlines = false; + private _outlineWidth = 1; // Store local center offset to align outlines // TODO: Can this be moved to Engine? It can increase performance | Needs to be used in other shapes too @@ -71,7 +84,13 @@ export class Opening extends THREE.Mesh { setConfig(options: IOpeningOptions) { this.validateOptions(); - const { width, height, depth, center } = options; + this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; + this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); + this.options.fatOutlines = this._fatOutlines; + this.options.outlineWidth = this._outlineWidth; + + const { width, height, depth, center } = this.options; this.opening.set_config( center?.clone(), width, @@ -121,7 +140,7 @@ export class Opening extends THREE.Mesh { this.material = material; // outline - if (this.#outlineMesh) { + if (this._outlineEnabled) { this.outline = true; } } @@ -136,42 +155,63 @@ export class Opening extends THREE.Mesh { } set outline(enable: boolean) { - if (this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; - } - + this._outlineEnabled = enable; + this.clearOutlineMesh(); if (enable) { const outline_buff = this.opening.get_outline_geometry_serialized(); - const outline_buf = JSON.parse(outline_buff); + const outline_buf = JSON.parse(outline_buff) as number[]; + this.#outlineMesh = createShapeOutlineMesh({ + positions: outline_buf, + color: 0x000000, + fatOutlines: this._fatOutlines, + outlineWidth: this._outlineWidth, + }); - const outlineGeometry = new THREE.BufferGeometry(); - outlineGeometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(outline_buf, 3) - ); + this.add(this.#outlineMesh); + } + } - const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 }); - this.#outlineMesh = new THREE.LineSegments( - outlineGeometry, - outlineMaterial - ); + get outline() { + return this._outlineEnabled; + } - this.add(this.#outlineMesh); + set fatOutlines(value: boolean) { + this._fatOutlines = value; + this.options.fatOutlines = value; + if (this._outlineEnabled) { + this.outline = true; } + } + + get fatOutlines() { + return this._fatOutlines; + } - if (!enable && this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; + set outlineWidth(value: number) { + this._outlineWidth = sanitizeOutlineWidth(value); + this.options.outlineWidth = this._outlineWidth; + if (this._outlineEnabled) { + this.outline = true; } } + get outlineWidth() { + return this._outlineWidth; + } + get outlineMesh() { return this.#outlineMesh; } + private clearOutlineMesh() { + if (!this.#outlineMesh) { + return; + } + this.remove(this.#outlineMesh); + disposeShapeOutlineMesh(this.#outlineMesh); + this.#outlineMesh = null; + } + discardGeometry() { this.geometry.dispose(); } diff --git a/main/opengeometry-three/src/shapes/outline-utils.ts b/main/opengeometry-three/src/shapes/outline-utils.ts new file mode 100644 index 0000000..9d043fb --- /dev/null +++ b/main/opengeometry-three/src/shapes/outline-utils.ts @@ -0,0 +1,106 @@ +import * as THREE from "three"; +import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; +import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js"; +import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js"; + +export type ShapeOutlineMesh = THREE.LineSegments | LineSegments2; + +export interface ShapeOutlineMeshOptions { + positions: number[]; + color?: number; + fatOutlines?: boolean; + outlineWidth?: number; +} + +const DEFAULT_OUTLINE_COLOR = 0x000000; +const DEFAULT_OUTLINE_WIDTH = 1; + +function getOutlineResolution() { + if (typeof window === "undefined") { + return new THREE.Vector2(1, 1); + } + return new THREE.Vector2(window.innerWidth, window.innerHeight); +} + +export function sanitizeOutlineWidth(width?: number) { + if (!Number.isFinite(width) || typeof width !== "number" || width <= 0) { + return DEFAULT_OUTLINE_WIDTH; + } + return width; +} + +export function createShapeOutlineMesh({ + positions, + color = DEFAULT_OUTLINE_COLOR, + fatOutlines = false, + outlineWidth = DEFAULT_OUTLINE_WIDTH, +}: ShapeOutlineMeshOptions): ShapeOutlineMesh { + if (fatOutlines) { + const fatGeometry = new LineSegmentsGeometry(); + fatGeometry.setPositions(positions); + const fatMaterial = new LineMaterial({ + color, + linewidth: sanitizeOutlineWidth(outlineWidth), + resolution: getOutlineResolution(), + }); + const fatMesh = new LineSegments2(fatGeometry, fatMaterial); + fatMesh.computeLineDistances(); + return fatMesh; + } + + const outlineGeometry = new THREE.BufferGeometry(); + outlineGeometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(positions, 3) + ); + + const outlineMaterial = new THREE.LineBasicMaterial({ color }); + return new THREE.LineSegments(outlineGeometry, outlineMaterial); +} + +export function disposeShapeOutlineMesh(mesh: ShapeOutlineMesh | null) { + if (!mesh) { + return; + } + + mesh.geometry.dispose(); + if (Array.isArray(mesh.material)) { + mesh.material.forEach((material) => material.dispose()); + return; + } + mesh.material.dispose(); +} + +export function setShapeOutlineColor(mesh: ShapeOutlineMesh | null, color: number) { + if (!mesh) { + return; + } + + if (mesh.material instanceof THREE.LineBasicMaterial) { + mesh.material.color.set(color); + return; + } + + if (mesh.material instanceof LineMaterial) { + mesh.material.color.set(color); + } +} + +export function getShapeOutlineColor( + mesh: ShapeOutlineMesh | null, + fallback: number = DEFAULT_OUTLINE_COLOR +) { + if (!mesh) { + return fallback; + } + + if (mesh.material instanceof THREE.LineBasicMaterial) { + return mesh.material.color.getHex(); + } + + if (mesh.material instanceof LineMaterial) { + return mesh.material.color.getHex(); + } + + return fallback; +} diff --git a/main/opengeometry-three/src/shapes/polygon.ts b/main/opengeometry-three/src/shapes/polygon.ts index ce5168b..6cc1995 100644 --- a/main/opengeometry-three/src/shapes/polygon.ts +++ b/main/opengeometry-three/src/shapes/polygon.ts @@ -1,18 +1,37 @@ import * as THREE from "three"; import { OGPolygon, Vector3 } from "../../../opengeometry/pkg/opengeometry"; import { getUUID } from "../utils/randomizer"; +import { + createShapeOutlineMesh, + disposeShapeOutlineMesh, + getShapeOutlineColor, + sanitizeOutlineWidth, + setShapeOutlineColor, + ShapeOutlineMesh, +} from "./outline-utils"; export interface IPolygonOptions { ogid?: string; vertices: Vector3[]; color: number; + fatOutlines?: boolean; + outlineWidth?: number; } export class Polygon extends THREE.Mesh { ogid: string; - options: IPolygonOptions = { vertices: [], color: 0x00ff00 }; + options: IPolygonOptions = { + vertices: [], + color: 0x00ff00, + fatOutlines: false, + outlineWidth: 1, + }; polygon: OGPolygon; - #outlineMesh: THREE.Line | null = null; + #outlineMesh: ShapeOutlineMesh | null = null; + private _outlineEnabled = false; + private _fatOutlines = false; + private _outlineWidth = 1; + private _outlineColor = 0x000000; transformationMatrix: THREE.Matrix4 = new THREE.Matrix4(); @@ -70,7 +89,13 @@ export class Polygon extends THREE.Mesh { setConfig(options: IPolygonOptions) { this.validateOptions(); - const { vertices, color } = options; + this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; + this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); + this.options.fatOutlines = this._fatOutlines; + this.options.outlineWidth = this._outlineWidth; + + const { vertices, color } = this.options; this.polygon.set_config(vertices); this.options.color = color; @@ -187,6 +212,10 @@ export class Polygon extends THREE.Mesh { this.geometry = geometry; this.material = material; + if (this._outlineEnabled) { + this.outline = true; + } + // this.geometry.computeBoundingBox(); // const originalCenter = new THREE.Vector3(); // this.geometry.boundingBox?.getCenter(originalCenter); @@ -288,11 +317,6 @@ export class Polygon extends THREE.Mesh { this.disposeGeometryMaterial(); this.generateGeometry(); - - // We end up calling the outline method again with creation of geometry - if (this.outline) { - this.outline = true; - } } // extrude(height: number) { @@ -329,60 +353,61 @@ export class Polygon extends THREE.Mesh { } set outlineColor(color: number) { - if (this.#outlineMesh && this.#outlineMesh.material instanceof THREE.LineBasicMaterial) { - this.#outlineMesh.material.color.set(color); - } + this._outlineColor = color; + setShapeOutlineColor(this.#outlineMesh, color); } get outlineColor() { - if (this.#outlineMesh && this.#outlineMesh.material instanceof THREE.LineBasicMaterial) { - return this.#outlineMesh.material.color.getHex(); - } - return 0x000000; // Default color if outline mesh is not present + return getShapeOutlineColor(this.#outlineMesh, this._outlineColor); } // TODO: Do we need a separate method for Hole Outlines? set outline(enable: boolean) { - if (this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; - } - - if (enable && !this.#outlineMesh) { + this._outlineEnabled = enable; + this.clearOutlineMesh(); + if (enable) { const outline_buff = this.polygon.get_outline_geometry_serialized(); - const outline_buf = JSON.parse(outline_buff); - - const outlineGeometry = new THREE.BufferGeometry(); - outlineGeometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(outline_buf, 3) - ); - - const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 }); - this.#outlineMesh = new THREE.LineSegments( - outlineGeometry, - outlineMaterial - ); + const outline_buf = JSON.parse(outline_buff) as number[]; + this.#outlineMesh = createShapeOutlineMesh({ + positions: outline_buf, + color: this._outlineColor, + fatOutlines: this._fatOutlines, + outlineWidth: this._outlineWidth, + }); // this.#outlineMesh.geometry.center(); // this.#outlineMesh.applyMatrix4(this.transformationMatrix); this.add(this.#outlineMesh); } + } + + get outline() { + return this._outlineEnabled; + } - if (!enable && this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; + set fatOutlines(value: boolean) { + this._fatOutlines = value; + this.options.fatOutlines = value; + if (this._outlineEnabled) { + this.outline = true; } } - get outline() { - if (this.#outlineMesh) { - return true; + get fatOutlines() { + return this._fatOutlines; + } + + set outlineWidth(value: number) { + this._outlineWidth = sanitizeOutlineWidth(value); + this.options.outlineWidth = this._outlineWidth; + if (this._outlineEnabled) { + this.outline = true; } - return false; + } + + get outlineWidth() { + return this._outlineWidth; } // bTree() { @@ -399,12 +424,16 @@ export class Polygon extends THREE.Mesh { if (this.material instanceof THREE.Material) { this.material.dispose(); } - if (this.#outlineMesh) { - this.#outlineMesh.geometry.dispose(); - if (this.#outlineMesh.material instanceof THREE.Material) { - this.#outlineMesh.material.dispose(); - } + this.clearOutlineMesh(); + } + + private clearOutlineMesh() { + if (!this.#outlineMesh) { + return; } + this.remove(this.#outlineMesh); + disposeShapeOutlineMesh(this.#outlineMesh); + this.#outlineMesh = null; } dispose() { diff --git a/main/opengeometry-three/src/shapes/sphere.ts b/main/opengeometry-three/src/shapes/sphere.ts index aff39d9..a2e2c2f 100644 --- a/main/opengeometry-three/src/shapes/sphere.ts +++ b/main/opengeometry-three/src/shapes/sphere.ts @@ -2,6 +2,12 @@ import * as OGKernel from "../../../opengeometry/pkg/opengeometry"; import { Vector3 } from "../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { getUUID } from "../utils/randomizer"; +import { + createShapeOutlineMesh, + disposeShapeOutlineMesh, + sanitizeOutlineWidth, + ShapeOutlineMesh, +} from "./outline-utils"; export interface ISphereOptions { ogid?: string; @@ -10,6 +16,8 @@ export interface ISphereOptions { widthSegments: number; heightSegments: number; color: number; + fatOutlines?: boolean; + outlineWidth?: number; } /* eslint-disable no-unused-vars */ @@ -33,10 +41,15 @@ export class Sphere extends THREE.Mesh { widthSegments: 24, heightSegments: 16, color: 0x00ff00, + fatOutlines: false, + outlineWidth: 1, }; private sphere: ISphereKernelInstance; - #outlineMesh: THREE.LineSegments | null = null; + #outlineMesh: ShapeOutlineMesh | null = null; + private _outlineEnabled = false; + private _fatOutlines = false; + private _outlineWidth = 1; set radius(value: number) { this.options.radius = value; @@ -80,6 +93,11 @@ export class Sphere extends THREE.Mesh { this.validateOptions(); this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; + this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); + this.options.fatOutlines = this._fatOutlines; + this.options.outlineWidth = this._outlineWidth; + this.sphere.set_config( this.options.center.clone(), this.options.radius, @@ -123,7 +141,7 @@ export class Sphere extends THREE.Mesh { this.geometry = geometry; this.material = material; - if (this.#outlineMesh) { + if (this._outlineEnabled) { this.outline = true; } } @@ -137,37 +155,62 @@ export class Sphere extends THREE.Mesh { } set outline(enable: boolean) { - if (this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - if (Array.isArray(this.#outlineMesh.material)) { - this.#outlineMesh.material.forEach((material) => material.dispose()); - } else { - this.#outlineMesh.material.dispose(); - } - this.#outlineMesh = null; - } - + this._outlineEnabled = enable; + this.clearOutlineMesh(); if (enable) { const outlineBuffer = this.sphere.get_outline_geometry_serialized(); const lineBuffer = JSON.parse(outlineBuffer) as number[]; + this.#outlineMesh = createShapeOutlineMesh({ + positions: lineBuffer, + color: 0x000000, + fatOutlines: this._fatOutlines, + outlineWidth: this._outlineWidth, + }); + this.add(this.#outlineMesh); + } + } - const outlineGeometry = new THREE.BufferGeometry(); - outlineGeometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(lineBuffer, 3) - ); + get outline() { + return this._outlineEnabled; + } - const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 }); - this.#outlineMesh = new THREE.LineSegments(outlineGeometry, outlineMaterial); - this.add(this.#outlineMesh); + set fatOutlines(value: boolean) { + this._fatOutlines = value; + this.options.fatOutlines = value; + if (this._outlineEnabled) { + this.outline = true; } } + get fatOutlines() { + return this._fatOutlines; + } + + set outlineWidth(value: number) { + this._outlineWidth = sanitizeOutlineWidth(value); + this.options.outlineWidth = this._outlineWidth; + if (this._outlineEnabled) { + this.outline = true; + } + } + + get outlineWidth() { + return this._outlineWidth; + } + get outlineMesh() { return this.#outlineMesh; } + private clearOutlineMesh() { + if (!this.#outlineMesh) { + return; + } + this.remove(this.#outlineMesh); + disposeShapeOutlineMesh(this.#outlineMesh); + this.#outlineMesh = null; + } + discardGeometry() { this.geometry.dispose(); } diff --git a/main/opengeometry-three/src/shapes/sweep.ts b/main/opengeometry-three/src/shapes/sweep.ts index c24cb42..ac0228b 100644 --- a/main/opengeometry-three/src/shapes/sweep.ts +++ b/main/opengeometry-three/src/shapes/sweep.ts @@ -2,6 +2,12 @@ import * as OGKernel from "../../../opengeometry/pkg/opengeometry"; import { Vector3 } from "../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { getUUID } from "../utils/randomizer"; +import { + createShapeOutlineMesh, + disposeShapeOutlineMesh, + sanitizeOutlineWidth, + ShapeOutlineMesh, +} from "./outline-utils"; export interface ISweepOptions { ogid?: string; @@ -10,6 +16,8 @@ export interface ISweepOptions { color: number; capStart?: boolean; capEnd?: boolean; + fatOutlines?: boolean; + outlineWidth?: number; } /* eslint-disable no-unused-vars */ @@ -41,10 +49,15 @@ export class Sweep extends THREE.Mesh { color: 0x00ff00, capStart: true, capEnd: true, + fatOutlines: false, + outlineWidth: 1, }; private sweep: ISweepKernelInstance; - #outlineMesh: THREE.LineSegments | null = null; + #outlineMesh: ShapeOutlineMesh | null = null; + private _outlineEnabled = false; + private _fatOutlines = false; + private _outlineWidth = 1; set color(color: number) { this.options.color = color; @@ -88,6 +101,11 @@ export class Sweep extends THREE.Mesh { setConfig(options: ISweepOptions) { this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; + this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); + this.options.fatOutlines = this._fatOutlines; + this.options.outlineWidth = this._outlineWidth; + this.validateOptions(); const path = this.options.path.map((point) => point.clone()); @@ -132,7 +150,7 @@ export class Sweep extends THREE.Mesh { this.geometry = geometry; this.material = material; - if (this.#outlineMesh) { + if (this._outlineEnabled) { this.outline = true; } } @@ -146,32 +164,57 @@ export class Sweep extends THREE.Mesh { } set outline(enable: boolean) { - if (this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - if (Array.isArray(this.#outlineMesh.material)) { - this.#outlineMesh.material.forEach((material) => material.dispose()); - } else { - this.#outlineMesh.material.dispose(); - } - this.#outlineMesh = null; - } - + this._outlineEnabled = enable; + this.clearOutlineMesh(); if (enable) { const outlineBuff = this.sweep.get_outline_geometry_serialized(); - const outlineData = JSON.parse(outlineBuff); + const outlineData = JSON.parse(outlineBuff) as number[]; + this.#outlineMesh = createShapeOutlineMesh({ + positions: outlineData, + color: 0x000000, + fatOutlines: this._fatOutlines, + outlineWidth: this._outlineWidth, + }); - const outlineGeometry = new THREE.BufferGeometry(); - outlineGeometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(outlineData, 3) - ); + this.add(this.#outlineMesh); + } + } - const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 }); - this.#outlineMesh = new THREE.LineSegments(outlineGeometry, outlineMaterial); + get outline() { + return this._outlineEnabled; + } - this.add(this.#outlineMesh); + set fatOutlines(value: boolean) { + this._fatOutlines = value; + this.options.fatOutlines = value; + if (this._outlineEnabled) { + this.outline = true; + } + } + + get fatOutlines() { + return this._fatOutlines; + } + + set outlineWidth(value: number) { + this._outlineWidth = sanitizeOutlineWidth(value); + this.options.outlineWidth = this._outlineWidth; + if (this._outlineEnabled) { + this.outline = true; + } + } + + get outlineWidth() { + return this._outlineWidth; + } + + private clearOutlineMesh() { + if (!this.#outlineMesh) { + return; } + this.remove(this.#outlineMesh); + disposeShapeOutlineMesh(this.#outlineMesh); + this.#outlineMesh = null; } discardGeometry() { diff --git a/main/opengeometry-three/src/shapes/wedge.ts b/main/opengeometry-three/src/shapes/wedge.ts index d08395b..a5e5d17 100644 --- a/main/opengeometry-three/src/shapes/wedge.ts +++ b/main/opengeometry-three/src/shapes/wedge.ts @@ -1,6 +1,12 @@ import { OGWedge, Vector3 } from "../../../opengeometry/pkg/opengeometry"; import * as THREE from "three"; import { getUUID } from "../utils/randomizer"; +import { + createShapeOutlineMesh, + disposeShapeOutlineMesh, + sanitizeOutlineWidth, + ShapeOutlineMesh, +} from "./outline-utils"; export interface IWedgeOptions { ogid?: string; @@ -9,6 +15,8 @@ export interface IWedgeOptions { height: number; depth: number; color: number; + fatOutlines?: boolean; + outlineWidth?: number; } export class Wedge extends THREE.Mesh { @@ -19,10 +27,15 @@ export class Wedge extends THREE.Mesh { height: 1, depth: 1, color: 0x00ff00, + fatOutlines: false, + outlineWidth: 1, }; private wedge: OGWedge; - #outlineMesh: THREE.Line | null = null; + #outlineMesh: ShapeOutlineMesh | null = null; + private _outlineEnabled = false; + private _fatOutlines = false; + private _outlineWidth = 1; set color(color: number) { this.options.color = color; @@ -51,7 +64,13 @@ export class Wedge extends THREE.Mesh { setConfig(options: IWedgeOptions) { this.validateOptions(); - const { width, height, depth, center, color } = options; + this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; + this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); + this.options.fatOutlines = this._fatOutlines; + this.options.outlineWidth = this._outlineWidth; + + const { width, height, depth, center, color } = this.options; this.wedge.set_config(center.clone(), width, height, depth); this.options.color = color; @@ -91,7 +110,7 @@ export class Wedge extends THREE.Mesh { this.geometry = geometry; this.material = material; - if (this.#outlineMesh) { + if (this._outlineEnabled) { this.outline = true; } } @@ -105,32 +124,62 @@ export class Wedge extends THREE.Mesh { } set outline(enable: boolean) { - if (this.#outlineMesh) { - this.remove(this.#outlineMesh); - this.#outlineMesh.geometry.dispose(); - this.#outlineMesh = null; - } - + this._outlineEnabled = enable; + this.clearOutlineMesh(); if (enable) { const outlineBuffer = this.wedge.get_outline_geometry_serialized(); - const outlineData = JSON.parse(outlineBuffer); + const outlineData = JSON.parse(outlineBuffer) as number[]; + this.#outlineMesh = createShapeOutlineMesh({ + positions: outlineData, + color: 0x000000, + fatOutlines: this._fatOutlines, + outlineWidth: this._outlineWidth, + }); + this.add(this.#outlineMesh); + } + } - const outlineGeometry = new THREE.BufferGeometry(); - outlineGeometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(outlineData, 3) - ); + get outline() { + return this._outlineEnabled; + } - const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x000000 }); - this.#outlineMesh = new THREE.LineSegments(outlineGeometry, outlineMaterial); - this.add(this.#outlineMesh); + set fatOutlines(value: boolean) { + this._fatOutlines = value; + this.options.fatOutlines = value; + if (this._outlineEnabled) { + this.outline = true; } } + get fatOutlines() { + return this._fatOutlines; + } + + set outlineWidth(value: number) { + this._outlineWidth = sanitizeOutlineWidth(value); + this.options.outlineWidth = this._outlineWidth; + if (this._outlineEnabled) { + this.outline = true; + } + } + + get outlineWidth() { + return this._outlineWidth; + } + get outlineMesh() { return this.#outlineMesh; } + private clearOutlineMesh() { + if (!this.#outlineMesh) { + return; + } + this.remove(this.#outlineMesh); + disposeShapeOutlineMesh(this.#outlineMesh); + this.#outlineMesh = null; + } + discardGeometry() { this.geometry.dispose(); } From 9c3a92661adf0666739d3315366306e41c07f7fa Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sun, 8 Mar 2026 17:22:04 +0100 Subject: [PATCH 03/10] cleanup --- main/opengeometry-three/src/shapes/cuboid.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/main/opengeometry-three/src/shapes/cuboid.ts b/main/opengeometry-three/src/shapes/cuboid.ts index 8118c3e..5809982 100644 --- a/main/opengeometry-three/src/shapes/cuboid.ts +++ b/main/opengeometry-three/src/shapes/cuboid.ts @@ -70,16 +70,26 @@ export class Cuboid extends THREE.Mesh { } } + getConfig() { + return this.options; + } + setConfig(options: ICuboidOptions) { this.validateOptions(); this.options = { ...this.options, ...options }; + this._fatOutlines = this.options.fatOutlines ?? false; this._outlineWidth = sanitizeOutlineWidth(this.options.outlineWidth); this.options.fatOutlines = this._fatOutlines; this.options.outlineWidth = this._outlineWidth; const { width, height, depth, center, color } = this.options; + + // Material Configs + this.options.color = color; + + // Kernel Configs this.cuboid.set_config( center.clone(), width, @@ -87,8 +97,6 @@ export class Cuboid extends THREE.Mesh { depth ); - this.options.color = color; - this.generateGeometry(); } From 21d24052dac33a57b8d04e1d4f0294998908ceca Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Mon, 9 Mar 2026 09:59:31 +0100 Subject: [PATCH 04/10] update examples --- .gitignore | 1 + .../terminology-and-definitions.md | 0 .../examples-vite/index.html | 163 +----- .../examples-vite/operations/offset.html | 14 +- .../operations/sweep-path-profile.html | 14 +- .../operations/wall-from-offsets.html | 14 +- .../examples-vite/primitives/arc.html | 14 +- .../examples-vite/primitives/curve.html | 14 +- .../examples-vite/primitives/line.html | 14 +- .../examples-vite/primitives/polyline.html | 14 +- .../examples-vite/primitives/rectangle.html | 14 +- .../examples-vite/shapes/cuboid.html | 14 +- .../examples-vite/shapes/cylinder.html | 14 +- .../examples-vite/shapes/opening.html | 14 +- .../examples-vite/shapes/polygon-suite.html | 13 + .../examples-vite/shapes/polygon.html | 14 +- .../examples-vite/shapes/sphere.html | 14 +- .../examples-vite/shapes/sweep.html | 14 +- .../examples-vite/shapes/wedge.html | 14 +- .../examples-vite/src/catalog.ts | 71 +++ .../examples-vite/src/example-page.ts | 15 + .../operations/offset.ts} | 18 +- .../operations/sweep-path-profile.ts} | 18 +- .../operations/wall-from-offsets.ts} | 20 +- .../primitives/arc.ts} | 18 +- .../primitives/curve.ts} | 18 +- .../primitives/line.ts} | 18 +- .../primitives/polyline.ts} | 18 +- .../primitives/rectangle.ts} | 18 +- .../shapes/cuboid.ts} | 18 +- .../shapes/cylinder.ts} | 18 +- .../shapes/opening.ts} | 18 +- .../src/examples/shapes/polygon-suite.ts | 362 +++++++++++++ .../shapes/polygon.ts} | 18 +- .../shapes/sphere.ts} | 18 +- .../shapes/sweep.ts} | 18 +- .../shapes/wedge.ts} | 20 +- .../src/shared/example-contract.ts | 18 + .../examples-vite/src/shared/examples.ts | 34 ++ .../examples-vite/src/shared/icon-registry.ts | 45 ++ .../examples-vite/src/shared/runtime.ts | 46 +- .../examples-vite/src/styles/theme.css | 507 ++++++++++-------- main/opengeometry-three/package-lock.json | 35 ++ main/opengeometry-three/package.json | 2 + .../vite.examples.config.mjs | 58 +- 45 files changed, 1311 insertions(+), 543 deletions(-) rename knowledge.md => knowledge/terminology-and-definitions.md (100%) create mode 100644 main/opengeometry-three/examples-vite/shapes/polygon-suite.html create mode 100644 main/opengeometry-three/examples-vite/src/catalog.ts create mode 100644 main/opengeometry-three/examples-vite/src/example-page.ts rename main/opengeometry-three/examples-vite/src/{pages/operations-offset.ts => examples/operations/offset.ts} (76%) rename main/opengeometry-three/examples-vite/src/{pages/operations-sweep-path-profile.ts => examples/operations/sweep-path-profile.ts} (86%) rename main/opengeometry-three/examples-vite/src/{pages/operations-wall-from-offsets.ts => examples/operations/wall-from-offsets.ts} (83%) rename main/opengeometry-three/examples-vite/src/{pages/primitives-arc.ts => examples/primitives/arc.ts} (76%) rename main/opengeometry-three/examples-vite/src/{pages/primitives-curve.ts => examples/primitives/curve.ts} (71%) rename main/opengeometry-three/examples-vite/src/{pages/primitives-line.ts => examples/primitives/line.ts} (73%) rename main/opengeometry-three/examples-vite/src/{pages/primitives-polyline.ts => examples/primitives/polyline.ts} (72%) rename main/opengeometry-three/examples-vite/src/{pages/primitives-rectangle.ts => examples/primitives/rectangle.ts} (68%) rename main/opengeometry-three/examples-vite/src/{pages/shapes-cuboid.ts => examples/shapes/cuboid.ts} (76%) rename main/opengeometry-three/examples-vite/src/{pages/shapes-cylinder.ts => examples/shapes/cylinder.ts} (77%) rename main/opengeometry-three/examples-vite/src/{pages/shapes-opening.ts => examples/shapes/opening.ts} (76%) create mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts rename main/opengeometry-three/examples-vite/src/{pages/shapes-polygon.ts => examples/shapes/polygon.ts} (77%) rename main/opengeometry-three/examples-vite/src/{pages/shapes-sphere.ts => examples/shapes/sphere.ts} (76%) rename main/opengeometry-three/examples-vite/src/{pages/shapes-sweep.ts => examples/shapes/sweep.ts} (84%) rename main/opengeometry-three/examples-vite/src/{pages/shapes-wedge.ts => examples/shapes/wedge.ts} (74%) create mode 100644 main/opengeometry-three/examples-vite/src/shared/example-contract.ts create mode 100644 main/opengeometry-three/examples-vite/src/shared/examples.ts create mode 100644 main/opengeometry-three/examples-vite/src/shared/icon-registry.ts diff --git a/.gitignore b/.gitignore index 8aea210..3008b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ node_modules/ # JS/TS build artifacts dist/ main/opengeometry-three/examples-dist/ +main/opengeometry-three/examples-vite/.generated/ main/opengeometry/pkg/ .DS_Store diff --git a/knowledge.md b/knowledge/terminology-and-definitions.md similarity index 100% rename from knowledge.md rename to knowledge/terminology-and-definitions.md diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index d4b3d00..00cc91e 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -4,168 +4,9 @@ OpenGeometry Three Examples - -
-
-

Specs

-

- OpenGeometry technical sandbox for AEC, MEP and mechanical kernel - primitives. Each page is an isolated spec sheet with live parameter - controls and local wasm execution. -

-
- -
-
-

Primitives

-

5 items

-
- -
- -
-
-

Shapes

-

7 items

-
- -
- -
-
-

Operations

-

3 items

-
- -
-

- Build command: npm --prefix main/opengeometry-three run build-example-three -

-
+
+ diff --git a/main/opengeometry-three/examples-vite/operations/offset.html b/main/opengeometry-three/examples-vite/operations/offset.html index f41bda2..173621b 100644 --- a/main/opengeometry-three/examples-vite/operations/offset.html +++ b/main/opengeometry-three/examples-vite/operations/offset.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html b/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html index 0d8a222..34f1e97 100644 --- a/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html +++ b/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html b/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html index 0bdbf98..935af04 100644 --- a/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html +++ b/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/primitives/arc.html b/main/opengeometry-three/examples-vite/primitives/arc.html index d45ddfc..384db3e 100644 --- a/main/opengeometry-three/examples-vite/primitives/arc.html +++ b/main/opengeometry-three/examples-vite/primitives/arc.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/primitives/curve.html b/main/opengeometry-three/examples-vite/primitives/curve.html index 200d252..29e8d51 100644 --- a/main/opengeometry-three/examples-vite/primitives/curve.html +++ b/main/opengeometry-three/examples-vite/primitives/curve.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/primitives/line.html b/main/opengeometry-three/examples-vite/primitives/line.html index ebb9030..765fecf 100644 --- a/main/opengeometry-three/examples-vite/primitives/line.html +++ b/main/opengeometry-three/examples-vite/primitives/line.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/primitives/polyline.html b/main/opengeometry-three/examples-vite/primitives/polyline.html index a75abaf..0dcbfec 100644 --- a/main/opengeometry-three/examples-vite/primitives/polyline.html +++ b/main/opengeometry-three/examples-vite/primitives/polyline.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/primitives/rectangle.html b/main/opengeometry-three/examples-vite/primitives/rectangle.html index fddf8cd..7a66eaa 100644 --- a/main/opengeometry-three/examples-vite/primitives/rectangle.html +++ b/main/opengeometry-three/examples-vite/primitives/rectangle.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/cuboid.html b/main/opengeometry-three/examples-vite/shapes/cuboid.html index 3f7f17b..6f2f2ac 100644 --- a/main/opengeometry-three/examples-vite/shapes/cuboid.html +++ b/main/opengeometry-three/examples-vite/shapes/cuboid.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/cylinder.html b/main/opengeometry-three/examples-vite/shapes/cylinder.html index 93e447a..e1fe56e 100644 --- a/main/opengeometry-three/examples-vite/shapes/cylinder.html +++ b/main/opengeometry-three/examples-vite/shapes/cylinder.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/opening.html b/main/opengeometry-three/examples-vite/shapes/opening.html index cdf7a77..ce02bc3 100644 --- a/main/opengeometry-three/examples-vite/shapes/opening.html +++ b/main/opengeometry-three/examples-vite/shapes/opening.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/polygon-suite.html b/main/opengeometry-three/examples-vite/shapes/polygon-suite.html new file mode 100644 index 0000000..de3c8a3 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/polygon-suite.html @@ -0,0 +1,13 @@ + + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/polygon.html b/main/opengeometry-three/examples-vite/shapes/polygon.html index 48e914e..8917d2d 100644 --- a/main/opengeometry-three/examples-vite/shapes/polygon.html +++ b/main/opengeometry-three/examples-vite/shapes/polygon.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/sphere.html b/main/opengeometry-three/examples-vite/shapes/sphere.html index 9e474c3..603a504 100644 --- a/main/opengeometry-three/examples-vite/shapes/sphere.html +++ b/main/opengeometry-three/examples-vite/shapes/sphere.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/sweep.html b/main/opengeometry-three/examples-vite/shapes/sweep.html index 79167e7..830a823 100644 --- a/main/opengeometry-three/examples-vite/shapes/sweep.html +++ b/main/opengeometry-three/examples-vite/shapes/sweep.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/shapes/wedge.html b/main/opengeometry-three/examples-vite/shapes/wedge.html index 389840b..81724b4 100644 --- a/main/opengeometry-three/examples-vite/shapes/wedge.html +++ b/main/opengeometry-three/examples-vite/shapes/wedge.html @@ -1 +1,13 @@ -
+ + + + + + OpenGeometry Example + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/src/catalog.ts b/main/opengeometry-three/examples-vite/src/catalog.ts new file mode 100644 index 0000000..4eceb7f --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/catalog.ts @@ -0,0 +1,71 @@ +import "./styles/theme.css"; +import { categoryLabels, getExamplesByCategory } from "./shared/examples"; +import { getExampleIconMarkup } from "./shared/icon-registry"; +import type { ExampleCategory } from "./shared/example-contract"; + +const app = document.getElementById("app"); + +if (!app) { + throw new Error("Missing #app container"); +} + +const shell = document.createElement("main"); +shell.className = "og-specs-shell"; + +const head = document.createElement("header"); +head.className = "og-specs-head"; +head.innerHTML = ` +
+

OpenGeometry Technical Sandbox

+

Examples

+
+

Build command: npm --prefix main/opengeometry-three run build-example-three

+`; +shell.appendChild(head); + +for (const category of ["primitives", "shapes", "operations"] as ExampleCategory[]) { + const items = getExamplesByCategory(category); + const section = document.createElement("section"); + section.className = "og-specs-section"; + + const heading = document.createElement("div"); + heading.className = "og-specs-section-head"; + heading.innerHTML = ` +

${categoryLabels[category]}

+

${items.length} items

+ `; + section.appendChild(heading); + + const grid = document.createElement("div"); + grid.className = "og-specs-grid"; + + for (const example of items) { + const card = document.createElement("article"); + card.className = "og-spec-card"; + const chips = example.chips + .map((chip) => `${chip}`) + .join(""); + + card.innerHTML = ` +
+
${getExampleIconMarkup(example.slug)}
+

${example.statusLabel}

+
+
+

${example.title}

+

${example.description}

+
+
${chips}
+ + `; + + grid.appendChild(card); + } + + section.appendChild(grid); + shell.appendChild(section); +} + +app.replaceChildren(shell); diff --git a/main/opengeometry-three/examples-vite/src/example-page.ts b/main/opengeometry-three/examples-vite/src/example-page.ts new file mode 100644 index 0000000..c9c9698 --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/example-page.ts @@ -0,0 +1,15 @@ +import { bootstrapExample } from "./shared/runtime"; +import { getExampleBySlug } from "./shared/examples"; + +const slug = document.body.dataset.exampleSlug; + +if (!slug) { + throw new Error("Missing example slug on page body"); +} + +const example = getExampleBySlug(slug); + +void bootstrapExample({ + example, + build: example.build, +}); diff --git a/main/opengeometry-three/examples-vite/src/pages/operations-offset.ts b/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts similarity index 76% rename from main/opengeometry-three/examples-vite/src/pages/operations-offset.ts rename to main/opengeometry-three/examples-vite/src/examples/operations/offset.ts index f5b017a..08bbda1 100644 --- a/main/opengeometry-three/examples-vite/src/pages/operations-offset.ts +++ b/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts @@ -1,10 +1,7 @@ import { Polyline, Vector3 } from "@og-three"; import * as THREE from "three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; function createBasePolyline(turn: number): Vector3[] { return [ @@ -15,9 +12,14 @@ function createBasePolyline(turn: number): Vector3[] { ]; } -bootstrapExample({ - title: "Operation: Offset", - description: "Interactive offset operation on a polyline primitive.", +export default defineExample({ + slug: "operations/offset", + category: "operations", + title: "Offset", + description: "Offset generation with acute-corner and bevel parameters.", + statusLabel: "ready", + chips: ["Control: Offset", "Control: Bevel"], + footerText: "Control: Offset, Bevel", build: ({ scene }) => { let current: THREE.Group | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/operations-sweep-path-profile.ts b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts similarity index 86% rename from main/opengeometry-three/examples-vite/src/pages/operations-sweep-path-profile.ts rename to main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts index 942a6cf..4039642 100644 --- a/main/opengeometry-three/examples-vite/src/pages/operations-sweep-path-profile.ts +++ b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts @@ -1,10 +1,7 @@ import { Polyline, Rectangle, Sweep, Vector3 } from "@og-three"; import * as THREE from "three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; type KernelVertex = { position: { @@ -35,9 +32,14 @@ function buildPath(height: number): Vector3[] { ]; } -bootstrapExample({ - title: "Operation: Sweep Path + Profile", - description: "Interactive sweep generated from path and profile primitives.", +export default defineExample({ + slug: "operations/sweep-path-profile", + category: "operations", + title: "Sweep Path + Profile", + description: "Operation-level sweep from path primitive + profile primitive.", + statusLabel: "ready", + chips: ["Control: Path", "Control: Caps"], + footerText: "Control: Path + Caps", build: ({ scene }) => { let current: THREE.Group | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/operations-wall-from-offsets.ts b/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts similarity index 83% rename from main/opengeometry-three/examples-vite/src/pages/operations-wall-from-offsets.ts rename to main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts index 39f00cb..ab18c48 100644 --- a/main/opengeometry-three/examples-vite/src/pages/operations-wall-from-offsets.ts +++ b/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts @@ -1,10 +1,7 @@ import { Polygon, Polyline, Vector3 } from "@og-three"; import * as THREE from "three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; function buildCenterline(curveBias: number): Vector3[] { return [ @@ -22,12 +19,17 @@ function buildWallOutline(left: Vector3[], right: Vector3[]): Vector3[] { return []; } - return [...left.map((p) => p.clone()), ...right.map((p) => p.clone()).reverse()]; + return [...left.map((point) => point.clone()), ...right.map((point) => point.clone()).reverse()]; } -bootstrapExample({ - title: "Operation: Wall from Offsets", - description: "Interactive wall polygon generated from +/- polyline offsets.", +export default defineExample({ + slug: "operations/wall-from-offsets", + category: "operations", + title: "Wall from Offsets", + description: "Composite wall profile assembled from offset centerlines.", + statusLabel: "ready", + chips: ["Control: Thickness"], + footerText: "Control: Thickness", build: ({ scene }) => { let current: THREE.Group | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/primitives-arc.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts similarity index 76% rename from main/opengeometry-three/examples-vite/src/pages/primitives-arc.ts rename to main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts index c821281..4ab52a1 100644 --- a/main/opengeometry-three/examples-vite/src/pages/primitives-arc.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts @@ -1,13 +1,15 @@ import { Arc, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Primitive: Arc", - description: "Interactive arc with radius, angle span, and segment controls.", +export default defineExample({ + slug: "primitives/arc", + category: "primitives", + title: "Arc", + description: "Circular arc with angle span and segmentation control.", + statusLabel: "ready", + chips: ["Control: Radius", "Control: Span"], + footerText: "Control: Radius, Span", build: ({ scene }) => { let current: Arc | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/primitives-curve.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts similarity index 71% rename from main/opengeometry-three/examples-vite/src/pages/primitives-curve.ts rename to main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts index c62776d..e4ada54 100644 --- a/main/opengeometry-three/examples-vite/src/pages/primitives-curve.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts @@ -1,9 +1,6 @@ import { Curve, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; function buildControlPoints(span: number, sag: number, lift: number): Vector3[] { return [ @@ -14,9 +11,14 @@ function buildControlPoints(span: number, sag: number, lift: number): Vector3[] ]; } -bootstrapExample({ - title: "Primitive: Curve", - description: "Interactive curve primitive defined by control points.", +export default defineExample({ + slug: "primitives/curve", + category: "primitives", + title: "Curve", + description: "Control-point curve for route and profile sketching.", + statusLabel: "ready", + chips: ["Control: Sag", "Control: Lift"], + footerText: "Control: Sag, Lift", build: ({ scene }) => { let current: Curve | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/primitives-line.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts similarity index 73% rename from main/opengeometry-three/examples-vite/src/pages/primitives-line.ts rename to main/opengeometry-three/examples-vite/src/examples/primitives/line.ts index fd4c151..460512f 100644 --- a/main/opengeometry-three/examples-vite/src/pages/primitives-line.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts @@ -1,13 +1,15 @@ import { Line, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Primitive: Line", - description: "Interactive line primitive with live parameter controls.", +export default defineExample({ + slug: "primitives/line", + category: "primitives", + title: "Line", + description: "Two-point line primitive with direct endpoint control.", + statusLabel: "ready", + chips: ["Control: Length", "Control: Angle"], + footerText: "Control: Length, Angle", build: ({ scene }) => { let current: Line | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/primitives-polyline.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts similarity index 72% rename from main/opengeometry-three/examples-vite/src/pages/primitives-polyline.ts rename to main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts index 9f7b93c..c2d1cc5 100644 --- a/main/opengeometry-three/examples-vite/src/pages/primitives-polyline.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts @@ -1,9 +1,6 @@ import { Polyline, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; function buildPolyline(amplitude: number, length: number, closed: boolean): Vector3[] { const points = [ @@ -20,9 +17,14 @@ function buildPolyline(amplitude: number, length: number, closed: boolean): Vect return points; } -bootstrapExample({ - title: "Primitive: Polyline", - description: "Interactive open/closed polyline configurations.", +export default defineExample({ + slug: "primitives/polyline", + category: "primitives", + title: "Polyline", + description: "Open and closed path definitions for profile work.", + statusLabel: "ready", + chips: ["Control: Closure", "Control: Span"], + footerText: "Control: Closure, Span", build: ({ scene }) => { let current: Polyline | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/primitives-rectangle.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts similarity index 68% rename from main/opengeometry-three/examples-vite/src/pages/primitives-rectangle.ts rename to main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts index 3d3d83a..ca2e9e2 100644 --- a/main/opengeometry-three/examples-vite/src/pages/primitives-rectangle.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts @@ -1,13 +1,15 @@ import { Rectangle, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Primitive: Rectangle", - description: "Interactive rectangle primitive with width/breadth controls.", +export default defineExample({ + slug: "primitives/rectangle", + category: "primitives", + title: "Rectangle", + description: "Parametric rectangular primitive for base profiles.", + statusLabel: "ready", + chips: ["Control: Width", "Control: Breadth"], + footerText: "Control: Width, Breadth", build: ({ scene }) => { let current: Rectangle | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-cuboid.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts similarity index 76% rename from main/opengeometry-three/examples-vite/src/pages/shapes-cuboid.ts rename to main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts index cb74765..e0bd8ac 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-cuboid.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts @@ -1,13 +1,15 @@ import { Cuboid, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Shape: Cuboid", - description: "Interactive cuboid BREP with outline controls.", +export default defineExample({ + slug: "shapes/cuboid", + category: "shapes", + title: "Cuboid", + description: "Rectangular solid for rooms, equipment blocks and massing.", + statusLabel: "ready", + chips: ["Control: W/H/D"], + footerText: "Control: W/H/D", build: ({ scene }) => { let current: Cuboid | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-cylinder.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts similarity index 77% rename from main/opengeometry-three/examples-vite/src/pages/shapes-cylinder.ts rename to main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts index df3c611..2e53d99 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-cylinder.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts @@ -1,13 +1,15 @@ import { Cylinder, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Shape: Cylinder", - description: "Interactive cylinder with segment and angle controls.", +export default defineExample({ + slug: "shapes/cylinder", + category: "shapes", + title: "Cylinder", + description: "Cylindrical volume for ducts, pipes and mechanical shafts.", + statusLabel: "ready", + chips: ["Control: R", "Control: H", "Control: Seg"], + footerText: "Control: R, H, Seg", build: ({ scene }) => { let current: Cylinder | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-opening.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts similarity index 76% rename from main/opengeometry-three/examples-vite/src/pages/shapes-opening.ts rename to main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts index f843843..72e3de3 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-opening.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts @@ -1,13 +1,15 @@ import { Opening, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Shape: Opening", - description: "Interactive opening helper rendered as cutout volume.", +export default defineExample({ + slug: "shapes/opening", + category: "shapes", + title: "Opening", + description: "Opening helper volume for void and penetration previews.", + statusLabel: "ready", + chips: ["Control: W/H/D"], + footerText: "Control: W/H/D", build: ({ scene }) => { let current: Opening | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts new file mode 100644 index 0000000..861a4a3 --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts @@ -0,0 +1,362 @@ +import { Polygon, Vector3 } from "@og-three"; +import Stats from "three/examples/jsm/libs/stats.module.js"; +import GUI from "three/examples/jsm/libs/lil-gui.module.min.js"; +import { defineExample } from "../../shared/example-contract"; +import { replaceSceneObject } from "../../shared/runtime"; + +type LoopPoint = [number, number, number]; + +type PolygonDatasetEntry = { + vertices: LoopPoint[]; + holes?: LoopPoint[][]; + description: string; + category: string; +}; + +const polygonDataset: Record = { + Triangle: { + vertices: [ + [0, 0, 0], + [3, 0, 0], + [1.5, 0, 3], + ], + description: "Basic triangle test for the smallest valid polygon footprint.", + category: "Basic Shapes", + }, + Square: { + vertices: [ + [-2, 0, -2], + [2, 0, -2], + [2, 0, 2], + [-2, 0, 2], + ], + description: "Simple rectangular slab used as the baseline fill and outline case.", + category: "Basic Shapes", + }, + L_Shape: { + vertices: [ + [0, 0, 0], + [6, 0, 0], + [6, 0, 4], + [10, 0, 4], + [10, 0, 10], + [0, 0, 10], + ], + description: "Concave architectural footprint for notch handling and triangulation checks.", + category: "Concave Cases", + }, + Concave_Polygon: { + vertices: [ + [0, 0, 0], + [3, 0, 0], + [4, 0, 2], + [6, 0, 0], + [10, 0, 0], + [10, 0, 3], + [8, 0, 4], + [10, 0, 6], + [10, 0, 10], + [6, 0, 10], + [4, 0, 8], + [3, 0, 10], + [0, 0, 10], + [0, 0, 6], + [2, 0, 4], + [0, 0, 3], + ], + description: "Complex concave polygon with multiple indentations for triangulation stress.", + category: "Concave Cases", + }, + Complex_Multi_Hole: { + vertices: [ + [-12, 0, -8], + [12, 0, -8], + [12, 0, 8], + [-12, 0, 8], + ], + holes: [ + [ + [-8, 0, -5], + [-8, 0, 1], + [-3, 0, 1], + [-3, 0, -5], + ], + [ + [2, 0, -5], + [5, 0, -4], + [6, 0, -1], + [5, 0, 2], + [2, 0, 1], + [1, 0, -2], + ], + [ + [0, 0, 4], + [0, 0, 6.5], + [5, 0, 6.5], + [5, 0, 5], + [2, 0, 5], + [2, 0, 4], + ], + ], + description: "Rectangular slab with three interior voids to validate multi-hole redraw stability.", + category: "Hole Test Suite", + }, + Highly_Complex: { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [2, 0, 1], + [3, 0, 0], + [4, 0, 1], + [5, 0, 0], + [6, 0, 1], + [7, 0, 0], + [8, 0, 1], + [9, 0, 0], + [10, 0, 1], + [11, 0, 2], + [12, 0, 1], + [13, 0, 2], + [14, 0, 3], + [15, 0, 4], + [16, 0, 5], + [17, 0, 6], + [18, 0, 7], + [19, 0, 8], + [20, 0, 9], + [19, 0, 10], + [18, 0, 9], + [17, 0, 8], + [16, 0, 7], + [15, 0, 6], + [14, 0, 5], + [13, 0, 4], + [12, 0, 3], + [11, 0, 2], + [10, 0, 3], + [9, 0, 2], + [8, 0, 3], + [7, 0, 2], + [6, 0, 3], + [5, 0, 2], + [4, 0, 3], + [3, 0, 2], + [2, 0, 3], + [1, 0, 2], + [0, 0, 1], + ], + description: "Large irregular polygon used to inspect performance and triangulation consistency.", + category: "Performance Testing", + }, + Building: { + vertices: [ + [66.1, 0, 11.2], + [66.1, 0, 9.6], + [66.6, 0, 9.6], + [66.6, 0, 8.7], + [74.3, 0, 8.7], + [77.1, 0, 8.7], + [77.1, 0, 11.4], + [75.0, 0, 11.4], + [75.0, 0, 11.3], + [74.2, 0, 11.3], + [74.2, 0, 10.6], + [71.0, 0, 10.6], + [71.0, 0, 11.3], + [66.6, 0, 11.3], + [66.6, 0, 11.2], + ], + description: "Architectural outline sampled from a real-world earcut-oriented building footprint.", + category: "Dataset Cases", + }, +}; + +function toVector3Loop(points: LoopPoint[]): Vector3[] { + return points.map((point) => new Vector3(point[0], point[1], point[2])); +} + +function applyPolygonMaterial( + polygon: Polygon, + color: string, + opacity: number, + wireframe: boolean, + outline: boolean +) { + const candidate = polygon as unknown as { + material?: { + color?: { set: (next: string) => void }; + opacity?: number; + transparent?: boolean; + wireframe?: boolean; + }; + }; + + polygon.outline = outline; + candidate.material?.color?.set(color); + if (candidate.material) { + candidate.material.opacity = opacity; + candidate.material.transparent = opacity < 1; + candidate.material.wireframe = wireframe; + } +} + +export default defineExample({ + slug: "shapes/polygon-suite", + category: "shapes", + title: "Polygon Suite", + description: "Dataset-backed polygon validation with concave, performance, and multi-hole cases.", + statusLabel: "ready", + chips: ["Coverage: Holes", "Coverage: Concavity"], + footerText: "Coverage: Holes, Concavity", + build: ({ scene, camera, renderer, controls }) => { + camera.position.set(0, 20, 20); + controls.target.set(0, 0, 0); + controls.update(); + + const stats = new Stats(); + stats.dom.id = "stats"; + document.body.appendChild(stats.dom); + + const description = document.createElement("div"); + description.id = "polygon-description"; + document.body.appendChild(description); + + const renderBase = renderer.render.bind(renderer); + renderer.render = ((renderScene, renderCamera) => { + stats.begin(); + renderBase(renderScene, renderCamera); + stats.end(); + }) as typeof renderer.render; + + const info = { + vertices: 0, + holes: 0, + category: "", + }; + + const params = { + polygonType: "Complex_Multi_Hole", + showOutline: true, + polygonColor: "#4CAF50", + opacity: 0.7, + wireframe: false, + showStats: true, + statsMode: 0, + }; + + let current: Polygon | null = null; + + const updatePolygonDescription = (polygonName: string) => { + const polygonData = polygonDataset[polygonName]; + if (!polygonData) { + return; + } + + const holeSummary = polygonData.holes?.length + ? `
Holes: ${polygonData.holes.length}` + : ""; + + description.innerHTML = ` + ${polygonName}
+ Category: ${polygonData.category}${holeSummary}
+ ${polygonData.description} + `; + }; + + const createPolygon = (polygonName: string) => { + const polygonData = polygonDataset[polygonName]; + if (!polygonData) { + return; + } + + const polygon = new Polygon({ + vertices: toVector3Loop(polygonData.vertices), + color: 0x4caf50, + }); + + for (const hole of polygonData.holes ?? []) { + polygon.addHole(toVector3Loop(hole)); + } + + polygon.position.y = 0.01; + applyPolygonMaterial( + polygon, + params.polygonColor, + params.opacity, + params.wireframe, + params.showOutline + ); + + current = replaceSceneObject(scene, current, polygon); + info.vertices = polygonData.vertices.length; + info.holes = polygonData.holes?.length ?? 0; + info.category = polygonData.category; + updatePolygonDescription(polygonName); + }; + + const gui = new GUI(); + const polygonFolder = gui.addFolder("Polygon Test Suite"); + polygonFolder.open(); + polygonFolder + .add(params, "polygonType", Object.keys(polygonDataset)) + .name("Polygon Shape") + .onChange((value: string) => { + createPolygon(value); + }); + polygonFolder + .add(params, "showOutline") + .name("Show Outline") + .onChange((value: boolean) => { + if (current) { + applyPolygonMaterial(current, params.polygonColor, params.opacity, params.wireframe, value); + } + }); + polygonFolder + .addColor(params, "polygonColor") + .name("Polygon Color") + .onChange((value: string) => { + if (current) { + applyPolygonMaterial(current, value, params.opacity, params.wireframe, params.showOutline); + } + }); + polygonFolder + .add(params, "opacity", 0, 1, 0.01) + .name("Opacity") + .onChange((value: number) => { + if (current) { + applyPolygonMaterial(current, params.polygonColor, value, params.wireframe, params.showOutline); + } + }); + polygonFolder + .add(params, "wireframe") + .name("Wireframe") + .onChange((value: boolean) => { + if (current) { + applyPolygonMaterial(current, params.polygonColor, params.opacity, value, params.showOutline); + } + }); + + const performanceFolder = gui.addFolder("Performance"); + performanceFolder.open(); + performanceFolder + .add(params, "showStats") + .name("Show FPS Stats") + .onChange((value: boolean) => { + stats.dom.style.display = value ? "block" : "none"; + }); + performanceFolder + .add(params, "statsMode", { FPS: 0, "Frame Time (ms)": 1, "Memory (MB)": 2 }) + .name("Stats Mode") + .onChange((value: number) => { + stats.showPanel(Number(value)); + }); + + const infoFolder = gui.addFolder("Polygon Info"); + infoFolder.open(); + infoFolder.add(info, "vertices").name("Vertex Count").listen(); + infoFolder.add(info, "holes").name("Hole Count").listen(); + infoFolder.add(info, "category").name("Category").listen(); + + createPolygon(params.polygonType); + }, +}); diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-polygon.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts similarity index 77% rename from main/opengeometry-three/examples-vite/src/pages/shapes-polygon.ts rename to main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts index ef1131d..209f843 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-polygon.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts @@ -1,9 +1,6 @@ import { Polygon, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; function buildPolygonVertices(sides: number, radius: number): Vector3[] { const clampedSides = Math.max(3, Math.floor(sides)); @@ -18,9 +15,14 @@ function buildPolygonVertices(sides: number, radius: number): Vector3[] { return points; } -bootstrapExample({ - title: "Shape: Polygon", - description: "Interactive polygon triangulation with side/radius controls.", +export default defineExample({ + slug: "shapes/polygon", + category: "shapes", + title: "Polygon", + description: "Planar polygon triangulation for surfaces and slabs.", + statusLabel: "ready", + chips: ["Control: Sides", "Control: Radius"], + footerText: "Control: Sides, Radius", build: ({ scene }) => { let current: Polygon | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-sphere.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts similarity index 76% rename from main/opengeometry-three/examples-vite/src/pages/shapes-sphere.ts rename to main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts index 680cc98..92fd223 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-sphere.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts @@ -1,13 +1,15 @@ import { Sphere, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Shape: Sphere", - description: "Interactive UV-sphere primitive generated by the kernel.", +export default defineExample({ + slug: "shapes/sphere", + category: "shapes", + title: "Sphere", + description: "UV sphere for equipment envelopes and clearance studies.", + statusLabel: "ready", + chips: ["Control: R", "Control: Segments"], + footerText: "Control: R, Segments", build: ({ scene }) => { let current: Sphere | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-sweep.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts similarity index 84% rename from main/opengeometry-three/examples-vite/src/pages/shapes-sweep.ts rename to main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts index 0ded1c9..3e137ca 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-sweep.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts @@ -1,9 +1,6 @@ import { Sweep, Vector3 } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; function buildPath(height: number, spread: number): Vector3[] { return [ @@ -24,9 +21,14 @@ function buildProfile(width: number, depth: number): Vector3[] { ]; } -bootstrapExample({ - title: "Shape: Sweep", - description: "Interactive profile sweep along a 3D path.", +export default defineExample({ + slug: "shapes/sweep", + category: "shapes", + title: "Sweep", + description: "Profile along path sweep for framing and custom sections.", + statusLabel: "ready", + chips: ["Control: Path", "Control: Caps"], + footerText: "Control: Path, Caps", build: ({ scene }) => { let current: Sweep | null = null; diff --git a/main/opengeometry-three/examples-vite/src/pages/shapes-wedge.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts similarity index 74% rename from main/opengeometry-three/examples-vite/src/pages/shapes-wedge.ts rename to main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts index 8f4a7eb..c5099ba 100644 --- a/main/opengeometry-three/examples-vite/src/pages/shapes-wedge.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts @@ -1,13 +1,15 @@ import { Vector3, Wedge } from "@og-three"; -import { - bootstrapExample, - mountControls, - replaceSceneObject, -} from "../shared/runtime"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; -bootstrapExample({ - title: "Shape: Wedge", - description: "Interactive wedge primitive with size controls.", +export default defineExample({ + slug: "shapes/wedge", + category: "shapes", + title: "Wedge", + description: "Tapered solid for ramps and sloped technical elements.", + statusLabel: "ready", + chips: ["Control: W/H/D"], + footerText: "Control: W/H/D", build: ({ scene }) => { let current: Wedge | null = null; @@ -27,7 +29,7 @@ bootstrapExample({ width: state.width as number, height: state.height as number, depth: state.depth as number, - color: 0x7c3aed, + color: 0xd97706, fatOutlines: state.fatOutlines as boolean, outlineWidth: state.outlineWidth as number, }); diff --git a/main/opengeometry-three/examples-vite/src/shared/example-contract.ts b/main/opengeometry-three/examples-vite/src/shared/example-contract.ts new file mode 100644 index 0000000..a210ec6 --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/shared/example-contract.ts @@ -0,0 +1,18 @@ +import type { ExampleContext } from "./runtime"; + +export type ExampleCategory = "primitives" | "shapes" | "operations"; + +export interface ExampleDefinition { + slug: `${ExampleCategory}/${string}`; + category: ExampleCategory; + title: string; + description: string; + statusLabel: string; + chips: string[]; + footerText: string; + build: (ctx: ExampleContext) => void | Promise; +} + +export function defineExample(example: ExampleDefinition): ExampleDefinition { + return example; +} diff --git a/main/opengeometry-three/examples-vite/src/shared/examples.ts b/main/opengeometry-three/examples-vite/src/shared/examples.ts new file mode 100644 index 0000000..5844955 --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/shared/examples.ts @@ -0,0 +1,34 @@ +import type { ExampleCategory, ExampleDefinition } from "./example-contract"; + +export const categoryLabels: Record = { + primitives: "Primitives", + shapes: "Shapes", + operations: "Operations", +}; + +const modules = import.meta.glob("../examples/**/*.ts", { eager: true }); + +const discoveredExamples = Object.values(modules) + .map((entry) => (entry as { default?: ExampleDefinition }).default) + .filter((entry): entry is ExampleDefinition => Boolean(entry)); + +export const examples = discoveredExamples.sort((left, right) => { + if (left.category === right.category) { + return left.title.localeCompare(right.title); + } + + return left.category.localeCompare(right.category); +}); + +export function getExampleBySlug(slug: string): ExampleDefinition { + const match = examples.find((example) => example.slug === slug); + if (!match) { + throw new Error(`Unknown example slug: ${slug}`); + } + + return match; +} + +export function getExamplesByCategory(category: ExampleCategory): ExampleDefinition[] { + return examples.filter((example) => example.category === category); +} diff --git a/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts b/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts new file mode 100644 index 0000000..20eac0e --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts @@ -0,0 +1,45 @@ +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { icon } from "@fortawesome/fontawesome-svg-core"; +import { + faBezierCurve, + faCompassDrafting, + faCube, + faDatabase, + faDoorOpen, + faDrawPolygon, + faGlobe, + faLayerGroup, + faMountain, + faRoad, + faRoute, + faShapes, + faSlash, + faUpRightAndDownLeftFromCenter, + faVectorSquare, + faWarehouse, +} from "@fortawesome/free-solid-svg-icons"; + +const iconBySlug: Record = { + line: faSlash, + polyline: faDrawPolygon, + arc: faCompassDrafting, + rectangle: faVectorSquare, + curve: faBezierCurve, + polygon: faDrawPolygon, + "polygon-suite": faLayerGroup, + cuboid: faCube, + cylinder: faDatabase, + wedge: faMountain, + opening: faDoorOpen, + sweep: faRoad, + sphere: faGlobe, + offset: faUpRightAndDownLeftFromCenter, + "wall-from-offsets": faWarehouse, + "sweep-path-profile": faRoute, +}; + +export function getExampleIconMarkup(slug: string): string { + const key = slug.split("/").pop() ?? slug; + const selected = iconBySlug[key] ?? faShapes; + return icon(selected).html.join(""); +} diff --git a/main/opengeometry-three/examples-vite/src/shared/runtime.ts b/main/opengeometry-three/examples-vite/src/shared/runtime.ts index 480b8b2..84087d8 100644 --- a/main/opengeometry-three/examples-vite/src/shared/runtime.ts +++ b/main/opengeometry-three/examples-vite/src/shared/runtime.ts @@ -2,6 +2,8 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { OpenGeometry } from "@og-three"; import "../styles/theme.css"; +import type { ExampleDefinition } from "./example-contract"; +import { getExampleIconMarkup } from "./icon-registry"; export interface ExampleContext { scene: THREE.Scene; @@ -30,20 +32,15 @@ export type ExampleControlDefinition = export type ExampleControlState = Record; interface BootstrapConfig { - title: string; - description: string; + example: ExampleDefinition; build: (ctx: ExampleContext) => void | Promise; } function getWasmUrl(): string { - // Resolve from the built runtime chunk location (`examples-dist/assets/...`) - // so nested example pages (e.g. `shapes/sphere.html`) do not request - // `shapes/assets/...` and 404. if (import.meta.env.PROD) { return new URL("./wasm/opengeometry_bg.wasm", import.meta.url).toString(); } - // Dev server fallback. return new URL( "../../../../opengeometry/pkg/opengeometry_bg.wasm", import.meta.url @@ -55,20 +52,35 @@ export async function bootstrapExample(config: BootstrapConfig) { if (!app) { throw new Error("Missing #app container"); } + + const { example } = config; document.body.classList.add("og-example-page"); + document.title = `${example.title} | OpenGeometry`; const badge = document.createElement("div"); badge.className = "og-badge"; badge.innerHTML = ` -
OPEN GEOMETRY • SPEC VIEW
- ${config.title} - ${config.description} - All Example Specs +
+
${getExampleIconMarkup(example.slug)}
+

${example.statusLabel}

+
+
+

OPEN GEOMETRY • ${example.category.toUpperCase()}

+ ${example.title} + ${example.description} +
+
+ ${example.chips.map((chip) => `${chip}`).join("")} +
+ `; document.body.appendChild(badge); const scene = new THREE.Scene(); - scene.background = new THREE.Color(0xf3f4f6); + scene.background = new THREE.Color(0xf6f1eb); const camera = new THREE.PerspectiveCamera( 55, @@ -88,16 +100,16 @@ export async function bootstrapExample(config: BootstrapConfig) { controls.target.set(0, 0.8, 0); controls.update(); - scene.add(new THREE.GridHelper(32, 32, 0x9ca3af, 0xd1d5db)); + scene.add(new THREE.GridHelper(32, 32, 0xd88b63, 0xe7d4ca)); - const ambient = new THREE.AmbientLight(0xffffff, 0.65); + const ambient = new THREE.AmbientLight(0xffffff, 0.72); scene.add(ambient); - const key = new THREE.DirectionalLight(0xffffff, 0.85); + const key = new THREE.DirectionalLight(0xffffff, 0.9); key.position.set(6, 8, 4); scene.add(key); - const fill = new THREE.DirectionalLight(0xffffff, 0.35); + const fill = new THREE.DirectionalLight(0xffffff, 0.42); fill.position.set(-5, 3, -6); scene.add(fill); @@ -194,7 +206,7 @@ export function mountControls( header.className = "og-control-header"; const label = document.createElement("span"); - label.className = "og-control-label"; + label.className = "og-control-label-chip"; label.textContent = definition.label; header.appendChild(label); @@ -261,8 +273,8 @@ export function mountControls( boolLabel.textContent = toggle.checked ? "Enabled" : "Disabled"; emitChange(); }; - toggle.addEventListener("change", updateToggle); + toggle.addEventListener("change", updateToggle); boolWrap.appendChild(toggle); boolWrap.appendChild(boolLabel); row.appendChild(header); diff --git a/main/opengeometry-three/examples-vite/src/styles/theme.css b/main/opengeometry-three/examples-vite/src/styles/theme.css index a75c485..8b53954 100644 --- a/main/opengeometry-three/examples-vite/src/styles/theme.css +++ b/main/opengeometry-three/examples-vite/src/styles/theme.css @@ -1,15 +1,19 @@ :root { color-scheme: light; - --og-bg: #d1d1d1; - --og-panel: rgba(214, 214, 214, 0.92); - --og-surface: rgba(220, 220, 220, 0.92); - --og-line: #b8b8b8; - --og-line-strong: #8d8d8d; - --og-text: #242424; - --og-muted: #5f5f5f; - --og-accent: #f18f33; - --og-font: "Sora", "Avenir Next", "Segoe UI", sans-serif; + --og-bg: #ede7e0; + --og-panel: rgba(255, 250, 246, 0.92); + --og-surface: rgba(255, 252, 248, 0.96); + --og-line: rgba(95, 61, 46, 0.1); + --og-line-strong: rgba(95, 61, 46, 0.22); + --og-text: #221a14; + --og-muted: #736255; + --og-muted-soft: #9f8f83; + --og-accent: #ff5400; + --og-accent-soft: rgba(255, 84, 0, 0.12); + --og-shadow: 0 22px 44px rgba(72, 44, 24, 0.08); + --og-font: "Space Grotesk", "Sora", "Avenir Next", "Segoe UI", sans-serif; --og-mono: "IBM Plex Mono", "JetBrains Mono", "SFMono-Regular", monospace; + --og-squircle: 32px 34px 30px 36px / 32px 28px 36px 30px; } * { @@ -22,12 +26,17 @@ body { width: 100%; min-height: 100%; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.04)), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)), + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(116, 84, 63, 0.05)), var(--og-bg); color: var(--og-text); font: 14px/1.45 var(--og-font); } +body { + overflow-x: hidden; +} + code { font-family: var(--og-mono); } @@ -38,25 +47,28 @@ code { } .og-specs-shell { - width: min(1320px, calc(100% - 28px)); + width: min(1320px, calc(100% - 30px)); margin: 0 auto; - padding: 22px 8px 36px; + padding: 26px 8px 48px; } .og-specs-head { display: grid; - grid-template-columns: 1fr minmax(240px, 420px); - gap: 20px; - padding-bottom: 18px; - border-bottom: 1px solid var(--og-line); + grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr); + gap: 24px; + padding: 22px 24px; + border: 1px solid var(--og-line); + border-radius: var(--og-squircle); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.78), rgba(249, 239, 229, 0.9)); + box-shadow: var(--og-shadow); } .og-specs-title { - margin: 0; - font-weight: 600; + margin: 6px 0 0; + font-weight: 700; letter-spacing: -0.03em; - font-size: clamp(42px, 7vw, 70px); - line-height: 0.95; + font-size: clamp(42px, 6vw, 74px); + line-height: 0.92; } .og-specs-note { @@ -66,7 +78,13 @@ code { line-height: 1.4; text-transform: uppercase; letter-spacing: 0.08em; - align-self: center; +} + +.og-specs-head .og-specs-note:last-child { + align-self: end; + justify-self: end; + max-width: 320px; + text-align: right; } .og-specs-section { @@ -78,16 +96,15 @@ code { align-items: baseline; justify-content: space-between; gap: 10px; - margin-bottom: 12px; - padding-bottom: 8px; - border-bottom: 1px solid var(--og-line); + margin-bottom: 14px; + padding: 0 4px; } .og-specs-section-title { margin: 0; - font-size: clamp(28px, 4vw, 46px); + font-size: clamp(28px, 4vw, 42px); letter-spacing: -0.03em; - font-weight: 550; + font-weight: 650; } .og-specs-count { @@ -96,86 +113,149 @@ code { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.45); + border: 1px solid var(--og-line); } .og-specs-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 18px; } .og-spec-card { - display: block; - text-decoration: none; - color: inherit; - background: var(--og-surface); + display: grid; + gap: 16px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(250, 242, 235, 0.96)); border: 1px solid var(--og-line); - min-height: 230px; - padding: 10px; - transition: border-color 120ms ease, transform 120ms ease; + min-height: 312px; + padding: 18px 18px 16px; + border-radius: var(--og-squircle); + box-shadow: var(--og-shadow); + transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } .og-spec-card:hover { - border-color: var(--og-line-strong); - transform: translateY(-1px); + border-color: rgba(255, 84, 0, 0.28); + transform: translateY(-3px); + box-shadow: 0 28px 52px rgba(72, 44, 24, 0.12); } .og-spec-card-head { display: flex; justify-content: space-between; - align-items: flex-start; - gap: 10px; + align-items: start; + gap: 12px; +} + +.og-spec-card-brand, +.og-badge-icon-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 20px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(255, 235, 223, 0.92)); + border: 1px solid rgba(255, 84, 0, 0.18); + color: var(--og-accent); +} + +.og-spec-card-brand svg, +.og-badge-icon-wrap svg { + width: 20px; + height: 20px; + opacity: 0.88; } .og-spec-card-title { margin: 0; - font-weight: 600; - font-size: 19px; + font-weight: 650; + font-size: 28px; letter-spacing: -0.02em; } -.og-spec-badge { +.og-spec-badge, +.og-badge-status { margin: 0; - color: var(--og-accent); - font-size: 10px; - letter-spacing: 0.13em; - text-transform: uppercase; + color: #4f7259; + background: rgba(133, 189, 149, 0.12); + border: 1px solid rgba(133, 189, 149, 0.24); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: capitalize; + padding: 8px 12px; + border-radius: 14px; } .og-spec-desc { - margin: 4px 0 12px; + margin: 6px 0 0; color: var(--og-muted); + font-size: 14px; + min-height: 44px; +} + +.og-spec-chip-row, +.og-badge-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.og-chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid rgba(214, 132, 83, 0.14); + background: rgba(246, 234, 226, 0.92); + color: #9f6b4e; font-size: 12px; + font-weight: 500; } -.og-spec-meta { - margin: 0; - border-top: 1px solid var(--og-line); +.og-chip-accent { + border-color: rgba(255, 84, 0, 0.14); + background: var(--og-accent-soft); + color: var(--og-accent); } -.og-spec-meta-row { +.og-spec-card-footer, +.og-badge-footer { display: flex; align-items: center; - justify-content: space-between; - gap: 8px; - margin: 0; - border-bottom: 1px solid var(--og-line); - padding: 4px 0; - font-size: 11px; + justify-content: flex-end; + gap: 14px; + margin-top: auto; + padding-top: 14px; + border-top: 1px solid rgba(95, 61, 46, 0.08); } -.og-spec-meta dt { - color: var(--og-muted); - text-transform: uppercase; - letter-spacing: 0.08em; +.og-spec-context, +.og-badge-context { + color: var(--og-muted-soft); + font-size: 12px; + letter-spacing: 0.02em; } -.og-spec-meta dd { - margin: 0; - font-family: var(--og-mono); - font-size: 10px; - letter-spacing: 0.06em; - color: #424242; +.og-spec-open, +.og-badge-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 16px; + border-radius: 14px; + border: 1px solid rgba(96, 87, 78, 0.14); + background: #64584f; + color: #fbf7f3; + font-weight: 600; + text-decoration: none; + box-shadow: 0 10px 18px rgba(76, 63, 54, 0.16); } .og-example-page { @@ -186,16 +266,30 @@ code { .og-badge { position: fixed; - top: 12px; - left: 12px; - width: min(460px, calc(100vw - 24px)); + top: 16px; + left: 16px; + width: min(480px, calc(100vw - 32px)); border: 1px solid var(--og-line); background: var(--og-panel); - padding: 10px 12px; + padding: 16px; + border-radius: var(--og-squircle); + box-shadow: var(--og-shadow); z-index: 30; } +.og-badge-top { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; +} + +.og-badge-meta { + margin-top: 14px; +} + .og-badge-kicker { + margin: 0; color: var(--og-muted); font-size: 10px; letter-spacing: 0.13em; @@ -204,55 +298,49 @@ code { .og-badge-title { display: block; - margin-top: 2px; - margin-bottom: 3px; - font-size: 17px; - font-weight: 600; + margin-top: 8px; + margin-bottom: 4px; + font-size: 26px; + font-weight: 650; letter-spacing: -0.02em; } .og-badge-desc { display: block; color: var(--og-muted); - font-size: 12px; -} - -.og-badge-link { - display: inline-block; - margin-top: 8px; - color: var(--og-text); - font-size: 11px; - text-decoration: none; - border-bottom: 1px solid var(--og-line-strong); + font-size: 14px; } .og-controls { position: fixed; - top: 12px; - right: 12px; - width: min(420px, calc(100vw - 24px)); - max-height: calc(100vh - 24px); + top: 16px; + right: 16px; + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--og-line); background: var(--og-panel); - padding: 10px 12px; + padding: 14px; + border-radius: var(--og-squircle); + box-shadow: var(--og-shadow); z-index: 30; } .og-controls h3 { - margin: 0 0 10px; - font-size: 16px; - font-weight: 600; + margin: 0 0 12px; + font-size: 18px; + font-weight: 650; letter-spacing: -0.02em; } .og-control-row { display: grid; - grid-template-columns: 1fr; - gap: 4px; - margin: 0 0 10px; - padding-top: 6px; - border-top: 1px solid var(--og-line); + gap: 10px; + margin: 0 0 12px; + padding: 12px; + border: 1px solid rgba(95, 61, 46, 0.08); + border-radius: 20px; + background: rgba(255, 255, 255, 0.72); } .og-control-header { @@ -262,159 +350,148 @@ code { gap: 10px; } -.og-control-label { - font-size: 11px; - color: var(--og-muted); - text-transform: uppercase; - letter-spacing: 0.09em; +.og-control-label-chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 7px 12px; + border-radius: 999px; + background: rgba(255, 84, 0, 0.1); + color: #a76439; + font-size: 12px; + font-weight: 600; } .og-control-value { - font-size: 10px; - color: #3d3d3d; + color: var(--og-text); + font-size: 12px; } .og-control-range { display: grid; - grid-template-columns: 1fr 96px; - gap: 8px; - align-items: center; -} - -.og-control-range input[type="number"] { - width: 100%; - border: 1px solid var(--og-line); - background: #dfdfdf; - color: var(--og-text); - padding: 4px 6px; - font: 11px/1.2 var(--og-mono); + grid-template-columns: 1fr 94px; + gap: 10px; } .og-control-range input[type="range"] { width: 100%; - margin: 0; - padding: 0; - height: 18px; - background: transparent; - -webkit-appearance: none; - appearance: none; -} - -.og-control-range input[type="range"]::-webkit-slider-runnable-track { - height: 4px; - border: 1px solid var(--og-line-strong); - background: linear-gradient(90deg, #b3b3b3 0%, #9f9f9f 100%); + accent-color: var(--og-accent); } -.og-control-range input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - margin-top: -5px; - border: 1px solid #767676; - border-radius: 2px; - background: #ececec; - box-shadow: inset 0 0 0 1px #d0d0d0; +.og-control-range input[type="number"] { + width: 100%; + border: 1px solid var(--og-line); + background: rgba(255, 255, 255, 0.85); + color: var(--og-text); + padding: 8px 10px; + font: inherit; + border-radius: 12px; } -.og-control-range input[type="range"]::-moz-range-track { - height: 4px; - border: 1px solid var(--og-line-strong); - background: linear-gradient(90deg, #b3b3b3 0%, #9f9f9f 100%); +.og-control-bool { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -.og-control-range input[type="range"]::-moz-range-thumb { - width: 12px; - height: 12px; - border: 1px solid #767676; - border-radius: 2px; - background: #ececec; - box-shadow: inset 0 0 0 1px #d0d0d0; +.og-toggle { + width: 18px; + height: 18px; + accent-color: var(--og-accent); } -.og-control-range input[type="range"]:focus-visible { - outline: none; +.og-control-bool-state { + color: var(--og-muted); + font-size: 12px; } -.og-control-range input[type="range"]:focus-visible::-webkit-slider-runnable-track { - border-color: #6a88b8; +#polygon-description { + position: fixed; + left: 16px; + bottom: 16px; + max-width: 360px; + padding: 14px 16px; + border-radius: 24px; + border: 1px solid var(--og-line); + background: rgba(34, 26, 20, 0.86); + color: #fff7f2; + box-shadow: var(--og-shadow); + z-index: 25; } -.og-control-range input[type="range"]:focus-visible::-moz-range-track { - border-color: #6a88b8; +#stats { + position: fixed; + left: 16px; + bottom: 160px; + z-index: 26; +} + +.lil-gui.root { + --background-color: rgba(255, 250, 246, 0.95); + --text-color: #221a14; + --title-background-color: rgba(255, 84, 0, 0.1); + --title-text-color: #221a14; + --widget-color: rgba(255, 255, 255, 0.88); + --hover-color: rgba(255, 84, 0, 0.12); + position: fixed; + top: 16px; + right: 16px; + z-index: 32; + border-radius: 24px; + overflow: hidden; + box-shadow: var(--og-shadow); } -.og-control-bool { - display: flex; - align-items: center; - gap: 8px; - color: var(--og-muted); - font-size: 12px; -} +@media (max-width: 900px) { + .og-specs-head { + grid-template-columns: 1fr; + } -.og-toggle { - -webkit-appearance: none; - appearance: none; - width: 42px; - height: 22px; - margin: 0; - border: 1px solid var(--og-line-strong); - border-radius: 999px; - background: #c4c4c4; - position: relative; - cursor: pointer; - transition: background-color 140ms ease, border-color 140ms ease; -} - -.og-toggle::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - border-radius: 999px; - background: #efefef; - border: 0; - box-shadow: inset 0 0 0 1px #9a9a9a; - transition: left 140ms ease; + .og-specs-head .og-specs-note:last-child { + justify-self: start; + text-align: left; + } } -.og-toggle:checked { - background: #cdd8cb; - border-color: #7d9279; -} +@media (max-width: 780px) { + .og-badge, + .og-controls, + .lil-gui.root { + position: static; + width: auto; + max-height: none; + margin: 16px; + } -.og-toggle:checked::after { - left: calc(100% - 18px); -} + #polygon-description, + #stats { + position: static; + margin: 16px; + } -.og-toggle:focus-visible { - outline: 2px solid #6a88b8; - outline-offset: 2px; + .og-example-page #app { + height: calc(100vh - 420px); + } } -.og-control-bool-state { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--og-muted); -} +@media (max-width: 640px) { + .og-specs-shell { + width: min(100%, calc(100% - 20px)); + padding-top: 18px; + } -@media (max-width: 860px) { - .og-specs-head { - grid-template-columns: 1fr; + .og-spec-card { + min-height: 286px; } - .og-badge { - width: calc(100vw - 24px); + .og-spec-card-title { + font-size: 24px; } - .og-controls { - top: auto; - bottom: 12px; - width: calc(100vw - 24px); - max-height: min(50vh, 420px); + .og-spec-card-footer, + .og-badge-footer { + flex-direction: column; + align-items: stretch; } } diff --git a/main/opengeometry-three/package-lock.json b/main/opengeometry-three/package-lock.json index f05fd40..992f21f 100644 --- a/main/opengeometry-three/package-lock.json +++ b/main/opengeometry-three/package-lock.json @@ -9,12 +9,47 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", "@opengeometry/openmaths": "^0.2.3" }, "devDependencies": { "typedoc": "^0.28.14" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@gerrit0/mini-shiki": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", diff --git a/main/opengeometry-three/package.json b/main/opengeometry-three/package.json index 2407e56..785e300 100644 --- a/main/opengeometry-three/package.json +++ b/main/opengeometry-three/package.json @@ -20,6 +20,8 @@ "author": "Vishwajeet Mane", "license": "MIT", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", "@opengeometry/openmaths": "^0.2.3" }, "devDependencies": { diff --git a/main/opengeometry-three/vite.examples.config.mjs b/main/opengeometry-three/vite.examples.config.mjs index cc9920e..0a1e737 100644 --- a/main/opengeometry-three/vite.examples.config.mjs +++ b/main/opengeometry-three/vite.examples.config.mjs @@ -1,4 +1,4 @@ -import { readdirSync, statSync } from "node:fs"; +import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,17 +8,18 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const examplesRoot = resolve(__dirname, "examples-vite"); +const examplesSourceRoot = resolve(examplesRoot, "src/examples"); -function collectHtmlFiles(dir, files = []) { +function collectFiles(dir, extension, files = []) { for (const entry of readdirSync(dir)) { const fullPath = join(dir, entry); const stats = statSync(fullPath); if (stats.isDirectory()) { - collectHtmlFiles(fullPath, files); + collectFiles(fullPath, extension, files); continue; } - if (extname(fullPath) === ".html") { + if (extname(fullPath) === extension) { files.push(fullPath); } } @@ -26,15 +27,46 @@ function collectHtmlFiles(dir, files = []) { return files; } -const htmlInputs = collectHtmlFiles(examplesRoot); -const input = Object.fromEntries( - htmlInputs.map((filePath) => { - const id = relative(examplesRoot, filePath) - .replace(/\\/g, "/") - .replace(/\.html$/, ""); - return [id, filePath]; - }) -); +function toPosixPath(path) { + return path.replace(/\\/g, "/"); +} + +function createExampleHtml(slug) { + return ` + + + + + OpenGeometry Example + + + +
+ + + +`; +} + +function syncGeneratedExampleEntries() { + const moduleFiles = collectFiles(examplesSourceRoot, ".ts"); + + const inputs = { + index: resolve(examplesRoot, "index.html"), + }; + + for (const modulePath of moduleFiles) { + const slug = toPosixPath(relative(examplesSourceRoot, modulePath)).replace(/\.ts$/, ""); + const htmlPath = resolve(examplesRoot, `${slug}.html`); + mkdirSync(dirname(htmlPath), { recursive: true }); + writeFileSync(htmlPath, createExampleHtml(slug)); + inputs[slug] = htmlPath; + } + + return inputs; +} + +const input = syncGeneratedExampleEntries(); export default defineConfig({ root: examplesRoot, From 542297df4cb42e016b5c66e9953998ce7ab100c0 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Mon, 9 Mar 2026 23:21:36 +0100 Subject: [PATCH 05/10] improve example structure --- .../examples-vite/operations/offset.html | 341 +++++++++++- .../operations/sweep-hilbert-profiles.html | 498 ++++++++++++++++++ .../operations/sweep-path-profile.html | 367 ++++++++++++- .../operations/wall-from-offsets.html | 370 ++++++++++++- .../examples-vite/primitives/arc.html | 331 +++++++++++- .../examples-vite/primitives/curve.html | 330 +++++++++++- .../examples-vite/primitives/line.html | 327 +++++++++++- .../examples-vite/primitives/polyline.html | 336 +++++++++++- .../examples-vite/primitives/rectangle.html | 324 +++++++++++- .../examples-vite/shapes/cuboid.html | 331 +++++++++++- .../examples-vite/shapes/cylinder.html | 333 +++++++++++- .../examples-vite/shapes/opening.html | 331 +++++++++++- .../examples-vite/shapes/polygon-suite.html | 497 ++++++++++++++++- .../examples-vite/shapes/polygon.html | 339 +++++++++++- .../examples-vite/shapes/sphere.html | 330 +++++++++++- .../examples-vite/shapes/sweep.html | 352 ++++++++++++- .../examples-vite/shapes/wedge.html | 331 +++++++++++- .../examples-vite/src/catalog.ts | 2 +- .../examples-vite/src/example-page.ts | 15 - .../src/examples/operations/offset.ts | 4 +- .../operations/sweep-hilbert-profiles.ts | 253 +++++++++ .../examples/operations/sweep-path-profile.ts | 4 +- .../examples/operations/wall-from-offsets.ts | 4 +- .../src/examples/primitives/arc.ts | 4 +- .../src/examples/primitives/curve.ts | 4 +- .../src/examples/primitives/line.ts | 4 +- .../src/examples/primitives/polyline.ts | 4 +- .../src/examples/primitives/rectangle.ts | 4 +- .../src/examples/shapes/cuboid.ts | 4 +- .../src/examples/shapes/cylinder.ts | 4 +- .../src/examples/shapes/opening.ts | 4 +- .../src/examples/shapes/polygon-suite.ts | 4 +- .../src/examples/shapes/polygon.ts | 4 +- .../src/examples/shapes/sphere.ts | 4 +- .../src/examples/shapes/sweep.ts | 4 +- .../src/examples/shapes/wedge.ts | 4 +- .../examples-vite/src/shared/examples.ts | 237 ++++++++- .../examples-vite/src/shared/icon-registry.ts | 1 + .../examples-vite/src/shared/runtime.ts | 88 +++- .../examples-vite/src/styles/theme.css | 26 +- main/opengeometry-three/examples/offset.html | 118 ----- main/opengeometry-three/examples/sweep.html | 127 ----- .../examples/wall-from-offsets.html | 118 ----- main/opengeometry-three/examples/wedge.html | 110 ---- .../vite.examples.config.mjs | 44 +- main/opengeometry/src/primitives/arc.rs | 6 - main/opengeometry/src/primitives/polyline.rs | 6 - 47 files changed, 6558 insertions(+), 725 deletions(-) create mode 100644 main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html delete mode 100644 main/opengeometry-three/examples-vite/src/example-page.ts create mode 100644 main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts delete mode 100644 main/opengeometry-three/examples/offset.html delete mode 100644 main/opengeometry-three/examples/sweep.html delete mode 100644 main/opengeometry-three/examples/wall-from-offsets.html delete mode 100644 main/opengeometry-three/examples/wedge.html diff --git a/main/opengeometry-three/examples-vite/operations/offset.html b/main/opengeometry-three/examples-vite/operations/offset.html index 173621b..a15361b 100644 --- a/main/opengeometry-three/examples-vite/operations/offset.html +++ b/main/opengeometry-three/examples-vite/operations/offset.html @@ -1,13 +1,340 @@ - + - - - OpenGeometry Example + OpenGeometry Offset Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html b/main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html new file mode 100644 index 0000000..4c211b7 --- /dev/null +++ b/main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html @@ -0,0 +1,498 @@ + + + + OpenGeometry Sweep Hilbert Profiles Example + + + + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html b/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html index 34f1e97..2cb271a 100644 --- a/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html +++ b/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html @@ -1,13 +1,366 @@ - + - - - OpenGeometry Example + OpenGeometry Sweep Path + Profile Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html b/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html index 935af04..202f533 100644 --- a/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html +++ b/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html @@ -1,13 +1,369 @@ - + - - - OpenGeometry Example + OpenGeometry Wall from Offsets Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/primitives/arc.html b/main/opengeometry-three/examples-vite/primitives/arc.html index 384db3e..6c33150 100644 --- a/main/opengeometry-three/examples-vite/primitives/arc.html +++ b/main/opengeometry-three/examples-vite/primitives/arc.html @@ -1,13 +1,330 @@ - + - - - OpenGeometry Example + OpenGeometry Arc Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/primitives/curve.html b/main/opengeometry-three/examples-vite/primitives/curve.html index 29e8d51..814d823 100644 --- a/main/opengeometry-three/examples-vite/primitives/curve.html +++ b/main/opengeometry-three/examples-vite/primitives/curve.html @@ -1,13 +1,329 @@ - + - - - OpenGeometry Example + OpenGeometry Curve Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/primitives/line.html b/main/opengeometry-three/examples-vite/primitives/line.html index 765fecf..eb47b7b 100644 --- a/main/opengeometry-three/examples-vite/primitives/line.html +++ b/main/opengeometry-three/examples-vite/primitives/line.html @@ -1,13 +1,326 @@ - + - - - OpenGeometry Example + OpenGeometry Line Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/primitives/polyline.html b/main/opengeometry-three/examples-vite/primitives/polyline.html index 0dcbfec..6a635eb 100644 --- a/main/opengeometry-three/examples-vite/primitives/polyline.html +++ b/main/opengeometry-three/examples-vite/primitives/polyline.html @@ -1,13 +1,335 @@ - + - - - OpenGeometry Example + OpenGeometry Polyline Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/primitives/rectangle.html b/main/opengeometry-three/examples-vite/primitives/rectangle.html index 7a66eaa..df817e7 100644 --- a/main/opengeometry-three/examples-vite/primitives/rectangle.html +++ b/main/opengeometry-three/examples-vite/primitives/rectangle.html @@ -1,13 +1,323 @@ - + - - - OpenGeometry Example + OpenGeometry Rectangle Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/cuboid.html b/main/opengeometry-three/examples-vite/shapes/cuboid.html index 6f2f2ac..9718a34 100644 --- a/main/opengeometry-three/examples-vite/shapes/cuboid.html +++ b/main/opengeometry-three/examples-vite/shapes/cuboid.html @@ -1,13 +1,330 @@ - + - - - OpenGeometry Example + OpenGeometry Cuboid Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/cylinder.html b/main/opengeometry-three/examples-vite/shapes/cylinder.html index e1fe56e..a0f07d2 100644 --- a/main/opengeometry-three/examples-vite/shapes/cylinder.html +++ b/main/opengeometry-three/examples-vite/shapes/cylinder.html @@ -1,13 +1,332 @@ - + - - - OpenGeometry Example + OpenGeometry Cylinder Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/opening.html b/main/opengeometry-three/examples-vite/shapes/opening.html index ce02bc3..4345f80 100644 --- a/main/opengeometry-three/examples-vite/shapes/opening.html +++ b/main/opengeometry-three/examples-vite/shapes/opening.html @@ -1,13 +1,330 @@ - + - - - OpenGeometry Example + OpenGeometry Opening Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/polygon-suite.html b/main/opengeometry-three/examples-vite/shapes/polygon-suite.html index de3c8a3..7ac8edb 100644 --- a/main/opengeometry-three/examples-vite/shapes/polygon-suite.html +++ b/main/opengeometry-three/examples-vite/shapes/polygon-suite.html @@ -1,13 +1,496 @@ - + - - - OpenGeometry Example + OpenGeometry Polygon Suite Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/polygon.html b/main/opengeometry-three/examples-vite/shapes/polygon.html index 8917d2d..ca2f29c 100644 --- a/main/opengeometry-three/examples-vite/shapes/polygon.html +++ b/main/opengeometry-three/examples-vite/shapes/polygon.html @@ -1,13 +1,338 @@ - + - - - OpenGeometry Example + OpenGeometry Polygon Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/sphere.html b/main/opengeometry-three/examples-vite/shapes/sphere.html index 603a504..508b54e 100644 --- a/main/opengeometry-three/examples-vite/shapes/sphere.html +++ b/main/opengeometry-three/examples-vite/shapes/sphere.html @@ -1,13 +1,329 @@ - + - - - OpenGeometry Example + OpenGeometry Sphere Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/sweep.html b/main/opengeometry-three/examples-vite/shapes/sweep.html index 830a823..dc5e29f 100644 --- a/main/opengeometry-three/examples-vite/shapes/sweep.html +++ b/main/opengeometry-three/examples-vite/shapes/sweep.html @@ -1,13 +1,351 @@ - + - - - OpenGeometry Example + OpenGeometry Sweep Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/shapes/wedge.html b/main/opengeometry-three/examples-vite/shapes/wedge.html index 81724b4..c9d2c0a 100644 --- a/main/opengeometry-three/examples-vite/shapes/wedge.html +++ b/main/opengeometry-three/examples-vite/shapes/wedge.html @@ -1,13 +1,330 @@ - + - - - OpenGeometry Example + OpenGeometry Wedge Example + + + + - - +
- + diff --git a/main/opengeometry-three/examples-vite/src/catalog.ts b/main/opengeometry-three/examples-vite/src/catalog.ts index 4eceb7f..fa507a0 100644 --- a/main/opengeometry-three/examples-vite/src/catalog.ts +++ b/main/opengeometry-three/examples-vite/src/catalog.ts @@ -43,7 +43,7 @@ for (const category of ["primitives", "shapes", "operations"] as ExampleCategory const card = document.createElement("article"); card.className = "og-spec-card"; const chips = example.chips - .map((chip) => `${chip}`) + .map((chip) => `
${chip}
`) .join(""); card.innerHTML = ` diff --git a/main/opengeometry-three/examples-vite/src/example-page.ts b/main/opengeometry-three/examples-vite/src/example-page.ts deleted file mode 100644 index c9c9698..0000000 --- a/main/opengeometry-three/examples-vite/src/example-page.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { bootstrapExample } from "./shared/runtime"; -import { getExampleBySlug } from "./shared/examples"; - -const slug = document.body.dataset.exampleSlug; - -if (!slug) { - throw new Error("Missing example slug on page body"); -} - -const example = getExampleBySlug(slug); - -void bootstrapExample({ - example, - build: example.build, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts b/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts index 08bbda1..e781d2b 100644 --- a/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts +++ b/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts @@ -18,8 +18,8 @@ export default defineExample({ title: "Offset", description: "Offset generation with acute-corner and bevel parameters.", statusLabel: "ready", - chips: ["Control: Offset", "Control: Bevel"], - footerText: "Control: Offset, Bevel", + chips: ["Offset", "Bevel"], + footerText: "Offset, Bevel", build: ({ scene }) => { let current: THREE.Group | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts new file mode 100644 index 0000000..4f3874e --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts @@ -0,0 +1,253 @@ +import { Arc, Polyline, Rectangle, Sweep, Vector3 } from "@og-three"; +import * as THREE from "three"; +import { hilbert3D } from "three/examples/jsm/utils/GeometryUtils.js"; +import { defineExample } from "../../shared/example-contract"; +import { mountControls, replaceSceneObject } from "../../shared/runtime"; + +type KernelVertex = { + position: { + x: number; + y: number; + z: number; + }; +}; + +type KernelBrep = { + vertices: KernelVertex[]; +}; + +type ProfileType = + | "arc-pipe" + | "rectangle" + | "architectural-molding" + | "trapezoid" + | "diamond" + | "hexagon"; + +const LOCKED_HILBERT_SIZE = 8; +const LOCKED_HILBERT_ITERATIONS = 1; + +function toVector3List(vertices: KernelVertex[]): Vector3[] { + return vertices.map((vertex) => { + const { x, y, z } = vertex.position; + return new Vector3(x, y, z); + }); +} + +function readLineGeometryPoints(object: THREE.Object3D): Vector3[] { + const geometryOwner = object as THREE.Line; + const positions = geometryOwner.geometry?.getAttribute("position"); + if (!positions || positions.itemSize !== 3) { + throw new Error("Profile preview geometry is missing line positions."); + } + + const points: Vector3[] = []; + for (let index = 0; index < positions.count; index += 1) { + points.push( + new Vector3( + positions.getX(index), + positions.getY(index), + positions.getZ(index) + ) + ); + } + + if (points.length >= 3) { + const first = points[0]; + const last = points[points.length - 1]; + const dx = first.x - last.x; + const dy = first.y - last.y; + const dz = first.z - last.z; + if (dx * dx + dy * dy + dz * dz <= 1.0e-12) { + points.pop(); + } + } + + return points; +} + +function scaleProfileShape(points: Array<[number, number]>, width: number, depth: number): Vector3[] { + return points.map(([x, z]) => new Vector3(x * width, 0, z * depth)); +} + +function buildHilbertPath(size: number, iterations: number): Vector3[] { + const rawPoints = hilbert3D(new THREE.Vector3(0, 0, 0), size, iterations); + const minY = rawPoints.reduce((lowest, point) => Math.min(lowest, point.y), Number.POSITIVE_INFINITY); + const lift = Number.isFinite(minY) ? Math.max(0.25 - minY, 0) : 0; + + return rawPoints.map((point) => new Vector3(point.x, point.y + lift, point.z)); +} + +function createPreviewLoop(points: Vector3[], color: number): THREE.LineLoop { + const positions: number[] = []; + for (const point of points) { + positions.push(point.x, point.y, point.z); + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + return new THREE.LineLoop( + geometry, + new THREE.LineBasicMaterial({ color }) + ); +} + +function buildCustomProfile(profileType: Exclude, width: number, depth: number): Vector3[] { + const normalizedPointsByType: Record, Array<[number, number]>> = { + "architectural-molding": [ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, -0.16], + [0.18, -0.16], + [0.28, 0.04], + [0.12, 0.26], + [-0.08, 0.4], + [-0.28, 0.5], + [-0.5, 0.5], + ], + trapezoid: [ + [-0.5, -0.5], + [0.5, -0.5], + [0.24, 0.5], + [-0.24, 0.5], + ], + diamond: [ + [0, -0.5], + [0.5, 0], + [0, 0.5], + [-0.5, 0], + ], + hexagon: [ + [-0.25, -0.5], + [0.25, -0.5], + [0.5, 0], + [0.25, 0.5], + [-0.25, 0.5], + [-0.5, 0], + ], + }; + + return scaleProfileShape(normalizedPointsByType[profileType], width, depth); +} + +function buildProfilePoints(profileType: ProfileType, width: number, depth: number): Vector3[] { + if (profileType === "arc-pipe") { + const radius = Math.min(width, depth) * 0.5; + const profilePrimitive = new Arc({ + center: new Vector3(0, 0, 0), + radius, + startAngle: 0, + endAngle: Math.PI * 2, + segments: 48, + color: 0xc2410c, + }); + + return readLineGeometryPoints(profilePrimitive); + } + + if (profileType === "rectangle") { + const profilePrimitive = new Rectangle({ + center: new Vector3(0, 0, 0), + width, + breadth: depth, + color: 0xc2410c, + }); + + const profileBrep = profilePrimitive.getBrep() as KernelBrep; + return toVector3List(profileBrep.vertices); + } + + return buildCustomProfile(profileType, width, depth); +} + +export default defineExample({ + slug: "operations/sweep-hilbert-profiles", + category: "operations", + title: "Sweep Hilbert Profiles", + description: "Locked Hilbert3D path with switchable kernel and custom section profiles.", + statusLabel: "ready", + chips: ["Hilbert Path", "Profiles", "Sweep"], + footerText: "Profile Type, Caps, Outlines", + build: ({ scene }) => { + let current: THREE.Group | null = null; + let cachedProfileKey = ""; + let cachedProfilePoints: Vector3[] = []; + const lockedPathPoints = buildHilbertPath( + LOCKED_HILBERT_SIZE, + LOCKED_HILBERT_ITERATIONS + ); + + function getProfilePoints(profileType: ProfileType, width: number, depth: number): Vector3[] { + const nextKey = `${profileType}:${width}:${depth}`; + if (nextKey !== cachedProfileKey) { + cachedProfileKey = nextKey; + cachedProfilePoints = buildProfilePoints(profileType, width, depth); + } + + return cachedProfilePoints.map((point) => point.clone()); + } + + mountControls( + "Hilbert Sweep Parameters", + [ + { + type: "select", + key: "profileType", + label: "Profile Type", + value: "arc-pipe", + options: [ + { label: "Arc Pipe", value: "arc-pipe" }, + { label: "Rectangle", value: "rectangle" }, + { label: "Architectural Molding", value: "architectural-molding" }, + { label: "Trapezoid", value: "trapezoid" }, + { label: "Diamond", value: "diamond" }, + { label: "Hexagon", value: "hexagon" }, + ], + }, + { type: "number", key: "profileWidth", label: "Profile Width", min: 0.15, max: 1.5, step: 0.05, value: 0.6 }, + { type: "number", key: "profileDepth", label: "Profile Depth", min: 0.15, max: 1.5, step: 0.05, value: 0.45 }, + { type: "boolean", key: "capStart", label: "Cap Start", value: true }, + { type: "boolean", key: "capEnd", label: "Cap End", value: true }, + { type: "boolean", key: "outline", label: "Outline", value: false }, + { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, + { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, + ], + (state) => { + const profileType = state.profileType as ProfileType; + const profileWidth = state.profileWidth as number; + const profileDepth = state.profileDepth as number; + const pathPoints = lockedPathPoints.map((point) => point.clone()); + const profilePoints = getProfilePoints(profileType, profileWidth, profileDepth); + + const pathPrimitive = new Polyline({ + points: pathPoints.map((point) => point.clone()), + color: 0x4b5563, + }); + + const previewObject = createPreviewLoop(profilePoints, 0xc2410c); + + const sweep = new Sweep({ + path: pathPoints.map((point) => point.clone()), + profile: profilePoints.map((point) => point.clone()), + color: 0x0f766e, + capStart: state.capStart as boolean, + capEnd: state.capEnd as boolean, + fatOutlines: state.fatOutlines as boolean, + outlineWidth: state.outlineWidth as number, + }); + sweep.outline = state.outline as boolean; + + const previewOffset = LOCKED_HILBERT_SIZE * 0.95; + pathPrimitive.position.y += 0.01; + previewObject.position.set(-previewOffset, 0.02, -previewOffset); + + const group = new THREE.Group(); + group.add(pathPrimitive); + group.add(previewObject); + group.add(sweep); + + current = replaceSceneObject(scene, current, group); + } + ); + }, +}); diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts index 4039642..1826644 100644 --- a/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts +++ b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts @@ -38,8 +38,8 @@ export default defineExample({ title: "Sweep Path + Profile", description: "Operation-level sweep from path primitive + profile primitive.", statusLabel: "ready", - chips: ["Control: Path", "Control: Caps"], - footerText: "Control: Path + Caps", + chips: ["Path", "Caps"], + footerText: "Path, Caps", build: ({ scene }) => { let current: THREE.Group | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts b/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts index ab18c48..6b79600 100644 --- a/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts +++ b/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts @@ -28,8 +28,8 @@ export default defineExample({ title: "Wall from Offsets", description: "Composite wall profile assembled from offset centerlines.", statusLabel: "ready", - chips: ["Control: Thickness"], - footerText: "Control: Thickness", + chips: ["Thickness"], + footerText: "Thickness", build: ({ scene }) => { let current: THREE.Group | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts index 4ab52a1..bce6f72 100644 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Arc", description: "Circular arc with angle span and segmentation control.", statusLabel: "ready", - chips: ["Control: Radius", "Control: Span"], - footerText: "Control: Radius, Span", + chips: ["Radius", "Span"], + footerText: "Radius, Span", build: ({ scene }) => { let current: Arc | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts index e4ada54..f61fbf1 100644 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts @@ -17,8 +17,8 @@ export default defineExample({ title: "Curve", description: "Control-point curve for route and profile sketching.", statusLabel: "ready", - chips: ["Control: Sag", "Control: Lift"], - footerText: "Control: Sag, Lift", + chips: ["Sag", "Lift"], + footerText: "Sag, Lift", build: ({ scene }) => { let current: Curve | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts index 460512f..fa17ed1 100644 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Line", description: "Two-point line primitive with direct endpoint control.", statusLabel: "ready", - chips: ["Control: Length", "Control: Angle"], - footerText: "Control: Length, Angle", + chips: ["Length", "Angle"], + footerText: "Length, Angle", build: ({ scene }) => { let current: Line | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts index c2d1cc5..ad19d15 100644 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts @@ -23,8 +23,8 @@ export default defineExample({ title: "Polyline", description: "Open and closed path definitions for profile work.", statusLabel: "ready", - chips: ["Control: Closure", "Control: Span"], - footerText: "Control: Closure, Span", + chips: ["Closure", "Span"], + footerText: "Closure, Span", build: ({ scene }) => { let current: Polyline | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts index ca2e9e2..76f9ecf 100644 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts +++ b/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Rectangle", description: "Parametric rectangular primitive for base profiles.", statusLabel: "ready", - chips: ["Control: Width", "Control: Breadth"], - footerText: "Control: Width, Breadth", + chips: ["Width", "Breadth"], + footerText: "Width, Breadth", build: ({ scene }) => { let current: Rectangle | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts index e0bd8ac..b89bce6 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Cuboid", description: "Rectangular solid for rooms, equipment blocks and massing.", statusLabel: "ready", - chips: ["Control: W/H/D"], - footerText: "Control: W/H/D", + chips: ["Width", "Height", "Depth"], + footerText: "Width, Height, Depth", build: ({ scene }) => { let current: Cuboid | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts index 2e53d99..d627e15 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Cylinder", description: "Cylindrical volume for ducts, pipes and mechanical shafts.", statusLabel: "ready", - chips: ["Control: R", "Control: H", "Control: Seg"], - footerText: "Control: R, H, Seg", + chips: ["Radius", "Height", "Segments"], + footerText: "Radius, Height, Segments", build: ({ scene }) => { let current: Cylinder | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts index 72e3de3..4c3e553 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Opening", description: "Opening helper volume for void and penetration previews.", statusLabel: "ready", - chips: ["Control: W/H/D"], - footerText: "Control: W/H/D", + chips: ["Width", "Height", "Depth"], + footerText: "Width, Height, Depth", build: ({ scene }) => { let current: Opening | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts index 861a4a3..30007a6 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts @@ -206,8 +206,8 @@ export default defineExample({ title: "Polygon Suite", description: "Dataset-backed polygon validation with concave, performance, and multi-hole cases.", statusLabel: "ready", - chips: ["Coverage: Holes", "Coverage: Concavity"], - footerText: "Coverage: Holes, Concavity", + chips: ["Holes", "Concavity"], + footerText: "Holes, Concavity", build: ({ scene, camera, renderer, controls }) => { camera.position.set(0, 20, 20); controls.target.set(0, 0, 0); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts index 209f843..d598297 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts @@ -21,8 +21,8 @@ export default defineExample({ title: "Polygon", description: "Planar polygon triangulation for surfaces and slabs.", statusLabel: "ready", - chips: ["Control: Sides", "Control: Radius"], - footerText: "Control: Sides, Radius", + chips: ["Sides", "Radius"], + footerText: "Sides, Radius", build: ({ scene }) => { let current: Polygon | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts index 92fd223..9847f00 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Sphere", description: "UV sphere for equipment envelopes and clearance studies.", statusLabel: "ready", - chips: ["Control: R", "Control: Segments"], - footerText: "Control: R, Segments", + chips: ["Radius", "Segments"], + footerText: "Radius, Segments", build: ({ scene }) => { let current: Sphere | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts index 3e137ca..e15e812 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts @@ -27,8 +27,8 @@ export default defineExample({ title: "Sweep", description: "Profile along path sweep for framing and custom sections.", statusLabel: "ready", - chips: ["Control: Path", "Control: Caps"], - footerText: "Control: Path, Caps", + chips: ["Path", "Caps"], + footerText: "Path, Caps", build: ({ scene }) => { let current: Sweep | null = null; diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts index c5099ba..ff86bee 100644 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts +++ b/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts @@ -8,8 +8,8 @@ export default defineExample({ title: "Wedge", description: "Tapered solid for ramps and sloped technical elements.", statusLabel: "ready", - chips: ["Control: W/H/D"], - footerText: "Control: W/H/D", + chips: ["Width", "Height", "Depth"], + footerText: "Width, Height, Depth", build: ({ scene }) => { let current: Wedge | null = null; diff --git a/main/opengeometry-three/examples-vite/src/shared/examples.ts b/main/opengeometry-three/examples-vite/src/shared/examples.ts index 5844955..3a4389d 100644 --- a/main/opengeometry-three/examples-vite/src/shared/examples.ts +++ b/main/opengeometry-three/examples-vite/src/shared/examples.ts @@ -1,4 +1,4 @@ -import type { ExampleCategory, ExampleDefinition } from "./example-contract"; +import type { ExampleCategory } from "./example-contract"; export const categoryLabels: Record = { primitives: "Primitives", @@ -6,29 +6,236 @@ export const categoryLabels: Record = { operations: "Operations", }; -const modules = import.meta.glob("../examples/**/*.ts", { eager: true }); - -const discoveredExamples = Object.values(modules) - .map((entry) => (entry as { default?: ExampleDefinition }).default) - .filter((entry): entry is ExampleDefinition => Boolean(entry)); +export interface ExampleMetadata { + slug: string; + category: ExampleCategory; + title: string; + description: string; + statusLabel: string; + chips: string[]; + footerText: string; +} -export const examples = discoveredExamples.sort((left, right) => { - if (left.category === right.category) { - return left.title.localeCompare(right.title); +export const examples: ExampleMetadata[] = [ + { + "slug": "primitives/arc", + "category": "primitives", + "title": "Arc", + "description": "Circular arc with angle span and segmentation control.", + "statusLabel": "ready", + "chips": [ + "Radius", + "Span" + ], + "footerText": "Radius, Span" + }, + { + "slug": "primitives/curve", + "category": "primitives", + "title": "Curve", + "description": "Control-point curve for route and profile sketching.", + "statusLabel": "ready", + "chips": [ + "Sag", + "Lift" + ], + "footerText": "Sag, Lift" + }, + { + "slug": "primitives/line", + "category": "primitives", + "title": "Line", + "description": "Two-point line primitive with direct endpoint control.", + "statusLabel": "ready", + "chips": [ + "Length", + "Angle" + ], + "footerText": "Length, Angle" + }, + { + "slug": "primitives/polyline", + "category": "primitives", + "title": "Polyline", + "description": "Open and closed path definitions for profile work.", + "statusLabel": "ready", + "chips": [ + "Closure", + "Span" + ], + "footerText": "Closure, Span" + }, + { + "slug": "primitives/rectangle", + "category": "primitives", + "title": "Rectangle", + "description": "Parametric rectangular primitive for base profiles.", + "statusLabel": "ready", + "chips": [ + "Width", + "Breadth" + ], + "footerText": "Width, Breadth" + }, + { + "slug": "shapes/cuboid", + "category": "shapes", + "title": "Cuboid", + "description": "Rectangular solid for rooms, equipment blocks and massing.", + "statusLabel": "ready", + "chips": [ + "Width", + "Height", + "Depth" + ], + "footerText": "Width, Height, Depth" + }, + { + "slug": "shapes/cylinder", + "category": "shapes", + "title": "Cylinder", + "description": "Cylindrical volume for ducts, pipes and mechanical shafts.", + "statusLabel": "ready", + "chips": [ + "Radius", + "Height", + "Segments" + ], + "footerText": "Radius, Height, Segments" + }, + { + "slug": "shapes/opening", + "category": "shapes", + "title": "Opening", + "description": "Opening helper volume for void and penetration previews.", + "statusLabel": "ready", + "chips": [ + "Width", + "Height", + "Depth" + ], + "footerText": "Width, Height, Depth" + }, + { + "slug": "shapes/polygon", + "category": "shapes", + "title": "Polygon", + "description": "Planar polygon triangulation for surfaces and slabs.", + "statusLabel": "ready", + "chips": [ + "Sides", + "Radius" + ], + "footerText": "Sides, Radius" + }, + { + "slug": "shapes/polygon-suite", + "category": "shapes", + "title": "Polygon Suite", + "description": "Dataset-backed polygon validation with concave, performance, and multi-hole cases.", + "statusLabel": "ready", + "chips": [ + "Holes", + "Concavity" + ], + "footerText": "Holes, Concavity" + }, + { + "slug": "shapes/sphere", + "category": "shapes", + "title": "Sphere", + "description": "UV sphere for equipment envelopes and clearance studies.", + "statusLabel": "ready", + "chips": [ + "Radius", + "Segments" + ], + "footerText": "Radius, Segments" + }, + { + "slug": "shapes/sweep", + "category": "shapes", + "title": "Sweep", + "description": "Profile along path sweep for framing and custom sections.", + "statusLabel": "ready", + "chips": [ + "Path", + "Caps" + ], + "footerText": "Path, Caps" + }, + { + "slug": "shapes/wedge", + "category": "shapes", + "title": "Wedge", + "description": "Tapered solid for ramps and sloped technical elements.", + "statusLabel": "ready", + "chips": [ + "Width", + "Height", + "Depth" + ], + "footerText": "Width, Height, Depth" + }, + { + "slug": "operations/offset", + "category": "operations", + "title": "Offset", + "description": "Offset generation with acute-corner and bevel parameters.", + "statusLabel": "ready", + "chips": [ + "Offset", + "Bevel" + ], + "footerText": "Offset, Bevel" + }, + { + "slug": "operations/sweep-path-profile", + "category": "operations", + "title": "Sweep Path + Profile", + "description": "Operation-level sweep from path primitive + profile primitive.", + "statusLabel": "ready", + "chips": [ + "Path", + "Caps" + ], + "footerText": "Path, Caps" + }, + { + "slug": "operations/sweep-hilbert-profiles", + "category": "operations", + "title": "Sweep Hilbert Profiles", + "description": "Locked Hilbert3D path with switchable kernel and custom section profiles.", + "statusLabel": "ready", + "chips": [ + "Hilbert Path", + "Profiles", + "Sweep" + ], + "footerText": "Profile Type, Caps, Outlines" + }, + { + "slug": "operations/wall-from-offsets", + "category": "operations", + "title": "Wall from Offsets", + "description": "Composite wall profile assembled from offset centerlines.", + "statusLabel": "ready", + "chips": [ + "Thickness" + ], + "footerText": "Thickness" } +]; - return left.category.localeCompare(right.category); -}); - -export function getExampleBySlug(slug: string): ExampleDefinition { +export function getExampleBySlug(slug: string): ExampleMetadata { const match = examples.find((example) => example.slug === slug); if (!match) { - throw new Error(`Unknown example slug: ${slug}`); + throw new Error("Unknown example slug: " + slug); } return match; } -export function getExamplesByCategory(category: ExampleCategory): ExampleDefinition[] { +export function getExamplesByCategory(category: ExampleCategory): ExampleMetadata[] { return examples.filter((example) => example.category === category); } diff --git a/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts b/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts index 20eac0e..b64b295 100644 --- a/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts +++ b/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts @@ -36,6 +36,7 @@ const iconBySlug: Record = { offset: faUpRightAndDownLeftFromCenter, "wall-from-offsets": faWarehouse, "sweep-path-profile": faRoute, + "sweep-hilbert-profiles": faRoute, }; export function getExampleIconMarkup(slug: string): string { diff --git a/main/opengeometry-three/examples-vite/src/shared/runtime.ts b/main/opengeometry-three/examples-vite/src/shared/runtime.ts index 84087d8..4958683 100644 --- a/main/opengeometry-three/examples-vite/src/shared/runtime.ts +++ b/main/opengeometry-three/examples-vite/src/shared/runtime.ts @@ -27,18 +27,30 @@ export type ExampleControlDefinition = key: string; label: string; value: boolean; + } + | { + type: "select"; + key: string; + label: string; + value: string; + options: Array<{ + label: string; + value: string; + }>; }; -export type ExampleControlState = Record; +export type ExampleControlState = Record; interface BootstrapConfig { example: ExampleDefinition; - build: (ctx: ExampleContext) => void | Promise; } function getWasmUrl(): string { if (import.meta.env.PROD) { - return new URL("./wasm/opengeometry_bg.wasm", import.meta.url).toString(); + return new URL( + /* @vite-ignore */ "./wasm/opengeometry_bg.wasm", + import.meta.url + ).toString(); } return new URL( @@ -114,7 +126,7 @@ export async function bootstrapExample(config: BootstrapConfig) { scene.add(fill); await OpenGeometry.create({ wasmURL: getWasmUrl() }); - await config.build({ scene, camera, renderer, controls }); + await config.example.build({ scene, camera, renderer, controls }); function onResize() { camera.aspect = window.innerWidth / window.innerHeight; @@ -255,30 +267,52 @@ export function mountControls( inputs.appendChild(numberInput); row.appendChild(inputs); } else { - const boolWrap = document.createElement("div"); - boolWrap.className = "og-control-bool"; - - const toggle = document.createElement("input"); - toggle.type = "checkbox"; - toggle.className = "og-toggle"; - toggle.checked = definition.value; - toggle.setAttribute("aria-label", definition.label); - - const boolLabel = document.createElement("span"); - boolLabel.className = "og-control-bool-state"; - boolLabel.textContent = definition.value ? "Enabled" : "Disabled"; - - const updateToggle = () => { - state[definition.key] = toggle.checked; - boolLabel.textContent = toggle.checked ? "Enabled" : "Disabled"; - emitChange(); - }; - - toggle.addEventListener("change", updateToggle); - boolWrap.appendChild(toggle); - boolWrap.appendChild(boolLabel); row.appendChild(header); - row.appendChild(boolWrap); + + if (definition.type === "boolean") { + const boolWrap = document.createElement("div"); + boolWrap.className = "og-control-bool"; + + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.className = "og-toggle"; + toggle.checked = definition.value; + toggle.setAttribute("aria-label", definition.label); + + const boolLabel = document.createElement("span"); + boolLabel.className = "og-control-bool-state"; + boolLabel.textContent = definition.value ? "Enabled" : "Disabled"; + + const updateToggle = () => { + state[definition.key] = toggle.checked; + boolLabel.textContent = toggle.checked ? "Enabled" : "Disabled"; + emitChange(); + }; + + toggle.addEventListener("change", updateToggle); + boolWrap.appendChild(toggle); + boolWrap.appendChild(boolLabel); + row.appendChild(boolWrap); + } else { + const select = document.createElement("select"); + select.className = "og-control-select"; + select.setAttribute("aria-label", definition.label); + + for (const option of definition.options) { + const optionElement = document.createElement("option"); + optionElement.value = option.value; + optionElement.textContent = option.label; + optionElement.selected = option.value === definition.value; + select.appendChild(optionElement); + } + + select.addEventListener("change", () => { + state[definition.key] = select.value; + emitChange(); + }); + + row.appendChild(select); + } } panel.appendChild(row); diff --git a/main/opengeometry-three/examples-vite/src/styles/theme.css b/main/opengeometry-three/examples-vite/src/styles/theme.css index 8b53954..e459f52 100644 --- a/main/opengeometry-three/examples-vite/src/styles/theme.css +++ b/main/opengeometry-three/examples-vite/src/styles/theme.css @@ -202,20 +202,24 @@ code { .og-badge-chip-row { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 6px; } .og-chip { display: inline-flex; align-items: center; - min-height: 32px; - padding: 7px 12px; - border-radius: 999px; + padding: 2px 9px; + corner-shape: squicle; + border-radius: 12px; border: 1px solid rgba(214, 132, 83, 0.14); background: rgba(246, 234, 226, 0.92); +} + +.og-chip span { + font-size: 11px; color: #9f6b4e; - font-size: 12px; - font-weight: 500; + font-weight: 550; + letter-spacing: 0.02em; } .og-chip-accent { @@ -388,6 +392,16 @@ code { border-radius: 12px; } +.og-control-select { + width: 100%; + border: 1px solid var(--og-line); + background: rgba(255, 255, 255, 0.9); + color: var(--og-text); + padding: 10px 12px; + font: inherit; + border-radius: 12px; +} + .og-control-bool { display: flex; align-items: center; diff --git a/main/opengeometry-three/examples/offset.html b/main/opengeometry-three/examples/offset.html deleted file mode 100644 index 9d6a8ad..0000000 --- a/main/opengeometry-three/examples/offset.html +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - OpenGeometry Offset Example - - - -
-
-
OpenGeometry Offset Example
-
- Shows offset on Line, Polyline, Curve, and - Rectangle with bevel enabled for acute corners. -
-
- - - - - - diff --git a/main/opengeometry-three/examples/sweep.html b/main/opengeometry-three/examples/sweep.html deleted file mode 100644 index 4c298c0..0000000 --- a/main/opengeometry-three/examples/sweep.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - OpenGeometry Three.js Sweep Example - - - -
-
-
OpenGeometry Sweep Example
-
Path from Polyline, profile from Rectangle, output via Sweep.
-
- - - - - - diff --git a/main/opengeometry-three/examples/wall-from-offsets.html b/main/opengeometry-three/examples/wall-from-offsets.html deleted file mode 100644 index 47e2c24..0000000 --- a/main/opengeometry-three/examples/wall-from-offsets.html +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - OpenGeometry Wall From Offsets Example - - - -
-
-
Wall Polygon From Two Polyline Offsets
-
- Builds left/right offsets from a center polyline, then stitches both offsets into a - wall polygon footprint. -
-
- - - - - - diff --git a/main/opengeometry-three/examples/wedge.html b/main/opengeometry-three/examples/wedge.html deleted file mode 100644 index 9c8e794..0000000 --- a/main/opengeometry-three/examples/wedge.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - OpenGeometry Three - Wedge Example - - - -
-
OpenGeometry Wedge in Three.js
- - - - - - diff --git a/main/opengeometry-three/vite.examples.config.mjs b/main/opengeometry-three/vite.examples.config.mjs index 0a1e737..16d553c 100644 --- a/main/opengeometry-three/vite.examples.config.mjs +++ b/main/opengeometry-three/vite.examples.config.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { readdirSync, statSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,10 +8,13 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const examplesRoot = resolve(__dirname, "examples-vite"); -const examplesSourceRoot = resolve(examplesRoot, "src/examples"); function collectFiles(dir, extension, files = []) { for (const entry of readdirSync(dir)) { + if (entry.startsWith(".")) { + continue; + } + const fullPath = join(dir, entry); const stats = statSync(fullPath); if (stats.isDirectory()) { @@ -31,42 +34,19 @@ function toPosixPath(path) { return path.replace(/\\/g, "/"); } -function createExampleHtml(slug) { - return ` - - - - - OpenGeometry Example - - - -
- - - -`; -} - -function syncGeneratedExampleEntries() { - const moduleFiles = collectFiles(examplesSourceRoot, ".ts"); - - const inputs = { - index: resolve(examplesRoot, "index.html"), - }; +function collectHtmlInputs() { + const htmlFiles = collectFiles(examplesRoot, ".html"); + const inputs = {}; - for (const modulePath of moduleFiles) { - const slug = toPosixPath(relative(examplesSourceRoot, modulePath)).replace(/\.ts$/, ""); - const htmlPath = resolve(examplesRoot, `${slug}.html`); - mkdirSync(dirname(htmlPath), { recursive: true }); - writeFileSync(htmlPath, createExampleHtml(slug)); - inputs[slug] = htmlPath; + for (const htmlPath of htmlFiles) { + const key = toPosixPath(relative(examplesRoot, htmlPath)).replace(/\.html$/, ""); + inputs[key] = htmlPath; } return inputs; } -const input = syncGeneratedExampleEntries(); +const input = collectHtmlInputs(); export default defineConfig({ root: examplesRoot, diff --git a/main/opengeometry/src/primitives/arc.rs b/main/opengeometry/src/primitives/arc.rs index 11073a7..2a1698f 100644 --- a/main/opengeometry/src/primitives/arc.rs +++ b/main/opengeometry/src/primitives/arc.rs @@ -99,12 +99,6 @@ impl OGArc { builder .add_wire(&indices, is_closed && points.len() > 2) .map_err(|err| JsValue::from_str(&format!("Failed to build arc wire: {}", err)))?; - - if is_closed && points.len() > 2 { - builder.add_face(&indices, &[]).map_err(|err| { - JsValue::from_str(&format!("Failed to build arc face: {}", err)) - })?; - } } self.brep = builder diff --git a/main/opengeometry/src/primitives/polyline.rs b/main/opengeometry/src/primitives/polyline.rs index 35813b1..f477e91 100644 --- a/main/opengeometry/src/primitives/polyline.rs +++ b/main/opengeometry/src/primitives/polyline.rs @@ -105,12 +105,6 @@ impl OGPolyline { builder.add_wire(&indices, closed_wire).map_err(|err| { JsValue::from_str(&format!("Failed to build polyline wire: {}", err)) })?; - - if closed_wire { - builder.add_face(&indices, &[]).map_err(|err| { - JsValue::from_str(&format!("Failed to build polyline face: {}", err)) - })?; - } } self.brep = builder.build().map_err(|err| { From b1cca5cb829e2b41ae313325ba9c31f03601d6f4 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Mar 2026 01:10:21 +0100 Subject: [PATCH 06/10] update examples --- .../examples-vite/index.html | 520 +++++++++++++++++- .../examples-vite/src/catalog.ts | 71 --- .../src/examples/operations/offset.ts | 59 -- .../operations/sweep-hilbert-profiles.ts | 253 --------- .../examples/operations/sweep-path-profile.ts | 97 ---- .../examples/operations/wall-from-offsets.ts | 84 --- .../src/examples/primitives/arc.ts | 43 -- .../src/examples/primitives/curve.ts | 46 -- .../src/examples/primitives/line.ts | 43 -- .../src/examples/primitives/polyline.ts | 48 -- .../src/examples/primitives/rectangle.ts | 36 -- .../src/examples/shapes/cuboid.ts | 42 -- .../src/examples/shapes/cylinder.ts | 44 -- .../src/examples/shapes/opening.ts | 42 -- .../src/examples/shapes/polygon-suite.ts | 362 ------------ .../src/examples/shapes/polygon.ts | 51 -- .../src/examples/shapes/sphere.ts | 43 -- .../src/examples/shapes/sweep.ts | 64 --- .../src/examples/shapes/wedge.ts | 42 -- .../src/shared/example-contract.ts | 18 - .../examples-vite/src/shared/examples.ts | 241 -------- .../examples-vite/src/shared/icon-registry.ts | 46 -- .../examples-vite/src/shared/runtime.ts | 327 ----------- 23 files changed, 517 insertions(+), 2105 deletions(-) delete mode 100644 main/opengeometry-three/examples-vite/src/catalog.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/operations/offset.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/primitives/line.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts delete mode 100644 main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts delete mode 100644 main/opengeometry-three/examples-vite/src/shared/example-contract.ts delete mode 100644 main/opengeometry-three/examples-vite/src/shared/examples.ts delete mode 100644 main/opengeometry-three/examples-vite/src/shared/icon-registry.ts delete mode 100644 main/opengeometry-three/examples-vite/src/shared/runtime.ts diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index 00cc91e..97cd657 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -1,12 +1,526 @@ - + - + OpenGeometry Three Examples +
- + diff --git a/main/opengeometry-three/examples-vite/src/catalog.ts b/main/opengeometry-three/examples-vite/src/catalog.ts deleted file mode 100644 index fa507a0..0000000 --- a/main/opengeometry-three/examples-vite/src/catalog.ts +++ /dev/null @@ -1,71 +0,0 @@ -import "./styles/theme.css"; -import { categoryLabels, getExamplesByCategory } from "./shared/examples"; -import { getExampleIconMarkup } from "./shared/icon-registry"; -import type { ExampleCategory } from "./shared/example-contract"; - -const app = document.getElementById("app"); - -if (!app) { - throw new Error("Missing #app container"); -} - -const shell = document.createElement("main"); -shell.className = "og-specs-shell"; - -const head = document.createElement("header"); -head.className = "og-specs-head"; -head.innerHTML = ` -
-

OpenGeometry Technical Sandbox

-

Examples

-
-

Build command: npm --prefix main/opengeometry-three run build-example-three

-`; -shell.appendChild(head); - -for (const category of ["primitives", "shapes", "operations"] as ExampleCategory[]) { - const items = getExamplesByCategory(category); - const section = document.createElement("section"); - section.className = "og-specs-section"; - - const heading = document.createElement("div"); - heading.className = "og-specs-section-head"; - heading.innerHTML = ` -

${categoryLabels[category]}

-

${items.length} items

- `; - section.appendChild(heading); - - const grid = document.createElement("div"); - grid.className = "og-specs-grid"; - - for (const example of items) { - const card = document.createElement("article"); - card.className = "og-spec-card"; - const chips = example.chips - .map((chip) => `
${chip}
`) - .join(""); - - card.innerHTML = ` -
-
${getExampleIconMarkup(example.slug)}
-

${example.statusLabel}

-
-
-

${example.title}

-

${example.description}

-
-
${chips}
- - `; - - grid.appendChild(card); - } - - section.appendChild(grid); - shell.appendChild(section); -} - -app.replaceChildren(shell); diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts b/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts deleted file mode 100644 index e781d2b..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/operations/offset.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Polyline, Vector3 } from "@og-three"; -import * as THREE from "three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -function createBasePolyline(turn: number): Vector3[] { - return [ - new Vector3(-2.4, 0, -1.8), - new Vector3(-0.7, 0, -0.6), - new Vector3(-0.2 + turn, 0, 0.7), - new Vector3(1.9, 0, 1.6), - ]; -} - -export default defineExample({ - slug: "operations/offset", - category: "operations", - title: "Offset", - description: "Offset generation with acute-corner and bevel parameters.", - statusLabel: "ready", - chips: ["Offset", "Bevel"], - footerText: "Offset, Bevel", - build: ({ scene }) => { - let current: THREE.Group | null = null; - - mountControls( - "Offset Parameters", - [ - { type: "number", key: "offset", label: "Offset", min: -1.2, max: 1.2, step: 0.05, value: 0.45 }, - { type: "number", key: "acute", label: "Acute Threshold", min: 1, max: 179, step: 1, value: 90 }, - { type: "number", key: "turn", label: "Path Turn", min: -0.8, max: 0.8, step: 0.05, value: 0.0 }, - { type: "boolean", key: "bevel", label: "Bevel", value: true }, - ], - (state) => { - const base = new Polyline({ - points: createBasePolyline(state.turn as number), - color: 0x1f2937, - }); - - const offsetData = base.getOffset( - state.offset as number, - state.acute as number, - state.bevel as boolean - ); - - const offset = new Polyline({ - points: offsetData.points, - color: 0xdc2626, - }); - - const group = new THREE.Group(); - group.add(base); - group.add(offset); - - current = replaceSceneObject(scene, current, group); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts deleted file mode 100644 index 4f3874e..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/operations/sweep-hilbert-profiles.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Arc, Polyline, Rectangle, Sweep, Vector3 } from "@og-three"; -import * as THREE from "three"; -import { hilbert3D } from "three/examples/jsm/utils/GeometryUtils.js"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -type KernelVertex = { - position: { - x: number; - y: number; - z: number; - }; -}; - -type KernelBrep = { - vertices: KernelVertex[]; -}; - -type ProfileType = - | "arc-pipe" - | "rectangle" - | "architectural-molding" - | "trapezoid" - | "diamond" - | "hexagon"; - -const LOCKED_HILBERT_SIZE = 8; -const LOCKED_HILBERT_ITERATIONS = 1; - -function toVector3List(vertices: KernelVertex[]): Vector3[] { - return vertices.map((vertex) => { - const { x, y, z } = vertex.position; - return new Vector3(x, y, z); - }); -} - -function readLineGeometryPoints(object: THREE.Object3D): Vector3[] { - const geometryOwner = object as THREE.Line; - const positions = geometryOwner.geometry?.getAttribute("position"); - if (!positions || positions.itemSize !== 3) { - throw new Error("Profile preview geometry is missing line positions."); - } - - const points: Vector3[] = []; - for (let index = 0; index < positions.count; index += 1) { - points.push( - new Vector3( - positions.getX(index), - positions.getY(index), - positions.getZ(index) - ) - ); - } - - if (points.length >= 3) { - const first = points[0]; - const last = points[points.length - 1]; - const dx = first.x - last.x; - const dy = first.y - last.y; - const dz = first.z - last.z; - if (dx * dx + dy * dy + dz * dz <= 1.0e-12) { - points.pop(); - } - } - - return points; -} - -function scaleProfileShape(points: Array<[number, number]>, width: number, depth: number): Vector3[] { - return points.map(([x, z]) => new Vector3(x * width, 0, z * depth)); -} - -function buildHilbertPath(size: number, iterations: number): Vector3[] { - const rawPoints = hilbert3D(new THREE.Vector3(0, 0, 0), size, iterations); - const minY = rawPoints.reduce((lowest, point) => Math.min(lowest, point.y), Number.POSITIVE_INFINITY); - const lift = Number.isFinite(minY) ? Math.max(0.25 - minY, 0) : 0; - - return rawPoints.map((point) => new Vector3(point.x, point.y + lift, point.z)); -} - -function createPreviewLoop(points: Vector3[], color: number): THREE.LineLoop { - const positions: number[] = []; - for (const point of points) { - positions.push(point.x, point.y, point.z); - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); - return new THREE.LineLoop( - geometry, - new THREE.LineBasicMaterial({ color }) - ); -} - -function buildCustomProfile(profileType: Exclude, width: number, depth: number): Vector3[] { - const normalizedPointsByType: Record, Array<[number, number]>> = { - "architectural-molding": [ - [-0.5, -0.5], - [0.5, -0.5], - [0.5, -0.16], - [0.18, -0.16], - [0.28, 0.04], - [0.12, 0.26], - [-0.08, 0.4], - [-0.28, 0.5], - [-0.5, 0.5], - ], - trapezoid: [ - [-0.5, -0.5], - [0.5, -0.5], - [0.24, 0.5], - [-0.24, 0.5], - ], - diamond: [ - [0, -0.5], - [0.5, 0], - [0, 0.5], - [-0.5, 0], - ], - hexagon: [ - [-0.25, -0.5], - [0.25, -0.5], - [0.5, 0], - [0.25, 0.5], - [-0.25, 0.5], - [-0.5, 0], - ], - }; - - return scaleProfileShape(normalizedPointsByType[profileType], width, depth); -} - -function buildProfilePoints(profileType: ProfileType, width: number, depth: number): Vector3[] { - if (profileType === "arc-pipe") { - const radius = Math.min(width, depth) * 0.5; - const profilePrimitive = new Arc({ - center: new Vector3(0, 0, 0), - radius, - startAngle: 0, - endAngle: Math.PI * 2, - segments: 48, - color: 0xc2410c, - }); - - return readLineGeometryPoints(profilePrimitive); - } - - if (profileType === "rectangle") { - const profilePrimitive = new Rectangle({ - center: new Vector3(0, 0, 0), - width, - breadth: depth, - color: 0xc2410c, - }); - - const profileBrep = profilePrimitive.getBrep() as KernelBrep; - return toVector3List(profileBrep.vertices); - } - - return buildCustomProfile(profileType, width, depth); -} - -export default defineExample({ - slug: "operations/sweep-hilbert-profiles", - category: "operations", - title: "Sweep Hilbert Profiles", - description: "Locked Hilbert3D path with switchable kernel and custom section profiles.", - statusLabel: "ready", - chips: ["Hilbert Path", "Profiles", "Sweep"], - footerText: "Profile Type, Caps, Outlines", - build: ({ scene }) => { - let current: THREE.Group | null = null; - let cachedProfileKey = ""; - let cachedProfilePoints: Vector3[] = []; - const lockedPathPoints = buildHilbertPath( - LOCKED_HILBERT_SIZE, - LOCKED_HILBERT_ITERATIONS - ); - - function getProfilePoints(profileType: ProfileType, width: number, depth: number): Vector3[] { - const nextKey = `${profileType}:${width}:${depth}`; - if (nextKey !== cachedProfileKey) { - cachedProfileKey = nextKey; - cachedProfilePoints = buildProfilePoints(profileType, width, depth); - } - - return cachedProfilePoints.map((point) => point.clone()); - } - - mountControls( - "Hilbert Sweep Parameters", - [ - { - type: "select", - key: "profileType", - label: "Profile Type", - value: "arc-pipe", - options: [ - { label: "Arc Pipe", value: "arc-pipe" }, - { label: "Rectangle", value: "rectangle" }, - { label: "Architectural Molding", value: "architectural-molding" }, - { label: "Trapezoid", value: "trapezoid" }, - { label: "Diamond", value: "diamond" }, - { label: "Hexagon", value: "hexagon" }, - ], - }, - { type: "number", key: "profileWidth", label: "Profile Width", min: 0.15, max: 1.5, step: 0.05, value: 0.6 }, - { type: "number", key: "profileDepth", label: "Profile Depth", min: 0.15, max: 1.5, step: 0.05, value: 0.45 }, - { type: "boolean", key: "capStart", label: "Cap Start", value: true }, - { type: "boolean", key: "capEnd", label: "Cap End", value: true }, - { type: "boolean", key: "outline", label: "Outline", value: false }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const profileType = state.profileType as ProfileType; - const profileWidth = state.profileWidth as number; - const profileDepth = state.profileDepth as number; - const pathPoints = lockedPathPoints.map((point) => point.clone()); - const profilePoints = getProfilePoints(profileType, profileWidth, profileDepth); - - const pathPrimitive = new Polyline({ - points: pathPoints.map((point) => point.clone()), - color: 0x4b5563, - }); - - const previewObject = createPreviewLoop(profilePoints, 0xc2410c); - - const sweep = new Sweep({ - path: pathPoints.map((point) => point.clone()), - profile: profilePoints.map((point) => point.clone()), - color: 0x0f766e, - capStart: state.capStart as boolean, - capEnd: state.capEnd as boolean, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - sweep.outline = state.outline as boolean; - - const previewOffset = LOCKED_HILBERT_SIZE * 0.95; - pathPrimitive.position.y += 0.01; - previewObject.position.set(-previewOffset, 0.02, -previewOffset); - - const group = new THREE.Group(); - group.add(pathPrimitive); - group.add(previewObject); - group.add(sweep); - - current = replaceSceneObject(scene, current, group); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts b/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts deleted file mode 100644 index 1826644..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/operations/sweep-path-profile.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Polyline, Rectangle, Sweep, Vector3 } from "@og-three"; -import * as THREE from "three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -type KernelVertex = { - position: { - x: number; - y: number; - z: number; - }; -}; - -type KernelBrep = { - vertices: KernelVertex[]; -}; - -function toVector3List(vertices: KernelVertex[]): Vector3[] { - return vertices.map((vertex) => { - const { x, y, z } = vertex.position; - return new Vector3(x, y, z); - }); -} - -function buildPath(height: number): Vector3[] { - return [ - new Vector3(-2.0, 0.0, -1.0), - new Vector3(-1.2, height * 0.2, 0.2), - new Vector3(0.0, height * 0.5, 0.9), - new Vector3(1.1, height * 0.78, 0.3), - new Vector3(2.0, height, -0.8), - ]; -} - -export default defineExample({ - slug: "operations/sweep-path-profile", - category: "operations", - title: "Sweep Path + Profile", - description: "Operation-level sweep from path primitive + profile primitive.", - statusLabel: "ready", - chips: ["Path", "Caps"], - footerText: "Path, Caps", - build: ({ scene }) => { - let current: THREE.Group | null = null; - - mountControls( - "Sweep Operation Parameters", - [ - { type: "number", key: "pathHeight", label: "Path Height", min: 0.4, max: 4, step: 0.05, value: 2.2 }, - { type: "number", key: "profileWidth", label: "Profile Width", min: 0.1, max: 1.5, step: 0.05, value: 0.7 }, - { type: "number", key: "profileDepth", label: "Profile Depth", min: 0.1, max: 1.5, step: 0.05, value: 0.4 }, - { type: "boolean", key: "capStart", label: "Cap Start", value: true }, - { type: "boolean", key: "capEnd", label: "Cap End", value: false }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const pathPrimitive = new Polyline({ - points: buildPath(state.pathHeight as number), - color: 0x444444, - }); - - const profilePrimitive = new Rectangle({ - center: new Vector3(0, 0, 0), - width: state.profileWidth as number, - breadth: state.profileDepth as number, - color: 0x1f77b4, - }); - - const profileBrep = profilePrimitive.getBrep() as KernelBrep; - const profilePoints = toVector3List(profileBrep.vertices); - - const sweep = new Sweep({ - path: pathPrimitive.options.points.map((point) => point.clone()), - profile: profilePoints, - color: 0x2a9d8f, - capStart: state.capStart as boolean, - capEnd: state.capEnd as boolean, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - sweep.outline = state.outline as boolean; - - pathPrimitive.position.y += 0.01; - profilePrimitive.position.set(-3.0, 0.0, -2.2); - - const group = new THREE.Group(); - group.add(pathPrimitive); - group.add(profilePrimitive); - group.add(sweep); - - current = replaceSceneObject(scene, current, group); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts b/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts deleted file mode 100644 index 6b79600..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/operations/wall-from-offsets.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Polygon, Polyline, Vector3 } from "@og-three"; -import * as THREE from "three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -function buildCenterline(curveBias: number): Vector3[] { - return [ - new Vector3(-2.6, 0, -1.9), - new Vector3(-1.2, 0, -1.0), - new Vector3(-0.2, 0, 0.1), - new Vector3(0.7 + curveBias, 0, 0.2), - new Vector3(0.1, 0, 1.0), - new Vector3(2.6, 0, 2.0), - ]; -} - -function buildWallOutline(left: Vector3[], right: Vector3[]): Vector3[] { - if (left.length === 0 || right.length === 0) { - return []; - } - - return [...left.map((point) => point.clone()), ...right.map((point) => point.clone()).reverse()]; -} - -export default defineExample({ - slug: "operations/wall-from-offsets", - category: "operations", - title: "Wall from Offsets", - description: "Composite wall profile assembled from offset centerlines.", - statusLabel: "ready", - chips: ["Thickness"], - footerText: "Thickness", - build: ({ scene }) => { - let current: THREE.Group | null = null; - - mountControls( - "Wall Parameters", - [ - { type: "number", key: "thickness", label: "Wall Thickness", min: 0.1, max: 2, step: 0.05, value: 0.45 }, - { type: "number", key: "acute", label: "Acute Threshold", min: 1, max: 179, step: 1, value: 90 }, - { type: "number", key: "curveBias", label: "Curve Bias", min: -0.8, max: 0.8, step: 0.05, value: 0.0 }, - { type: "boolean", key: "bevel", label: "Bevel", value: true }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const centerline = new Polyline({ - points: buildCenterline(state.curveBias as number), - color: 0x1f2937, - }); - - const half = (state.thickness as number) * 0.5; - const leftOffset = centerline.getOffset(half, state.acute as number, state.bevel as boolean); - const rightOffset = centerline.getOffset(-half, state.acute as number, state.bevel as boolean); - - const leftPolyline = new Polyline({ points: leftOffset.points, color: 0x22c55e }); - const rightPolyline = new Polyline({ points: rightOffset.points, color: 0xf97316 }); - - const outline = buildWallOutline(leftOffset.points, rightOffset.points); - if (outline.length < 3) { - return; - } - - const polygon = new Polygon({ - vertices: outline, - color: 0x3b82f6, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - polygon.position.y = 0.01; - polygon.outline = state.outline as boolean; - - const group = new THREE.Group(); - group.add(centerline); - group.add(leftPolyline); - group.add(rightPolyline); - group.add(polygon); - - current = replaceSceneObject(scene, current, group); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts deleted file mode 100644 index bce6f72..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/arc.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Arc, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "primitives/arc", - category: "primitives", - title: "Arc", - description: "Circular arc with angle span and segmentation control.", - statusLabel: "ready", - chips: ["Radius", "Span"], - footerText: "Radius, Span", - build: ({ scene }) => { - let current: Arc | null = null; - - mountControls( - "Arc Parameters", - [ - { type: "number", key: "radius", label: "Radius", min: 0.2, max: 4, step: 0.05, value: 1.2 }, - { type: "number", key: "startDeg", label: "Start (deg)", min: 0, max: 360, step: 1, value: 0 }, - { type: "number", key: "endDeg", label: "End (deg)", min: 1, max: 360, step: 1, value: 300 }, - { type: "number", key: "segments", label: "Segments", min: 4, max: 128, step: 1, value: 48 }, - { type: "number", key: "centerX", label: "Center X", min: -3, max: 3, step: 0.1, value: 0 }, - { type: "number", key: "centerZ", label: "Center Z", min: -3, max: 3, step: 0.1, value: 0 }, - ], - (state) => { - const start = ((state.startDeg as number) * Math.PI) / 180; - const end = ((state.endDeg as number) * Math.PI) / 180; - - const arc = new Arc({ - center: new Vector3(state.centerX as number, 0, state.centerZ as number), - radius: state.radius as number, - startAngle: Math.min(start, end), - endAngle: Math.max(start, end), - segments: Math.floor(state.segments as number), - color: 0xdc2626, - }); - - current = replaceSceneObject(scene, current, arc); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts deleted file mode 100644 index f61fbf1..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/curve.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Curve, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -function buildControlPoints(span: number, sag: number, lift: number): Vector3[] { - return [ - new Vector3(-span, 0, -0.6), - new Vector3(-span * 0.4, lift, -sag), - new Vector3(span * 0.2, lift * 0.3, sag), - new Vector3(span, 0, 0.8), - ]; -} - -export default defineExample({ - slug: "primitives/curve", - category: "primitives", - title: "Curve", - description: "Control-point curve for route and profile sketching.", - statusLabel: "ready", - chips: ["Sag", "Lift"], - footerText: "Sag, Lift", - build: ({ scene }) => { - let current: Curve | null = null; - - mountControls( - "Curve Parameters", - [ - { type: "number", key: "span", label: "Span", min: 0.6, max: 3, step: 0.05, value: 2.4 }, - { type: "number", key: "sag", label: "Sag", min: 0.1, max: 2, step: 0.05, value: 1.1 }, - { type: "number", key: "lift", label: "Lift", min: 0, max: 2, step: 0.05, value: 0.7 }, - ], - (state) => { - const curve = new Curve({ - controlPoints: buildControlPoints( - state.span as number, - state.sag as number, - state.lift as number - ), - color: 0x0f766e, - }); - - current = replaceSceneObject(scene, current, curve); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts deleted file mode 100644 index fa17ed1..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/line.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Line, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "primitives/line", - category: "primitives", - title: "Line", - description: "Two-point line primitive with direct endpoint control.", - statusLabel: "ready", - chips: ["Length", "Angle"], - footerText: "Length, Angle", - build: ({ scene }) => { - let current: Line | null = null; - - mountControls( - "Line Parameters", - [ - { type: "number", key: "length", label: "Length", min: 0.4, max: 8, step: 0.1, value: 4 }, - { type: "number", key: "angleDeg", label: "Angle (deg)", min: 0, max: 360, step: 1, value: 35 }, - { type: "number", key: "centerX", label: "Center X", min: -3, max: 3, step: 0.1, value: 0 }, - { type: "number", key: "centerZ", label: "Center Z", min: -3, max: 3, step: 0.1, value: 0 }, - ], - (state) => { - const length = state.length as number; - const angle = ((state.angleDeg as number) * Math.PI) / 180; - const cx = state.centerX as number; - const cz = state.centerZ as number; - - const halfDx = Math.cos(angle) * (length * 0.5); - const halfDz = Math.sin(angle) * (length * 0.5); - - const line = new Line({ - start: new Vector3(cx - halfDx, 0, cz - halfDz), - end: new Vector3(cx + halfDx, 0, cz + halfDz), - color: 0x111827, - }); - - current = replaceSceneObject(scene, current, line); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts deleted file mode 100644 index ad19d15..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/polyline.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Polyline, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -function buildPolyline(amplitude: number, length: number, closed: boolean): Vector3[] { - const points = [ - new Vector3(-length, 0, -amplitude), - new Vector3(-length * 0.35, 0, amplitude), - new Vector3(length * 0.2, 0, -amplitude * 0.65), - new Vector3(length, 0, amplitude * 0.8), - ]; - - if (closed) { - points.push(points[0].clone()); - } - - return points; -} - -export default defineExample({ - slug: "primitives/polyline", - category: "primitives", - title: "Polyline", - description: "Open and closed path definitions for profile work.", - statusLabel: "ready", - chips: ["Closure", "Span"], - footerText: "Closure, Span", - build: ({ scene }) => { - let current: Polyline | null = null; - - mountControls( - "Polyline Parameters", - [ - { type: "number", key: "amplitude", label: "Amplitude", min: 0.2, max: 2.5, step: 0.05, value: 1.1 }, - { type: "number", key: "length", label: "Length", min: 0.4, max: 3, step: 0.05, value: 2.2 }, - { type: "boolean", key: "closed", label: "Closed", value: false }, - ], - (state) => { - const polyline = new Polyline({ - points: buildPolyline(state.amplitude as number, state.length as number, state.closed as boolean), - color: 0x1d4ed8, - }); - - current = replaceSceneObject(scene, current, polyline); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts b/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts deleted file mode 100644 index 76f9ecf..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/primitives/rectangle.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Rectangle, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "primitives/rectangle", - category: "primitives", - title: "Rectangle", - description: "Parametric rectangular primitive for base profiles.", - statusLabel: "ready", - chips: ["Width", "Breadth"], - footerText: "Width, Breadth", - build: ({ scene }) => { - let current: Rectangle | null = null; - - mountControls( - "Rectangle Parameters", - [ - { type: "number", key: "width", label: "Width", min: 0.2, max: 5, step: 0.05, value: 1.8 }, - { type: "number", key: "breadth", label: "Breadth", min: 0.2, max: 5, step: 0.05, value: 1.1 }, - { type: "number", key: "centerX", label: "Center X", min: -3, max: 3, step: 0.1, value: 0 }, - { type: "number", key: "centerZ", label: "Center Z", min: -3, max: 3, step: 0.1, value: 0 }, - ], - (state) => { - const rectangle = new Rectangle({ - center: new Vector3(state.centerX as number, 0, state.centerZ as number), - width: state.width as number, - breadth: state.breadth as number, - color: 0x1f2937, - }); - - current = replaceSceneObject(scene, current, rectangle); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts deleted file mode 100644 index b89bce6..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/cuboid.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Cuboid, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "shapes/cuboid", - category: "shapes", - title: "Cuboid", - description: "Rectangular solid for rooms, equipment blocks and massing.", - statusLabel: "ready", - chips: ["Width", "Height", "Depth"], - footerText: "Width, Height, Depth", - build: ({ scene }) => { - let current: Cuboid | null = null; - - mountControls( - "Cuboid Parameters", - [ - { type: "number", key: "width", label: "Width", min: 0.2, max: 4, step: 0.05, value: 1.8 }, - { type: "number", key: "height", label: "Height", min: 0.2, max: 4, step: 0.05, value: 1.6 }, - { type: "number", key: "depth", label: "Depth", min: 0.2, max: 4, step: 0.05, value: 1.2 }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const cuboid = new Cuboid({ - center: new Vector3(0, (state.height as number) * 0.5, 0), - width: state.width as number, - height: state.height as number, - depth: state.depth as number, - color: 0x10b981, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - cuboid.outline = state.outline as boolean; - - current = replaceSceneObject(scene, current, cuboid); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts deleted file mode 100644 index d627e15..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/cylinder.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Cylinder, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "shapes/cylinder", - category: "shapes", - title: "Cylinder", - description: "Cylindrical volume for ducts, pipes and mechanical shafts.", - statusLabel: "ready", - chips: ["Radius", "Height", "Segments"], - footerText: "Radius, Height, Segments", - build: ({ scene }) => { - let current: Cylinder | null = null; - - mountControls( - "Cylinder Parameters", - [ - { type: "number", key: "radius", label: "Radius", min: 0.2, max: 3, step: 0.05, value: 0.8 }, - { type: "number", key: "height", label: "Height", min: 0.2, max: 4, step: 0.05, value: 1.8 }, - { type: "number", key: "segments", label: "Segments", min: 6, max: 96, step: 1, value: 36 }, - { type: "number", key: "angleDeg", label: "Angle (deg)", min: 20, max: 360, step: 1, value: 360 }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const cylinder = new Cylinder({ - center: new Vector3(0, (state.height as number) * 0.5, 0), - radius: state.radius as number, - height: state.height as number, - segments: Math.floor(state.segments as number), - angle: ((state.angleDeg as number) * Math.PI) / 180, - color: 0xf59e0b, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - cylinder.outline = state.outline as boolean; - - current = replaceSceneObject(scene, current, cylinder); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts deleted file mode 100644 index 4c3e553..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/opening.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Opening, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "shapes/opening", - category: "shapes", - title: "Opening", - description: "Opening helper volume for void and penetration previews.", - statusLabel: "ready", - chips: ["Width", "Height", "Depth"], - footerText: "Width, Height, Depth", - build: ({ scene }) => { - let current: Opening | null = null; - - mountControls( - "Opening Parameters", - [ - { type: "number", key: "width", label: "Width", min: 0.2, max: 4, step: 0.05, value: 1.3 }, - { type: "number", key: "height", label: "Height", min: 0.2, max: 4, step: 0.05, value: 2.0 }, - { type: "number", key: "depth", label: "Depth", min: 0.1, max: 2, step: 0.05, value: 0.35 }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const opening = new Opening({ - center: new Vector3(0, (state.height as number) * 0.5, 0), - width: state.width as number, - height: state.height as number, - depth: state.depth as number, - color: 0x94a3b8, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - opening.outline = state.outline as boolean; - - current = replaceSceneObject(scene, current, opening); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts deleted file mode 100644 index 30007a6..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon-suite.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { Polygon, Vector3 } from "@og-three"; -import Stats from "three/examples/jsm/libs/stats.module.js"; -import GUI from "three/examples/jsm/libs/lil-gui.module.min.js"; -import { defineExample } from "../../shared/example-contract"; -import { replaceSceneObject } from "../../shared/runtime"; - -type LoopPoint = [number, number, number]; - -type PolygonDatasetEntry = { - vertices: LoopPoint[]; - holes?: LoopPoint[][]; - description: string; - category: string; -}; - -const polygonDataset: Record = { - Triangle: { - vertices: [ - [0, 0, 0], - [3, 0, 0], - [1.5, 0, 3], - ], - description: "Basic triangle test for the smallest valid polygon footprint.", - category: "Basic Shapes", - }, - Square: { - vertices: [ - [-2, 0, -2], - [2, 0, -2], - [2, 0, 2], - [-2, 0, 2], - ], - description: "Simple rectangular slab used as the baseline fill and outline case.", - category: "Basic Shapes", - }, - L_Shape: { - vertices: [ - [0, 0, 0], - [6, 0, 0], - [6, 0, 4], - [10, 0, 4], - [10, 0, 10], - [0, 0, 10], - ], - description: "Concave architectural footprint for notch handling and triangulation checks.", - category: "Concave Cases", - }, - Concave_Polygon: { - vertices: [ - [0, 0, 0], - [3, 0, 0], - [4, 0, 2], - [6, 0, 0], - [10, 0, 0], - [10, 0, 3], - [8, 0, 4], - [10, 0, 6], - [10, 0, 10], - [6, 0, 10], - [4, 0, 8], - [3, 0, 10], - [0, 0, 10], - [0, 0, 6], - [2, 0, 4], - [0, 0, 3], - ], - description: "Complex concave polygon with multiple indentations for triangulation stress.", - category: "Concave Cases", - }, - Complex_Multi_Hole: { - vertices: [ - [-12, 0, -8], - [12, 0, -8], - [12, 0, 8], - [-12, 0, 8], - ], - holes: [ - [ - [-8, 0, -5], - [-8, 0, 1], - [-3, 0, 1], - [-3, 0, -5], - ], - [ - [2, 0, -5], - [5, 0, -4], - [6, 0, -1], - [5, 0, 2], - [2, 0, 1], - [1, 0, -2], - ], - [ - [0, 0, 4], - [0, 0, 6.5], - [5, 0, 6.5], - [5, 0, 5], - [2, 0, 5], - [2, 0, 4], - ], - ], - description: "Rectangular slab with three interior voids to validate multi-hole redraw stability.", - category: "Hole Test Suite", - }, - Highly_Complex: { - vertices: [ - [0, 0, 0], - [1, 0, 0], - [2, 0, 1], - [3, 0, 0], - [4, 0, 1], - [5, 0, 0], - [6, 0, 1], - [7, 0, 0], - [8, 0, 1], - [9, 0, 0], - [10, 0, 1], - [11, 0, 2], - [12, 0, 1], - [13, 0, 2], - [14, 0, 3], - [15, 0, 4], - [16, 0, 5], - [17, 0, 6], - [18, 0, 7], - [19, 0, 8], - [20, 0, 9], - [19, 0, 10], - [18, 0, 9], - [17, 0, 8], - [16, 0, 7], - [15, 0, 6], - [14, 0, 5], - [13, 0, 4], - [12, 0, 3], - [11, 0, 2], - [10, 0, 3], - [9, 0, 2], - [8, 0, 3], - [7, 0, 2], - [6, 0, 3], - [5, 0, 2], - [4, 0, 3], - [3, 0, 2], - [2, 0, 3], - [1, 0, 2], - [0, 0, 1], - ], - description: "Large irregular polygon used to inspect performance and triangulation consistency.", - category: "Performance Testing", - }, - Building: { - vertices: [ - [66.1, 0, 11.2], - [66.1, 0, 9.6], - [66.6, 0, 9.6], - [66.6, 0, 8.7], - [74.3, 0, 8.7], - [77.1, 0, 8.7], - [77.1, 0, 11.4], - [75.0, 0, 11.4], - [75.0, 0, 11.3], - [74.2, 0, 11.3], - [74.2, 0, 10.6], - [71.0, 0, 10.6], - [71.0, 0, 11.3], - [66.6, 0, 11.3], - [66.6, 0, 11.2], - ], - description: "Architectural outline sampled from a real-world earcut-oriented building footprint.", - category: "Dataset Cases", - }, -}; - -function toVector3Loop(points: LoopPoint[]): Vector3[] { - return points.map((point) => new Vector3(point[0], point[1], point[2])); -} - -function applyPolygonMaterial( - polygon: Polygon, - color: string, - opacity: number, - wireframe: boolean, - outline: boolean -) { - const candidate = polygon as unknown as { - material?: { - color?: { set: (next: string) => void }; - opacity?: number; - transparent?: boolean; - wireframe?: boolean; - }; - }; - - polygon.outline = outline; - candidate.material?.color?.set(color); - if (candidate.material) { - candidate.material.opacity = opacity; - candidate.material.transparent = opacity < 1; - candidate.material.wireframe = wireframe; - } -} - -export default defineExample({ - slug: "shapes/polygon-suite", - category: "shapes", - title: "Polygon Suite", - description: "Dataset-backed polygon validation with concave, performance, and multi-hole cases.", - statusLabel: "ready", - chips: ["Holes", "Concavity"], - footerText: "Holes, Concavity", - build: ({ scene, camera, renderer, controls }) => { - camera.position.set(0, 20, 20); - controls.target.set(0, 0, 0); - controls.update(); - - const stats = new Stats(); - stats.dom.id = "stats"; - document.body.appendChild(stats.dom); - - const description = document.createElement("div"); - description.id = "polygon-description"; - document.body.appendChild(description); - - const renderBase = renderer.render.bind(renderer); - renderer.render = ((renderScene, renderCamera) => { - stats.begin(); - renderBase(renderScene, renderCamera); - stats.end(); - }) as typeof renderer.render; - - const info = { - vertices: 0, - holes: 0, - category: "", - }; - - const params = { - polygonType: "Complex_Multi_Hole", - showOutline: true, - polygonColor: "#4CAF50", - opacity: 0.7, - wireframe: false, - showStats: true, - statsMode: 0, - }; - - let current: Polygon | null = null; - - const updatePolygonDescription = (polygonName: string) => { - const polygonData = polygonDataset[polygonName]; - if (!polygonData) { - return; - } - - const holeSummary = polygonData.holes?.length - ? `
Holes: ${polygonData.holes.length}` - : ""; - - description.innerHTML = ` - ${polygonName}
- Category: ${polygonData.category}${holeSummary}
- ${polygonData.description} - `; - }; - - const createPolygon = (polygonName: string) => { - const polygonData = polygonDataset[polygonName]; - if (!polygonData) { - return; - } - - const polygon = new Polygon({ - vertices: toVector3Loop(polygonData.vertices), - color: 0x4caf50, - }); - - for (const hole of polygonData.holes ?? []) { - polygon.addHole(toVector3Loop(hole)); - } - - polygon.position.y = 0.01; - applyPolygonMaterial( - polygon, - params.polygonColor, - params.opacity, - params.wireframe, - params.showOutline - ); - - current = replaceSceneObject(scene, current, polygon); - info.vertices = polygonData.vertices.length; - info.holes = polygonData.holes?.length ?? 0; - info.category = polygonData.category; - updatePolygonDescription(polygonName); - }; - - const gui = new GUI(); - const polygonFolder = gui.addFolder("Polygon Test Suite"); - polygonFolder.open(); - polygonFolder - .add(params, "polygonType", Object.keys(polygonDataset)) - .name("Polygon Shape") - .onChange((value: string) => { - createPolygon(value); - }); - polygonFolder - .add(params, "showOutline") - .name("Show Outline") - .onChange((value: boolean) => { - if (current) { - applyPolygonMaterial(current, params.polygonColor, params.opacity, params.wireframe, value); - } - }); - polygonFolder - .addColor(params, "polygonColor") - .name("Polygon Color") - .onChange((value: string) => { - if (current) { - applyPolygonMaterial(current, value, params.opacity, params.wireframe, params.showOutline); - } - }); - polygonFolder - .add(params, "opacity", 0, 1, 0.01) - .name("Opacity") - .onChange((value: number) => { - if (current) { - applyPolygonMaterial(current, params.polygonColor, value, params.wireframe, params.showOutline); - } - }); - polygonFolder - .add(params, "wireframe") - .name("Wireframe") - .onChange((value: boolean) => { - if (current) { - applyPolygonMaterial(current, params.polygonColor, params.opacity, value, params.showOutline); - } - }); - - const performanceFolder = gui.addFolder("Performance"); - performanceFolder.open(); - performanceFolder - .add(params, "showStats") - .name("Show FPS Stats") - .onChange((value: boolean) => { - stats.dom.style.display = value ? "block" : "none"; - }); - performanceFolder - .add(params, "statsMode", { FPS: 0, "Frame Time (ms)": 1, "Memory (MB)": 2 }) - .name("Stats Mode") - .onChange((value: number) => { - stats.showPanel(Number(value)); - }); - - const infoFolder = gui.addFolder("Polygon Info"); - infoFolder.open(); - infoFolder.add(info, "vertices").name("Vertex Count").listen(); - infoFolder.add(info, "holes").name("Hole Count").listen(); - infoFolder.add(info, "category").name("Category").listen(); - - createPolygon(params.polygonType); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts deleted file mode 100644 index d598297..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/polygon.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Polygon, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -function buildPolygonVertices(sides: number, radius: number): Vector3[] { - const clampedSides = Math.max(3, Math.floor(sides)); - const points: Vector3[] = []; - - for (let i = 0; i < clampedSides; i += 1) { - const t = (i / clampedSides) * Math.PI * 2; - const r = i % 2 === 0 ? radius : radius * 0.72; - points.push(new Vector3(Math.cos(t) * r, 0, Math.sin(t) * r)); - } - - return points; -} - -export default defineExample({ - slug: "shapes/polygon", - category: "shapes", - title: "Polygon", - description: "Planar polygon triangulation for surfaces and slabs.", - statusLabel: "ready", - chips: ["Sides", "Radius"], - footerText: "Sides, Radius", - build: ({ scene }) => { - let current: Polygon | null = null; - - mountControls( - "Polygon Parameters", - [ - { type: "number", key: "sides", label: "Sides", min: 3, max: 12, step: 1, value: 5 }, - { type: "number", key: "radius", label: "Radius", min: 0.4, max: 3, step: 0.05, value: 1.8 }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const polygon = new Polygon({ - vertices: buildPolygonVertices(state.sides as number, state.radius as number), - color: 0x2563eb, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - polygon.outline = state.outline as boolean; - - current = replaceSceneObject(scene, current, polygon); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts deleted file mode 100644 index 9847f00..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/sphere.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Sphere, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "shapes/sphere", - category: "shapes", - title: "Sphere", - description: "UV sphere for equipment envelopes and clearance studies.", - statusLabel: "ready", - chips: ["Radius", "Segments"], - footerText: "Radius, Segments", - build: ({ scene }) => { - let current: Sphere | null = null; - - mountControls( - "Sphere Parameters", - [ - { type: "number", key: "radius", label: "Radius", min: 0.2, max: 3, step: 0.05, value: 1.0 }, - { type: "number", key: "widthSegments", label: "Width Segments", min: 3, max: 96, step: 1, value: 32 }, - { type: "number", key: "heightSegments", label: "Height Segments", min: 2, max: 64, step: 1, value: 20 }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const radius = state.radius as number; - const sphere = new Sphere({ - center: new Vector3(0, radius, 0), - radius, - widthSegments: Math.floor(state.widthSegments as number), - heightSegments: Math.floor(state.heightSegments as number), - color: 0x0891b2, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - sphere.outline = state.outline as boolean; - - current = replaceSceneObject(scene, current, sphere); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts deleted file mode 100644 index e15e812..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/sweep.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Sweep, Vector3 } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -function buildPath(height: number, spread: number): Vector3[] { - return [ - new Vector3(-2.0, 0.0, -1.2), - new Vector3(-1.0, height * 0.25, -0.2 * spread), - new Vector3(0.2, height * 0.55, 0.7 * spread), - new Vector3(1.3, height * 0.8, 0.2 * spread), - new Vector3(2.1, height, -0.9 * spread), - ]; -} - -function buildProfile(width: number, depth: number): Vector3[] { - return [ - new Vector3(-width * 0.5, 0, -depth * 0.5), - new Vector3(width * 0.5, 0, -depth * 0.5), - new Vector3(width * 0.5, 0, depth * 0.5), - new Vector3(-width * 0.5, 0, depth * 0.5), - ]; -} - -export default defineExample({ - slug: "shapes/sweep", - category: "shapes", - title: "Sweep", - description: "Profile along path sweep for framing and custom sections.", - statusLabel: "ready", - chips: ["Path", "Caps"], - footerText: "Path, Caps", - build: ({ scene }) => { - let current: Sweep | null = null; - - mountControls( - "Sweep Parameters", - [ - { type: "number", key: "height", label: "Path Height", min: 0.4, max: 4, step: 0.05, value: 2.3 }, - { type: "number", key: "spread", label: "Path Spread", min: 0.4, max: 2, step: 0.05, value: 1.0 }, - { type: "number", key: "profileWidth", label: "Profile Width", min: 0.1, max: 1.5, step: 0.05, value: 0.5 }, - { type: "number", key: "profileDepth", label: "Profile Depth", min: 0.1, max: 1.5, step: 0.05, value: 0.4 }, - { type: "boolean", key: "capStart", label: "Cap Start", value: true }, - { type: "boolean", key: "capEnd", label: "Cap End", value: true }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const sweep = new Sweep({ - path: buildPath(state.height as number, state.spread as number), - profile: buildProfile(state.profileWidth as number, state.profileDepth as number), - color: 0x0ea5e9, - capStart: state.capStart as boolean, - capEnd: state.capEnd as boolean, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - sweep.outline = state.outline as boolean; - - current = replaceSceneObject(scene, current, sweep); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts b/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts deleted file mode 100644 index ff86bee..0000000 --- a/main/opengeometry-three/examples-vite/src/examples/shapes/wedge.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Vector3, Wedge } from "@og-three"; -import { defineExample } from "../../shared/example-contract"; -import { mountControls, replaceSceneObject } from "../../shared/runtime"; - -export default defineExample({ - slug: "shapes/wedge", - category: "shapes", - title: "Wedge", - description: "Tapered solid for ramps and sloped technical elements.", - statusLabel: "ready", - chips: ["Width", "Height", "Depth"], - footerText: "Width, Height, Depth", - build: ({ scene }) => { - let current: Wedge | null = null; - - mountControls( - "Wedge Parameters", - [ - { type: "number", key: "width", label: "Width", min: 0.2, max: 4, step: 0.05, value: 2.0 }, - { type: "number", key: "height", label: "Height", min: 0.2, max: 4, step: 0.05, value: 1.8 }, - { type: "number", key: "depth", label: "Depth", min: 0.2, max: 4, step: 0.05, value: 1.4 }, - { type: "boolean", key: "outline", label: "Outline", value: true }, - { type: "boolean", key: "fatOutlines", label: "Fat Outlines", value: false }, - { type: "number", key: "outlineWidth", label: "Outline Width", min: 1, max: 12, step: 0.5, value: 4 }, - ], - (state) => { - const wedge = new Wedge({ - center: new Vector3(0, (state.height as number) * 0.5, 0), - width: state.width as number, - height: state.height as number, - depth: state.depth as number, - color: 0xd97706, - fatOutlines: state.fatOutlines as boolean, - outlineWidth: state.outlineWidth as number, - }); - wedge.outline = state.outline as boolean; - - current = replaceSceneObject(scene, current, wedge); - } - ); - }, -}); diff --git a/main/opengeometry-three/examples-vite/src/shared/example-contract.ts b/main/opengeometry-three/examples-vite/src/shared/example-contract.ts deleted file mode 100644 index a210ec6..0000000 --- a/main/opengeometry-three/examples-vite/src/shared/example-contract.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ExampleContext } from "./runtime"; - -export type ExampleCategory = "primitives" | "shapes" | "operations"; - -export interface ExampleDefinition { - slug: `${ExampleCategory}/${string}`; - category: ExampleCategory; - title: string; - description: string; - statusLabel: string; - chips: string[]; - footerText: string; - build: (ctx: ExampleContext) => void | Promise; -} - -export function defineExample(example: ExampleDefinition): ExampleDefinition { - return example; -} diff --git a/main/opengeometry-three/examples-vite/src/shared/examples.ts b/main/opengeometry-three/examples-vite/src/shared/examples.ts deleted file mode 100644 index 3a4389d..0000000 --- a/main/opengeometry-three/examples-vite/src/shared/examples.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { ExampleCategory } from "./example-contract"; - -export const categoryLabels: Record = { - primitives: "Primitives", - shapes: "Shapes", - operations: "Operations", -}; - -export interface ExampleMetadata { - slug: string; - category: ExampleCategory; - title: string; - description: string; - statusLabel: string; - chips: string[]; - footerText: string; -} - -export const examples: ExampleMetadata[] = [ - { - "slug": "primitives/arc", - "category": "primitives", - "title": "Arc", - "description": "Circular arc with angle span and segmentation control.", - "statusLabel": "ready", - "chips": [ - "Radius", - "Span" - ], - "footerText": "Radius, Span" - }, - { - "slug": "primitives/curve", - "category": "primitives", - "title": "Curve", - "description": "Control-point curve for route and profile sketching.", - "statusLabel": "ready", - "chips": [ - "Sag", - "Lift" - ], - "footerText": "Sag, Lift" - }, - { - "slug": "primitives/line", - "category": "primitives", - "title": "Line", - "description": "Two-point line primitive with direct endpoint control.", - "statusLabel": "ready", - "chips": [ - "Length", - "Angle" - ], - "footerText": "Length, Angle" - }, - { - "slug": "primitives/polyline", - "category": "primitives", - "title": "Polyline", - "description": "Open and closed path definitions for profile work.", - "statusLabel": "ready", - "chips": [ - "Closure", - "Span" - ], - "footerText": "Closure, Span" - }, - { - "slug": "primitives/rectangle", - "category": "primitives", - "title": "Rectangle", - "description": "Parametric rectangular primitive for base profiles.", - "statusLabel": "ready", - "chips": [ - "Width", - "Breadth" - ], - "footerText": "Width, Breadth" - }, - { - "slug": "shapes/cuboid", - "category": "shapes", - "title": "Cuboid", - "description": "Rectangular solid for rooms, equipment blocks and massing.", - "statusLabel": "ready", - "chips": [ - "Width", - "Height", - "Depth" - ], - "footerText": "Width, Height, Depth" - }, - { - "slug": "shapes/cylinder", - "category": "shapes", - "title": "Cylinder", - "description": "Cylindrical volume for ducts, pipes and mechanical shafts.", - "statusLabel": "ready", - "chips": [ - "Radius", - "Height", - "Segments" - ], - "footerText": "Radius, Height, Segments" - }, - { - "slug": "shapes/opening", - "category": "shapes", - "title": "Opening", - "description": "Opening helper volume for void and penetration previews.", - "statusLabel": "ready", - "chips": [ - "Width", - "Height", - "Depth" - ], - "footerText": "Width, Height, Depth" - }, - { - "slug": "shapes/polygon", - "category": "shapes", - "title": "Polygon", - "description": "Planar polygon triangulation for surfaces and slabs.", - "statusLabel": "ready", - "chips": [ - "Sides", - "Radius" - ], - "footerText": "Sides, Radius" - }, - { - "slug": "shapes/polygon-suite", - "category": "shapes", - "title": "Polygon Suite", - "description": "Dataset-backed polygon validation with concave, performance, and multi-hole cases.", - "statusLabel": "ready", - "chips": [ - "Holes", - "Concavity" - ], - "footerText": "Holes, Concavity" - }, - { - "slug": "shapes/sphere", - "category": "shapes", - "title": "Sphere", - "description": "UV sphere for equipment envelopes and clearance studies.", - "statusLabel": "ready", - "chips": [ - "Radius", - "Segments" - ], - "footerText": "Radius, Segments" - }, - { - "slug": "shapes/sweep", - "category": "shapes", - "title": "Sweep", - "description": "Profile along path sweep for framing and custom sections.", - "statusLabel": "ready", - "chips": [ - "Path", - "Caps" - ], - "footerText": "Path, Caps" - }, - { - "slug": "shapes/wedge", - "category": "shapes", - "title": "Wedge", - "description": "Tapered solid for ramps and sloped technical elements.", - "statusLabel": "ready", - "chips": [ - "Width", - "Height", - "Depth" - ], - "footerText": "Width, Height, Depth" - }, - { - "slug": "operations/offset", - "category": "operations", - "title": "Offset", - "description": "Offset generation with acute-corner and bevel parameters.", - "statusLabel": "ready", - "chips": [ - "Offset", - "Bevel" - ], - "footerText": "Offset, Bevel" - }, - { - "slug": "operations/sweep-path-profile", - "category": "operations", - "title": "Sweep Path + Profile", - "description": "Operation-level sweep from path primitive + profile primitive.", - "statusLabel": "ready", - "chips": [ - "Path", - "Caps" - ], - "footerText": "Path, Caps" - }, - { - "slug": "operations/sweep-hilbert-profiles", - "category": "operations", - "title": "Sweep Hilbert Profiles", - "description": "Locked Hilbert3D path with switchable kernel and custom section profiles.", - "statusLabel": "ready", - "chips": [ - "Hilbert Path", - "Profiles", - "Sweep" - ], - "footerText": "Profile Type, Caps, Outlines" - }, - { - "slug": "operations/wall-from-offsets", - "category": "operations", - "title": "Wall from Offsets", - "description": "Composite wall profile assembled from offset centerlines.", - "statusLabel": "ready", - "chips": [ - "Thickness" - ], - "footerText": "Thickness" - } -]; - -export function getExampleBySlug(slug: string): ExampleMetadata { - const match = examples.find((example) => example.slug === slug); - if (!match) { - throw new Error("Unknown example slug: " + slug); - } - - return match; -} - -export function getExamplesByCategory(category: ExampleCategory): ExampleMetadata[] { - return examples.filter((example) => example.category === category); -} diff --git a/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts b/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts deleted file mode 100644 index b64b295..0000000 --- a/main/opengeometry-three/examples-vite/src/shared/icon-registry.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; -import { icon } from "@fortawesome/fontawesome-svg-core"; -import { - faBezierCurve, - faCompassDrafting, - faCube, - faDatabase, - faDoorOpen, - faDrawPolygon, - faGlobe, - faLayerGroup, - faMountain, - faRoad, - faRoute, - faShapes, - faSlash, - faUpRightAndDownLeftFromCenter, - faVectorSquare, - faWarehouse, -} from "@fortawesome/free-solid-svg-icons"; - -const iconBySlug: Record = { - line: faSlash, - polyline: faDrawPolygon, - arc: faCompassDrafting, - rectangle: faVectorSquare, - curve: faBezierCurve, - polygon: faDrawPolygon, - "polygon-suite": faLayerGroup, - cuboid: faCube, - cylinder: faDatabase, - wedge: faMountain, - opening: faDoorOpen, - sweep: faRoad, - sphere: faGlobe, - offset: faUpRightAndDownLeftFromCenter, - "wall-from-offsets": faWarehouse, - "sweep-path-profile": faRoute, - "sweep-hilbert-profiles": faRoute, -}; - -export function getExampleIconMarkup(slug: string): string { - const key = slug.split("/").pop() ?? slug; - const selected = iconBySlug[key] ?? faShapes; - return icon(selected).html.join(""); -} diff --git a/main/opengeometry-three/examples-vite/src/shared/runtime.ts b/main/opengeometry-three/examples-vite/src/shared/runtime.ts deleted file mode 100644 index 4958683..0000000 --- a/main/opengeometry-three/examples-vite/src/shared/runtime.ts +++ /dev/null @@ -1,327 +0,0 @@ -import * as THREE from "three"; -import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { OpenGeometry } from "@og-three"; -import "../styles/theme.css"; -import type { ExampleDefinition } from "./example-contract"; -import { getExampleIconMarkup } from "./icon-registry"; - -export interface ExampleContext { - scene: THREE.Scene; - camera: THREE.PerspectiveCamera; - renderer: THREE.WebGLRenderer; - controls: OrbitControls; -} - -export type ExampleControlDefinition = - | { - type: "number"; - key: string; - label: string; - min: number; - max: number; - step?: number; - value: number; - } - | { - type: "boolean"; - key: string; - label: string; - value: boolean; - } - | { - type: "select"; - key: string; - label: string; - value: string; - options: Array<{ - label: string; - value: string; - }>; - }; - -export type ExampleControlState = Record; - -interface BootstrapConfig { - example: ExampleDefinition; -} - -function getWasmUrl(): string { - if (import.meta.env.PROD) { - return new URL( - /* @vite-ignore */ "./wasm/opengeometry_bg.wasm", - import.meta.url - ).toString(); - } - - return new URL( - "../../../../opengeometry/pkg/opengeometry_bg.wasm", - import.meta.url - ).toString(); -} - -export async function bootstrapExample(config: BootstrapConfig) { - const app = document.getElementById("app"); - if (!app) { - throw new Error("Missing #app container"); - } - - const { example } = config; - document.body.classList.add("og-example-page"); - document.title = `${example.title} | OpenGeometry`; - - const badge = document.createElement("div"); - badge.className = "og-badge"; - badge.innerHTML = ` -
-
${getExampleIconMarkup(example.slug)}
-

${example.statusLabel}

-
-
-

OPEN GEOMETRY • ${example.category.toUpperCase()}

- ${example.title} - ${example.description} -
-
- ${example.chips.map((chip) => `${chip}`).join("")} -
- - `; - document.body.appendChild(badge); - - const scene = new THREE.Scene(); - scene.background = new THREE.Color(0xf6f1eb); - - const camera = new THREE.PerspectiveCamera( - 55, - window.innerWidth / window.innerHeight, - 0.1, - 4000 - ); - camera.position.set(5.5, 4.2, 6.5); - - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setPixelRatio(window.devicePixelRatio); - renderer.setSize(window.innerWidth, window.innerHeight); - app.appendChild(renderer.domElement); - - const controls = new OrbitControls(camera, renderer.domElement); - controls.enableDamping = true; - controls.target.set(0, 0.8, 0); - controls.update(); - - scene.add(new THREE.GridHelper(32, 32, 0xd88b63, 0xe7d4ca)); - - const ambient = new THREE.AmbientLight(0xffffff, 0.72); - scene.add(ambient); - - const key = new THREE.DirectionalLight(0xffffff, 0.9); - key.position.set(6, 8, 4); - scene.add(key); - - const fill = new THREE.DirectionalLight(0xffffff, 0.42); - fill.position.set(-5, 3, -6); - scene.add(fill); - - await OpenGeometry.create({ wasmURL: getWasmUrl() }); - await config.example.build({ scene, camera, renderer, controls }); - - function onResize() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); - } - - window.addEventListener("resize", onResize); - - function animate() { - requestAnimationFrame(animate); - controls.update(); - renderer.render(scene, camera); - } - - animate(); -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function disposeMaterial(material: unknown) { - if (!material) { - return; - } - - if (Array.isArray(material)) { - material.forEach((entry) => { - const candidate = entry as { dispose?: () => void }; - candidate.dispose?.(); - }); - return; - } - - const candidate = material as { dispose?: () => void }; - candidate.dispose?.(); -} - -function disposeObject3D(object: THREE.Object3D) { - object.traverse((node) => { - const withGeometry = node as { geometry?: { dispose?: () => void } }; - const withMaterial = node as { material?: unknown }; - withGeometry.geometry?.dispose?.(); - disposeMaterial(withMaterial.material); - }); -} - -export function replaceSceneObject( - scene: THREE.Scene, - previous: T | null, - next: T -): T { - if (previous) { - previous.parent?.remove(previous); - disposeObject3D(previous); - } - - scene.add(next); - return next; -} - -export function mountControls( - title: string, - definitions: ExampleControlDefinition[], - onChange: (state: ExampleControlState) => void -) { - const panel = document.createElement("aside"); - panel.className = "og-controls"; - - const heading = document.createElement("h3"); - heading.textContent = title; - panel.appendChild(heading); - - const state: ExampleControlState = {}; - for (const definition of definitions) { - state[definition.key] = definition.value; - } - - const emitChange = () => { - onChange({ ...state }); - }; - - for (const definition of definitions) { - const row = document.createElement("label"); - row.className = "og-control-row"; - - const header = document.createElement("div"); - header.className = "og-control-header"; - - const label = document.createElement("span"); - label.className = "og-control-label-chip"; - label.textContent = definition.label; - header.appendChild(label); - - if (definition.type === "number") { - const valueLabel = document.createElement("code"); - valueLabel.className = "og-control-value"; - valueLabel.textContent = definition.value.toFixed(3).replace(/\.?0+$/, ""); - header.appendChild(valueLabel); - row.appendChild(header); - - const inputs = document.createElement("div"); - inputs.className = "og-control-range"; - - const rangeInput = document.createElement("input"); - rangeInput.type = "range"; - rangeInput.min = String(definition.min); - rangeInput.max = String(definition.max); - rangeInput.step = String(definition.step ?? 0.01); - rangeInput.value = String(definition.value); - - const numberInput = document.createElement("input"); - numberInput.type = "number"; - numberInput.min = String(definition.min); - numberInput.max = String(definition.max); - numberInput.step = String(definition.step ?? 0.01); - numberInput.value = String(definition.value); - - const updateValue = (raw: number) => { - const next = clamp(raw, definition.min, definition.max); - state[definition.key] = next; - rangeInput.value = String(next); - numberInput.value = String(next); - valueLabel.textContent = next.toFixed(3).replace(/\.?0+$/, ""); - emitChange(); - }; - - rangeInput.addEventListener("input", () => { - updateValue(Number(rangeInput.value)); - }); - - numberInput.addEventListener("change", () => { - updateValue(Number(numberInput.value)); - }); - - inputs.appendChild(rangeInput); - inputs.appendChild(numberInput); - row.appendChild(inputs); - } else { - row.appendChild(header); - - if (definition.type === "boolean") { - const boolWrap = document.createElement("div"); - boolWrap.className = "og-control-bool"; - - const toggle = document.createElement("input"); - toggle.type = "checkbox"; - toggle.className = "og-toggle"; - toggle.checked = definition.value; - toggle.setAttribute("aria-label", definition.label); - - const boolLabel = document.createElement("span"); - boolLabel.className = "og-control-bool-state"; - boolLabel.textContent = definition.value ? "Enabled" : "Disabled"; - - const updateToggle = () => { - state[definition.key] = toggle.checked; - boolLabel.textContent = toggle.checked ? "Enabled" : "Disabled"; - emitChange(); - }; - - toggle.addEventListener("change", updateToggle); - boolWrap.appendChild(toggle); - boolWrap.appendChild(boolLabel); - row.appendChild(boolWrap); - } else { - const select = document.createElement("select"); - select.className = "og-control-select"; - select.setAttribute("aria-label", definition.label); - - for (const option of definition.options) { - const optionElement = document.createElement("option"); - optionElement.value = option.value; - optionElement.textContent = option.label; - optionElement.selected = option.value === definition.value; - select.appendChild(optionElement); - } - - select.addEventListener("change", () => { - state[definition.key] = select.value; - emitChange(); - }); - - row.appendChild(select); - } - } - - panel.appendChild(row); - } - - document.body.appendChild(panel); - emitChange(); - - return () => { - panel.remove(); - }; -} From ba09b715ab4c8bcfb3568c644d1f07115d376d1d Mon Sep 17 00:00:00 2001 From: blackboots <47943405+aka-blackboots@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:32:55 +0100 Subject: [PATCH 07/10] Beta2/export/stl (#11) * stl support for 2.0 * poc: stl + step exports * sweep example * update branch --- .../2026-03-04-stl-export-handoff.md | 106 ++ .../.generated/operations/offset.html | 12 + .../operations/sweep-path-profile.html | 12 + .../operations/wall-from-offsets.html | 12 + .../.generated/primitives/arc.html | 12 + .../.generated/primitives/curve.html | 12 + .../.generated/primitives/line.html | 12 + .../.generated/primitives/polyline.html | 12 + .../.generated/primitives/rectangle.html | 12 + .../.generated/shapes/cuboid.html | 12 + .../.generated/shapes/cylinder.html | 12 + .../.generated/shapes/opening.html | 12 + .../.generated/shapes/polygon-suite.html | 12 + .../.generated/shapes/polygon.html | 12 + .../.generated/shapes/sphere.html | 12 + .../.generated/shapes/sweep.html | 12 + .../.generated/shapes/wedge.html | 12 + .../examples-vite/index.html | 506 ++++++--- .../examples-vite/operations/door-export.html | 726 +++++++++++++ .../examples-vite/operations/ifc-export.html | 668 ++++++++++++ .../examples-vite/operations/step-export.html | 579 +++++++++++ .../examples-vite/operations/stl-export.html | 570 ++++++++++ .../examples-vite/tsconfig.json | 18 + .../examples/ifc-export.html | 338 ++++++ .../examples/step-export.html | 267 +++++ .../examples/stl-export.html | 263 +++++ main/opengeometry-three/index.ts | 19 +- main/opengeometry/Cargo.toml | 3 +- .../examples/export_step_ifc_fixture.rs | 51 + main/opengeometry/sandbox-native/src/main.rs | 8 +- .../scripts/validate_ifc_fixture.py | 26 + main/opengeometry/src/export/ifc.rs | 981 ++++++++++++++++++ main/opengeometry/src/export/mod.rs | 26 + main/opengeometry/src/export/part21.rs | 216 ++++ main/opengeometry/src/export/step.rs | 617 +++++++++++ main/opengeometry/src/export/stl.rs | 491 +++++++++ main/opengeometry/src/operations/sweep.rs | 93 +- main/opengeometry/src/primitives/polyline.rs | 25 + main/opengeometry/src/scenegraph.rs | 509 +++++++++ 39 files changed, 7132 insertions(+), 166 deletions(-) create mode 100644 AI-DOCs/opengeometry/2026-03-04-stl-export-handoff.md create mode 100644 main/opengeometry-three/examples-vite/.generated/operations/offset.html create mode 100644 main/opengeometry-three/examples-vite/.generated/operations/sweep-path-profile.html create mode 100644 main/opengeometry-three/examples-vite/.generated/operations/wall-from-offsets.html create mode 100644 main/opengeometry-three/examples-vite/.generated/primitives/arc.html create mode 100644 main/opengeometry-three/examples-vite/.generated/primitives/curve.html create mode 100644 main/opengeometry-three/examples-vite/.generated/primitives/line.html create mode 100644 main/opengeometry-three/examples-vite/.generated/primitives/polyline.html create mode 100644 main/opengeometry-three/examples-vite/.generated/primitives/rectangle.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/cuboid.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/cylinder.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/opening.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/polygon-suite.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/polygon.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/sphere.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/sweep.html create mode 100644 main/opengeometry-three/examples-vite/.generated/shapes/wedge.html create mode 100644 main/opengeometry-three/examples-vite/operations/door-export.html create mode 100644 main/opengeometry-three/examples-vite/operations/ifc-export.html create mode 100644 main/opengeometry-three/examples-vite/operations/step-export.html create mode 100644 main/opengeometry-three/examples-vite/operations/stl-export.html create mode 100644 main/opengeometry-three/examples-vite/tsconfig.json create mode 100644 main/opengeometry-three/examples/ifc-export.html create mode 100644 main/opengeometry-three/examples/step-export.html create mode 100644 main/opengeometry-three/examples/stl-export.html create mode 100644 main/opengeometry/examples/export_step_ifc_fixture.rs create mode 100644 main/opengeometry/scripts/validate_ifc_fixture.py create mode 100644 main/opengeometry/src/export/ifc.rs create mode 100644 main/opengeometry/src/export/part21.rs create mode 100644 main/opengeometry/src/export/step.rs create mode 100644 main/opengeometry/src/export/stl.rs diff --git a/AI-DOCs/opengeometry/2026-03-04-stl-export-handoff.md b/AI-DOCs/opengeometry/2026-03-04-stl-export-handoff.md new file mode 100644 index 0000000..0ab9f9b --- /dev/null +++ b/AI-DOCs/opengeometry/2026-03-04-stl-export-handoff.md @@ -0,0 +1,106 @@ +# STL Export Handoff (Binary STL + Three.js Validation) + +## What Changed + +- Added binary STL export support in the Rust core: + - `main/opengeometry/src/export/stl.rs` + - best-effort and strict policies + - single-BREP and multi-BREP export APIs + - native file output helpers (`cfg(not(target_arch = "wasm32"))`) +- Wired STL export through `scenegraph`: + - `exportBrepToStl(...)` + - `exportSceneToStl(...)` + - `exportCurrentSceneToStl(...)` + - wasm return payload includes bytes + report metadata (`OGStlExportResult`) +- Hardened incoming serialized BREP handling: + - `addBrepEntityToScene(...)` now validates topology before storing. +- Fixed stale half-edge usage in native sandbox: + - `main/opengeometry/sandbox-native/src/main.rs` now resolves endpoints via `get_edge_endpoints(...)`. +- Added Three.js validation examples: + - Legacy page: `main/opengeometry-three/examples/stl-export.html` + - Vite page: `main/opengeometry-three/examples-vite/operations/stl-export.html` + - Vite script: `main/opengeometry-three/examples-vite/src/pages/operations-stl-export.ts` + - Vite specs index updated to include STL export card. + - Vite STL page now includes a shape dropdown (`Cuboid`, `Cylinder`, `Sphere`, `Wedge`) with per-shape parameter groups and shape-specific STL filenames (`opengeometry-.stl`). + - Vite STL controls were consolidated into a single panel to prevent control container overlap. +- Exposed STL-related wasm types from package entry: + - `main/opengeometry-three/index.ts` now re-exports `OGSceneManager` and `OGStlExportResult`. + +## API Summary + +- Rust core: + - `export_brep_to_stl_bytes(&Brep, &StlExportConfig) -> Result<(Vec, StlExportReport), StlExportError>` + - `export_breps_to_stl_bytes(...) -> Result<(Vec, StlExportReport), StlExportError>` + - `export_brep_to_stl_file(...)` and `export_breps_to_stl_file(...)` for native file output +- Wasm scenegraph: + - `OGSceneManager.exportBrepToStl(brep_serialized, config_json?)` + - `OGSceneManager.exportSceneToStl(scene_id, config_json?)` + - `OGSceneManager.exportCurrentSceneToStl(config_json?)` + - return type: `OGStlExportResult` with: + - `bytes: Uint8Array` + - `reportJson: string` + +## Config Defaults + +- Binary STL only. +- Default policy is best-effort. +- Units are preserved as-is (`scale: 1.0` by default). +- Topology validation is enabled by default (`validate_topology: true`). + +## How To Test Locally + +### Rust quality gates + +```bash +cargo fmt --manifest-path main/opengeometry/Cargo.toml +cargo check --manifest-path main/opengeometry/Cargo.toml +cargo test --manifest-path main/opengeometry/Cargo.toml +cargo test --examples --manifest-path main/opengeometry/Cargo.toml +cargo check --target wasm32-unknown-unknown --manifest-path main/opengeometry/Cargo.toml +``` + +### Native sandbox check + +```bash +cargo check --offline --manifest-path main/opengeometry/sandbox-native/Cargo.toml +``` + +Note: online `cargo check` for `sandbox-native` may fail in restricted environments that cannot reach `index.crates.io`. + +### Regenerate wasm package and build examples + +```bash +npm run build-core +npm --prefix main/opengeometry-three run build-example-three +``` + +### Run examples + +- Legacy page (static host): + - `main/opengeometry-three/examples/stl-export.html` +- Vite built page: + - `main/opengeometry-three/examples-dist/operations/stl-export.html` + - Use the Shape dropdown and export button to verify shape-specific downloads (`opengeometry-cuboid.stl`, `opengeometry-cylinder.stl`, etc.). + +## STL Validation Notes + +- Binary STL writer uses `stl_io::write_stl` (spec-compliant binary structure). +- Unit tests cover: + - expected binary size (`84 + 50 * triangle_count`) + - triangle count consistency with STL header + - custom header injection + - best-effort skip behavior and strict failure behavior + +## Backward Compatibility + +- Existing primitive generation, projection, and PDF APIs remain intact. +- STL functionality is additive. +- One behavior hardening change: + - serialized BREPs added through scene manager are now topology-validated before insertion. + +## Known Caveats / Follow-ups + +- Existing non-critical warnings remain: + - `operations/windingsort.rs` (`ccw_and_flag` naming) + - `geometry/triangle.rs` (`crso` unused variable) +- `sandbox-native` still has pre-existing `unused_must_use` warnings in some call sites (not introduced by STL work). diff --git a/main/opengeometry-three/examples-vite/.generated/operations/offset.html b/main/opengeometry-three/examples-vite/.generated/operations/offset.html new file mode 100644 index 0000000..ca6c6b7 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/operations/offset.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/operations/sweep-path-profile.html b/main/opengeometry-three/examples-vite/.generated/operations/sweep-path-profile.html new file mode 100644 index 0000000..6017369 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/operations/sweep-path-profile.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/operations/wall-from-offsets.html b/main/opengeometry-three/examples-vite/.generated/operations/wall-from-offsets.html new file mode 100644 index 0000000..b5777d7 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/operations/wall-from-offsets.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/primitives/arc.html b/main/opengeometry-three/examples-vite/.generated/primitives/arc.html new file mode 100644 index 0000000..78777f5 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/primitives/arc.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/primitives/curve.html b/main/opengeometry-three/examples-vite/.generated/primitives/curve.html new file mode 100644 index 0000000..52839be --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/primitives/curve.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/primitives/line.html b/main/opengeometry-three/examples-vite/.generated/primitives/line.html new file mode 100644 index 0000000..5e96f91 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/primitives/line.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/primitives/polyline.html b/main/opengeometry-three/examples-vite/.generated/primitives/polyline.html new file mode 100644 index 0000000..a943a7c --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/primitives/polyline.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/primitives/rectangle.html b/main/opengeometry-three/examples-vite/.generated/primitives/rectangle.html new file mode 100644 index 0000000..906acc3 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/primitives/rectangle.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/cuboid.html b/main/opengeometry-three/examples-vite/.generated/shapes/cuboid.html new file mode 100644 index 0000000..22cd293 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/cuboid.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/cylinder.html b/main/opengeometry-three/examples-vite/.generated/shapes/cylinder.html new file mode 100644 index 0000000..77c85e0 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/cylinder.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/opening.html b/main/opengeometry-three/examples-vite/.generated/shapes/opening.html new file mode 100644 index 0000000..481cf37 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/opening.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/polygon-suite.html b/main/opengeometry-three/examples-vite/.generated/shapes/polygon-suite.html new file mode 100644 index 0000000..7df85c4 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/polygon-suite.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/polygon.html b/main/opengeometry-three/examples-vite/.generated/shapes/polygon.html new file mode 100644 index 0000000..23c5538 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/polygon.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/sphere.html b/main/opengeometry-three/examples-vite/.generated/shapes/sphere.html new file mode 100644 index 0000000..526f83a --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/sphere.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/sweep.html b/main/opengeometry-three/examples-vite/.generated/shapes/sweep.html new file mode 100644 index 0000000..f4790d1 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/sweep.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/.generated/shapes/wedge.html b/main/opengeometry-three/examples-vite/.generated/shapes/wedge.html new file mode 100644 index 0000000..6b11fd5 --- /dev/null +++ b/main/opengeometry-three/examples-vite/.generated/shapes/wedge.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry Example + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index 97cd657..93505c1 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -47,7 +47,7 @@ #app { width: 100%; - height: 100%; + min-height: 100vh; } .og-specs-shell { @@ -95,46 +95,6 @@ margin-top: 26px; } - .og-support { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - margin-top: 28px; - padding: 20px 22px; - border: 1px solid rgba(255, 84, 0, 0.18); - border-radius: var(--og-squircle); - background: linear-gradient(145deg, rgba(255, 248, 242, 0.96), rgba(250, 239, 230, 0.92)); - box-shadow: var(--og-shadow); - } - - .og-support-copy { - display: grid; - gap: 6px; - } - - .og-support-kicker { - margin: 0; - color: var(--og-accent); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - } - - .og-support-title { - margin: 0; - font-size: 22px; - font-weight: 650; - letter-spacing: -0.02em; - } - - .og-support-text { - margin: 0; - color: var(--og-muted); - font-size: 14px; - } - .og-specs-section-head { display: flex; align-items: baseline; @@ -172,11 +132,11 @@ .og-spec-card { display: grid; gap: 16px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(250, 242, 235, 0.96)); - border: 1px solid var(--og-line); min-height: 312px; padding: 18px 18px 16px; + border: 1px solid var(--og-line); border-radius: var(--og-squircle); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(250, 242, 235, 0.96)); box-shadow: var(--og-shadow); transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } @@ -189,8 +149,8 @@ .og-spec-card-head { display: flex; - justify-content: space-between; align-items: start; + justify-content: space-between; gap: 12px; } @@ -204,9 +164,6 @@ background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(255, 235, 223, 0.92)); border: 1px solid rgba(255, 84, 0, 0.18); color: var(--og-accent); - font: 700 11px/1 var(--og-mono); - letter-spacing: 0.08em; - text-transform: uppercase; } .og-spec-card-brand svg { @@ -252,7 +209,6 @@ .og-chip { display: inline-flex; flex: 0 0 auto; - align-self: flex-start; align-items: center; justify-content: center; height: 28px; @@ -282,6 +238,12 @@ border-top: 1px solid rgba(95, 61, 46, 0.08); } + .og-spec-context { + color: var(--og-muted-soft); + font-size: 12px; + letter-spacing: 0.02em; + } + .og-spec-open { display: inline-flex; align-items: center; @@ -297,6 +259,46 @@ box-shadow: 0 10px 18px rgba(76, 63, 54, 0.16); } + .og-support { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 28px; + padding: 20px 22px; + border: 1px solid rgba(255, 84, 0, 0.18); + border-radius: var(--og-squircle); + background: linear-gradient(145deg, rgba(255, 248, 242, 0.96), rgba(250, 239, 230, 0.92)); + box-shadow: var(--og-shadow); + } + + .og-support-copy { + display: grid; + gap: 6px; + } + + .og-support-kicker { + margin: 0; + color: var(--og-accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + } + + .og-support-title { + margin: 0; + font-size: 22px; + font-weight: 650; + letter-spacing: -0.02em; + } + + .og-support-text { + margin: 0; + color: var(--og-muted); + font-size: 14px; + } + .og-support-link { display: inline-flex; align-items: center; @@ -345,11 +347,7 @@ font-size: 24px; } - .og-spec-card-footer { - flex-direction: column; - align-items: stretch; - } - + .og-spec-card-footer, .og-support { flex-direction: column; align-items: stretch; @@ -380,144 +378,338 @@ faWarehouse, } from "@fortawesome/free-solid-svg-icons"; + const app = document.getElementById("app"); + if (!app) { + throw new Error("Missing #app container"); + } + const categoryLabels = { primitives: "Primitives", shapes: "Shapes", operations: "Operations", }; - const examples = [ - { slug: "primitives/arc", category: "primitives", title: "Arc", description: "Circular arc with angle span and segmentation control.", statusLabel: "ready", chips: ["Radius", "Span"], footerText: "Radius, Span" }, - { slug: "primitives/curve", category: "primitives", title: "Curve", description: "Control-point curve for route and profile sketching.", statusLabel: "ready", chips: ["Sag", "Lift"], footerText: "Sag, Lift" }, - { slug: "primitives/line", category: "primitives", title: "Line", description: "Two-point line primitive with direct endpoint control.", statusLabel: "ready", chips: ["Length", "Angle"], footerText: "Length, Angle" }, - { slug: "primitives/polyline", category: "primitives", title: "Polyline", description: "Open and closed path definitions for profile work.", statusLabel: "ready", chips: ["Closure", "Span"], footerText: "Closure, Span" }, - { slug: "primitives/rectangle", category: "primitives", title: "Rectangle", description: "Parametric rectangular primitive for base profiles.", statusLabel: "ready", chips: ["Width", "Breadth"], footerText: "Width, Breadth" }, - { slug: "shapes/cuboid", category: "shapes", title: "Cuboid", description: "Rectangular solid for rooms, equipment blocks and massing.", statusLabel: "ready", chips: ["Width", "Height", "Depth"], footerText: "Width, Height, Depth" }, - { slug: "shapes/cylinder", category: "shapes", title: "Cylinder", description: "Cylindrical volume for ducts, pipes and mechanical shafts.", statusLabel: "ready", chips: ["Radius", "Height", "Segments"], footerText: "Radius, Height, Segments" }, - { slug: "shapes/opening", category: "shapes", title: "Opening", description: "Opening helper volume for void and penetration previews.", statusLabel: "ready", chips: ["Width", "Height", "Depth"], footerText: "Width, Height, Depth" }, - { slug: "shapes/polygon", category: "shapes", title: "Polygon", description: "Planar polygon triangulation for surfaces and slabs.", statusLabel: "ready", chips: ["Sides", "Radius"], footerText: "Sides, Radius" }, - { slug: "shapes/polygon-suite", category: "shapes", title: "Polygon Suite", description: "Dataset-backed polygon validation with concave, performance, and multi-hole cases.", statusLabel: "ready", chips: ["Holes", "Concavity"], footerText: "Holes, Concavity" }, - { slug: "shapes/sphere", category: "shapes", title: "Sphere", description: "UV sphere for equipment envelopes and clearance studies.", statusLabel: "ready", chips: ["Radius", "Segments"], footerText: "Radius, Segments" }, - { slug: "shapes/sweep", category: "shapes", title: "Sweep", description: "Profile along path sweep for framing and custom sections.", statusLabel: "ready", chips: ["Path", "Caps"], footerText: "Path, Caps" }, - { slug: "shapes/wedge", category: "shapes", title: "Wedge", description: "Tapered solid for ramps and sloped technical elements.", statusLabel: "ready", chips: ["Width", "Height", "Depth"], footerText: "Width, Height, Depth" }, - { slug: "operations/offset", category: "operations", title: "Offset", description: "Offset generation with acute-corner and bevel parameters.", statusLabel: "ready", chips: ["Offset", "Bevel"], footerText: "Offset, Bevel" }, - { slug: "operations/sweep-path-profile", category: "operations", title: "Sweep Path + Profile", description: "Operation-level sweep from path primitive + profile primitive.", statusLabel: "ready", chips: ["Path", "Caps"], footerText: "Path, Caps" }, - { slug: "operations/sweep-hilbert-profiles", category: "operations", title: "Sweep Hilbert Profiles", description: "Locked Hilbert3D path with switchable kernel and custom section profiles.", statusLabel: "ready", chips: ["Hilbert Path", "Profiles", "Sweep"], footerText: "Profile Type, Caps, Outlines" }, - { slug: "operations/wall-from-offsets", category: "operations", title: "Wall from Offsets", description: "Composite wall profile assembled from offset centerlines.", statusLabel: "ready", chips: ["Thickness"], footerText: "Thickness" }, - ]; - - const iconBySlug = { - line: faSlash, - polyline: faDrawPolygon, + const iconMap = { arc: faCompassDrafting, - rectangle: faVectorSquare, curve: faBezierCurve, + line: faSlash, + polyline: faRoute, + rectangle: faVectorSquare, + cuboid: faCube, + cylinder: faWarehouse, + opening: faUpRightAndDownLeftFromCenter, polygon: faDrawPolygon, "polygon-suite": faLayerGroup, - cuboid: faCube, - cylinder: faDatabase, - wedge: faMountain, - opening: faDoorOpen, - sweep: faRoad, sphere: faGlobe, - offset: faUpRightAndDownLeftFromCenter, - "wall-from-offsets": faWarehouse, + sweep: faShapes, + wedge: faMountain, + offset: faCompassDrafting, "sweep-path-profile": faRoute, - "sweep-hilbert-profiles": faRoute, + "sweep-hilbert-profiles": faRoad, + "wall-from-offsets": faLayerGroup, + "step-export": faDatabase, + "stl-export": faDatabase, + "ifc-export": faDatabase, + "door-export": faDoorOpen, }; - function getExamplesByCategory(category) { - return examples.filter((example) => example.category === category); - } + const examples = [ + { + slug: "primitives/arc", + category: "primitives", + iconKey: "arc", + title: "Arc", + description: "Circular arc with angle span and segmentation control.", + statusLabel: "ready", + chips: ["Radius", "Span"], + footerText: "Radius, Span", + }, + { + slug: "primitives/curve", + category: "primitives", + iconKey: "curve", + title: "Curve", + description: "Control-point curve for route and profile sketching.", + statusLabel: "ready", + chips: ["Sag", "Lift"], + footerText: "Sag, Lift", + }, + { + slug: "primitives/line", + category: "primitives", + iconKey: "line", + title: "Line", + description: "Two-point line primitive with direct endpoint control.", + statusLabel: "ready", + chips: ["Length", "Angle"], + footerText: "Length, Angle", + }, + { + slug: "primitives/polyline", + category: "primitives", + iconKey: "polyline", + title: "Polyline", + description: "Open and closed path definitions for profile work.", + statusLabel: "ready", + chips: ["Closure", "Span"], + footerText: "Closure, Span", + }, + { + slug: "primitives/rectangle", + category: "primitives", + iconKey: "rectangle", + title: "Rectangle", + description: "Parametric rectangular primitive for base profiles.", + statusLabel: "ready", + chips: ["Width", "Breadth"], + footerText: "Width, Breadth", + }, + { + slug: "shapes/cuboid", + category: "shapes", + iconKey: "cuboid", + title: "Cuboid", + description: "Rectangular solid for rooms, equipment blocks and massing.", + statusLabel: "ready", + chips: ["Width", "Height", "Depth"], + footerText: "Width, Height, Depth", + }, + { + slug: "shapes/cylinder", + category: "shapes", + iconKey: "cylinder", + title: "Cylinder", + description: "Cylindrical volume for ducts, pipes and mechanical shafts.", + statusLabel: "ready", + chips: ["Radius", "Height", "Segments"], + footerText: "Radius, Height, Segments", + }, + { + slug: "shapes/opening", + category: "shapes", + iconKey: "opening", + title: "Opening", + description: "Opening helper volume for void and penetration previews.", + statusLabel: "ready", + chips: ["Width", "Height", "Depth"], + footerText: "Width, Height, Depth", + }, + { + slug: "shapes/polygon", + category: "shapes", + iconKey: "polygon", + title: "Polygon", + description: "Planar polygon triangulation for surfaces and slabs.", + statusLabel: "ready", + chips: ["Sides", "Radius"], + footerText: "Sides, Radius", + }, + { + slug: "shapes/polygon-suite", + category: "shapes", + iconKey: "polygon-suite", + title: "Polygon Suite", + description: "Dataset-backed polygon validation with concave, performance, and multi-hole cases.", + statusLabel: "ready", + chips: ["Holes", "Concavity"], + footerText: "Holes, Concavity", + }, + { + slug: "shapes/sphere", + category: "shapes", + iconKey: "sphere", + title: "Sphere", + description: "UV sphere for equipment envelopes and clearance studies.", + statusLabel: "ready", + chips: ["Radius", "Segments"], + footerText: "Radius, Segments", + }, + { + slug: "shapes/sweep", + category: "shapes", + iconKey: "sweep", + title: "Sweep", + description: "Profile along path sweep for framing and custom sections.", + statusLabel: "ready", + chips: ["Path", "Caps"], + footerText: "Path, Caps", + }, + { + slug: "shapes/wedge", + category: "shapes", + iconKey: "wedge", + title: "Wedge", + description: "Tapered solid for ramps and sloped technical elements.", + statusLabel: "ready", + chips: ["Width", "Height", "Depth"], + footerText: "Width, Height, Depth", + }, + { + slug: "operations/door-export", + category: "operations", + iconKey: "door-export", + title: "Door Export", + description: "Door panel as Cuboid plus frame from swept profile, exported through STL, STEP and IFC.", + statusLabel: "ready", + chips: ["Door Panel", "Frame Profile", "IFC"], + footerText: "Door Assembly", + }, + { + slug: "operations/ifc-export", + category: "operations", + iconKey: "ifc-export", + title: "IFC Export", + description: "IFC4 export with user-side semantics sidecar and optional web-ifc parse probe.", + statusLabel: "ready", + chips: ["Semantics JSON", "IFC4"], + footerText: "BIM Export", + }, + { + slug: "operations/offset", + category: "operations", + iconKey: "offset", + title: "Offset", + description: "Offset generation with acute-corner and bevel parameters.", + statusLabel: "ready", + chips: ["Offset", "Bevel"], + footerText: "Offset, Bevel", + }, + { + slug: "operations/step-export", + category: "operations", + iconKey: "step-export", + title: "STEP Export", + description: "STEP Part-21 export workflow with scene-level reporting and download.", + statusLabel: "ready", + chips: ["STEP", "Shape"], + footerText: "Scene Export", + }, + { + slug: "operations/stl-export", + category: "operations", + iconKey: "stl-export", + title: "STL Export", + description: "Binary STL export workflow with scene-level reporting and download.", + statusLabel: "ready", + chips: ["Binary STL", "Shape"], + footerText: "Scene Export", + }, + { + slug: "operations/sweep-hilbert-profiles", + category: "operations", + iconKey: "sweep-hilbert-profiles", + title: "Sweep Hilbert Profiles", + description: "Locked Hilbert3D path with switchable kernel and custom section profiles.", + statusLabel: "ready", + chips: ["Hilbert Path", "Profiles", "Sweep"], + footerText: "Profile Type, Caps, Outlines", + }, + { + slug: "operations/sweep-path-profile", + category: "operations", + iconKey: "sweep-path-profile", + title: "Sweep Path + Profile", + description: "Operation-level sweep from path primitive plus profile primitive.", + statusLabel: "ready", + chips: ["Path", "Caps"], + footerText: "Path, Caps", + }, + { + slug: "operations/wall-from-offsets", + category: "operations", + iconKey: "wall-from-offsets", + title: "Wall from Offsets", + description: "Composite wall profile assembled from offset centerlines.", + statusLabel: "ready", + chips: ["Offsets", "Thickness"], + footerText: "Composite Wall", + }, + ]; - function getExampleIconMarkup(slug) { - const key = slug.split("/").pop() || slug; - const selected = iconBySlug[key] || faShapes; - return icon(selected).html.join(""); + function getIconMarkup(iconKey) { + const iconDefinition = iconMap[iconKey] || faShapes; + return icon(iconDefinition).html.join(""); } function getDiscordIconMarkup() { - return ` - - `; - } - - const app = document.getElementById("app"); - if (!app) { - throw new Error("Missing #app container"); + return ( + '" + ); + } + + function createCard(example) { + const article = document.createElement("article"); + article.className = "og-spec-card"; + + const chipsMarkup = example.chips + .map((chip) => '' + chip + "") + .join(""); + + article.innerHTML = + '
' + + '" + + '

' + example.statusLabel + "

" + + "
" + + "
" + + '

' + example.title + "

" + + '

' + example.description + "

" + + "
" + + '
' + chipsMarkup + "
" + + '"; + + return article; } const shell = document.createElement("main"); shell.className = "og-specs-shell"; - - const head = document.createElement("header"); - head.className = "og-specs-head"; - head.innerHTML = ` -
-

OpenGeometry Technical Sandbox

-

Examples

-
-

Build command: npm --prefix main/opengeometry-three run build-example-three

- `; - shell.appendChild(head); - - for (const category of ["primitives", "shapes", "operations"]) { - const items = getExamplesByCategory(category); + shell.innerHTML = + '
' + + "
" + + '

OpenGeometry Three Example Catalog

' + + '

Examples

' + + "
" + + '

Standalone Vite-served pages for primitives, shapes, and operation workflows. Read any example in one file and open it directly from this catalog.

' + + "
"; + + Object.keys(categoryLabels).forEach((category) => { + const sectionExamples = examples.filter((example) => example.category === category); const section = document.createElement("section"); section.className = "og-specs-section"; - const heading = document.createElement("div"); - heading.className = "og-specs-section-head"; - heading.innerHTML = ` -

${categoryLabels[category]}

-

${items.length} items

- `; - section.appendChild(heading); + const head = document.createElement("div"); + head.className = "og-specs-section-head"; + head.innerHTML = + '

' + categoryLabels[category] + "

" + + '

' + sectionExamples.length + " items

"; const grid = document.createElement("div"); grid.className = "og-specs-grid"; + sectionExamples.forEach((example) => { + grid.appendChild(createCard(example)); + }); - for (const example of items) { - const card = document.createElement("article"); - card.className = "og-spec-card"; - const chips = example.chips - .map((chip) => `
${chip}
`) - .join(""); - - card.innerHTML = ` -
-
${getExampleIconMarkup(example.slug)}
-

${example.statusLabel}

-
-
-

${example.title}

-

${example.description}

-
-
${chips}
- - `; - - grid.appendChild(card); - } - + section.appendChild(head); section.appendChild(grid); shell.appendChild(section); - } + }); const support = document.createElement("section"); support.className = "og-support"; - support.innerHTML = ` -
-

Need Help?

-

Didn’t find what you were looking for?

-

Reach out to us on Discord and tell us what example or workflow you want next.

-
- ${getDiscordIconMarkup()}Join Discord - `; + support.innerHTML = + '
' + + '

Need Help

' + + '

Talk to the OpenGeometry team

' + + '

Use Discord for example issues, geometry questions, and requests for new standalone demos.

' + + "
" + + '' + + getDiscordIconMarkup() + + "Join Discord" + + ""; shell.appendChild(support); app.replaceChildren(shell); diff --git a/main/opengeometry-three/examples-vite/operations/door-export.html b/main/opengeometry-three/examples-vite/operations/door-export.html new file mode 100644 index 0000000..90fd9f5 --- /dev/null +++ b/main/opengeometry-three/examples-vite/operations/door-export.html @@ -0,0 +1,726 @@ + + + + OpenGeometry Door Export Example + + + + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/operations/ifc-export.html b/main/opengeometry-three/examples-vite/operations/ifc-export.html new file mode 100644 index 0000000..bd0bc3e --- /dev/null +++ b/main/opengeometry-three/examples-vite/operations/ifc-export.html @@ -0,0 +1,668 @@ + + + + OpenGeometry IFC Export Example + + + + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/operations/step-export.html b/main/opengeometry-three/examples-vite/operations/step-export.html new file mode 100644 index 0000000..0a67288 --- /dev/null +++ b/main/opengeometry-three/examples-vite/operations/step-export.html @@ -0,0 +1,579 @@ + + + + OpenGeometry STEP Export Example + + + + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/operations/stl-export.html b/main/opengeometry-three/examples-vite/operations/stl-export.html new file mode 100644 index 0000000..85b7875 --- /dev/null +++ b/main/opengeometry-three/examples-vite/operations/stl-export.html @@ -0,0 +1,570 @@ + + + + OpenGeometry STL Export Example + + + + + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/tsconfig.json b/main/opengeometry-three/examples-vite/tsconfig.json new file mode 100644 index 0000000..7561cd8 --- /dev/null +++ b/main/opengeometry-three/examples-vite/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "baseUrl": ".", + "paths": { + "@og-three": ["../index.ts"] + }, + "types": ["vite/client"], + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/main/opengeometry-three/examples/ifc-export.html b/main/opengeometry-three/examples/ifc-export.html new file mode 100644 index 0000000..9b4d73e --- /dev/null +++ b/main/opengeometry-three/examples/ifc-export.html @@ -0,0 +1,338 @@ + + + + + + OpenGeometry Three - IFC Export Example + + + +
+ + + + + diff --git a/main/opengeometry-three/examples/step-export.html b/main/opengeometry-three/examples/step-export.html new file mode 100644 index 0000000..2b70e5b --- /dev/null +++ b/main/opengeometry-three/examples/step-export.html @@ -0,0 +1,267 @@ + + + + + + OpenGeometry Three - STEP Export Example + + + +
+ + + + + diff --git a/main/opengeometry-three/examples/stl-export.html b/main/opengeometry-three/examples/stl-export.html new file mode 100644 index 0000000..77e214e --- /dev/null +++ b/main/opengeometry-three/examples/stl-export.html @@ -0,0 +1,263 @@ + + + + + + OpenGeometry Three - STL Export Example + + + +
+ + + + + diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 2a61930..96439f3 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -3,7 +3,8 @@ * @module @opengeometry/kernel-three */ import init, { - Vector3 + OGSceneManager, + Vector3, } from "../opengeometry/pkg/opengeometry"; // Vector3 is also available in opengeometry package // import { Vector3 } from "@opengeometry/openmaths"; @@ -12,6 +13,21 @@ import { OPEN_GEOMETRY_THREE_VERSION, OpenGeometryOptions } from "./src/base-typ export type OUTLINE_TYPE = "front" | "side" | "top"; +export interface OGStlExportResult { + bytes: Uint8Array; + reportJson: string; +} + +export interface OGStepExportResult { + text: string; + reportJson: string; +} + +export interface OGIfcExportResult { + text: string; + reportJson: string; +} + export class OpenGeometry { static version = OPEN_GEOMETRY_THREE_VERSION; static instance: OpenGeometry | null = null; @@ -76,6 +92,7 @@ export class OpenGeometry { } export { + OGSceneManager, Vector3, SpotLabel, } diff --git a/main/opengeometry/Cargo.toml b/main/opengeometry/Cargo.toml index c0dd20e..d5dfa88 100644 --- a/main/opengeometry/Cargo.toml +++ b/main/opengeometry/Cargo.toml @@ -17,10 +17,11 @@ serde-wasm-bindgen = "0.4" serde_json = "1.0.127" web-sys = { version = "0.3", features = ["console"] } openmaths = "0.2.4" -uuid = { version = "1.17.0", features = ["v4", "serde", "js"] } +uuid = { version = "1.17.0", features = ["v4", "v5", "serde", "js"] } getrandom = { version = "0.2", features = ["js"] } earcutr = "=0.3.0" dxf = "0.6.0" +stl_io = "0.8" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] printpdf = "0.5" diff --git a/main/opengeometry/examples/export_step_ifc_fixture.rs b/main/opengeometry/examples/export_step_ifc_fixture.rs new file mode 100644 index 0000000..e171444 --- /dev/null +++ b/main/opengeometry/examples/export_step_ifc_fixture.rs @@ -0,0 +1,51 @@ +use opengeometry::export::{ + export_brep_to_ifc_text, export_brep_to_step_text, IfcExportConfig, StepExportConfig, +}; +use opengeometry::primitives::cuboid::OGCuboid; +use openmaths::Vector3; +use std::env; +use std::fs; +use std::path::PathBuf; + +fn js_err_to_string(err: wasm_bindgen::JsValue) -> String { + err.as_string() + .unwrap_or_else(|| "unknown js error".to_string()) +} + +fn main() -> Result<(), Box> { + let out_dir = env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target/export-validation")); + fs::create_dir_all(&out_dir)?; + + let mut cuboid = OGCuboid::new("validation-cuboid".to_string()); + cuboid + .set_config(Vector3::new(0.0, 1.0, 0.0), 2.0, 2.0, 2.0) + .map_err(js_err_to_string)?; + + let step_config = StepExportConfig::default(); + let ifc_config = IfcExportConfig::default(); + + let (step_text, step_report) = export_brep_to_step_text(cuboid.brep(), &step_config)?; + let (ifc_text, ifc_report) = export_brep_to_ifc_text(cuboid.brep(), &ifc_config)?; + + let step_path = out_dir.join("validation-cuboid.step"); + let ifc_path = out_dir.join("validation-cuboid.ifc"); + + fs::write(&step_path, step_text)?; + fs::write(&ifc_path, ifc_text)?; + + println!("STEP fixture: {}", step_path.display()); + println!("IFC fixture: {}", ifc_path.display()); + println!( + "STEP report: solids={}, triangles={}, skipped_entities={}", + step_report.exported_solids, step_report.exported_triangles, step_report.skipped_entities + ); + println!( + "IFC report: elements={}, triangles={}, skipped_entities={}", + ifc_report.exported_elements, ifc_report.exported_triangles, ifc_report.skipped_entities + ); + + Ok(()) +} diff --git a/main/opengeometry/sandbox-native/src/main.rs b/main/opengeometry/sandbox-native/src/main.rs index 881b8e9..17081b9 100644 --- a/main/opengeometry/sandbox-native/src/main.rs +++ b/main/opengeometry/sandbox-native/src/main.rs @@ -191,8 +191,12 @@ fn line_buffer_from_brep(brep: &Brep) -> Vec { let mut line_buffer = Vec::with_capacity(brep.edges.len() * 6); for edge in &brep.edges { - let start = brep.vertices.get(edge.v1 as usize); - let end = brep.vertices.get(edge.v2 as usize); + let Some((start_id, end_id)) = brep.get_edge_endpoints(edge.id) else { + continue; + }; + + let start = brep.vertices.get(start_id as usize); + let end = brep.vertices.get(end_id as usize); if let (Some(start), Some(end)) = (start, end) { line_buffer.push(start.position.x); diff --git a/main/opengeometry/scripts/validate_ifc_fixture.py b/main/opengeometry/scripts/validate_ifc_fixture.py new file mode 100644 index 0000000..625223e --- /dev/null +++ b/main/opengeometry/scripts/validate_ifc_fixture.py @@ -0,0 +1,26 @@ +from pathlib import Path + +import ifcopenshell + + +def main() -> None: + ifc_path = Path("main/opengeometry/target/export-validation/validation-cuboid.ifc") + if not ifc_path.exists(): + raise SystemExit(f"IFC fixture not found: {ifc_path}") + + model = ifcopenshell.open(str(ifc_path)) + if model is None: + raise SystemExit("IfcOpenShell failed to open IFC fixture") + + projects = model.by_type("IfcProject") + products = model.by_type("IfcProduct") + if not projects: + raise SystemExit("IFC fixture missing IfcProject") + if not products: + raise SystemExit("IFC fixture missing IfcProduct") + + print(f"Validated IFC fixture: projects={len(projects)} products={len(products)}") + + +if __name__ == "__main__": + main() diff --git a/main/opengeometry/src/export/ifc.rs b/main/opengeometry/src/export/ifc.rs new file mode 100644 index 0000000..df5402c --- /dev/null +++ b/main/opengeometry/src/export/ifc.rs @@ -0,0 +1,981 @@ +use std::collections::HashMap; +use std::fmt; + +use openmaths::Vector3; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::brep::Brep; +use crate::operations::triangulate::triangulate_polygon_with_holes; + +use super::part21::{sanitize_string_literal, Part21Writer}; + +const IFC_LENGTH_EPSILON: f64 = 1.0e-12; +const IFC_CLASS_PROXY: &str = "IFCBUILDINGELEMENTPROXY"; +const IFC_ALLOWED_CLASSES: [&str; 12] = [ + IFC_CLASS_PROXY, + "IFCWALL", + "IFCSLAB", + "IFCCOLUMN", + "IFCBEAM", + "IFCMEMBER", + "IFCDOOR", + "IFCWINDOW", + "IFCROOF", + "IFCSTAIR", + "IFCRAILING", + "IFCFOOTING", +]; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum IfcErrorPolicy { + Strict, + BestEffort, +} + +impl Default for IfcErrorPolicy { + fn default() -> Self { + Self::BestEffort + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum IfcSchemaVersion { + Ifc4Add2, +} + +impl Default for IfcSchemaVersion { + fn default() -> Self { + Self::Ifc4Add2 + } +} + +impl IfcSchemaVersion { + fn as_file_schema(self) -> &'static str { + match self { + Self::Ifc4Add2 => "IFC4", + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct IfcEntitySemantics { + pub ifc_class: Option, + pub name: Option, + pub description: Option, + pub object_type: Option, + pub tag: Option, + #[serde(default)] + pub property_sets: HashMap>, + #[serde(default)] + pub quantity_sets: HashMap>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct IfcExportConfig { + pub schema: IfcSchemaVersion, + pub project_name: Option, + pub site_name: Option, + pub building_name: Option, + pub storey_name: Option, + pub scale: f64, + pub error_policy: IfcErrorPolicy, + pub validate_topology: bool, + pub require_closed_shell: bool, + pub semantics: Option>, +} + +impl Default for IfcExportConfig { + fn default() -> Self { + Self { + schema: IfcSchemaVersion::default(), + project_name: Some("OpenGeometry Project".to_string()), + site_name: Some("OpenGeometry Site".to_string()), + building_name: Some("OpenGeometry Building".to_string()), + storey_name: Some("OpenGeometry Storey".to_string()), + scale: 1.0, + error_policy: IfcErrorPolicy::BestEffort, + validate_topology: true, + require_closed_shell: true, + semantics: None, + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct IfcExportReport { + pub input_breps: usize, + pub input_faces: usize, + pub exported_elements: usize, + pub exported_faces: usize, + pub exported_triangles: usize, + pub skipped_entities: usize, + pub skipped_faces: usize, + pub topology_errors: usize, + pub semantics_applied: usize, + pub proxy_fallbacks: usize, + pub property_sets_written: usize, + pub quantity_sets_written: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum IfcExportError { + EmptyInput, + InvalidTopology(String), + UnsupportedEntity(String), + InvalidSemantics(String), + MeshGeneration(String), + Serialization(String), + Io(String), +} + +impl fmt::Display for IfcExportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IfcExportError::EmptyInput => write!(f, "No BREP input provided for IFC export"), + IfcExportError::InvalidTopology(msg) => write!(f, "Invalid topology: {}", msg), + IfcExportError::UnsupportedEntity(msg) => write!(f, "Unsupported BREP: {}", msg), + IfcExportError::InvalidSemantics(msg) => write!(f, "Invalid IFC semantics: {}", msg), + IfcExportError::MeshGeneration(msg) => write!(f, "Mesh generation failed: {}", msg), + IfcExportError::Serialization(msg) => write!(f, "IFC serialization failed: {}", msg), + IfcExportError::Io(msg) => write!(f, "IFC I/O failed: {}", msg), + } + } +} + +impl std::error::Error for IfcExportError {} + +#[derive(Clone, Copy)] +pub struct IfcEntityInput<'a> { + pub entity_id: &'a str, + pub kind: &'a str, + pub brep: &'a Brep, +} + +#[derive(Clone)] +struct IfcOwnedEntity<'a> { + entity_id: String, + kind: String, + brep: &'a Brep, +} + +#[derive(Clone)] +struct TessellatedMesh { + points: Vec, + faces: Vec<[usize; 3]>, +} + +pub fn export_brep_to_ifc_text( + brep: &Brep, + config: &IfcExportConfig, +) -> Result<(String, IfcExportReport), IfcExportError> { + let owned = vec![IfcOwnedEntity { + entity_id: "brep-0".to_string(), + kind: "BREP".to_string(), + brep, + }]; + export_owned_entities_to_ifc_text(&owned, config) +} + +pub fn export_breps_to_ifc_text<'a, I>( + breps: I, + config: &IfcExportConfig, +) -> Result<(String, IfcExportReport), IfcExportError> +where + I: IntoIterator, +{ + let mut owned = Vec::new(); + for (index, brep) in breps.into_iter().enumerate() { + owned.push(IfcOwnedEntity { + entity_id: format!("brep-{}", index), + kind: "BREP".to_string(), + brep, + }); + } + + export_owned_entities_to_ifc_text(&owned, config) +} + +pub fn export_scene_entities_to_ifc_text<'a, I>( + entities: I, + config: &IfcExportConfig, +) -> Result<(String, IfcExportReport), IfcExportError> +where + I: IntoIterator>, +{ + let owned: Vec> = entities + .into_iter() + .map(|entity| IfcOwnedEntity { + entity_id: entity.entity_id.to_string(), + kind: entity.kind.to_string(), + brep: entity.brep, + }) + .collect(); + + export_owned_entities_to_ifc_text(&owned, config) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn export_brep_to_ifc_file( + brep: &Brep, + file_path: &str, + config: &IfcExportConfig, +) -> Result { + let (text, report) = export_brep_to_ifc_text(brep, config)?; + std::fs::write(file_path, text).map_err(|err| IfcExportError::Io(err.to_string()))?; + Ok(report) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn export_breps_to_ifc_file<'a, I>( + breps: I, + file_path: &str, + config: &IfcExportConfig, +) -> Result +where + I: IntoIterator, +{ + let (text, report) = export_breps_to_ifc_text(breps, config)?; + std::fs::write(file_path, text).map_err(|err| IfcExportError::Io(err.to_string()))?; + Ok(report) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn export_scene_entities_to_ifc_file<'a, I>( + entities: I, + file_path: &str, + config: &IfcExportConfig, +) -> Result +where + I: IntoIterator>, +{ + let (text, report) = export_scene_entities_to_ifc_text(entities, config)?; + std::fs::write(file_path, text).map_err(|err| IfcExportError::Io(err.to_string()))?; + Ok(report) +} + +fn export_owned_entities_to_ifc_text<'a>( + entities: &[IfcOwnedEntity<'a>], + config: &IfcExportConfig, +) -> Result<(String, IfcExportReport), IfcExportError> { + let scale = validate_config(config)?; + + if entities.is_empty() { + return Err(IfcExportError::EmptyInput); + } + + let mut report = IfcExportReport { + input_breps: entities.len(), + ..IfcExportReport::default() + }; + + let project_name = config + .project_name + .clone() + .unwrap_or_else(|| "OpenGeometry Project".to_string()); + + let mut writer = Part21Writer::new(config.schema.as_file_schema()); + writer.set_description("ViewDefinition [CoordinationView]"); + writer.set_file_name(project_name.clone()); + + let origin = writer.add_entity("IFCCARTESIANPOINT((0.,0.,0.))"); + let axis_z = writer.add_entity("IFCDIRECTION((0.,0.,1.))"); + let axis_x = writer.add_entity("IFCDIRECTION((1.,0.,0.))"); + let world_axis = writer.add_entity(format!( + "IFCAXIS2PLACEMENT3D({},{},{})", + Part21Writer::reference(origin), + Part21Writer::reference(axis_z), + Part21Writer::reference(axis_x) + )); + + let geom_context = writer.add_entity(format!( + "IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,{},$)", + Part21Writer::reference(world_axis) + )); + + let length_unit = writer.add_entity("IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.)"); + let area_unit = writer.add_entity("IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.)"); + let volume_unit = writer.add_entity("IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.)"); + let angle_unit = writer.add_entity("IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.)"); + let unit_assignment = writer.add_entity(format!( + "IFCUNITASSIGNMENT(({}, {}, {}, {}))", + Part21Writer::reference(length_unit), + Part21Writer::reference(area_unit), + Part21Writer::reference(volume_unit), + Part21Writer::reference(angle_unit) + )); + + let project = writer.add_entity(format!( + "IFCPROJECT('{}',$,'{}',$,$,$,$,({}),{})", + ifc_guid("project"), + sanitize_string_literal(&project_name), + Part21Writer::reference(geom_context), + Part21Writer::reference(unit_assignment) + )); + + let site_axis = writer.add_entity(format!( + "IFCLOCALPLACEMENT($,{})", + Part21Writer::reference(world_axis) + )); + let building_axis = writer.add_entity(format!( + "IFCLOCALPLACEMENT({},{})", + Part21Writer::reference(site_axis), + Part21Writer::reference(world_axis) + )); + let storey_axis = writer.add_entity(format!( + "IFCLOCALPLACEMENT({},{})", + Part21Writer::reference(building_axis), + Part21Writer::reference(world_axis) + )); + + let site = writer.add_entity(format!( + "IFCSITE('{}',$,'{}',$,$,{},$,$,.ELEMENT.,$,$,$,$,$)", + ifc_guid("site"), + sanitize_string_literal( + &config + .site_name + .clone() + .unwrap_or_else(|| "OpenGeometry Site".to_string()) + ), + Part21Writer::reference(site_axis) + )); + + let building = writer.add_entity(format!( + "IFCBUILDING('{}',$,'{}',$,$,{},$,$,.ELEMENT.,$,$,$)", + ifc_guid("building"), + sanitize_string_literal( + &config + .building_name + .clone() + .unwrap_or_else(|| "OpenGeometry Building".to_string()) + ), + Part21Writer::reference(building_axis) + )); + + let storey = writer.add_entity(format!( + "IFCBUILDINGSTOREY('{}',$,'{}',$,$,{},$,$,.ELEMENT.,0.)", + ifc_guid("storey"), + sanitize_string_literal( + &config + .storey_name + .clone() + .unwrap_or_else(|| "OpenGeometry Storey".to_string()) + ), + Part21Writer::reference(storey_axis) + )); + + writer.add_entity(format!( + "IFCRELAGGREGATES('{}',$,$,$,{},({}))", + ifc_guid("rel-project-site"), + Part21Writer::reference(project), + Part21Writer::reference(site) + )); + + writer.add_entity(format!( + "IFCRELAGGREGATES('{}',$,$,$,{},({}))", + ifc_guid("rel-site-building"), + Part21Writer::reference(site), + Part21Writer::reference(building) + )); + + writer.add_entity(format!( + "IFCRELAGGREGATES('{}',$,$,$,{},({}))", + ifc_guid("rel-building-storey"), + Part21Writer::reference(building), + Part21Writer::reference(storey) + )); + + let mut element_ids = Vec::new(); + + for entity in entities { + let brep = entity.brep; + + if config.validate_topology { + if let Err(error) = brep.validate_topology() { + if config.error_policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::InvalidTopology(format!( + "Entity '{}' failed topology validation: {}", + entity.entity_id, error + ))); + } + report.topology_errors += 1; + report.skipped_entities += 1; + continue; + } + } + + if config.require_closed_shell && !is_closed_solid(brep) { + if config.error_policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::UnsupportedEntity(format!( + "Entity '{}' is not a closed-shell solid", + entity.entity_id + ))); + } + report.skipped_entities += 1; + continue; + } + + let mesh = triangulate_entity_mesh( + entity, + scale, + config.error_policy, + &mut report, + format!("entity '{}'", entity.entity_id), + )?; + + if mesh.faces.is_empty() || mesh.points.is_empty() { + if config.error_policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "Entity '{}' generated no exportable mesh", + entity.entity_id + ))); + } + report.skipped_entities += 1; + continue; + } + + let semantics = config + .semantics + .as_ref() + .and_then(|map| map.get(&entity.entity_id)); + + let class_name = resolve_ifc_class(&entity.entity_id, semantics, config, &mut report)?; + + let mesh_point_list = writer.add_entity(format!( + "IFCCARTESIANPOINTLIST3D({})", + format_ifc_coord_list(&mesh.points) + )); + + let mesh_faceset = writer.add_entity(format!( + "IFCTRIANGULATEDFACESET({},$,.T.,{},$)", + Part21Writer::reference(mesh_point_list), + format_ifc_face_index_list(&mesh.faces) + )); + + let shape_representation = writer.add_entity(format!( + "IFCSHAPEREPRESENTATION({},'Body','Tessellation',({}))", + Part21Writer::reference(geom_context), + Part21Writer::reference(mesh_faceset) + )); + + let definition_shape = writer.add_entity(format!( + "IFCPRODUCTDEFINITIONSHAPE($,$,({}))", + Part21Writer::reference(shape_representation) + )); + + let placement = writer.add_entity(format!( + "IFCLOCALPLACEMENT({},{})", + Part21Writer::reference(storey_axis), + Part21Writer::reference(world_axis) + )); + + let default_name = format!("{}-{}", entity.kind, entity.entity_id); + let name = semantics + .and_then(|sem| sem.name.clone()) + .unwrap_or(default_name); + let description = semantics + .and_then(|sem| sem.description.clone()) + .unwrap_or_default(); + let object_type = semantics + .and_then(|sem| sem.object_type.clone()) + .unwrap_or_else(|| entity.kind.clone()); + let tag = semantics + .and_then(|sem| sem.tag.clone()) + .unwrap_or_else(|| entity.entity_id.clone()); + + let element_expr = format!( + "{}('{}',$,'{}',{},'{}',{},{},'{}',.NOTDEFINED.)", + class_name, + ifc_guid(&format!("element-{}", entity.entity_id)), + sanitize_string_literal(&name), + if description.is_empty() { + "$".to_string() + } else { + format!("'{}'", sanitize_string_literal(&description)) + }, + sanitize_string_literal(&object_type), + Part21Writer::reference(placement), + Part21Writer::reference(definition_shape), + sanitize_string_literal(&tag) + ); + + let element_id = writer.add_entity(element_expr); + element_ids.push(element_id); + + if let Some(semantics) = semantics { + write_property_sets( + &mut writer, + element_id, + &entity.entity_id, + semantics, + &mut report, + ); + write_quantity_sets( + &mut writer, + element_id, + &entity.entity_id, + semantics, + &mut report, + ); + } + + report.exported_elements += 1; + report.exported_triangles += mesh.faces.len(); + report.exported_faces += mesh.faces.len(); + } + + if element_ids.is_empty() { + return Err(IfcExportError::MeshGeneration( + "No elements were exported from the provided BREP inputs".to_string(), + )); + } + + writer.add_entity(format!( + "IFCRELCONTAINEDINSPATIALSTRUCTURE('{}',$,'ContainedInStorey',$,({}),{})", + ifc_guid("rel-contained-storey"), + join_refs(&element_ids), + Part21Writer::reference(storey) + )); + + let text = writer.build().map_err(IfcExportError::Serialization)?; + Ok((text, report)) +} + +fn validate_config(config: &IfcExportConfig) -> Result { + if !config.scale.is_finite() || config.scale <= 0.0 { + return Err(IfcExportError::MeshGeneration( + "IFC scale must be a finite positive value".to_string(), + )); + } + Ok(config.scale) +} + +fn is_closed_solid(brep: &Brep) -> bool { + if brep.faces.is_empty() || brep.edges.is_empty() { + return false; + } + + if !brep.shells.is_empty() && brep.shells.iter().all(|shell| !shell.is_closed) { + return false; + } + + brep.edges.iter().all(|edge| edge.twin_halfedge.is_some()) +} + +fn triangulate_entity_mesh( + entity: &IfcOwnedEntity<'_>, + scale: f64, + policy: IfcErrorPolicy, + report: &mut IfcExportReport, + label: String, +) -> Result { + let mut points = Vec::::new(); + let mut point_map = HashMap::::new(); + let mut faces = Vec::<[usize; 3]>::new(); + + for face in &entity.brep.faces { + report.input_faces += 1; + + let (outer_vertices, holes_vertices) = + entity.brep.get_vertices_and_holes_by_face_id(face.id); + + if outer_vertices.len() < 3 { + if policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "{} face {} has fewer than 3 vertices", + label, face.id + ))); + } + report.skipped_faces += 1; + continue; + } + + if holes_vertices.iter().any(|hole| hole.len() < 3) { + if policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "{} face {} has invalid hole loops", + label, face.id + ))); + } + report.skipped_faces += 1; + continue; + } + + let triangle_indices = triangulate_polygon_with_holes(&outer_vertices, &holes_vertices); + if triangle_indices.is_empty() { + if policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "{} face {} produced no triangles", + label, face.id + ))); + } + report.skipped_faces += 1; + continue; + } + + let mut all_vertices = outer_vertices; + for hole in holes_vertices { + all_vertices.extend(hole); + } + + let mut face_has_triangle = false; + + for triangle in triangle_indices { + let Some((&a, &b, &c)) = all_vertices + .get(triangle[0]) + .zip(all_vertices.get(triangle[1])) + .zip(all_vertices.get(triangle[2])) + .map(|((a, b), c)| (a, b, c)) + else { + if policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "{} face {} emitted out-of-range triangle indices", + label, face.id + ))); + } + continue; + }; + + if !is_finite_vec3(a) || !is_finite_vec3(b) || !is_finite_vec3(c) { + if policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "{} face {} has non-finite coordinates", + label, face.id + ))); + } + continue; + } + + let scaled = [ + Vector3::new(a.x * scale, a.y * scale, a.z * scale), + Vector3::new(b.x * scale, b.y * scale, b.z * scale), + Vector3::new(c.x * scale, c.y * scale, c.z * scale), + ]; + + if is_degenerate_triangle(scaled[0], scaled[1], scaled[2]) { + if policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "{} face {} contains degenerate triangle", + label, face.id + ))); + } + continue; + } + + let i0 = get_or_create_mesh_point(&mut points, &mut point_map, scaled[0]); + let i1 = get_or_create_mesh_point(&mut points, &mut point_map, scaled[1]); + let i2 = get_or_create_mesh_point(&mut points, &mut point_map, scaled[2]); + + faces.push([i0 + 1, i1 + 1, i2 + 1]); + face_has_triangle = true; + } + + if !face_has_triangle { + if policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::MeshGeneration(format!( + "{} face {} yielded no valid triangles", + label, face.id + ))); + } + report.skipped_faces += 1; + } + } + + Ok(TessellatedMesh { points, faces }) +} + +fn get_or_create_mesh_point( + points: &mut Vec, + point_map: &mut HashMap, + point: Vector3, +) -> usize { + let key = format!("{:.9}|{:.9}|{:.9}", point.x, point.y, point.z); + if let Some(index) = point_map.get(&key) { + return *index; + } + + let index = points.len(); + points.push(point); + point_map.insert(key, index); + index +} + +fn resolve_ifc_class( + entity_id: &str, + semantics: Option<&IfcEntitySemantics>, + config: &IfcExportConfig, + report: &mut IfcExportReport, +) -> Result<&'static str, IfcExportError> { + let Some(semantics) = semantics else { + return Ok(IFC_CLASS_PROXY); + }; + + let Some(raw_class) = semantics.ifc_class.as_ref() else { + return Ok(IFC_CLASS_PROXY); + }; + + let normalized = raw_class.trim().to_ascii_uppercase(); + if let Some(class_name) = IFC_ALLOWED_CLASSES + .iter() + .find(|candidate| **candidate == normalized) + .copied() + { + report.semantics_applied += 1; + return Ok(class_name); + } + + if config.error_policy == IfcErrorPolicy::Strict { + return Err(IfcExportError::InvalidSemantics(format!( + "Entity '{}' requested unsupported ifc_class '{}'. Allowed classes: {}", + entity_id, + raw_class, + IFC_ALLOWED_CLASSES.join(", ") + ))); + } + + report.proxy_fallbacks += 1; + Ok(IFC_CLASS_PROXY) +} + +fn write_property_sets( + writer: &mut Part21Writer, + element_id: usize, + entity_id: &str, + semantics: &IfcEntitySemantics, + report: &mut IfcExportReport, +) { + for (set_name, properties) in &semantics.property_sets { + if properties.is_empty() { + continue; + } + + let mut property_ids = Vec::new(); + for (property_name, property_value) in properties { + let property = writer.add_entity(format!( + "IFCPROPERTYSINGLEVALUE('{}',$,IFCTEXT('{}'),$)", + sanitize_string_literal(property_name), + sanitize_string_literal(property_value) + )); + property_ids.push(property); + } + + let property_set = writer.add_entity(format!( + "IFCPROPERTYSET('{}',$,'{}',$,({}))", + ifc_guid(&format!("pset-{}-{}", entity_id, set_name)), + sanitize_string_literal(set_name), + join_refs(&property_ids) + )); + + writer.add_entity(format!( + "IFCRELDEFINESBYPROPERTIES('{}',$,$,$,({}),{})", + ifc_guid(&format!("pset-rel-{}-{}", entity_id, set_name)), + Part21Writer::reference(element_id), + Part21Writer::reference(property_set) + )); + + report.property_sets_written += 1; + } +} + +fn write_quantity_sets( + writer: &mut Part21Writer, + element_id: usize, + entity_id: &str, + semantics: &IfcEntitySemantics, + report: &mut IfcExportReport, +) { + for (set_name, quantities) in &semantics.quantity_sets { + if quantities.is_empty() { + continue; + } + + let mut quantity_ids = Vec::new(); + for (quantity_name, quantity_value) in quantities { + let quantity = writer.add_entity(format!( + "IFCQUANTITYLENGTH('{}',$,$,{},$)", + sanitize_string_literal(quantity_name), + format_real(*quantity_value) + )); + quantity_ids.push(quantity); + } + + let quantity_set = writer.add_entity(format!( + "IFCELEMENTQUANTITY('{}',$,'{}',$,$,({}))", + ifc_guid(&format!("qset-{}-{}", entity_id, set_name)), + sanitize_string_literal(set_name), + join_refs(&quantity_ids) + )); + + writer.add_entity(format!( + "IFCRELDEFINESBYPROPERTIES('{}',$,$,$,({}),{})", + ifc_guid(&format!("qset-rel-{}-{}", entity_id, set_name)), + Part21Writer::reference(element_id), + Part21Writer::reference(quantity_set) + )); + + report.quantity_sets_written += 1; + } +} + +fn join_refs(ids: &[usize]) -> String { + ids.iter() + .map(|id| Part21Writer::reference(*id)) + .collect::>() + .join(",") +} + +fn format_real(value: f64) -> String { + let mut out = format!("{:.9}", value); + while out.contains('.') && out.ends_with('0') { + out.pop(); + } + if out.ends_with('.') { + out.push('0'); + } + out +} + +fn format_ifc_coord_list(points: &[Vector3]) -> String { + let coords = points + .iter() + .map(|point| { + format!( + "({},{},{})", + format_real(point.x), + format_real(point.y), + format_real(point.z) + ) + }) + .collect::>() + .join(","); + + format!("({})", coords) +} + +fn format_ifc_face_index_list(faces: &[[usize; 3]]) -> String { + let entries = faces + .iter() + .map(|face| format!("({},{},{})", face[0], face[1], face[2])) + .collect::>() + .join(","); + + format!("({})", entries) +} + +fn is_finite_vec3(point: Vector3) -> bool { + point.x.is_finite() && point.y.is_finite() && point.z.is_finite() +} + +fn is_degenerate_triangle(a: Vector3, b: Vector3, c: Vector3) -> bool { + let ab = [b.x - a.x, b.y - a.y, b.z - a.z]; + let ac = [c.x - a.x, c.y - a.y, c.z - a.z]; + + let cross = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + + let area_sq = cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]; + !area_sq.is_finite() || area_sq <= IFC_LENGTH_EPSILON +} + +fn ifc_guid(seed: &str) -> String { + const IFC_CHARS: &[u8; 64] = + b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$"; + + let uuid = Uuid::new_v5(&Uuid::NAMESPACE_URL, seed.as_bytes()); + let mut number = u128::from_be_bytes(*uuid.as_bytes()); + let mut out = [b'0'; 22]; + + for index in (0..22).rev() { + out[index] = IFC_CHARS[(number & 63) as usize]; + number >>= 6; + } + + String::from_utf8(out.to_vec()).unwrap_or_else(|_| "0000000000000000000000".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::brep::BrepBuilder; + + fn tetrahedron_brep() -> Brep { + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(0.5, 0.8660254, 0.0), + Vector3::new(0.5, 0.2886751, 0.8164966), + ]); + + builder.add_face(&[0, 2, 1], &[]).unwrap(); + builder.add_face(&[0, 1, 3], &[]).unwrap(); + builder.add_face(&[1, 2, 3], &[]).unwrap(); + builder.add_face(&[2, 0, 3], &[]).unwrap(); + + builder.build().unwrap() + } + + #[test] + fn exports_ifc_spf_document() { + let brep = tetrahedron_brep(); + let (text, report) = + export_brep_to_ifc_text(&brep, &IfcExportConfig::default()).expect("ifc export"); + + assert!(text.starts_with("ISO-10303-21;")); + assert!(text.contains("FILE_SCHEMA(('IFC4'));")); + assert!(text.contains("IFCPROJECT(")); + assert!(text.contains("IFCTRIANGULATEDFACESET(")); + assert!(report.exported_elements >= 1); + assert!(report.exported_triangles >= 4); + } + + #[test] + fn applies_semantics_class_when_supported() { + let brep = tetrahedron_brep(); + + let mut semantics = HashMap::new(); + semantics.insert( + "brep-0".to_string(), + IfcEntitySemantics { + ifc_class: Some("IFCWALL".to_string()), + name: Some("Wall A".to_string()), + ..IfcEntitySemantics::default() + }, + ); + + let config = IfcExportConfig { + semantics: Some(semantics), + ..IfcExportConfig::default() + }; + + let (text, report) = export_brep_to_ifc_text(&brep, &config).expect("ifc export"); + assert!(text.contains("IFCWALL(")); + assert_eq!(report.semantics_applied, 1); + } + + #[test] + fn strict_rejects_invalid_ifc_class() { + let brep = tetrahedron_brep(); + + let mut semantics = HashMap::new(); + semantics.insert( + "brep-0".to_string(), + IfcEntitySemantics { + ifc_class: Some("IFCUNKNOWN".to_string()), + ..IfcEntitySemantics::default() + }, + ); + + let config = IfcExportConfig { + semantics: Some(semantics), + error_policy: IfcErrorPolicy::Strict, + ..IfcExportConfig::default() + }; + + let result = export_brep_to_ifc_text(&brep, &config); + assert!(result.is_err()); + } +} diff --git a/main/opengeometry/src/export/mod.rs b/main/opengeometry/src/export/mod.rs index e55bcf9..fae2ed3 100644 --- a/main/opengeometry/src/export/mod.rs +++ b/main/opengeometry/src/export/mod.rs @@ -1,9 +1,35 @@ +pub mod ifc; +pub mod part21; pub mod projection; +pub mod step; +pub mod stl; #[cfg(not(target_arch = "wasm32"))] pub mod pdf; +pub use ifc::{ + export_brep_to_ifc_text, export_breps_to_ifc_text, export_scene_entities_to_ifc_text, + IfcEntityInput, IfcEntitySemantics, IfcErrorPolicy, IfcExportConfig, IfcExportError, + IfcExportReport, IfcSchemaVersion, +}; pub use projection::{ project_brep_to_scene, CameraParameters, HlrOptions, Line2D, Path2D, ProjectionMode, Scene2D, Scene2DLines, Segment2D, Vec2, }; +pub use step::{ + export_brep_to_step_text, export_breps_to_step_text, StepErrorPolicy, StepExportConfig, + StepExportError, StepExportReport, StepSchema, +}; +pub use stl::{ + export_brep_to_stl_bytes, export_breps_to_stl_bytes, StlErrorPolicy, StlExportConfig, + StlExportError, StlExportReport, +}; + +#[cfg(not(target_arch = "wasm32"))] +pub use ifc::{ + export_brep_to_ifc_file, export_breps_to_ifc_file, export_scene_entities_to_ifc_file, +}; +#[cfg(not(target_arch = "wasm32"))] +pub use step::{export_brep_to_step_file, export_breps_to_step_file}; +#[cfg(not(target_arch = "wasm32"))] +pub use stl::{export_brep_to_stl_file, export_breps_to_stl_file}; diff --git a/main/opengeometry/src/export/part21.rs b/main/opengeometry/src/export/part21.rs new file mode 100644 index 0000000..65afdc3 --- /dev/null +++ b/main/opengeometry/src/export/part21.rs @@ -0,0 +1,216 @@ +use std::collections::HashSet; + +const DEFAULT_PART21_VERSION: &str = "2;1"; + +#[derive(Clone, Debug)] +pub struct Part21Writer { + schema: String, + description: String, + file_name: String, + timestamp: String, + author: String, + organization: String, + preprocessor: String, + originating_system: String, + authorization: String, + next_id: usize, + entries: Vec<(usize, String)>, +} + +impl Part21Writer { + pub fn new(schema: impl Into) -> Self { + Self { + schema: sanitize_string_literal(&schema.into()), + description: "OpenGeometry Export".to_string(), + file_name: "opengeometry-export".to_string(), + timestamp: "1970-01-01T00:00:00".to_string(), + author: "OpenGeometry".to_string(), + organization: "OpenGeometry".to_string(), + preprocessor: "OpenGeometry".to_string(), + originating_system: "OpenGeometry".to_string(), + authorization: String::new(), + next_id: 1, + entries: Vec::new(), + } + } + + pub fn set_description(&mut self, description: impl Into) { + self.description = sanitize_string_literal(&description.into()); + } + + pub fn set_file_name(&mut self, file_name: impl Into) { + self.file_name = sanitize_string_literal(&file_name.into()); + } + + pub fn set_timestamp(&mut self, timestamp: impl Into) { + self.timestamp = sanitize_string_literal(×tamp.into()); + } + + pub fn set_author(&mut self, author: impl Into) { + self.author = sanitize_string_literal(&author.into()); + } + + pub fn set_organization(&mut self, organization: impl Into) { + self.organization = sanitize_string_literal(&organization.into()); + } + + pub fn set_preprocessor(&mut self, preprocessor: impl Into) { + self.preprocessor = sanitize_string_literal(&preprocessor.into()); + } + + pub fn set_originating_system(&mut self, originating_system: impl Into) { + self.originating_system = sanitize_string_literal(&originating_system.into()); + } + + pub fn set_authorization(&mut self, authorization: impl Into) { + self.authorization = sanitize_string_literal(&authorization.into()); + } + + pub fn add_entity(&mut self, expression: impl Into) -> usize { + let id = self.next_id; + self.next_id += 1; + self.entries.push((id, expression.into())); + id + } + + pub fn reference(id: usize) -> String { + format!("#{}", id) + } + + pub fn build(self) -> Result { + if self.entries.is_empty() { + return Err("Part-21 writer has no DATA entities".to_string()); + } + + let mut defined = HashSet::new(); + for (id, _) in &self.entries { + if !defined.insert(*id) { + return Err(format!("Duplicate Part-21 entity id detected: #{}", id)); + } + } + + for (id, expression) in &self.entries { + for reference in extract_references(expression) { + if !defined.contains(&reference) { + return Err(format!( + "Part-21 entity #{} references undefined id #{}", + id, reference + )); + } + } + } + + let mut output = String::new(); + output.push_str("ISO-10303-21;\n"); + output.push_str("HEADER;\n"); + output.push_str(&format!( + "FILE_DESCRIPTION(('{}'),'{}');\n", + self.description, DEFAULT_PART21_VERSION + )); + output.push_str(&format!( + "FILE_NAME('{}','{}',('{}'),('{}'),'{}','{}','{}');\n", + self.file_name, + self.timestamp, + self.author, + self.organization, + self.preprocessor, + self.originating_system, + self.authorization + )); + output.push_str(&format!("FILE_SCHEMA(('{}'));\n", self.schema)); + output.push_str("ENDSEC;\n"); + output.push_str("DATA;\n"); + + for (id, expression) in &self.entries { + output.push_str(&format!("#{}={};\n", id, expression)); + } + + output.push_str("ENDSEC;\n"); + output.push_str("END-ISO-10303-21;\n"); + Ok(output) + } +} + +pub fn sanitize_string_literal(value: &str) -> String { + let mut sanitized = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\'' => sanitized.push_str("''"), + '\n' | '\r' => sanitized.push(' '), + _ if ch.is_ascii() => sanitized.push(ch), + _ => sanitized.push('?'), + } + } + sanitized +} + +fn extract_references(expression: &str) -> Vec { + let bytes = expression.as_bytes(); + let mut refs = Vec::new(); + let mut idx = 0; + + while idx < bytes.len() { + if bytes[idx] != b'#' { + idx += 1; + continue; + } + + idx += 1; + let start = idx; + while idx < bytes.len() && bytes[idx].is_ascii_digit() { + idx += 1; + } + + if idx > start { + let raw = &expression[start..idx]; + if let Ok(id) = raw.parse::() { + refs.push(id); + } + } + } + + refs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_deterministic_part21_document() { + let mut writer = Part21Writer::new("AUTOMOTIVE_DESIGN"); + writer.set_file_name("test"); + let p1 = writer.add_entity("CARTESIAN_POINT('',(0.,0.,0.))"); + let p2 = writer.add_entity("CARTESIAN_POINT('',(1.,0.,0.))"); + writer.add_entity(format!( + "POLY_LOOP('',({},{},{}))", + Part21Writer::reference(p1), + Part21Writer::reference(p2), + Part21Writer::reference(p1) + )); + + let text = writer.build().expect("part21 should build"); + assert!(text.starts_with("ISO-10303-21;")); + assert!(text.contains("FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));")); + assert!(text.contains("#1=CARTESIAN_POINT")); + assert!(text.contains("#2=CARTESIAN_POINT")); + assert!(text.ends_with("END-ISO-10303-21;\n")); + } + + #[test] + fn fails_for_unresolved_reference() { + let mut writer = Part21Writer::new("IFC4"); + writer.add_entity("IFCPROJECT('x',#999,$,$,$,$,$,$)"); + let err = writer + .build() + .expect_err("writer should reject unresolved refs"); + assert!(err.contains("undefined id #999")); + } + + #[test] + fn sanitizes_non_ascii_literals() { + let raw = "A'B\nCø"; + let sanitized = sanitize_string_literal(raw); + assert_eq!(sanitized, "A''B C?"); + } +} diff --git a/main/opengeometry/src/export/step.rs b/main/opengeometry/src/export/step.rs new file mode 100644 index 0000000..473be12 --- /dev/null +++ b/main/opengeometry/src/export/step.rs @@ -0,0 +1,617 @@ +use std::collections::HashMap; +use std::fmt; + +use openmaths::Vector3; +use serde::{Deserialize, Serialize}; + +use crate::brep::Brep; +use crate::operations::triangulate::triangulate_polygon_with_holes; + +use super::part21::{sanitize_string_literal, Part21Writer}; + +const STEP_LENGTH_EPSILON: f64 = 1.0e-12; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum StepErrorPolicy { + Strict, + BestEffort, +} + +impl Default for StepErrorPolicy { + fn default() -> Self { + Self::BestEffort + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum StepSchema { + AutomotiveDesign, +} + +impl Default for StepSchema { + fn default() -> Self { + Self::AutomotiveDesign + } +} + +impl StepSchema { + fn as_file_schema(self) -> &'static str { + match self { + Self::AutomotiveDesign => "AUTOMOTIVE_DESIGN", + } + } + + fn as_application_context(self) -> &'static str { + match self { + Self::AutomotiveDesign => "automotive_design", + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StepExportConfig { + pub schema: StepSchema, + pub product_name: Option, + pub scale: f64, + pub error_policy: StepErrorPolicy, + pub validate_topology: bool, + pub require_closed_shell: bool, +} + +impl Default for StepExportConfig { + fn default() -> Self { + Self { + schema: StepSchema::default(), + product_name: Some("OpenGeometry STEP Export".to_string()), + scale: 1.0, + error_policy: StepErrorPolicy::BestEffort, + validate_topology: true, + require_closed_shell: true, + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct StepExportReport { + pub input_breps: usize, + pub input_faces: usize, + pub exported_solids: usize, + pub exported_faces: usize, + pub exported_triangles: usize, + pub skipped_entities: usize, + pub skipped_faces: usize, + pub topology_errors: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum StepExportError { + EmptyInput, + InvalidTopology(String), + UnsupportedEntity(String), + MeshGeneration(String), + Serialization(String), + Io(String), +} + +impl fmt::Display for StepExportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StepExportError::EmptyInput => write!(f, "No BREP input provided for STEP export"), + StepExportError::InvalidTopology(msg) => write!(f, "Invalid topology: {}", msg), + StepExportError::UnsupportedEntity(msg) => write!(f, "Unsupported BREP: {}", msg), + StepExportError::MeshGeneration(msg) => write!(f, "Mesh generation failed: {}", msg), + StepExportError::Serialization(msg) => { + write!(f, "STEP Part-21 serialization failed: {}", msg) + } + StepExportError::Io(msg) => write!(f, "STEP I/O failed: {}", msg), + } + } +} + +impl std::error::Error for StepExportError {} + +pub fn export_brep_to_step_text( + brep: &Brep, + config: &StepExportConfig, +) -> Result<(String, StepExportReport), StepExportError> { + export_breps_to_step_text([brep], config) +} + +pub fn export_breps_to_step_text<'a, I>( + breps: I, + config: &StepExportConfig, +) -> Result<(String, StepExportReport), StepExportError> +where + I: IntoIterator, +{ + let scale = validate_config(config)?; + let breps: Vec<&Brep> = breps.into_iter().collect(); + if breps.is_empty() { + return Err(StepExportError::EmptyInput); + } + + let mut report = StepExportReport { + input_breps: breps.len(), + ..StepExportReport::default() + }; + + let file_name = config + .product_name + .clone() + .unwrap_or_else(|| "opengeometry-export".to_string()); + let mut writer = Part21Writer::new(config.schema.as_file_schema()); + writer.set_description("OpenGeometry STEP Export"); + writer.set_file_name(file_name.clone()); + + let mut solid_ids = Vec::new(); + + for (brep_index, brep) in breps.iter().enumerate() { + if config.validate_topology { + if let Err(error) = brep.validate_topology() { + if config.error_policy == StepErrorPolicy::Strict { + return Err(StepExportError::InvalidTopology(format!( + "BREP {} failed validation: {}", + brep.id, error + ))); + } + report.topology_errors += 1; + report.skipped_entities += 1; + continue; + } + } + + if config.require_closed_shell && !is_closed_solid(brep) { + let message = format!("BREP {} is not a closed shell solid", brep.id); + if config.error_policy == StepErrorPolicy::Strict { + return Err(StepExportError::UnsupportedEntity(message)); + } + report.skipped_entities += 1; + continue; + } + + let triangles = triangulate_brep_faces( + brep, + scale, + config.error_policy, + &mut report, + format!("BREP index {}", brep_index), + )?; + + if triangles.is_empty() { + if config.error_policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "BREP {} generated no valid triangles", + brep.id + ))); + } + report.skipped_entities += 1; + continue; + } + + let mut point_map: HashMap = HashMap::new(); + let mut face_ids = Vec::new(); + + for triangle in &triangles { + let p0 = get_or_create_point(&mut writer, &mut point_map, triangle[0]); + let p1 = get_or_create_point(&mut writer, &mut point_map, triangle[1]); + let p2 = get_or_create_point(&mut writer, &mut point_map, triangle[2]); + + let poly_loop = writer.add_entity(format!( + "POLY_LOOP('',({},{},{}))", + Part21Writer::reference(p0), + Part21Writer::reference(p1), + Part21Writer::reference(p2) + )); + let outer_bound = writer.add_entity(format!( + "FACE_OUTER_BOUND('',{},.T.)", + Part21Writer::reference(poly_loop) + )); + let face = writer.add_entity(format!( + "FACE('',({}))", + Part21Writer::reference(outer_bound) + )); + face_ids.push(face); + } + + if face_ids.is_empty() { + if config.error_policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "BREP {} has no exportable faces", + brep.id + ))); + } + report.skipped_entities += 1; + continue; + } + + let shell = writer.add_entity(format!("CLOSED_SHELL('',({}))", join_refs(&face_ids))); + + let solid = writer.add_entity(format!( + "MANIFOLD_SOLID_BREP('{}',{})", + sanitize_string_literal(&format!("solid-{}", brep_index)), + Part21Writer::reference(shell) + )); + + report.exported_faces += face_ids.len(); + report.exported_triangles += triangles.len(); + report.exported_solids += 1; + solid_ids.push(solid); + } + + if solid_ids.is_empty() { + return Err(StepExportError::MeshGeneration( + "No solids were exported from the provided BREP inputs".to_string(), + )); + } + + let app_context = writer.add_entity(format!( + "APPLICATION_CONTEXT('{}')", + sanitize_string_literal(config.schema.as_application_context()) + )); + + writer.add_entity(format!( + "APPLICATION_PROTOCOL_DEFINITION('international standard','{}',2000,{})", + sanitize_string_literal(config.schema.as_application_context()), + Part21Writer::reference(app_context) + )); + + let product_context = writer.add_entity(format!( + "PRODUCT_CONTEXT('',{},'mechanical')", + Part21Writer::reference(app_context) + )); + + let product = writer.add_entity(format!( + "PRODUCT('{}','{}','',({}))", + sanitize_string_literal("opengeometry-product"), + sanitize_string_literal(&file_name), + Part21Writer::reference(product_context) + )); + + let product_formation = writer.add_entity(format!( + "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('1','',{},.NOT_KNOWN.)", + Part21Writer::reference(product) + )); + + writer.add_entity(format!( + "PRODUCT_RELATED_PRODUCT_CATEGORY('part','',({}))", + Part21Writer::reference(product) + )); + + let product_definition_context = writer.add_entity(format!( + "PRODUCT_DEFINITION_CONTEXT('part definition',{},'design')", + Part21Writer::reference(app_context) + )); + + let product_definition = writer.add_entity(format!( + "PRODUCT_DEFINITION('','',{}, {})", + Part21Writer::reference(product_formation), + Part21Writer::reference(product_definition_context) + )); + + let length_unit = writer.add_entity("(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT($,.METRE.))"); + let angle_unit = writer.add_entity("(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))"); + let solid_angle_unit = + writer.add_entity("(NAMED_UNIT(*) SOLID_ANGLE_UNIT() SI_UNIT($,.STERADIAN.))"); + + let uncertainty = writer.add_entity(format!( + "UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),{},'distance_accuracy_value','confusion accuracy')", + Part21Writer::reference(length_unit) + )); + + let geometric_context = writer.add_entity(format!( + "( GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({})) GLOBAL_UNIT_ASSIGNED_CONTEXT(({}, {}, {})) REPRESENTATION_CONTEXT('','') )", + Part21Writer::reference(uncertainty), + Part21Writer::reference(length_unit), + Part21Writer::reference(angle_unit), + Part21Writer::reference(solid_angle_unit) + )); + + let shape_representation = writer.add_entity(format!( + "ADVANCED_BREP_SHAPE_REPRESENTATION('',({}),{})", + join_refs(&solid_ids), + Part21Writer::reference(geometric_context) + )); + + let product_shape = writer.add_entity(format!( + "PRODUCT_DEFINITION_SHAPE('','',{})", + Part21Writer::reference(product_definition) + )); + + writer.add_entity(format!( + "SHAPE_DEFINITION_REPRESENTATION({}, {})", + Part21Writer::reference(product_shape), + Part21Writer::reference(shape_representation) + )); + + let text = writer.build().map_err(StepExportError::Serialization)?; + Ok((text, report)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn export_brep_to_step_file( + brep: &Brep, + file_path: &str, + config: &StepExportConfig, +) -> Result { + let (text, report) = export_brep_to_step_text(brep, config)?; + std::fs::write(file_path, text).map_err(|err| StepExportError::Io(err.to_string()))?; + Ok(report) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn export_breps_to_step_file<'a, I>( + breps: I, + file_path: &str, + config: &StepExportConfig, +) -> Result +where + I: IntoIterator, +{ + let (text, report) = export_breps_to_step_text(breps, config)?; + std::fs::write(file_path, text).map_err(|err| StepExportError::Io(err.to_string()))?; + Ok(report) +} + +fn validate_config(config: &StepExportConfig) -> Result { + if !config.scale.is_finite() || config.scale <= 0.0 { + return Err(StepExportError::MeshGeneration( + "STEP scale must be a finite positive value".to_string(), + )); + } + Ok(config.scale) +} + +fn is_closed_solid(brep: &Brep) -> bool { + if brep.faces.is_empty() || brep.edges.is_empty() { + return false; + } + + if !brep.shells.is_empty() && brep.shells.iter().all(|shell| !shell.is_closed) { + return false; + } + + brep.edges.iter().all(|edge| edge.twin_halfedge.is_some()) +} + +fn triangulate_brep_faces( + brep: &Brep, + scale: f64, + policy: StepErrorPolicy, + report: &mut StepExportReport, + label: String, +) -> Result, StepExportError> { + let mut triangles = Vec::new(); + + for face in &brep.faces { + report.input_faces += 1; + let (outer_vertices, holes_vertices) = brep.get_vertices_and_holes_by_face_id(face.id); + + if outer_vertices.len() < 3 { + if policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "{} face {} has fewer than 3 vertices", + label, face.id + ))); + } + report.skipped_faces += 1; + continue; + } + + if holes_vertices.iter().any(|hole| hole.len() < 3) { + if policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "{} face {} has an invalid hole loop", + label, face.id + ))); + } + report.skipped_faces += 1; + continue; + } + + let triangle_indices = triangulate_polygon_with_holes(&outer_vertices, &holes_vertices); + if triangle_indices.is_empty() { + if policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "{} face {} produced no triangles", + label, face.id + ))); + } + report.skipped_faces += 1; + continue; + } + + let mut all_vertices = outer_vertices; + for hole in holes_vertices { + all_vertices.extend(hole); + } + + let mut face_has_triangle = false; + + for triangle in triangle_indices { + let Some((&a, &b, &c)) = all_vertices + .get(triangle[0]) + .zip(all_vertices.get(triangle[1])) + .zip(all_vertices.get(triangle[2])) + .map(|((a, b), c)| (a, b, c)) + else { + if policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "{} face {} emitted out-of-range triangle indices", + label, face.id + ))); + } + continue; + }; + + if !is_finite_vec3(a) || !is_finite_vec3(b) || !is_finite_vec3(c) { + if policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "{} face {} has non-finite coordinates", + label, face.id + ))); + } + continue; + } + + let scaled = [ + Vector3::new(a.x * scale, a.y * scale, a.z * scale), + Vector3::new(b.x * scale, b.y * scale, b.z * scale), + Vector3::new(c.x * scale, c.y * scale, c.z * scale), + ]; + + if is_degenerate_triangle(scaled[0], scaled[1], scaled[2]) { + if policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "{} face {} contains degenerate triangle", + label, face.id + ))); + } + continue; + } + + triangles.push(scaled); + face_has_triangle = true; + } + + if !face_has_triangle { + if policy == StepErrorPolicy::Strict { + return Err(StepExportError::MeshGeneration(format!( + "{} face {} yielded no valid triangles", + label, face.id + ))); + } + report.skipped_faces += 1; + } + } + + Ok(triangles) +} + +fn get_or_create_point( + writer: &mut Part21Writer, + point_map: &mut HashMap, + point: Vector3, +) -> usize { + let key = format!("{:.9}|{:.9}|{:.9}", point.x, point.y, point.z); + if let Some(existing) = point_map.get(&key) { + return *existing; + } + + let id = writer.add_entity(format!( + "CARTESIAN_POINT('',({},{},{}))", + format_real(point.x), + format_real(point.y), + format_real(point.z) + )); + + point_map.insert(key, id); + id +} + +fn join_refs(ids: &[usize]) -> String { + ids.iter() + .map(|id| Part21Writer::reference(*id)) + .collect::>() + .join(",") +} + +fn format_real(value: f64) -> String { + let mut out = format!("{:.9}", value); + while out.contains('.') && out.ends_with('0') { + out.pop(); + } + if out.ends_with('.') { + out.push('0'); + } + out +} + +fn is_finite_vec3(point: Vector3) -> bool { + point.x.is_finite() && point.y.is_finite() && point.z.is_finite() +} + +fn is_degenerate_triangle(a: Vector3, b: Vector3, c: Vector3) -> bool { + let ab = [b.x - a.x, b.y - a.y, b.z - a.z]; + let ac = [c.x - a.x, c.y - a.y, c.z - a.z]; + + let cross = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + + let area_sq = cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]; + !area_sq.is_finite() || area_sq <= STEP_LENGTH_EPSILON +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::brep::BrepBuilder; + use uuid::Uuid; + + fn tetrahedron_brep() -> Brep { + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(0.5, 0.8660254, 0.0), + Vector3::new(0.5, 0.2886751, 0.8164966), + ]); + + builder.add_face(&[0, 2, 1], &[]).unwrap(); + builder.add_face(&[0, 1, 3], &[]).unwrap(); + builder.add_face(&[1, 2, 3], &[]).unwrap(); + builder.add_face(&[2, 0, 3], &[]).unwrap(); + + builder.build().unwrap() + } + + #[test] + fn exports_step_part21_document() { + let brep = tetrahedron_brep(); + let (text, report) = + export_brep_to_step_text(&brep, &StepExportConfig::default()).expect("step export"); + + assert!(text.starts_with("ISO-10303-21;")); + assert!(text.contains("FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));")); + assert!(text.contains("MANIFOLD_SOLID_BREP")); + assert!(text.contains("ADVANCED_BREP_SHAPE_REPRESENTATION")); + assert!(report.exported_solids >= 1); + assert!(report.exported_triangles >= 4); + } + + #[test] + fn best_effort_skips_non_solid_brep() { + let solid = tetrahedron_brep(); + + let mut wire_builder = BrepBuilder::new(Uuid::new_v4()); + wire_builder.add_vertices(&[Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)]); + wire_builder.add_wire(&[0, 1], false).unwrap(); + let wire = wire_builder.build().unwrap(); + + let (text, report) = + export_breps_to_step_text([&solid, &wire], &StepExportConfig::default()) + .expect("best effort should succeed"); + + assert!(text.contains("MANIFOLD_SOLID_BREP")); + assert_eq!(report.exported_solids, 1); + assert!(report.skipped_entities >= 1); + } + + #[test] + fn strict_fails_on_non_solid_brep() { + let mut wire_builder = BrepBuilder::new(Uuid::new_v4()); + wire_builder.add_vertices(&[Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)]); + wire_builder.add_wire(&[0, 1], false).unwrap(); + let wire = wire_builder.build().unwrap(); + + let config = StepExportConfig { + error_policy: StepErrorPolicy::Strict, + ..StepExportConfig::default() + }; + + let result = export_brep_to_step_text(&wire, &config); + assert!(result.is_err()); + } +} diff --git a/main/opengeometry/src/export/stl.rs b/main/opengeometry/src/export/stl.rs new file mode 100644 index 0000000..39d6612 --- /dev/null +++ b/main/opengeometry/src/export/stl.rs @@ -0,0 +1,491 @@ +use std::fmt; + +use crate::brep::{Brep, Face}; +use crate::operations::triangulate::triangulate_polygon_with_holes; +use openmaths::Vector3; +use serde::{Deserialize, Serialize}; + +const DEFAULT_STL_HEADER: &str = "OpenGeometry STL Export"; +const STL_HEADER_BYTES: usize = 80; +const TRIANGLE_EPSILON: f64 = 1.0e-12; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum StlErrorPolicy { + Strict, + BestEffort, +} + +impl Default for StlErrorPolicy { + fn default() -> Self { + Self::BestEffort + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StlExportConfig { + pub header: Option, + pub scale: f64, + pub error_policy: StlErrorPolicy, + pub validate_topology: bool, +} + +impl Default for StlExportConfig { + fn default() -> Self { + Self { + header: Some(DEFAULT_STL_HEADER.to_string()), + scale: 1.0, + error_policy: StlErrorPolicy::BestEffort, + validate_topology: true, + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct StlExportReport { + pub input_breps: usize, + pub input_faces: usize, + pub exported_triangles: usize, + pub skipped_faces: usize, + pub skipped_triangles: usize, + pub topology_errors: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum StlExportError { + EmptyInput, + InvalidTopology(String), + MeshGeneration(String), + Io(String), +} + +impl fmt::Display for StlExportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StlExportError::EmptyInput => write!(f, "No BREP input provided for STL export"), + StlExportError::InvalidTopology(message) => write!(f, "Invalid topology: {}", message), + StlExportError::MeshGeneration(message) => { + write!(f, "Failed to generate STL mesh: {}", message) + } + StlExportError::Io(message) => write!(f, "STL I/O error: {}", message), + } + } +} + +impl std::error::Error for StlExportError {} + +pub fn export_brep_to_stl_bytes( + brep: &Brep, + config: &StlExportConfig, +) -> Result<(Vec, StlExportReport), StlExportError> { + export_breps_to_stl_bytes([brep], config) +} + +pub fn export_breps_to_stl_bytes<'a, I>( + breps: I, + config: &StlExportConfig, +) -> Result<(Vec, StlExportReport), StlExportError> +where + I: IntoIterator, +{ + let scale = validate_config(config)?; + let breps: Vec<&Brep> = breps.into_iter().collect(); + if breps.is_empty() { + return Err(StlExportError::EmptyInput); + } + + let mut triangles: Vec = Vec::new(); + let mut report = StlExportReport { + input_breps: breps.len(), + ..StlExportReport::default() + }; + + for brep in breps { + if config.validate_topology { + if let Err(error) = brep.validate_topology() { + if config.error_policy == StlErrorPolicy::Strict { + return Err(StlExportError::InvalidTopology(format!( + "BREP {} failed validation: {}", + brep.id, error + ))); + } + report.topology_errors += 1; + continue; + } + } + + for face in &brep.faces { + report.input_faces += 1; + triangulate_face( + brep, + face, + scale, + config.error_policy, + &mut triangles, + &mut report, + )?; + } + } + + if triangles.is_empty() { + return Err(StlExportError::MeshGeneration( + "No triangles were exported from the provided BREP inputs".to_string(), + )); + } + + let mut bytes = Vec::with_capacity(STL_HEADER_BYTES + 4 + triangles.len() * 50); + stl_io::write_stl(&mut bytes, triangles.iter()) + .map_err(|error| StlExportError::Io(error.to_string()))?; + apply_header( + &mut bytes, + config.header.as_deref().unwrap_or(DEFAULT_STL_HEADER), + ); + + Ok((bytes, report)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn export_brep_to_stl_file( + brep: &Brep, + file_path: &str, + config: &StlExportConfig, +) -> Result { + let (bytes, report) = export_brep_to_stl_bytes(brep, config)?; + std::fs::write(file_path, bytes).map_err(|error| StlExportError::Io(error.to_string()))?; + Ok(report) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn export_breps_to_stl_file<'a, I>( + breps: I, + file_path: &str, + config: &StlExportConfig, +) -> Result +where + I: IntoIterator, +{ + let (bytes, report) = export_breps_to_stl_bytes(breps, config)?; + std::fs::write(file_path, bytes).map_err(|error| StlExportError::Io(error.to_string()))?; + Ok(report) +} + +fn validate_config(config: &StlExportConfig) -> Result { + if !config.scale.is_finite() || config.scale <= 0.0 { + return Err(StlExportError::MeshGeneration( + "STL scale must be a finite positive number".to_string(), + )); + } + Ok(config.scale) +} + +fn triangulate_face( + brep: &Brep, + face: &Face, + scale: f64, + policy: StlErrorPolicy, + triangles: &mut Vec, + report: &mut StlExportReport, +) -> Result<(), StlExportError> { + let (outer_vertices, holes_vertices) = brep.get_vertices_and_holes_by_face_id(face.id); + if outer_vertices.len() < 3 { + return handle_face_issue( + policy, + report, + format!("Face {} has fewer than 3 outer vertices", face.id), + ); + } + + if holes_vertices.iter().any(|hole| hole.len() < 3) { + return handle_face_issue( + policy, + report, + format!("Face {} has a hole with fewer than 3 vertices", face.id), + ); + } + + let triangle_indices = triangulate_polygon_with_holes(&outer_vertices, &holes_vertices); + if triangle_indices.is_empty() { + return handle_face_issue( + policy, + report, + format!("Face {} triangulation returned no triangles", face.id), + ); + } + + let mut all_vertices = outer_vertices; + for hole in holes_vertices { + all_vertices.extend(hole); + } + + let target_normal = face_normal_hint(face, &all_vertices); + let mut face_has_exported_triangle = false; + + for triangle in triangle_indices { + let Some((&a, &b, &c)) = all_vertices + .get(triangle[0]) + .zip(all_vertices.get(triangle[1])) + .zip(all_vertices.get(triangle[2])) + .map(|((a, b), c)| (a, b, c)) + else { + if policy == StlErrorPolicy::Strict { + return Err(StlExportError::MeshGeneration(format!( + "Face {} produced out-of-range triangle index", + face.id + ))); + } + report.skipped_triangles += 1; + continue; + }; + + if !is_finite_vec3(a) || !is_finite_vec3(b) || !is_finite_vec3(c) { + if policy == StlErrorPolicy::Strict { + return Err(StlExportError::MeshGeneration(format!( + "Face {} contains non-finite coordinates", + face.id + ))); + } + report.skipped_triangles += 1; + continue; + } + + let mut oriented = [a, b, c]; + let Some(mut normal) = compute_triangle_normal(&a, &b, &c) else { + if policy == StlErrorPolicy::Strict { + return Err(StlExportError::MeshGeneration(format!( + "Face {} contains a degenerate triangle", + face.id + ))); + } + report.skipped_triangles += 1; + continue; + }; + + if let Some(target) = target_normal { + if dot(normal, target) < 0.0 { + oriented.swap(1, 2); + normal = [-normal[0], -normal[1], -normal[2]]; + } + } + + let Some(v0) = scaled_vertex(oriented[0], scale) else { + if policy == StlErrorPolicy::Strict { + return Err(StlExportError::MeshGeneration(format!( + "Face {} failed to scale triangle coordinates", + face.id + ))); + } + report.skipped_triangles += 1; + continue; + }; + let Some(v1) = scaled_vertex(oriented[1], scale) else { + if policy == StlErrorPolicy::Strict { + return Err(StlExportError::MeshGeneration(format!( + "Face {} failed to scale triangle coordinates", + face.id + ))); + } + report.skipped_triangles += 1; + continue; + }; + let Some(v2) = scaled_vertex(oriented[2], scale) else { + if policy == StlErrorPolicy::Strict { + return Err(StlExportError::MeshGeneration(format!( + "Face {} failed to scale triangle coordinates", + face.id + ))); + } + report.skipped_triangles += 1; + continue; + }; + + triangles.push(stl_io::Triangle { + normal: stl_io::Normal::new([normal[0] as f32, normal[1] as f32, normal[2] as f32]), + vertices: [ + stl_io::Vertex::new(v0), + stl_io::Vertex::new(v1), + stl_io::Vertex::new(v2), + ], + }); + report.exported_triangles += 1; + face_has_exported_triangle = true; + } + + if !face_has_exported_triangle { + return handle_face_issue( + policy, + report, + format!("Face {} generated no valid triangles", face.id), + ); + } + + Ok(()) +} + +fn handle_face_issue( + policy: StlErrorPolicy, + report: &mut StlExportReport, + message: String, +) -> Result<(), StlExportError> { + if policy == StlErrorPolicy::Strict { + return Err(StlExportError::MeshGeneration(message)); + } + report.skipped_faces += 1; + Ok(()) +} + +fn face_normal_hint(face: &Face, all_vertices: &[Vector3]) -> Option<[f64; 3]> { + let face_normal = [face.normal.x, face.normal.y, face.normal.z]; + normalize(face_normal).or_else(|| compute_polygon_normal(all_vertices)) +} + +fn compute_polygon_normal(vertices: &[Vector3]) -> Option<[f64; 3]> { + if vertices.len() < 3 { + return None; + } + + let mut nx = 0.0; + let mut ny = 0.0; + let mut nz = 0.0; + + for index in 0..vertices.len() { + let current = vertices[index]; + let next = vertices[(index + 1) % vertices.len()]; + nx += (current.y - next.y) * (current.z + next.z); + ny += (current.z - next.z) * (current.x + next.x); + nz += (current.x - next.x) * (current.y + next.y); + } + + normalize([nx, ny, nz]) +} + +fn compute_triangle_normal(a: &Vector3, b: &Vector3, c: &Vector3) -> Option<[f64; 3]> { + let ab = [b.x - a.x, b.y - a.y, b.z - a.z]; + let ac = [c.x - a.x, c.y - a.y, c.z - a.z]; + normalize(cross(ab, ac)) +} + +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn normalize(vector: [f64; 3]) -> Option<[f64; 3]> { + let length_sq = dot(vector, vector); + if !length_sq.is_finite() || length_sq <= TRIANGLE_EPSILON { + return None; + } + let inv_len = length_sq.sqrt().recip(); + Some([ + vector[0] * inv_len, + vector[1] * inv_len, + vector[2] * inv_len, + ]) +} + +fn is_finite_vec3(point: Vector3) -> bool { + point.x.is_finite() && point.y.is_finite() && point.z.is_finite() +} + +fn scaled_vertex(point: Vector3, scale: f64) -> Option<[f32; 3]> { + let x = point.x * scale; + let y = point.y * scale; + let z = point.z * scale; + if !(x.is_finite() && y.is_finite() && z.is_finite()) { + return None; + } + Some([x as f32, y as f32, z as f32]) +} + +fn apply_header(bytes: &mut [u8], header: &str) { + if bytes.len() < STL_HEADER_BYTES { + return; + } + + bytes[..STL_HEADER_BYTES].fill(0); + let header_bytes = header.as_bytes(); + let copy_len = header_bytes.len().min(STL_HEADER_BYTES); + bytes[..copy_len].copy_from_slice(&header_bytes[..copy_len]); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::brep::BrepBuilder; + use uuid::Uuid; + + fn triangle_brep() -> Brep { + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(0.0, 1.0, 0.0), + ]); + builder + .add_face(&[0, 1, 2], &[]) + .expect("triangle face should be valid"); + builder.build().expect("triangle brep should be valid") + } + + #[test] + fn exports_binary_stl_with_expected_size_and_count() { + let brep = triangle_brep(); + let config = StlExportConfig::default(); + + let (bytes, report) = export_brep_to_stl_bytes(&brep, &config).expect("export should pass"); + + assert_eq!(report.exported_triangles, 1); + assert_eq!(bytes.len(), 84 + 50); + + let count = u32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]); + assert_eq!(count, 1); + } + + #[test] + fn writes_custom_binary_header() { + let brep = triangle_brep(); + let config = StlExportConfig { + header: Some("OpenGeometry Test STL".to_string()), + ..StlExportConfig::default() + }; + + let (bytes, _) = export_brep_to_stl_bytes(&brep, &config).expect("export should pass"); + assert_eq!( + &bytes[..b"OpenGeometry Test STL".len()], + b"OpenGeometry Test STL" + ); + } + + #[test] + fn best_effort_skips_degenerate_triangles() { + let valid = triangle_brep(); + let mut degenerate = triangle_brep(); + degenerate.vertices[2].position = degenerate.vertices[1].position; + + let (bytes, report) = + export_breps_to_stl_bytes([&valid, °enerate], &StlExportConfig::default()) + .expect("best-effort export should still succeed"); + + assert!(!bytes.is_empty()); + assert_eq!(report.exported_triangles, 1); + assert!(report.skipped_triangles >= 1 || report.skipped_faces >= 1); + } + + #[test] + fn strict_policy_fails_on_degenerate_triangles() { + let mut degenerate = triangle_brep(); + degenerate.vertices[2].position = degenerate.vertices[1].position; + + let config = StlExportConfig { + error_policy: StlErrorPolicy::Strict, + ..StlExportConfig::default() + }; + let result = export_brep_to_stl_bytes(°enerate, &config); + assert!(result.is_err()); + } +} diff --git a/main/opengeometry/src/operations/sweep.rs b/main/opengeometry/src/operations/sweep.rs index 68f29f8..d484ac9 100644 --- a/main/opengeometry/src/operations/sweep.rs +++ b/main/opengeometry/src/operations/sweep.rs @@ -327,9 +327,11 @@ fn compute_path_tangent(path: &[Vec3f], is_closed: bool, index: usize) -> Vec3f let count = path.len(); if is_closed { - let prev = path[(index + count - 1) % count]; - let next = path[(index + 1) % count]; - return next.sub(prev); + return blend_corner_tangent( + path[(index + count - 1) % count], + path[index], + path[(index + 1) % count], + ); } if index == 0 { @@ -337,7 +339,30 @@ fn compute_path_tangent(path: &[Vec3f], is_closed: bool, index: usize) -> Vec3f } else if index == count - 1 { path[count - 1].sub(path[count - 2]) } else { - path[index + 1].sub(path[index - 1]) + blend_corner_tangent(path[index - 1], path[index], path[index + 1]) + } +} + +fn blend_corner_tangent(prev: Vec3f, current: Vec3f, next: Vec3f) -> Vec3f { + let incoming = current.sub(prev); + let outgoing = next.sub(current); + + match (incoming.normalized(), outgoing.normalized()) { + (Some(in_dir), Some(out_dir)) => { + let blended = in_dir.add(out_dir); + if blended.norm_sq() <= EPSILON * EPSILON { + if outgoing.norm_sq() > EPSILON * EPSILON { + outgoing + } else { + incoming + } + } else { + blended + } + } + (Some(_), None) => incoming, + (None, Some(_)) => outgoing, + (None, None) => Vec3f::new(0.0, 1.0, 0.0), } } @@ -432,7 +457,7 @@ fn rotate_around_axis(vector: Vec3f, axis: Vec3f, angle: f64) -> Vec3f { #[cfg(test)] mod tests { - use super::{sweep_profile_along_path, SweepOptions}; + use super::{compute_path_tangent, sweep_profile_along_path, SweepOptions, Vec3f}; use openmaths::Vector3; fn rectangle_profile(width: f64, depth: f64) -> Vec { @@ -496,4 +521,62 @@ mod tests { assert_eq!(brep.vertices.len(), 16); assert_eq!(brep.faces.len(), 16); } + + fn assert_direction_close(actual: Vec3f, expected: Vec3f) { + let actual = actual + .normalized() + .expect("actual direction must be non-degenerate"); + let expected = expected + .normalized() + .expect("expected direction must be non-degenerate"); + let error = actual.sub(expected).norm(); + assert!( + error <= 1.0e-9, + "direction mismatch: actual=({:.6},{:.6},{:.6}) expected=({:.6},{:.6},{:.6}) error={:.3e}", + actual.x, + actual.y, + actual.z, + expected.x, + expected.y, + expected.z, + error + ); + } + + #[test] + fn corner_tangent_uses_angle_bisector_for_unequal_segments() { + let path = vec![ + Vec3f::new(0.0, 0.0, 0.0), + Vec3f::new(0.0, 2.0, 0.0), + Vec3f::new(5.0, 2.0, 0.0), + Vec3f::new(5.0, 0.0, 0.0), + ]; + + let left_corner_tangent = compute_path_tangent(&path, false, 1); + let right_corner_tangent = compute_path_tangent(&path, false, 2); + + assert_direction_close(left_corner_tangent, Vec3f::new(1.0, 1.0, 0.0)); + assert_direction_close(right_corner_tangent, Vec3f::new(1.0, -1.0, 0.0)); + } + + #[test] + fn corner_tangent_is_not_weighted_by_neighbor_segment_length() { + let short_segments = vec![ + Vec3f::new(0.0, 0.0, 0.0), + Vec3f::new(0.0, 1.0, 0.0), + Vec3f::new(1.0, 1.0, 0.0), + ]; + + let long_segments = vec![ + Vec3f::new(0.0, 0.0, 0.0), + Vec3f::new(0.0, 4.0, 0.0), + Vec3f::new(8.0, 4.0, 0.0), + ]; + + let short_tangent = compute_path_tangent(&short_segments, false, 1); + let long_tangent = compute_path_tangent(&long_segments, false, 1); + + assert_direction_close(short_tangent, Vec3f::new(1.0, 1.0, 0.0)); + assert_direction_close(long_tangent, Vec3f::new(1.0, 1.0, 0.0)); + } } diff --git a/main/opengeometry/src/primitives/polyline.rs b/main/opengeometry/src/primitives/polyline.rs index f477e91..8b5ed4e 100644 --- a/main/opengeometry/src/primitives/polyline.rs +++ b/main/opengeometry/src/primitives/polyline.rs @@ -222,3 +222,28 @@ impl OGPolyline { .points } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn closed_polyline_builds_wire_and_face_without_duplicate_halfedge_error() { + let mut polyline = OGPolyline::new("polyline-test".to_string()); + let points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 2.0), + Vector3::new(0.0, 0.0, 2.0), + Vector3::new(0.0, 0.0, 0.0), + ]; + + polyline + .set_config(points) + .expect("closed polyline should build without duplicate directed halfedge"); + + assert!(polyline.is_closed()); + assert_eq!(polyline.brep.wires.len(), 1); + assert_eq!(polyline.brep.faces.len(), 1); + } +} diff --git a/main/opengeometry/src/scenegraph.rs b/main/opengeometry/src/scenegraph.rs index 5ab8959..7d206fd 100644 --- a/main/opengeometry/src/scenegraph.rs +++ b/main/opengeometry/src/scenegraph.rs @@ -5,9 +5,19 @@ use uuid::Uuid; use wasm_bindgen::prelude::*; use crate::brep::Brep; +use crate::export::ifc::{ + export_brep_to_ifc_text, export_scene_entities_to_ifc_text, IfcEntityInput, IfcExportConfig, + IfcExportReport, +}; use crate::export::projection::{ project_brep_to_scene, CameraParameters, HlrOptions, Scene2D, Scene2DLines, }; +use crate::export::step::{ + export_brep_to_step_text, export_breps_to_step_text, StepExportConfig, StepExportReport, +}; +use crate::export::stl::{ + export_brep_to_stl_bytes, export_breps_to_stl_bytes, StlExportConfig, StlExportReport, +}; use crate::primitives::arc::OGArc; use crate::primitives::cuboid::OGCuboid; use crate::primitives::cylinder::OGCylinder; @@ -18,8 +28,14 @@ use crate::primitives::rectangle::OGRectangle; use crate::primitives::sphere::OGSphere; use crate::primitives::wedge::OGWedge; +#[cfg(not(target_arch = "wasm32"))] +use crate::export::ifc::export_scene_entities_to_ifc_file; #[cfg(not(target_arch = "wasm32"))] use crate::export::pdf::{export_scene_to_pdf_with_config, PdfExportConfig}; +#[cfg(not(target_arch = "wasm32"))] +use crate::export::step::export_breps_to_step_file; +#[cfg(not(target_arch = "wasm32"))] +use crate::export::stl::export_breps_to_stl_file; #[derive(Clone, Serialize, Deserialize)] pub struct SceneEntity { @@ -75,6 +91,93 @@ pub struct SceneSummary { pub entity_count: usize, } +#[derive(Clone, Serialize, Deserialize)] +pub struct StlExportPayload { + pub bytes: Vec, + pub report: StlExportReport, +} + +#[wasm_bindgen] +pub struct OGStlExportResult { + bytes: Vec, + report_json: String, +} + +impl OGStlExportResult { + fn from_parts(bytes: Vec, report: StlExportReport) -> Result { + let report_json = serde_json::to_string(&report) + .map_err(|err| format!("Failed to serialize STL export report: {}", err))?; + Ok(Self { bytes, report_json }) + } +} + +#[wasm_bindgen] +impl OGStlExportResult { + #[wasm_bindgen(getter)] + pub fn bytes(&self) -> Vec { + self.bytes.clone() + } + + #[wasm_bindgen(getter, js_name = reportJson)] + pub fn report_json(&self) -> String { + self.report_json.clone() + } +} + +#[wasm_bindgen] +pub struct OGStepExportResult { + text: String, + report_json: String, +} + +impl OGStepExportResult { + fn from_parts(text: String, report: StepExportReport) -> Result { + let report_json = serde_json::to_string(&report) + .map_err(|err| format!("Failed to serialize STEP export report: {}", err))?; + Ok(Self { text, report_json }) + } +} + +#[wasm_bindgen] +impl OGStepExportResult { + #[wasm_bindgen(getter)] + pub fn text(&self) -> String { + self.text.clone() + } + + #[wasm_bindgen(getter, js_name = reportJson)] + pub fn report_json(&self) -> String { + self.report_json.clone() + } +} + +#[wasm_bindgen] +pub struct OGIfcExportResult { + text: String, + report_json: String, +} + +impl OGIfcExportResult { + fn from_parts(text: String, report: IfcExportReport) -> Result { + let report_json = serde_json::to_string(&report) + .map_err(|err| format!("Failed to serialize IFC export report: {}", err))?; + Ok(Self { text, report_json }) + } +} + +#[wasm_bindgen] +impl OGIfcExportResult { + #[wasm_bindgen(getter)] + pub fn text(&self) -> String { + self.text.clone() + } + + #[wasm_bindgen(getter, js_name = reportJson)] + pub fn report_json(&self) -> String { + self.report_json.clone() + } +} + #[wasm_bindgen] pub struct OGSceneManager { scenes: HashMap, @@ -126,6 +229,30 @@ impl OGSceneManager { } } + fn parse_stl_config_json(config_json: Option) -> Result { + match config_json { + Some(payload) if !payload.trim().is_empty() => serde_json::from_str(&payload) + .map_err(|err| format!("Invalid STL config JSON payload: {}", err)), + _ => Ok(StlExportConfig::default()), + } + } + + fn parse_step_config_json(config_json: Option) -> Result { + match config_json { + Some(payload) if !payload.trim().is_empty() => serde_json::from_str(&payload) + .map_err(|err| format!("Invalid STEP config JSON payload: {}", err)), + _ => Ok(StepExportConfig::default()), + } + } + + fn parse_ifc_config_json(config_json: Option) -> Result { + match config_json { + Some(payload) if !payload.trim().is_empty() => serde_json::from_str(&payload) + .map_err(|err| format!("Invalid IFC config JSON payload: {}", err)), + _ => Ok(IfcExportConfig::default()), + } + } + fn upsert_entity_brep( &mut self, scene_id: &str, @@ -325,6 +452,111 @@ impl OGSceneManager { serde_json::to_string_pretty(&projected_lines) .map_err(|err| format!("Failed to serialize projected line scene: {}", err)) } + + pub fn export_scene_to_stl_bytes_internal( + &self, + scene_id: &str, + config: &StlExportConfig, + ) -> Result<(Vec, StlExportReport), String> { + let scene = self.get_scene(scene_id)?; + let breps: Vec<&Brep> = scene.entities.iter().map(|entity| &entity.brep).collect(); + export_breps_to_stl_bytes(breps, config).map_err(|err| err.to_string()) + } + + pub fn export_brep_serialized_to_stl_bytes_internal( + &self, + brep_serialized: &str, + config: &StlExportConfig, + ) -> Result<(Vec, StlExportReport), String> { + let brep: Brep = serde_json::from_str(brep_serialized) + .map_err(|err| format!("Failed to deserialize BRep JSON payload: {}", err))?; + export_brep_to_stl_bytes(&brep, config).map_err(|err| err.to_string()) + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn export_scene_to_stl_file_internal( + &self, + scene_id: &str, + file_path: &str, + config: &StlExportConfig, + ) -> Result { + let scene = self.get_scene(scene_id)?; + let breps: Vec<&Brep> = scene.entities.iter().map(|entity| &entity.brep).collect(); + export_breps_to_stl_file(breps, file_path, config).map_err(|err| err.to_string()) + } + + pub fn export_scene_to_step_text_internal( + &self, + scene_id: &str, + config: &StepExportConfig, + ) -> Result<(String, StepExportReport), String> { + let scene = self.get_scene(scene_id)?; + let breps: Vec<&Brep> = scene.entities.iter().map(|entity| &entity.brep).collect(); + export_breps_to_step_text(breps, config).map_err(|err| err.to_string()) + } + + pub fn export_brep_serialized_to_step_text_internal( + &self, + brep_serialized: &str, + config: &StepExportConfig, + ) -> Result<(String, StepExportReport), String> { + let brep: Brep = serde_json::from_str(brep_serialized) + .map_err(|err| format!("Failed to deserialize BRep JSON payload: {}", err))?; + export_brep_to_step_text(&brep, config).map_err(|err| err.to_string()) + } + + pub fn export_scene_to_ifc_text_internal( + &self, + scene_id: &str, + config: &IfcExportConfig, + ) -> Result<(String, IfcExportReport), String> { + let scene = self.get_scene(scene_id)?; + let entities = scene.entities.iter().map(|entity| IfcEntityInput { + entity_id: entity.id.as_str(), + kind: entity.kind.as_str(), + brep: &entity.brep, + }); + export_scene_entities_to_ifc_text(entities, config).map_err(|err| err.to_string()) + } + + pub fn export_brep_serialized_to_ifc_text_internal( + &self, + brep_serialized: &str, + config: &IfcExportConfig, + ) -> Result<(String, IfcExportReport), String> { + let brep: Brep = serde_json::from_str(brep_serialized) + .map_err(|err| format!("Failed to deserialize BRep JSON payload: {}", err))?; + export_brep_to_ifc_text(&brep, config).map_err(|err| err.to_string()) + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn export_scene_to_step_file_internal( + &self, + scene_id: &str, + file_path: &str, + config: &StepExportConfig, + ) -> Result { + let scene = self.get_scene(scene_id)?; + let breps: Vec<&Brep> = scene.entities.iter().map(|entity| &entity.brep).collect(); + export_breps_to_step_file(breps, file_path, config).map_err(|err| err.to_string()) + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn export_scene_to_ifc_file_internal( + &self, + scene_id: &str, + file_path: &str, + config: &IfcExportConfig, + ) -> Result { + let scene = self.get_scene(scene_id)?; + let entities = scene.entities.iter().map(|entity| IfcEntityInput { + entity_id: entity.id.as_str(), + kind: entity.kind.as_str(), + brep: &entity.brep, + }); + export_scene_entities_to_ifc_file(entities, file_path, config) + .map_err(|err| err.to_string()) + } } #[wasm_bindgen] @@ -414,6 +646,13 @@ impl OGSceneManager { JsValue::from_str(&format!("Failed to deserialize BRep JSON payload: {}", err)) })?; + brep.validate_topology().map_err(|err| { + JsValue::from_str(&format!( + "Invalid BRep topology for '{}': {}", + entity_id, err + )) + })?; + self.upsert_entity_brep(&scene_id, entity_id, kind, brep) .map_err(|err| JsValue::from_str(&err)) } @@ -718,6 +957,183 @@ impl OGSceneManager { self.project_to_2d_lines(scene_id, camera_json, hlr_json) } + #[wasm_bindgen(js_name = exportBrepToStl)] + pub fn export_brep_to_stl( + &self, + brep_serialized: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_stl_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let (bytes, report) = self + .export_brep_serialized_to_stl_bytes_internal(&brep_serialized, &config) + .map_err(|err| JsValue::from_str(&err))?; + + OGStlExportResult::from_parts(bytes, report).map_err(|err| JsValue::from_str(&err)) + } + + #[wasm_bindgen(js_name = exportSceneToStl)] + pub fn export_scene_to_stl( + &self, + scene_id: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_stl_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let (bytes, report) = self + .export_scene_to_stl_bytes_internal(&scene_id, &config) + .map_err(|err| JsValue::from_str(&err))?; + + OGStlExportResult::from_parts(bytes, report).map_err(|err| JsValue::from_str(&err)) + } + + #[wasm_bindgen(js_name = exportCurrentSceneToStl)] + pub fn export_current_scene_to_stl( + &self, + config_json: Option, + ) -> Result { + let scene_id = self + .scene_id_or_current(None) + .map_err(|err| JsValue::from_str(&err))?; + self.export_scene_to_stl(scene_id, config_json) + } + + #[wasm_bindgen(js_name = exportBrepToStep)] + pub fn export_brep_to_step( + &self, + brep_serialized: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_step_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let (text, report) = self + .export_brep_serialized_to_step_text_internal(&brep_serialized, &config) + .map_err(|err| JsValue::from_str(&err))?; + + OGStepExportResult::from_parts(text, report).map_err(|err| JsValue::from_str(&err)) + } + + #[wasm_bindgen(js_name = exportSceneToStep)] + pub fn export_scene_to_step( + &self, + scene_id: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_step_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let (text, report) = self + .export_scene_to_step_text_internal(&scene_id, &config) + .map_err(|err| JsValue::from_str(&err))?; + + OGStepExportResult::from_parts(text, report).map_err(|err| JsValue::from_str(&err)) + } + + #[wasm_bindgen(js_name = exportCurrentSceneToStep)] + pub fn export_current_scene_to_step( + &self, + config_json: Option, + ) -> Result { + let scene_id = self + .scene_id_or_current(None) + .map_err(|err| JsValue::from_str(&err))?; + self.export_scene_to_step(scene_id, config_json) + } + + #[wasm_bindgen(js_name = exportBrepToIfc)] + pub fn export_brep_to_ifc( + &self, + brep_serialized: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_ifc_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let (text, report) = self + .export_brep_serialized_to_ifc_text_internal(&brep_serialized, &config) + .map_err(|err| JsValue::from_str(&err))?; + + OGIfcExportResult::from_parts(text, report).map_err(|err| JsValue::from_str(&err)) + } + + #[wasm_bindgen(js_name = exportSceneToIfc)] + pub fn export_scene_to_ifc( + &self, + scene_id: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_ifc_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let (text, report) = self + .export_scene_to_ifc_text_internal(&scene_id, &config) + .map_err(|err| JsValue::from_str(&err))?; + + OGIfcExportResult::from_parts(text, report).map_err(|err| JsValue::from_str(&err)) + } + + #[wasm_bindgen(js_name = exportCurrentSceneToIfc)] + pub fn export_current_scene_to_ifc( + &self, + config_json: Option, + ) -> Result { + let scene_id = self + .scene_id_or_current(None) + .map_err(|err| JsValue::from_str(&err))?; + self.export_scene_to_ifc(scene_id, config_json) + } + + #[cfg(not(target_arch = "wasm32"))] + #[wasm_bindgen(js_name = exportSceneToStlFile)] + pub fn export_scene_to_stl_file( + &self, + scene_id: String, + file_path: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_stl_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let report = self + .export_scene_to_stl_file_internal(&scene_id, &file_path, &config) + .map_err(|err| JsValue::from_str(&err))?; + serde_json::to_string(&report).map_err(|err| { + JsValue::from_str(&format!("Failed to serialize STL export report: {}", err)) + }) + } + + #[cfg(not(target_arch = "wasm32"))] + #[wasm_bindgen(js_name = exportSceneToStepFile)] + pub fn export_scene_to_step_file( + &self, + scene_id: String, + file_path: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_step_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let report = self + .export_scene_to_step_file_internal(&scene_id, &file_path, &config) + .map_err(|err| JsValue::from_str(&err))?; + serde_json::to_string(&report).map_err(|err| { + JsValue::from_str(&format!("Failed to serialize STEP export report: {}", err)) + }) + } + + #[cfg(not(target_arch = "wasm32"))] + #[wasm_bindgen(js_name = exportSceneToIfcFile)] + pub fn export_scene_to_ifc_file( + &self, + scene_id: String, + file_path: String, + config_json: Option, + ) -> Result { + let config = + Self::parse_ifc_config_json(config_json).map_err(|err| JsValue::from_str(&err))?; + let report = self + .export_scene_to_ifc_file_internal(&scene_id, &file_path, &config) + .map_err(|err| JsValue::from_str(&err))?; + serde_json::to_string(&report).map_err(|err| { + JsValue::from_str(&format!("Failed to serialize IFC export report: {}", err)) + }) + } + #[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen(js_name = projectToPDF)] pub fn project_to_pdf( @@ -772,4 +1188,97 @@ mod tests { assert!(!scene_2d.is_empty()); } + + #[test] + fn test_scene_stl_export_binary_payload() { + let mut manager = OGSceneManager::new(); + let scene_id = manager.create_scene_internal("stl-scene"); + + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(0.0, 1.0, 0.0), + ]); + builder.add_face(&[0, 1, 2], &[]).unwrap(); + let brep: Brep = builder.build().unwrap(); + + manager + .add_brep_entity_to_scene_internal(&scene_id, "tri-1", "Triangle", &brep) + .unwrap(); + + let (bytes, report) = manager + .export_scene_to_stl_bytes_internal(&scene_id, &StlExportConfig::default()) + .unwrap(); + + assert!(bytes.len() >= 84); + let triangle_count = u32::from_le_bytes([bytes[80], bytes[81], bytes[82], bytes[83]]); + assert_eq!(triangle_count as usize, report.exported_triangles); + assert_eq!(report.exported_triangles, 1); + } + + #[test] + fn test_scene_step_export_text_payload() { + let mut manager = OGSceneManager::new(); + let scene_id = manager.create_scene_internal("step-scene"); + + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(0.5, 0.8660254, 0.0), + Vector3::new(0.5, 0.2886751, 0.8164966), + ]); + builder.add_face(&[0, 2, 1], &[]).unwrap(); + builder.add_face(&[0, 1, 3], &[]).unwrap(); + builder.add_face(&[1, 2, 3], &[]).unwrap(); + builder.add_face(&[2, 0, 3], &[]).unwrap(); + let brep: Brep = builder.build().unwrap(); + + manager + .add_brep_entity_to_scene_internal(&scene_id, "tetra-1", "Tetrahedron", &brep) + .unwrap(); + + let (text, report) = manager + .export_scene_to_step_text_internal(&scene_id, &StepExportConfig::default()) + .unwrap(); + + assert!(text.starts_with("ISO-10303-21;")); + assert!(text.contains("FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));")); + assert!(text.contains("MANIFOLD_SOLID_BREP")); + assert_eq!(report.exported_solids, 1); + } + + #[test] + fn test_scene_ifc_export_text_payload() { + let mut manager = OGSceneManager::new(); + let scene_id = manager.create_scene_internal("ifc-scene"); + + let mut builder = BrepBuilder::new(Uuid::new_v4()); + builder.add_vertices(&[ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(0.5, 0.8660254, 0.0), + Vector3::new(0.5, 0.2886751, 0.8164966), + ]); + builder.add_face(&[0, 2, 1], &[]).unwrap(); + builder.add_face(&[0, 1, 3], &[]).unwrap(); + builder.add_face(&[1, 2, 3], &[]).unwrap(); + builder.add_face(&[2, 0, 3], &[]).unwrap(); + let brep: Brep = builder.build().unwrap(); + + manager + .add_brep_entity_to_scene_internal(&scene_id, "tetra-1", "Tetrahedron", &brep) + .unwrap(); + + let (text, report) = manager + .export_scene_to_ifc_text_internal(&scene_id, &IfcExportConfig::default()) + .unwrap(); + + assert!(text.starts_with("ISO-10303-21;")); + assert!(text.contains("FILE_SCHEMA(('IFC4'));")); + assert!(text.contains("IFCPROJECT(")); + assert!(text.contains("IFCTRIANGULATEDFACESET(")); + assert_eq!(report.exported_elements, 1); + } } From 09253e463ded72dce2593ce2411293b21d05831c Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Mar 2026 16:30:29 +0100 Subject: [PATCH 08/10] file exorts --- .../examples-vite/index.html | 23 +++++++--------- .../examples-vite/operations/door-export.html | 4 +-- .../examples-vite/operations/ifc-export.html | 4 +-- .../examples-vite/operations/offset.html | 1 - .../examples-vite/operations/step-export.html | 4 +-- .../examples-vite/operations/stl-export.html | 4 +-- .../operations/sweep-hilbert-profiles.html | 1 - .../operations/sweep-path-profile.html | 1 - .../operations/wall-from-offsets.html | 1 - .../examples-vite/primitives/arc.html | 1 - .../examples-vite/primitives/curve.html | 1 - .../examples-vite/primitives/line.html | 1 - .../examples-vite/primitives/polyline.html | 1 - .../examples-vite/primitives/rectangle.html | 1 - .../examples-vite/shapes/cuboid.html | 1 - .../examples-vite/shapes/cylinder.html | 1 - .../examples-vite/shapes/opening.html | 1 - .../examples-vite/shapes/polygon-suite.html | 1 - .../examples-vite/shapes/polygon.html | 1 - .../examples-vite/shapes/sphere.html | 1 - .../examples-vite/shapes/sweep.html | 1 - .../examples-vite/shapes/wedge.html | 1 - .../examples-vite/src/styles/theme.css | 26 +++++++++---------- 23 files changed, 30 insertions(+), 52 deletions(-) diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index 93505c1..ff3e01f 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -17,7 +17,7 @@ --og-shadow: 0 22px 44px rgba(72, 44, 24, 0.08); --og-font: "Space Grotesk", "Sora", "Avenir Next", "Segoe UI", sans-serif; --og-mono: "IBM Plex Mono", "JetBrains Mono", "SFMono-Regular", monospace; - --og-squircle: 32px 34px 30px 36px / 32px 28px 36px 30px; + --og-squircle-radius: 24px; } * { @@ -56,13 +56,21 @@ padding: 26px 8px 48px; } + .og-specs-head, + .og-spec-card, + .og-support, + .og-spec-open, + .og-support-link { + corner-shape: squircle; + border-radius: var(--og-squircle-radius); + } + .og-specs-head { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr); gap: 24px; padding: 22px 24px; border: 1px solid var(--og-line); - border-radius: var(--og-squircle); background: linear-gradient(145deg, rgba(255, 255, 255, 0.78), rgba(249, 239, 229, 0.9)); box-shadow: var(--og-shadow); } @@ -135,7 +143,6 @@ min-height: 312px; padding: 18px 18px 16px; border: 1px solid var(--og-line); - border-radius: var(--og-squircle); background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(250, 242, 235, 0.96)); box-shadow: var(--og-shadow); transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease; @@ -238,19 +245,12 @@ border-top: 1px solid rgba(95, 61, 46, 0.08); } - .og-spec-context { - color: var(--og-muted-soft); - font-size: 12px; - letter-spacing: 0.02em; - } - .og-spec-open { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 0 16px; - border-radius: 14px; border: 1px solid rgba(96, 87, 78, 0.14); background: #64584f; color: #fbf7f3; @@ -267,7 +267,6 @@ margin-top: 28px; padding: 20px 22px; border: 1px solid rgba(255, 84, 0, 0.18); - border-radius: var(--og-squircle); background: linear-gradient(145deg, rgba(255, 248, 242, 0.96), rgba(250, 239, 230, 0.92)); box-shadow: var(--og-shadow); } @@ -306,7 +305,6 @@ gap: 10px; min-height: 46px; padding: 0 18px; - border-radius: 14px; border: 1px solid rgba(255, 84, 0, 0.18); background: var(--og-accent); color: #fff7f2; @@ -658,7 +656,6 @@ "" + '
' + chipsMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/operations/door-export.html b/main/opengeometry-three/examples-vite/operations/door-export.html index 90fd9f5..0a9e391 100644 --- a/main/opengeometry-three/examples-vite/operations/door-export.html +++ b/main/opengeometry-three/examples-vite/operations/door-export.html @@ -244,7 +244,6 @@ "" + '
' + chipMarkup + "
" + '"; @@ -385,7 +384,8 @@ button.style.padding = "0.55rem 0.75rem"; button.style.cursor = "pointer"; button.style.border = "1px solid rgba(95, 61, 46, 0.14)"; - button.style.borderRadius = "14px"; + button.style.cornerShape = "squircle"; + button.style.borderRadius = "24px"; button.style.background = background; button.style.color = "#171717"; button.style.font = "600 14px/1.2 inherit"; diff --git a/main/opengeometry-three/examples-vite/operations/ifc-export.html b/main/opengeometry-three/examples-vite/operations/ifc-export.html index bd0bc3e..97a9003 100644 --- a/main/opengeometry-three/examples-vite/operations/ifc-export.html +++ b/main/opengeometry-three/examples-vite/operations/ifc-export.html @@ -90,7 +90,6 @@ "" + '
' + chipMarkup + "
" + '"; @@ -353,7 +352,8 @@ button.style.padding = "0.55rem 0.75rem"; button.style.cursor = "pointer"; button.style.border = "1px solid rgba(95, 61, 46, 0.14)"; - button.style.borderRadius = "14px"; + button.style.cornerShape = "squircle"; + button.style.borderRadius = "24px"; button.style.background = background; button.style.color = "#171717"; button.style.font = "600 14px/1.2 inherit"; diff --git a/main/opengeometry-three/examples-vite/operations/offset.html b/main/opengeometry-three/examples-vite/operations/offset.html index a15361b..80b3ea6 100644 --- a/main/opengeometry-three/examples-vite/operations/offset.html +++ b/main/opengeometry-three/examples-vite/operations/offset.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/operations/step-export.html b/main/opengeometry-three/examples-vite/operations/step-export.html index 0a67288..8119a67 100644 --- a/main/opengeometry-three/examples-vite/operations/step-export.html +++ b/main/opengeometry-three/examples-vite/operations/step-export.html @@ -90,7 +90,6 @@ "" + '
' + chipMarkup + "
" + '"; @@ -280,7 +279,8 @@ button.style.padding = "0.55rem 0.75rem"; button.style.cursor = "pointer"; button.style.border = "1px solid rgba(95, 61, 46, 0.14)"; - button.style.borderRadius = "14px"; + button.style.cornerShape = "squircle"; + button.style.borderRadius = "24px"; button.style.background = background; button.style.color = "#171717"; button.style.font = "600 14px/1.2 inherit"; diff --git a/main/opengeometry-three/examples-vite/operations/stl-export.html b/main/opengeometry-three/examples-vite/operations/stl-export.html index 85b7875..89b9923 100644 --- a/main/opengeometry-three/examples-vite/operations/stl-export.html +++ b/main/opengeometry-three/examples-vite/operations/stl-export.html @@ -90,7 +90,6 @@ "" + '
' + chipMarkup + "
" + '"; @@ -277,7 +276,8 @@ button.style.padding = "0.55rem 0.75rem"; button.style.cursor = "pointer"; button.style.border = "1px solid rgba(95, 61, 46, 0.14)"; - button.style.borderRadius = "14px"; + button.style.cornerShape = "squircle"; + button.style.borderRadius = "24px"; button.style.background = background; button.style.color = "#171717"; button.style.font = "600 14px/1.2 inherit"; diff --git a/main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html b/main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html index 4c211b7..d895e4a 100644 --- a/main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html +++ b/main/opengeometry-three/examples-vite/operations/sweep-hilbert-profiles.html @@ -46,7 +46,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html b/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html index 2cb271a..ef5b8f5 100644 --- a/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html +++ b/main/opengeometry-three/examples-vite/operations/sweep-path-profile.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html b/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html index 202f533..7218011 100644 --- a/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html +++ b/main/opengeometry-three/examples-vite/operations/wall-from-offsets.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/primitives/arc.html b/main/opengeometry-three/examples-vite/primitives/arc.html index 6c33150..37b9bdb 100644 --- a/main/opengeometry-three/examples-vite/primitives/arc.html +++ b/main/opengeometry-three/examples-vite/primitives/arc.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/primitives/curve.html b/main/opengeometry-three/examples-vite/primitives/curve.html index 814d823..24d40ba 100644 --- a/main/opengeometry-three/examples-vite/primitives/curve.html +++ b/main/opengeometry-three/examples-vite/primitives/curve.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/primitives/line.html b/main/opengeometry-three/examples-vite/primitives/line.html index eb47b7b..607facc 100644 --- a/main/opengeometry-three/examples-vite/primitives/line.html +++ b/main/opengeometry-three/examples-vite/primitives/line.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/primitives/polyline.html b/main/opengeometry-three/examples-vite/primitives/polyline.html index 6a635eb..f9c8bee 100644 --- a/main/opengeometry-three/examples-vite/primitives/polyline.html +++ b/main/opengeometry-three/examples-vite/primitives/polyline.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/primitives/rectangle.html b/main/opengeometry-three/examples-vite/primitives/rectangle.html index df817e7..cf96627 100644 --- a/main/opengeometry-three/examples-vite/primitives/rectangle.html +++ b/main/opengeometry-three/examples-vite/primitives/rectangle.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/cuboid.html b/main/opengeometry-three/examples-vite/shapes/cuboid.html index 9718a34..dce1801 100644 --- a/main/opengeometry-three/examples-vite/shapes/cuboid.html +++ b/main/opengeometry-three/examples-vite/shapes/cuboid.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/cylinder.html b/main/opengeometry-three/examples-vite/shapes/cylinder.html index a0f07d2..1658583 100644 --- a/main/opengeometry-three/examples-vite/shapes/cylinder.html +++ b/main/opengeometry-three/examples-vite/shapes/cylinder.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/opening.html b/main/opengeometry-three/examples-vite/shapes/opening.html index 4345f80..8c2774f 100644 --- a/main/opengeometry-three/examples-vite/shapes/opening.html +++ b/main/opengeometry-three/examples-vite/shapes/opening.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/polygon-suite.html b/main/opengeometry-three/examples-vite/shapes/polygon-suite.html index 7ac8edb..c36fb72 100644 --- a/main/opengeometry-three/examples-vite/shapes/polygon-suite.html +++ b/main/opengeometry-three/examples-vite/shapes/polygon-suite.html @@ -47,7 +47,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/polygon.html b/main/opengeometry-three/examples-vite/shapes/polygon.html index ca2f29c..5bf487b 100644 --- a/main/opengeometry-three/examples-vite/shapes/polygon.html +++ b/main/opengeometry-three/examples-vite/shapes/polygon.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/sphere.html b/main/opengeometry-three/examples-vite/shapes/sphere.html index 508b54e..97ef59f 100644 --- a/main/opengeometry-three/examples-vite/shapes/sphere.html +++ b/main/opengeometry-three/examples-vite/shapes/sphere.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/sweep.html b/main/opengeometry-three/examples-vite/shapes/sweep.html index dc5e29f..f3f53db 100644 --- a/main/opengeometry-three/examples-vite/shapes/sweep.html +++ b/main/opengeometry-three/examples-vite/shapes/sweep.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/shapes/wedge.html b/main/opengeometry-three/examples-vite/shapes/wedge.html index c9d2c0a..42b3d11 100644 --- a/main/opengeometry-three/examples-vite/shapes/wedge.html +++ b/main/opengeometry-three/examples-vite/shapes/wedge.html @@ -45,7 +45,6 @@ "" + '
' + chipMarkup + "
" + '"; diff --git a/main/opengeometry-three/examples-vite/src/styles/theme.css b/main/opengeometry-three/examples-vite/src/styles/theme.css index e459f52..a6c8489 100644 --- a/main/opengeometry-three/examples-vite/src/styles/theme.css +++ b/main/opengeometry-three/examples-vite/src/styles/theme.css @@ -13,7 +13,7 @@ --og-shadow: 0 22px 44px rgba(72, 44, 24, 0.08); --og-font: "Space Grotesk", "Sora", "Avenir Next", "Segoe UI", sans-serif; --og-mono: "IBM Plex Mono", "JetBrains Mono", "SFMono-Regular", monospace; - --og-squircle: 32px 34px 30px 36px / 32px 28px 36px 30px; + --og-squircle-radius: 24px; } * { @@ -52,13 +52,22 @@ code { padding: 26px 8px 48px; } +.og-specs-head, +.og-spec-card, +.og-spec-open, +.og-badge, +.og-badge-link, +.og-controls { + corner-shape: squircle; + border-radius: var(--og-squircle-radius); +} + .og-specs-head { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr); gap: 24px; padding: 22px 24px; border: 1px solid var(--og-line); - border-radius: var(--og-squircle); background: linear-gradient(145deg, rgba(255, 255, 255, 0.78), rgba(249, 239, 229, 0.9)); box-shadow: var(--og-shadow); } @@ -132,7 +141,6 @@ code { border: 1px solid var(--og-line); min-height: 312px; padding: 18px 18px 16px; - border-radius: var(--og-squircle); box-shadow: var(--og-shadow); transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } @@ -209,7 +217,7 @@ code { display: inline-flex; align-items: center; padding: 2px 9px; - corner-shape: squicle; + corner-shape: squircle; border-radius: 12px; border: 1px solid rgba(214, 132, 83, 0.14); background: rgba(246, 234, 226, 0.92); @@ -239,13 +247,6 @@ code { border-top: 1px solid rgba(95, 61, 46, 0.08); } -.og-spec-context, -.og-badge-context { - color: var(--og-muted-soft); - font-size: 12px; - letter-spacing: 0.02em; -} - .og-spec-open, .og-badge-link { display: inline-flex; @@ -253,7 +254,6 @@ code { justify-content: center; min-height: 42px; padding: 0 16px; - border-radius: 14px; border: 1px solid rgba(96, 87, 78, 0.14); background: #64584f; color: #fbf7f3; @@ -276,7 +276,6 @@ code { border: 1px solid var(--og-line); background: var(--og-panel); padding: 16px; - border-radius: var(--og-squircle); box-shadow: var(--og-shadow); z-index: 30; } @@ -325,7 +324,6 @@ code { border: 1px solid var(--og-line); background: var(--og-panel); padding: 14px; - border-radius: var(--og-squircle); box-shadow: var(--og-shadow); z-index: 30; } From 9ae98b7a5857dade11b43e28e2e878bda43fbc36 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Tue, 10 Mar 2026 18:52:50 +0100 Subject: [PATCH 09/10] update examples --- .../examples-vite/index.html | 696 +++++++++--------- .../examples-vite/operations/door-export.html | 341 +++++---- .../examples-vite/operations/ifc-export.html | 487 ++++++------ .../examples-vite/operations/offset.html | 225 +++--- .../examples-vite/operations/step-export.html | 448 ++++++----- .../examples-vite/operations/stl-export.html | 447 ++++++----- .../operations/sweep-hilbert-profiles.html | 266 ++++--- .../operations/sweep-path-profile.html | 262 ++++--- .../operations/wall-from-offsets.html | 252 ++++--- .../examples-vite/primitives/arc.html | 246 ++++--- .../examples-vite/primitives/curve.html | 216 +++--- .../examples-vite/primitives/line.html | 226 +++--- .../examples-vite/primitives/polyline.html | 215 +++--- .../examples-vite/primitives/rectangle.html | 226 +++--- .../examples-vite/shapes/cuboid.html | 245 +++--- .../examples-vite/shapes/cylinder.html | 255 ++++--- .../examples-vite/shapes/opening.html | 245 +++--- .../examples-vite/shapes/polygon-suite.html | 666 +++++++++++------ .../examples-vite/shapes/polygon.html | 234 +++--- .../examples-vite/shapes/sphere.html | 244 +++--- .../examples-vite/shapes/sweep.html | 272 ++++--- .../examples-vite/shapes/wedge.html | 245 +++--- .../examples-vite/src/styles/theme.css | 85 +++ 23 files changed, 3746 insertions(+), 3298 deletions(-) diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index ff3e01f..16252b6 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -4,7 +4,7 @@ OpenGeometry Three Examples - -
- +
+
+
+

OpenGeometry Three Example Catalog

+

Examples

+
+

+ Standalone Vite-served pages for primitives, shapes, and operation workflows. Read any example in one + file and open it directly from this catalog. +

+
+
+
+

Primitives

+

5 items

+
+
+
+ +

ready

+
+
+

Arc

+

Circular arc with angle span and segmentation control.

+
+
RadiusSpan
+ +
+
+
+ +

ready

+
+
+

Curve

+

Control-point curve for route and profile sketching.

+
+
SagLift
+ +
+
+
+ +

ready

+
+
+

Line

+

Two-point line primitive with direct endpoint control.

+
+
LengthAngle
+ +
+
+
+ +

ready

+
+
+

Polyline

+

Open and closed path definitions for profile work.

+
+
ClosureSpan
+ +
+
+
+ +

ready

+
+
+

Rectangle

+

Parametric rectangular primitive for base profiles.

+
+
WidthBreadth
+ +
+
+
+
+

Shapes

+

8 items

+
+
+
+ +

ready

+
+
+

Cuboid

+

Rectangular solid for rooms, equipment blocks and massing.

+
+
WidthHeightDepth
+ +
+
+
+ +

ready

+
+
+

Cylinder

+

Cylindrical volume for ducts, pipes and mechanical shafts.

+
+
RadiusHeightSegments
+ +
+
+
+ +

ready

+
+
+

Opening

+

Opening helper volume for void and penetration previews.

+
+
WidthHeightDepth
+ +
+
+
+ +

ready

+
+
+

Polygon

+

Planar polygon triangulation for surfaces and slabs.

+
+
SidesRadius
+ +
+
+
+ +

ready

+
+
+

Polygon Suite

+

Dataset-backed polygon validation with concave, performance, and multi-hole cases.

+
+
HolesConcavity
+ +
+
+
+ +

ready

+
+
+

Sphere

+

UV sphere for equipment envelopes and clearance studies.

+
+
RadiusSegments
+ +
+
+
+ +

ready

+
+
+

Sweep

+

Profile along path sweep for framing and custom sections.

+
+
PathCaps
+ +
+
+
+ +

ready

+
+
+

Wedge

+

Tapered solid for ramps and sloped technical elements.

+
+
WidthHeightDepth
+ +
+
+
+
+

Operations

+

8 items

+
+
+
+ +

ready

+
+
+

Door Export

+

Door panel as Cuboid plus frame from swept profile, exported through STL, STEP and IFC.

+
+
Door PanelFrame ProfileIFC
+ +
+
+
+ +

ready

+
+
+

IFC Export

+

IFC4 export with user-side semantics sidecar and optional web-ifc parse probe.

+
+
Semantics JSONIFC4
+ +
+
+
+ +

ready

+
+
+

Offset

+

Offset generation with acute-corner and bevel parameters.

+
+
OffsetBevel
+ +
+
+
+ +

ready

+
+
+

STEP Export

+

STEP Part-21 export workflow with scene-level reporting and download.

+
+
STEPShape
+ +
+
+
+ +

ready

+
+
+

STL Export

+

Binary STL export workflow with scene-level reporting and download.

+
+
Binary STLShape
+ +
+
+
+ +

ready

+
+
+

Sweep Hilbert Profiles

+

Locked Hilbert3D path with switchable kernel and custom section profiles.

+
+
Hilbert PathProfilesSweep
+ +
+
+
+ +

ready

+
+
+

Sweep Path + Profile

+

Operation-level sweep from path primitive plus profile primitive.

+
+
PathCaps
+ +
+
+
+ +

ready

+
+
+

Wall from Offsets

+

Composite wall profile assembled from offset centerlines.

+
+
OffsetsThickness
+ +
+
+
+
+

Need Help

+

Talk to the OpenGeometry team

+

+ Use Discord for example issues, geometry questions, and requests for new standalone demos. +

+
+ + + Join Discord + +
+
diff --git a/main/opengeometry-three/examples-vite/operations/door-export.html b/main/opengeometry-three/examples-vite/operations/door-export.html index 0a9e391..c30518d 100644 --- a/main/opengeometry-three/examples-vite/operations/door-export.html +++ b/main/opengeometry-three/examples-vite/operations/door-export.html @@ -8,7 +8,156 @@ + + + +
+