diff --git a/common/api/core-geometry.api.md b/common/api/core-geometry.api.md index 3e73ec2c0e0f..f9545c779952 100644 --- a/common/api/core-geometry.api.md +++ b/common/api/core-geometry.api.md @@ -692,6 +692,7 @@ export class Box extends SolidPrimitive { isAlmostEqual(other: GeometryQuery): boolean; get isClosedVolume(): boolean; isSameGeometryClass(other: any): boolean; + get isSkew(): boolean; readonly solidPrimitiveType = "box"; strokeConstantVSection(zFraction: number): LineString3d; tryTransformInPlace(transform: Transform): boolean; @@ -1378,6 +1379,7 @@ export class Cone extends SolidPrimitive implements UVSurface, UVSurfaceIsoParam static createAxisPoints(centerA: Point3d, centerB: Point3d, radiusA: number, radiusB: number, capped?: boolean): Cone | undefined; static createBaseAndTarget(centerA: Point3d, centerB: Point3d, vectorX: Vector3d, vectorY: Vector3d, radiusA: number, radiusB: number, capped?: boolean): Cone; static createDgnCone(centerA: Point3d, centerB: Point3d, vectorX: Vector3d, vectorY: Vector3d, radiusA: number, radiusB: number, capped?: boolean): Cone | undefined; + cylinderRadius(allowSkew?: boolean): number; dispatchToGeometryHandler(handler: GeometryHandler): any; extendRange(rangeToExtend: Range3d, transform?: Transform): void; getCenterA(): Point3d; @@ -1391,6 +1393,7 @@ export class Cone extends SolidPrimitive implements UVSurface, UVSurfaceIsoParam isAlmostEqual(other: GeometryQuery): boolean; get isClosedVolume(): boolean; isSameGeometryClass(other: any): boolean; + get isSkew(): boolean; maxIsoParametricDistance(): Vector2d; readonly solidPrimitiveType = "cone"; strokeConstantVSection(v: number, fixedStrokeCount?: number, options?: StrokeOptions): LineString3d; @@ -3380,6 +3383,7 @@ export class LinearSweep extends SolidPrimitive { isAlmostEqual(other: GeometryQuery): boolean; get isClosedVolume(): boolean; isSameGeometryClass(other: any): boolean; + get isSkew(): boolean; readonly solidPrimitiveType = "linearSweep"; tryTransformInPlace(transform: Transform): boolean; } @@ -3662,6 +3666,7 @@ export class Matrix3d implements BeJSONFunctions { coffs: Float64Array; columnDotXYZ(columnIndex: AxisIndex, x: number, y: number, z: number): number; columnX(result?: Vector3d): Vector3d; + columnXCrossColumnY(result?: Vector3d): Vector3d; columnXDotColumnY(): number; columnXDotColumnZ(): number; columnXMagnitude(): number; @@ -5626,6 +5631,7 @@ export class RotationalSweep extends SolidPrimitive { isAlmostEqual(other: GeometryQuery): boolean; get isClosedVolume(): boolean; isSameGeometryClass(other: any): boolean; + get isSkew(): boolean; readonly solidPrimitiveType = "rotationalSweep"; tryTransformInPlace(transform: Transform): boolean; } @@ -5790,6 +5796,7 @@ export abstract class SolidPrimitive extends GeometryQuery { readonly geometryCategory = "solid"; abstract getConstructiveFrame(): Transform | undefined; abstract get isClosedVolume(): boolean; + get isSkew(): boolean; abstract readonly solidPrimitiveType: SolidPrimitiveType; } @@ -5849,6 +5856,7 @@ export class Sphere extends SolidPrimitive implements UVSurface { isAlmostEqual(other: GeometryQuery): boolean; get isClosedVolume(): boolean; isSameGeometryClass(other: any): boolean; + get isSkew(): boolean; get latitudeSweepFraction(): number; maxAxisRadius(): number; maxIsoParametricDistance(): Vector2d; @@ -6048,6 +6056,7 @@ export class TorusPipe extends SolidPrimitive implements UVSurface, UVSurfaceIso isAlmostEqual(other: GeometryQuery): boolean; get isClosedVolume(): boolean; isSameGeometryClass(other: any): boolean; + get isSkew(): boolean; maxIsoParametricDistance(): Vector2d; readonly solidPrimitiveType = "torusPipe"; tryTransformInPlace(transform: Transform): boolean; diff --git a/common/changes/@itwin/core-geometry/da4-solid-primitive-is-skew_2026-05-11-15-46.json b/common/changes/@itwin/core-geometry/da4-solid-primitive-is-skew_2026-05-11-15-46.json new file mode 100644 index 000000000000..6769e4b6833b --- /dev/null +++ b/common/changes/@itwin/core-geometry/da4-solid-primitive-is-skew_2026-05-11-15-46.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-geometry", + "comment": "Add SolidPrimitive.isSkew and Cone.cylinderRadius", + "type": "none" + } + ], + "packageName": "@itwin/core-geometry" +} \ No newline at end of file diff --git a/core/geometry/src/geometry3d/Matrix3d.ts b/core/geometry/src/geometry3d/Matrix3d.ts index 8f9876a8a4db..5177a8cda460 100644 --- a/core/geometry/src/geometry3d/Matrix3d.ts +++ b/core/geometry/src/geometry3d/Matrix3d.ts @@ -1455,6 +1455,10 @@ export class Matrix3d implements BeJSONFunctions { public rowZMagnitude(): number { return Geometry.hypotenuseXYZ(this.coffs[6], this.coffs[7], this.coffs[8]); } + /** Return the cross product of column X with column Y. */ + public columnXCrossColumnY(result?: Vector3d): Vector3d { + return Geometry.crossProductXYZXYZ(this.coffs[0], this.coffs[3], this.coffs[6], this.coffs[1], this.coffs[4], this.coffs[7], result); + } /** Return the dot product of column X with column Y */ public columnXDotColumnY(): number { return this.coffs[0] * this.coffs[1] diff --git a/core/geometry/src/serialization/IModelJsonSchema.ts b/core/geometry/src/serialization/IModelJsonSchema.ts index b092e9339c51..520ac965edd1 100644 --- a/core/geometry/src/serialization/IModelJsonSchema.ts +++ b/core/geometry/src/serialization/IModelJsonSchema.ts @@ -1467,20 +1467,15 @@ export namespace IModelJson { const xySameLength = Geometry.isSameCoordinate(xMag, yMag); const axisVector = Vector3d.createStartEnd(centerA, centerB); - // special case of cylinder - if (Geometry.isSameCoordinate(radiusA, radiusB) - && vectorX.isPerpendicularTo(axisVector) - && vectorY.isPerpendicularTo(axisVector) - && xySameLength - && Geometry.isSameCoordinate(xMag, 1.0)) { - return { - cylinder: { - capped: data.capped, - start: centerA.toJSON(), - end: centerB.toJSON(), - radius: radiusA, - }, + const cylinderRadius = data.cylinderRadius(); + if (cylinderRadius > 0) { + const cylinderProps: CylinderProps = { + capped: data.capped, + start: centerA.toJSON(), + end: centerB.toJSON(), + radius: cylinderRadius, }; + return { cylinder: cylinderProps }; } const coneProps: ConeProps = { diff --git a/core/geometry/src/solid/Box.ts b/core/geometry/src/solid/Box.ts index f67c5379653d..5611fc156d7a 100644 --- a/core/geometry/src/solid/Box.ts +++ b/core/geometry/src/solid/Box.ts @@ -262,6 +262,10 @@ export class Box extends SolidPrimitive { rangeToExtend.extendTransformedXYZ(boxTransform, bx, by, 1); } } + /** Return true if the solid's local z-axis is not perpendicular to its local xy-plane. */ + public override get isSkew(): boolean { + return !this._localToWorld.matrix.columnZ().isParallelTo(this._localToWorld.matrix.columnXCrossColumnY(), true, true); + } /** * @return true if this is a closed volume. */ diff --git a/core/geometry/src/solid/Cone.ts b/core/geometry/src/solid/Cone.ts index b029428d1476..318eaa01dd15 100644 --- a/core/geometry/src/solid/Cone.ts +++ b/core/geometry/src/solid/Cone.ts @@ -296,6 +296,29 @@ export class Cone extends SolidPrimitive implements UVSurface, UVSurfaceIsoParam this._localToWorld.multiplyVectorXYZ(drdv * cosTheta, drdv * sinTheta, 1.0), result); } + /** Return true if the solid's local z-axis is not perpendicular to its local xy-plane. */ + public override get isSkew(): boolean { + return !this._localToWorld.matrix.columnZ().isParallelTo(this._localToWorld.matrix.columnXCrossColumnY(), true, true); + } + /** + * Test if this cone is a cylinder. + * * A cone is cylindrical if both conditions hold: + * 1. cross sections are circles of equal radius + * 2. axis is perpendicular to cross sections + * * Radii within [[Geometry.smallMetricDistance]] are considered equal. + * @param allowSkew whether to test the first condition only. Default value is `false`: test both conditions. + * @return cross sectional radius > 0 if cylindrical cone; otherwise 0 for non-cylindrical or degenerate cone. + */ + public cylinderRadius(allowSkew: boolean = false): number { + let radius = 0; + if (allowSkew || !this.isSkew) { + const magX = this._localToWorld.matrix.columnXMagnitude(); + const magY = this._localToWorld.matrix.columnYMagnitude(); + if (Geometry.isSameCoordinate(magX, magY) && Geometry.isSameCoordinate(this._radiusA, this._radiusB)) + radius = Math.abs(magX * this._radiusA); + } + return radius; + } /** * @return true if this is a closed volume. */ diff --git a/core/geometry/src/solid/LinearSweep.ts b/core/geometry/src/solid/LinearSweep.ts index b2a091667005..d709bc8dd333 100644 --- a/core/geometry/src/solid/LinearSweep.ts +++ b/core/geometry/src/solid/LinearSweep.ts @@ -158,6 +158,10 @@ export class LinearSweep extends SolidPrimitive { } rangeToExtend.extendRange(contourRange); } + /** Return true if the solid's local z-axis is not perpendicular to its local xy-plane. */ + public override get isSkew(): boolean { + return !this._direction.isParallelTo(this._contour.localToWorld.matrix.columnXCrossColumnY(), true, true); + } /** * @return true if this is a closed volume. */ diff --git a/core/geometry/src/solid/RotationalSweep.ts b/core/geometry/src/solid/RotationalSweep.ts index fac2c7794f2d..fb1f09d400dd 100644 --- a/core/geometry/src/solid/RotationalSweep.ts +++ b/core/geometry/src/solid/RotationalSweep.ts @@ -171,6 +171,10 @@ export class RotationalSweep extends SolidPrimitive { strokes.extendRange(range, this.getFractionalRotationTransform(i / numStep, stepTransform)); } } + /** Return true if the sweep axis and contour are not coplanar. */ + public override get isSkew(): boolean { + return !this._normalizedAxis.direction.isPerpendicularTo(this._contour.localToWorld.matrix.columnXCrossColumnY(), true); + } /** Specify if the sweep forms a closed volume. */ public get isClosedVolume(): boolean { return this.capped || this._sweepAngle.isFullCircle; diff --git a/core/geometry/src/solid/RuledSweep.ts b/core/geometry/src/solid/RuledSweep.ts index 9b9b3c6df4e4..f35ed523d42a 100644 --- a/core/geometry/src/solid/RuledSweep.ts +++ b/core/geometry/src/solid/RuledSweep.ts @@ -21,7 +21,7 @@ import { SweepContour } from "./SweepContour"; /** * Type for a function argument taking 2 curves and returning another curve or failing with undefined. - * * This is used (for instance) by `RuleSweep.mutatePartners`. + * * This is used (for instance) by [[RuledSweep.mutatePartners]]. * @public */ export type CurvePrimitiveMutator = (primitiveA: CurvePrimitive, primitiveB: CurvePrimitive) => CurvePrimitive | undefined; diff --git a/core/geometry/src/solid/SolidPrimitive.ts b/core/geometry/src/solid/SolidPrimitive.ts index c7abdc2296be..da139f20db05 100644 --- a/core/geometry/src/solid/SolidPrimitive.ts +++ b/core/geometry/src/solid/SolidPrimitive.ts @@ -64,10 +64,19 @@ export abstract class SolidPrimitive extends GeometryQuery { /** Return a cross section at specified vFraction. */ public abstract constantVSection(_vFraction: number): CurveCollection | undefined; /** - * Return a Transform from the local system of the solid to world. + * Return a Transform from the solid's local coordinate system to world. * * The particulars of origin and orientation are specific to each SolidPrimitive type. + * * The returned Transform is generally rigid (no preservation of skew, mirror, or scale in the solid's definition). */ public abstract getConstructiveFrame(): Transform | undefined; + /** + * Return true if the solid's local coordinate axes lack full orthogonality. + * * Skew typically takes the form of a local z-axis that is not perpendicular to the local xy-plane. + * * This property is always `false` for a [[RuledSweep]]. + */ + public get isSkew(): boolean { + return false; + } /** * Return true if this is a closed volume * * LinearSweep, Box, Cone only depend on capped. diff --git a/core/geometry/src/solid/Sphere.ts b/core/geometry/src/solid/Sphere.ts index e4a20019cb3b..0eb68f08339a 100644 --- a/core/geometry/src/solid/Sphere.ts +++ b/core/geometry/src/solid/Sphere.ts @@ -324,6 +324,10 @@ export class Sphere extends SolidPrimitive implements UVSurface { this._localToWorld.matrix.multiplyXYZ(-fPhi * cosTheta * sinPhi, -fPhi * sinTheta * sinPhi, fPhi * cosPhi), result); } + /** Return true if the solid's local z-axis is not perpendicular to its local xy-plane. */ + public override get isSkew(): boolean { + return !this._localToWorld.matrix.columnZ().isParallelTo(this._localToWorld.matrix.columnXCrossColumnY(), true, true); + } /** * * A sphere is can be closed two ways: * * full sphere (no caps needed for closure) diff --git a/core/geometry/src/solid/TorusPipe.ts b/core/geometry/src/solid/TorusPipe.ts index af5460ae9b9f..1e8066824fcf 100644 --- a/core/geometry/src/solid/TorusPipe.ts +++ b/core/geometry/src/solid/TorusPipe.ts @@ -361,6 +361,10 @@ export class TorusPipe extends SolidPrimitive implements UVSurface, UVSurfaceIso const b = Math.abs(this.getMinorRadius()); return Vector2d.create(b * Math.PI * 2.0, (a + b) * this._sweep.radians); } + /** Return true if the solid's local z-axis is not perpendicular to its local xy-plane. */ + public override get isSkew(): boolean { + return !this._localToWorld.matrix.columnZ().isParallelTo(this._localToWorld.matrix.columnXCrossColumnY(), true, true); + } /** * @return true if this is a closed volume. */ diff --git a/core/geometry/src/test/curve/Curve.test.ts b/core/geometry/src/test/curve/Curve.test.ts index 35753709b806..b676197bfa1f 100644 --- a/core/geometry/src/test/curve/Curve.test.ts +++ b/core/geometry/src/test/curve/Curve.test.ts @@ -9,6 +9,7 @@ import { BSplineCurve3d, BSplineCurve3dBase } from "../../bspline/BSplineCurve"; import { BSplineCurve3dH } from "../../bspline/BSplineCurve3dH"; import { InterpolationCurve3d } from "../../bspline/InterpolationCurve3d"; import { Arc3d } from "../../curve/Arc3d"; +import { ConstructCurveBetweenCurves } from "../../curve/ConstructCurveBetweenCurves"; import { CoordinateXYZ } from "../../curve/CoordinateXYZ"; import { CurveChainWithDistanceIndex } from "../../curve/CurveChainWithDistanceIndex"; import { BagOfCurves, CurveCollection } from "../../curve/CurveCollection"; @@ -1472,3 +1473,22 @@ describe("GeometryQuery", () => { expect(ck.getNumErrors()).toBe(0); }); }); + +describe("CurveBetweenCurves", () => { + it("Mismatches", () => { + const ck = new Checker(); + const segment = LineSegment3d.createXYZXYZ(1, 2, 2, 4, 2, -1); + const arc = Arc3d.createUnitCircle(); + const points = [Point3d.create(0, 0, 0), Point3d.create(1, 1, 0), Point3d.create(3, 1, 0), Point3d.create(3, 0, 0)]; + const bcurve = BSplineCurve3d.createUniformKnots(points, 3)!; + const linestring = LineString3d.create(points); + ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(segment, 0.5, arc)); + ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(segment, 0.5, linestring)); + ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(segment, 0.5, bcurve)); + ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(arc, 0.5, linestring)); + ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(linestring, 0.5, arc)); + ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(arc, 0.5, bcurve)); + ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(bcurve, 0.5, segment)); + expect(ck.getNumErrors()).toBe(0); + }); +}); diff --git a/core/geometry/src/test/solid/Solid.test.ts b/core/geometry/src/test/solid/Solid.test.ts index 5aca0605ed8f..013fad112b4c 100644 --- a/core/geometry/src/test/solid/Solid.test.ts +++ b/core/geometry/src/test/solid/Solid.test.ts @@ -4,9 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it } from "vitest"; -import { BSplineCurve3d } from "../../bspline/BSplineCurve"; import { Arc3d } from "../../curve/Arc3d"; -import { ConstructCurveBetweenCurves } from "../../curve/ConstructCurveBetweenCurves"; import { GeometryQuery } from "../../curve/GeometryQuery"; import { LineSegment3d } from "../../curve/LineSegment3d"; import { LineString3d } from "../../curve/LineString3d"; @@ -26,7 +24,6 @@ import { Transform } from "../../geometry3d/Transform"; import { IndexedPolyface } from "../../polyface/Polyface"; import { PolyfaceBuilder } from "../../polyface/PolyfaceBuilder"; import { PolyfaceQuery } from "../../polyface/PolyfaceQuery"; -import { Sample } from "../GeometrySamples"; import { Box } from "../../solid/Box"; import { Cone } from "../../solid/Cone"; import { LinearSweep } from "../../solid/LinearSweep"; @@ -38,8 +35,34 @@ import { SweepContour } from "../../solid/SweepContour"; import { TorusPipe } from "../../solid/TorusPipe"; import { Checker } from "../Checker"; import { GeometryCoreTestIO } from "../GeometryCoreTestIO"; +import { Sample } from "../GeometrySamples"; import { testGeometryQueryRoundTrip } from "../serialization/FlatBuffer.test"; +type AnnouncePolyface = (source: GeometryQuery, polyface: IndexedPolyface) => void; + +// output the geometry, then its facets shifted vertically. +// return the geometry range +function transformAndFacet(allGeometry: GeometryQuery[], + g: GeometryQuery, + transform: Transform | undefined, + options: StrokeOptions | undefined, + x0: number, y0: number, + announcePolyface?: AnnouncePolyface): Range3d { + const g1 = transform ? g.cloneTransformed(transform) : g; + if (g1) { + const builder = PolyfaceBuilder.create(options); + builder.addGeometryQuery(g1); + const facets = builder.claimPolyface(); + const range = g1.range(); + GeometryCoreTestIO.captureCloneGeometry(allGeometry, g1, x0, y0); + GeometryCoreTestIO.captureCloneGeometry(allGeometry, facets, x0, y0 + 2.0 * range.yLength()); + if (announcePolyface !== undefined) + announcePolyface(g1, facets); + return range; + } + return Range3d.createNull(); +} + function verifyUnitPerpendicularFrame(ck: Checker, frame: Transform, source: any) { ck.testTrue(frame.matrix.isRigid(), "perpendicular frame", source); } @@ -66,6 +89,7 @@ function exerciseUVToWorld(ck: Checker, s: SolidPrimitive, u: number, v: number, GeometryCoreTestIO.consoleLog(" V", vector01, plane00.vectorV); } } + function exerciseSolids(ck: Checker, solids: GeometryQuery[], _name: string) { const scaleTransform = Transform.createFixedPointAndMatrix(Point3d.create(1, 2, 2), Matrix3d.createUniformScale(2)); for (const s of solids) { @@ -124,6 +148,7 @@ function exerciseSolids(ck: Checker, solids: GeometryQuery[], _name: string) { } } } + describe("Solids", () => { it("Cones", () => { const ck = new Checker(); @@ -573,47 +598,137 @@ describe("Solids", () => { GeometryCoreTestIO.saveGeometry(allGeometry, "Solids", "LinearSweepWithHoles"); expect(ck.getNumErrors()).equals(0); }); -}); -describe("CurveCurve", () => { - it("Mismatches", () => { + it("IsSkew", () => { const ck = new Checker(); - const segment = LineSegment3d.createXYZXYZ(1, 2, 2, 4, 2, -1); - const arc = Arc3d.createUnitCircle(); - const points = [Point3d.create(0, 0, 0), Point3d.create(1, 1, 0), Point3d.create(3, 1, 0), Point3d.create(3, 0, 0)]; - const bcurve = BSplineCurve3d.createUniformKnots(points, 3)!; - const linestring = LineString3d.create(points); - ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(segment, 0.5, arc)); - ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(segment, 0.5, linestring)); - ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(segment, 0.5, bcurve)); - ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(arc, 0.5, linestring)); - ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(linestring, 0.5, arc)); - ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(arc, 0.5, bcurve)); - ck.testUndefined(ConstructCurveBetweenCurves.interpolateBetween(bcurve, 0.5, segment)); + const allGeometry: GeometryQuery[] = []; + const origin = Point3d.createZero(); + const solids: SolidPrimitive[] = []; + let x0 = 0; + + // ---- Cone.isSkew ---- + // Non-skew: createAxisPoints always builds an orthogonal (rigid) local frame. + const nonSkewCone = Cone.createAxisPoints(origin, Point3d.create(0, 0, 5), 1, 2, false)!; + solids.push(nonSkewCone); + ck.testFalse(nonSkewCone.isSkew, "cone via createAxisPoints is not skew"); + const nonSkewCylinder = Cone.createAxisPoints(origin, Point3d.create(0, 0, 5), 1, 1, true)!; + solids.push(nonSkewCylinder); + ck.testFalse(nonSkewCylinder.isSkew, "cylinder via createAxisPoints is not skew"); + + // Skew: createBaseAndTarget with vectorZ = (1,0,1), which is not perpendicular to the xy-plane normal (0,0,1). + const skewCone = Cone.createBaseAndTarget(origin, Point3d.create(1, 0, 1), Vector3d.unitX(), Vector3d.unitY(), 1, 2, false); + solids.push(skewCone); + ck.testTrue(skewCone.isSkew, "cone with oblique z-axis is skew"); + const skewCylinder = Cone.createBaseAndTarget(origin, Point3d.create(1, 0, 1), Vector3d.unitX(), Vector3d.unitY(), 1, 1, false); + solids.push(skewCylinder); + ck.testTrue(skewCylinder.isSkew, "cylinder with oblique z-axis is skew"); + + // ---- Cone.cylinderRadius ---- + // Non-skew cylinder: equal radii and equal column magnitudes → returns the radius. + ck.testCoordinate(1, nonSkewCylinder.cylinderRadius(), "non-skew cylinder: cylinderRadius = 1"); + + // Non-skew cone with unequal radii → 0. + ck.testCoordinate(0, nonSkewCone.cylinderRadius(), "non-skew cone with unequal radii: cylinderRadius = 0"); + + // Skew cylinder, allowSkew=false (default) → 0 because the skew check blocks it. + ck.testCoordinate(0, skewCylinder.cylinderRadius(), "skew cylinder, allowSkew=false: cylinderRadius = 0"); + + // Skew cylinder, allowSkew=true → skew check is skipped; equal radii and equal column magnitudes → 1. + ck.testCoordinate(1, skewCylinder.cylinderRadius(true), "skew cylinder, allowSkew=true: cylinderRadius = 1"); + + // Elliptical cross-section: unequal column X and Y magnitudes → 0 even though not skew. + const ellipticalCone = Cone.createBaseAndTarget(origin, Point3d.create(0, 0, 1), Vector3d.create(2, 0, 0), Vector3d.unitY(), 1, 1, false); + solids.push(ellipticalCone); + ck.testFalse(ellipticalCone.isSkew, "cone with unequal xy column scales is not skew"); + ck.testCoordinate(0, ellipticalCone.cylinderRadius(), "elliptical cone: cylinderRadius = 0 (magX != magY)"); + + // ---- Box.isSkew ---- + const nonSkewBox = Box.createRange(Range3d.createXYZXYZ(0, 0, 0, 1, 1, 1), true)!; + solids.push(nonSkewBox); + ck.testFalse(nonSkewBox.isSkew, "axis-aligned box is not skew"); + + // Oblique z-axis: topOrigin displaced in X gives vectorZ = (1,0,1). + const skewBox = Box.createDgnBox(origin, Vector3d.unitX(), Vector3d.unitY(), Point3d.create(1, 0, 1), 1, 1, 1, 1, false)!; + solids.push(skewBox); + ck.testTrue(skewBox.isSkew, "box with oblique z-axis is skew"); + + // ---- LinearSweep.isSkew ---- + // Sweep direction = z → parallel to the contour plane normal (0,0,1) → not skew. + const linearSweepNonSkew = LinearSweep.create(Loop.create(Arc3d.createXY(Point3d.create(3, 0, 0), 1)), Vector3d.unitZ(), false)!; + solids.push(linearSweepNonSkew); + ck.testFalse(linearSweepNonSkew.isSkew, "linear sweep along z is not skew"); + + // Sweep direction = (1,0,1) → not parallel to z → skew. + const linearSweepSkew = LinearSweep.create(Loop.create(Arc3d.createXY(Point3d.create(3, 0, 0), 1)), Vector3d.create(1, 0, 1), false)!; + solids.push(linearSweepSkew); + ck.testTrue(linearSweepSkew.isSkew, "linear sweep with oblique direction is skew"); + + // ---- RotationalSweep.isSkew ---- + // Contour in xy-plane → contour normal ≈ z. Rotation about x: x ⊥ z → not skew. + const rotSweepNonSkew = RotationalSweep.create( + Loop.create(Arc3d.createXY(Point3d.create(3, 3, 0), 1)), + Ray3d.createXAxis(), Angle.create360(), false, + )!; + solids.push(rotSweepNonSkew); + ck.testFalse(rotSweepNonSkew.isSkew, "rotational sweep around x-axis (x perp to contour normal z) is not skew"); + + // Rotation about (1,1,1) NOT ⊥ contour z → skew. + const rotSweepSkew = RotationalSweep.create( + Loop.create(Arc3d.createXY(Point3d.create(3, 3, 0), 1, AngleSweep.createStartEndDegrees(30, -30))), + Ray3d.create(Point3d.create(0, 0, 0), Vector3d.create(1, 1, 1)), Angle.create360(), false, + )!; + solids.push(rotSweepSkew); + ck.testTrue(rotSweepSkew.isSkew, "rotational sweep around z-axis (z parallel to contour normal z) is skew"); + + // ---- RuledSweep.isSkew (inherits base class default, always false) ---- + const ruledSweep = RuledSweep.create([ + Loop.create(Arc3d.createXY(origin, 1)), + Loop.create(Arc3d.createXY(Point3d.create(0, 0, 2), 1.5)), + Loop.create(Arc3d.createXY(origin, 0.5)), + ], false)!; + solids.push(ruledSweep); + ck.testFalse(ruledSweep.isSkew, "ruled sweep always returns false for isSkew"); + + // ---- Sphere.isSkew ---- + // Standard sphere: orthogonal local frame → not skew. + const nonSkewSphere = Sphere.createCenterRadius(origin, 1); + solids.push(nonSkewSphere); + ck.testFalse(nonSkewSphere.isSkew, "standard sphere is not skew"); + + // Oblique local frame: columnZ = (0.5,0,1) is not parallel to xy-plane normal (0,0,1) → skew. + const skewSphere = Sphere.createEllipsoid( + Transform.createOriginAndMatrixColumns(origin, Vector3d.unitX(), Vector3d.unitY(), Vector3d.create(0.5, 0, 1)), + )!; + solids.push(skewSphere); + ck.testTrue(skewSphere.isSkew, "sphere with oblique local frame is skew"); + + // ---- TorusPipe.isSkew ---- + // Standard torus with identity frame: orthogonal → not skew. + const nonSkewTorus = TorusPipe.createInFrame(Transform.createIdentity(), 3, 1, Angle.createDegrees(360), false)!; + solids.push(nonSkewTorus); + ck.testFalse(nonSkewTorus.isSkew, "standard torus pipe is not skew"); + + // Oblique local frame: columnZ = (0.5,0,1) not perpendicular to xy → skew. + const skewTorus = TorusPipe.createInFrame( + Transform.createOriginAndMatrixColumns(origin, Vector3d.unitX(), Vector3d.unitY(), Vector3d.create(0.5, 0, 1)), + 3, 1, Angle.createDegrees(360), false, + )!; + solids.push(skewTorus); + ck.testTrue(skewTorus.isSkew, "torus pipe with oblique local frame is skew"); + + // Collect all solids for visual inspection output. Stroke in case skew not supported by JSON viewer. + const options = StrokeOptions.createForFacets(); + options.angleTol = Angle.createDegrees(5); + for (const solid of solids) { + const builder = PolyfaceBuilder.create(options); + builder.addGeometryQuery(solid); + GeometryCoreTestIO.captureCloneGeometry(allGeometry, builder.claimPolyface(true, 1.0e-10), x0); + x0 += 10; + } + + GeometryCoreTestIO.saveGeometry(allGeometry, "Solids", "IsSkew"); expect(ck.getNumErrors()).toBe(0); }); }); -type AnnouncePolyface = (source: GeometryQuery, polyface: IndexedPolyface) => void; -// output the geometry, then its facets shifted vertically. -// return the geometry range -function transformAndFacet(allGeometry: GeometryQuery[], - g: GeometryQuery, - transform: Transform | undefined, - options: StrokeOptions | undefined, - x0: number, y0: number, - announcePolyface?: AnnouncePolyface): Range3d { - const g1 = transform ? g.cloneTransformed(transform) : g; - if (g1) { - const builder = PolyfaceBuilder.create(options); - builder.addGeometryQuery(g1); - const facets = builder.claimPolyface(); - const range = g1.range(); - GeometryCoreTestIO.captureCloneGeometry(allGeometry, g1, x0, y0); - GeometryCoreTestIO.captureCloneGeometry(allGeometry, facets, x0, y0 + 2.0 * range.yLength()); - if (announcePolyface !== undefined) - announcePolyface(g1, facets); - return range; - } - return Range3d.createNull(); -} +