diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f2bac7..ab9c11b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to the `opengeometry` package are in this file. The format f [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Solid primitives `OG_PRIMITIVE_SLOT_PRISM`, `OG_PRIMITIVE_ROUNDED_BOX` and + `OG_PRIMITIVE_PIPE_SEGMENT`, with their `OG_PRIMITIVE_PARAMS_*` helpers and the + `SlotPrismParams`, `RoundedBoxParams` and `PipeSegmentParams` types. +- `OG_PRIMITIVE_POLYSOLID`: a solid wall of constant width and height along a path of straight + legs, with `justify` `'centre'`, `'left'` or `'right'`, mitred corners and an open or closed + path. `OG_PRIMITIVE_PARAMS_POLYSOLID` and the type `PolysolidParams` give it a + type. +- Wire primitives `OG_PRIMITIVE_REGULAR_POLYGON`, `OG_PRIMITIVE_SLOT` and + `OG_PRIMITIVE_ROUNDED_RECTANGLE`, with their `OG_PRIMITIVE_PARAMS_*` helpers and the + `RegularPolygonParams`, `SlotParams` and `RoundedRectangleParams` types. +- `OG_PRIMITIVE_POLYSOLID` paths take arc segments that join their neighbours tangentially. A + joint where an arc meets a segment at an angle throws `InvalidParameter` that names the joint. + ## [3.0.0] - 2026-11-09 3.0 is a new API. Code written for 2.0 does not run without changes. Version 2.5 was not @@ -70,7 +87,11 @@ published. Follow [MIGRATION.md](./MIGRATION.md): it gives every change with cod ### Removed - **Breaking:** IFC file text, STL and PDF export, projection, loft, triangulation as a public - call, curves other than lines, circles and arcs, and wedges. They have no replacement in 3.0. + call, and curves other than lines, circles, arcs and polylines, such as the elliptical arc. + They have no replacement in 3.0. 3.0 has no spline, no ellipse and no elliptical arc. The + wedge and the 2.0 `Curve` class are gone too. Build a wedge as a five-face + `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its + points. ### Fixed diff --git a/MIGRATION.md b/MIGRATION.md index 1af10d5f..cfa55af4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -73,7 +73,7 @@ const sector = new AnalyticSolid({ height: 3, }); const arc = new Arc({ center: new Vector3(0, 0, 0), radius: 2, startAngle: 0, sweepAngle: Math.PI / 2 }); -const wedge = new AnalyticSolid({ kind: 'cylinderSector', radius: 2, height: 3, startAngle: 0, sweepAngle: Math.PI / 2 }); +const quarterCylinder = new AnalyticSolid({ kind: 'cylinderSector', radius: 2, height: 3, startAngle: 0, sweepAngle: Math.PI / 2 }); const roof = new AnalyticSolid({ kind: 'planarPolyhedron', vertices: [[0, 0, 0], [4, 0, 0], [4, 0, 2], [0, 0, 2], [1, 1, 1], [3, 1, 1]], @@ -115,7 +115,7 @@ const sector = new Solid(OG_OPERATION_EXTRUDE, { profile, distance: 3 }); const arc = new Wire(OG_PRIMITIVE_ARC, OG_PRIMITIVE_PARAMS_ARC({ radius: 2, startAngle: 0, sweepAngle: Math.PI / 2 })); -const wedge = new Solid( +const quarterCylinder = new Solid( OG_PRIMITIVE_CYLINDER_SECTOR, OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR({ radius: 2, height: 3, startAngle: 0, sweepAngle: Math.PI / 2 }), ); @@ -760,6 +760,8 @@ In the 2.5 column, a `?` after a parameter name marks it as optional, and rows t - `WorldGraph`: nodes and hierarchy become `SystemAssembly`, `addChild` or the `parent` option; `getWorldBounds(id)` becomes `body.getBounds()`, six numbers instead of a `THREE.Box3`; `removeNode(id)` becomes `parent.removeChild(body)`, which keeps the body (pass `{ keepWorld: true }` to leave it where it is), or `body.dispose()`, which frees it and everything under it; a body with no parent has only `dispose()`; `exportStep` becomes `OpenGeometry.exportStep({ nodes })` (nodes may be bodies or assemblies). Its `clash`, `clearance`, `distance`, `projectLines`, `projectViews` and `bind` are gone. - The pattern helpers (`AnalyticPattern`, `linearPattern`, `rectangularPattern`, `circularPattern`): make copies with `solid.instance()` and move each with `transform`; check the positions, since 2.0 drew a pattern around its own origin. `rebuild` and `operate` on an instanced body need `{ instances: 'all' }` or `makeUnique()` first. - `AnalyticSolid` kinds: `linearExtrusion` becomes `OG_OPERATION_EXTRUDE` with closed polyline wires for the outline and each hole, each 2.0 point `[u, v]` written `[u, 0, -v]` when the 2.0 `frame` was the default (with another `frame`, also give each wire `plane: { origin: frame.origin, normal: frame.z, xDirection: frame.x }` and check which way it extrudes); `revolvedRectangle` (a tube) becomes `OG_OPERATION_EXTRUDE` with a circle wire as `profile` and a smaller circle wire with the same centre as the one hole. +- `Wedge`: `OG_OPERATION_EXTRUDE` with a closed triangle polyline wire as `profile`; give the wire `plane: { normal: [0, 0, 1], xDirection: [1, 0, 0] }` so it extrudes along z as the 2.0 wedge did, and move it by the 2.0 `center`; from 3.0, also a five-face `OG_PRIMITIVE_POLYHEDRON`. +- `Curve`: a polyline through its control points, so `new Wire(OG_PRIMITIVE_POLYLINE, { points })` through the same points. ### Removed in 2.5 @@ -767,7 +769,7 @@ These are gone: - STL and IFC export. PDF export was never in the browser build. - Projection and 2D views, offset, loft, and public triangulation (`tessellate_brep`, `tessellateFacetedBrep`). -- `Sphere`, `Wedge`, `Curve`, `EllipticalArc`, `AnalyticCurve`, arcs that are not full circles, and cylinder sectors. +- `Sphere`, `EllipticalArc`, `AnalyticCurve`, arcs that are not full circles, and cylinder sectors. - `AnalyticSolid` kinds other than cuboid, cylinder, `linearExtrusion` and `revolvedRectangle`, `AnalyticSolid.fromBrep`, and `new Solid({ brep })`. - `WorldGraph`'s queries and projection: `clash`, `clearance`, `distance`, `projectLines`, `projectViews` and `bind`. - The older scene manager (`add*ToScene`, `replaceBrepEntityInScene`, `refreshBrepEntityInScene`), already removed in 2.0.16. diff --git a/README.md b/README.md index 68fc2992..5a159d23 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ It is the geometry engine layer, not a full CAD application. Floorkit is a downs | Area | What 3.0 has | | --- | --- | -| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron. Each can be placed on any plane. | +| **Primitives** | Wires: rectangle, circle, polyline (open or closed), line-and-arc profile, arc, regular polygon, slot, rounded rectangle. Solids: cuboid, cylinder, cylinder sector, sphere, cone or frustum, ring torus, planar polyhedron, slot prism, rounded box, pipe segment, polysolid wall along a path of straight legs and tangent arcs. Each can be placed on any plane. | | **Operations** | Extrude a closed wire into a solid, with holes. Sweep a profile along an open or closed path of lines and arcs, each arc an exact bend, capped into a solid, open-ended into a sheet, or closed into a ring. Revolve a closed line-and-arc profile a full turn about an axis in its plane. Shell a solid into a hollow one with walls of one thickness. Union, subtract and intersect, with one to a hundred tool solids in one call. | | **Parametric edits** | `rebuild` gives an existing body new parameters in place. `mirror` rebuilds a body as its reflection across a plane. | | **Assemblies** | Assemblies with parent and child placement; translate, rotate, scale and place; independent copies and shared instances. | @@ -51,7 +51,7 @@ It is the geometry engine layer, not a full CAD application. Floorkit is a downs | **Queries** | Bounds, placements, the B-rep of a body, that B-rep placed in the world, the normal of a face at a point, whether a point is inside, volume and area within a stated error bound, whether a body is valid and closed, and the report of its last boolean. | | **Export** | STEP, in millimetres or metres, with Y or Z up, together with an export report. The IFC exchange body of a solid, in world metres with Z up, for an IFC writer. | -Not in 3.0, though 2.0 had them: IFC file text and STL export; projection; loft; triangulation as a public call; curves other than lines, circles and arcs; wedges; and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. +Not in 3.0, though 2.0 had them: IFC file text and STL export; projection; loft; triangulation as a public call; curves other than lines, circles, arcs and polylines, such as the elliptical arc; and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. 3.0 has no spline, no ellipse and no elliptical arc. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. ## Installation @@ -164,10 +164,10 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | Export | Call and parameters | | --- | --- | | `OpenGeometry.create` | `await OpenGeometry.create(input?, options?)`. `input`: `{ wasmURL?, wasmModule? }`. `options`: `{ workerURL?, tessellation?: 'worker' \| 'inline' }`. | -| `Wire` | `new Wire(kind, params, options?)`. `OG_PRIMITIVE_RECTANGLE` `{ width, breadth }`, `OG_PRIMITIVE_CIRCLE` `{ radius }`, `OG_PRIMITIVE_POLYLINE` `{ points, closed? }`. | +| `Wire` | `new Wire(kind, params, options?)`. `OG_PRIMITIVE_RECTANGLE` `{ width, breadth }`, `OG_PRIMITIVE_CIRCLE` `{ radius }`, `OG_PRIMITIVE_POLYLINE` `{ points, closed? }`, `OG_PRIMITIVE_REGULAR_POLYGON` `{ sides, radius }` (3 to 1024 corners on a circle about the `plane` origin, the first on the x direction), `OG_PRIMITIVE_SLOT` `{ length, width }` (the outline of `OG_PRIMITIVE_SLOT_PRISM`), `OG_PRIMITIVE_ROUNDED_RECTANGLE` `{ width, breadth, radius }` (a rectangle with rounded corners; `radius` below half the width and half the breadth). | | Profile wire | `new Wire(OG_PRIMITIVE_PROFILE, { start, segments, closed }, options?)`. Points are `[u, v]` in `plane`; a segment is `{ to }` for a line or `{ to, arc: { center, counterClockwise } }` for an arc. A closed profile is an extrude profile or hole. | | Arc wire | `new Wire(OG_PRIMITIVE_ARC, { radius, startAngle, sweepAngle }, options?)`. Angles in radians from the `plane` x direction, centred on the `plane` origin; the sweep is nonzero and less than one turn. | -| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis); `profile`, `holes` and `path` are wires. | +| `Solid` | `new Solid(kind, params, options?)`. `OG_PRIMITIVE_CUBOID` `{ width, height, depth }`, `OG_PRIMITIVE_CYLINDER` `{ radius, height }`, `OG_PRIMITIVE_CYLINDER_SECTOR` `{ radius, height, startAngle, sweepAngle }` (radians from the `plane` x direction, counter-clockwise seen from the side the normal points to; sweep nonzero and less than one turn), `OG_PRIMITIVE_POLYHEDRON` `{ points, faces }` (planar faces as rings of point indices, counter-clockwise seen from outside), `OG_PRIMITIVE_SPHERE` `{ radius }` (centred on the `plane` origin), `OG_PRIMITIVE_CONE` `{ radiusBottom, radiusTop, height }` (standing on the `plane`; one radius may be zero, equal radii make a cylinder), `OG_PRIMITIVE_TORUS` `{ majorRadius, minorRadius }` (lying on the `plane`; minor below major), `OG_OPERATION_EXTRUDE` `{ profile, holes?, distance }`, `OG_OPERATION_SWEEP` `{ profile, path, caps? }`, `OG_OPERATION_REVOLVE` `{ profile, axis? }` (a full turn about `axis` `{ point: [u, v], direction: [du, dv] }` in the profile's `[u, v]`, by default its v axis), `OG_PRIMITIVE_SLOT_PRISM` `{ length, width, height }` (a stadium standing on the `plane`, centred on its origin; `length` between the arc centres along the x direction), `OG_PRIMITIVE_ROUNDED_BOX` `{ width, height, depth, radius }` (a cuboid with its four upright edges rounded; `radius` below half the width and half the depth), `OG_PRIMITIVE_PIPE_SEGMENT` `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` (a ring sector placed and angled like the cylinder sector), `OG_PRIMITIVE_POLYSOLID` `{ path, width, height, justify? }` (a wall of constant width along `path`, a `{ start, segments, closed }` of straight legs and tangent arcs in the `plane`'s `[u, v]`, standing on the `plane` and rising along its normal; `justify` is `'centre'` (default), `'left'` or `'right'` of the path direction); `profile`, `holes` and `path` are wires. | | `Sheet` | `new Sheet(OG_OPERATION_SWEEP, { profile, path, caps: false })`: an open-ended sweep, faces that enclose no volume. It shows its surface, from the front only, and its outline; `OpenGeometry.exportStep` refuses it. | | Body options | `{ ogId?, parent?, appearance?, plane? }`. `plane` is `{ origin?, normal?, xDirection? }` and applies to primitives only; by default it is the ground plane through the origin, normal `[0, 1, 0]`, x direction `[1, 0, 0]`. | | Booleans | `solid.operate(kind, { tools, instances? })` with `OG_OPERATION_SUBTRACT`, `OG_OPERATION_UNION` or `OG_OPERATION_INTERSECT`; `tools` is one to a hundred solids. | @@ -189,7 +189,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o | 2D offsets | `OpenGeometry.offsetRingVariable(ring, distances, holes?)` insets a closed ring by one distance per edge, each hole `{ ring, distances }` grown by its own; `OpenGeometry.offsetCurvedRingVariable(region, distances, holeDistances?)` insets one `CurvedRegion2D` of lines and arcs by one distance (>= 0) per edge, one list per hole, each arc kept on its centre at its own distance and refused when that distance passes the centre; `OpenGeometry.offsetPolylineRegions(path, width, closed, miterLimit?)` strokes a path. Both return `CurvedRegion2D[]` with exact arcs. `OpenGeometry.insetCurvedRegion2D(region, distance)` insets one `CurvedRegion2D` by a distance above zero, holes grown by the same, and returns `CurvedRegion2D[]` of exact lines and arcs: each arc keeps its centre and a reflex corner rounds to an arc of the distance; an inset past the half width is an empty list. `OpenGeometry.offsetPolyline(path, distance, { acuteThresholdDegrees?, bevel?, closed? })` offsets a path to one side and returns `{ edges, bevelled, closed }` (type `OffsetPath2D`). Rings and paths are flat `[x, y, z, ...]` lists; `y` is ignored. | | `OGError` | Failures throw an `OGError` with `code` (for example `InvalidParameter`, `SharedShape`, `Disposed`), `call`, `message` and `details`. | | Reset | `OpenGeometry.reset()` frees every body and leaves an empty document. | -| Typed parameters | `OG_PRIMITIVE_PARAMS_(params)`, `OG_OPERATION_PARAMS_EXTRUDE(params)`, `OG_OPERATION_PARAMS_SWEEP(params)` and `OG_OPERATION_PARAMS_REVOLVE(params)` return their argument unchanged; they only give the object a type. | +| Typed parameters | `OG_PRIMITIVE_PARAMS_(params)`, `OG_OPERATION_PARAMS_EXTRUDE(params)`, `OG_OPERATION_PARAMS_SWEEP(params)` and `OG_OPERATION_PARAMS_REVOLVE(params)` return their argument unchanged; they only give the object a type. The entry exports the parameter types too, such as `ProfileParams` and `PolysolidParams`. | **Rules that are easy to get wrong.** @@ -218,7 +218,7 @@ This section is a compact reference for writing OpenGeometry 3.0 code, by hand o - Call `dispose()` on bodies and assemblies you no longer need. - Catch `OGError` and branch on `code`. After a `'fatal'` event the runtime refuses every call until `OpenGeometry.reset()`. -**What does not exist.** IFC file text, STL and PDF export, projection, loft, public triangulation, curves other than lines, circles and arcs, wedges, and the 2.0 classes. Do not call them. +**What does not exist.** IFC file text, STL and PDF export, projection, loft, public triangulation, curves other than lines, circles, arcs and polylines, such as the elliptical arc, and the 2.0 classes. Do not call them. 3.0 has no spline, no ellipse and no elliptical arc. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. **Where the truth is.** `index.d.ts` in the installed package lists every export and its types. The examples in [`main/opengeometry-three/examples-vite/`](./main/opengeometry-three/examples-vite/) are working 3.0 code, except that they import the built package by path (`../../../dist/index.js`) where an app imports from `'opengeometry'`. diff --git a/docs/api-coverage.json b/docs/api-coverage.json index 0f545529..8f0a70e8 100644 --- a/docs/api-coverage.json +++ b/docs/api-coverage.json @@ -129,6 +129,41 @@ "kind": "type", "page": "opengeometry/api/constants" }, + { + "export": "SlotPrismParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "RoundedBoxParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "PipeSegmentParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "PolysolidParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "RegularPolygonParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "SlotParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, + { + "export": "RoundedRectangleParams", + "kind": "type", + "page": "opengeometry/api/constants" + }, { "export": "CurvedRegion2D", "kind": "type", @@ -219,6 +254,41 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_SLOT_PRISM", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_ROUNDED_BOX", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PIPE_SEGMENT", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_POLYSOLID", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_REGULAR_POLYGON", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_SLOT", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_ROUNDED_RECTANGLE", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_PRIMITIVE_PARAMS_RECTANGLE", "kind": "value", @@ -279,6 +349,41 @@ "kind": "value", "page": "opengeometry/api/constants" }, + { + "export": "OG_PRIMITIVE_PARAMS_SLOT_PRISM", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_ROUNDED_BOX", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_PIPE_SEGMENT", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_POLYSOLID", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_REGULAR_POLYGON", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_SLOT", + "kind": "value", + "page": "opengeometry/api/constants" + }, + { + "export": "OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE", + "kind": "value", + "page": "opengeometry/api/constants" + }, { "export": "OG_OPERATION_EXTRUDE", "kind": "value", diff --git a/docs/opengeometry/api/constants.mdx b/docs/opengeometry/api/constants.mdx index ce9fefdc..0e3d5d56 100644 --- a/docs/opengeometry/api/constants.mdx +++ b/docs/opengeometry/api/constants.mdx @@ -8,20 +8,28 @@ Each kind is a string constant. Pass it as the first argument of a constructor, ## Primitives -| Constant | Value | Body | Parameters type | -| ------------------------------ | ------------------ | ----- | -------------------------- | -| `OG_PRIMITIVE_RECTANGLE` | `'Rectangle'` | Wire | `{ width, breadth }` | -| `OG_PRIMITIVE_CIRCLE` | `'Circle'` | Wire | `{ radius }` | -| `OG_PRIMITIVE_POLYLINE` | `'Polyline'` | Wire | `{ points, closed? }` | -| `OG_PRIMITIVE_PROFILE` | `'Profile'` | Wire | `ProfileParams` | -| `OG_PRIMITIVE_ARC` | `'Arc'` | Wire | `ArcParams` | -| `OG_PRIMITIVE_CUBOID` | `'Cuboid'` | Solid | `{ width, height, depth }` | -| `OG_PRIMITIVE_CYLINDER` | `'Cylinder'` | Solid | `{ radius, height }` | -| `OG_PRIMITIVE_CYLINDER_SECTOR` | `'CylinderSector'` | Solid | `CylinderSectorParams` | -| `OG_PRIMITIVE_POLYHEDRON` | `'Polyhedron'` | Solid | `PolyhedronParams` | -| `OG_PRIMITIVE_SPHERE` | `'Sphere'` | Solid | `SphereParams` | -| `OG_PRIMITIVE_CONE` | `'Cone'` | Solid | `ConeParams` | -| `OG_PRIMITIVE_TORUS` | `'Torus'` | Solid | `TorusParams` | +| Constant | Value | Body | Parameters type | +| -------------------------------- | -------------------- | ----- | -------------------------- | +| `OG_PRIMITIVE_RECTANGLE` | `'Rectangle'` | Wire | `{ width, breadth }` | +| `OG_PRIMITIVE_CIRCLE` | `'Circle'` | Wire | `{ radius }` | +| `OG_PRIMITIVE_POLYLINE` | `'Polyline'` | Wire | `{ points, closed? }` | +| `OG_PRIMITIVE_PROFILE` | `'Profile'` | Wire | `ProfileParams` | +| `OG_PRIMITIVE_ARC` | `'Arc'` | Wire | `ArcParams` | +| `OG_PRIMITIVE_CUBOID` | `'Cuboid'` | Solid | `{ width, height, depth }` | +| `OG_PRIMITIVE_CYLINDER` | `'Cylinder'` | Solid | `{ radius, height }` | +| `OG_PRIMITIVE_CYLINDER_SECTOR` | `'CylinderSector'` | Solid | `CylinderSectorParams` | +| `OG_PRIMITIVE_POLYHEDRON` | `'Polyhedron'` | Solid | `PolyhedronParams` | +| `OG_PRIMITIVE_SPHERE` | `'Sphere'` | Solid | `SphereParams` | +| `OG_PRIMITIVE_CONE` | `'Cone'` | Solid | `ConeParams` | +| `OG_PRIMITIVE_TORUS` | `'Torus'` | Solid | `TorusParams` | +| `OG_PRIMITIVE_SLOT_PRISM` | `'SlotPrism'` | Solid | `SlotPrismParams` | +| `OG_PRIMITIVE_ROUNDED_BOX` | `'RoundedBox'` | Solid | `RoundedBoxParams` | +| `OG_PRIMITIVE_PIPE_SEGMENT` | `'PipeSegment'` | Solid | `PipeSegmentParams` | +| `OG_PRIMITIVE_POLYSOLID` | `'Polysolid'` | Solid | `PolysolidParams` | +| -------------------------------- | -------------------- | ----- | -------------------------- | +| `OG_PRIMITIVE_REGULAR_POLYGON` | `'RegularPolygon'` | Wire | `RegularPolygonParams` | +| `OG_PRIMITIVE_SLOT` | `'Slot'` | Wire | `SlotParams` | +| `OG_PRIMITIVE_ROUNDED_RECTANGLE` | `'RoundedRectangle'` | Wire | `RoundedRectangleParams` | ## Operations @@ -48,23 +56,31 @@ Each kind is a string constant. Pass it as the first argument of a constructor, Each helper returns its argument unchanged. It only gives the object a type, so the compiler checks the field names. -| Helper | Type | -| ------------------------------------- | ------------------------------- | -| `OG_PRIMITIVE_PARAMS_RECTANGLE` | `{ width, breadth }` | -| `OG_PRIMITIVE_PARAMS_CIRCLE` | `{ radius }` | -| `OG_PRIMITIVE_PARAMS_POLYLINE` | `{ points, closed? }` | -| `OG_PRIMITIVE_PARAMS_CUBOID` | `{ width, height, depth }` | -| `OG_PRIMITIVE_PARAMS_CYLINDER` | `{ radius, height }` | -| `OG_PRIMITIVE_PARAMS_PROFILE` | `ProfileParams` | -| `OG_PRIMITIVE_PARAMS_ARC` | `ArcParams` | -| `OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR` | `CylinderSectorParams` | -| `OG_PRIMITIVE_PARAMS_POLYHEDRON` | `PolyhedronParams` | -| `OG_PRIMITIVE_PARAMS_SPHERE` | `SphereParams` | -| `OG_PRIMITIVE_PARAMS_CONE` | `ConeParams` | -| `OG_PRIMITIVE_PARAMS_TORUS` | `TorusParams` | -| `OG_OPERATION_PARAMS_EXTRUDE` | `{ profile, holes?, distance }` | -| `OG_OPERATION_PARAMS_SWEEP` | `{ profile, path, caps? }` | -| `OG_OPERATION_PARAMS_REVOLVE` | `{ profile, axis? }` | +| Helper | Type | +| --------------------------------------- | ------------------------------- | +| `OG_PRIMITIVE_PARAMS_RECTANGLE` | `{ width, breadth }` | +| `OG_PRIMITIVE_PARAMS_CIRCLE` | `{ radius }` | +| `OG_PRIMITIVE_PARAMS_POLYLINE` | `{ points, closed? }` | +| `OG_PRIMITIVE_PARAMS_CUBOID` | `{ width, height, depth }` | +| `OG_PRIMITIVE_PARAMS_CYLINDER` | `{ radius, height }` | +| `OG_PRIMITIVE_PARAMS_PROFILE` | `ProfileParams` | +| `OG_PRIMITIVE_PARAMS_ARC` | `ArcParams` | +| `OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR` | `CylinderSectorParams` | +| `OG_PRIMITIVE_PARAMS_POLYHEDRON` | `PolyhedronParams` | +| `OG_PRIMITIVE_PARAMS_SPHERE` | `SphereParams` | +| `OG_PRIMITIVE_PARAMS_CONE` | `ConeParams` | +| `OG_PRIMITIVE_PARAMS_TORUS` | `TorusParams` | +| `OG_PRIMITIVE_PARAMS_POLYSOLID` | `PolysolidParams` | +| `OG_OPERATION_PARAMS_EXTRUDE` | `{ profile, holes?, distance }` | +| `OG_OPERATION_PARAMS_SWEEP` | `{ profile, path, caps? }` | +| `OG_OPERATION_PARAMS_REVOLVE` | `{ profile, axis? }` | +| `OG_PRIMITIVE_PARAMS_SLOT_PRISM` | `SlotPrismParams` | +| `OG_PRIMITIVE_PARAMS_ROUNDED_BOX` | `RoundedBoxParams` | +| `OG_PRIMITIVE_PARAMS_PIPE_SEGMENT` | `PipeSegmentParams` | +| --------------------------------------- | ------------------------------- | +| `OG_PRIMITIVE_PARAMS_REGULAR_POLYGON` | `RegularPolygonParams` | +| `OG_PRIMITIVE_PARAMS_SLOT` | `SlotParams` | +| `OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE` | `RoundedRectangleParams` | ```ts import { @@ -85,23 +101,31 @@ const params = OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR({ startAngle: 0, sweepAngle: Math.PI / 2, }); -const wedge = new Solid(OG_PRIMITIVE_CYLINDER_SECTOR, params); -console.log(wedge.validity().valid); +const sector = new Solid(OG_PRIMITIVE_CYLINDER_SECTOR, params); +console.log(sector.validity().valid); ``` ## Parameter types -| Type | Fields | -| ---------------------- | ------------------------------------------------------- | -| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | -| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | -| `ProfileArc` | `center: [u, v]`, `counterClockwise` | -| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | -| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | -| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | -| `SphereParams` | `radius` | -| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | -| `TorusParams` | `majorRadius`, `minorRadius` | +| Type | Fields | +| ------------------------ | --------------------------------------------------------------------------------------- | +| `ProfileParams` | `start: [u, v]`, `segments: ProfileSegment[]`, `closed` | +| `ProfileSegment` | `to: [u, v]`, `arc?: ProfileArc` | +| `ProfileArc` | `center: [u, v]`, `counterClockwise` | +| `ArcParams` | `radius`, `startAngle`, `sweepAngle` | +| `CylinderSectorParams` | `radius`, `height`, `startAngle`, `sweepAngle` | +| `PolyhedronParams` | `points: [x, y, z][]`, `faces: number[][]` | +| `SphereParams` | `radius` | +| `ConeParams` | `radiusBottom`, `radiusTop`, `height` | +| `TorusParams` | `majorRadius`, `minorRadius` | +| `SlotPrismParams` | `length`, `width`, `height` | +| `RoundedBoxParams` | `width`, `height`, `depth`, `radius` | +| `PipeSegmentParams` | `outerRadius`, `innerRadius`, `height`, `startAngle`, `sweepAngle` | +| `PolysolidParams` | `path: ProfileParams`, `width`, `height`, `justify?`: `'centre'`, `'left'` or `'right'` | +| ------------------------ | ------------------------------------------------------------------ | +| `RegularPolygonParams` | `sides`, `radius` | +| `SlotParams` | `length`, `width` | +| `RoundedRectangleParams` | `width`, `breadth`, `radius` | ## OG_REGION_TOLERANCE diff --git a/docs/opengeometry/api/overview.mdx b/docs/opengeometry/api/overview.mdx index 02235b3b..b83388a8 100644 --- a/docs/opengeometry/api/overview.mdx +++ b/docs/opengeometry/api/overview.mdx @@ -28,15 +28,15 @@ The methods that `Wire`, `Solid` and `Sheet` share are on [Body methods](/openge ## Types -| Type | Page | -| ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | -| `HostGroup` | [Display](/opengeometry/api/display) | -| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | -| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | -| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | -| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | -| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams` | [Constants](/opengeometry/api/constants) | +| Type | Page | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `BodyEventMap`, `PointClassification`, `Measure`, `Validity`, `MirrorPlane` | [Body methods](/opengeometry/api/body) | +| `HostGroup` | [Display](/opengeometry/api/display) | +| `ErrorDetails`, `OperateErrorDetails` | [OGError](/opengeometry/api/og-error) | +| `AnalyticExchangeBodyV2`, `IfcPreparedValue` | [Export](/opengeometry/api/export) | +| `OffsetRegion`, `CurvedRegion2D`, `CurvedRegionEdge` | [Regions](/opengeometry/api/regions) | +| `OffsetHole`, `OffsetPath2D`, `OffsetPolylineOptions` | [Offsets](/opengeometry/api/offsets) | +| `ProfileParams`, `ProfileSegment`, `ProfileArc`, `ArcParams`, `CylinderSectorParams`, `PolyhedronParams`, `SphereParams`, `ConeParams`, `TorusParams`, `SlotPrismParams`, `RoundedBoxParams`, `PipeSegmentParams`, `PolysolidParams`, `RegularPolygonParams`, `SlotParams`, `RoundedRectangleParams` | [Constants](/opengeometry/api/constants) | ## Conventions diff --git a/docs/opengeometry/api/solid.mdx b/docs/opengeometry/api/solid.mdx index a836ed71..2059e8cb 100644 --- a/docs/opengeometry/api/solid.mdx +++ b/docs/opengeometry/api/solid.mdx @@ -34,18 +34,22 @@ console.log(base.getReport()?.report.operation); `new Solid(kind: string, params: Record, options?: BodyOptions)` -| `kind` | `params` | -| ------------------------------ | -------------------------------------------- | -| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | -| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | -| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | -| `OG_PRIMITIVE_SPHERE` | `{ radius }` | -| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | -| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | -| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | -| `OG_OPERATION_EXTRUDE` | `{ profile, holes?, distance }` | -| `OG_OPERATION_SWEEP` | `{ profile, path, caps? }` | -| `OG_OPERATION_REVOLVE` | `{ profile, axis? }` | +| `kind` | `params` | +| ------------------------------ | -------------------------------------------------------------- | +| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | +| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | +| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | +| `OG_PRIMITIVE_SPHERE` | `{ radius }` | +| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | +| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | +| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | +| `OG_OPERATION_EXTRUDE` | `{ profile, holes?, distance }` | +| `OG_OPERATION_SWEEP` | `{ profile, path, caps? }` | +| `OG_OPERATION_REVOLVE` | `{ profile, axis? }` | +| `OG_PRIMITIVE_SLOT_PRISM` | `{ length, width, height }` | +| `OG_PRIMITIVE_ROUNDED_BOX` | `{ width, height, depth, radius }` | +| `OG_PRIMITIVE_PIPE_SEGMENT` | `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` | +| `OG_PRIMITIVE_POLYSOLID` | `{ path, width, height, justify? }` | `profile`, `holes` and `path` are wires. A sweep with `caps: false` makes a sheet, so `new Solid` throws `BodyTypeMismatch` for it. diff --git a/docs/opengeometry/api/wire.mdx b/docs/opengeometry/api/wire.mdx index eb8e0cf9..4d46868e 100644 --- a/docs/opengeometry/api/wire.mdx +++ b/docs/opengeometry/api/wire.mdx @@ -28,13 +28,16 @@ console.log(path.bodyType, path.validity().closed); `new Wire(kind: string, params: Record, options?: BodyOptions)` -| `kind` | `params` | -| ------------------------ | ------------------------------------ | -| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | -| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | -| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | -| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | -| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | +| `kind` | `params` | +| -------------------------------- | ------------------------------------ | +| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | +| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | +| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | +| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | +| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | +| `OG_PRIMITIVE_REGULAR_POLYGON` | `{ sides, radius }` | +| `OG_PRIMITIVE_SLOT` | `{ length, width }` | +| `OG_PRIMITIVE_ROUNDED_RECTANGLE` | `{ width, breadth, radius }` | A kind that makes another body type throws `BodyTypeMismatch`. See [Primitives](/opengeometry/guides/primitives). diff --git a/docs/opengeometry/changelog.mdx b/docs/opengeometry/changelog.mdx index 19cf3a3a..6ec4a03f 100644 --- a/docs/opengeometry/changelog.mdx +++ b/docs/opengeometry/changelog.mdx @@ -5,6 +5,17 @@ icon: clock-rotate-left rss: true --- + + +### Added + +- Solid primitives: slot prism, rounded box and pipe segment. See [Primitives](/opengeometry/guides/primitives). +- **Polysolid.** `OG_PRIMITIVE_POLYSOLID` makes a solid wall of constant width and height along a path of straight legs, justified `'centre'`, `'left'` or `'right'`, with mitred corners. See [Primitives](/opengeometry/guides/primitives). +- Wire primitives: regular polygon, slot and rounded rectangle. See [Primitives](/opengeometry/guides/primitives). +- **Polysolid arcs.** A polysolid path takes arc segments that join their neighbours tangentially. A joint where an arc meets a segment at an angle throws `InvalidParameter` that names the joint. See [Primitives](/opengeometry/guides/primitives). + + + 3.0 is a new API. Code written for 2.0 does not run without changes. Follow [Migrate from 2.0](/opengeometry/migrate-from-2-0). @@ -21,7 +32,7 @@ rss: true - **Breaking: the shell hollows in place.** Use `solid.operate(OG_OPERATION_SHELL, { thickness })`. - **Breaking: display follows drawing.** A body gets its mesh when it is drawn or walked. Its `surface` and `outline` meshes are static: call `updateMatrix()` after you move them yourself. - **Smooth edges.** Outlines leave out smooth edges, and faceted curves shade smooth. Set `appearance.smoothAngle` to `0` for the 2.0 look. -- **Removed:** IFC file text, STL and PDF export, projection, loft, public triangulation, curves other than lines, circles and arcs, and wedges. +- **Removed:** IFC file text, STL and PDF export, projection, loft, public triangulation, and curves other than lines, circles, arcs and polylines, such as the elliptical arc. 3.0 has no spline, no ellipse and no elliptical arc. The wedge and the 2.0 `Curve` class are gone too. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. ### Added diff --git a/docs/opengeometry/examples.mdx b/docs/opengeometry/examples.mdx index 4e53cf25..4a86ef39 100644 --- a/docs/opengeometry/examples.mdx +++ b/docs/opengeometry/examples.mdx @@ -19,6 +19,11 @@ The examples are working 3.0 code. They are in [`main/opengeometry-three/example | `shapes/cuboid.html` | A cuboid with width, height, depth and an outline toggle | | `shapes/cylinder.html` | An exact cylinder. Click a face to inspect it | | `shapes/rounded-profile.html` | A section with rounded corners, extruded from a line-and-arc profile | +| `shapes/slot-prism.html` | A slot prism with length, width and height controls | +| `shapes/rounded-box.html` | A rounded box with width, height, depth and corner radius controls | +| `shapes/pipe-segment.html` | A pipe segment with both radii, height and the two angles as controls | +| `shapes/polysolid.html` | A polysolid wall on straight legs or a tangent arc, with width, height and justify | +| `shapes/wire-kinds.html` | A regular polygon, a slot and a rounded rectangle wire, each extruded into a prism | ## Run them diff --git a/docs/opengeometry/guides/primitives.mdx b/docs/opengeometry/guides/primitives.mdx index b714b70c..54dc553a 100644 --- a/docs/opengeometry/guides/primitives.mdx +++ b/docs/opengeometry/guides/primitives.mdx @@ -8,13 +8,51 @@ A primitive is a body made from a few parameters. Pass the kind as the first arg ## Wires -| Kind | Parameters | Notes | -| ------------------------ | ------------------------------------ | ----------------------------------------------------------------------- | -| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | Centred on the plane origin. `width` along u, `breadth` along v | -| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | Centred on the plane origin | -| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | `points` are `[x, y, z]` in the body's own space | -| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | Lines and arcs, points as `[u, v]` in the plane | -| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | Centred on the plane origin. The sweep is not zero and less than a turn | +| Kind | Parameters | Notes | +| -------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `OG_PRIMITIVE_RECTANGLE` | `{ width, breadth }` | Centred on the plane origin. `width` along u, `breadth` along v | +| `OG_PRIMITIVE_CIRCLE` | `{ radius }` | Centred on the plane origin | +| `OG_PRIMITIVE_POLYLINE` | `{ points, closed? }` | `points` are `[x, y, z]` in the body's own space | +| `OG_PRIMITIVE_PROFILE` | `{ start, segments, closed }` | Lines and arcs, points as `[u, v]` in the plane | +| `OG_PRIMITIVE_ARC` | `{ radius, startAngle, sweepAngle }` | Centred on the plane origin. The sweep is not zero and less than a turn | +| `OG_PRIMITIVE_REGULAR_POLYGON` | `{ sides, radius }` | A closed polyline through `sides` corners (3 to 1024) on a circle of `radius`, the first on +u | +| `OG_PRIMITIVE_SLOT` | `{ length, width }` | The outline of `OG_PRIMITIVE_SLOT_PRISM`: two lines and two arcs, centred on the plane origin | +| `OG_PRIMITIVE_ROUNDED_RECTANGLE` | `{ width, breadth, radius }` | A rectangle with rounded corners, placed like the rectangle. `radius` below half the width and half the breadth | + +A regular polygon, a slot and a rounded rectangle are closed wires, so each is an extrude profile or hole: + +```ts +import { + OpenGeometry, + Solid, + Wire, + OG_OPERATION_EXTRUDE, + OG_PRIMITIVE_PARAMS_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE, + OG_PRIMITIVE_PARAMS_SLOT, + OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_ROUNDED_RECTANGLE, + OG_PRIMITIVE_SLOT, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const hexagon = new Wire( + OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_REGULAR_POLYGON({ sides: 6, radius: 0.2 }), +); +const plate = new Wire( + OG_PRIMITIVE_ROUNDED_RECTANGLE, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE({ width: 1, breadth: 0.6, radius: 0.05 }), +); +const slot = new Wire(OG_PRIMITIVE_SLOT, OG_PRIMITIVE_PARAMS_SLOT({ length: 0.3, width: 0.1 })); +const nut = new Solid(OG_OPERATION_EXTRUDE, { profile: hexagon, distance: 0.1 }); +const panel = new Solid(OG_OPERATION_EXTRUDE, { profile: plate, holes: [slot], distance: 0.02 }); +console.log(nut.ogId, panel.ogId); +``` ### Profile @@ -58,15 +96,19 @@ A profile refuses repeated points, an arc whose end is not on its circle, segmen ## Solids -| Kind | Parameters | Notes | -| ------------------------------ | -------------------------------------------- | ---------------------------------------------------------------------------------- | -| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | Standing on the plane, centred on the origin in u and v. `height` along the normal | -| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | Standing on the plane, axis along the normal | -| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | Angles in radians from the plane's x direction | -| `OG_PRIMITIVE_SPHERE` | `{ radius }` | Centred on the plane origin | -| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | A cone or frustum. One radius may be 0. Nearly equal radii make a cylinder | -| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | A ring torus lying on the plane. The minor radius must be below the major one | -| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | Planar faces as rings of point indices, counter-clockwise seen from outside | +| Kind | Parameters | Notes | +| ------------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `OG_PRIMITIVE_CUBOID` | `{ width, height, depth }` | Standing on the plane, centred on the origin in u and v. `height` along the normal | +| `OG_PRIMITIVE_CYLINDER` | `{ radius, height }` | Standing on the plane, axis along the normal | +| `OG_PRIMITIVE_CYLINDER_SECTOR` | `{ radius, height, startAngle, sweepAngle }` | Angles in radians from the plane's x direction | +| `OG_PRIMITIVE_SPHERE` | `{ radius }` | Centred on the plane origin | +| `OG_PRIMITIVE_CONE` | `{ radiusBottom, radiusTop, height }` | A cone or frustum. One radius may be 0. Nearly equal radii make a cylinder | +| `OG_PRIMITIVE_TORUS` | `{ majorRadius, minorRadius }` | A ring torus lying on the plane. The minor radius must be below the major one | +| `OG_PRIMITIVE_POLYHEDRON` | `{ points, faces }` | Planar faces as rings of point indices, counter-clockwise seen from outside | +| `OG_PRIMITIVE_SLOT_PRISM` | `{ length, width, height }` | A stadium standing on the plane, centred in u and v. `length` between the arc centres along u, `width` along v | +| `OG_PRIMITIVE_ROUNDED_BOX` | `{ width, height, depth, radius }` | A cuboid with its four upright edges rounded. `radius` below half the width and half the depth | +| `OG_PRIMITIVE_PIPE_SEGMENT` | `{ outerRadius, innerRadius, height, startAngle, sweepAngle }` | A ring sector, placed and angled like the cylinder sector. The sweep is below one full turn | +| `OG_PRIMITIVE_POLYSOLID` | `{ path, width, height, justify? }` | A wall of constant width along a path of legs and tangent arcs. See below | ```ts import { @@ -101,6 +143,111 @@ A size that is not finite, or not above four times the geometric tolerance, thro Not built: horn, spindle and lemon tori, whose minor radius is equal to or above the major one. +A slot, a rounded box and a pipe segment are straight extrusions of lines and arcs, so they cut and join like any arc-edged solid: + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_PARAMS_PIPE_SEGMENT, + OG_PRIMITIVE_PARAMS_ROUNDED_BOX, + OG_PRIMITIVE_PARAMS_SLOT_PRISM, + OG_PRIMITIVE_PIPE_SEGMENT, + OG_PRIMITIVE_ROUNDED_BOX, + OG_PRIMITIVE_SLOT_PRISM, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const slot = new Solid( + OG_PRIMITIVE_SLOT_PRISM, + OG_PRIMITIVE_PARAMS_SLOT_PRISM({ length: 0.6, width: 0.2, height: 0.05 }), +); +const plinth = new Solid( + OG_PRIMITIVE_ROUNDED_BOX, + OG_PRIMITIVE_PARAMS_ROUNDED_BOX({ width: 1.2, height: 0.1, depth: 0.6, radius: 0.08 }), +); +const bend = new Solid( + OG_PRIMITIVE_PIPE_SEGMENT, + OG_PRIMITIVE_PARAMS_PIPE_SEGMENT({ + outerRadius: 0.3, + innerRadius: 0.25, + height: 0.2, + startAngle: 0, + sweepAngle: Math.PI / 2, + }), +); +console.log(slot.ogId, plinth.ogId, bend.ogId); +``` + +## Polysolid + +A polysolid is a wall of constant `width` and `height` along a path. The path has the fields of a profile, in plane coordinates `[u, v]`. The wall stands on the plane where the path is, and rises along the normal. It is not centred. + +`justify` is `'centre'` (the default), `'left'` or `'right'` of the path direction. Corners between two legs are mitred, and bevelled above a mitre limit of 4. `closed: true` makes a ring wall; an open path has square ends. + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const wall = new Solid( + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2] }], closed: false }, + width: 0.2, + height: 1, + justify: 'left', + }), +); +console.log(wall.measure().volume); +``` + +A segment with `arc` bends the wall around the arc's centre. Each arc must join the segments before and after it tangentially: the path keeps its direction through the joint. A joint where an arc meets a leg or another arc at an angle throws `InvalidParameter` that names the joint, for example `joint 1 between segment 0 and segment 1 is not tangent`. A closed path of arcs needs two segments, such as two half circles: + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const curvedWall = new Solid( + OG_PRIMITIVE_POLYSOLID, + OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: { + start: [0, 0], + segments: [{ to: [2, 0] }, { to: [3, 1], arc: { center: [2, 1], counterClockwise: true } }], + closed: false, + }, + width: 0.2, + height: 1, + }), +); +console.log(curvedWall.measure({ deflection: 1e-4 }).volume); +``` + +A path with fewer than two points, a segment of zero length, or a path that crosses itself throws `InvalidParameter`. A straight leg next to another straight leg must be at least half the width long. An arc needs room on the inside of its bend: with `'centre'`, its radius must be above half the width; with `'left'` or `'right'`, it must be above the full width when the wall lies on the inside of the bend. In a path with arcs, a wall that would overlap itself, for example two arms closer together than the width, throws `InvalidParameter` that names the two segments. + +With `'left'` or `'right'`, a straight corner sharper than the mitre limit is built in two ways. In a path with arcs, the wall keeps the path's corner and bevels its far side. In a path of straight legs only, the shifted centre line is bevelled, so the corner on the path side is cut too. + ## Typed parameters `OG_PRIMITIVE_PARAMS_(params)` returns its argument unchanged. It only gives the object a type, so your editor checks the field names. The entry exports the types too, such as `ProfileParams`, `ArcParams`, `SphereParams`, `ConeParams` and `TorusParams`. diff --git a/docs/opengeometry/migrate-from-2-0.mdx b/docs/opengeometry/migrate-from-2-0.mdx index ca0f4eab..285d8b9a 100644 --- a/docs/opengeometry/migrate-from-2-0.mdx +++ b/docs/opengeometry/migrate-from-2-0.mdx @@ -23,7 +23,80 @@ The full guide is [MIGRATION.md](https://github.com/OpenGeometry-io/OpenGeometry ## Removed -These 2.0 features are not in 3.0, and have no replacement: IFC file text, STL and PDF export, projection, loft, triangulation as a public call, curves other than lines, circles and arcs, and wedges. Take that code out, or stay on 2.0. +These 2.0 features are not in 3.0, and have no replacement: IFC file text, STL and PDF export, projection, loft, triangulation as a public call, and curves other than lines, circles, arcs and polylines, such as the elliptical arc. Take that code out, or stay on 2.0. 3.0 has no spline, no ellipse and no elliptical arc. + +The 2.0 wedge and the 2.0 `Curve` class are gone too. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points, as the next section shows. + +## Wedges and the 2.0 curve + +A wedge is a five-face polyhedron: two triangles and three rectangles. Give its points and its faces to `OG_PRIMITIVE_POLYHEDRON`. Each face is a ring of point indices, counter-clockwise seen from outside. + +```ts +import { + OpenGeometry, + Solid, + OG_PRIMITIVE_PARAMS_POLYHEDRON, + OG_PRIMITIVE_POLYHEDRON, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const ramp = new Solid( + OG_PRIMITIVE_POLYHEDRON, + OG_PRIMITIVE_PARAMS_POLYHEDRON({ + points: [ + [0, 0, 0], + [2, 0, 0], + [2, 0, 1], + [0, 0, 1], + [0, 1, 0], + [0, 1, 1], + ], + faces: [ + [0, 1, 2, 3], + [0, 3, 5, 4], + [0, 4, 1], + [3, 2, 5], + [1, 4, 5, 2], + ], + }), +); +console.log(ramp.validity().valid); +``` + +This ramp is 2 m long, 1 m wide and 1 m high at its tall end. You can also extrude a closed triangle: a closed `OG_PRIMITIVE_POLYLINE` wire as the profile of `OG_OPERATION_EXTRUDE`. + +The 2.0 `Curve` was a polyline through its control points. In 3.0, make an `OG_PRIMITIVE_POLYLINE` wire through the same points. + +```ts +import { + OpenGeometry, + Wire, + OG_PRIMITIVE_PARAMS_POLYLINE, + OG_PRIMITIVE_POLYLINE, +} from 'opengeometry'; + +await OpenGeometry.create( + { wasmURL: '/opengeometry_bg.wasm' }, + { workerURL: '/tessellation-worker.js' }, +); + +const curve = new Wire( + OG_PRIMITIVE_POLYLINE, + OG_PRIMITIVE_PARAMS_POLYLINE({ + points: [ + [0, 0, 0], + [1, 0, 0.5], + [2, 0, 0.5], + [3, 0, 0], + ], + }), +); +console.log(curve.bodyType); +``` ## How to move diff --git a/docs/opengeometry/what-is-opengeometry.mdx b/docs/opengeometry/what-is-opengeometry.mdx index 1e8aed0f..caab7ade 100644 --- a/docs/opengeometry/what-is-opengeometry.mdx +++ b/docs/opengeometry/what-is-opengeometry.mdx @@ -30,7 +30,7 @@ It also fits AI-assisted CAD. An assistant or an agent proposes the modelling st ## What 3.0 does not have -IFC file text, STL and PDF export, projection, loft, triangulation as a public call, curves other than lines, circles and arcs, wedges, and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. Code that uses them is written for 2.0. See [Migrate from 2.0](/opengeometry/migrate-from-2-0). +IFC file text, STL and PDF export, projection, loft, triangulation as a public call, curves other than lines, circles, arcs and polylines, such as the elliptical arc, and the 2.0 classes such as `Cuboid`, `Polygon`, `Opening` and `Vector3`. Code that uses them is written for 2.0. 3.0 has no spline, no ellipse and no elliptical arc. Build a wedge as a five-face `OG_PRIMITIVE_POLYHEDRON`, and a 2.0 `Curve` as an `OG_PRIMITIVE_POLYLINE` wire through its points. See [Migrate from 2.0](/opengeometry/migrate-from-2-0). ## Next steps diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index 9f6cc0e4..d2efd500 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -27,6 +27,11 @@

Shapes

Cuboid Cylinder Rounded section + Slot prism + Rounded box + Pipe segment + Polysolid wall + Wire kinds

Sweeps

Sweep frames Arc path sweep diff --git a/main/opengeometry-three/examples-vite/shapes/pipe-segment.html b/main/opengeometry-three/examples-vite/shapes/pipe-segment.html new file mode 100644 index 00000000..c6c128f9 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/pipe-segment.html @@ -0,0 +1,89 @@ + + + + OpenGeometry Pipe Segment Example + + + + + + + +
+ Back + Pipe Segment +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/pipe-segment.ts b/main/opengeometry-three/examples-vite/shapes/pipe-segment.ts new file mode 100644 index 00000000..9438579e --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/pipe-segment.ts @@ -0,0 +1,14 @@ +import { startParametricSolid } from '../shared/parametric-solid'; +import { OG_PRIMITIVE_PIPE_SEGMENT, OG_PRIMITIVE_PARAMS_PIPE_SEGMENT } from '../../../dist/index.js'; + +await startParametricSolid({ + kind: OG_PRIMITIVE_PIPE_SEGMENT, + title: 'Pipe segment', + params: OG_PRIMITIVE_PARAMS_PIPE_SEGMENT({ + outerRadius: 0.8, + innerRadius: 0.6, + height: 0.5, + startAngle: 0, + sweepAngle: 1.57, + }), +}); diff --git a/main/opengeometry-three/examples-vite/shapes/polysolid.html b/main/opengeometry-three/examples-vite/shapes/polysolid.html new file mode 100644 index 00000000..724a5f4b --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/polysolid.html @@ -0,0 +1,87 @@ + + + + OpenGeometry Polysolid Example + + + + + + + +
+ Back + Polysolid +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/polysolid.ts b/main/opengeometry-three/examples-vite/shapes/polysolid.ts new file mode 100644 index 00000000..6406c3e1 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/polysolid.ts @@ -0,0 +1,138 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { numberControl, onNumberInput } from '../shared/controls'; +import { requiredElement } from '../shared/dom'; +import { startFpsMeter } from '../shared/fps'; +import { bootExample } from '../shared/kernel'; +import { releaseOnUnload } from '../shared/unload'; +import { fitRenderer } from '../shared/viewport'; +import { + OpenGeometry, Solid, OG_PRIMITIVE_POLYSOLID, OG_PRIMITIVE_PARAMS_POLYSOLID, +} from '../../../dist/index.js'; +import type { PolysolidParams } from '../../../dist/index.js'; + +type Justify = NonNullable; + +const APP = requiredElement('#app', HTMLDivElement); +const STATUS = requiredElement('#status', HTMLElement); +await bootExample('../'); + +const SCENE = new THREE.Scene(); +SCENE.background = new THREE.Color(0xeef2ff); +SCENE.add(new THREE.HemisphereLight(0xffffff, 0x526070, 2)); +const LIGHT = new THREE.DirectionalLight(0xffffff, 2); +LIGHT.position.set(5, 8, 5); +SCENE.add(LIGHT); +SCENE.add(new THREE.GridHelper(20, 20, 0x4460ff, 0xd5ddff)); +const CAMERA = new THREE.PerspectiveCamera(55, 1, 0.1, 100); +CAMERA.position.set(5.8, 4.4, 6.8); +const RENDERER = new THREE.WebGLRenderer({ antialias: true }); +RENDERER.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); +APP.append(RENDERER.domElement); +const CONTROLS = new OrbitControls(CAMERA, RENDERER.domElement); +CONTROLS.target.set(1.5, 0.5, -1); +CONTROLS.update(); + +type PathName = 'legs' | 'arc'; + +const CONFIG: { width: number; height: number; justify: Justify; path: PathName; outline: boolean } = { + width: 0.2, height: 1, justify: 'centre', path: 'legs', outline: true, +}; +const PATHS: Record = { + legs: { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2] }], closed: false }, + arc: { + start: [0, 0], + segments: [{ to: [2, 0] }, { to: [3, 1], arc: { center: [2, 1], counterClockwise: true } }, { to: [3, 2] }], + closed: false, + }, +}; + +function params(): PolysolidParams { + return OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: PATHS[CONFIG.path], width: CONFIG.width, height: CONFIG.height, justify: CONFIG.justify, + }); +} + +const WALL = new Solid(OG_PRIMITIVE_POLYSOLID, params(), { ogId: 'polysolid' }); +WALL.setAppearance({ color: 0x0ea5e9, outline: true, deflection: 0.005 }); +SCENE.add(WALL); + +function render(): void { RENDERER.render(SCENE, CAMERA); } + +function resize(): void { + fitRenderer(APP, CAMERA, RENDERER); + render(); +} + +let updateFrame = 0; +function updateWall(): void { + const started = performance.now(); + try { + WALL.rebuild(OG_PRIMITIVE_POLYSOLID, params()); + SCENE.updateMatrixWorld(true); + OpenGeometry.flush({ geometry: 'sync' }); + if (WALL.record?.revision !== WALL.lastInfo.shapeRevision) { + throw new Error('Polysolid geometry did not finish'); + } + STATUS.textContent = `Polysolid ready · ${(performance.now() - started).toFixed(1)} ms`; + } catch (error) { STATUS.textContent = `Polysolid error: ${String(error)}`; } +} + +function scheduleUpdate(): void { + if (updateFrame) return; + updateFrame = window.requestAnimationFrame(() => { updateFrame = 0; updateWall(); }); +} + +function attachNumberControl(name: 'width' | 'height'): void { + onNumberInput(numberControl(name), (value) => { + CONFIG[name] = value; + scheduleUpdate(); + }); +} + +attachNumberControl('width'); +attachNumberControl('height'); +const JUSTIFY_OPTIONS: Justify[] = ['centre', 'left', 'right']; +const JUSTIFY_ROW = requiredElement('[data-control="justify"]', HTMLElement); +const JUSTIFY = requiredElement('[data-select]', HTMLSelectElement, JUSTIFY_ROW); +JUSTIFY.addEventListener('change', () => { + CONFIG.justify = JUSTIFY_OPTIONS.find((option) => option === JUSTIFY.value) ?? 'centre'; + scheduleUpdate(); +}); +const PATH_OPTIONS: PathName[] = ['legs', 'arc']; +const PATH_ROW = requiredElement('[data-control="path"]', HTMLElement); +const PATH_SELECT = requiredElement('[data-select]', HTMLSelectElement, PATH_ROW); +PATH_SELECT.addEventListener('change', () => { + CONFIG.path = PATH_OPTIONS.find((option) => option === PATH_SELECT.value) ?? 'legs'; + scheduleUpdate(); +}); +const OUTLINE_ROW = requiredElement('[data-control="outline"]', HTMLElement); +const OUTLINE = requiredElement('[data-toggle]', HTMLInputElement, OUTLINE_ROW); +const OUTLINE_STATE = requiredElement('[data-state]', HTMLElement, OUTLINE_ROW); +OUTLINE.addEventListener('change', () => { + CONFIG.outline = OUTLINE.checked; + OUTLINE_STATE.textContent = OUTLINE.checked ? 'Enabled' : 'Disabled'; + WALL.setAppearance({ outline: OUTLINE.checked }); + render(); +}); + +const RESIZE_OBSERVER = new ResizeObserver(resize); +RESIZE_OBSERVER.observe(APP); +CONTROLS.addEventListener('start', () => { OpenGeometry.setCameraMotion(true); }); +CONTROLS.addEventListener('end', () => { OpenGeometry.setCameraMotion(false); render(); }); +const UNSUBSCRIBE_ERROR = OpenGeometry.on('error', (error) => { + STATUS.textContent = `Geometry error: ${String(error)}`; +}); +resize(); +const READY = await OpenGeometry.settled(); +if (READY.failed.length) throw new Error('Polysolid geometry failed'); +render(); +STATUS.textContent = 'Polysolid ready'; +const STOP_FPS_METER = startFpsMeter(RENDERER, SCENE, CAMERA, CONTROLS); + +releaseOnUnload({ + resizeObserver: RESIZE_OBSERVER, stopFpsMeter: STOP_FPS_METER, unsubscribeError: UNSUBSCRIBE_ERROR, + controls: CONTROLS, renderer: RENDERER, +}, () => { if (updateFrame) window.cancelAnimationFrame(updateFrame); }, () => { + WALL.dispose(); +}); diff --git a/main/opengeometry-three/examples-vite/shapes/rounded-box.html b/main/opengeometry-three/examples-vite/shapes/rounded-box.html new file mode 100644 index 00000000..cfdf77fe --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/rounded-box.html @@ -0,0 +1,78 @@ + + + + OpenGeometry Rounded Box Example + + + + + + + +
+ Back + Rounded Box +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/rounded-box.ts b/main/opengeometry-three/examples-vite/shapes/rounded-box.ts new file mode 100644 index 00000000..0939403d --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/rounded-box.ts @@ -0,0 +1,8 @@ +import { startParametricSolid } from '../shared/parametric-solid'; +import { OG_PRIMITIVE_ROUNDED_BOX, OG_PRIMITIVE_PARAMS_ROUNDED_BOX } from '../../../dist/index.js'; + +await startParametricSolid({ + kind: OG_PRIMITIVE_ROUNDED_BOX, + title: 'Rounded box', + params: OG_PRIMITIVE_PARAMS_ROUNDED_BOX({ width: 1.2, height: 0.6, depth: 0.8, radius: 0.15 }), +}); diff --git a/main/opengeometry-three/examples-vite/shapes/slot-prism.html b/main/opengeometry-three/examples-vite/shapes/slot-prism.html new file mode 100644 index 00000000..e834c8ef --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/slot-prism.html @@ -0,0 +1,67 @@ + + + + OpenGeometry Slot Prism Example + + + + + + + +
+ Back + Slot Prism +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/slot-prism.ts b/main/opengeometry-three/examples-vite/shapes/slot-prism.ts new file mode 100644 index 00000000..3b61e569 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/slot-prism.ts @@ -0,0 +1,8 @@ +import { startParametricSolid } from '../shared/parametric-solid'; +import { OG_PRIMITIVE_SLOT_PRISM, OG_PRIMITIVE_PARAMS_SLOT_PRISM } from '../../../dist/index.js'; + +await startParametricSolid({ + kind: OG_PRIMITIVE_SLOT_PRISM, + title: 'Slot prism', + params: OG_PRIMITIVE_PARAMS_SLOT_PRISM({ length: 1, width: 0.5, height: 0.4 }), +}); diff --git a/main/opengeometry-three/examples-vite/shapes/wire-kinds.html b/main/opengeometry-three/examples-vite/shapes/wire-kinds.html new file mode 100644 index 00000000..57f45db7 --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/wire-kinds.html @@ -0,0 +1,34 @@ + + + + OpenGeometry Wire Kinds Example + + + + + + + +
+ Back + Wire Kinds +
+ + + +
+ + + + + diff --git a/main/opengeometry-three/examples-vite/shapes/wire-kinds.ts b/main/opengeometry-three/examples-vite/shapes/wire-kinds.ts new file mode 100644 index 00000000..6429009e --- /dev/null +++ b/main/opengeometry-three/examples-vite/shapes/wire-kinds.ts @@ -0,0 +1,79 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { requiredElement } from '../shared/dom'; +import { startFpsMeter } from '../shared/fps'; +import { bootExample } from '../shared/kernel'; +import { releaseOnUnload } from '../shared/unload'; +import { fitRenderer } from '../shared/viewport'; +import { + OpenGeometry, Solid, Wire, OG_OPERATION_EXTRUDE, OG_PRIMITIVE_PARAMS_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE, OG_PRIMITIVE_PARAMS_SLOT, OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_ROUNDED_RECTANGLE, OG_PRIMITIVE_SLOT, +} from '../../../dist/index.js'; + +const APP = requiredElement('#app', HTMLDivElement); +const STATUS = requiredElement('#status', HTMLElement); +await bootExample('../'); + +const SCENE = new THREE.Scene(); +SCENE.background = new THREE.Color(0xeef2ff); +SCENE.add(new THREE.HemisphereLight(0xffffff, 0x526070, 2)); +const LIGHT = new THREE.DirectionalLight(0xffffff, 2); +LIGHT.position.set(5, 8, 5); +SCENE.add(LIGHT); +SCENE.add(new THREE.GridHelper(10, 20, 0x4460ff, 0xd5ddff)); +const CAMERA = new THREE.PerspectiveCamera(50, 1, 0.05, 100); +CAMERA.position.set(0, 2.6, 3.2); +const RENDERER = new THREE.WebGLRenderer({ antialias: true }); +RENDERER.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); +APP.append(RENDERER.domElement); +const CONTROLS = new OrbitControls(CAMERA, RENDERER.domElement); +CONTROLS.target.set(0, 0.2, 0); +CONTROLS.update(); + +const WIRES = [ + new Wire(OG_PRIMITIVE_REGULAR_POLYGON, OG_PRIMITIVE_PARAMS_REGULAR_POLYGON({ sides: 6, radius: 0.45 }), { + ogId: 'regular-polygon', plane: { origin: [-1.4, 0, 0] }, + }), + new Wire(OG_PRIMITIVE_SLOT, OG_PRIMITIVE_PARAMS_SLOT({ length: 0.6, width: 0.4 }), { ogId: 'slot' }), + new Wire(OG_PRIMITIVE_ROUNDED_RECTANGLE, OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE({ + width: 0.9, breadth: 0.6, radius: 0.12, + }), { ogId: 'rounded-rectangle', plane: { origin: [1.4, 0, 0] } }), +]; +const PRISMS = WIRES.map((profile) => new Solid( + OG_OPERATION_EXTRUDE, { profile, distance: 0.3 }, { ogId: `${profile.ogId}-prism` }, +)); +for (const prism of PRISMS) { + prism.setAppearance({ color: 0x0ea5e9, outline: true, opacity: 0.6 }); + SCENE.add(prism); +} +for (const wire of WIRES) SCENE.add(wire); + +function render(): void { RENDERER.render(SCENE, CAMERA); } + +function resize(): void { + fitRenderer(APP, CAMERA, RENDERER); + render(); +} + +const RESIZE_OBSERVER = new ResizeObserver(resize); +RESIZE_OBSERVER.observe(APP); +CONTROLS.addEventListener('start', () => { OpenGeometry.setCameraMotion(true); }); +CONTROLS.addEventListener('end', () => { OpenGeometry.setCameraMotion(false); render(); }); +const UNSUBSCRIBE_ERROR = OpenGeometry.on('error', (error) => { + STATUS.textContent = `Geometry error: ${String(error)}`; +}); +resize(); +const READY = await OpenGeometry.settled(); +if (READY.failed.length) throw new Error('Wire kinds geometry failed'); +render(); +const EDGES = WIRES.map((wire) => wire.getBrep().topology.edges.length).join(', '); +STATUS.textContent = `Wire kinds ready · ${EDGES} edges`; +const STOP_FPS_METER = startFpsMeter(RENDERER, SCENE, CAMERA, CONTROLS); + +releaseOnUnload({ + resizeObserver: RESIZE_OBSERVER, stopFpsMeter: STOP_FPS_METER, unsubscribeError: UNSUBSCRIBE_ERROR, + controls: CONTROLS, renderer: RENDERER, +}, () => undefined, () => { + for (const body of [...PRISMS, ...WIRES]) body.dispose(); +}); diff --git a/main/opengeometry-three/examples-vite/shared/parametric-solid.ts b/main/opengeometry-three/examples-vite/shared/parametric-solid.ts new file mode 100644 index 00000000..e81d109d --- /dev/null +++ b/main/opengeometry-three/examples-vite/shared/parametric-solid.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { numberControl, onNumberInput } from './controls'; +import { requiredElement } from './dom'; +import { startFpsMeter } from './fps'; +import { bootExample } from './kernel'; +import { releaseOnUnload } from './unload'; +import { fitRenderer } from './viewport'; +import { OpenGeometry, Solid } from '../../../dist/index.js'; + +export type ParametricSolid = { kind: string; title: string; params: Record }; + +type Stage = { scene: THREE.Scene; camera: THREE.PerspectiveCamera; renderer: THREE.WebGLRenderer }; + +function createStage(app: HTMLElement): Stage { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xeef2ff); + scene.add(new THREE.HemisphereLight(0xffffff, 0x526070, 2)); + const light = new THREE.DirectionalLight(0xffffff, 2); + light.position.set(5, 8, 5); + scene.add(light); + scene.add(new THREE.GridHelper(10, 20, 0x4460ff, 0xd5ddff)); + const camera = new THREE.PerspectiveCamera(50, 1, 0.05, 100); + camera.position.set(2.2, 1.8, 2.6); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); + app.append(renderer.domElement); + return { scene, camera, renderer }; +} + +function rebuildOnInput(page: ParametricSolid, solid: Solid, scene: THREE.Scene, status: HTMLElement): () => void { + let frame = 0; + const update = (): void => { + const started = performance.now(); + try { + solid.rebuild(page.kind, page.params); + scene.updateMatrixWorld(true); + OpenGeometry.flush({ geometry: 'sync' }); + if (solid.record?.revision !== solid.lastInfo.shapeRevision) { + throw new Error(`${page.title} geometry did not finish`); + } + status.textContent = `${page.title} ready · ${(performance.now() - started).toFixed(1)} ms`; + } catch (error) { status.textContent = `${page.title} error: ${String(error)}`; } + }; + for (const name of Object.keys(page.params)) { + onNumberInput(numberControl(name), (value) => { + page.params[name] = value; + if (frame) return; + frame = window.requestAnimationFrame(() => { frame = 0; update(); }); + }); + } + return () => { if (frame) window.cancelAnimationFrame(frame); }; +} + +export async function startParametricSolid(page: ParametricSolid): Promise { + const app = requiredElement('#app', HTMLDivElement); + const status = requiredElement('#status', HTMLElement); + await bootExample('../'); + const { scene, camera, renderer } = createStage(app); + const controls = new OrbitControls(camera, renderer.domElement); + controls.target.set(0, 0.3, 0); + controls.update(); + const solid = new Solid(page.kind, page.params, { ogId: page.kind }); + solid.setAppearance({ color: 0x0ea5e9, outline: true, deflection: 0.002 }); + scene.add(solid); + const render = (): void => { renderer.render(scene, camera); }; + const resizeObserver = new ResizeObserver(() => { fitRenderer(app, camera, renderer); render(); }); + resizeObserver.observe(app); + const cancelUpdate = rebuildOnInput(page, solid, scene, status); + controls.addEventListener('start', () => { OpenGeometry.setCameraMotion(true); }); + controls.addEventListener('end', () => { OpenGeometry.setCameraMotion(false); render(); }); + const unsubscribeError = OpenGeometry.on('error', (error) => { + status.textContent = `Geometry error: ${String(error)}`; + }); + const ready = await OpenGeometry.settled(); + if (ready.failed.length) throw new Error(`${page.title} geometry failed`); + render(); + status.textContent = `${page.title} ready`; + const stopFpsMeter = startFpsMeter(renderer, scene, camera, controls); + releaseOnUnload( + { resizeObserver, stopFpsMeter, unsubscribeError, controls, renderer }, cancelUpdate, () => { solid.dispose(); }, + ); +} diff --git a/main/opengeometry-three/examples-vite/vite.config.ts b/main/opengeometry-three/examples-vite/vite.config.ts index f7da2652..6af358a4 100644 --- a/main/opengeometry-three/examples-vite/vite.config.ts +++ b/main/opengeometry-three/examples-vite/vite.config.ts @@ -24,6 +24,11 @@ export default defineConfig({ arcPathSweep: `${ROOT}/operations/sweep-arc-path.html`, curvedMeetsStraight: `${ROOT}/operations/curved-meets-straight.html`, stepExport: `${ROOT}/operations/step-export.html`, + slotPrism: `${ROOT}/shapes/slot-prism.html`, + roundedBox: `${ROOT}/shapes/rounded-box.html`, + pipeSegment: `${ROOT}/shapes/pipe-segment.html`, + polysolid: `${ROOT}/shapes/polysolid.html`, + wireKinds: `${ROOT}/shapes/wire-kinds.html`, }, }, }, diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 62ea1cbf..9410d5d5 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -16,7 +16,8 @@ export type { AnalyticExchangeBodyV2, IfcPreparedValue } from './src/dto/ifc-bod export * from './src/constants/index'; export type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileArc, ProfileParams, ProfileSegment, - SphereParams, TorusParams, + SphereParams, TorusParams, SlotPrismParams, RoundedBoxParams, PipeSegmentParams, PolysolidParams, + RegularPolygonParams, SlotParams, RoundedRectangleParams, } from './src/dto/primitives'; export type { CurvedRegion2D, CurvedRegionEdge, OffsetHole, OffsetPath2D, OffsetPolylineOptions, OffsetRegion, diff --git a/main/opengeometry-three/src/constants/primitives.ts b/main/opengeometry-three/src/constants/primitives.ts index b1f9bbcf..f5933443 100644 --- a/main/opengeometry-three/src/constants/primitives.ts +++ b/main/opengeometry-three/src/constants/primitives.ts @@ -1,5 +1,7 @@ import type { ArcParams, ConeParams, CylinderSectorParams, PolyhedronParams, ProfileParams, SphereParams, TorusParams, + SlotPrismParams, RoundedBoxParams, PipeSegmentParams, PolysolidParams, + RegularPolygonParams, SlotParams, RoundedRectangleParams, } from '../dto/primitives'; export const OG_PRIMITIVE_RECTANGLE = 'Rectangle' as const; @@ -14,6 +16,13 @@ export const OG_PRIMITIVE_CONE = 'Cone' as const; export const OG_PRIMITIVE_TORUS = 'Torus' as const; export const OG_PRIMITIVE_PROFILE = 'Profile' as const; export const OG_PRIMITIVE_ARC = 'Arc' as const; +export const OG_PRIMITIVE_SLOT_PRISM = 'SlotPrism' as const; +export const OG_PRIMITIVE_ROUNDED_BOX = 'RoundedBox' as const; +export const OG_PRIMITIVE_PIPE_SEGMENT = 'PipeSegment' as const; +export const OG_PRIMITIVE_POLYSOLID = 'Polysolid' as const; +export const OG_PRIMITIVE_REGULAR_POLYGON = 'RegularPolygon' as const; +export const OG_PRIMITIVE_SLOT = 'Slot' as const; +export const OG_PRIMITIVE_ROUNDED_RECTANGLE = 'RoundedRectangle' as const; export const OG_PRIMITIVE_PARAMS_RECTANGLE = ( value: { width: number; breadth: number }, @@ -35,3 +44,10 @@ export const OG_PRIMITIVE_PARAMS_POLYHEDRON = (value: PolyhedronParams): Polyhed export const OG_PRIMITIVE_PARAMS_SPHERE = (value: SphereParams): SphereParams => value; export const OG_PRIMITIVE_PARAMS_CONE = (value: ConeParams): ConeParams => value; export const OG_PRIMITIVE_PARAMS_TORUS = (value: TorusParams): TorusParams => value; +export const OG_PRIMITIVE_PARAMS_SLOT_PRISM = (value: SlotPrismParams): SlotPrismParams => value; +export const OG_PRIMITIVE_PARAMS_ROUNDED_BOX = (value: RoundedBoxParams): RoundedBoxParams => value; +export const OG_PRIMITIVE_PARAMS_PIPE_SEGMENT = (value: PipeSegmentParams): PipeSegmentParams => value; +export const OG_PRIMITIVE_PARAMS_POLYSOLID = (value: PolysolidParams): PolysolidParams => value; +export const OG_PRIMITIVE_PARAMS_REGULAR_POLYGON = (value: RegularPolygonParams): RegularPolygonParams => value; +export const OG_PRIMITIVE_PARAMS_SLOT = (value: SlotParams): SlotParams => value; +export const OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE = (value: RoundedRectangleParams): RoundedRectangleParams => value; diff --git a/main/opengeometry-three/src/dto/primitives.ts b/main/opengeometry-three/src/dto/primitives.ts index f5f694c9..c3a2d47d 100644 --- a/main/opengeometry-three/src/dto/primitives.ts +++ b/main/opengeometry-three/src/dto/primitives.ts @@ -7,3 +7,14 @@ export type PolyhedronParams = { points: [number, number, number][]; faces: numb export type SphereParams = { radius: number }; export type ConeParams = { radiusBottom: number; radiusTop: number; height: number }; export type TorusParams = { majorRadius: number; minorRadius: number }; +export type SlotPrismParams = { length: number; width: number; height: number }; +export type RoundedBoxParams = { width: number; height: number; depth: number; radius: number }; +export type PipeSegmentParams = { + outerRadius: number; innerRadius: number; height: number; startAngle: number; sweepAngle: number; +}; +export type PolysolidParams = { + path: ProfileParams; width: number; height: number; justify?: 'centre' | 'left' | 'right'; +}; +export type RegularPolygonParams = { sides: number; radius: number }; +export type SlotParams = { length: number; width: number }; +export type RoundedRectangleParams = { width: number; breadth: number; radius: number }; diff --git a/main/opengeometry-three/tests/node/primitives.test.mjs b/main/opengeometry-three/tests/node/primitives.test.mjs index 0e7376c8..080fee90 100644 --- a/main/opengeometry-three/tests/node/primitives.test.mjs +++ b/main/opengeometry-three/tests/node/primitives.test.mjs @@ -14,14 +14,28 @@ import { OG_PRIMITIVE_PARAMS_CONE, OG_PRIMITIVE_PARAMS_CYLINDER_SECTOR, OG_PRIMITIVE_PARAMS_POLYHEDRON, + OG_PRIMITIVE_PARAMS_POLYSOLID, OG_PRIMITIVE_PARAMS_SPHERE, OG_PRIMITIVE_PARAMS_TORUS, OG_PRIMITIVE_POLYHEDRON, + OG_PRIMITIVE_POLYSOLID, OG_PRIMITIVE_PROFILE, OG_PRIMITIVE_RECTANGLE, OG_PRIMITIVE_SPHERE, OG_PRIMITIVE_TORUS, OG_TRANSFORM_TRANSLATE, + OG_PRIMITIVE_SLOT_PRISM, + OG_PRIMITIVE_ROUNDED_BOX, + OG_PRIMITIVE_PIPE_SEGMENT, + OG_PRIMITIVE_PARAMS_SLOT_PRISM, + OG_PRIMITIVE_PARAMS_ROUNDED_BOX, + OG_PRIMITIVE_PARAMS_PIPE_SEGMENT, + OG_PRIMITIVE_REGULAR_POLYGON, + OG_PRIMITIVE_SLOT, + OG_PRIMITIVE_ROUNDED_RECTANGLE, + OG_PRIMITIVE_PARAMS_REGULAR_POLYGON, + OG_PRIMITIVE_PARAMS_SLOT, + OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE, } from '../../../dist/index.js'; import { boot, failsWith } from './support.mjs'; @@ -166,3 +180,106 @@ test('an arc-edged pan minus a blind inset is one Solid built by the prismatic c const { text } = await OpenGeometry.exportStep({ nodes: [pan] }); assert.equal(text.match(/CYLINDRICAL_SURFACE/g)?.length, 2); }); + +const STANDARD_SOLIDS = [ + { + kind: OG_PRIMITIVE_SLOT_PRISM, + params: OG_PRIMITIVE_PARAMS_SLOT_PRISM({ length: 1, width: 0.5, height: 1 }), + cylinders: 2, + volume: 0.5 + Math.PI / 16, + }, + { + kind: OG_PRIMITIVE_ROUNDED_BOX, + params: OG_PRIMITIVE_PARAMS_ROUNDED_BOX({ width: 1, height: 1, depth: 1, radius: 0.1 }), + cylinders: 4, + volume: 1 - (4 - Math.PI) * 0.01, + }, + { + kind: OG_PRIMITIVE_PIPE_SEGMENT, + params: OG_PRIMITIVE_PARAMS_PIPE_SEGMENT({ + outerRadius: 0.5, innerRadius: 0.3, height: 1, startAngle: 0, sweepAngle: Math.PI / 2, + }), + cylinders: 2, + volume: Math.PI * 0.16 / 4, + }, +]; + +test('slot prism, rounded box and pipe segment Solids have exact volumes and cylindrical faces', async () => { + await boot(); + for (const { kind, params, cylinders, volume } of STANDARD_SOLIDS) { + const solid = new Solid(kind, params, { ogId: kind }); + assert.equal(solid.lastInfo.bodyType, 'Solid'); + const measured = solid.measure({ deflection: 1e-4 }); + assert.equal(Math.abs(measured.volume - volume) <= measured.errorBound, true, kind); + const { text } = await OpenGeometry.exportStep({ nodes: [solid] }); + assert.equal(text.match(/CYLINDRICAL_SURFACE/g)?.length, cylinders, kind); + } +}); + +test('a rounded box radius of half its width and an inverted pipe segment are InvalidParameter', async () => { + await boot(); + const corner = { width: 1, height: 1, depth: 2, radius: 0.5 }; + assert.throws(() => new Solid(OG_PRIMITIVE_ROUNDED_BOX, corner, { ogId: 'corner' }), failsWith('InvalidParameter')); + const pipe = { outerRadius: 0.3, innerRadius: 0.5, height: 1, startAngle: 0, sweepAngle: 1 }; + assert.throws(() => new Solid(OG_PRIMITIVE_PIPE_SEGMENT, pipe, { ogId: 'pipe' }), failsWith('InvalidParameter')); +}); + +function lPath(arc) { + return { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2], arc }], closed: false }; +} + +test('an L-shaped polysolid is a Solid of volume one that exports planes only to STEP', async () => { + await boot(); + const params = OG_PRIMITIVE_PARAMS_POLYSOLID({ + path: { start: [0, 0], segments: [{ to: [3, 0] }, { to: [3, 2] }], closed: false }, + width: 0.2, + height: 1, + justify: 'centre', + }); + const wall = new Solid(OG_PRIMITIVE_POLYSOLID, params, { ogId: 'l-wall' }); + assert.equal(wall.lastInfo.bodyType, 'Solid'); + assert.equal(Math.abs(wall.measure().volume - 1) <= 1e-9, true); + const { text } = await OpenGeometry.exportStep({ nodes: [wall] }); + assert.equal(text.match(/\bPLANE\(/g)?.length, wall.getBrep().topology.faces.length); + assert.equal(text.match(/CYLINDRICAL_SURFACE/g), null); +}); + +test('a polysolid with a tangent arc is a curved Solid and a sharp arc corner is InvalidParameter', async () => { + await boot(); + const params = { path: lPath({ center: [3, 1], counterClockwise: true }), width: 0.2, height: 1 }; + const wall = new Solid(OG_PRIMITIVE_POLYSOLID, params, { ogId: 'curved' }); + assert.equal(Math.abs(wall.measure({ deflection: 1e-4 }).volume - (0.6 + 0.2 * Math.PI)) <= 1e-3, true); + const { text } = await OpenGeometry.exportStep({ nodes: [wall] }); + assert.equal(text.match(/CYLINDRICAL_SURFACE/g)?.length, 2); + const sharp = { ...params, path: lPath({ center: [3.5, 1], counterClockwise: true }) }; + assert.throws(() => new Solid(OG_PRIMITIVE_POLYSOLID, sharp, { ogId: 'sharp' }), failsWith('InvalidParameter')); +}); + +test('regular polygon, slot and rounded rectangle Wires extrude into prisms with their edges', async () => { + await boot(); + const wires = [ + { + kind: OG_PRIMITIVE_REGULAR_POLYGON, + params: OG_PRIMITIVE_PARAMS_REGULAR_POLYGON({ sides: 6, radius: 0.5 }), + edges: 6, + }, + { kind: OG_PRIMITIVE_SLOT, params: OG_PRIMITIVE_PARAMS_SLOT({ length: 1, width: 0.5 }), edges: 4 }, + { + kind: OG_PRIMITIVE_ROUNDED_RECTANGLE, + params: OG_PRIMITIVE_PARAMS_ROUNDED_RECTANGLE({ width: 1.2, breadth: 0.8, radius: 0.1 }), + edges: 8, + }, + ]; + for (const { kind, params, edges } of wires) { + const profile = new Wire(kind, params, { ogId: kind }); + assert.equal(profile.lastInfo.bodyType, 'Wire'); + assert.equal(profile.getBrep().topology.edges.length, edges, kind); + const solid = new Solid(OG_OPERATION_EXTRUDE, { profile, distance: 1 }, { ogId: `${kind}-solid` }); + assert.equal(solid.getBrep().topology.faces.length, edges + 2, kind); + } + for (const sides of [2, 5.5, -3]) { + const params = { sides, radius: 1 }; + const options = { ogId: `sides-${sides}` }; + assert.throws(() => new Wire(OG_PRIMITIVE_REGULAR_POLYGON, params, options), failsWith('InvalidParameter')); + } +}); diff --git a/main/opengeometry/src/primitives/internal/mod.rs b/main/opengeometry/src/primitives/internal/mod.rs index 363c3451..e3164dc8 100644 --- a/main/opengeometry/src/primitives/internal/mod.rs +++ b/main/opengeometry/src/primitives/internal/mod.rs @@ -6,6 +6,8 @@ mod cylinder_with_circular_hole; mod extruded_loop; mod frustum; mod linear_extrusion; +mod outlines; +mod polysolid; mod revolved_profile; mod sphere; mod torus; @@ -17,6 +19,8 @@ pub use cylinder_sector::cylinder_sector; pub use cylinder_with_circular_hole::cylinder_with_circular_hole; pub use frustum::frustum; pub use linear_extrusion::linear_extrusion; +pub use outlines::{ring_sector_outline, rounded_rectangle_outline, slot_outline}; +pub use polysolid::{polysolid, PolysolidError, PolysolidJustify, PolysolidPath}; pub use revolved_profile::revolved_profile; pub use sphere::sphere; pub use torus::torus; diff --git a/main/opengeometry/src/primitives/internal/outlines.rs b/main/opengeometry/src/primitives/internal/outlines.rs new file mode 100644 index 00000000..929bcb28 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/outlines.rs @@ -0,0 +1,55 @@ +use crate::primitives::ProfileEdge; +use std::f64::consts::{FRAC_PI_2, PI}; + +type ArcSpec = ([f64; 2], f64, f64, f64); + +pub fn slot_outline(length: f64, width: f64) -> Vec { + let half = length / 2.0; + let radius = width / 2.0; + arcs_joined_by_lines(&[ + ([half, 0.0], radius, -FRAC_PI_2, PI), + ([-half, 0.0], radius, FRAC_PI_2, PI), + ]) +} + +pub fn rounded_rectangle_outline(width: f64, depth: f64, radius: f64) -> Vec { + let u = width / 2.0 - radius; + let v = depth / 2.0 - radius; + arcs_joined_by_lines(&[ + ([u, -v], radius, -FRAC_PI_2, FRAC_PI_2), + ([u, v], radius, 0.0, FRAC_PI_2), + ([-u, v], radius, FRAC_PI_2, FRAC_PI_2), + ([-u, -v], radius, PI, FRAC_PI_2), + ]) +} + +pub fn ring_sector_outline([outer, inner]: [f64; 2], [start, sweep]: [f64; 2]) -> Vec { + arcs_joined_by_lines(&[ + ([0.0; 2], outer, start, sweep), + ([0.0; 2], inner, start + sweep, -sweep), + ]) +} + +fn arcs_joined_by_lines(specs: &[ArcSpec]) -> Vec { + let arcs = specs + .iter() + .map( + |&(center, radius, start_angle, sweep_angle)| ProfileEdge::Arc { + center, + radius, + start_angle, + sweep_angle, + }, + ) + .collect::>(); + let mut edges = Vec::with_capacity(2 * arcs.len()); + for (index, current) in arcs.iter().enumerate() { + let next = &arcs[(index + 1) % arcs.len()]; + edges.push(current.clone()); + edges.push(ProfileEdge::Line { + from: current.endpoints().1, + to: next.endpoints().0, + }); + } + edges +} diff --git a/main/opengeometry/src/primitives/internal/polysolid/checks.rs b/main/opengeometry/src/primitives/internal/polysolid/checks.rs new file mode 100644 index 00000000..6b0bafa4 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/polysolid/checks.rs @@ -0,0 +1,220 @@ +use super::path::{check_path_ends, distance, parameter, PolysolidError, PolysolidPath}; +use crate::primitives::profile::{ + profile_edge_intersections, profile_self_intersects, ProfileEdge, +}; +use std::f64::consts::TAU; + +const TANGENT_TOLERANCE: f64 = 1.0e-9; + +pub(super) fn check_path( + path: &PolysolidPath, + edges: &[ProfileEdge], + g: f64, +) -> Result<(), PolysolidError> { + check_path_ends( + path, + if path.closed { 2 } else { 1 }, + "polysolid path has too few segments", + g, + )?; + for (index, edge) in edges.iter().enumerate() { + let to = if path.closed && index + 1 == edges.len() { + path.start + } else { + path.segments[index].to + }; + check_segment(edge, to, index, g)?; + } + if profile_self_intersects(edges, path.closed, g) { + return Err(parameter("polysolid path crosses itself".into())); + } + Ok(()) +} + +fn check_segment( + edge: &ProfileEdge, + to: [f64; 2], + index: usize, + g: f64, +) -> Result<(), PolysolidError> { + if distance(edge.endpoints().0, to) <= 4.0 * g { + return Err(parameter(format!( + "polysolid path segment {index} has zero length" + ))); + } + let &ProfileEdge::Arc { + center, + radius, + sweep_angle, + .. + } = edge + else { + return Ok(()); + }; + if radius <= 4.0 * g || (distance(to, center) - radius).abs() > g / 4.0 { + return Err(parameter(format!( + "polysolid path segment {index} is an arc that does not end on its circle" + ))); + } + if sweep_angle.abs() >= TAU || radius * sweep_angle.abs() <= 4.0 * g { + return Err(parameter(format!( + "polysolid path segment {index} is an arc below geometric resolution" + ))); + } + Ok(()) +} + +pub(super) fn check_tangent_joints( + edges: &[ProfileEdge], + closed: bool, + closing: f64, +) -> Result<(), PolysolidError> { + let tolerance = TANGENT_TOLERANCE.min(closing); + for joint in usize::from(!closed)..edges.len() { + let before = (joint + edges.len() - 1) % edges.len(); + if is_line(&edges[before]) && is_line(&edges[joint]) { + continue; + } + let incoming = tangent(&edges[before], true); + let outgoing = tangent(&edges[joint], false); + let angle = cross(incoming, outgoing) + .atan2(dot(incoming, outgoing)) + .abs(); + if angle > tolerance { + return Err(parameter(format!( + "polysolid path joint {joint} between segment {before} and segment {joint} is not tangent" + ))); + } + } + Ok(()) +} + +pub(super) fn check_short_legs( + edges: &[ProfileEdge], + closed: bool, + width: f64, +) -> Result<(), PolysolidError> { + let count = edges.len(); + for (index, edge) in edges.iter().enumerate() { + let &ProfileEdge::Line { from, to } = edge else { + continue; + }; + let before = (closed || index > 0).then(|| (index + count - 1) % count); + let after = (closed || index + 1 < count).then(|| (index + 1) % count); + let meets_line = [before, after] + .into_iter() + .flatten() + .any(|other| other != index && is_line(&edges[other])); + if meets_line && distance(from, to) < width / 2.0 { + return Err(parameter(format!( + "polysolid path segment {index} is a leg shorter than half the width" + ))); + } + } + Ok(()) +} + +pub(super) fn check_overlap( + ring: &[ProfileEdge], + segments: &[usize], + g: f64, +) -> Result<(), PolysolidError> { + if !profile_self_intersects(ring, true, g) { + return Ok(()); + } + let count = ring.len(); + let pair = (0..count) + .flat_map(|first| ((first + 1)..count).map(move |second| (first, second))) + .find(|&(first, second)| { + let neighbours = if second == first + 1 { + Some([first, second]) + } else if first == 0 && second == count - 1 { + Some([second, first]) + } else { + None + }; + match neighbours { + Some([a, b]) => { + profile_self_intersects(&[ring[a].clone(), ring[b].clone()], false, g) + } + None => !profile_edge_intersections(&ring[first], &ring[second], g).is_empty(), + } + }); + Err(overlap( + pair.map(|(first, second)| [segments[first], segments[second]]), + )) +} + +pub(super) fn check_rings_apart( + (outer, outer_segments): (&[ProfileEdge], &[usize]), + (hole, hole_segments): (&[ProfileEdge], &[usize]), + g: f64, +) -> Result<(), PolysolidError> { + for (first, outer_edge) in outer.iter().enumerate() { + for (second, hole_edge) in hole.iter().enumerate() { + if !profile_edge_intersections(outer_edge, hole_edge, g).is_empty() { + return Err(overlap(Some([ + outer_segments[first], + hole_segments[second], + ]))); + } + } + } + Ok(()) +} + +pub(super) fn check_leg_kept( + piece: &ProfileEdge, + edge: &ProfileEdge, + pair: [usize; 2], + g: f64, +) -> Result<(), PolysolidError> { + let (&ProfileEdge::Line { from, to }, true) = (piece, is_line(edge)) else { + return Ok(()); + }; + if dot([to[0] - from[0], to[1] - from[1]], tangent(edge, false)) <= 4.0 * g { + return Err(overlap(Some(pair))); + } + Ok(()) +} + +fn overlap(segments: Option<[usize; 2]>) -> PolysolidError { + parameter(match segments { + Some([a, b]) => format!( + "polysolid wall overlaps itself between segment {} and segment {}", + a.min(b), + a.max(b) + ), + None => "polysolid wall overlaps itself".into(), + }) +} + +pub(super) fn tangent(edge: &ProfileEdge, at_end: bool) -> [f64; 2] { + match *edge { + ProfileEdge::Line { from, to } => { + let length = (to[0] - from[0]).hypot(to[1] - from[1]); + [(to[0] - from[0]) / length, (to[1] - from[1]) / length] + } + ProfileEdge::Arc { + start_angle, + sweep_angle, + .. + } => { + let angle = start_angle + if at_end { sweep_angle } else { 0.0 }; + let sense = sweep_angle.signum(); + [-angle.sin() * sense, angle.cos() * sense] + } + } +} + +pub(super) fn is_line(edge: &ProfileEdge) -> bool { + matches!(edge, ProfileEdge::Line { .. }) +} + +pub(super) fn cross(a: [f64; 2], b: [f64; 2]) -> f64 { + a[0] * b[1] - a[1] * b[0] +} + +pub(super) fn dot(a: [f64; 2], b: [f64; 2]) -> f64 { + a[0] * b[0] + a[1] * b[1] +} diff --git a/main/opengeometry/src/primitives/internal/polysolid/mod.rs b/main/opengeometry/src/primitives/internal/polysolid/mod.rs new file mode 100644 index 00000000..28b045b5 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/polysolid/mod.rs @@ -0,0 +1,27 @@ +mod checks; +mod outline; +mod path; +mod stroke; + +#[cfg(test)] +mod tests; + +use crate::brep::{Accuracy, BrepEnvelope, Frame3}; +use outline::arc_polysolid; +pub use path::{PolysolidError, PolysolidJustify, PolysolidPath}; +use stroke::stroked_polysolid; + +pub fn polysolid( + id: String, + frame: Frame3, + path: &PolysolidPath, + justify: PolysolidJustify, + sizes: [f64; 2], + accuracy: Accuracy, +) -> Result { + if path.segments.iter().any(|segment| segment.arc.is_some()) { + arc_polysolid(id, frame, path, justify, sizes, accuracy) + } else { + stroked_polysolid(id, frame, path, justify, sizes, accuracy) + } +} diff --git a/main/opengeometry/src/primitives/internal/polysolid/outline.rs b/main/opengeometry/src/primitives/internal/polysolid/outline.rs new file mode 100644 index 00000000..42caa521 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/polysolid/outline.rs @@ -0,0 +1,224 @@ +use super::super::arc_edged_extrusion::arc_edged_extrusion_with_holes; +use super::checks::{ + check_leg_kept, check_overlap, check_path, check_rings_apart, check_short_legs, + check_tangent_joints, cross, dot, is_line, tangent, +}; +use super::path::{PolysolidError, PolysolidJustify, PolysolidPath, MITER_LIMIT}; +use crate::brep::{Accuracy, BrepEnvelope, Frame3}; +use crate::primitives::profile::{profile_area_twice, ProfileEdge}; +use crate::primitives::profile_wire::profile_segment_edges; + +struct Side { + edges: Vec, + segments: Vec, +} + +pub(super) fn arc_polysolid( + id: String, + frame: Frame3, + path: &PolysolidPath, + justify: PolysolidJustify, + [width, height]: [f64; 2], + accuracy: Accuracy, +) -> Result { + let g = accuracy.geometric; + let edges = profile_segment_edges(path.start, &path.segments, path.closed); + check_path(path, &edges, g)?; + let [left, right] = match justify { + PolysolidJustify::Centre => [width / 2.0, -width / 2.0], + PolysolidJustify::Left => [width, 0.0], + PolysolidJustify::Right => [0.0, -width], + }; + check_tangent_joints(&edges, path.closed, g / (4.0 * left.abs().max(right.abs())))?; + check_short_legs(&edges, path.closed, width)?; + let side_a = offset_side(&edges, path.closed, left, g)?; + let side_b = offset_side(&edges, path.closed, right, g)?; + let rings = if !path.closed { + vec![open_outline(side_a, side_b)] + } else if profile_area_twice(&side_a.edges).abs() >= profile_area_twice(&side_b.edges).abs() { + vec![side_a, side_b] + } else { + vec![side_b, side_a] + }; + for ring in &rings { + check_overlap(&ring.edges, &ring.segments, g)?; + } + if let [outer, hole] = rings.as_slice() { + check_rings_apart( + (&outer.edges, &outer.segments), + (&hole.edges, &hole.segments), + g, + )?; + } + let mut rings = rings.into_iter().map(|ring| ring.edges); + let outer = rings.next().unwrap_or_default(); + arc_edged_extrusion_with_holes(id, frame, outer, rings.collect(), height, accuracy) + .map_err(PolysolidError::Geometry) +} + +fn offset_side( + edges: &[ProfileEdge], + closed: bool, + distance: f64, + g: f64, +) -> Result { + let mut pieces = edges + .iter() + .enumerate() + .map(|(index, edge)| offset_piece(edge, index, distance, g)) + .collect::, _>>()?; + let mut side = Side { + edges: Vec::with_capacity(2 * pieces.len()), + segments: Vec::with_capacity(2 * pieces.len()), + }; + let mut bevels = vec![None; pieces.len()]; + for joint in usize::from(!closed)..pieces.len() { + let before = (joint + pieces.len() - 1) % pieces.len(); + bevels[joint] = join(&mut pieces, [before, joint], edges, distance); + } + let count = pieces.len(); + for (index, piece) in pieces.iter().enumerate() { + let next = (index + 1) % count; + let pair = if (closed || index + 1 < count) && is_line(&edges[next]) { + [index, next] + } else { + [(index + count - 1) % count, index] + }; + check_leg_kept(piece, &edges[index], pair, g)?; + } + for (index, (bevel, piece)) in bevels.into_iter().zip(pieces).enumerate() { + if let Some(bevel) = bevel { + side.edges.push(bevel); + side.segments.push(index); + } + side.edges.push(piece); + side.segments.push(index); + } + Ok(side) +} + +fn offset_piece( + edge: &ProfileEdge, + index: usize, + distance: f64, + g: f64, +) -> Result { + match *edge { + ProfileEdge::Line { from, to } => { + let normal = left_normal(tangent(edge, false)); + Ok(ProfileEdge::Line { + from: along(from, normal, distance), + to: along(to, normal, distance), + }) + } + ProfileEdge::Arc { + center, + radius, + start_angle, + sweep_angle, + } => { + let offset = radius - distance * sweep_angle.signum(); + if offset <= 4.0 * g { + return Err(PolysolidError::Parameter(format!( + "polysolid path segment {index} is an arc whose offset radius is not above four times the tolerance" + ))); + } + Ok(ProfileEdge::Arc { + center, + radius: offset, + start_angle, + sweep_angle, + }) + } + } +} + +fn join( + pieces: &mut [ProfileEdge], + [before, joint]: [usize; 2], + path: &[ProfileEdge], + distance: f64, +) -> Option { + let (end, start) = (pieces[before].endpoints().1, pieces[joint].endpoints().0); + match (is_line(&pieces[before]), is_line(&pieces[joint])) { + (true, false) => set_end(&mut pieces[before], start), + (false, true) => set_start(&mut pieces[joint], end), + (false, false) => {} + (true, true) => { + let incoming = tangent(&path[before], true); + let outgoing = tangent(&path[joint], false); + let turn = cross(incoming, outgoing); + let deflection = dot(incoming, outgoing).clamp(-1.0, 1.0).acos(); + if distance * turn < 0.0 && 1.0 / (deflection * 0.5).cos() > MITER_LIMIT { + return Some(ProfileEdge::Line { + from: end, + to: start, + }); + } + let (normals, at) = ( + [left_normal(incoming), left_normal(outgoing)], + path[joint].endpoints().0, + ); + let scale = distance / (1.0 + dot(normals[0], normals[1])); + let corner = along(at, add(normals[0], normals[1]), scale); + set_end(&mut pieces[before], corner); + set_start(&mut pieces[joint], corner); + } + } + None +} + +fn open_outline(side_a: Side, side_b: Side) -> Side { + let a_ends = ( + side_a.edges[0].endpoints().0, + side_a.edges[side_a.edges.len() - 1].endpoints().1, + ); + let b_ends = ( + side_b.edges[0].endpoints().0, + side_b.edges[side_b.edges.len() - 1].endpoints().1, + ); + let last = side_a.segments[side_a.segments.len() - 1]; + let mut outline = side_a; + outline.edges.push(ProfileEdge::Line { + from: a_ends.1, + to: b_ends.1, + }); + outline.segments.push(last); + outline + .edges + .extend(side_b.edges.iter().rev().map(ProfileEdge::reversed)); + outline.segments.extend(side_b.segments.iter().rev()); + outline.edges.push(ProfileEdge::Line { + from: b_ends.0, + to: a_ends.0, + }); + outline.segments.push(0); + outline +} + +fn set_start(edge: &mut ProfileEdge, point: [f64; 2]) { + if let ProfileEdge::Line { from, .. } = edge { + *from = point; + } +} + +fn set_end(edge: &mut ProfileEdge, point: [f64; 2]) { + if let ProfileEdge::Line { to, .. } = edge { + *to = point; + } +} + +fn left_normal(direction: [f64; 2]) -> [f64; 2] { + [-direction[1], direction[0]] +} + +fn along(point: [f64; 2], direction: [f64; 2], distance: f64) -> [f64; 2] { + [ + point[0] + direction[0] * distance, + point[1] + direction[1] * distance, + ] +} + +fn add(a: [f64; 2], b: [f64; 2]) -> [f64; 2] { + [a[0] + b[0], a[1] + b[1]] +} diff --git a/main/opengeometry/src/primitives/internal/polysolid/path.rs b/main/opengeometry/src/primitives/internal/polysolid/path.rs new file mode 100644 index 00000000..ae6daf53 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/polysolid/path.rs @@ -0,0 +1,65 @@ +use crate::brep::GeometryError; +use crate::primitives::profile_wire::ProfileSegment; +use serde::{Deserialize, Serialize}; + +pub(super) const MITER_LIMIT: f64 = 4.0; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolysolidPath { + pub start: [f64; 2], + pub segments: Vec, + pub closed: bool, +} + +#[derive(Debug)] +pub enum PolysolidError { + Parameter(String), + Geometry(GeometryError), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PolysolidJustify { + #[default] + Centre, + Left, + Right, +} + +pub(super) fn check_path_ends( + path: &PolysolidPath, + fewest: usize, + too_few: &str, + g: f64, +) -> Result<(), PolysolidError> { + let centres = path.segments.iter().filter_map(|segment| segment.arc); + let values = path + .segments + .iter() + .flat_map(|segment| segment.to) + .chain(centres.flat_map(|arc| arc.center)); + if !path.start.into_iter().chain(values).all(f64::is_finite) { + return Err(parameter("polysolid path points must be finite".into())); + } + if path.segments.len() < fewest { + return Err(parameter(too_few.into())); + } + let last = path.segments[path.segments.len() - 1].to; + if path.closed != (distance(last, path.start) <= 4.0 * g) { + return Err(parameter(if path.closed { + "closed polysolid path must end at its start".into() + } else { + "open polysolid path must not end at its start".into() + })); + } + Ok(()) +} + +pub(super) fn distance(a: [f64; 2], b: [f64; 2]) -> f64 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} + +pub(super) fn parameter(message: String) -> PolysolidError { + PolysolidError::Parameter(message) +} diff --git a/main/opengeometry/src/primitives/internal/polysolid/stroke.rs b/main/opengeometry/src/primitives/internal/polysolid/stroke.rs new file mode 100644 index 00000000..8c364137 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/polysolid/stroke.rs @@ -0,0 +1,182 @@ +use super::super::linear_extrusion::linear_extrusion; +use super::path::{ + check_path_ends, distance, parameter, PolysolidError, PolysolidJustify, PolysolidPath, + MITER_LIMIT, +}; +use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError}; +use crate::geom2d::{ + offset_path, offset_polyline_regions, CurveEdge2, OffsetError, OffsetOptions, Pt2, +}; +use crate::primitives::profile::{ + profile_edge_intersections, profile_self_intersects, ProfileEdge, +}; +use crate::primitives::profile_wire::profile_segment_edges; + +const STROKE_TOLERANCE: f64 = 1.0e-6; +const BEVEL_BELOW_DEGREES: f64 = 28.955_024_371_859_85; + +pub(super) fn stroked_polysolid( + id: String, + frame: Frame3, + path: &PolysolidPath, + justify: PolysolidJustify, + [width, height]: [f64; 2], + accuracy: Accuracy, +) -> Result { + let g = accuracy.geometric; + let centre = path_points(path, g)?; + check_legs(¢re, path.closed, width, g, "")?; + let edges = profile_segment_edges(path.start, &path.segments, path.closed); + check_crossing(&edges, path.closed, g)?; + let points = justified(¢re, path.closed, justify, width)?; + check_legs(&points, path.closed, width, g, " after justify")?; + let (outer, holes) = stroke(&points, path.closed, width)?; + linear_extrusion(id, frame, outer, holes, height, accuracy).map_err(PolysolidError::Geometry) +} + +fn path_points(path: &PolysolidPath, g: f64) -> Result, PolysolidError> { + let (fewest, too_few) = if path.closed { + (3, "closed polysolid path needs at least three points") + } else { + (1, "polysolid path needs at least two points") + }; + check_path_ends(path, fewest, too_few, g)?; + let mut points = vec![path.start]; + let count = if path.closed { + path.segments.len() - 1 + } else { + path.segments.len() + }; + points.extend(path.segments[..count].iter().map(|segment| segment.to)); + Ok(points) +} + +fn check_legs( + points: &[[f64; 2]], + closed: bool, + width: f64, + g: f64, + stage: &str, +) -> Result<(), PolysolidError> { + let legs = if closed { + points.len() + } else { + points.len().saturating_sub(1) + }; + for index in 0..legs { + let length = distance(points[index], points[(index + 1) % points.len()]); + if length <= 4.0 * g { + return Err(parameter(format!( + "polysolid path leg {index} has zero length{stage}" + ))); + } + if length < width / 2.0 { + return Err(parameter(format!( + "polysolid path leg {index} is shorter than half the width{stage}" + ))); + } + } + Ok(()) +} + +fn check_crossing(edges: &[ProfileEdge], closed: bool, g: f64) -> Result<(), PolysolidError> { + if !profile_self_intersects(edges, closed, g) { + return Ok(()); + } + let last = edges.len() - 1; + let pair = (0..edges.len()) + .flat_map(|first| (first + 2..edges.len()).map(move |second| (first, second))) + .filter(|&(first, second)| !(closed && first == 0 && second == last)) + .find(|&(first, second)| { + !profile_edge_intersections(&edges[first], &edges[second], g).is_empty() + }); + Err(parameter(match pair { + Some((first, second)) => { + format!("polysolid path crosses itself at legs {first} and {second}") + } + None => "polysolid path crosses itself where two legs overlap".into(), + })) +} + +fn justified( + points: &[[f64; 2]], + closed: bool, + justify: PolysolidJustify, + width: f64, +) -> Result, PolysolidError> { + let shift = match justify { + PolysolidJustify::Centre => return Ok(points.to_vec()), + PolysolidJustify::Left => width / 2.0, + PolysolidJustify::Right => -width / 2.0, + }; + let options = OffsetOptions { + bevel: true, + acute_threshold_degrees: BEVEL_BELOW_DEGREES, + }; + let offset = offset_path( + &plane_points(points), + shift, + Some(closed), + options, + STROKE_TOLERANCE, + ); + line_points(&offset.edges, closed).map_err(PolysolidError::Geometry) +} + +fn stroke( + points: &[[f64; 2]], + closed: bool, + width: f64, +) -> Result<(Vec<[f64; 2]>, Vec>), PolysolidError> { + let regions = offset_polyline_regions( + &plane_points(points), + width, + closed, + MITER_LIMIT, + STROKE_TOLERANCE, + ) + .map_err(|error| match error { + OffsetError::InvalidParameter(message) => parameter(message), + OffsetError::InvalidTopology(message) => { + PolysolidError::Geometry(GeometryError::InvalidTopology(message)) + } + OffsetError::Clip(error) => PolysolidError::Geometry(error), + })?; + let [region] = regions.as_slice() else { + return Err(parameter(format!( + "polysolid stroke gives {} regions instead of one", + regions.len() + ))); + }; + let holes = region + .holes + .iter() + .map(|hole| line_points(hole, true)) + .collect::, _>>() + .map_err(PolysolidError::Geometry)?; + let outer = line_points(®ion.outer, true).map_err(PolysolidError::Geometry)?; + Ok((outer, holes)) +} + +fn line_points(edges: &[CurveEdge2], closed: bool) -> Result, GeometryError> { + let mut points = Vec::with_capacity(edges.len() + 1); + for (index, edge) in edges.iter().enumerate() { + let &CurveEdge2::Line { from, to } = edge else { + return Err(GeometryError::InvalidGeometry( + "polysolid outline has an arc edge".into(), + )); + }; + points.push(from); + if !closed && index + 1 == edges.len() { + points.push(to); + } + } + Ok(points) +} + +fn plane_points(points: &[[f64; 2]]) -> Vec { + points + .iter() + .map(|point| Pt2::new(point[0], point[1])) + .collect() +} diff --git a/main/opengeometry/src/primitives/internal/polysolid/tests.rs b/main/opengeometry/src/primitives/internal/polysolid/tests.rs new file mode 100644 index 00000000..501f36c9 --- /dev/null +++ b/main/opengeometry/src/primitives/internal/polysolid/tests.rs @@ -0,0 +1,87 @@ +use super::outline::arc_polysolid; +use super::stroke::stroked_polysolid; +use super::{PolysolidJustify, PolysolidPath}; +use crate::brep::{Accuracy, BrepEnvelope, GROUND}; +use crate::primitives::profile_wire::ProfileSegment; + +fn straight(points: &[[f64; 2]], closed: bool) -> PolysolidPath { + PolysolidPath { + start: points[0], + segments: points[1..] + .iter() + .map(|&to| ProfileSegment { to, arc: None }) + .collect(), + closed, + } +} + +fn corners(body: &BrepEnvelope) -> Vec<[f64; 3]> { + body.topology + .vertices + .iter() + .map(|vertex| vertex.position) + .collect() +} + +fn on_segment(point: [f64; 3], a: [f64; 3], b: [f64; 3]) -> bool { + let along = (0..3).map(|axis| (b[axis] - a[axis]) * (point[axis] - a[axis])); + let length = (0..3).map(|axis| (b[axis] - a[axis]).powi(2)).sum::(); + let t = along.sum::() / length; + (0.0..=1.0).contains(&t) + && (0..3).all(|axis| (a[axis] + t * (b[axis] - a[axis]) - point[axis]).abs() <= 1e-9) +} + +fn same_outline(stroke: &[[f64; 3]], outline: &[[f64; 3]]) -> bool { + let near = |a: [f64; 3], b: [f64; 3]| (0..3).all(|axis| (a[axis] - b[axis]).abs() <= 1e-9); + outline.iter().all(|&o| stroke.iter().any(|&s| near(o, s))) + && stroke.iter().all(|&s| { + outline + .iter() + .any(|&a| outline.iter().any(|&b| !near(a, b) && on_segment(s, a, b))) + }) +} + +#[test] +fn straight_path_through_the_arc_outline_matches_the_stroke() { + let justified = [ + PolysolidJustify::Centre, + PolysolidJustify::Left, + PolysolidJustify::Right, + ]; + let cases = [ + ( + straight(&[[0.0, 0.0], [3.0, 0.0], [3.0, 2.0]], false), + &justified[..], + ), + ( + straight( + &[[0.0, 0.0], [3.0, 0.0], [3.0, 2.0], [0.0, 2.0], [0.0, 0.0]], + true, + ), + &justified[..], + ), + ( + straight(&[[0.0, 0.0], [3.0, 0.0], [1.5, 2.6]], false), + &justified[..], + ), + ( + straight(&[[0.0, 0.0], [3.0, 0.0], [0.0, 0.5]], false), + &justified[..1], + ), + ]; + for (path, justifies) in &cases { + for &justify in *justifies { + let sizes = [0.2, 1.0]; + let accuracy = Accuracy::STANDARD; + let stroke = + stroked_polysolid("s".into(), GROUND, path, justify, sizes, accuracy).unwrap(); + let outline = + arc_polysolid("o".into(), GROUND, path, justify, sizes, accuracy).unwrap(); + let label = format!("{path:?} {justify:?}"); + assert!( + same_outline(&corners(&stroke), &corners(&outline)), + "{label}" + ); + } + } +} diff --git a/main/opengeometry/src/primitives/mod.rs b/main/opengeometry/src/primitives/mod.rs index e8ea53e7..815d4276 100644 --- a/main/opengeometry/src/primitives/mod.rs +++ b/main/opengeometry/src/primitives/mod.rs @@ -21,7 +21,9 @@ pub use cuboid::cuboid; pub use cylinder::cylinder; pub use internal::{ annular_cylinder, arc_edged_extrusion, arc_edged_extrusion_with_holes, cone, cylinder_sector, - cylinder_with_circular_hole, frustum, linear_extrusion, revolved_profile, sphere, torus, + cylinder_with_circular_hole, frustum, linear_extrusion, polysolid, revolved_profile, + ring_sector_outline, rounded_rectangle_outline, slot_outline, sphere, torus, PolysolidError, + PolysolidJustify, PolysolidPath, }; pub use polyhedron::polyhedron; pub use polyline::{polyline, polyline_with_keys}; diff --git a/main/opengeometry/src/world_graph/primitive/kind.rs b/main/opengeometry/src/world_graph/primitive/kind.rs new file mode 100644 index 00000000..0ea2a5aa --- /dev/null +++ b/main/opengeometry/src/world_graph/primitive/kind.rs @@ -0,0 +1,130 @@ +use crate::brep::BodyType; +use crate::math::Point3; +use crate::primitives::{PolysolidJustify, PolysolidPath, ProfileSegment}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", deny_unknown_fields)] +pub enum Primitive { + Cuboid { + width: f64, + height: f64, + depth: f64, + }, + Cylinder { + radius: f64, + height: f64, + }, + #[serde(rename_all = "camelCase")] + CylinderSector { + radius: f64, + height: f64, + start_angle: f64, + sweep_angle: f64, + }, + Polyhedron { + points: Vec, + faces: Vec>, + }, + Sphere { + radius: f64, + }, + #[serde(rename_all = "camelCase")] + Cone { + radius_bottom: f64, + radius_top: f64, + height: f64, + }, + #[serde(rename_all = "camelCase")] + Torus { + major_radius: f64, + minor_radius: f64, + }, + Rectangle { + width: f64, + breadth: f64, + }, + Circle { + radius: f64, + }, + Polyline { + points: Vec, + closed: bool, + }, + Profile { + start: [f64; 2], + segments: Vec, + closed: bool, + }, + #[serde(rename_all = "camelCase")] + Arc { + radius: f64, + start_angle: f64, + sweep_angle: f64, + }, + SlotPrism { + length: f64, + width: f64, + height: f64, + }, + RoundedBox { + width: f64, + height: f64, + depth: f64, + radius: f64, + }, + #[serde(rename_all = "camelCase")] + PipeSegment { + outer_radius: f64, + inner_radius: f64, + height: f64, + start_angle: f64, + sweep_angle: f64, + }, + Polysolid { + path: PolysolidPath, + width: f64, + height: f64, + #[serde(default)] + justify: PolysolidJustify, + }, + RegularPolygon { + sides: u32, + radius: f64, + }, + Slot { + length: f64, + width: f64, + }, + RoundedRectangle { + width: f64, + breadth: f64, + radius: f64, + }, +} + +impl Primitive { + pub(super) fn body_type(&self) -> BodyType { + match self { + Self::Cuboid { .. } + | Self::Cylinder { .. } + | Self::CylinderSector { .. } + | Self::Polyhedron { .. } + | Self::Sphere { .. } + | Self::Cone { .. } + | Self::Torus { .. } + | Self::SlotPrism { .. } + | Self::RoundedBox { .. } + | Self::PipeSegment { .. } + | Self::Polysolid { .. } => BodyType::Solid, + Self::Rectangle { .. } + | Self::Circle { .. } + | Self::Polyline { .. } + | Self::Profile { .. } + | Self::Arc { .. } + | Self::RegularPolygon { .. } + | Self::Slot { .. } + | Self::RoundedRectangle { .. } => BodyType::Wire, + } + } +} diff --git a/main/opengeometry/src/world_graph/primitive/mod.rs b/main/opengeometry/src/world_graph/primitive/mod.rs index 2ce579f8..970918b0 100644 --- a/main/opengeometry/src/world_graph/primitive/mod.rs +++ b/main/opengeometry/src/world_graph/primitive/mod.rs @@ -1,82 +1,21 @@ +mod kind; mod sizes; mod solids; +mod wires; + +pub use kind::Primitive; use super::candidate::IdKind; use super::change_log::ChangeSet; use super::error::{ErrorCode, ErrorContext, GraphError}; use super::graph::WorldGraph; use super::node::EditScope; -use crate::brep::{ - Accuracy, BodyType, BrepEnvelope, CurveGeometry, Frame3, GeometryError, Similarity3, GROUND, -}; -use crate::math::{norm, sub, Point3}; -use crate::primitives::{self, ProfileEdge, ProfileSegment}; +use crate::brep::{Accuracy, BodyType, BrepEnvelope, Frame3, Similarity3, GROUND}; +use crate::math::Point3; use serde::{Deserialize, Serialize}; -use sizes::{parameter, required_size}; -use std::f64::consts::TAU; +use sizes::wrong_body_type; use std::sync::Arc; -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "kind", deny_unknown_fields)] -pub enum Primitive { - Cuboid { - width: f64, - height: f64, - depth: f64, - }, - Cylinder { - radius: f64, - height: f64, - }, - #[serde(rename_all = "camelCase")] - CylinderSector { - radius: f64, - height: f64, - start_angle: f64, - sweep_angle: f64, - }, - Polyhedron { - points: Vec, - faces: Vec>, - }, - Sphere { - radius: f64, - }, - #[serde(rename_all = "camelCase")] - Cone { - radius_bottom: f64, - radius_top: f64, - height: f64, - }, - #[serde(rename_all = "camelCase")] - Torus { - major_radius: f64, - minor_radius: f64, - }, - Rectangle { - width: f64, - breadth: f64, - }, - Circle { - radius: f64, - }, - Polyline { - points: Vec, - closed: bool, - }, - Profile { - start: [f64; 2], - segments: Vec, - closed: bool, - }, - #[serde(rename_all = "camelCase")] - Arc { - radius: f64, - start_angle: f64, - sweep_angle: f64, - }, -} - #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Plane { @@ -94,25 +33,6 @@ pub struct CreateOptions { pub body_type: Option, } -impl Primitive { - fn body_type(&self) -> BodyType { - match self { - Self::Cuboid { .. } - | Self::Cylinder { .. } - | Self::CylinderSector { .. } - | Self::Polyhedron { .. } - | Self::Sphere { .. } - | Self::Cone { .. } - | Self::Torus { .. } => BodyType::Solid, - Self::Rectangle { .. } - | Self::Circle { .. } - | Self::Polyline { .. } - | Self::Profile { .. } - | Self::Arc { .. } => BodyType::Wire, - } - } -} - impl WorldGraph { pub fn create_primitive( &mut self, @@ -281,309 +201,9 @@ fn build_primitive( primitive: Primitive, accuracy: Accuracy, ) -> Result<(BrepEnvelope, Vec), GraphError> { - let result = match primitive { - Primitive::Cuboid { - width, - height, - depth, - } => cuboid_body(shape_id.clone(), [width, height, depth], accuracy)?, - Primitive::Cylinder { radius, height } => { - cylinder_body(shape_id.clone(), [radius, height], accuracy)? - } - Primitive::CylinderSector { - radius, - height, - start_angle, - sweep_angle, - } => sector_body( - shape_id.clone(), - [radius, height], - [start_angle, sweep_angle], - accuracy, - )?, - Primitive::Polyhedron { points, faces } => { - polyhedron_body(shape_id.clone(), &points, &faces, accuracy)? - } - Primitive::Sphere { radius } => solids::sphere_body(shape_id.clone(), radius, accuracy)?, - Primitive::Cone { - radius_bottom, - radius_top, - height, - } => solids::cone_body( - shape_id.clone(), - [radius_bottom, radius_top], - height, - accuracy, - )?, - Primitive::Torus { - major_radius, - minor_radius, - } => solids::torus_body(shape_id.clone(), [major_radius, minor_radius], accuracy)?, - Primitive::Rectangle { width, breadth } => { - rectangle_body(shape_id.clone(), [width, breadth], accuracy)? - } - Primitive::Circle { radius } => { - required_size(radius, accuracy.geometric, "radius")?; - arc_body(shape_id.clone(), radius, [0.0, TAU], accuracy)? - } - Primitive::Arc { - radius, - start_angle, - sweep_angle, - } => { - check_arc("arc", radius, [start_angle, sweep_angle], accuracy)?; - arc_body( - shape_id.clone(), - radius, - [start_angle, sweep_angle], - accuracy, - )? - } - Primitive::Polyline { points, closed } => { - check_polyline(&points, closed, accuracy)?; - let (brep, keys) = - primitives::polyline_with_keys(shape_id.clone(), &points, closed, accuracy)?; - (brep, keys) - } - Primitive::Profile { - start, - segments, - closed, - } => profile_body(shape_id.clone(), start, &segments, closed, accuracy)?, - }; - Ok(result) -} - -fn profile_body( - shape_id: String, - start: [f64; 2], - segments: &[ProfileSegment], - closed: bool, - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - let edges = primitives::profile_segment_edges(start, segments, closed); - check_profile(start, segments, &edges, closed, accuracy.geometric)?; - Ok(primitives::profile_wire_with_keys( - shape_id, start, segments, closed, accuracy, - )?) -} - -fn cuboid_body( - shape_id: String, - [width, height, depth]: [f64; 3], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - for (name, value) in [("width", width), ("height", height), ("depth", depth)] { - required_size(value, accuracy.geometric, name)?; - } - let frame = Frame3 { - origin: [-width / 2.0, 0.0, depth / 2.0], - ..GROUND - }; - Ok(( - primitives::cuboid(shape_id, frame, [width, depth, height], accuracy)?, - Vec::new(), - )) -} - -fn cylinder_body( - shape_id: String, - [radius, height]: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - required_size(radius, accuracy.geometric, "radius")?; - required_size(height, accuracy.geometric, "height")?; - Ok(( - primitives::cylinder(shape_id, GROUND, radius, height, accuracy)?, - Vec::new(), - )) -} - -fn rectangle_body( - shape_id: String, - [width, breadth]: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - required_size(width, accuracy.geometric, "width")?; - required_size(breadth, accuracy.geometric, "breadth")?; - Ok(primitives::rectangle_with_keys( - shape_id, GROUND, width, breadth, accuracy, - )?) -} - -fn polyhedron_body( - shape_id: String, - points: &[Point3], - faces: &[Vec], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - let brep = - primitives::polyhedron(shape_id, points, faces, accuracy).map_err(|error| match error { - GeometryError::InvalidGeometry(message) - | GeometryError::UnresolvedIntersection(message) => { - GraphError::code(ErrorCode::InvalidParameter, message) - } - GeometryError::Math(error) => GraphError::code( - ErrorCode::InvalidParameter, - format!("polyhedron points are out of range: {error:?}"), - ), - other => other.into(), - })?; - Ok((brep, Vec::new())) -} - -fn sector_body( - shape_id: String, - [radius, height]: [f64; 2], - angles: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - check_arc("sector", radius, angles, accuracy)?; - required_size(height, accuracy.geometric, "height")?; - let [start_angle, sweep_angle] = angles; - Ok(( - primitives::cylinder_sector( - shape_id, - GROUND, - radius, - height, - start_angle, - sweep_angle, - accuracy, - )?, - Vec::new(), - )) -} - -fn arc_body( - shape_id: String, - radius: f64, - [start_angle, sweep_angle]: [f64; 2], - accuracy: Accuracy, -) -> Result<(BrepEnvelope, Vec), GraphError> { - let curve = CurveGeometry::Circle { - frame: GROUND, - radius, - }; - Ok(( - primitives::arc_wire(shape_id, curve, start_angle, sweep_angle, accuracy)?, - vec!["edge-0".into()], - )) -} - -fn check_polyline(points: &[Point3], closed: bool, accuracy: Accuracy) -> Result<(), GraphError> { - if points.len() < if closed { 3 } else { 2 } { - return Err(GraphError::code( - ErrorCode::InvalidParameter, - "polyline has too few points", - )); - } - if points.iter().flatten().any(|value| !value.is_finite()) { - return Err(GraphError::code( - ErrorCode::InvalidParameter, - "polyline points must be finite", - )); + match primitive.body_type() { + BodyType::Solid => solids::build_solid(shape_id, primitive, accuracy), + BodyType::Wire => wires::build_wire(shape_id, primitive, accuracy), + BodyType::Sheet => Err(wrong_body_type("solid or a wire")), } - for index in 0..if closed { - points.len() - } else { - points.len() - 1 - } { - let next = (index + 1) % points.len(); - if norm(sub(points[next], points[index])) <= 4.0 * accuracy.geometric { - return Err(GraphError::code( - ErrorCode::InvalidParameter, - "polyline has a zero-length segment", - )); - } - } - Ok(()) -} - -fn check_arc( - kind: &str, - radius: f64, - [start, sweep]: [f64; 2], - accuracy: Accuracy, -) -> Result<(), GraphError> { - required_size(radius, accuracy.geometric, "radius")?; - if !start.is_finite() || !sweep.is_finite() || sweep == 0.0 || sweep.abs() >= TAU { - return Err(parameter(&format!( - "{kind} sweep must be nonzero and less than one turn" - ))); - } - if radius * sweep.abs() <= 4.0 * accuracy.geometric { - return Err(parameter(&format!( - "{kind} length must exceed four times geometric tolerance" - ))); - } - Ok(()) -} - -fn check_profile( - start: [f64; 2], - segments: &[ProfileSegment], - edges: &[ProfileEdge], - closed: bool, - g: f64, -) -> Result<(), GraphError> { - if segments.len() < if closed { 2 } else { 1 } { - return Err(parameter("profile has too few segments")); - } - let values = segments.iter().flat_map(|segment| { - segment - .to - .into_iter() - .chain(segment.arc.into_iter().flat_map(|arc| arc.center)) - }); - if !start.into_iter().chain(values).all(f64::is_finite) { - return Err(parameter("profile points must be finite")); - } - let last = segments[segments.len() - 1].to; - if closed != ((last[0] - start[0]).hypot(last[1] - start[1]) <= 4.0 * g) { - return Err(parameter(if closed { - "closed profile must end at its start" - } else { - "open profile must not end at its start" - })); - } - for (index, (edge, segment)) in edges.iter().zip(segments).enumerate() { - let to = if closed && index + 1 == segments.len() { - start - } else { - segment.to - }; - check_profile_edge(edge, to, g)?; - } - if primitives::profile_self_intersects(edges, closed, g) { - return Err(parameter("profile self-intersects")); - } - if closed && primitives::profile_area_twice(edges).abs() <= 16.0 * g * g { - return Err(parameter("profile area is below geometric resolution")); - } - Ok(()) -} - -fn check_profile_edge(edge: &ProfileEdge, to: [f64; 2], g: f64) -> Result<(), GraphError> { - let (from, _) = edge.endpoints(); - if (to[0] - from[0]).hypot(to[1] - from[1]) <= 4.0 * g { - return Err(parameter("profile has a zero-length segment")); - } - if let ProfileEdge::Arc { - center, - radius, - sweep_angle, - .. - } = *edge - { - if radius <= 4.0 * g - || ((to[0] - center[0]).hypot(to[1] - center[1]) - radius).abs() > g / 4.0 - { - return Err(parameter("profile arc does not end on its circle")); - } - if sweep_angle.abs() >= TAU || radius * sweep_angle.abs() <= 4.0 * g { - return Err(parameter("profile arc is below geometric resolution")); - } - } - Ok(()) } diff --git a/main/opengeometry/src/world_graph/primitive/sizes.rs b/main/opengeometry/src/world_graph/primitive/sizes.rs index 2290a7f6..118a2827 100644 --- a/main/opengeometry/src/world_graph/primitive/sizes.rs +++ b/main/opengeometry/src/world_graph/primitive/sizes.rs @@ -1,4 +1,6 @@ +use crate::brep::Accuracy; use crate::world_graph::error::{ErrorCode, GraphError}; +use std::f64::consts::TAU; pub(super) fn required_size(value: f64, accuracy: f64, name: &str) -> Result<(), GraphError> { if !value.is_finite() || value <= 4.0 * accuracy { @@ -13,3 +15,53 @@ pub(super) fn required_size(value: f64, accuracy: f64, name: &str) -> Result<(), pub(super) fn parameter(message: &str) -> GraphError { GraphError::code(ErrorCode::InvalidParameter, message) } + +pub(super) fn wrong_body_type(expected: &str) -> GraphError { + GraphError::code( + ErrorCode::BodyTypeMismatch, + format!("primitive is not a {expected}"), + ) +} + +pub(super) fn check_arc( + kind: &str, + radius: f64, + [start, sweep]: [f64; 2], + accuracy: Accuracy, +) -> Result<(), GraphError> { + required_size(radius, accuracy.geometric, "radius")?; + if !start.is_finite() || !sweep.is_finite() || sweep == 0.0 || sweep.abs() >= TAU { + return Err(parameter(&format!( + "{kind} sweep must be nonzero and less than one turn" + ))); + } + if radius * sweep.abs() <= 4.0 * accuracy.geometric { + return Err(parameter(&format!( + "{kind} length must exceed four times geometric tolerance" + ))); + } + Ok(()) +} + +pub(super) fn check_slot(length: f64, width: f64, g: f64) -> Result<(), GraphError> { + required_size(length, g, "length")?; + required_size(width / 2.0, g, "width") +} + +pub(super) fn check_corner_radius( + kind: &str, + sides: [(&str, f64); 2], + radius: f64, + g: f64, +) -> Result<(), GraphError> { + for (name, value) in sides.into_iter().chain([("radius", radius)]) { + required_size(value, g, name)?; + } + if sides[0].1.min(sides[1].1) - 2.0 * radius <= 4.0 * g { + return Err(parameter(&format!( + "{kind} radius must be below half its {} and {}", + sides[0].0, sides[1].0 + ))); + } + Ok(()) +} diff --git a/main/opengeometry/src/world_graph/primitive/solids.rs b/main/opengeometry/src/world_graph/primitive/solids.rs index 870075fd..0d10425c 100644 --- a/main/opengeometry/src/world_graph/primitive/solids.rs +++ b/main/opengeometry/src/world_graph/primitive/solids.rs @@ -1,10 +1,160 @@ -use super::sizes::{parameter, required_size}; -use crate::brep::{Accuracy, BrepEnvelope, Frame3, GROUND}; -use crate::math::scale; -use crate::primitives; -use crate::world_graph::error::GraphError; +use super::kind::Primitive; +use super::sizes::{ + check_arc, check_corner_radius, check_slot, parameter, required_size, wrong_body_type, +}; +use crate::brep::{Accuracy, BrepEnvelope, Frame3, GeometryError, GROUND}; +use crate::math::{scale, Point3}; +use crate::primitives::{self, PolysolidError, PolysolidJustify, PolysolidPath, ProfileEdge}; +use crate::world_graph::error::{ErrorCode, GraphError}; -pub(super) fn sphere_body( +pub(super) fn build_solid( + shape_id: String, + primitive: Primitive, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let result = match primitive { + Primitive::Cuboid { + width, + height, + depth, + } => cuboid_body(shape_id, [width, height, depth], accuracy)?, + Primitive::Cylinder { radius, height } => { + cylinder_body(shape_id, [radius, height], accuracy)? + } + Primitive::CylinderSector { + radius, + height, + start_angle, + sweep_angle, + } => sector_body( + shape_id, + [radius, height], + [start_angle, sweep_angle], + accuracy, + )?, + Primitive::Polyhedron { points, faces } => { + polyhedron_body(shape_id, &points, &faces, accuracy)? + } + Primitive::Sphere { radius } => sphere_body(shape_id, radius, accuracy)?, + Primitive::Cone { + radius_bottom, + radius_top, + height, + } => cone_body(shape_id, [radius_bottom, radius_top], height, accuracy)?, + Primitive::Torus { + major_radius, + minor_radius, + } => torus_body(shape_id, [major_radius, minor_radius], accuracy)?, + Primitive::SlotPrism { + length, + width, + height, + } => slot_body(shape_id, [length, width, height], accuracy)?, + Primitive::RoundedBox { + width, + height, + depth, + radius, + } => rounded_box_body(shape_id, [width, height, depth, radius], accuracy)?, + Primitive::PipeSegment { + outer_radius, + inner_radius, + height, + start_angle, + sweep_angle, + } => pipe_body( + shape_id, + [outer_radius, inner_radius, height], + [start_angle, sweep_angle], + accuracy, + )?, + Primitive::Polysolid { + path, + width, + height, + justify, + } => polysolid_body(shape_id, &path, justify, [width, height], accuracy)?, + _ => return Err(wrong_body_type("solid")), + }; + Ok(result) +} + +fn cuboid_body( + shape_id: String, + [width, height, depth]: [f64; 3], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + for (name, value) in [("width", width), ("height", height), ("depth", depth)] { + required_size(value, accuracy.geometric, name)?; + } + let frame = Frame3 { + origin: [-width / 2.0, 0.0, depth / 2.0], + ..GROUND + }; + Ok(( + primitives::cuboid(shape_id, frame, [width, depth, height], accuracy)?, + Vec::new(), + )) +} + +fn cylinder_body( + shape_id: String, + [radius, height]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + required_size(radius, accuracy.geometric, "radius")?; + required_size(height, accuracy.geometric, "height")?; + Ok(( + primitives::cylinder(shape_id, GROUND, radius, height, accuracy)?, + Vec::new(), + )) +} + +fn polyhedron_body( + shape_id: String, + points: &[Point3], + faces: &[Vec], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let brep = + primitives::polyhedron(shape_id, points, faces, accuracy).map_err(|error| match error { + GeometryError::InvalidGeometry(message) + | GeometryError::UnresolvedIntersection(message) => { + GraphError::code(ErrorCode::InvalidParameter, message) + } + GeometryError::Math(error) => GraphError::code( + ErrorCode::InvalidParameter, + format!("polyhedron points are out of range: {error:?}"), + ), + other => other.into(), + })?; + Ok((brep, Vec::new())) +} + +fn sector_body( + shape_id: String, + [radius, height]: [f64; 2], + angles: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + check_arc("sector", radius, angles, accuracy)?; + required_size(height, accuracy.geometric, "height")?; + let [start_angle, sweep_angle] = angles; + Ok(( + primitives::cylinder_sector( + shape_id, + GROUND, + radius, + height, + start_angle, + sweep_angle, + accuracy, + )?, + Vec::new(), + )) +} + +fn sphere_body( shape_id: String, radius: f64, accuracy: Accuracy, @@ -16,7 +166,7 @@ pub(super) fn sphere_body( )) } -pub(super) fn cone_body( +fn cone_body( shape_id: String, [bottom, top]: [f64; 2], height: f64, @@ -49,7 +199,7 @@ pub(super) fn cone_body( Ok((brep, Vec::new())) } -pub(super) fn torus_body( +fn torus_body( shape_id: String, [major, minor]: [f64; 2], accuracy: Accuracy, @@ -70,3 +220,78 @@ pub(super) fn torus_body( Vec::new(), )) } + +fn slot_body( + shape_id: String, + [length, width, height]: [f64; 3], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + check_slot(length, width, accuracy.geometric)?; + required_size(height, accuracy.geometric, "height")?; + let outline = primitives::slot_outline(length, width); + outline_body(shape_id, outline, height, accuracy) +} + +fn rounded_box_body( + shape_id: String, + [width, height, depth, radius]: [f64; 4], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + required_size(height, accuracy.geometric, "height")?; + let sides = [("width", width), ("depth", depth)]; + check_corner_radius("rounded box", sides, radius, accuracy.geometric)?; + let outline = primitives::rounded_rectangle_outline(width, depth, radius); + outline_body(shape_id, outline, height, accuracy) +} + +fn pipe_body( + shape_id: String, + [outer, inner, height]: [f64; 3], + angles: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + for (name, value) in [ + ("outerRadius", outer), + ("innerRadius", inner), + ("height", height), + ] { + required_size(value, accuracy.geometric, name)?; + } + check_arc("pipe segment", inner, angles, accuracy)?; + if outer - inner <= 4.0 * accuracy.geometric { + return Err(parameter( + "pipe segment inner radius must be below its outer radius", + )); + } + let outline = primitives::ring_sector_outline([outer, inner], angles); + outline_body(shape_id, outline, height, accuracy) +} + +fn outline_body( + shape_id: String, + outline: Vec, + height: f64, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + Ok(( + primitives::arc_edged_extrusion(shape_id, GROUND, outline, height, accuracy)?, + Vec::new(), + )) +} + +fn polysolid_body( + shape_id: String, + path: &PolysolidPath, + justify: PolysolidJustify, + [width, height]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + required_size(width, accuracy.geometric, "width")?; + required_size(height, accuracy.geometric, "height")?; + let brep = primitives::polysolid(shape_id, GROUND, path, justify, [width, height], accuracy) + .map_err(|error| match error { + PolysolidError::Parameter(message) => parameter(&message), + PolysolidError::Geometry(error) => error.into(), + })?; + Ok((brep, Vec::new())) +} diff --git a/main/opengeometry/src/world_graph/primitive/wires.rs b/main/opengeometry/src/world_graph/primitive/wires.rs new file mode 100644 index 00000000..27f491f0 --- /dev/null +++ b/main/opengeometry/src/world_graph/primitive/wires.rs @@ -0,0 +1,262 @@ +use super::kind::Primitive; +use super::sizes::{ + check_arc, check_corner_radius, check_slot, parameter, required_size, wrong_body_type, +}; +use crate::brep::{Accuracy, BrepEnvelope, CurveGeometry, GROUND}; +use crate::math::{norm, sub, Point3}; +use crate::primitives::{self, ProfileEdge, ProfileSegment, SegmentArc}; +use crate::world_graph::error::{ErrorCode, GraphError}; +use std::f64::consts::TAU; + +pub(super) fn build_wire( + shape_id: String, + primitive: Primitive, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let result = match primitive { + Primitive::Rectangle { width, breadth } => { + rectangle_body(shape_id, [width, breadth], accuracy)? + } + Primitive::Circle { radius } => { + required_size(radius, accuracy.geometric, "radius")?; + arc_body(shape_id, radius, [0.0, TAU], accuracy)? + } + Primitive::Arc { + radius, + start_angle, + sweep_angle, + } => { + check_arc("arc", radius, [start_angle, sweep_angle], accuracy)?; + arc_body(shape_id, radius, [start_angle, sweep_angle], accuracy)? + } + Primitive::Polyline { points, closed } => { + check_polyline(&points, closed, accuracy)?; + let (brep, keys) = primitives::polyline_with_keys(shape_id, &points, closed, accuracy)?; + (brep, keys) + } + Primitive::Profile { + start, + segments, + closed, + } => profile_body(shape_id, start, &segments, closed, accuracy)?, + Primitive::RegularPolygon { sides, radius } => { + polygon_body(shape_id, sides, radius, accuracy)? + } + Primitive::Slot { length, width } => { + check_slot(length, width, accuracy.geometric)?; + outline_wire(shape_id, primitives::slot_outline(length, width), accuracy)? + } + Primitive::RoundedRectangle { + width, + breadth, + radius, + } => rounded_rectangle_body(shape_id, [width, breadth], radius, accuracy)?, + _ => return Err(wrong_body_type("wire")), + }; + Ok(result) +} + +fn rectangle_body( + shape_id: String, + [width, breadth]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + required_size(width, accuracy.geometric, "width")?; + required_size(breadth, accuracy.geometric, "breadth")?; + Ok(primitives::rectangle_with_keys( + shape_id, GROUND, width, breadth, accuracy, + )?) +} + +fn arc_body( + shape_id: String, + radius: f64, + [start_angle, sweep_angle]: [f64; 2], + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let curve = CurveGeometry::Circle { + frame: GROUND, + radius, + }; + Ok(( + primitives::arc_wire(shape_id, curve, start_angle, sweep_angle, accuracy)?, + vec!["edge-0".into()], + )) +} + +fn check_polyline(points: &[Point3], closed: bool, accuracy: Accuracy) -> Result<(), GraphError> { + if points.len() < if closed { 3 } else { 2 } { + return Err(GraphError::code( + ErrorCode::InvalidParameter, + "polyline has too few points", + )); + } + if points.iter().flatten().any(|value| !value.is_finite()) { + return Err(GraphError::code( + ErrorCode::InvalidParameter, + "polyline points must be finite", + )); + } + for index in 0..if closed { + points.len() + } else { + points.len() - 1 + } { + let next = (index + 1) % points.len(); + if norm(sub(points[next], points[index])) <= 4.0 * accuracy.geometric { + return Err(GraphError::code( + ErrorCode::InvalidParameter, + "polyline has a zero-length segment", + )); + } + } + Ok(()) +} + +fn profile_body( + shape_id: String, + start: [f64; 2], + segments: &[ProfileSegment], + closed: bool, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let edges = primitives::profile_segment_edges(start, segments, closed); + check_profile(start, segments, &edges, closed, accuracy.geometric)?; + Ok(primitives::profile_wire_with_keys( + shape_id, start, segments, closed, accuracy, + )?) +} + +fn check_profile( + start: [f64; 2], + segments: &[ProfileSegment], + edges: &[ProfileEdge], + closed: bool, + g: f64, +) -> Result<(), GraphError> { + if segments.len() < if closed { 2 } else { 1 } { + return Err(parameter("profile has too few segments")); + } + let values = segments.iter().flat_map(|segment| { + segment + .to + .into_iter() + .chain(segment.arc.into_iter().flat_map(|arc| arc.center)) + }); + if !start.into_iter().chain(values).all(f64::is_finite) { + return Err(parameter("profile points must be finite")); + } + let last = segments[segments.len() - 1].to; + if closed != ((last[0] - start[0]).hypot(last[1] - start[1]) <= 4.0 * g) { + return Err(parameter(if closed { + "closed profile must end at its start" + } else { + "open profile must not end at its start" + })); + } + for (index, (edge, segment)) in edges.iter().zip(segments).enumerate() { + let to = if closed && index + 1 == segments.len() { + start + } else { + segment.to + }; + check_profile_edge(edge, to, g)?; + } + if primitives::profile_self_intersects(edges, closed, g) { + return Err(parameter("profile self-intersects")); + } + if closed && primitives::profile_area_twice(edges).abs() <= 16.0 * g * g { + return Err(parameter("profile area is below geometric resolution")); + } + Ok(()) +} + +fn check_profile_edge(edge: &ProfileEdge, to: [f64; 2], g: f64) -> Result<(), GraphError> { + let (from, _) = edge.endpoints(); + if (to[0] - from[0]).hypot(to[1] - from[1]) <= 4.0 * g { + return Err(parameter("profile has a zero-length segment")); + } + if let ProfileEdge::Arc { + center, + radius, + sweep_angle, + .. + } = *edge + { + if radius <= 4.0 * g + || ((to[0] - center[0]).hypot(to[1] - center[1]) - radius).abs() > g / 4.0 + { + return Err(parameter("profile arc does not end on its circle")); + } + if sweep_angle.abs() >= TAU || radius * sweep_angle.abs() <= 4.0 * g { + return Err(parameter("profile arc is below geometric resolution")); + } + } + Ok(()) +} + +fn polygon_body( + shape_id: String, + sides: u32, + radius: f64, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + if !(3..=1024).contains(&sides) { + return Err(parameter( + "regular polygon sides must be between 3 and 1024", + )); + } + required_size(radius, accuracy.geometric, "radius")?; + let step = TAU / f64::from(sides); + let side = 2.0 * radius * (step / 2.0).sin(); + required_size(side, accuracy.geometric, "regular polygon side")?; + let points = (0..sides) + .map(|index| { + let angle = step * f64::from(index); + GROUND.point([radius * angle.cos(), radius * angle.sin(), 0.0]) + }) + .collect::>(); + Ok(primitives::polyline_with_keys( + shape_id, &points, true, accuracy, + )?) +} + +fn rounded_rectangle_body( + shape_id: String, + [width, breadth]: [f64; 2], + radius: f64, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let sides = [("width", width), ("breadth", breadth)]; + check_corner_radius("rounded rectangle", sides, radius, accuracy.geometric)?; + let outline = primitives::rounded_rectangle_outline(width, breadth, radius); + outline_wire(shape_id, outline, accuracy) +} + +fn outline_wire( + shape_id: String, + edges: Vec, + accuracy: Accuracy, +) -> Result<(BrepEnvelope, Vec), GraphError> { + let start = edges[0].endpoints().0; + let segments = edges + .iter() + .map(|edge| ProfileSegment { + to: edge.endpoints().1, + arc: match *edge { + ProfileEdge::Arc { + center, + sweep_angle, + .. + } => Some(SegmentArc { + center, + counter_clockwise: sweep_angle > 0.0, + }), + ProfileEdge::Line { .. } => None, + }, + }) + .collect::>(); + Ok(primitives::profile_wire_with_keys( + shape_id, start, &segments, true, accuracy, + )?) +} diff --git a/main/opengeometry/test-support/src/scenes/bar.rs b/main/opengeometry/test-support/src/scenes/bar.rs new file mode 100644 index 00000000..3b878e13 --- /dev/null +++ b/main/opengeometry/test-support/src/scenes/bar.rs @@ -0,0 +1,120 @@ +use crate::world_graph::{graph, named}; +use opengeometry::world_graph::{EditScope, ModifyingOperation, Primitive, Transform, WorldGraph}; + +type Bounds = [f64; 6]; + +struct Placement { + name: &'static str, + at: [f64; 3], + height: f64, +} + +const OPERATIONS: [(&str, ModifyingOperation); 3] = [ + ("S", ModifyingOperation::Subtract), + ("U", ModifyingOperation::Union), + ("I", ModifyingOperation::Intersect), +]; + +pub fn bar_handlers(target: &Primitive) -> Vec { + let bounds = created(target).bounds("target").unwrap().unwrap(); + let mut rows = Vec::new(); + for placement in placements(bounds) { + for (name, tool) in tools(bounds, placement.height) { + let cells = OPERATIONS.map(|(label, operation)| { + format!("{label}={}", cell(target, &tool, placement.at, operation)) + }); + rows.push(format!("{}/{name} {}", placement.name, cells.join(" "))); + } + } + rows +} + +pub fn shell_is_valid(target: &Primitive, thickness: f64) -> bool { + let mut world = created(target); + world.shell("target", thickness, EditScope::Node).unwrap(); + let validity = world.validity("target").unwrap(); + validity.valid && validity.closed +} + +fn created(target: &Primitive) -> WorldGraph { + let mut world = graph(); + world + .create_primitive(target.clone(), named("target")) + .unwrap(); + world +} + +fn placements(b: Bounds) -> [Placement; 3] { + let size = [b[3] - b[0], b[4] - b[1], b[5] - b[2]]; + let mid = [(b[0] + b[3]) / 2.0, (b[2] + b[5]) / 2.0]; + let top = b[1] + 0.75 * size[1]; + let inner = mid[0] + 0.07 * size[0]; + let back = mid[1] + 0.05 * size[2]; + [ + Placement { + name: "A", + at: [b[3] - 0.02 * size[0], top, mid[1] + 0.03 * size[2]], + height: 0.5 * size[1], + }, + Placement { + name: "B", + at: [inner, top, back], + height: 0.5 * size[1], + }, + Placement { + name: "T", + at: [inner, b[1] - 0.25 * size[1], back], + height: 1.5 * size[1], + }, + ] +} + +fn tools(b: Bounds, height: f64) -> [(&'static str, Primitive); 2] { + let width = 0.3 * (b[3] - b[0]).min(b[5] - b[2]); + [ + ( + "box", + Primitive::Cuboid { + width, + height, + depth: width, + }, + ), + ( + "cyl", + Primitive::Cylinder { + radius: width / 2.0, + height, + }, + ), + ] +} + +fn cell( + target: &Primitive, + tool: &Primitive, + at: [f64; 3], + operation: ModifyingOperation, +) -> String { + let mut world = created(target); + world.create_primitive(tool.clone(), named("tool")).unwrap(); + world + .transform("tool", Transform::Translate { offset: at }) + .unwrap(); + world + .operate("target", operation, &["tool".into()], EditScope::Node) + .unwrap_or_else(|error| panic!("{operation:?} {tool:?} at {at:?}: {error:?}")); + let validity = world.validity("target").unwrap(); + assert!( + validity.valid && validity.closed, + "{operation:?} {tool:?} at {at:?}" + ); + let report = world.report("target").unwrap().unwrap(); + report["handlers"] + .as_array() + .unwrap() + .iter() + .map(|handler| handler.as_str().unwrap()) + .collect::>() + .join("+") +} diff --git a/main/opengeometry/test-support/src/scenes/mod.rs b/main/opengeometry/test-support/src/scenes/mod.rs index a00f75a2..73e0a56c 100644 --- a/main/opengeometry/test-support/src/scenes/mod.rs +++ b/main/opengeometry/test-support/src/scenes/mod.rs @@ -1,4 +1,5 @@ pub mod acceptance; +pub mod bar; pub mod coverage_gap; pub mod prismatic; pub mod revolve; diff --git a/main/opengeometry/tests/creating/main.rs b/main/opengeometry/tests/creating/main.rs index 1354aa2c..011270bb 100644 --- a/main/opengeometry/tests/creating/main.rs +++ b/main/opengeometry/tests/creating/main.rs @@ -1,13 +1,20 @@ mod extrude; mod holes; mod mesh_walls; +mod pipe_segment; mod polyhedron; +mod polysolid; +mod polysolid_arcs; mod profiles; mod rebuild; mod revolve; mod revolved; +mod rounded_box; mod sectors; +mod slot_prism; +mod standard; mod support; mod sweep; mod sweep_bends; mod sweep_closed; +mod wire_kinds; diff --git a/main/opengeometry/tests/creating/pipe_segment.rs b/main/opengeometry/tests/creating/pipe_segment.rs new file mode 100644 index 00000000..499ecf17 --- /dev/null +++ b/main/opengeometry/tests/creating/pipe_segment.rs @@ -0,0 +1,91 @@ +use super::standard::{ + assert_bar, assert_exact_volume, assert_on_plane, assert_refused, assert_step_surfaces, +}; +use opengeometry::world_graph::Primitive; +use opengeometry_test_support::scenes::bar::shell_is_valid; +use std::f64::consts::{FRAC_PI_2, PI, TAU}; + +fn pipe(radii: [f64; 2], height: f64, angles: [f64; 2]) -> Primitive { + Primitive::PipeSegment { + outer_radius: radii[0], + inner_radius: radii[1], + height, + start_angle: angles[0], + sweep_angle: angles[1], + } +} + +#[test] +fn a_pipe_segment_is_closed_with_the_exact_ring_sector_volume() { + for sweep in [FRAC_PI_2, -1.2, 5.0] { + let expected = sweep.abs() / 2.0 * (0.25 - 0.09) * 2.0; + assert_exact_volume(pipe([0.5, 0.3], 2.0, [0.3, sweep]), expected); + } +} + +#[test] +fn invalid_pipe_segments_are_invalid_parameter_naming_the_value() { + let size = |name: &str| format!("{name} must exceed four times geometric tolerance"); + let sweep = "pipe segment sweep must be nonzero and less than one turn".to_string(); + let order = "pipe segment inner radius must be below its outer radius".to_string(); + for (primitive, message) in [ + (pipe([f64::NAN, 0.3], 1.0, [0.0, 1.0]), size("outerRadius")), + (pipe([0.5, 0.0], 1.0, [0.0, 1.0]), size("innerRadius")), + (pipe([0.5, 0.3], 0.0, [0.0, 1.0]), size("height")), + (pipe([0.5, 0.3], 1.0, [0.0, 0.0]), sweep.clone()), + (pipe([0.5, 0.3], 1.0, [0.0, TAU]), sweep.clone()), + (pipe([0.5, 0.3], 1.0, [f64::INFINITY, 1.0]), sweep), + (pipe([0.3, 0.5], 1.0, [0.0, 1.0]), order.clone()), + (pipe([0.5, 0.5 - 1e-8], 1.0, [0.0, 1.0]), order), + ] { + assert_refused(primitive, &message); + } +} + +#[test] +fn pipe_segment_json_uses_camel_case_fields() { + let json = r#"{"kind":"PipeSegment","outerRadius":0.5,"innerRadius":0.3,"height":1,"startAngle":0,"sweepAngle":1}"#; + let parsed = serde_json::from_str::(json).unwrap(); + assert!(matches!( + parsed, + Primitive::PipeSegment { outer_radius, inner_radius, sweep_angle, .. } + if outer_radius == 0.5 && inner_radius == 0.3 && sweep_angle == 1.0 + )); + let json = r#"{"kind":"PipeSegment","outer_radius":0.5,"inner_radius":0.3,"height":1,"start_angle":0,"sweep_angle":1}"#; + assert!(serde_json::from_str::(json).is_err()); +} + +#[test] +fn a_pipe_segment_on_a_plane_sweeps_counter_clockwise_from_the_x_direction() { + let corners = [[1.0, 2.0, 3.5], [2.0, 1.7, 3.0], [1.0, 1.5, 3.0]]; + let expected = PI / 4.0 * (0.25 - 0.09); + assert_on_plane(pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), expected, &corners); +} + +#[test] +fn a_pipe_segment_passes_the_boolean_bar_with_a_box_and_a_cylinder() { + assert_bar( + pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), + [ + "A/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "A/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + ], + ); +} + +#[test] +fn a_pipe_segment_shells_to_a_valid_closed_solid() { + assert!(shell_is_valid( + &pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), + 0.03 + )); +} + +#[test] +fn a_pipe_segment_exports_one_solid_with_two_cylindrical_faces() { + assert_step_surfaces(pipe([0.5, 0.3], 1.0, [0.0, FRAC_PI_2]), 2, 4); +} diff --git a/main/opengeometry/tests/creating/polysolid.rs b/main/opengeometry/tests/creating/polysolid.rs new file mode 100644 index 00000000..22a52c79 --- /dev/null +++ b/main/opengeometry/tests/creating/polysolid.rs @@ -0,0 +1,386 @@ +use opengeometry::primitives::{PolysolidJustify, PolysolidPath, ProfileSegment}; +use opengeometry::world_graph::{ + CreateOptions, EditScope, ErrorCode, ModifyingOperation, Plane, Primitive, StepOptions, + Transform, WorldGraph, +}; +use opengeometry_test_support::part21::Document; +use opengeometry_test_support::world_graph::{graph, named}; +use serde_json::json; + +fn path(start: [f64; 2], points: &[[f64; 2]], closed: bool) -> PolysolidPath { + PolysolidPath { + start, + segments: points + .iter() + .map(|&to| ProfileSegment { to, arc: None }) + .collect(), + closed, + } +} + +fn wall(path: PolysolidPath, width: f64, justify: PolysolidJustify) -> Primitive { + Primitive::Polysolid { + path, + width, + height: 1.0, + justify, + } +} + +fn l_path() -> PolysolidPath { + path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0]], false) +} + +fn loop_path() -> PolysolidPath { + path( + [0.0, 0.0], + &[[3.0, 0.0], [3.0, 2.0], [0.0, 2.0], [0.0, 0.0]], + true, + ) +} + +fn built(primitive: Primitive) -> WorldGraph { + let mut world = graph(); + world.create_primitive(primitive, named("wall")).unwrap(); + world +} + +fn assert_valid_closed(world: &WorldGraph, og_id: &str, label: &str) { + let validity = world.validity(og_id).unwrap(); + assert!(validity.valid && validity.closed, "{label}: {validity:?}"); +} + +fn assert_flat_volume(world: &WorldGraph, expected: f64) { + let measured = world.measure("wall", Some(1e-4)).unwrap(); + assert!( + (measured.volume - expected).abs() <= 1e-9, + "{} versus {expected}", + measured.volume + ); +} + +fn face_count(world: &WorldGraph) -> usize { + world.brep("wall").unwrap().topology.faces.len() +} + +fn assert_bounds(world: &WorldGraph, expected: [f64; 6]) { + let bounds = world.bounds("wall").unwrap().unwrap(); + assert!( + (0..6).all(|axis| (bounds[axis] - expected[axis]).abs() <= 1e-9), + "{bounds:?} versus {expected:?}" + ); +} + +fn refused(world: &mut WorldGraph, primitive: Primitive) -> String { + let label = format!("{primitive:?}"); + let error = world + .create_primitive(primitive, named("wall")) + .expect_err(&label); + assert_eq!(error.error_code(), ErrorCode::InvalidParameter, "{label}"); + assert_eq!(world.node_count(), 0); + error.message().to_string() +} + +fn refusal(primitive: Primitive) -> String { + refused(&mut graph(), primitive) +} + +#[test] +fn l_shaped_polysolid_is_a_closed_wall_of_volume_one_with_ten_faces() { + let world = built(wall(l_path(), 0.2, PolysolidJustify::Centre)); + assert_valid_closed(&world, "wall", "l path"); + assert_flat_volume(&world, 1.0); + assert_eq!(face_count(&world), 10); + assert_bounds(&world, [0.0, 0.0, -2.0, 3.1, 1.0, 0.1]); +} + +#[test] +fn closed_polysolid_loop_is_a_closed_ring_wall_of_volume_two() { + let world = built(wall(loop_path(), 0.2, PolysolidJustify::Centre)); + assert_valid_closed(&world, "wall", "loop"); + assert_flat_volume(&world, 2.0); + assert_eq!(face_count(&world), 18); + assert_bounds(&world, [-0.1, 0.0, -2.1, 3.1, 1.0, 0.1]); +} + +#[test] +fn left_justified_polysolid_lies_inside_the_l_corner() { + let world = built(wall(l_path(), 0.2, PolysolidJustify::Left)); + assert_valid_closed(&world, "wall", "left"); + assert_flat_volume(&world, (2.9 + 1.9) * 0.2); + assert_bounds(&world, [0.0, 0.0, -2.0, 3.0, 1.0, 0.0]); +} + +#[test] +fn right_justified_polysolid_lies_outside_the_l_corner() { + let world = built(wall(l_path(), 0.2, PolysolidJustify::Right)); + assert_valid_closed(&world, "wall", "right"); + assert_flat_volume(&world, (3.1 + 2.1) * 0.2); + assert_bounds(&world, [0.0, 0.0, -2.0, 3.2, 1.0, 0.2]); +} + +#[test] +fn polysolid_with_fewer_than_two_points_is_refused() { + let message = refusal(wall( + path([0.0, 0.0], &[], false), + 0.2, + PolysolidJustify::Centre, + )); + assert!(message.contains("at least two points"), "{message}"); +} + +#[test] +fn polysolid_with_a_zero_length_leg_is_refused() { + let repeated = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 0.0], [3.0, 2.0]], false); + let message = refusal(wall(repeated, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("leg 1 has zero length"), "{message}"); +} + +#[test] +fn polysolid_with_a_leg_shorter_than_half_its_width_is_refused() { + let short = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 0.09], [5.0, 0.09]], false); + let message = refusal(wall(short, 0.2, PolysolidJustify::Centre)); + assert!( + message.contains("leg 1 is shorter than half the width"), + "{message}" + ); +} + +#[test] +fn polysolid_path_that_crosses_itself_is_refused() { + let crossing = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0], [1.5, -1.0]], false); + let message = refusal(wall(crossing, 0.2, PolysolidJustify::Centre)); + assert!( + message.contains("crosses itself at legs 0 and 2"), + "{message}" + ); +} + +#[test] +fn polysolid_whose_stroke_gives_no_region_is_refused() { + let message = refusal(wall(l_path(), 3e-6, PolysolidJustify::Centre)); + assert!(message.contains("0 regions"), "{message}"); +} + +#[test] +fn closed_polysolid_path_that_does_not_end_at_its_start_is_refused() { + let open_end = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0], [0.0, 2.0]], true); + let message = refusal(wall(open_end, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("must end at its start"), "{message}"); +} + +#[test] +fn polysolid_with_a_bad_width_or_height_is_refused() { + for (width, height) in [ + (0.0, 1.0), + (-0.2, 1.0), + (f64::NAN, 1.0), + (0.2, 0.0), + (0.2, f64::INFINITY), + ] { + refusal(Primitive::Polysolid { + path: l_path(), + width, + height, + justify: PolysolidJustify::Centre, + }); + } +} + +#[test] +fn polysolid_json_is_camel_case_and_justify_defaults_to_centre() { + let parsed: Primitive = serde_json::from_value(json!({ + "kind": "Polysolid", + "path": {"start": [0, 0], "segments": [{"to": [3, 0]}, {"to": [3, 2]}], "closed": false}, + "width": 0.2, + "height": 1 + })) + .unwrap(); + let Primitive::Polysolid { justify, .. } = parsed else { + panic!("{parsed:?}"); + }; + assert_eq!(justify, PolysolidJustify::Centre); + let right = json!({ + "kind": "Polysolid", + "path": {"start": [0, 0], "segments": [{"to": [3, 0]}], "closed": false}, + "width": 0.2, + "height": 1, + "justify": "right" + }); + let world = built(serde_json::from_value(right.clone()).unwrap()); + assert_valid_closed(&world, "wall", "json"); + let mut unknown = right; + unknown["path"]["isClosed"] = json!(false); + assert!(serde_json::from_value::(unknown).is_err()); +} + +#[test] +fn polysolid_on_a_plane_is_placed_and_keeps_its_volume() { + let mut world = graph(); + let options = CreateOptions { + plane: Some(Plane { + origin: Some([1.0, 2.0, 3.0]), + normal: Some([1.0, 0.0, 0.0]), + x_direction: Some([0.0, 0.0, 1.0]), + }), + ..named("wall") + }; + world + .create_primitive(wall(l_path(), 0.2, PolysolidJustify::Centre), options) + .unwrap(); + assert_valid_closed(&world, "wall", "placed"); + assert_flat_volume(&world, 1.0); + assert_bounds(&world, [1.0, 0.0, 3.0, 2.0, 2.1, 6.1]); +} + +#[test] +fn polysolid_shell_and_step_export_are_valid() { + let mut world = built(wall(l_path(), 0.2, PolysolidJustify::Centre)); + let (text, report) = world + .export_step(&["wall".into()], &StepOptions::default()) + .unwrap(); + assert_eq!(report.pcurveless_edges, 0); + let document = Document::parse(&text).unwrap(); + assert_eq!(document.count("MANIFOLD_SOLID_BREP"), 1); + assert_eq!(document.pcurveless_edges().unwrap(), 0); + assert_eq!(document.count("PLANE"), face_count(&world)); + assert_eq!(document.count("CYLINDRICAL_SURFACE"), 0); + world.shell("wall", 0.03, EditScope::Node).unwrap(); + assert_valid_closed(&world, "wall", "shell"); +} + +fn hairpin_bounds(justify: PolysolidJustify) -> [f64; 6] { + let hairpin = path([0.0, 0.0], &[[3.0, 0.0], [0.0, 0.3]], false); + let world = built(wall(hairpin, 0.2, justify)); + assert_valid_closed(&world, "wall", &format!("{justify:?}")); + world.bounds("wall").unwrap().unwrap() +} + +#[test] +fn justified_near_hairpin_polysolid_stays_within_the_mitre_limit_of_its_corner() { + for justify in [PolysolidJustify::Left, PolysolidJustify::Right] { + let bounds = hairpin_bounds(justify); + let reach = 0.2 * 4.0; + assert!(bounds[3] <= 3.0 + reach, "{justify:?} {bounds:?}"); + assert!(bounds[2] >= -0.3 - reach, "{justify:?} {bounds:?}"); + assert!(bounds[5] <= reach, "{justify:?} {bounds:?}"); + } +} + +#[test] +fn open_polysolid_path_that_ends_at_its_start_is_refused() { + let returning = path([0.0, 0.0], &[[3.0, 0.0], [3.0, 2.0], [0.0, 0.0]], false); + let message = refusal(wall(returning, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("must not end at its start"), "{message}"); +} + +#[test] +fn closed_polysolid_path_with_two_segments_is_refused() { + let there_and_back = path([0.0, 0.0], &[[3.0, 0.0], [0.0, 0.0]], true); + let message = refusal(wall(there_and_back, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("at least three points"), "{message}"); +} + +#[test] +fn closed_bow_tie_polysolid_path_is_refused() { + let bow_tie = path( + [0.0, 0.0], + &[[2.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]], + true, + ); + let message = refusal(wall(bow_tie, 0.2, PolysolidJustify::Centre)); + assert!( + message.contains("crosses itself at legs 1 and 3"), + "{message}" + ); +} + +#[test] +fn polysolid_with_collinear_legs_is_one_straight_wall() { + let straight = path([0.0, 0.0], &[[1.0, 0.0], [2.0, 0.0]], false); + let world = built(wall(straight, 0.2, PolysolidJustify::Centre)); + assert_valid_closed(&world, "wall", "collinear"); + assert_flat_volume(&world, 2.0 * 0.2 * 1.0); +} + +#[test] +fn polysolid_path_that_turns_straight_back_is_refused() { + let u_turn = path([0.0, 0.0], &[[3.0, 0.0], [1.0, 0.0]], false); + let message = refusal(wall(u_turn, 0.2, PolysolidJustify::Centre)); + assert!(message.contains("crosses itself"), "{message}"); +} + +const OPERATIONS: [ModifyingOperation; 3] = [ + ModifyingOperation::Subtract, + ModifyingOperation::Union, + ModifyingOperation::Intersect, +]; + +const PLACEMENTS: [(&str, [f64; 3], f64); 3] = [ + ("A", [1.5, 0.75, -0.1], 0.5), + ("B", [1.5, 0.75, 0.0], 0.5), + ("T", [1.5, -0.25, 0.0], 1.5), +]; + +fn tools(height: f64) -> [(&'static str, Primitive); 2] { + [ + ( + "box", + Primitive::Cuboid { + width: 0.08, + height, + depth: 0.08, + }, + ), + ( + "cylinder", + Primitive::Cylinder { + radius: 0.04, + height, + }, + ), + ] +} + +fn bar_cell(tool: &Primitive, offset: [f64; 3], operation: ModifyingOperation) -> String { + let mut world = built(wall(l_path(), 0.2, PolysolidJustify::Centre)); + world.create_primitive(tool.clone(), named("tool")).unwrap(); + world + .transform("tool", Transform::Translate { offset }) + .unwrap(); + if let Err(error) = world.operate("wall", operation, &["tool".into()], EditScope::Node) { + return format!("refused {:?}", error.error_code()); + } + let validity = world.validity("wall").unwrap(); + format!( + "valid {} closed {} {}", + validity.valid, + validity.closed, + world.report("wall").unwrap().unwrap()["handlers"] + ) +} + +#[test] +fn l_shaped_polysolid_passes_the_boolean_bar_with_box_and_cylinder_tools() { + let mut seen = Vec::new(); + let mut expected = Vec::new(); + for (place, offset, height) in PLACEMENTS { + for (name, tool) in tools(height) { + for operation in OPERATIONS { + let handlers = bar_cell(&tool, offset, operation); + seen.push(format!("{place}/{name}/{operation:?} {handlers}")); + let handler = match (place, name, operation) { + (_, "box", _) => "boolean_rectilinear", + ("T", _, ModifyingOperation::Subtract) => { + "generic_two_sided_cutter_band_subtraction" + } + _ => "prismatic_curved_boolean", + }; + expected.push(format!( + "{place}/{name}/{operation:?} valid true closed true [\"{handler}\"]" + )); + } + } + } + assert_eq!(seen, expected); +} diff --git a/main/opengeometry/tests/creating/polysolid_arcs.rs b/main/opengeometry/tests/creating/polysolid_arcs.rs new file mode 100644 index 00000000..324ab1d9 --- /dev/null +++ b/main/opengeometry/tests/creating/polysolid_arcs.rs @@ -0,0 +1,419 @@ +use super::support::{arc, line}; +use opengeometry::primitives::{PolysolidJustify, PolysolidPath, ProfileSegment}; +use opengeometry::world_graph::{ + CreateOptions, EditScope, ErrorCode, ModifyingOperation, Plane, Primitive, StepOptions, + Transform, WorldGraph, +}; +use opengeometry_test_support::part21::Document; +use opengeometry_test_support::volume; +use opengeometry_test_support::world_graph::{graph, named}; +use serde_json::json; +use std::f64::consts::{FRAC_1_SQRT_2, PI}; + +fn arc_path(start: [f64; 2], segments: Vec, closed: bool) -> PolysolidPath { + PolysolidPath { + start, + segments, + closed, + } +} + +fn leg_and_arc() -> PolysolidPath { + arc_path( + [0.0, 0.0], + vec![line([2.0, 0.0]), arc([3.0, 1.0], [2.0, 1.0], true)], + false, + ) +} + +fn circle_path() -> PolysolidPath { + arc_path( + [1.0, 0.0], + vec![ + arc([-1.0, 0.0], [0.0, 0.0], true), + arc([1.0, 0.0], [0.0, 0.0], true), + ], + true, + ) +} + +fn curved_wall(path: PolysolidPath, justify: PolysolidJustify) -> Primitive { + Primitive::Polysolid { + path, + width: 0.2, + height: 1.0, + justify, + } +} + +fn curved(path: PolysolidPath, justify: PolysolidJustify) -> WorldGraph { + let mut world = graph(); + world + .create_primitive(curved_wall(path, justify), named("wall")) + .unwrap(); + world +} + +fn assert_closed_body(world: &WorldGraph, label: &str) { + let validity = world.validity("wall").unwrap(); + assert!(validity.valid && validity.closed, "{label}: {validity:?}"); +} + +fn assert_exact_volume(world: &WorldGraph, expected: f64) { + let measured = volume::estimate(&world.brep("wall").unwrap(), 1e-4); + assert!( + (measured.value - expected).abs() <= measured.error_bound, + "{} versus {expected} with bound {}", + measured.value, + measured.error_bound + ); +} + +fn assert_step_cylinders(world: &mut WorldGraph, cylinders: usize) { + let (text, report) = world + .export_step(&["wall".into()], &StepOptions::default()) + .unwrap(); + assert_eq!(report.pcurveless_edges, 0); + let document = Document::parse(&text).unwrap(); + assert_eq!(document.count("MANIFOLD_SOLID_BREP"), 1); + assert_eq!(document.pcurveless_edges().unwrap(), 0); + assert_eq!(document.count("CYLINDRICAL_SURFACE"), cylinders); +} + +fn assert_shell_valid(world: &mut WorldGraph) { + world.shell("wall", 0.03, EditScope::Node).unwrap(); + assert_closed_body(world, "shell"); +} + +fn refused_message(path: PolysolidPath) -> String { + let mut world = graph(); + let error = world + .create_primitive(curved_wall(path, PolysolidJustify::Centre), named("wall")) + .expect_err("refused"); + assert_eq!(error.error_code(), ErrorCode::InvalidParameter); + assert_eq!(world.node_count(), 0); + error.message().to_string() +} + +#[test] +fn leg_and_tangent_arc_polysolid_is_a_closed_wall_with_eight_faces() { + let world = curved(leg_and_arc(), PolysolidJustify::Centre); + assert_closed_body(&world, "leg and arc"); + assert_exact_volume(&world, 0.4 + 0.1 * PI); + assert_eq!(world.brep("wall").unwrap().topology.faces.len(), 8); + let bounds = world.bounds("wall").unwrap().unwrap(); + let expected = [0.0, 0.0, -1.0, 3.1, 1.0, 0.1]; + assert!( + (0..6).all(|axis| (bounds[axis] - expected[axis]).abs() <= 1e-6), + "{bounds:?}" + ); +} + +#[test] +fn leg_and_arc_polysolid_exports_two_cylinders_to_step_and_shells() { + let mut world = curved(leg_and_arc(), PolysolidJustify::Centre); + assert_step_cylinders(&mut world, 2); + assert_shell_valid(&mut world); +} + +#[test] +fn closed_path_of_two_half_circles_is_a_tube_wall_of_four_half_cylinder_faces() { + let mut world = curved(circle_path(), PolysolidJustify::Centre); + assert_closed_body(&world, "tube"); + assert_exact_volume(&world, 0.4 * PI); + assert_step_cylinders(&mut world, 4); + assert_shell_valid(&mut world); +} + +#[test] +fn clockwise_tube_wall_keeps_its_outer_ring_outside() { + let path = arc_path( + [1.0, 0.0], + vec![ + arc([-1.0, 0.0], [0.0, 0.0], false), + arc([1.0, 0.0], [0.0, 0.0], false), + ], + true, + ); + let world = curved(path, PolysolidJustify::Left); + assert_closed_body(&world, "clockwise tube"); + assert_exact_volume(&world, 0.44 * PI); +} + +#[test] +fn arc_that_does_not_end_on_its_circle_is_refused() { + let mut path = leg_and_arc(); + path.segments[1].to = [3.0, 1.2]; + let message = refused_message(path); + assert!(message.contains("does not end on its circle"), "{message}"); +} + +#[test] +fn left_and_right_justified_leg_and_arc_keep_the_path_on_one_side() { + for (justify, expected) in [ + (PolysolidJustify::Left, 0.4 + 0.09 * PI), + (PolysolidJustify::Right, 0.4 + 0.11 * PI), + ] { + let world = curved(leg_and_arc(), justify); + assert_closed_body(&world, &format!("{justify:?}")); + assert_exact_volume(&world, expected); + } +} + +#[test] +fn straight_corner_before_a_tangent_arc_is_mitred() { + let path = arc_path( + [0.0, 0.0], + vec![ + line([2.0, 0.0]), + line([2.0, 2.0]), + arc([0.0, 2.0], [1.0, 2.0], true), + ], + false, + ); + let world = curved(path, PolysolidJustify::Centre); + assert_closed_body(&world, "mitre"); + assert_exact_volume(&world, 0.8 + 0.2 * PI); +} + +#[test] +fn line_into_an_arc_at_a_sharp_corner_is_refused_naming_the_joint() { + let path = arc_path( + [0.0, 0.0], + vec![line([2.0, 0.0]), arc([4.0, 0.0], [3.0, 0.0], true)], + false, + ); + let message = refused_message(path); + assert!( + message.contains("joint 1 between segment 0 and segment 1 is not tangent"), + "{message}" + ); +} + +#[test] +fn arc_into_a_line_at_a_sharp_corner_is_refused_naming_the_joint() { + let mut path = leg_and_arc(); + path.segments.push(line([4.0, 1.0])); + let message = refused_message(path); + assert!( + message.contains("joint 2 between segment 1 and segment 2 is not tangent"), + "{message}" + ); +} + +#[test] +fn closed_path_checks_the_joint_at_its_start() { + let path = arc_path( + [1.0, 0.0], + vec![ + arc([0.0, 1.0], [0.0, 0.0], true), + line([-2.0, 1.0]), + line([1.0, 0.0]), + ], + true, + ); + let message = refused_message(path); + assert!( + message.contains("joint 0 between segment 2 and segment 0 is not tangent"), + "{message}" + ); +} + +#[test] +fn arc_tighter_than_half_the_width_is_refused() { + let path = arc_path( + [0.0, 0.0], + vec![line([1.0, 0.0]), arc([1.1, 0.1], [1.0, 0.1], true)], + false, + ); + let message = refused_message(path); + assert!(message.contains("segment 1"), "{message}"); + assert!(message.contains("offset radius"), "{message}"); +} + +#[test] +fn arc_path_whose_wall_overlaps_itself_is_refused_naming_the_segments() { + let path = arc_path( + [0.0, 0.0], + vec![ + line([2.0, 0.0]), + arc([2.0, 0.3], [2.0, 0.15], true), + line([1.0, 0.3]), + line([0.0, 0.12]), + ], + false, + ); + let message = refused_message(path); + assert!( + message.contains("overlaps itself between segment 0 and segment 3"), + "{message}" + ); +} + +#[test] +fn acute_straight_corner_next_to_an_arc_is_refused_as_an_overlap() { + let turn = 160f64.to_radians(); + let corner = [0.3 + 0.3 * turn.cos(), 0.3 * turn.sin()]; + let center = [corner[0] - 0.5 * turn.sin(), corner[1] + 0.5 * turn.cos()]; + let end = [center[0] + 0.5 * turn.cos(), center[1] + 0.5 * turn.sin()]; + let path = arc_path( + [0.0, 0.0], + vec![line([0.3, 0.0]), line(corner), arc(end, center, true)], + false, + ); + let message = refused_message(path); + assert!( + message.contains("overlaps itself between segment"), + "{message}" + ); +} + +#[test] +fn short_leg_at_a_straight_corner_in_an_arc_path_is_refused_naming_the_segment() { + let path = arc_path( + [0.0, 0.0], + vec![ + line([2.0, 0.0]), + line([2.0, 0.05]), + arc([0.0, 0.05], [1.0, 0.05], true), + ], + false, + ); + let message = refused_message(path); + assert!( + message.contains("segment 1 is a leg shorter than half the width"), + "{message}" + ); +} + +#[test] +fn arc_path_that_crosses_itself_is_refused() { + let path = arc_path( + [0.0, 0.0], + vec![ + line([2.0, 0.0]), + arc([1.0, 1.0], [2.0, 1.0], true), + line([1.0, -1.0]), + ], + false, + ); + refused_message(path); +} + +#[test] +fn arc_polysolid_json_is_camel_case() { + let parsed: Primitive = serde_json::from_value(json!({ + "kind": "Polysolid", + "path": { + "start": [0, 0], + "segments": [{"to": [2, 0]}, {"to": [3, 1], "arc": {"center": [2, 1], "counterClockwise": true}}], + "closed": false + }, + "width": 0.2, + "height": 1, + "justify": "left" + })) + .unwrap(); + let mut world = graph(); + world.create_primitive(parsed, named("wall")).unwrap(); + assert_exact_volume(&world, 0.4 + 0.09 * PI); +} + +#[test] +fn arc_polysolid_on_a_plane_is_placed() { + let mut world = graph(); + let options = CreateOptions { + plane: Some(Plane { + origin: Some([1.0, 2.0, 3.0]), + normal: Some([1.0, 0.0, 0.0]), + x_direction: Some([0.0, 0.0, 1.0]), + }), + ..named("wall") + }; + world + .create_primitive( + curved_wall(leg_and_arc(), PolysolidJustify::Centre), + options, + ) + .unwrap(); + assert_closed_body(&world, "placed"); + assert_exact_volume(&world, 0.4 + 0.1 * PI); + let bounds = world.bounds("wall").unwrap().unwrap(); + let expected = [1.0, 1.0, 3.0, 2.0, 2.1, 6.1]; + assert!( + (0..6).all(|axis| (bounds[axis] - expected[axis]).abs() <= 1e-6), + "{bounds:?}" + ); +} + +const BAR_OPERATIONS: [ModifyingOperation; 3] = [ + ModifyingOperation::Subtract, + ModifyingOperation::Union, + ModifyingOperation::Intersect, +]; + +const BAR_PLACEMENTS: [(&str, [f64; 3], f64); 4] = [ + ("A", [1.0, 0.75, 0.1], 0.5), + ("B", [1.0, 0.75, 0.0], 0.5), + ("T", [1.0, -0.25, 0.0], 1.5), + ("C", [2.0 + FRAC_1_SQRT_2, 0.75, FRAC_1_SQRT_2 - 1.0], 0.5), +]; + +fn bar_tool(name: &str, height: f64) -> Primitive { + if name == "box" { + Primitive::Cuboid { + width: 0.08, + height, + depth: 0.08, + } + } else { + Primitive::Cylinder { + radius: 0.04, + height, + } + } +} + +fn arc_bar_cell(tool: Primitive, offset: [f64; 3], operation: ModifyingOperation) -> String { + let mut world = curved(leg_and_arc(), PolysolidJustify::Centre); + world.create_primitive(tool, named("tool")).unwrap(); + world + .transform("tool", Transform::Translate { offset }) + .unwrap(); + if let Err(error) = world.operate("wall", operation, &["tool".into()], EditScope::Node) { + return format!("refused {:?}", error.error_code()); + } + let validity = world.validity("wall").unwrap(); + format!( + "valid {} closed {} {}", + validity.valid, + validity.closed, + world.report("wall").unwrap().unwrap()["handlers"] + ) +} + +fn arc_bar_handler(place: &str, name: &str, operation: ModifyingOperation) -> &'static str { + match (place, name, operation) { + (_, "box", ModifyingOperation::Subtract) => "subtract_vertical_arc_extrusion", + ("T", _, ModifyingOperation::Subtract) => "generic_two_sided_cutter_band_subtraction", + _ => "prismatic_curved_boolean", + } +} + +#[test] +fn leg_and_arc_polysolid_passes_the_boolean_bar_with_box_and_cylinder_tools() { + let mut seen = Vec::new(); + let mut expected = Vec::new(); + for (place, offset, height) in BAR_PLACEMENTS { + for name in ["box", "cylinder"] { + for operation in BAR_OPERATIONS { + let cell = format!("{place}/{name}/{operation:?}"); + let result = arc_bar_cell(bar_tool(name, height), offset, operation); + seen.push(format!("{cell} {result}")); + let handler = arc_bar_handler(place, name, operation); + expected.push(format!("{cell} valid true closed true [\"{handler}\"]")); + } + } + } + assert_eq!(seen, expected); +} diff --git a/main/opengeometry/tests/creating/profiles.rs b/main/opengeometry/tests/creating/profiles.rs index 44c9c357..829f40c1 100644 --- a/main/opengeometry/tests/creating/profiles.rs +++ b/main/opengeometry/tests/creating/profiles.rs @@ -1,6 +1,6 @@ -use super::support::extrude; +use super::support::{arc, extrude, line}; use opengeometry::brep::SurfaceGeometry; -use opengeometry::primitives::{self, ProfileSegment, SegmentArc}; +use opengeometry::primitives::{self, ProfileSegment}; use opengeometry::world_graph::{ CreateOptions, CreatingOperation, EditScope, ErrorCode, Plane, Primitive, StepOptions, WorldGraph, @@ -11,20 +11,6 @@ use opengeometry_test_support::volume; use opengeometry_test_support::world_graph::{graph, named}; use std::f64::consts::{FRAC_PI_2, PI, TAU}; -fn line(to: [f64; 2]) -> ProfileSegment { - ProfileSegment { to, arc: None } -} - -fn arc(to: [f64; 2], center: [f64; 2], counter_clockwise: bool) -> ProfileSegment { - ProfileSegment { - to, - arc: Some(SegmentArc { - center, - counter_clockwise, - }), - } -} - fn closed(start: [f64; 2], segments: Vec) -> Primitive { Primitive::Profile { start, diff --git a/main/opengeometry/tests/creating/rounded_box.rs b/main/opengeometry/tests/creating/rounded_box.rs new file mode 100644 index 00000000..a3e48c23 --- /dev/null +++ b/main/opengeometry/tests/creating/rounded_box.rs @@ -0,0 +1,87 @@ +use super::standard::{ + assert_bar, assert_exact_volume, assert_on_plane, assert_refused, assert_step_surfaces, +}; +use opengeometry::world_graph::Primitive; +use opengeometry_test_support::scenes::bar::shell_is_valid; +use std::f64::consts::PI; + +fn rounded(size: [f64; 3], radius: f64) -> Primitive { + Primitive::RoundedBox { + width: size[0], + height: size[1], + depth: size[2], + radius, + } +} + +#[test] +fn a_rounded_box_is_closed_with_the_exact_volume_less_its_four_rounded_corners() { + assert_exact_volume(rounded([1.0, 1.0, 1.0], 0.1), 1.0 - (4.0 - PI) * 0.01); + assert_exact_volume( + rounded([2.0, 0.5, 0.6], 0.25), + (1.2 - (4.0 - PI) * 0.0625) * 0.5, + ); +} + +#[test] +fn invalid_rounded_boxes_are_invalid_parameter_naming_the_value() { + let corner = "rounded box radius must be below half its width and depth"; + let size = |name: &str| format!("{name} must exceed four times geometric tolerance"); + for (primitive, message) in [ + (rounded([f64::NAN, 1.0, 1.0], 0.1), size("width")), + (rounded([1.0, 0.0, 1.0], 0.1), size("height")), + (rounded([1.0, 1.0, -1.0], 0.1), size("depth")), + (rounded([1.0, 1.0, 1.0], 0.0), size("radius")), + (rounded([1.0, 1.0, 1.0], 0.5), corner.to_string()), + (rounded([2.0, 1.0, 0.8], 0.45), corner.to_string()), + (rounded([0.8, 1.0, 2.0], 0.4), corner.to_string()), + ] { + assert_refused(primitive, &message); + } +} + +#[test] +fn rounded_box_json_parses_its_fields() { + let json = r#"{"kind":"RoundedBox","width":1,"height":2,"depth":3,"radius":0.25}"#; + let parsed = serde_json::from_str::(json).unwrap(); + assert!(matches!( + parsed, + Primitive::RoundedBox { width, height, depth, radius } + if width == 1.0 && height == 2.0 && depth == 3.0 && radius == 0.25 + )); +} + +#[test] +fn a_rounded_box_on_a_plane_keeps_width_along_x_direction_and_height_along_the_normal() { + let corners = [[1.0, 2.4, 3.5], [2.0, 1.7, 2.4], [1.0, 1.6, 2.5]]; + assert_on_plane( + rounded([1.2, 1.0, 0.8], 0.1), + 0.96 - (4.0 - PI) * 0.01, + &corners, + ); +} + +#[test] +fn a_rounded_box_passes_the_boolean_bar_with_a_box_and_a_cylinder() { + assert_bar( + rounded([1.0, 1.0, 1.0], 0.1), + [ + "A/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "A/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/cyl S=generic_two_sided_cutter_band_subtraction U=prismatic_curved_boolean I=prismatic_curved_boolean", + ], + ); +} + +#[test] +fn a_rounded_box_shells_to_a_valid_closed_solid() { + assert!(shell_is_valid(&rounded([1.0, 1.0, 1.0], 0.1), 0.03)); +} + +#[test] +fn a_rounded_box_exports_one_solid_with_four_cylindrical_faces() { + assert_step_surfaces(rounded([1.0, 1.0, 1.0], 0.1), 4, 6); +} diff --git a/main/opengeometry/tests/creating/slot_prism.rs b/main/opengeometry/tests/creating/slot_prism.rs new file mode 100644 index 00000000..9ee9455d --- /dev/null +++ b/main/opengeometry/tests/creating/slot_prism.rs @@ -0,0 +1,76 @@ +use super::standard::{ + assert_bar, assert_exact_volume, assert_on_plane, assert_refused, assert_step_surfaces, +}; +use opengeometry::world_graph::Primitive; +use opengeometry_test_support::scenes::bar::shell_is_valid; +use std::f64::consts::PI; + +fn slot(length: f64, width: f64, height: f64) -> Primitive { + Primitive::SlotPrism { + length, + width, + height, + } +} + +#[test] +fn a_slot_prism_is_closed_with_the_exact_stadium_volume() { + assert_exact_volume(slot(1.0, 0.5, 1.0), 0.5 + PI * 0.0625); + assert_exact_volume(slot(3.0, 0.2, 0.4), (0.6 + PI * 0.01) * 0.4); +} + +#[test] +fn invalid_slot_prisms_are_invalid_parameter_naming_the_value() { + for (primitive, message) in [ + (slot(0.0, 0.5, 1.0), "length"), + (slot(f64::INFINITY, 0.5, 1.0), "length"), + (slot(1.0, f64::NAN, 1.0), "width"), + (slot(1.0, 1e-9, 1.0), "width"), + (slot(1.0, 6e-8, 1.0), "width"), + (slot(1.0, 0.5, -1.0), "height"), + ] { + let message = format!("{message} must exceed four times geometric tolerance"); + assert_refused(primitive, &message); + } +} + +#[test] +fn slot_prism_json_parses_its_fields() { + let json = r#"{"kind":"SlotPrism","length":1,"width":0.5,"height":2}"#; + let parsed = serde_json::from_str::(json).unwrap(); + assert!(matches!( + parsed, + Primitive::SlotPrism { length, width, height } if length == 1.0 && width == 0.5 && height == 2.0 + )); +} + +#[test] +fn a_slot_prism_on_a_plane_runs_its_length_along_the_x_direction() { + let corners = [[1.0, 2.25, 3.5], [2.0, 1.75, 2.5], [1.0, 1.75, 2.5]]; + assert_on_plane(slot(1.0, 0.5, 1.0), 0.5 + PI * 0.0625, &corners); +} + +#[test] +fn a_slot_prism_passes_the_boolean_bar_with_a_box_and_a_cylinder() { + assert_bar( + slot(1.0, 0.5, 1.0), + [ + "A/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "A/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "B/cyl S=prismatic_curved_boolean U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/box S=subtract_vertical_arc_extrusion U=prismatic_curved_boolean I=prismatic_curved_boolean", + "T/cyl S=generic_two_sided_cutter_band_subtraction U=prismatic_curved_boolean I=prismatic_curved_boolean", + ], + ); +} + +#[test] +fn a_slot_prism_shells_to_a_valid_closed_solid() { + assert!(shell_is_valid(&slot(1.0, 0.5, 1.0), 0.03)); +} + +#[test] +fn a_slot_prism_exports_one_solid_with_two_cylindrical_faces() { + assert_step_surfaces(slot(1.0, 0.5, 1.0), 2, 4); +} diff --git a/main/opengeometry/tests/creating/standard.rs b/main/opengeometry/tests/creating/standard.rs new file mode 100644 index 00000000..7d2370a9 --- /dev/null +++ b/main/opengeometry/tests/creating/standard.rs @@ -0,0 +1,83 @@ +use opengeometry::world_graph::{ + CreateOptions, ErrorCode, Plane, Primitive, StepOptions, WorldGraph, +}; +use opengeometry_test_support::part21::Document; +use opengeometry_test_support::scenes::bar::bar_handlers; +use opengeometry_test_support::world_graph::{graph, named}; + +fn created(primitive: Primitive, options: CreateOptions) -> WorldGraph { + let mut world = graph(); + world.create_primitive(primitive, options).unwrap(); + world +} + +fn assert_closed_with_volume(world: &WorldGraph, expected: f64) { + let validity = world.validity("solid").unwrap(); + assert!(validity.valid && validity.closed); + let measured = world.measure("solid", Some(1e-4)).unwrap(); + assert!( + (measured.volume - expected).abs() <= measured.error_bound, + "{} versus {expected} with bound {}", + measured.volume, + measured.error_bound + ); +} + +pub(super) fn assert_exact_volume(primitive: Primitive, expected: f64) { + assert_closed_with_volume(&created(primitive, named("solid")), expected); +} + +pub(super) fn assert_refused(primitive: Primitive, message: &str) { + let mut world = graph(); + let label = format!("{primitive:?}"); + let error = world + .create_primitive(primitive, named("solid")) + .expect_err(&label); + assert_eq!(error.error_code(), ErrorCode::InvalidParameter, "{label}"); + assert_eq!(error.message(), message, "{label}"); + assert_eq!(world.node_count(), 0); +} + +pub(super) fn assert_on_plane(primitive: Primitive, expected: f64, corners: &[[f64; 3]]) { + let options = CreateOptions { + plane: Some(Plane { + origin: Some([1.0, 2.0, 3.0]), + normal: Some([1.0, 0.0, 0.0]), + x_direction: Some([0.0, 0.0, 1.0]), + }), + ..named("solid") + }; + let world = created(primitive, options); + assert_closed_with_volume(&world, expected); + let placement = world.world_placement("solid").unwrap(); + let brep = world.brep("solid").unwrap(); + let points = brep + .topology + .vertices + .iter() + .map(|vertex| placement.apply_point(vertex.position)) + .collect::>(); + let near = |a: [f64; 3], b: [f64; 3]| (0..3).all(|axis| (a[axis] - b[axis]).abs() <= 1e-9); + for corner in corners { + assert!( + points.iter().any(|point| near(*point, *corner)), + "{corner:?} in {points:?}" + ); + } +} + +pub(super) fn assert_bar(primitive: Primitive, rows: [&str; 6]) { + assert_eq!(bar_handlers(&primitive), rows); +} + +pub(super) fn assert_step_surfaces(primitive: Primitive, cylinders: usize, planes: usize) { + let world = created(primitive, named("solid")); + let (text, _) = world + .export_step(&["solid".into()], &StepOptions::default()) + .unwrap(); + let document = Document::parse(&text).unwrap(); + assert_eq!(document.count("MANIFOLD_SOLID_BREP"), 1); + assert_eq!(document.pcurveless_edges().unwrap(), 0); + assert_eq!(document.count("CYLINDRICAL_SURFACE"), cylinders); + assert_eq!(document.count("PLANE"), planes); +} diff --git a/main/opengeometry/tests/creating/support.rs b/main/opengeometry/tests/creating/support.rs index 611ed718..f01455f6 100644 --- a/main/opengeometry/tests/creating/support.rs +++ b/main/opengeometry/tests/creating/support.rs @@ -1,3 +1,4 @@ +use opengeometry::primitives::{ProfileSegment, SegmentArc}; use opengeometry::world_graph::{CreatingOperation, WorldGraph}; use opengeometry_test_support::volume; @@ -20,3 +21,17 @@ pub(super) fn assert_volume(world: &WorldGraph, og_id: &str, expected: f64) { measured.error_bound ); } + +pub(super) fn line(to: [f64; 2]) -> ProfileSegment { + ProfileSegment { to, arc: None } +} + +pub(super) fn arc(to: [f64; 2], center: [f64; 2], counter_clockwise: bool) -> ProfileSegment { + ProfileSegment { + to, + arc: Some(SegmentArc { + center, + counter_clockwise, + }), + } +} diff --git a/main/opengeometry/tests/creating/wire_kinds.rs b/main/opengeometry/tests/creating/wire_kinds.rs new file mode 100644 index 00000000..fb8b08a8 --- /dev/null +++ b/main/opengeometry/tests/creating/wire_kinds.rs @@ -0,0 +1,197 @@ +use super::standard::assert_refused; +use super::support::extrude; +use opengeometry::brep::BodyType; +use opengeometry::world_graph::{CreateOptions, Plane, Primitive, StepOptions, WorldGraph}; +use opengeometry_test_support::part21::Document; +use opengeometry_test_support::world_graph::{graph, named}; +use std::f64::consts::TAU; + +struct Faces { + total: usize, + planes: usize, + cylinders: usize, + volume: f64, + bounds: [f64; 6], +} + +fn polygon(sides: u32, radius: f64) -> Primitive { + Primitive::RegularPolygon { sides, radius } +} + +fn slot(length: f64, width: f64) -> Primitive { + Primitive::Slot { length, width } +} + +fn rounded(width: f64, breadth: f64, radius: f64) -> Primitive { + Primitive::RoundedRectangle { + width, + breadth, + radius, + } +} + +fn assert_closed_wire(primitive: Primitive, edges: usize) { + let mut world = graph(); + world.create_primitive(primitive, named("wire")).unwrap(); + let validity = world.validity("wire").unwrap(); + assert!(validity.valid && validity.closed); + assert_eq!(validity.body_type, Some(BodyType::Wire)); + assert_eq!(world.brep("wire").unwrap().topology.edges.len(), edges); +} + +fn faces(world: &WorldGraph, og_id: &str) -> Faces { + let (text, _) = world + .export_step(&[og_id.into()], &StepOptions::default()) + .unwrap(); + let document = Document::parse(&text).unwrap(); + let measured = world.measure(og_id, Some(1e-4)).unwrap(); + assert!(world.validity(og_id).unwrap().closed); + Faces { + total: world.brep(og_id).unwrap().topology.faces.len(), + planes: document.count("PLANE"), + cylinders: document.count("CYLINDRICAL_SURFACE"), + volume: measured.volume, + bounds: world.bounds(og_id).unwrap().unwrap(), + } +} + +fn extruded(wire: Primitive) -> (WorldGraph, Faces) { + let mut world = graph(); + world.create_primitive(wire, named("profile")).unwrap(); + world + .create_operation(extrude("profile", 1.0), named("solid")) + .unwrap(); + let found = faces(&world, "solid"); + (world, found) +} + +fn assert_extrudes_like(wire: Primitive, solid: Primitive) { + let (_, from_wire) = extruded(wire); + let mut world = graph(); + world.create_primitive(solid, named("kind")).unwrap(); + let from_kind = faces(&world, "kind"); + let bound = world.measure("kind", Some(1e-4)).unwrap().error_bound; + assert_eq!( + [from_wire.total, from_wire.planes, from_wire.cylinders], + [from_kind.total, from_kind.planes, from_kind.cylinders] + ); + assert!((from_wire.volume - from_kind.volume).abs() <= 2.0 * bound); + for axis in 0..6 { + assert!((from_wire.bounds[axis] - from_kind.bounds[axis]).abs() <= 1e-9); + } +} + +fn assert_placed_corner(primitive: Primitive, corner: [f64; 3]) { + let options = CreateOptions { + plane: Some(Plane { + origin: Some([1.0, 2.0, 3.0]), + normal: Some([1.0, 0.0, 0.0]), + x_direction: Some([0.0, 0.0, 1.0]), + }), + ..named("wire") + }; + let mut world = graph(); + world.create_primitive(primitive, options).unwrap(); + let placement = world.world_placement("wire").unwrap(); + let found = world + .brep("wire") + .unwrap() + .topology + .vertices + .iter() + .any(|vertex| { + let point = placement.apply_point(vertex.position); + (0..3).all(|axis| (point[axis] - corner[axis]).abs() <= 1e-9) + }); + assert!(found, "{corner:?}"); +} + +#[test] +fn wire_kinds_are_closed_wires_with_their_edge_counts() { + assert_closed_wire(polygon(6, 0.5), 6); + assert_closed_wire(polygon(3, 2.0), 3); + assert_closed_wire(slot(1.0, 0.5), 4); + assert_closed_wire(rounded(1.2, 0.8, 0.1), 8); +} + +#[test] +fn invalid_wire_kinds_are_invalid_parameter_naming_the_value() { + let size = |name: &str| format!("{name} must exceed four times geometric tolerance"); + let sides = "regular polygon sides must be between 3 and 1024".to_string(); + let corner = "rounded rectangle radius must be below half its width and breadth".to_string(); + for (primitive, message) in [ + (polygon(2, 1.0), sides.clone()), + (polygon(1025, 1.0), sides), + (polygon(6, f64::NAN), size("radius")), + (polygon(1024, 1e-7), size("regular polygon side")), + (slot(0.0, 0.5), size("length")), + (slot(1.0, 6e-8), size("width")), + (rounded(1.0, -1.0, 0.1), size("breadth")), + (rounded(1.0, 1.0, 0.0), size("radius")), + (rounded(1.0, 0.8, 0.4), corner), + ] { + assert_refused(primitive, &message); + } +} + +#[test] +fn wire_kind_json_parses_its_fields() { + for json in [ + r#"{"kind":"RegularPolygon","sides":6,"radius":0.5}"#, + r#"{"kind":"Slot","length":1,"width":0.5}"#, + r#"{"kind":"RoundedRectangle","width":1,"breadth":0.8,"radius":0.1}"#, + ] { + assert!(serde_json::from_str::(json).is_ok(), "{json}"); + } + let fractional = r#"{"kind":"RegularPolygon","sides":5.5,"radius":0.5}"#; + assert!(serde_json::from_str::(fractional).is_err()); +} + +#[test] +fn wire_kinds_on_a_plane_keep_u_along_the_x_direction_and_turn_counter_clockwise() { + assert_placed_corner(polygon(4, 0.5), [1.0, 2.0, 3.5]); + assert_placed_corner(polygon(4, 0.5), [1.0, 1.5, 3.0]); + assert_placed_corner(slot(1.0, 0.5), [1.0, 2.25, 3.5]); + assert_placed_corner(rounded(1.2, 0.8, 0.1), [1.0, 2.4, 3.5]); +} + +#[test] +fn an_extruded_slot_has_the_faces_volume_and_bounds_of_the_slot_prism() { + let prism = Primitive::SlotPrism { + length: 1.0, + width: 0.5, + height: 1.0, + }; + assert_extrudes_like(slot(1.0, 0.5), prism); +} + +#[test] +fn an_extruded_rounded_rectangle_has_the_faces_volume_and_bounds_of_the_rounded_box() { + let block = Primitive::RoundedBox { + width: 1.2, + height: 1.0, + depth: 0.8, + radius: 0.1, + }; + assert_extrudes_like(rounded(1.2, 0.8, 0.1), block); +} + +#[test] +fn an_extruded_regular_polygon_is_a_prism_with_the_exact_volume() { + for sides in [3_u32, 6, 7] { + let (_, prism) = extruded(polygon(sides, 0.5)); + let n = f64::from(sides); + let exact = n / 2.0 * 0.25 * (TAU / n).sin(); + let count = sides as usize + 2; + assert_eq!( + [prism.total, prism.planes, prism.cylinders], + [count, count, 0] + ); + assert!( + (prism.volume - exact).abs() <= 1e-9, + "{sides}: {}", + prism.volume + ); + assert!((prism.bounds[3] - 0.5).abs() <= 1e-12, "{sides}"); + } +} diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index 65141e71..1f59b51f 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -418,6 +418,7 @@ "builders.linear-extrusion.self-crossing": "e3a72f0afa92d3b4d9abb0c104952fb9408e2a518bbf98b907b6aa8f5e5a1a07", "builders.linear-extrusion.upright-sector": "fdbeb87035b021e04daaafbc58d80895bbae37051096986a096a50df6303d2d7", "builders.linear-extrusion.with-hole": "ce18d55e12c1cd2cea89cb3f3b4d58dbfeabef852a3c9cd10dc189f19d4aa980", + "builders.pipe-segment.ground": "bd0c70f03b5fb716595dec1e77fc6aa94a9486afed468601ba81e7ae7688cc49", "builders.placed.cuboid-scaled": "edd948c54a8736a507a6191347d561227c1c80128302b5618194d3de35d9efb5", "builders.placed.cylinder-rotated": "827f6382b3d290fcd6de612b22b63f7f56c8a1e95a8e8e96016a7c409ab98171", "builders.placed.zero-scale": "b0d66d72efc738d90870067da896fcf1c6a1fbcefc0b7e52f0ac53b1718e08d3", @@ -430,6 +431,10 @@ "builders.polyline.noncoplanar": "de106849f9cff95a618f76b860f88e797de65c30944c0a4b081b1cb62f140456", "builders.polyline.open": "485a0b9bebbe46e69ac2f3e44b6567aa57e430857f87c8eba1dc9652f37d5ed4", "builders.polyline.repeated-point": "d608bd770ef548c6c94c9bee36a322fa486575308e1521e96c1f92bf0fad7156", + "builders.polysolid.closed-loop": "bd28c9d24de20392f82def879c5dea7735d1e8bf6cb1e94899bdc8bbc0266938", + "builders.polysolid.l-path": "8e0fbb2609ab792ec775b221cb388da198500088c0b0f5761cae0a1166d029d4", + "builders.polysolid.leg-arc": "eba4cf7f023931daea7b7a7c98b8f100a6b09a408ace186b99b62eb86561afa1", + "builders.polysolid.tube": "2890603720c6d0fbdafb1411586def72d464b7211b6771d00499a360f378448a", "builders.profile-keys.ring-sector": "b2ad4caf9ab117586b3c141738bd06e07bd5047d87647566a6eece7216a9f61c", "builders.rectangle-keys.ground": "12e2f757757dc4f3e3fb5cd08cb103ed86f9ea393295ed2897fff72605186ed2", "builders.rectangle.identity": "31332072019245900457cc87ce607bc825c04cddf1589c11f1e870ba7b4a78c1", @@ -444,6 +449,8 @@ "builders.revolved.funnel": "615896c609dfeff094a502fd8805924a9a2813f34aff2f47ceedc9fca46f9111", "builders.revolved.ring": "e2d88493927fae7af8efdedf333309b55c286bccc8c8b7fa456623ccb90c9c5e", "builders.revolved.tube": "2caed571f40ed6d90e97ead5a7fdace4cf77e89cdbea6304480848e7223562ee", + "builders.rounded-box.ground": "e5d6c2a4c29e7b0fda0e4b285d96ea6f8dd6c8c70cbd0c7e334ff271c3f51da0", + "builders.slot-prism.ground": "4942885ae752f41c35e25edaed8023760f868542c67518d7731fa9d86c711efa", "builders.sphere.micro": "7dc9082a6cf18b309258300b8b365233a0fe5748fac9e91a953d04b1fa77bfcb", "builders.sphere.offset": "4b18f1443b10d400daddc789fabe0dc3ce606040a3f30de5ef3abf9cd8791342", "builders.sphere.oracle": "40739ef55be6a000955527fea3b14ca18e00e84fc6453b3315470683e7d45f85", @@ -527,7 +534,10 @@ "graph.operate.serial-union-and-intersection": "16bd35cf6461799233096ebb26d3e1b7ad8b1b4bcaef1c728c7cb01fc73732f0", "graph.operate.shared-target": "2ee3bc921fc2b82ae944d0d42a0f86230b71eddd1707ced075730d7927ccf109", "graph.operate.sweep-result": "81930c589ddf8723d0f515cd0426f7f1834894b8abbeaaf6dbc56e1f9109626c", + "graph.primitive.regular-polygon": "a0dab425fadc4e334643b968a9668339a5e06df6d6a8f0e4bb26d067c11d5771", "graph.primitive.revolved": "087959e66f828ca09267499853cda4ebe6bc8b382a763d07a579f144bbce0c34", + "graph.primitive.rounded-rectangle": "20ae06e9fc70dcba6ca399bad3171f4529809dadddb6dbd62c10e27dab83845f", + "graph.primitive.slot": "d862a14387cd5b8c68090c158516da4602ff974d1afbdee5b018fe9b5c3a8f86", "graph.scenario.copies-and-planes": "8cf5afbcb3e240a68aebc72a50874828d2e0d118e043dee8080d268aaf0f4a36", "graph.scenario.failed-calls": "5bd6c20c7e44aa77b5021118987d64ca4af4110568c6870baca83da9c34f1d00", "graph.scenario.instances-and-duplicates": "77e4630c9683726e7e9b6a5d254b5b97e8a632881670ff46c8cbf34984bfb5f8", diff --git a/main/opengeometry/tests/golden/builders_profiles.rs b/main/opengeometry/tests/golden/builders_profiles.rs index 3b15d489..da5deb38 100644 --- a/main/opengeometry/tests/golden/builders_profiles.rs +++ b/main/opengeometry/tests/golden/builders_profiles.rs @@ -1,7 +1,7 @@ use crate::builder_case::{built, keyed}; use crate::fixtures::{fine, ground, rectangle, standard, upright}; -use crate::graph_case::ring_segments; -use crate::kernel::{primitives, CurveGeometry, Frame3}; +use crate::graph_case::{create_options, graph_case, inspect, primitive, ring_segments}; +use crate::kernel::{primitives, CurveGeometry, Frame3, Primitive}; use crate::planar_shapes::profile_shell; use crate::profiles::{ band, line, polygon, quarter_annulus, rounded_outer, sector, split_quarter_annulus, @@ -240,9 +240,52 @@ fn curved_with_holes() -> Vec { ] } +fn wire_kind(name: &'static str, kind: Primitive) -> Case { + graph_case(&format!("graph.primitive.{name}"), move |graph, record| { + if primitive(record, graph, kind.clone(), create_options(name)).is_some() { + inspect(record, graph, name)?; + } + Ok(()) + }) +} + +fn wire_kinds() -> Vec { + vec![ + wire_kind( + "regular-polygon", + Primitive::RegularPolygon { + sides: 6, + radius: 0.5, + }, + ), + wire_kind( + "slot", + Primitive::Slot { + length: 1.0, + width: 0.5, + }, + ), + wire_kind( + "rounded-rectangle", + Primitive::RoundedRectangle { + width: 1.2, + breadth: 0.8, + radius: 0.1, + }, + ), + ] +} + pub(crate) fn cases() -> Vec { - [wires(), arcs(), linear(), curved(), curved_with_holes()] - .into_iter() - .flatten() - .collect() + [ + wires(), + arcs(), + linear(), + curved(), + curved_with_holes(), + wire_kinds(), + ] + .into_iter() + .flatten() + .collect() } diff --git a/main/opengeometry/tests/golden/builders_solids.rs b/main/opengeometry/tests/golden/builders_solids.rs index a9607c3b..1330c1dc 100644 --- a/main/opengeometry/tests/golden/builders_solids.rs +++ b/main/opengeometry/tests/golden/builders_solids.rs @@ -384,6 +384,105 @@ fn revolved_primitives(graph: &mut WorldGraph, record: &mut Record) -> Result<() Ok(()) } +fn standard_outlines() -> Vec { + vec![ + built("slot-prism.ground", || { + let outline = primitives::slot_outline(1.0, 0.5); + primitives::arc_edged_extrusion("slot".into(), ground(), outline, 1.0, standard()) + }), + built("rounded-box.ground", || { + let outline = primitives::rounded_rectangle_outline(1.2, 0.8, 0.1); + primitives::arc_edged_extrusion("rounded".into(), ground(), outline, 1.0, standard()) + }), + built("pipe-segment.ground", || { + let outline = primitives::ring_sector_outline([0.5, 0.3], [0.3, FRAC_PI_2]); + primitives::arc_edged_extrusion("pipe".into(), ground(), outline, 1.0, standard()) + }), + ] +} + +fn polysolid_path(points: &[[f64; 2]], closed: bool) -> primitives::PolysolidPath { + primitives::PolysolidPath { + start: points[0], + segments: points[1..] + .iter() + .map(|&to| primitives::ProfileSegment { to, arc: None }) + .collect(), + closed, + } +} + +fn polysolid(name: &str, points: &[[f64; 2]], closed: bool) -> Result { + polysolid_along(name, &polysolid_path(points, closed)) +} + +fn polysolid_along( + name: &str, + path: &primitives::PolysolidPath, +) -> Result { + let centre = primitives::PolysolidJustify::Centre; + primitives::polysolid(name.into(), ground(), path, centre, [0.2, 1.0], standard()).map_err( + |error| match error { + primitives::PolysolidError::Parameter(message) => { + GeometryError::InvalidGeometry(message) + } + primitives::PolysolidError::Geometry(error) => error, + }, + ) +} + +fn polysolids() -> Vec { + vec![ + built("polysolid.l-path", || { + polysolid("l", &[[0.0, 0.0], [3.0, 0.0], [3.0, 2.0]], false) + }), + built("polysolid.closed-loop", || { + let points = [[0.0, 0.0], [3.0, 0.0], [3.0, 2.0], [0.0, 2.0], [0.0, 0.0]]; + polysolid("loop", &points, true) + }), + ] +} + +fn arc_segment(to: [f64; 2], center: [f64; 2]) -> primitives::ProfileSegment { + primitives::ProfileSegment { + to, + arc: Some(primitives::SegmentArc { + center, + counter_clockwise: true, + }), + } +} + +fn polysolid_arcs() -> Vec { + vec![ + built("polysolid.leg-arc", || { + let path = primitives::PolysolidPath { + start: [0.0, 0.0], + segments: vec![ + primitives::ProfileSegment { + to: [2.0, 0.0], + arc: None, + }, + arc_segment([3.0, 1.0], [2.0, 1.0]), + ], + closed: false, + }; + polysolid_along("arc", &path) + }), + built("polysolid.tube", || { + let path = primitives::PolysolidPath { + start: [1.0, 0.0], + segments: vec![ + arc_segment([-1.0, 0.0], [0.0, 0.0]), + arc_segment([1.0, 0.0], [0.0, 0.0]), + ], + closed: true, + }; + polysolid_along("tube", &path) + }), + ] +} + pub(crate) fn cases() -> Vec { [ cuboids(), @@ -396,6 +495,9 @@ pub(crate) fn cases() -> Vec { revolved_lathe(), revolved_arcs(), vec![graph_case("graph.primitive.revolved", revolved_primitives)], + standard_outlines(), + polysolids(), + polysolid_arcs(), ] .into_iter() .flatten()