@code3d/core 0.0.1-alpha.7 → 0.0.1-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -171,6 +171,28 @@ export type ElementSnapshot = Readonly<{
171
171
  TopologySelection;
172
172
  }>;
173
173
 
174
+ /** A finite measurement in the common frame solved at the time of the call. */
175
+ export type DistanceSnapshot = Readonly<{
176
+ value: number;
177
+ start: Vec3;
178
+ end: Vec3;
179
+ axis?: Vec3;
180
+ axisName?: 'x' | 'y' | 'z';
181
+ operands: readonly Readonly<{
182
+ nodeId: string;
183
+ /** The entire model, rather than a named or topology reference. */
184
+ whole: boolean;
185
+ elements: readonly ElementSnapshot[];
186
+ }>[];
187
+ placements: readonly Readonly<{nodeId: string; transform: Transform}>[];
188
+ }>;
189
+
190
+ type DistanceObserver = (
191
+ snapshot: DistanceSnapshot,
192
+ objects: readonly RelationObject[],
193
+ ) => void;
194
+ let observeDistance: DistanceObserver | undefined;
195
+
174
196
  export type ConstraintAnchorSnapshot = Readonly<{
175
197
  nodeId: string;
176
198
  name: string;
@@ -406,6 +428,7 @@ type StoredElement = Readonly<{
406
428
  facing?: 1 | -1;
407
429
  direction?: 1 | -1;
408
430
  topology?: StoredTopology;
431
+ parts?: readonly StoredTopology[];
409
432
  members?: StoredElements;
410
433
  }>;
411
434
 
@@ -610,11 +633,22 @@ let operationTraces = new WeakMap<StoredOperation, OperationTrace>();
610
633
  * Finish in finally after snapshotting to retain this evaluation's kernel work.
611
634
  * An optional cancellation check may throw before any kernel operation starts;
612
635
  * complete results survive cancellation, and cleanup removes the check.
636
+ * The optional distance observer receives call-time geometry snapshots; cleanup
637
+ * also releases its evaluation scope, while distance() still returns a number.
613
638
  */
614
- export function beginModelEvaluation(checkCancelled?: () => void): () => void {
639
+ export function beginModelEvaluation(
640
+ checkCancelled?: () => void,
641
+ distanceObserver?: DistanceObserver,
642
+ ): () => void {
615
643
  valueTraces = new WeakMap();
616
644
  operationTraces = new WeakMap();
617
- return beginKernelOperationEvaluation(checkCancelled);
645
+ const previous = observeDistance;
646
+ observeDistance = distanceObserver;
647
+ const finish = beginKernelOperationEvaluation(checkCancelled);
648
+ return () => {
649
+ observeDistance = previous;
650
+ finish();
651
+ };
618
652
  }
619
653
 
620
654
  function valueTrace(value: object): ValueTrace {
@@ -741,6 +775,9 @@ export interface FaceAnchor extends Anchor<'face'> {
741
775
  flip(): this;
742
776
  }
743
777
 
778
+ /** Fixed solve-frame direction, or the solved direction of a straight edge/axis. */
779
+ export type DistanceAxis = 'x' | 'y' | 'z' | Vec3 | LineAnchor;
780
+
744
781
  const boundKind = Symbol('boundKind');
745
782
  export interface Bound extends FaceAnchor {
746
783
  readonly [boundKind]: true;
@@ -805,7 +842,7 @@ export type ExposedValue<Value> = Value extends {
805
842
  : Kind extends 'vertex'
806
843
  ? Vertex
807
844
  : Kind extends 'group'
808
- ? Anchor<'frame'>
845
+ ? Anchor<'frame'> & DirectionalBounds
809
846
  : Anchor) &
810
847
  Elements
811
848
  : Value extends Anchor
@@ -844,6 +881,13 @@ export type ModelForKind<
844
881
  ? GroupModel<Elements>
845
882
  : never;
846
883
 
884
+ /** Tight axis-aligned bounds in the selected model coordinate frame. */
885
+ export type ModelBounds = Readonly<{
886
+ minimum: Vec3;
887
+ maximum: Vec3;
888
+ size: Vec3;
889
+ }>;
890
+
847
891
  export interface ModelCapabilities<
848
892
  Elements extends NamedElements,
849
893
  Kind extends ModelKind,
@@ -851,6 +895,10 @@ export interface ModelCapabilities<
851
895
  extends Anchor<ModelElementKind<Kind>>, DirectionalBounds {
852
896
  readonly [modelKind]: Kind;
853
897
  readonly [modelNamedElements]: Elements;
898
+ /** Measure finite geometry in this model's local frame, or in relativeTo's frame. */
899
+ bounds(relativeTo?: Model): ModelBounds;
900
+ /** Model-origin coordinates in relativeTo's local frame, including placement. */
901
+ position(relativeTo: Model): Vec3;
854
902
  relate(
855
903
  build: (
856
904
  self: ModelForKind<Elements, Kind>,
@@ -1470,10 +1518,13 @@ function rotationPointReference(point: PointAnchor): AnchorReference {
1470
1518
  return reference;
1471
1519
  }
1472
1520
 
1473
- function rotationAxisReference(axis: LineAnchor): AnchorReference {
1521
+ function straightAxisReference(
1522
+ axis: LineAnchor,
1523
+ operation = 'axisLine()',
1524
+ ): AnchorReference {
1474
1525
  const reference = anchorReference(axis);
1475
1526
  if (reference.kind !== 'line')
1476
- throw new Error('axisLine() requires an axis or straight edge.');
1527
+ throw new Error(`${operation} requires an axis or straight edge.`);
1477
1528
  const geometry =
1478
1529
  reference.topology?.source[modelGeometry]()?.value ??
1479
1530
  (reference.whole && isModelObject(reference.model)
@@ -1490,7 +1541,7 @@ function rotationAxisReference(axis: LineAnchor): AnchorReference {
1490
1541
  : (geometry.shape as ReplicadEdge).geomType === 'LINE';
1491
1542
  if (!straight)
1492
1543
  throw new Error(
1493
- 'axisLine() requires a straight axis; curved edges do not define one rotation axis.',
1544
+ `${operation} requires a straight axis; curved edges do not define one axis.`,
1494
1545
  );
1495
1546
  }
1496
1547
  return reference;
@@ -1652,7 +1703,7 @@ export function axisEdge(id: EdgeId): AxisChain {
1652
1703
  export function axisLine(axis: LineAnchor): AxisChain {
1653
1704
  return new AxisChain(new TransformationRotation(), {
1654
1705
  kind: 'axisLine',
1655
- axis: rotationAxisReference(axis),
1706
+ axis: straightAxisReference(axis),
1656
1707
  });
1657
1708
  }
1658
1709
 
@@ -2821,39 +2872,78 @@ export class ModelObject<
2821
2872
  >;
2822
2873
  }
2823
2874
 
2824
- /** Resolve both own and occurrence references into this model's local frame. */
2825
- private localElements(sources: readonly Anchor[]): StoredElement[] {
2875
+ /**
2876
+ * Measure finite geometry in this model's local frame, or in relativeTo's frame.
2877
+ * An explicit reference includes solved placement and nested group occurrences.
2878
+ * Empty groups have no finite bounds.
2879
+ */
2880
+ bounds(relativeTo?: Model): ModelBounds {
2881
+ const target =
2882
+ relativeTo === undefined
2883
+ ? this
2884
+ : requireModelObject(relativeTo, 'bounds requires a reference model.');
2885
+ const transform = target.localFrames([this])[0];
2886
+ const [minimum, maximum] = this[referenceBounds](
2887
+ this.relationAnchorReference(),
2888
+ transform,
2889
+ );
2890
+ return {
2891
+ minimum,
2892
+ maximum,
2893
+ size: [
2894
+ maximum[0] - minimum[0],
2895
+ maximum[1] - minimum[1],
2896
+ maximum[2] - minimum[2],
2897
+ ],
2898
+ };
2899
+ }
2900
+
2901
+ /**
2902
+ * Model-origin coordinates in relativeTo's local frame, including placement.
2903
+ * This model's origin in its own frame is always [0, 0, 0], even for point models.
2904
+ */
2905
+ position(relativeTo: Model): Vec3 {
2906
+ const target = requireModelObject(
2907
+ relativeTo,
2908
+ 'position requires a reference model.',
2909
+ );
2910
+ return target.localFrames([this])[0].position;
2911
+ }
2912
+
2913
+ /** Resolve source frames without losing nested occurrence positions. */
2914
+ private localFrames(sources: readonly RelationObject[]): RigidTransform[] {
2826
2915
  const members = this.memberPoses();
2827
- const external = sources
2828
- .map(source => anchorReference(source).model)
2829
- .filter(model => !members.has(model));
2916
+ const external = sources.filter(model => !members.has(model));
2830
2917
  const context = external.length
2831
2918
  ? ModelObject.createSolveContext([this, ...external])
2832
2919
  : undefined;
2833
2920
  return sources.map(source => {
2834
- const reference = anchorReference(source);
2835
- const {
2836
- kind,
2837
- transform: frame,
2838
- topology,
2839
- members: nested,
2840
- bound,
2841
- facing,
2842
- } = source instanceof ModelObject ? source.exposedElement() : reference;
2843
- const memberPose = members.get(reference.model);
2921
+ const memberPose = members.get(source);
2844
2922
  if (memberPose === null)
2845
2923
  throw new Error(
2846
2924
  "The point or element belongs to multiple occurrences. Expose it through the intended child model's named reference.",
2847
2925
  );
2848
- const transform =
2926
+ return (
2849
2927
  memberPose ??
2850
- relativeTransform(
2851
- reference.model.solvePose(context!),
2852
- this.solvePose(context!),
2853
- );
2928
+ relativeTransform(source.solvePose(context!), this.solvePose(context!))
2929
+ );
2930
+ });
2931
+ }
2932
+
2933
+ /** Resolve both own and occurrence references into this model's local frame. */
2934
+ private localElements(sources: readonly Anchor[]): StoredElement[] {
2935
+ const references = sources.map(anchorReference);
2936
+ const transforms = this.localFrames(
2937
+ references.map(reference => reference.model),
2938
+ );
2939
+ return sources.map((source, index) => {
2940
+ const {kind, transform, topology, parts, members, bound, facing} =
2941
+ source instanceof ModelObject
2942
+ ? source.exposedElement()
2943
+ : references[index];
2854
2944
  return transformElement(
2855
- {kind, transform: frame, topology, members: nested, bound, facing},
2856
- transform,
2945
+ {kind, transform, topology, parts, members, bound, facing},
2946
+ transforms[index],
2857
2947
  );
2858
2948
  });
2859
2949
  }
@@ -2901,7 +2991,11 @@ export class ModelObject<
2901
2991
  /** @internal */
2902
2992
  exposedElement(): StoredElement {
2903
2993
  if (this.kind === 'group')
2904
- return {...this.geometryAnchor, members: this.elements};
2994
+ return {
2995
+ ...this.geometryAnchor,
2996
+ parts: this.geometryParts(),
2997
+ members: this.elements,
2998
+ };
2905
2999
  const geometry = this.requireGeometry().value;
2906
3000
  const kind = (
2907
3001
  this.kind === 'face' ? 'surface' : this.kind
@@ -3183,7 +3277,7 @@ export class ModelObject<
3183
3277
  {
3184
3278
  shape: source.value.shape,
3185
3279
  topology: source.value.topology,
3186
- index: 1,
3280
+ namespace: 1,
3187
3281
  },
3188
3282
  direction,
3189
3283
  ),
@@ -3661,12 +3755,12 @@ export class ModelObject<
3661
3755
  {
3662
3756
  shape: geometry.value.shape,
3663
3757
  topology: geometry.value.topology,
3664
- index: otherIndex === 0 ? 1 : undefined,
3758
+ namespace: otherIndex === 0 ? 1 : 'intermediate',
3665
3759
  },
3666
3760
  {
3667
3761
  shape: operand.value,
3668
3762
  topology: otherGeometry.value.topology,
3669
- index: otherIndex + 2,
3763
+ namespace: otherIndex + 2,
3670
3764
  },
3671
3765
  operation,
3672
3766
  );
@@ -3720,6 +3814,230 @@ export class ModelObject<
3720
3814
  return members;
3721
3815
  }
3722
3816
 
3817
+ /** Finite members in this value's saved local assembly, without re-solving children. */
3818
+ private geometryParts(transform = identityRigidTransform): StoredTopology[] {
3819
+ if (this.geometry)
3820
+ return [{source: this, selection: {kind: 'solid'}, transform, scale: 1}];
3821
+ return this.children.flatMap(child =>
3822
+ child.geometryParts(
3823
+ composeTransforms(transform, child.solvePose(this.assembly!)),
3824
+ ),
3825
+ );
3826
+ }
3827
+
3828
+ /** @internal */
3829
+ static distance(a: Anchor, b: Anchor, axis?: DistanceAxis): number {
3830
+ const left = anchorReference(a),
3831
+ right = anchorReference(b);
3832
+ const axisReference =
3833
+ axis !== undefined && typeof axis !== 'string' && !Array.isArray(axis)
3834
+ ? straightAxisReference(axis as LineAnchor, 'distance() axis')
3835
+ : undefined;
3836
+ const context = ModelObject.createSolveContext([
3837
+ left.model,
3838
+ right.model,
3839
+ ...(axisReference ? [axisReference.model] : []),
3840
+ ]);
3841
+ const topologyParts = (reference: AnchorReference) =>
3842
+ reference.topology
3843
+ ? [reference.topology]
3844
+ : (reference.parts ??
3845
+ (reference.whole && reference.model instanceof ModelObject
3846
+ ? reference.model.geometryParts()
3847
+ : undefined));
3848
+ const parts = (reference: AnchorReference): DistancePart[] => {
3849
+ const pose = reference.model.solvePose(context);
3850
+ const frame = composeTransforms(pose, reference.transform);
3851
+ if (reference.kind === 'point') return [{points: [frame.position]}];
3852
+ const topology = topologyParts(reference);
3853
+ if (topology)
3854
+ return topology.map(part => ({
3855
+ geometry: part.source.requireGeometry(),
3856
+ selection: part.selection,
3857
+ scale: part.scale,
3858
+ transform: composeTransforms(pose, part.transform),
3859
+ }));
3860
+ if (reference.bound) {
3861
+ const [x, z] = reference.bound.size;
3862
+ const corners: Vec3[] =
3863
+ x === 0 && z === 0
3864
+ ? [origin]
3865
+ : x === 0
3866
+ ? [
3867
+ [0, 0, -z / 2],
3868
+ [0, 0, z / 2],
3869
+ ]
3870
+ : z === 0
3871
+ ? [
3872
+ [-x / 2, 0, 0],
3873
+ [x / 2, 0, 0],
3874
+ ]
3875
+ : [
3876
+ [-x / 2, 0, -z / 2],
3877
+ [x / 2, 0, -z / 2],
3878
+ [x / 2, 0, z / 2],
3879
+ [-x / 2, 0, z / 2],
3880
+ ];
3881
+ return [
3882
+ {
3883
+ points: corners.map(
3884
+ point => composeTransforms(frame, translation(point)).position,
3885
+ ),
3886
+ },
3887
+ ];
3888
+ }
3889
+ throw new Error(
3890
+ 'distance() requires finite geometry or a point; select an edge or face instead of an infinite axis or plane.',
3891
+ );
3892
+ };
3893
+ const first = parts(left),
3894
+ second = parts(right);
3895
+ if (!first.length || !second.length)
3896
+ throw new Error('distance() cannot measure an empty group.');
3897
+ const record = (result: DistanceResult, direction?: Vec3): number => {
3898
+ if (observeDistance) {
3899
+ const operands = [left, right].map(reference => {
3900
+ const topology = topologyParts(reference);
3901
+ const elements =
3902
+ topology && reference.kind !== 'point'
3903
+ ? topology.map(
3904
+ part =>
3905
+ snapshotElements({
3906
+ [reference.name]: {
3907
+ ...reference,
3908
+ topology: part,
3909
+ members: undefined,
3910
+ },
3911
+ })[0],
3912
+ )
3913
+ : [
3914
+ snapshotElements({
3915
+ [reference.name]: {...reference, members: undefined},
3916
+ })[0],
3917
+ ];
3918
+ return {
3919
+ nodeId: reference.model.nodeId,
3920
+ whole: reference.whole === true,
3921
+ elements,
3922
+ };
3923
+ });
3924
+ observeDistance(
3925
+ {
3926
+ ...result,
3927
+ axis: direction,
3928
+ axisName: typeof axis === 'string' ? axis : undefined,
3929
+ operands,
3930
+ placements: [...context.poses].map(([model, pose]) => ({
3931
+ nodeId: model.nodeId,
3932
+ transform: toTransform(pose),
3933
+ })),
3934
+ },
3935
+ [...context.poses.keys()],
3936
+ );
3937
+ }
3938
+ return result.value;
3939
+ };
3940
+ if (axis === undefined) {
3941
+ let result: DistanceResult | undefined;
3942
+ for (const a of first)
3943
+ for (const b of second) {
3944
+ const candidate =
3945
+ 'points' in a &&
3946
+ a.points.length === 1 &&
3947
+ 'points' in b &&
3948
+ b.points.length === 1
3949
+ ? {
3950
+ value: Math.hypot(
3951
+ ...a.points[0].map((value, i) => value - b.points[0][i]),
3952
+ ),
3953
+ start: a.points[0],
3954
+ end: b.points[0],
3955
+ }
3956
+ : distanceBetweenParts(a, b).value;
3957
+ if (!result || candidate.value < result.value) result = candidate;
3958
+ if (result.value === 0) return record(result);
3959
+ }
3960
+ return record(result!);
3961
+ }
3962
+ const direction = axisReference
3963
+ ? rotateVector(
3964
+ [0, 1, 0],
3965
+ composeTransforms(
3966
+ axisReference.model.solvePose(context),
3967
+ axisReference.transform,
3968
+ ).quaternion,
3969
+ )
3970
+ : typeof axis === 'string'
3971
+ ? distanceAxes[axis]
3972
+ : (axis as Vec3);
3973
+ assertFiniteVector('distance() axis', direction);
3974
+ const magnitude = Math.hypot(...direction);
3975
+ if (magnitude === 0) throw new Error('distance() axis must be non-zero.');
3976
+ const normalized = direction.map(
3977
+ value => value / magnitude,
3978
+ ) as unknown as Vec3;
3979
+ const project = invertTransform(frameFromYAxis(origin, normalized));
3980
+ const projectedBounds = (parts: DistancePart[]): LocalBounds => {
3981
+ const bounds = parts.map(part => {
3982
+ if ('points' in part)
3983
+ return pointBounds(
3984
+ part.points.map(
3985
+ point => composeTransforms(project, translation(point)).position,
3986
+ ),
3987
+ );
3988
+ return evaluateSnapshotQuery(
3989
+ boundsQuery(
3990
+ part.geometry,
3991
+ composeTransforms(project, part.transform),
3992
+ part.selection,
3993
+ part.scale,
3994
+ ),
3995
+ ) as LocalBounds;
3996
+ });
3997
+ return [
3998
+ [0, 1, 2].map(i =>
3999
+ Math.min(...bounds.map(value => value[0][i])),
4000
+ ) as unknown as Vec3,
4001
+ [0, 1, 2].map(i =>
4002
+ Math.max(...bounds.map(value => value[1][i])),
4003
+ ) as unknown as Vec3,
4004
+ ];
4005
+ };
4006
+ const aBounds = projectedBounds(first),
4007
+ bBounds = projectedBounds(second);
4008
+ const [aMin, aMax] = [aBounds[0][1], aBounds[1][1]],
4009
+ [bMin, bMax] = [bBounds[0][1], bBounds[1][1]];
4010
+ const overlap = (Math.max(aMin, bMin) + Math.min(aMax, bMax)) / 2;
4011
+ const [start, end] =
4012
+ bMin > aMax
4013
+ ? [aMax, bMin]
4014
+ : aMin > bMax
4015
+ ? [aMin, bMax]
4016
+ : [overlap, overlap];
4017
+ // Place the axis-parallel dimension within the shared transverse bounds.
4018
+ // A point inside the other operand's projection then anchors the line at
4019
+ // that point. Disjoint bounds use the midpoint of their nearest limits;
4020
+ // projected endpoints remain reference positions for general geometry.
4021
+ const center = [0, 1, 2].map(
4022
+ i =>
4023
+ (Math.max(aBounds[0][i], bBounds[0][i]) +
4024
+ Math.min(aBounds[1][i], bBounds[1][i])) /
4025
+ 2,
4026
+ );
4027
+ const unproject = invertTransform(project);
4028
+ const at = (y: number) =>
4029
+ composeTransforms(unproject, translation([center[0], y, center[2]]))
4030
+ .position;
4031
+ return record(
4032
+ {
4033
+ value: Math.max(0, bMin - aMax, aMin - bMax),
4034
+ start: at(start),
4035
+ end: at(end),
4036
+ },
4037
+ normalized,
4038
+ );
4039
+ }
4040
+
3723
4041
  /** Bounds of the selected finite geometry after a rigid transform. */
3724
4042
  private [referenceBoundsParts](
3725
4043
  reference: StoredAnchor,
@@ -3754,6 +4072,15 @@ export class ModelObject<
3754
4072
  ),
3755
4073
  ];
3756
4074
  }
4075
+ if (reference.parts)
4076
+ return reference.parts.map(part =>
4077
+ boundsQuery(
4078
+ part.source.requireGeometry(),
4079
+ composeTransforms(transform, part.transform),
4080
+ part.selection,
4081
+ part.scale,
4082
+ ),
4083
+ );
3757
4084
  return [{bounds: super[referenceBounds](reference, transform)}];
3758
4085
  }
3759
4086
 
@@ -3856,7 +4183,7 @@ export class ModelObject<
3856
4183
  }
3857
4184
 
3858
4185
  protected override edgeReference(id: EdgeId): RelationReference {
3859
- return {...rotationAxisReference(this.edge(id)), model: undefined};
4186
+ return {...straightAxisReference(this.edge(id)), model: undefined};
3860
4187
  }
3861
4188
 
3862
4189
  protected override vertexPosition(id: VertexId): Vec3 {
@@ -4572,6 +4899,16 @@ export function group(children: readonly Model[], name = 'Group'): GroupModel {
4572
4899
  }) as unknown as GroupModel;
4573
4900
  }
4574
4901
 
4902
+ /**
4903
+ * Measure finite models, topology, bounds or point references in their solved placement.
4904
+ * Without axis, returns the shortest geometric distance. With axis, returns the
4905
+ * gap between projected intervals (zero when they overlap). The result is a
4906
+ * non-negative number computed now; later relations do not update it.
4907
+ */
4908
+ export function distance(a: Anchor, b: Anchor, axis?: DistanceAxis): number {
4909
+ return ModelObject.distance(a, b, axis);
4910
+ }
4911
+
4575
4912
  export function union(operands: readonly SolidModel<{}>[]): SolidModel {
4576
4913
  const {first, others} = booleanOperands('union', operands);
4577
4914
  return first[combineModels]('fuse', others);
@@ -4755,6 +5092,106 @@ type SnapshotQueryInput = {
4755
5092
  query: SnapshotQuery;
4756
5093
  };
4757
5094
 
5095
+ type DistancePart =
5096
+ | Readonly<{points: readonly Vec3[]}>
5097
+ | Readonly<{
5098
+ geometry: ModelGeometry;
5099
+ selection: TopologySelection;
5100
+ transform: RigidTransform;
5101
+ scale: number;
5102
+ }>;
5103
+
5104
+ type DistanceResult = Readonly<{value: number; start: Vec3; end: Vec3}>;
5105
+
5106
+ const distanceAxes = {x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1]} as const;
5107
+
5108
+ function withDistanceShape<T>(
5109
+ part: DistancePart,
5110
+ visit: (shape: AnyShape) => T,
5111
+ ): T {
5112
+ if (!('points' in part))
5113
+ return withTransformedGeometry(part.geometry.value, part, visit);
5114
+ const points = part.points;
5115
+ if (points.length <= 2) {
5116
+ const shape =
5117
+ points.length === 1
5118
+ ? makeVertex(toPoint(points[0]))
5119
+ : makeLine(toPoint(points[0]), toPoint(points[1]));
5120
+ try {
5121
+ return visit(shape);
5122
+ } finally {
5123
+ shape.delete();
5124
+ }
5125
+ }
5126
+ const edges: ReplicadEdge[] = [];
5127
+ let wire: ReplicadWire | undefined;
5128
+ let face: ReplicadFace | undefined;
5129
+ try {
5130
+ points.forEach((point, index) =>
5131
+ edges.push(
5132
+ makeLine(toPoint(point), toPoint(points[(index + 1) % points.length])),
5133
+ ),
5134
+ );
5135
+ wire = assembleWire(edges);
5136
+ face = makeFace(wire);
5137
+ return visit(face);
5138
+ } finally {
5139
+ face?.delete();
5140
+ wire?.delete();
5141
+ edges.forEach(edge => edge.delete());
5142
+ }
5143
+ }
5144
+
5145
+ const distanceBetweenParts = cachedArtifact(
5146
+ (a: DistancePart, b: DistancePart): DistanceResult =>
5147
+ withDistanceShape(a, left =>
5148
+ withDistanceShape(b, right => {
5149
+ const query = new (getOC().BRepExtrema_DistShapeShape)();
5150
+ try {
5151
+ query.LoadS1(left.wrapped);
5152
+ query.LoadS2(right.wrapped);
5153
+ query.Perform();
5154
+ if (!query.IsDone())
5155
+ throw new Error('distance() could not measure these geometries.');
5156
+ const first = query.PointOnShape1(1),
5157
+ second = query.PointOnShape2(1);
5158
+ try {
5159
+ return {
5160
+ value: query.Value(),
5161
+ start: [first.X(), first.Y(), first.Z()],
5162
+ end: [second.X(), second.Y(), second.Z()],
5163
+ };
5164
+ } finally {
5165
+ first.delete();
5166
+ second.delete();
5167
+ }
5168
+ } finally {
5169
+ query.delete();
5170
+ }
5171
+ }),
5172
+ ),
5173
+ {
5174
+ key: (a, b) =>
5175
+ kernelOperationKey(
5176
+ 'distance-witnesses',
5177
+ [a, b].map(part =>
5178
+ 'points' in part
5179
+ ? ['points', part.points]
5180
+ : [
5181
+ 'shape',
5182
+ part.geometry.id,
5183
+ part.selection.kind,
5184
+ part.selection.kind === 'solid' ? null : part.selection.id,
5185
+ part.transform.position,
5186
+ part.transform.quaternion,
5187
+ part.scale,
5188
+ ],
5189
+ ),
5190
+ [a, b].flatMap(part => ('points' in part ? [] : [part.geometry])),
5191
+ ),
5192
+ },
5193
+ );
5194
+
4758
5195
  function boundsQuery(
4759
5196
  geometry: ModelGeometry,
4760
5197
  transform: RigidTransform,
@@ -5047,6 +5484,7 @@ export const authoringApi = Object.freeze({
5047
5484
  frustum,
5048
5485
  regularPrism,
5049
5486
  group,
5487
+ distance,
5050
5488
  union,
5051
5489
  cut,
5052
5490
  intersect,
@@ -5461,7 +5899,7 @@ function buildLoftGeometry(
5461
5899
  const inputs: {
5462
5900
  shape: ReplicadFace;
5463
5901
  topology: ShapeTopology;
5464
- index: number;
5902
+ namespace: number;
5465
5903
  }[] = [];
5466
5904
  let spineWire: ReplicadWire | undefined;
5467
5905
  try {
@@ -5472,7 +5910,7 @@ function buildLoftGeometry(
5472
5910
  section.transform,
5473
5911
  ),
5474
5912
  topology: section.geometry.value.topology,
5475
- index: index + 1,
5913
+ namespace: index + 1,
5476
5914
  });
5477
5915
  }
5478
5916
  if (spine) {
@@ -5571,6 +6009,19 @@ function computeTransformedBounds(
5571
6009
  geometry: SnapshotQueryInput['geometry'],
5572
6010
  query: Extract<SnapshotQuery, {kind: 'bounds'}>,
5573
6011
  ): LocalBounds {
6012
+ return withTransformedGeometry(geometry, query, shapeBounds);
6013
+ }
6014
+
6015
+ /** Borrow the input, and release all selected/transformed handles after the query. */
6016
+ function withTransformedGeometry<T>(
6017
+ geometry: SnapshotQueryInput['geometry'],
6018
+ query: Readonly<{
6019
+ transform: RigidTransform;
6020
+ selection: TopologySelection;
6021
+ scale: number;
6022
+ }>,
6023
+ visit: (shape: AnyShape) => T,
6024
+ ): T {
5574
6025
  const {transform, selection, scale} = query;
5575
6026
  return withTopologyShape(
5576
6027
  geometry.shape,
@@ -5581,7 +6032,7 @@ function computeTransformedBounds(
5581
6032
  try {
5582
6033
  const moved = shapeWithTransform(scaled, transform);
5583
6034
  try {
5584
- return shapeBounds(moved);
6035
+ return visit(moved);
5585
6036
  } finally {
5586
6037
  moved.delete();
5587
6038
  }
@@ -5717,6 +6168,10 @@ function transformElement(
5717
6168
  transform: composeTransforms(transform, element.topology.transform),
5718
6169
  }
5719
6170
  : undefined,
6171
+ parts: element.parts?.map(part => ({
6172
+ ...part,
6173
+ transform: composeTransforms(transform, part.transform),
6174
+ })),
5720
6175
  members: element.members
5721
6176
  ? Object.fromEntries(
5722
6177
  Object.entries(element.members).map(([name, member]) => [
@@ -5747,6 +6202,11 @@ function scaleElement(element: StoredElement, factor: number): StoredElement {
5747
6202
  transform: scaleFrame(element.topology.transform, factor),
5748
6203
  }
5749
6204
  : undefined,
6205
+ parts: element.parts?.map(part => ({
6206
+ ...part,
6207
+ scale: part.scale * factor,
6208
+ transform: scaleFrame(part.transform, factor),
6209
+ })),
5750
6210
  members: element.members
5751
6211
  ? scaleElements(element.members, factor)
5752
6212
  : undefined,