fluidcad 0.0.30 → 0.0.32
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.
- package/lib/dist/common/build-error.d.ts +13 -0
- package/lib/dist/common/build-error.js +18 -0
- package/lib/dist/common/describe-error.d.ts +6 -0
- package/lib/dist/common/describe-error.js +26 -0
- package/lib/dist/common/operand-check.d.ts +19 -0
- package/lib/dist/common/operand-check.js +38 -0
- package/lib/dist/common/scene-object.d.ts +8 -0
- package/lib/dist/common/scene-object.js +10 -0
- package/lib/dist/common/shape-factory.d.ts +1 -1
- package/lib/dist/core/2d/arc.d.ts +4 -2
- package/lib/dist/core/2d/hmove.d.ts +8 -1
- package/lib/dist/core/2d/hmove.js +6 -2
- package/lib/dist/core/2d/pmove.d.ts +13 -3
- package/lib/dist/core/2d/pmove.js +6 -2
- package/lib/dist/core/2d/vmove.d.ts +8 -1
- package/lib/dist/core/2d/vmove.js +8 -4
- package/lib/dist/core/extrude.d.ts +17 -4
- package/lib/dist/core/extrude.js +8 -6
- package/lib/dist/core/interfaces.d.ts +16 -6
- package/lib/dist/core/mirror.d.ts +5 -5
- package/lib/dist/features/2d/aline.js +6 -2
- package/lib/dist/features/2d/arc.d.ts +3 -0
- package/lib/dist/features/2d/arc.js +28 -1
- package/lib/dist/features/2d/hline.js +5 -1
- package/lib/dist/features/2d/hmove.d.ts +2 -2
- package/lib/dist/features/2d/hmove.js +32 -7
- package/lib/dist/features/2d/intersect.js +17 -10
- package/lib/dist/features/2d/line.d.ts +2 -0
- package/lib/dist/features/2d/line.js +4 -0
- package/lib/dist/features/2d/pmove.d.ts +2 -2
- package/lib/dist/features/2d/pmove.js +47 -7
- package/lib/dist/features/2d/projection.d.ts +1 -1
- package/lib/dist/features/2d/projection.js +25 -15
- package/lib/dist/features/2d/sketch.d.ts +2 -2
- package/lib/dist/features/2d/sketch.js +10 -4
- package/lib/dist/features/2d/tarc-to-point.js +0 -3
- package/lib/dist/features/2d/tarc.js +0 -3
- package/lib/dist/features/2d/tline.js +0 -3
- package/lib/dist/features/2d/vline.js +5 -1
- package/lib/dist/features/2d/vmove.d.ts +2 -2
- package/lib/dist/features/2d/vmove.js +32 -7
- package/lib/dist/features/axis-from-edge.d.ts +1 -0
- package/lib/dist/features/axis-from-edge.js +8 -0
- package/lib/dist/features/chamfer.d.ts +1 -0
- package/lib/dist/features/chamfer.js +6 -0
- package/lib/dist/features/color.d.ts +1 -0
- package/lib/dist/features/color.js +6 -0
- package/lib/dist/features/common.d.ts +1 -0
- package/lib/dist/features/common.js +9 -0
- package/lib/dist/features/common2d.d.ts +1 -0
- package/lib/dist/features/common2d.js +9 -0
- package/lib/dist/features/draft.d.ts +1 -0
- package/lib/dist/features/draft.js +6 -0
- package/lib/dist/features/extrude-to-face.d.ts +5 -1
- package/lib/dist/features/extrude-to-face.js +50 -8
- package/lib/dist/features/extrude.js +19 -28
- package/lib/dist/features/fillet.d.ts +1 -0
- package/lib/dist/features/fillet.js +6 -0
- package/lib/dist/features/fillet2d.d.ts +1 -0
- package/lib/dist/features/fillet2d.js +9 -0
- package/lib/dist/features/fuse.d.ts +1 -0
- package/lib/dist/features/fuse.js +6 -0
- package/lib/dist/features/fuse2d.d.ts +1 -0
- package/lib/dist/features/fuse2d.js +9 -0
- package/lib/dist/features/loft.d.ts +1 -0
- package/lib/dist/features/loft.js +6 -0
- package/lib/dist/features/mirror-shape.d.ts +1 -0
- package/lib/dist/features/mirror-shape.js +28 -8
- package/lib/dist/features/plane-from-object.d.ts +1 -0
- package/lib/dist/features/plane-from-object.js +8 -0
- package/lib/dist/features/rotate.d.ts +1 -0
- package/lib/dist/features/rotate.js +11 -2
- package/lib/dist/features/select.d.ts +1 -0
- package/lib/dist/features/select.js +40 -12
- package/lib/dist/features/shell.d.ts +1 -0
- package/lib/dist/features/shell.js +6 -0
- package/lib/dist/features/simple-extruder.js +6 -3
- package/lib/dist/features/subtract.d.ts +1 -0
- package/lib/dist/features/subtract.js +5 -0
- package/lib/dist/features/subtract2d.d.ts +1 -0
- package/lib/dist/features/subtract2d.js +5 -0
- package/lib/dist/features/sweep.d.ts +1 -0
- package/lib/dist/features/sweep.js +4 -0
- package/lib/dist/features/translate.d.ts +1 -0
- package/lib/dist/features/translate.js +9 -0
- package/lib/dist/filters/face/above-below.d.ts +20 -0
- package/lib/dist/filters/face/above-below.js +57 -0
- package/lib/dist/filters/face/face-filter.d.ts +26 -0
- package/lib/dist/filters/face/face-filter.js +64 -0
- package/lib/dist/filters/face/planar-filter.d.ts +15 -0
- package/lib/dist/filters/face/planar-filter.js +30 -0
- package/lib/dist/filters/from-object.d.ts +1 -0
- package/lib/dist/filters/from-object.js +3 -0
- package/lib/dist/oc/boolean-ops.d.ts +2 -2
- package/lib/dist/oc/boolean-ops.js +8 -3
- package/lib/dist/oc/edge-ops.d.ts +17 -0
- package/lib/dist/oc/edge-ops.js +60 -0
- package/lib/dist/oc/face-maker2.d.ts +8 -0
- package/lib/dist/oc/face-maker2.js +42 -1
- package/lib/dist/oc/face-ops.d.ts +6 -1
- package/lib/dist/oc/face-ops.js +3 -2
- package/lib/dist/oc/face-query.js +19 -15
- package/lib/dist/oc/ray-intersect.d.ts +3 -2
- package/lib/dist/oc/ray-intersect.js +2 -4
- package/lib/dist/oc/shell-ops.js +15 -2
- package/lib/dist/oc/thin-face-maker.d.ts +15 -0
- package/lib/dist/oc/thin-face-maker.js +48 -7
- package/lib/dist/oc/wire-ops.d.ts +14 -0
- package/lib/dist/oc/wire-ops.js +38 -0
- package/lib/dist/rendering/render.js +6 -4
- package/lib/dist/tests/common/describe-error.test.d.ts +1 -0
- package/lib/dist/tests/common/describe-error.test.js +36 -0
- package/lib/dist/tests/features/2d/intersect.test.js +43 -0
- package/lib/dist/tests/features/2d/move.test.js +72 -1
- package/lib/dist/tests/features/2d/project-regression.test.js +35 -0
- package/lib/dist/tests/features/color-lineage.test.js +24 -0
- package/lib/dist/tests/features/cut.test.js +40 -0
- package/lib/dist/tests/features/cylinder-curve-filter.test.d.ts +1 -0
- package/lib/dist/tests/features/cylinder-curve-filter.test.js +99 -0
- package/lib/dist/tests/features/extrude-to-face.test.js +52 -0
- package/lib/dist/tests/features/extrude.test.js +46 -8
- package/lib/dist/tests/features/mirror.test.js +74 -0
- package/lib/dist/tests/features/select.test.js +141 -0
- package/lib/dist/tests/features/subtract-consumed-input.test.d.ts +1 -0
- package/lib/dist/tests/features/subtract-consumed-input.test.js +28 -0
- package/lib/dist/tests/features/thin-extrude-offset-fix.test.d.ts +1 -0
- package/lib/dist/tests/features/thin-extrude-offset-fix.test.js +34 -0
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -3
- package/ui/dist/assets/{index-6Ep4GPxf.js → index-DMw0OYCF.js} +70 -70
- package/ui/dist/assets/index-DR7c2Qk9.css +2 -0
- package/ui/dist/index.html +2 -2
- package/lib/dist/features/infinite-extrude.d.ts +0 -13
- package/lib/dist/features/infinite-extrude.js +0 -79
- package/ui/dist/assets/index-DRKfe6N9.css +0 -2
|
@@ -178,28 +178,32 @@ export class FaceQuery {
|
|
|
178
178
|
const ocFace = oc.TopoDS.Face(face);
|
|
179
179
|
const faceAdaptor = new oc.BRepAdaptor_Surface(ocFace, true);
|
|
180
180
|
const type = faceAdaptor.GetType();
|
|
181
|
-
faceAdaptor.delete();
|
|
182
181
|
if (type !== oc.GeomAbs_SurfaceType.GeomAbs_Cylinder) {
|
|
182
|
+
faceAdaptor.delete();
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const cylinder = faceAdaptor.Cylinder();
|
|
186
|
+
const radius = cylinder.Radius();
|
|
187
|
+
cylinder.delete();
|
|
188
|
+
faceAdaptor.delete();
|
|
189
|
+
if (diameter !== undefined && Math.abs(radius - diameter / 2) > oc.Precision.Confusion()) {
|
|
183
190
|
return false;
|
|
184
191
|
}
|
|
192
|
+
// A "cylinder curve" is a partial cylinder: it does not wrap fully around its axis.
|
|
193
|
+
// A full cylinder has at least one closed circular edge (the rim); a fillet/partial
|
|
194
|
+
// cylinder does not. Bounding edges may be lines, arcs, ellipses, or B-splines
|
|
195
|
+
// depending on adjacent geometry (e.g. drafted faces produce ellipse boundaries).
|
|
185
196
|
const edges = Explorer.findShapes(ocFace, oc.TopAbs_ShapeEnum.TopAbs_EDGE);
|
|
186
197
|
for (const edge of edges) {
|
|
187
198
|
const curveAdaptor = new oc.BRepAdaptor_Curve(oc.TopoDS.Edge(edge));
|
|
188
199
|
const curveType = curveAdaptor.GetType();
|
|
189
|
-
|
|
190
|
-
if (diameter === undefined) {
|
|
191
|
-
curveAdaptor.delete();
|
|
192
|
-
return true;
|
|
193
|
-
}
|
|
194
|
-
const circle = curveAdaptor.Circle();
|
|
195
|
-
const r = circle.Radius();
|
|
196
|
-
circle.delete();
|
|
197
|
-
curveAdaptor.delete();
|
|
198
|
-
return Math.abs(r - diameter / 2) <= oc.Precision.Confusion();
|
|
199
|
-
}
|
|
200
|
+
const isClosedCircle = curveType === oc.GeomAbs_CurveType.GeomAbs_Circle && curveAdaptor.IsClosed();
|
|
200
201
|
curveAdaptor.delete();
|
|
202
|
+
if (isClosedCircle) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
201
205
|
}
|
|
202
|
-
return
|
|
206
|
+
return true;
|
|
203
207
|
}
|
|
204
208
|
static isTorusFaceRaw(face, majorRadius, minorRadius) {
|
|
205
209
|
const oc = getOC();
|
|
@@ -342,8 +346,8 @@ export class FaceQuery {
|
|
|
342
346
|
let bestFace = null;
|
|
343
347
|
let bestDistance = mode === 'first' ? Infinity : -Infinity;
|
|
344
348
|
for (const face of faces) {
|
|
345
|
-
const distance = plane.
|
|
346
|
-
if (distance
|
|
349
|
+
const distance = plane.signedDistanceToPoint(face.center());
|
|
350
|
+
if (distance <= tolerance) {
|
|
347
351
|
continue;
|
|
348
352
|
}
|
|
349
353
|
if (mode === 'first' ? distance < bestDistance : distance > bestDistance) {
|
|
@@ -11,6 +11,7 @@ import { SceneObject } from "../common/scene-object.js";
|
|
|
11
11
|
* `start` (within tolerance) are skipped so a start point already on the
|
|
12
12
|
* target is not picked.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Returns null when no intersection is found. Callers should throw a
|
|
15
|
+
* context-appropriate error.
|
|
15
16
|
*/
|
|
16
|
-
export declare function findNearestRayIntersection(plane: Plane, start: Point2D, direction: Point2D, target: SceneObject): Point2D;
|
|
17
|
+
export declare function findNearestRayIntersection(plane: Plane, start: Point2D, direction: Point2D, target: SceneObject): Point2D | null;
|
|
@@ -14,7 +14,8 @@ const ON_TARGET_EPSILON = 1e-7;
|
|
|
14
14
|
* `start` (within tolerance) are skipped so a start point already on the
|
|
15
15
|
* target is not picked.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Returns null when no intersection is found. Callers should throw a
|
|
18
|
+
* context-appropriate error.
|
|
18
19
|
*/
|
|
19
20
|
export function findNearestRayIntersection(plane, start, direction, target) {
|
|
20
21
|
const oc = getOC();
|
|
@@ -84,8 +85,5 @@ export function findNearestRayIntersection(plane, start, direction, target) {
|
|
|
84
85
|
}
|
|
85
86
|
probeAdaptor.delete();
|
|
86
87
|
probeEdge.delete();
|
|
87
|
-
if (!bestHit) {
|
|
88
|
-
throw new Error("Line does not intersect target geometry");
|
|
89
|
-
}
|
|
90
88
|
return bestHit;
|
|
91
89
|
}
|
package/lib/dist/oc/shell-ops.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getOC } from "./init.js";
|
|
2
2
|
import { ShapeOps } from "./shape-ops.js";
|
|
3
3
|
import { ShapeFactory } from "../common/shape-factory.js";
|
|
4
|
+
import { ColorTransfer } from "./color-transfer.js";
|
|
4
5
|
export class ShellOps {
|
|
5
6
|
static makeThickSolid(solid, faces, thickness) {
|
|
6
7
|
const oc = getOC();
|
|
@@ -17,9 +18,21 @@ export class ShellOps {
|
|
|
17
18
|
listOfFaces.delete();
|
|
18
19
|
throw new Error("Failed to create thick solid.");
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
// Wrap the maker output so we can transfer colors before disposing it.
|
|
22
|
+
// The user-painted outer faces are mapped through `Modified()`; new
|
|
23
|
+
// internal walls have no source and stay uncolored — bleeding is
|
|
24
|
+
// intentionally skipped so painting the outside doesn't paint the
|
|
25
|
+
// inside.
|
|
26
|
+
const preClean = ShapeFactory.fromShape(maker.Shape());
|
|
27
|
+
ColorTransfer.applyThroughMaker([solid], [preClean], maker);
|
|
21
28
|
maker.delete();
|
|
22
29
|
listOfFaces.delete();
|
|
23
|
-
|
|
30
|
+
// Chain colors through the UnifySameDomain cleanup so any merged faces
|
|
31
|
+
// keep the colors that `applyThroughMaker` just placed.
|
|
32
|
+
const cleanup = ShapeOps.cleanShapeWithLineage(preClean);
|
|
33
|
+
ColorTransfer.applyThroughCleanup(preClean, cleanup);
|
|
34
|
+
const cleaned = cleanup.shape;
|
|
35
|
+
cleanup.dispose();
|
|
36
|
+
return cleaned;
|
|
24
37
|
}
|
|
25
38
|
}
|
|
@@ -16,8 +16,23 @@ export declare class ThinFaceMaker {
|
|
|
16
16
|
* For closed wires, WireOps.offsetWire handles negative distances natively.
|
|
17
17
|
* For open wires, negative distances are handled by reversing the wire,
|
|
18
18
|
* offsetting with the absolute value, then reversing back.
|
|
19
|
+
*
|
|
20
|
+
* If the wire-only offset throws (e.g. "Offset wire is not closed." on
|
|
21
|
+
* wires whose corners are GeomAbs_OffsetCurve segments from `offset()` over
|
|
22
|
+
* a drafted body's filleted bottom), retries with a planar face as the
|
|
23
|
+
* offset spine — that path supplies an explicit normal which keeps the
|
|
24
|
+
* algorithm stable on the same input.
|
|
19
25
|
*/
|
|
20
26
|
private static doOffset;
|
|
27
|
+
/**
|
|
28
|
+
* Merges adjacent edges that share the same underlying curve into a single
|
|
29
|
+
* edge (e.g. two conic-arc segments at a filleted corner produced by
|
|
30
|
+
* `offset()` over the section of a drafted body's fillets). Without this,
|
|
31
|
+
* BRepOffsetAPI_MakeOffset chokes on such split arcs with
|
|
32
|
+
* "Offset wire is not closed." Falls back to the original wire if the
|
|
33
|
+
* upgrader produces no usable result.
|
|
34
|
+
*/
|
|
35
|
+
private static unifyWireEdges;
|
|
21
36
|
/**
|
|
22
37
|
* Offsets an open wire on a given plane, using a planar face as reference
|
|
23
38
|
* so that BRepOffsetAPI_MakeOffset knows the offset direction.
|
|
@@ -24,7 +24,13 @@ export class ThinFaceMaker {
|
|
|
24
24
|
const inwardEdges = [];
|
|
25
25
|
const outwardEdges = [];
|
|
26
26
|
for (const group of groups) {
|
|
27
|
-
const
|
|
27
|
+
const rawWire = WireOps.makeWireFromEdges(group);
|
|
28
|
+
// ShapeUpgrade_UnifySameDomain merges adjacent edges that share the
|
|
29
|
+
// same underlying curve into single edges. Without this,
|
|
30
|
+
// BRepOffsetAPI_MakeOffset can choke on wires whose corners are split
|
|
31
|
+
// into multiple same-curve segments (e.g. wires returned by `offset()`
|
|
32
|
+
// over a drafted body's filleted bottom).
|
|
33
|
+
const wire = this.unifyWireEdges(rawWire);
|
|
28
34
|
const isClosed = wire.isClosed();
|
|
29
35
|
if (offset2 !== undefined) {
|
|
30
36
|
const result = this.makeDualOffsetFace(wire, isClosed, plane, offset1, offset2);
|
|
@@ -101,17 +107,52 @@ export class ThinFaceMaker {
|
|
|
101
107
|
* For closed wires, WireOps.offsetWire handles negative distances natively.
|
|
102
108
|
* For open wires, negative distances are handled by reversing the wire,
|
|
103
109
|
* offsetting with the absolute value, then reversing back.
|
|
110
|
+
*
|
|
111
|
+
* If the wire-only offset throws (e.g. "Offset wire is not closed." on
|
|
112
|
+
* wires whose corners are GeomAbs_OffsetCurve segments from `offset()` over
|
|
113
|
+
* a drafted body's filleted bottom), retries with a planar face as the
|
|
114
|
+
* offset spine — that path supplies an explicit normal which keeps the
|
|
115
|
+
* algorithm stable on the same input.
|
|
104
116
|
*/
|
|
105
117
|
static doOffset(wire, plane, distance, isClosed) {
|
|
106
|
-
if (isClosed) {
|
|
118
|
+
if (!isClosed) {
|
|
119
|
+
if (distance < 0) {
|
|
120
|
+
const reversed = WireOps.reverseWire(wire);
|
|
121
|
+
const offsetResult = this.offsetWireOnPlane(reversed, plane, -distance, true);
|
|
122
|
+
return WireOps.reverseWire(offsetResult);
|
|
123
|
+
}
|
|
124
|
+
return this.offsetWireOnPlane(wire, plane, distance, true);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
107
127
|
return WireOps.offsetWire(wire, distance, false);
|
|
108
128
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
129
|
+
catch {
|
|
130
|
+
return this.offsetWireOnPlane(wire, plane, distance, false);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Merges adjacent edges that share the same underlying curve into a single
|
|
135
|
+
* edge (e.g. two conic-arc segments at a filleted corner produced by
|
|
136
|
+
* `offset()` over the section of a drafted body's fillets). Without this,
|
|
137
|
+
* BRepOffsetAPI_MakeOffset chokes on such split arcs with
|
|
138
|
+
* "Offset wire is not closed." Falls back to the original wire if the
|
|
139
|
+
* upgrader produces no usable result.
|
|
140
|
+
*/
|
|
141
|
+
static unifyWireEdges(wire) {
|
|
142
|
+
const oc = getOC();
|
|
143
|
+
const upgrader = new oc.ShapeUpgrade_UnifySameDomain(wire.getShape(), true, false, true);
|
|
144
|
+
upgrader.AllowInternalEdges(true);
|
|
145
|
+
upgrader.Build();
|
|
146
|
+
const result = upgrader.Shape();
|
|
147
|
+
upgrader.delete();
|
|
148
|
+
if (Explorer.isWire(result)) {
|
|
149
|
+
return Wire.fromTopoDSWire(oc.TopoDS.Wire(result));
|
|
150
|
+
}
|
|
151
|
+
const wires = Explorer.findShapes(result, oc.TopAbs_ShapeEnum.TopAbs_WIRE);
|
|
152
|
+
if (wires.length === 0) {
|
|
153
|
+
return wire;
|
|
113
154
|
}
|
|
114
|
-
return
|
|
155
|
+
return Wire.fromTopoDSWire(oc.TopoDS.Wire(wires[0]));
|
|
115
156
|
}
|
|
116
157
|
/**
|
|
117
158
|
* Offsets an open wire on a given plane, using a planar face as reference
|
|
@@ -2,6 +2,7 @@ import type { TopoDS_Edge, TopoDS_Wire, TopoDS_Face } from "occjs-wrapper";
|
|
|
2
2
|
import { Vector3d } from "../math/vector3d.js";
|
|
3
3
|
import { Wire } from "../common/wire.js";
|
|
4
4
|
import { Edge } from "../common/edge.js";
|
|
5
|
+
import { Vertex } from "../common/vertex.js";
|
|
5
6
|
import { Plane } from "../math/plane.js";
|
|
6
7
|
export declare class WireOps {
|
|
7
8
|
static isCW(wire: Wire, normal: Vector3d): boolean;
|
|
@@ -15,6 +16,19 @@ export declare class WireOps {
|
|
|
15
16
|
static buildWireRaw(edges: TopoDS_Edge[]): TopoDS_Wire;
|
|
16
17
|
static fixWire(wire: Wire, plane: Plane): Wire;
|
|
17
18
|
static fixWireRaw(wire: TopoDS_Wire, face: TopoDS_Face): TopoDS_Wire;
|
|
19
|
+
/**
|
|
20
|
+
* Returns the two chain-end vertices of a connected edge set, or a single
|
|
21
|
+
* vertex (start === end) for a closed loop. Returns null if branching is
|
|
22
|
+
* detected (more than two unique endpoints).
|
|
23
|
+
*
|
|
24
|
+
* "Chain end" is a vertex that appears in exactly one edge; interior junction
|
|
25
|
+
* vertices appear in two or more. Vertex equivalence is tolerance-based so
|
|
26
|
+
* boundary representations with separately-built endpoints still match.
|
|
27
|
+
*/
|
|
28
|
+
static findChainEndpoints(edges: Edge[]): {
|
|
29
|
+
start: Vertex;
|
|
30
|
+
end: Vertex;
|
|
31
|
+
} | null;
|
|
18
32
|
static groupConnectedEdges(edges: Edge[]): Edge[][];
|
|
19
33
|
private static verticesMatch;
|
|
20
34
|
static offsetWireRaw(wire: TopoDS_Wire, distance: number, isOpen: boolean): TopoDS_Wire;
|
package/lib/dist/oc/wire-ops.js
CHANGED
|
@@ -109,6 +109,44 @@ export class WireOps {
|
|
|
109
109
|
fixer.delete();
|
|
110
110
|
return oc.TopoDS.Wire(fixed);
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Returns the two chain-end vertices of a connected edge set, or a single
|
|
114
|
+
* vertex (start === end) for a closed loop. Returns null if branching is
|
|
115
|
+
* detected (more than two unique endpoints).
|
|
116
|
+
*
|
|
117
|
+
* "Chain end" is a vertex that appears in exactly one edge; interior junction
|
|
118
|
+
* vertices appear in two or more. Vertex equivalence is tolerance-based so
|
|
119
|
+
* boundary representations with separately-built endpoints still match.
|
|
120
|
+
*/
|
|
121
|
+
static findChainEndpoints(edges) {
|
|
122
|
+
if (edges.length === 0) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const entries = [];
|
|
126
|
+
const findOrAdd = (vertex) => {
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (WireOps.verticesMatch(entry.vertex, vertex)) {
|
|
129
|
+
return entry;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const created = { vertex, count: 0 };
|
|
133
|
+
entries.push(created);
|
|
134
|
+
return created;
|
|
135
|
+
};
|
|
136
|
+
for (const edge of edges) {
|
|
137
|
+
findOrAdd(edge.getFirstVertex()).count++;
|
|
138
|
+
findOrAdd(edge.getLastVertex()).count++;
|
|
139
|
+
}
|
|
140
|
+
const ends = entries.filter(e => e.count === 1);
|
|
141
|
+
if (ends.length === 0) {
|
|
142
|
+
const v = edges[0].getFirstVertex();
|
|
143
|
+
return { start: v, end: v };
|
|
144
|
+
}
|
|
145
|
+
if (ends.length === 2) {
|
|
146
|
+
return { start: ends[0].vertex, end: ends[1].vertex };
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
112
150
|
static groupConnectedEdges(edges) {
|
|
113
151
|
if (edges.length === 0) {
|
|
114
152
|
return [];
|
|
@@ -5,6 +5,7 @@ import { Sketch } from "../features/2d/sketch.js";
|
|
|
5
5
|
import { transformMeshes } from "./mesh-transform.js";
|
|
6
6
|
import { ShapeOps } from "../oc/shape-ops.js";
|
|
7
7
|
import { Profiler } from "../common/profiler.js";
|
|
8
|
+
import { describeError } from "../common/describe-error.js";
|
|
8
9
|
export class SceneRenderer {
|
|
9
10
|
meshBuilder = new MeshBuilder();
|
|
10
11
|
render(scene) {
|
|
@@ -103,8 +104,8 @@ export class SceneRenderer {
|
|
|
103
104
|
return { renderedSceneShapes, ownShapeCount: sceneShapes.length };
|
|
104
105
|
}
|
|
105
106
|
catch (error) {
|
|
106
|
-
const message =
|
|
107
|
-
console.error(`Error rendering object ${obj.getUniqueType()}:`,
|
|
107
|
+
const message = describeError(error);
|
|
108
|
+
console.error(`Error rendering object ${obj.getUniqueType()}:`, message);
|
|
108
109
|
return { renderedSceneShapes, ownShapeCount: renderedSceneShapes.length, prepError: message };
|
|
109
110
|
}
|
|
110
111
|
}
|
|
@@ -135,6 +136,7 @@ export class SceneRenderer {
|
|
|
135
136
|
const start = performance.now();
|
|
136
137
|
const profiler = new Profiler();
|
|
137
138
|
try {
|
|
139
|
+
object.validate();
|
|
138
140
|
object.build({
|
|
139
141
|
getSceneObjects: () => scene.getPartScopedObjectsUpTo(object),
|
|
140
142
|
getActiveSceneObjects: () => scene.getPartScopedActiveObjectsUpTo(object),
|
|
@@ -161,8 +163,8 @@ export class SceneRenderer {
|
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
165
|
catch (error) {
|
|
164
|
-
const message =
|
|
165
|
-
console.error(`Error building object ${object.getUniqueType()}:`,
|
|
166
|
+
const message = describeError(error);
|
|
167
|
+
console.error(`Error building object ${object.getUniqueType()}:`, message);
|
|
166
168
|
object.setError(message);
|
|
167
169
|
}
|
|
168
170
|
const totalMs = performance.now() - start;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { setupOC } from "../setup.js";
|
|
3
|
+
import { describeError } from "../../common/describe-error.js";
|
|
4
|
+
import { getOC } from "../../oc/init.js";
|
|
5
|
+
describe("describeError", () => {
|
|
6
|
+
setupOC();
|
|
7
|
+
it("returns Error.message for Error instances", () => {
|
|
8
|
+
expect(describeError(new Error("boom"))).toBe("boom");
|
|
9
|
+
});
|
|
10
|
+
it("falls back to String() for non-numeric, non-Error values", () => {
|
|
11
|
+
expect(describeError("plain string")).toBe("plain string");
|
|
12
|
+
expect(describeError(undefined)).toBe("undefined");
|
|
13
|
+
expect(describeError(null)).toBe("null");
|
|
14
|
+
});
|
|
15
|
+
it("decodes a numeric OCC exception pointer to its message string", () => {
|
|
16
|
+
const oc = getOC();
|
|
17
|
+
// Trigger a real OCC throw (BRepBuilderAPI_MakeOffset on an unsupported
|
|
18
|
+
// shape) and capture the numeric pointer.
|
|
19
|
+
let caught = null;
|
|
20
|
+
try {
|
|
21
|
+
const maker = new oc.BRepOffsetAPI_MakeOffset();
|
|
22
|
+
maker.Perform(1, 0); // no wire added — should throw
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
caught = e;
|
|
26
|
+
}
|
|
27
|
+
// If OCC throws, it comes through as a number; otherwise the test setup
|
|
28
|
+
// didn't trigger it — skip in that case.
|
|
29
|
+
if (typeof caught !== 'number') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const decoded = describeError(caught);
|
|
33
|
+
expect(decoded.startsWith("OCC")).toBe(true);
|
|
34
|
+
expect(decoded).not.toBe(String(caught));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import { setupOC, render } from "../../setup.js";
|
|
3
3
|
import sketch from "../../../core/sketch.js";
|
|
4
4
|
import extrude from "../../../core/extrude.js";
|
|
5
|
+
import shell from "../../../core/shell.js";
|
|
5
6
|
import { intersect, rect } from "../../../core/2d/index.js";
|
|
7
|
+
import { Edge } from "../../../common/edge.js";
|
|
6
8
|
describe("intersect", () => {
|
|
7
9
|
setupOC();
|
|
8
10
|
describe("intersect 3D shape with sketch plane", () => {
|
|
@@ -18,5 +20,46 @@ describe("intersect", () => {
|
|
|
18
20
|
const shapes = s.getShapes();
|
|
19
21
|
expect(shapes.length).toBeGreaterThan(0);
|
|
20
22
|
});
|
|
23
|
+
it("start() should be at a chain endpoint, not at an interior junction", () => {
|
|
24
|
+
// Regression: when the section produces multiple edges (one per face),
|
|
25
|
+
// start/end were taken from an arbitrary "last edge". For a closed loop
|
|
26
|
+
// or branching chain that left start at an interior junction vertex
|
|
27
|
+
// instead of a real chain endpoint.
|
|
28
|
+
sketch("xy", () => {
|
|
29
|
+
rect(100, 50).centered().radius(8);
|
|
30
|
+
});
|
|
31
|
+
const e = extrude(20);
|
|
32
|
+
const s = shell(-2, e.endFaces());
|
|
33
|
+
let intersectFeature = null;
|
|
34
|
+
sketch("right", () => {
|
|
35
|
+
intersectFeature = intersect(s.internalFaces());
|
|
36
|
+
});
|
|
37
|
+
render();
|
|
38
|
+
// The plane-local start vertex must coincide with one of the section's
|
|
39
|
+
// edge endpoints. An interior junction would coincide with two edges,
|
|
40
|
+
// so we additionally assert the count of edges meeting at that point.
|
|
41
|
+
const start = intersectFeature.getState('start');
|
|
42
|
+
const end = intersectFeature.getState('end');
|
|
43
|
+
expect(start).toBeDefined();
|
|
44
|
+
expect(end).toBeDefined();
|
|
45
|
+
const edges = intersectFeature.getShapes().filter(s => s instanceof Edge);
|
|
46
|
+
expect(edges.length).toBeGreaterThan(0);
|
|
47
|
+
const plane = intersectFeature.getPlane();
|
|
48
|
+
const TOL_SQ = 1e-8;
|
|
49
|
+
let matchCount = 0;
|
|
50
|
+
const startPoint = start.toPoint2D();
|
|
51
|
+
for (const edge of edges) {
|
|
52
|
+
const v1 = plane.worldToLocal(edge.getFirstVertex().toPoint());
|
|
53
|
+
const v2 = plane.worldToLocal(edge.getLastVertex().toPoint());
|
|
54
|
+
const d1 = (v1.x - startPoint.x) ** 2 + (v1.y - startPoint.y) ** 2;
|
|
55
|
+
const d2 = (v2.x - startPoint.x) ** 2 + (v2.y - startPoint.y) ** 2;
|
|
56
|
+
if (d1 < TOL_SQ || d2 < TOL_SQ) {
|
|
57
|
+
matchCount++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// start must lie on at least one edge endpoint (so it's a real corner,
|
|
61
|
+
// not a constructed midpoint).
|
|
62
|
+
expect(matchCount).toBeGreaterThan(0);
|
|
63
|
+
});
|
|
21
64
|
});
|
|
22
65
|
});
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import { setupOC, render } from "../../setup.js";
|
|
3
3
|
import sketch from "../../../core/sketch.js";
|
|
4
4
|
import extrude from "../../../core/extrude.js";
|
|
5
|
-
import { move, hMove, vMove, rect, circle } from "../../../core/2d/index.js";
|
|
5
|
+
import { move, hMove, vMove, pMove, hLine, rect, circle } from "../../../core/2d/index.js";
|
|
6
6
|
import { ShapeOps } from "../../../oc/shape-ops.js";
|
|
7
7
|
describe("move functions", () => {
|
|
8
8
|
setupOC();
|
|
@@ -57,6 +57,77 @@ describe("move functions", () => {
|
|
|
57
57
|
expect(bbox.minY).toBeCloseTo(50, 0);
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
|
+
describe("hMove to target geometry", () => {
|
|
61
|
+
it("should move cursor to nearest intersection with a circle", () => {
|
|
62
|
+
sketch("xy", () => {
|
|
63
|
+
const c = circle([100, 0], 50).guide();
|
|
64
|
+
move([0, 0]);
|
|
65
|
+
hMove(c);
|
|
66
|
+
rect(10, 10);
|
|
67
|
+
});
|
|
68
|
+
const e = extrude(5);
|
|
69
|
+
render();
|
|
70
|
+
const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
|
|
71
|
+
expect(bbox.minX).toBeCloseTo(75, 1);
|
|
72
|
+
expect(bbox.minY).toBeCloseTo(0, 1);
|
|
73
|
+
});
|
|
74
|
+
it("should pick nearest intersection when target is behind start", () => {
|
|
75
|
+
sketch("xy", () => {
|
|
76
|
+
const c = circle([-100, 0], 50).guide();
|
|
77
|
+
move([0, 0]);
|
|
78
|
+
hMove(c);
|
|
79
|
+
rect(10, 10);
|
|
80
|
+
});
|
|
81
|
+
const e = extrude(5);
|
|
82
|
+
render();
|
|
83
|
+
const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
|
|
84
|
+
expect(bbox.minX).toBeCloseTo(-75, 1);
|
|
85
|
+
expect(bbox.minY).toBeCloseTo(0, 1);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("vMove to target geometry", () => {
|
|
89
|
+
it("should move cursor to nearest intersection with a circle above", () => {
|
|
90
|
+
sketch("xy", () => {
|
|
91
|
+
const c = circle([0, 100], 50).guide();
|
|
92
|
+
move([0, 0]);
|
|
93
|
+
vMove(c);
|
|
94
|
+
rect(10, 10);
|
|
95
|
+
});
|
|
96
|
+
const e = extrude(5);
|
|
97
|
+
render();
|
|
98
|
+
const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
|
|
99
|
+
expect(bbox.minX).toBeCloseTo(0, 1);
|
|
100
|
+
expect(bbox.minY).toBeCloseTo(75, 1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("pMove to target geometry", () => {
|
|
104
|
+
it("should move cursor along angle to nearest intersection", () => {
|
|
105
|
+
sketch("xy", () => {
|
|
106
|
+
const h = hLine([-100, 50], 200).guide();
|
|
107
|
+
move([0, 0]);
|
|
108
|
+
pMove(h, 90);
|
|
109
|
+
rect(10, 10);
|
|
110
|
+
});
|
|
111
|
+
const e = extrude(5);
|
|
112
|
+
render();
|
|
113
|
+
const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
|
|
114
|
+
expect(bbox.minX).toBeCloseTo(0, 1);
|
|
115
|
+
expect(bbox.minY).toBeCloseTo(50, 1);
|
|
116
|
+
});
|
|
117
|
+
it("should support 45° intersection with a horizontal line", () => {
|
|
118
|
+
sketch("xy", () => {
|
|
119
|
+
const h = hLine([-100, 50], 200).guide();
|
|
120
|
+
move([0, 0]);
|
|
121
|
+
pMove(h, 45);
|
|
122
|
+
rect(10, 10);
|
|
123
|
+
});
|
|
124
|
+
const e = extrude(5);
|
|
125
|
+
render();
|
|
126
|
+
const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
|
|
127
|
+
expect(bbox.minX).toBeCloseTo(50, 1);
|
|
128
|
+
expect(bbox.minY).toBeCloseTo(50, 1);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
60
131
|
describe("combined moves", () => {
|
|
61
132
|
it("should chain horizontal and vertical moves", () => {
|
|
62
133
|
sketch("xy", () => {
|
|
@@ -6,6 +6,19 @@ import cylinder from "../../../core/cylinder.js";
|
|
|
6
6
|
import { project, rect, circle } from "../../../core/2d/index.js";
|
|
7
7
|
import { Edge } from "../../../common/edge.js";
|
|
8
8
|
import { EdgeOps } from "../../../oc/edge-ops.js";
|
|
9
|
+
// Asserts no two projected edges share a midpoint within tolerance — a strong
|
|
10
|
+
// signal that overlap-dedup ran and removed coincident projections.
|
|
11
|
+
function assertNoDuplicateEdges(shapes, tol = 1e-5) {
|
|
12
|
+
const mids = shapes
|
|
13
|
+
.filter((s) => s instanceof Edge)
|
|
14
|
+
.map(e => EdgeOps.getEdgeMidPoint(e));
|
|
15
|
+
for (let i = 0; i < mids.length; i++) {
|
|
16
|
+
for (let j = i + 1; j < mids.length; j++) {
|
|
17
|
+
const d = mids[i].distanceTo(mids[j]);
|
|
18
|
+
expect(d, `edges ${i} and ${j} share a midpoint (distance ${d})`).toBeGreaterThan(tol);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
9
22
|
describe("project — regression: all projected edges land on sketch plane", () => {
|
|
10
23
|
setupOC();
|
|
11
24
|
it("projects endFaces of a plain extrude onto z=0 (the sketch plane)", () => {
|
|
@@ -26,6 +39,11 @@ describe("project — regression: all projected edges land on sketch plane", ()
|
|
|
26
39
|
expect(mid.z).toBeCloseTo(0, 4);
|
|
27
40
|
}
|
|
28
41
|
}
|
|
42
|
+
// endFaces() returns top + bottom (both rectangles); they project to the
|
|
43
|
+
// same 4-edge rectangle. Dedup must collapse 8 edges down to 4.
|
|
44
|
+
const edges = shapes.filter(s => s instanceof Edge);
|
|
45
|
+
expect(edges.length).toBe(4);
|
|
46
|
+
assertNoDuplicateEdges(shapes);
|
|
29
47
|
});
|
|
30
48
|
it("projects endFaces of an extrude fused with a cylinder onto z=0", () => {
|
|
31
49
|
// Mirrors the user's scenario: complex shape built by fusing an extrude
|
|
@@ -47,6 +65,7 @@ describe("project — regression: all projected edges land on sketch plane", ()
|
|
|
47
65
|
expect(mid.z).toBeCloseTo(0, 4);
|
|
48
66
|
}
|
|
49
67
|
}
|
|
68
|
+
assertNoDuplicateEdges(shapes);
|
|
50
69
|
});
|
|
51
70
|
it("projects sideFaces including a cylindrical one onto z=0", () => {
|
|
52
71
|
sketch("xy", () => {
|
|
@@ -65,5 +84,21 @@ describe("project — regression: all projected edges land on sketch plane", ()
|
|
|
65
84
|
expect(mid.z).toBeCloseTo(0, 4);
|
|
66
85
|
}
|
|
67
86
|
}
|
|
87
|
+
assertNoDuplicateEdges(shapes);
|
|
88
|
+
});
|
|
89
|
+
it("dedupes when the same source is projected twice", () => {
|
|
90
|
+
sketch("xy", () => {
|
|
91
|
+
rect(100, 50);
|
|
92
|
+
});
|
|
93
|
+
const e = extrude(30);
|
|
94
|
+
const ef = e.endFaces();
|
|
95
|
+
const s = sketch("xy", () => {
|
|
96
|
+
project(ef, ef);
|
|
97
|
+
});
|
|
98
|
+
render();
|
|
99
|
+
// Two endFaces × projected twice = 16 raw edges → 4 unique after dedup.
|
|
100
|
+
const edges = s.getShapes().filter(x => x instanceof Edge);
|
|
101
|
+
expect(edges.length).toBe(4);
|
|
102
|
+
assertNoDuplicateEdges(s.getShapes());
|
|
68
103
|
});
|
|
69
104
|
});
|
|
@@ -7,6 +7,7 @@ import select from "../../core/select.js";
|
|
|
7
7
|
import fillet from "../../core/fillet.js";
|
|
8
8
|
import chamfer from "../../core/chamfer.js";
|
|
9
9
|
import fuse from "../../core/fuse.js";
|
|
10
|
+
import shell from "../../core/shell.js";
|
|
10
11
|
import { circle, rect } from "../../core/2d/index.js";
|
|
11
12
|
import { face } from "../../filters/index.js";
|
|
12
13
|
function hasRed(solid) {
|
|
@@ -210,4 +211,27 @@ describe("color preservation through operations (Phase 3 lineage)", () => {
|
|
|
210
211
|
// another input was red.
|
|
211
212
|
expect(result.colorMap).toEqual([]);
|
|
212
213
|
});
|
|
214
|
+
it("shell preserves colored side faces and does not paint the new internal walls", () => {
|
|
215
|
+
sketch("xy", () => {
|
|
216
|
+
rect(100, 50).centered().radius(8);
|
|
217
|
+
});
|
|
218
|
+
const e = extrude(20);
|
|
219
|
+
color("orange", e.sideFaces());
|
|
220
|
+
const sh = shell(-2, e.endFaces());
|
|
221
|
+
render();
|
|
222
|
+
const result = sh.getShapes()[0];
|
|
223
|
+
expect(result).toBeDefined();
|
|
224
|
+
// The original side faces of the extrusion were orange. After shell, the
|
|
225
|
+
// outer side walls should still be orange (history-mapped through
|
|
226
|
+
// BRepOffsetAPI_MakeThickSolid). Internal walls are new geometry — the
|
|
227
|
+
// user only colored the outside, so they must NOT be painted.
|
|
228
|
+
const orangeFaces = result.colorMap.filter(e => e.color === '#ffa500');
|
|
229
|
+
expect(orangeFaces.length).toBeGreaterThan(0);
|
|
230
|
+
// None of the shell's internal faces should carry the orange color.
|
|
231
|
+
const internalFaces = sh.getState('internal-faces') || [];
|
|
232
|
+
expect(internalFaces.length).toBeGreaterThan(0);
|
|
233
|
+
for (const f of internalFaces) {
|
|
234
|
+
expect(result.getColor(f.getShape())).toBeUndefined();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
213
237
|
});
|
|
@@ -78,6 +78,46 @@ describe("cut", () => {
|
|
|
78
78
|
// Circle edges at top and bottom of the hole
|
|
79
79
|
expect(getEdgesByType(solid, "circle").length).toBeGreaterThanOrEqual(2);
|
|
80
80
|
});
|
|
81
|
+
it("should apply draft to a through-all cut", () => {
|
|
82
|
+
sketch("xy", () => {
|
|
83
|
+
rect(100, 100);
|
|
84
|
+
});
|
|
85
|
+
const e = extrude(50);
|
|
86
|
+
sketch(e.endFaces(), () => {
|
|
87
|
+
move([50, 50]);
|
|
88
|
+
circle(40);
|
|
89
|
+
});
|
|
90
|
+
cut().draft(-5);
|
|
91
|
+
const scene = render();
|
|
92
|
+
expect(countShapes(scene)).toBe(1);
|
|
93
|
+
const solid = scene.getAllSceneObjects()
|
|
94
|
+
.flatMap(o => o.getShapes())
|
|
95
|
+
.find(s => s.getType() === "solid");
|
|
96
|
+
// Drafted through-all cut: hole wall is a cone, not a cylinder
|
|
97
|
+
expect(getFacesByType(solid, "cone").length).toBeGreaterThanOrEqual(1);
|
|
98
|
+
expect(getFacesByType(solid, "cylinder")).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
it("should apply draft to a through-all cut on a small profile", () => {
|
|
101
|
+
// Mirrors the user's repro: small radius (1.5) and steep draft (-8°).
|
|
102
|
+
// With THROUGH_ALL_LENGTH=100000, lateral draft = 100000 * tan(8°) ≈ 14054
|
|
103
|
+
// would invert a 1.5-radius profile if applied over the full prism length.
|
|
104
|
+
sketch("xy", () => {
|
|
105
|
+
rect(7, 5).centered();
|
|
106
|
+
});
|
|
107
|
+
const e = extrude(1.5);
|
|
108
|
+
sketch(e.endFaces(), () => {
|
|
109
|
+
circle(1.5);
|
|
110
|
+
});
|
|
111
|
+
cut().draft(-8);
|
|
112
|
+
const scene = render();
|
|
113
|
+
expect(countShapes(scene)).toBe(1);
|
|
114
|
+
const solid = scene.getAllSceneObjects()
|
|
115
|
+
.flatMap(o => o.getShapes())
|
|
116
|
+
.find(s => s.getType() === "solid");
|
|
117
|
+
// Drafted through-all cut: hole wall is a cone, not a cylinder
|
|
118
|
+
expect(getFacesByType(solid, "cone").length).toBeGreaterThanOrEqual(1);
|
|
119
|
+
expect(getFacesByType(solid, "cylinder")).toHaveLength(0);
|
|
120
|
+
});
|
|
81
121
|
});
|
|
82
122
|
describe("section edges", () => {
|
|
83
123
|
it("should expose section edges", () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|