diff --git a/common/changes/@itwin/core-geometry/saeed-torabi-spiral-close-approach_2025-11-26-15-41.json b/common/changes/@itwin/core-geometry/saeed-torabi-spiral-close-approach_2025-11-26-15-41.json new file mode 100644 index 00000000000..9d7216f2850 --- /dev/null +++ b/common/changes/@itwin/core-geometry/saeed-torabi-spiral-close-approach_2025-11-26-15-41.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-geometry", + "comment": "", + "type": "none" + } + ], + "packageName": "@itwin/core-geometry" +} \ No newline at end of file diff --git a/core/geometry/src/curve/StrokeOptions.ts b/core/geometry/src/curve/StrokeOptions.ts index c13d33ff8d0..bb570a1c392 100644 --- a/core/geometry/src/curve/StrokeOptions.ts +++ b/core/geometry/src/curve/StrokeOptions.ts @@ -21,14 +21,21 @@ import { Angle } from "../geometry3d/Angle"; * * Nearly all stroke and facet use cases will apply an angle tolerance. * * For curves, 15 degrees is typical * * For facets, 22.5 degrees is typical. - * * Halving the angle tolerance will (roughly) make curves get twice as many strokes, and surfaces get 4 times as many facets. - * * The angle tolerance has the useful property that its effect is independent of scale of that data. If data is suddenly scaled into millimeters rather than meters, the facet counts remain the same. + * * Halving the angle tolerance will (roughly) make curves get twice as many strokes, and surfaces get 4 times as + * many facets. + * * The angle tolerance has the useful property that its effect is independent of scale of that data. If data is + * suddenly scaled into millimeters rather than meters, the facet counts remain the same. * * When creating output for devices such as 3D printing will want a chord tolerance. - * * For graphics display, use an angle tolerance of around 15 degrees and an chord tolerance which is the size of several pixels. + * * For graphics display, use an angle tolerance of around 15 degrees and an chord tolerance which is the size of + * several pixels. * * Analysis meshes (e.g. Finite Elements) commonly need to apply maxEdgeLength. - * * Using maxEdgeLength for graphics probably produces too many facets. For example, it causes long cylinders to get many nearly-square facets instead of the small number of long quads usually used for graphics. - * * Facet tolerances are, as the Pirates' Code, guidelines, not absolute rules. Facet and stroke code may ignore tolerances in awkward situations. - * * If multiple tolerances are in effect, the actual count will usually be based on the one that demands the most strokes or facets, unless it is so high that it violates some upper limit on the number of facets on an arc or a section of a curve. + * * Using maxEdgeLength for graphics probably produces too many facets. For example, it causes long cylinders to + * get many nearly-square facets instead of the small number of long quads usually used for graphics. + * * Facet tolerances are, as the Pirates' Code, guidelines, not absolute rules. Facet and stroke code may ignore + * tolerances in awkward situations. + * * If multiple tolerances are in effect, the actual count will usually be based on the one that demands the most + * strokes or facets, unless it is so high that it violates some upper limit on the number of facets on an arc or a + * section of a curve. * @public */ export class StrokeOptions { diff --git a/core/geometry/src/curve/internalContexts/CurveCurveCloseApproachXY.ts b/core/geometry/src/curve/internalContexts/CurveCurveCloseApproachXY.ts index eece426e2cc..180b789acdb 100644 --- a/core/geometry/src/curve/internalContexts/CurveCurveCloseApproachXY.ts +++ b/core/geometry/src/curve/internalContexts/CurveCurveCloseApproachXY.ts @@ -23,11 +23,14 @@ import { SmallSystem } from "../../numerics/SmallSystem"; import { Arc3d } from "../Arc3d"; import { CurveChainWithDistanceIndex } from "../CurveChainWithDistanceIndex"; import { CurveCollection } from "../CurveCollection"; +import { CurveCurve } from "../CurveCurve"; import { CurveIntervalRole, CurveLocationDetail, CurveLocationDetailPair } from "../CurveLocationDetail"; import { CurvePrimitive } from "../CurvePrimitive"; import { AnyCurve } from "../CurveTypes"; import { LineSegment3d } from "../LineSegment3d"; import { LineString3d } from "../LineString3d"; +import { TransitionSpiral3d } from "../spiral/TransitionSpiral3d"; +import { StrokeOptions } from "../StrokeOptions"; // cspell:word XYRR currentdFdX @@ -688,71 +691,22 @@ export class CurveCurveCloseApproachXY extends RecurseToCurvesGeometryHandler { } } } - /** Low level dispatch of curve collection. */ - private dispatchCurveCollection(geomA: AnyCurve, geomAHandler: (geomA: any) => any): void { - const geomB = this._geometryB; // save - if (!geomB || !geomB.children || !(geomB instanceof CurveCollection)) - return; - for (const child of geomB.children) { - this.resetGeometry(child); - geomAHandler(geomA); - } - this._geometryB = geomB; // restore - } - /** Low level dispatch to geomA given a CurveChainWithDistanceIndex in geometryB. */ - private dispatchCurveChainWithDistanceIndex(geomA: AnyCurve, geomAHandler: (geomA: any) => any): void { - if (!this._geometryB || !(this._geometryB instanceof CurveChainWithDistanceIndex)) - return; - if (geomA instanceof CurveChainWithDistanceIndex) { - assert(false, "call handleCurveChainWithDistanceIndex(geomA) instead"); - } - const index0 = this._results.length; - const geomB = this._geometryB; // save - for (const child of geomB.path.children) { - this.resetGeometry(child); - geomAHandler(geomA); - } - this.resetGeometry(geomB); // restore - this._results = CurveChainWithDistanceIndex.convertChildDetailToChainDetail(this._results, index0, undefined, geomB, true); - } - /** Double dispatch handler for strongly typed segment. */ - public override handleLineSegment3d(segmentA: LineSegment3d): any { - if (this._geometryB instanceof LineSegment3d) { - const segmentB = this._geometryB; - this.dispatchSegmentSegment( - segmentA, segmentA.point0Ref, 0.0, segmentA.point1Ref, 1.0, - segmentB, segmentB.point0Ref, 0.0, segmentB.point1Ref, 1.0, - false, - ); - } else if (this._geometryB instanceof LineString3d) { - this.computeSegmentLineString(segmentA, this._geometryB, false); - } else if (this._geometryB instanceof Arc3d) { - this.dispatchSegmentArc(segmentA, segmentA.point0Ref, 0.0, segmentA.point1Ref, 1.0, this._geometryB, false); - } else if (this._geometryB instanceof BSplineCurve3d) { - this.dispatchSegmentBsplineCurve(segmentA, this._geometryB, false); - } else if (this._geometryB instanceof CurveCollection) { - this.dispatchCurveCollection(segmentA, this.handleLineSegment3d.bind(this)); - } else if (this._geometryB instanceof CurveChainWithDistanceIndex) { - this.dispatchCurveChainWithDistanceIndex(segmentA, this.handleLineSegment3d.bind(this)); - } - return undefined; - } /** - * Set bits for comparison to range xy - * * bit 0x01 => x smaller than range.low.x - * * bit 0x02 => x larger than range.high.x - * * bit 0x04 => y smaller than range.low.y - * * bit 0x08 => y larger than range.high.y - * * If we divide XY plane into 9 areas using the range, the function returns 0 for points - * inside the range. Below is other binary numbers returned by the function for all 9 areas: - * 1001 | 1000 | 1010 - * ------------------ - * 1 | 0 | 10 - * ------------------ - * 101 | 100 | 110 - * @param xy point to test - * @param range range for comparison - */ + * Set bits for comparison to range xy + * * bit 0x01 => x smaller than range.low.x + * * bit 0x02 => x larger than range.high.x + * * bit 0x04 => y smaller than range.low.y + * * bit 0x08 => y larger than range.high.y + * * If we divide XY plane into 9 areas using the range, the function returns 0 for points + * inside the range. Below is other binary numbers returned by the function for all 9 areas: + * 1001 | 1000 | 1010 + * ------------------ + * 1 | 0 | 10 + * ------------------ + * 101 | 100 | 110 + * @param xy point to test + * @param range range for comparison + */ private classifyBitsPointRangeXY(x: number, y: number, range: Range3d): number { let result = 0; if (x < range.low.x) @@ -815,17 +769,70 @@ export class CurveCurveCloseApproachXY extends RecurseToCurvesGeometryHandler { } } } + /** Low level dispatch of curve collection. */ + private dispatchCurveCollection(geomA: AnyCurve, geomAHandler: (geomA: any) => any): void { + const geomB = this._geometryB; // save + if (!geomB || !geomB.children || !(geomB instanceof CurveCollection)) + return; + for (const child of geomB.children) { + this.resetGeometry(child); + geomAHandler(geomA); + } + this._geometryB = geomB; // restore + } + /** Low level dispatch to geomA given a CurveChainWithDistanceIndex in geometryB. */ + private dispatchCurveChainWithDistanceIndex(geomA: AnyCurve, geomAHandler: (geomA: any) => any): void { + if (!this._geometryB || !(this._geometryB instanceof CurveChainWithDistanceIndex)) + return; + if (geomA instanceof CurveChainWithDistanceIndex) { + assert(false, "call handleCurveChainWithDistanceIndex(geomA) instead"); + } + const index0 = this._results.length; + const geomB = this._geometryB; // save + for (const child of geomB.path.children) { + this.resetGeometry(child); + geomAHandler(geomA); + } + this.resetGeometry(geomB); // restore + this._results = CurveChainWithDistanceIndex.convertChildDetailToChainDetail(this._results, index0, undefined, geomB, true); + } + /** Double dispatch handler for strongly typed segment. */ + public override handleLineSegment3d(segmentA: LineSegment3d): any { + if (this._geometryB instanceof LineSegment3d) { + const segmentB = this._geometryB; + this.dispatchSegmentSegment( + segmentA, segmentA.point0Ref, 0.0, segmentA.point1Ref, 1.0, + segmentB, segmentB.point0Ref, 0.0, segmentB.point1Ref, 1.0, + false, + ); + } else if (this._geometryB instanceof LineString3d) { + this.computeSegmentLineString(segmentA, this._geometryB, false); + } else if (this._geometryB instanceof Arc3d) { + this.dispatchSegmentArc(segmentA, segmentA.point0Ref, 0.0, segmentA.point1Ref, 1.0, this._geometryB, false); + } else if (this._geometryB instanceof BSplineCurve3d) { + this.dispatchSegmentBsplineCurve(segmentA, this._geometryB, false); + } else if (this._geometryB instanceof TransitionSpiral3d) { + this.dispatchCurveSpiral(segmentA, this._geometryB, false); + } else if (this._geometryB instanceof CurveCollection) { + this.dispatchCurveCollection(segmentA, this.handleLineSegment3d.bind(this)); + } else if (this._geometryB instanceof CurveChainWithDistanceIndex) { + this.dispatchCurveChainWithDistanceIndex(segmentA, this.handleLineSegment3d.bind(this)); + } + return undefined; + } /** Double dispatch handler for strongly typed linestring. */ public override handleLineString3d(lsA: LineString3d): any { - if (this._geometryB instanceof LineString3d) { + if (this._geometryB instanceof LineSegment3d) { + this.computeSegmentLineString(this._geometryB, lsA, true); + } else if (this._geometryB instanceof LineString3d) { const lsB = this._geometryB; this.computeLineStringLineString(lsA, lsB, false); - } else if (this._geometryB instanceof LineSegment3d) { - this.computeSegmentLineString(this._geometryB, lsA, true); } else if (this._geometryB instanceof Arc3d) { this.computeArcLineString(this._geometryB, lsA, true); } else if (this._geometryB instanceof BSplineCurve3d) { this.dispatchLineStringBSplineCurve(lsA, this._geometryB, false); + } else if (this._geometryB instanceof TransitionSpiral3d) { + this.dispatchCurveSpiral(lsA, this._geometryB, false); } else if (this._geometryB instanceof CurveCollection) { this.dispatchCurveCollection(lsA, this.handleLineString3d.bind(this)); } else if (this._geometryB instanceof CurveChainWithDistanceIndex) { @@ -834,38 +841,159 @@ export class CurveCurveCloseApproachXY extends RecurseToCurvesGeometryHandler { return undefined; } /** Double dispatch handler for strongly typed arc. */ - public override handleArc3d(arc0: Arc3d): any { + public override handleArc3d(arcA: Arc3d): any { if (this._geometryB instanceof LineSegment3d) { this.dispatchSegmentArc( - this._geometryB, this._geometryB.point0Ref, 0.0, this._geometryB.point1Ref, 1.0, arc0, true, + this._geometryB, this._geometryB.point0Ref, 0.0, this._geometryB.point1Ref, 1.0, arcA, true, ); } else if (this._geometryB instanceof LineString3d) { - this.computeArcLineString(arc0, this._geometryB, false); + this.computeArcLineString(arcA, this._geometryB, false); } else if (this._geometryB instanceof Arc3d) { - this.dispatchArcArc(arc0, this._geometryB, false); + this.dispatchArcArc(arcA, this._geometryB, false); } else if (this._geometryB instanceof BSplineCurve3d) { - this.dispatchArcBsplineCurve3d(arc0, this._geometryB, false); + this.dispatchArcBsplineCurve3d(arcA, this._geometryB, false); + } else if (this._geometryB instanceof TransitionSpiral3d) { + this.dispatchCurveSpiral(arcA, this._geometryB, false); } else if (this._geometryB instanceof CurveCollection) { - this.dispatchCurveCollection(arc0, this.handleArc3d.bind(this)); + this.dispatchCurveCollection(arcA, this.handleArc3d.bind(this)); } else if (this._geometryB instanceof CurveChainWithDistanceIndex) { - this.dispatchCurveChainWithDistanceIndex(arc0, this.handleArc3d.bind(this)); + this.dispatchCurveChainWithDistanceIndex(arcA, this.handleArc3d.bind(this)); } return undefined; } /** Double dispatch handler for strongly typed bspline curve. */ - public override handleBSplineCurve3d(curve: BSplineCurve3d): any { + public override handleBSplineCurve3d(curveA: BSplineCurve3d): any { if (this._geometryB instanceof LineSegment3d) { - this.dispatchSegmentBsplineCurve(this._geometryB, curve, true); + this.dispatchSegmentBsplineCurve(this._geometryB, curveA, true); } else if (this._geometryB instanceof LineString3d) { - this.dispatchLineStringBSplineCurve(this._geometryB, curve, true); + this.dispatchLineStringBSplineCurve(this._geometryB, curveA, true); } else if (this._geometryB instanceof Arc3d) { - this.dispatchArcBsplineCurve3d(this._geometryB, curve, true); + this.dispatchArcBsplineCurve3d(this._geometryB, curveA, true); } else if (this._geometryB instanceof BSplineCurve3dBase) { - this.dispatchBSplineCurve3dBSplineCurve3d(curve, this._geometryB, false); + this.dispatchBSplineCurve3dBSplineCurve3d(curveA, this._geometryB, false); + } else if (this._geometryB instanceof TransitionSpiral3d) { + this.dispatchCurveSpiral(curveA, this._geometryB, false); } else if (this._geometryB instanceof CurveCollection) { - this.dispatchCurveCollection(curve, this.handleBSplineCurve3d.bind(this)); + this.dispatchCurveCollection(curveA, this.handleBSplineCurve3d.bind(this)); } else if (this._geometryB instanceof CurveChainWithDistanceIndex) { - this.dispatchCurveChainWithDistanceIndex(curve, this.handleBSplineCurve3d.bind(this)); + this.dispatchCurveChainWithDistanceIndex(curveA, this.handleBSplineCurve3d.bind(this)); + } + return undefined; + } + /** + * Process tail of `this._results` for xy close approach between the curve and spiral. + * * If a result is not already an intersection, refine it via Newton iteration unless it doesn't converge, in which + * case remove it. + * @param curveA The other curve primitive. May also be a transition spiral. + * @param spiralB The transition spiral. + * @param index0 index of first entry in tail of `this._results` to refine. + * @param reversed whether `spiralB` data is in `detailA` of each recorded pair, and `curveA` data in `detailB`. + */ + private refineSpiralResultsByNewton( + curveA: CurvePrimitive, spiralB: TransitionSpiral3d, index0: number, reversed = false, + ): void { + if (index0 >= this._results.length) + return; + // ASSUME: seeds in results tail are ordered by most accurate first, as only the first convergence within tolerance is recorded. + const xyMatchingFunction = new CurveCurveCloseApproachXYRRtoRRD(curveA, spiralB); + const newtonSearcher = new Newton2dUnboundedWithDerivative(xyMatchingFunction); + const myResults: CurveLocationDetailPair[] = []; + for (let i = index0; i < this._results.length; i++) { + const pair = this._results[i]; + const detailA = reversed ? pair.detailB : pair.detailA; + const detailB = reversed ? pair.detailA : pair.detailB; + if (detailA.point.isAlmostEqualXY(detailB.point)) { // an intersection + myResults.push(new CurveLocationDetailPair(reversed ? detailB : detailA, reversed ? detailA : detailB)); + continue; + } + assert(detailB.curve instanceof LineString3d, "Caller has discretized the spiral"); + newtonSearcher.setUV(detailA.fraction, detailB.fraction); // use linestring fraction as spiral param; it generally yields a closer point than fractional length! + if (newtonSearcher.runIterations()) { + const fractionA = newtonSearcher.getU(); + const fractionB = newtonSearcher.getV(); + if (this.acceptFraction(fractionA) && this.acceptFraction(fractionB)) + myResults.push(new CurveLocationDetailPair(reversed ? detailB : detailA, reversed ? detailA : detailB)); + } + } + this._results.splice(index0, this._results.length - index0, ...myResults); + } + /** + * Append stroke points and return the line string. + * * This is a convenient wrapper for [[CurvePrimitive.emitStrokes]] but the analogous instance method cannot be added + * to that class due to the ensuing recursion with subclass [[LineString3d]]. + * @param options options for stroking the instance curve. + * @param result object to receive appended stroke points; if omitted, a new object is created, populated, and returned. + */ + private strokeCurve(curve: CurvePrimitive, options?: StrokeOptions, result?: LineString3d): LineString3d { + const ls = result ? result : LineString3d.create(); + curve.emitStrokes(ls, options); + return ls; + } + /** + * Solve the intersection problem for curveA and spiralB. + * * @return the number of results appended. + */ + private appendDiscreteIntersectionResults( + curveA: CurvePrimitive, spiralB: TransitionSpiral3d, reversed: boolean, + ): number { + const i0 = this._results.length; + const intersectionPairs = CurveCurve.intersectionXYPairs( + reversed ? spiralB : curveA, false, reversed ? curveA : spiralB, false + ); + this._results.push(...intersectionPairs); + return this._results.length - i0; + } + /** + * Solve the close approach problem for stroked curveB. + * @return the number of results appended. + */ + private appendDiscreteCloseApproachResults(curveA: CurvePrimitive, lsB: LineString3d, reversed: boolean): number { + const i0 = this._results.length; + // handleLineString3d requires us to swap geometries + const geomB = this._geometryB; + if (curveA) + this._geometryB = curveA; + this.handleLineString3d(lsB); // this puts lsB data in detailA, as expected when reversed is true + if (!reversed) { // swap lsB data to detailB + for (let i = i0; i < this._results.length; i++) + this._results[i].swapDetails(); + } + this._geometryB = geomB; + return this._results.length - i0; + } + /** + * Compute the XY close approach of a curve and a spiral. + * @param curveA curve to find its close approach with spiralB. May also be a transition spiral. + * @param spiralB transition spiral to find its close approach with curveA. + * @param reversed whether `spiralB` data will be recorded in `detailA` of each result, and `curveA` data in `detailB`. + */ + private dispatchCurveSpiral(curveA: CurvePrimitive, spiralB: TransitionSpiral3d, reversed: boolean): void { + let cpA = curveA; + if (curveA instanceof TransitionSpiral3d) + cpA = this.strokeCurve(curveA); + const cpB = this.strokeCurve(spiralB); + const index0 = this._results.length; + // append seeds computed by solving discretized spiral problems, then refine the seeds via Newton + this.appendDiscreteIntersectionResults(curveA, spiralB, reversed); + this.appendDiscreteCloseApproachResults(cpA, cpB, reversed); // seeds for finding tangent intersections + this.refineSpiralResultsByNewton(curveA, spiralB, index0, reversed); + // start/end point approaches + if (curveA instanceof LineString3d) { + for (let segIndex = 0; segIndex < curveA.numEdges(); segIndex++) + // TODO: use getUncheckedIndexedSegment after lint PR is merged + this.testAndRecordFractionalPairApproach(curveA.getIndexedSegment(segIndex)!, 0, 1, true, spiralB, 0, 1, true, false); + } else { + this.testAndRecordFractionalPairApproach(curveA, 0, 1, true, spiralB, 0, 1, true, true); + } + } + /** Double dispatch handler for strongly typed spiral curve. */ + public override handleTransitionSpiral(spiral: TransitionSpiral3d): any { + if (this._geometryB instanceof CurveChainWithDistanceIndex) { + this.dispatchCurveChainWithDistanceIndex(spiral, this.handleTransitionSpiral.bind(this)); + } else if (this._geometryB instanceof CurvePrimitive) { + this.dispatchCurveSpiral(this._geometryB, spiral, true); + } else if (this._geometryB instanceof CurveCollection) { + this.dispatchCurveCollection(spiral, this.handleTransitionSpiral.bind(this)); } return undefined; } diff --git a/core/geometry/src/curve/internalContexts/CurveCurveIntersectXY.ts b/core/geometry/src/curve/internalContexts/CurveCurveIntersectXY.ts index 3a49adeebd0..ca4f9333966 100644 --- a/core/geometry/src/curve/internalContexts/CurveCurveIntersectXY.ts +++ b/core/geometry/src/curve/internalContexts/CurveCurveIntersectXY.ts @@ -1175,7 +1175,7 @@ export class CurveCurveIntersectXY extends RecurseToCurvesGeometryHandler { * @param curveA The other curve primitive. May also be a transition spiral. * @param spiralB The transition spiral. * @param index0 index of first entry in tail of `this._results` to refine. - * @param reversed Whether `spiralB` data is in `detailA` of each recorded pair, and `curveA` data in `detailB`. + * @param reversed whether `spiralB` data is in `detailA` of each recorded pair, and `curveA` data in `detailB`. */ private refineSpiralResultsByNewton( curveA: CurvePrimitive, spiralB: TransitionSpiral3d, index0: number, reversed = false, @@ -1277,7 +1277,7 @@ export class CurveCurveIntersectXY extends RecurseToCurvesGeometryHandler { curveA: CurvePrimitive, extendA0: boolean, extendA1: boolean, lsB: LineString3d, reversed: boolean, ): number { const i0 = this._results.length; - // handleLineString3d requires us to swap geometries: + // handleLineString3d requires us to swap geometries const geomB = this._geometryB; const extendB0 = this._extendB0; const extendB1 = this._extendB1; diff --git a/core/geometry/src/test/curve/CurveCurveCloseApproachXY.test.ts b/core/geometry/src/test/curve/CurveCurveCloseApproachXY.test.ts index 78b22230e1b..c687fb438b6 100644 --- a/core/geometry/src/test/curve/CurveCurveCloseApproachXY.test.ts +++ b/core/geometry/src/test/curve/CurveCurveCloseApproachXY.test.ts @@ -5,8 +5,10 @@ import { describe, expect, it } from "vitest"; import { BSplineCurve3d } from "../../bspline/BSplineCurve"; import { Arc3d } from "../../curve/Arc3d"; +import { CurveChainWithDistanceIndex } from "../../curve/CurveChainWithDistanceIndex"; import { BagOfCurves } from "../../curve/CurveCollection"; import { CurveCurve } from "../../curve/CurveCurve"; +import { CurveLocationDetailPair } from "../../curve/CurveLocationDetail"; import { CurvePrimitive } from "../../curve/CurvePrimitive"; import { AnyCurve } from "../../curve/CurveTypes"; import { GeometryQuery } from "../../curve/GeometryQuery"; @@ -15,12 +17,16 @@ import { LineString3d } from "../../curve/LineString3d"; import { Loop } from "../../curve/Loop"; import { ParityRegion } from "../../curve/ParityRegion"; import { Path } from "../../curve/Path"; +import { DirectSpiral3d } from "../../curve/spiral/DirectSpiral3d"; +import { IntegratedSpiral3d } from "../../curve/spiral/IntegratedSpiral3d"; +import { TransitionSpiral3d } from "../../curve/spiral/TransitionSpiral3d"; import { UnionRegion } from "../../curve/UnionRegion"; import { Geometry } from "../../Geometry"; import { Angle } from "../../geometry3d/Angle"; import { AngleSweep } from "../../geometry3d/AngleSweep"; import { Matrix3d } from "../../geometry3d/Matrix3d"; import { Point3d, Vector3d } from "../../geometry3d/Point3dVector3d"; +import { Segment1d } from "../../geometry3d/Segment1d"; import { Transform } from "../../geometry3d/Transform"; import { Checker } from "../Checker"; import { GeometryCoreTestIO } from "../GeometryCoreTestIO"; @@ -1548,4 +1554,261 @@ describe("CurveCurveCloseApproachXY", () => { GeometryCoreTestIO.saveGeometry(allGeometry, "CurveCurveCloseApproachXY", "LineBagOfCurves"); expect(ck.getNumErrors()).toBe(0); }); + + function captureCloseApproaches( + allGeometry: GeometryQuery[], approaches: CurveLocationDetailPair[], dx: number, dy: number) { + if (approaches.length > 0) { + for (const ap of approaches) { + const start = ap.detailA.point; + const end = ap.detailB.point; + if (start.isAlmostEqual(end)) // intersection between geometries + GeometryCoreTestIO.createAndCaptureXYCircle(allGeometry, start, 5, dx, dy); + else { // close approach between geometries + const approachSegment = LineSegment3d.create(start, end); + GeometryCoreTestIO.captureGeometry(allGeometry, approachSegment, dx, dy); + } + } + } + } + + function visualizeAndTestSpiralCloseApproaches( + ck: Checker, allGeometry: GeometryQuery[], + curve0: AnyCurve, curve1: AnyCurve, + numExpected: number, dx: number, dy: number, + ) { + GeometryCoreTestIO.captureCloneGeometry(allGeometry, curve0, dx, dy); + GeometryCoreTestIO.captureCloneGeometry(allGeometry, curve1, dx, dy); + if (curve0 instanceof TransitionSpiral3d) + GeometryCoreTestIO.captureCloneGeometry(allGeometry, curve0.activeStrokes, dx, dy); + if (curve1 instanceof TransitionSpiral3d) + GeometryCoreTestIO.captureCloneGeometry(allGeometry, curve1.activeStrokes, dx, dy); + + const testSpiralIntersection = (intersections: CurveLocationDetailPair[], _reversed: boolean) => { + captureCloseApproaches(allGeometry, intersections, dx, dy); + const curveName0 = curve0.constructor.name; + const curveName1 = curve1.constructor.name; + ck.testLE( + numExpected, + intersections.length, + `expect at least ${numExpected} close approach(es) between ${curveName1} and ${curveName0}`, + ); + } + // test both paths + const maxDistance = 22; + const closeApproachesAB = CurveCurve.closeApproachProjectedXYPairs(curve0, curve1, maxDistance); + testSpiralIntersection(closeApproachesAB, false); + const closeApproachesBA = CurveCurve.closeApproachProjectedXYPairs(curve1, curve0, maxDistance); + testSpiralIntersection(closeApproachesBA, true); + }; + + it("SpiralCloseApproach", () => { + const ck = new Checker(); + const allGeometry: GeometryQuery[] = []; + let dx = 0; + let dy = 0; + + const rotationTransform0 = Transform.createFixedPointAndMatrix( + Point3d.create(70, 0), + Matrix3d.createRotationAroundVector(Vector3d.create(0, 0, 1), Angle.createDegrees(180))!, + ); + const rotationTransform1 = Transform.createFixedPointAndMatrix( + Point3d.create(0, 0), + Matrix3d.createRotationAroundVector(Vector3d.create(0, 1, 0), Angle.createDegrees(45))!, + ); + const moveTransform = Transform.createTranslationXYZ(0, 0, 10); + const nonPlanarTransform = Transform.createZero(); + nonPlanarTransform.setMultiplyTransformTransform(rotationTransform0, moveTransform); + nonPlanarTransform.setMultiplyTransformTransform(rotationTransform1, nonPlanarTransform); + // integrated spirals + const integratedSpirals = []; + const r0 = 0; + const r1 = 50; + const activeInterval = Segment1d.create(0, 1); + for (const integratedSpiralType of ["clothoid", "bloss", "biquadratic", "sine", "cosine"]) { + for (const transform of [ + Transform.createIdentity(), + rotationTransform0, // rotated spirals have indices (n*i)+1 + nonPlanarTransform, // non-planar spirals have indices (n*i)+2 + ]) { + integratedSpirals.push( + IntegratedSpiral3d.createRadiusRadiusBearingBearing( + Segment1d.create(r0, r1), + AngleSweep.createStartEndDegrees(0, 120), + activeInterval, + transform, + integratedSpiralType, + ) as TransitionSpiral3d + ); + } + } + // direct spirals + const directSpirals = []; + const length = 100; + for (const directSpiralType of [ + "Arema", + "JapaneseCubic", + "ChineseCubic", + "WesternAustralian", + "HalfCosine", + "AustralianRailCorp", + // TODO: enable below lines after https://github.com/iTwin/itwinjs-backlog/issues/1693 is resolved + // "Czech", + // "Italian", + // "MXCubicAlongArc", + // "Polish", + ]) { + for (const transform of [ + Transform.createIdentity(), + rotationTransform0, // rotated spirals have indices (n*i)+1 + nonPlanarTransform, // non-planar spirals have indices (n*i)+2 + ]) { + directSpirals.push( + DirectSpiral3d.createFromLengthAndRadius( + directSpiralType, r0, r1, undefined, undefined, length, activeInterval, transform, + ) as TransitionSpiral3d + ); + } + } + // curve primitives + const lineSegment0 = LineSegment3d.create(Point3d.create(70, 30), Point3d.create(70, -30)); + const lineSegment1 = LineSegment3d.create(Point3d.create(20, -40), Point3d.create(130, 30)); + const lineSegment2 = LineSegment3d.create(Point3d.create(-20, 0), Point3d.create(100, 0)); + const lineString0 = LineString3d.create( + Point3d.create(10, -80), Point3d.create(40, -20), Point3d.create(100, -5), + Point3d.create(80, 10), Point3d.create(150, -10), + ); + const arc0 = Arc3d.createXY(Point3d.create(50, 50), 25); + const arc1 = Arc3d.createXY(Point3d.create(0, -30), 30); + const bspline0 = BSplineCurve3d.createUniformKnots( + [ + Point3d.create(0, -20, 0), + Point3d.create(20, -20, 0), + Point3d.create(50, -10, 0), + Point3d.create(80, 0, 0), + Point3d.create(100, 0, 0), + ], + 3, + )!; + // curve collection (path-loop), curve chain, and bag of curves + const lineString1 = LineString3d.create(Point3d.create(50, -30.95), Point3d.create(50, 10), Point3d.create(37.59, 16.32)); + const arc2 = Arc3d.create( + Point3d.create(0, 20), Vector3d.create(40, 0), Vector3d.create(0, 40), AngleSweep.createStartEndDegrees(340, 0), + ); + const arc3 = Arc3d.create( + Point3d.create(70, -40), Vector3d.create(20, 0), Vector3d.create(0, 20), AngleSweep.createStartEndDegrees(0, -180), + ); + const lineString3 = LineString3d.create(Point3d.create(50, -40), Point3d.create(0, -40), Point3d.create(0, 0)); + const lineString2 = LineString3d.create(Point3d.create(40, 20), Point3d.create(50, 20), Point3d.create(90, 50)); + const lineSegment3 = LineSegment3d.create(Point3d.create(90, 50), Point3d.create(140, 0)); + const lineSegment4 = LineSegment3d.create(Point3d.create(60, -50), Point3d.create(90, -40)); + const path0 = Path.create(arc2, lineString2, lineSegment3, directSpirals[1]); + const path1 = Path.create(lineSegment4, arc3, lineString3, directSpirals[0]); + const loop = Path.create(lineString1, arc2, lineString2, lineSegment3, directSpirals[1]); + const curveChain0 = CurveChainWithDistanceIndex.createCapture(path0); + const curveChain1 = CurveChainWithDistanceIndex.createCapture(path1); + const bagOfCurves = BagOfCurves.create(path0, arc0, lineString0); + + const curves: AnyCurve[] = [ + lineSegment0, + lineSegment1, + lineSegment2, + lineString0, + arc0, + arc1, + bspline0, + // add rotated and non-planar spirals + // integratedSpirals[1], + // integratedSpirals[2], + // integratedSpirals[4], + // integratedSpirals[5], + // integratedSpirals[7], + // integratedSpirals[8], + // integratedSpirals[10], + // integratedSpirals[11], + // integratedSpirals[13], + // integratedSpirals[14], + directSpirals[1], + directSpirals[2], + directSpirals[4], + directSpirals[5], + directSpirals[7], + directSpirals[8], + directSpirals[10], + directSpirals[11], + directSpirals[13], + directSpirals[14], + directSpirals[16], + directSpirals[17], + // TODO: enable below lines after https://github.com/iTwin/itwinjs-backlog/issues/1693 is resolved + // directSpirals[19], + // directSpirals[20], + // directSpirals[22], + // directSpirals[23], + // directSpirals[25], + // directSpirals[26], + // directSpirals[28], + // directSpirals[29], + path0, + loop, + curveChain0, + bagOfCurves, + ]; + const numExpectedCloseApproaches = [ + 1, 1, 1, 1, 1, 1, 1, // curve primitives other than spirals + // 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // rotated and non-planar integrated spirals + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 1, 1, 1, 1, 1, 1, 1, 1 // rotated and non-planar direct spirals + 4, 5, 4, 5, // path, loop, curve chain, and bag of curves + ]; + ck.testCoordinate(curves.length, numExpectedCloseApproaches.length, "matching arrays"); + // spiral vs all curves + const test0 = (spiral: TransitionSpiral3d, ddy = 0) => { + for (let j = 0; j < curves.length; j++) { + const curve = curves[j]; + const numExpectedCloseApproach = numExpectedCloseApproaches[j]; + visualizeAndTestSpiralCloseApproaches(ck, allGeometry, spiral, curve, numExpectedCloseApproach, dx, dy); + dy += 200; + } + dy = ddy; + dx += 200; + } + // for (let i = 0; i < integratedSpirals.length; i++) // skip rotated and non-planar integrated spirals + // if (i % 3 === 0) + // test0(integratedSpirals[i]); + // dx += 250; + for (let i = 0; i < directSpirals.length; i++) // skip rotated and non-planar direct spirals + if (i % 3 === 0) + test0(directSpirals[i]); + dx = 0; + dy = 6400; + let numExpected = 4; + // curve chain/collection vs curve chain/collection + visualizeAndTestSpiralCloseApproaches(ck, allGeometry, curveChain0, curveChain1, numExpected, dx, dy); + dy += 200; + visualizeAndTestSpiralCloseApproaches(ck, allGeometry, path0, path1, numExpected, dx, dy); + dy += 200; + visualizeAndTestSpiralCloseApproaches(ck, allGeometry, curveChain0, path1, numExpected, dx, dy); + dy += 200; + visualizeAndTestSpiralCloseApproaches(ck, allGeometry, curveChain1, path0, numExpected, dx, dy); + // tangency at the interior of the spiral + dy += 200; + numExpected = 1; + const test1 = (spiral: TransitionSpiral3d) => { + const ray = spiral.fractionToPointAndDerivative(0.5); + const ls = LineString3d.create( + ray.origin.plusScaled(ray.direction.normalize()!, 50), ray.origin.plusScaled(ray.direction.normalize()!, -50) + ); + visualizeAndTestSpiralCloseApproaches(ck, allGeometry, spiral, ls, numExpected, dx, dy); + dx += 200; + } + // for (let i = 0; i < integratedSpirals.length; i++) // skip rotated and non-planar integrated spirals + // if (i % 3 === 0) + // test1(integratedSpirals[i]); + // dx += 250; + for (let i = 0; i < directSpirals.length; i++) // skip rotated and non-planar direct spirals + if (i % 3 === 0) + test1(directSpirals[i]); + + GeometryCoreTestIO.saveGeometry(allGeometry, "CurveCurveCloseApproachXY", "SpiralCloseApproach"); + expect(ck.getNumErrors()).toBe(0); + }); }); diff --git a/core/geometry/src/test/curve/CurveCurveIntersectXY.test.ts b/core/geometry/src/test/curve/CurveCurveIntersectXY.test.ts index ff96af78f01..938f696c6f6 100644 --- a/core/geometry/src/test/curve/CurveCurveIntersectXY.test.ts +++ b/core/geometry/src/test/curve/CurveCurveIntersectXY.test.ts @@ -10,6 +10,7 @@ import { BagOfCurves, CurveCollection } from "../../curve/CurveCollection"; import { CurveCurve } from "../../curve/CurveCurve"; import { CurveLocationDetail, CurveLocationDetailPair } from "../../curve/CurveLocationDetail"; import { CurvePrimitive } from "../../curve/CurvePrimitive"; +import { AnyCurve } from "../../curve/CurveTypes"; import { GeometryQuery } from "../../curve/GeometryQuery"; import { LineSegment3d } from "../../curve/LineSegment3d"; import { LineString3d } from "../../curve/LineString3d"; @@ -34,7 +35,6 @@ import { Matrix4d } from "../../geometry4d/Matrix4d"; import { Sample } from "../GeometrySamples"; import { Checker } from "../Checker"; import { GeometryCoreTestIO } from "../GeometryCoreTestIO"; -import { AnyCurve } from "../../curve/CurveTypes"; /** * This function creates some sample Map4ds. The transform0 of the Map4d is passed as "worldToLocal" transform to