@metamesh/modeling 0.0.0-stage → 0.6.0
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/CHANGELOG.md +105 -0
- package/LICENSE +200 -0
- package/README.md +314 -2
- package/THIRD_PARTY_NOTICES.md +153 -0
- package/dist/api/engine.d.ts +54 -0
- package/dist/api/geometry.d.ts +2912 -0
- package/dist/api/index.d.ts +1648 -0
- package/dist/api/text.d.ts +77 -0
- package/dist/engine/construction-operations.js +189 -0
- package/dist/engine/execution.js +227 -0
- package/dist/engine/export-3mf.js +100 -0
- package/dist/engine/extrusion-finishing.js +82 -0
- package/dist/engine/finalization.js +214 -0
- package/dist/engine/index.js +72 -0
- package/dist/engine/inspection-operations.js +52 -0
- package/dist/engine/kernel/LICENSE +201 -0
- package/dist/engine/kernel/manifold.js +2 -0
- package/dist/engine/kernel/manifold.wasm +0 -0
- package/dist/engine/loft-operations.js +156 -0
- package/dist/engine/mechanical-operations.js +51 -0
- package/dist/engine/modeling-context.js +38 -0
- package/dist/engine/native/adapter.js +20 -0
- package/dist/engine/native/context.js +209 -0
- package/dist/engine/native/copied-views.js +46 -0
- package/dist/engine/native/index.js +13 -0
- package/dist/engine/native/operations.js +115 -0
- package/dist/engine/native/ownership.js +98 -0
- package/dist/engine/native/regions.js +138 -0
- package/dist/engine/native/scoped-primitives.js +127 -0
- package/dist/engine/native/scoped-threads.js +34 -0
- package/dist/engine/native/threads.js +52 -0
- package/dist/engine/native/transforms.js +111 -0
- package/dist/engine/operation-environment.js +20 -0
- package/dist/engine/pattern-operations.js +36 -0
- package/dist/engine/pattern-placements.js +176 -0
- package/dist/engine/primitive-anchors.js +70 -0
- package/dist/engine/primitive-correspondence.js +74 -0
- package/dist/engine/primitive-operations.js +151 -0
- package/dist/engine/primitive-profile-types.js +1 -0
- package/dist/engine/primitive-profiles.js +250 -0
- package/dist/engine/profile-mesh.js +279 -0
- package/dist/engine/section-operations.js +93 -0
- package/dist/engine/shape-anchors.js +88 -0
- package/dist/engine/transform-operations.js +225 -0
- package/dist/engine/types.js +1 -0
- package/dist/geometry/anchors.js +226 -0
- package/dist/geometry/curves/evaluation.js +162 -0
- package/dist/geometry/curves/sampling.js +265 -0
- package/dist/geometry/curves/types.js +1 -0
- package/dist/geometry/cylinder.js +117 -0
- package/dist/geometry/edge-finishing.js +160 -0
- package/dist/geometry/errors.js +76 -0
- package/dist/geometry/extrusion.js +150 -0
- package/dist/geometry/heightfield.js +107 -0
- package/dist/geometry/index.js +42 -0
- package/dist/geometry/loft-correspondence.js +204 -0
- package/dist/geometry/loft-geometry.js +184 -0
- package/dist/geometry/loft-options.js +56 -0
- package/dist/geometry/loft-simple-mesh.js +92 -0
- package/dist/geometry/loft-triangulation.js +64 -0
- package/dist/geometry/loft.js +80 -0
- package/dist/geometry/math/vector3.js +41 -0
- package/dist/geometry/mechanical/gear-motion.js +94 -0
- package/dist/geometry/mechanical/gears.js +484 -0
- package/dist/geometry/mechanical/index.js +13 -0
- package/dist/geometry/mechanical/standards.js +67 -0
- package/dist/geometry/mechanical/thread-helices.js +44 -0
- package/dist/geometry/mechanical/thread-mesh-plan.js +358 -0
- package/dist/geometry/mechanical/thread-mesh.js +142 -0
- package/dist/geometry/mechanical/thread-profiles.js +368 -0
- package/dist/geometry/mechanical/thread-solids.js +230 -0
- package/dist/geometry/mechanical/thread-surface.js +433 -0
- package/dist/geometry/mechanical/threads.js +138 -0
- package/dist/geometry/mesh-planar.js +408 -0
- package/dist/geometry/mesh-quality.js +396 -0
- package/dist/geometry/mesh-signature.js +137 -0
- package/dist/geometry/mesh.js +300 -0
- package/dist/geometry/model-limits.js +50 -0
- package/dist/geometry/numeric.js +11 -0
- package/dist/geometry/paths/frames.js +118 -0
- package/dist/geometry/paths/helix.js +49 -0
- package/dist/geometry/paths/path.js +30 -0
- package/dist/geometry/paths/planar.js +135 -0
- package/dist/geometry/paths/resampling.js +124 -0
- package/dist/geometry/polygon-clipping.js +52 -0
- package/dist/geometry/primitive-options.js +1 -0
- package/dist/geometry/primitive-resolution.js +17 -0
- package/dist/geometry/primitive-resolvers.js +235 -0
- package/dist/geometry/profiles/matched.js +184 -0
- package/dist/geometry/profiles/open-profile.js +137 -0
- package/dist/geometry/profiles/profiles.js +70 -0
- package/dist/geometry/profiles/rectangle.js +235 -0
- package/dist/geometry/profiles/shapes.js +34 -0
- package/dist/geometry/profiles/span-sampling.js +28 -0
- package/dist/geometry/profiles/types.js +1 -0
- package/dist/geometry/profiles/validation.js +20 -0
- package/dist/geometry/regions.js +371 -0
- package/dist/geometry/resolution.js +236 -0
- package/dist/geometry/revolve/revolve-mesh.js +123 -0
- package/dist/geometry/sampling-limits.js +29 -0
- package/dist/geometry/stroke-path.js +181 -0
- package/dist/geometry/svg/errors.js +23 -0
- package/dist/geometry/svg/fill.js +172 -0
- package/dist/geometry/svg/index.js +1 -0
- package/dist/geometry/svg/matrix.js +130 -0
- package/dist/geometry/svg/parser.js +536 -0
- package/dist/geometry/svg/path.js +349 -0
- package/dist/geometry/svg/types.js +1 -0
- package/dist/geometry/svg-import.js +662 -0
- package/dist/geometry/sweeps/options.js +28 -0
- package/dist/geometry/sweeps/orientation.js +106 -0
- package/dist/geometry/sweeps/stations.js +161 -0
- package/dist/geometry/sweeps/sweep-closed.js +96 -0
- package/dist/geometry/sweeps/sweep-mesh.js +20 -0
- package/dist/geometry/sweeps/sweep-open.js +76 -0
- package/dist/geometry/sweeps/validation.js +116 -0
- package/dist/geometry/text-contours.js +32 -0
- package/dist/geometry/text-layout.js +1 -0
- package/dist/geometry/tolerance.js +53 -0
- package/dist/geometry/transform-geometry.js +41 -0
- package/dist/geometry/transform3.js +340 -0
- package/dist/geometry/types.js +1 -0
- package/dist/index.js +1 -0
- package/dist/model/execution-binding.js +30 -0
- package/dist/model/operations.js +152 -0
- package/dist/model/options.js +1 -0
- package/dist/model/types.js +1 -0
- package/dist/text/index.js +48 -0
- package/dist/text/vector-text-layout.js +279 -0
- package/package.json +100 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { GeometryError } from "../errors.js";
|
|
2
|
+
import { length3, normalize3, subtract3 } from "../math/vector3.js";
|
|
3
|
+
import { assertSamplingCount, resolveGeometrySamplingLimits, } from "../sampling-limits.js";
|
|
4
|
+
const requireFinitePathValues = (values, operation) => {
|
|
5
|
+
if (!values.every(Number.isFinite)) {
|
|
6
|
+
throw new GeometryError("NON_FINITE_VALUE", "Path dimensions and coordinates must be finite.", {
|
|
7
|
+
operation,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
const segmentLengths = (path, closed) => path.map((point, index) => {
|
|
12
|
+
if (!closed && index === path.length - 1)
|
|
13
|
+
return 0;
|
|
14
|
+
const next = path[(index + 1) % path.length];
|
|
15
|
+
const delta = subtract3(next, point);
|
|
16
|
+
return Math.hypot(delta[0], delta[1], delta[2]);
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* Places an exact item count at equal arc-length intervals along a sampled path.
|
|
20
|
+
*
|
|
21
|
+
* Coordinates are unit-agnostic. Closed paths must omit their duplicated seam;
|
|
22
|
+
* open paths include both endpoints when `count` is greater than one.
|
|
23
|
+
* Coordinates and total length must be finite. Input points and output placements are each
|
|
24
|
+
* limited by maxPathPoints (default 100000); exact counts exceeding that ceiling reject before length calculation.
|
|
25
|
+
*/
|
|
26
|
+
export const distributeAlongPath = (path, count, closed, limits = {}) => {
|
|
27
|
+
if (path.length < (closed ? 3 : 2)) {
|
|
28
|
+
throw new Error(`${closed ? "Closed" : "Open"} distribution paths need more points.`);
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
31
|
+
throw new Error("Path distribution count must be a positive integer.");
|
|
32
|
+
}
|
|
33
|
+
const maximum = resolveGeometrySamplingLimits(limits).maxPathPoints;
|
|
34
|
+
assertSamplingCount(count, "distributeAlongPath", maximum);
|
|
35
|
+
assertSamplingCount(path.length, "distributeAlongPath", maximum);
|
|
36
|
+
for (const point of path)
|
|
37
|
+
requireFinitePathValues(point, "distributeAlongPath");
|
|
38
|
+
const lengths = segmentLengths(path, closed);
|
|
39
|
+
const total = lengths.reduce((sum, length) => sum + length, 0);
|
|
40
|
+
requireFinitePathValues([total], "distributeAlongPath");
|
|
41
|
+
if (!(total > 0))
|
|
42
|
+
throw new Error("Cannot distribute along a zero-length path.");
|
|
43
|
+
const denominator = closed ? count : Math.max(1, count - 1);
|
|
44
|
+
let traversed = 0;
|
|
45
|
+
let segment = 0;
|
|
46
|
+
return Array.from({ length: count }, (_, itemIndex) => {
|
|
47
|
+
const target = total * (itemIndex / denominator);
|
|
48
|
+
for (; segment < lengths.length; segment += 1) {
|
|
49
|
+
const length = lengths[segment];
|
|
50
|
+
if (length === 0)
|
|
51
|
+
continue;
|
|
52
|
+
if (target <= traversed + length || segment === lengths.length - 1) {
|
|
53
|
+
const start = path[segment];
|
|
54
|
+
const end = path[(segment + 1) % path.length];
|
|
55
|
+
const amount = Math.min(1, Math.max(0, (target - traversed) / length));
|
|
56
|
+
return {
|
|
57
|
+
position: [
|
|
58
|
+
start[0] + (end[0] - start[0]) * amount,
|
|
59
|
+
start[1] + (end[1] - start[1]) * amount,
|
|
60
|
+
start[2] + (end[2] - start[2]) * amount,
|
|
61
|
+
],
|
|
62
|
+
tangent: normalize3(subtract3(end, start)),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
traversed += length;
|
|
66
|
+
}
|
|
67
|
+
throw new Error("Path distribution invariant failed.");
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Replaces each vertex of a non-duplicated closed polyline with a sampled
|
|
72
|
+
* quadratic corner in the same coordinate system as the input.
|
|
73
|
+
*
|
|
74
|
+
* `radius` is the desired tangent distance for a right angle and is capped to
|
|
75
|
+
* 45% of adjacent segment lengths to prevent neighboring corners from crossing.
|
|
76
|
+
* The returned path does not duplicate its closing point.
|
|
77
|
+
* Coordinates and radius must be finite; input and output are each limited by maxPathPoints (default 100000).
|
|
78
|
+
*/
|
|
79
|
+
export const roundClosedPolylinePath = (points, radius, segmentsPerCorner, limits = {}) => {
|
|
80
|
+
if (points.length < 3)
|
|
81
|
+
throw new Error("Closed polylines need at least three points.");
|
|
82
|
+
const maximum = resolveGeometrySamplingLimits(limits).maxPathPoints;
|
|
83
|
+
assertSamplingCount(points.length, "roundClosedPolylinePath", maximum);
|
|
84
|
+
requireFinitePathValues([radius], "roundClosedPolylinePath");
|
|
85
|
+
if (!(radius > 0))
|
|
86
|
+
throw new Error("Corner radius must be positive.");
|
|
87
|
+
if (!Number.isInteger(segmentsPerCorner) || segmentsPerCorner < 1) {
|
|
88
|
+
throw new Error("Rounded corners need at least one segment.");
|
|
89
|
+
}
|
|
90
|
+
assertSamplingCount(points.length * (segmentsPerCorner + 1), "roundClosedPolylinePath", maximum);
|
|
91
|
+
for (const point of points)
|
|
92
|
+
requireFinitePathValues(point, "roundClosedPolylinePath");
|
|
93
|
+
return points.flatMap((point, index) => {
|
|
94
|
+
const previous = points[(index - 1 + points.length) % points.length];
|
|
95
|
+
const next = points[(index + 1) % points.length];
|
|
96
|
+
const incomingDelta = subtract3(point, previous);
|
|
97
|
+
const outgoingDelta = subtract3(next, point);
|
|
98
|
+
const incomingLength = length3(incomingDelta);
|
|
99
|
+
const outgoingLength = length3(outgoingDelta);
|
|
100
|
+
requireFinitePathValues([incomingLength, outgoingLength], "roundClosedPolylinePath");
|
|
101
|
+
if (!(incomingLength > 0) || !(outgoingLength > 0)) {
|
|
102
|
+
throw new Error("Closed polylines cannot contain duplicate adjacent points.");
|
|
103
|
+
}
|
|
104
|
+
const incoming = normalize3(incomingDelta);
|
|
105
|
+
const outgoing = normalize3(outgoingDelta);
|
|
106
|
+
const cornerCosine = Math.max(-1, Math.min(1, -(incoming[0] * outgoing[0] + incoming[1] * outgoing[1] + incoming[2] * outgoing[2])));
|
|
107
|
+
const cornerAngle = Math.acos(cornerCosine);
|
|
108
|
+
if (cornerAngle < 1e-6 || Math.abs(Math.PI - cornerAngle) < 1e-6) {
|
|
109
|
+
throw new Error("Rounded polyline corners must change direction.");
|
|
110
|
+
}
|
|
111
|
+
const tangentDistance = Math.min(radius / Math.tan(cornerAngle / 2), incomingLength * 0.45, outgoingLength * 0.45);
|
|
112
|
+
const entry = [
|
|
113
|
+
point[0] - incoming[0] * tangentDistance,
|
|
114
|
+
point[1] - incoming[1] * tangentDistance,
|
|
115
|
+
point[2] - incoming[2] * tangentDistance,
|
|
116
|
+
];
|
|
117
|
+
const exit = [
|
|
118
|
+
point[0] + outgoing[0] * tangentDistance,
|
|
119
|
+
point[1] + outgoing[1] * tangentDistance,
|
|
120
|
+
point[2] + outgoing[2] * tangentDistance,
|
|
121
|
+
];
|
|
122
|
+
requireFinitePathValues([...entry, ...exit], "roundClosedPolylinePath");
|
|
123
|
+
return Array.from({ length: segmentsPerCorner + 1 }, (_, step) => {
|
|
124
|
+
const t = step / segmentsPerCorner;
|
|
125
|
+
const inverse = 1 - t;
|
|
126
|
+
const sample = [
|
|
127
|
+
inverse * inverse * entry[0] + 2 * inverse * t * point[0] + t * t * exit[0],
|
|
128
|
+
inverse * inverse * entry[1] + 2 * inverse * t * point[1] + t * t * exit[1],
|
|
129
|
+
inverse * inverse * entry[2] + 2 * inverse * t * point[2] + t * t * exit[2],
|
|
130
|
+
];
|
|
131
|
+
requireFinitePathValues(sample, "roundClosedPolylinePath");
|
|
132
|
+
return sample;
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { curveFailure, normalizeCurveDirection, validateCurvePoint3, } from "../curves/evaluation.js";
|
|
2
|
+
import { assertSampleVertexCount, createPlanarSampleWriter, createSpatialSampleWriter, resolveSamplingBudget, } from "../curves/sampling.js";
|
|
3
|
+
import { resolveGeometryResolution } from "../resolution.js";
|
|
4
|
+
import { assertSamplingCount, resolveGeometrySamplingLimits } from "../sampling-limits.js";
|
|
5
|
+
const z = (point) => point[2] ?? 0;
|
|
6
|
+
const distance = (a, b) => {
|
|
7
|
+
const value = Math.hypot(b[0] - a[0], b[1] - a[1], z(b) - z(a));
|
|
8
|
+
if (!Number.isFinite(value))
|
|
9
|
+
curveFailure("NON_FINITE_VALUE", "Curve extent exceeds finite arithmetic.");
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
function resamplePolyline(path, dimensions, options, createWriter) {
|
|
13
|
+
const maximum = resolveSamplingBudget(options);
|
|
14
|
+
if (options.closed !== undefined && typeof options.closed !== "boolean")
|
|
15
|
+
curveFailure("INVALID_ARGUMENT", "closed must be a boolean.");
|
|
16
|
+
const closed = options.closed ?? false;
|
|
17
|
+
if (path.length < (closed ? 3 : 2))
|
|
18
|
+
curveFailure("INVALID_ARGUMENT", `A ${closed ? "closed" : "open"} polyline needs at least ${closed ? "three" : "two"} points.`);
|
|
19
|
+
assertSamplingCount(path.length, "sampleCurve", resolveGeometrySamplingLimits(options.limits).maxPathPoints, "Polyline input exceeds the configured path point ceiling.");
|
|
20
|
+
if (options.segments !== undefined)
|
|
21
|
+
assertSampleVertexCount(options.segments + (closed ? 0 : 1), maximum);
|
|
22
|
+
// Keep caller point references only within this synchronous operation. Unlike 2D promotion,
|
|
23
|
+
// normalization needs no extra 3D vector for every input point.
|
|
24
|
+
const points = [];
|
|
25
|
+
const lengths = [];
|
|
26
|
+
let total = 0;
|
|
27
|
+
for (const input of path) {
|
|
28
|
+
if (input.length !== dimensions)
|
|
29
|
+
curveFailure(dimensions === 2 ? "INVALID_ARGUMENT" : "NON_FINITE_VALUE", dimensions === 2
|
|
30
|
+
? "Planar curve points must have two coordinates."
|
|
31
|
+
: "Curve coordinates must be finite three-dimensional points.");
|
|
32
|
+
if (!input.every(Number.isFinite))
|
|
33
|
+
curveFailure("NON_FINITE_VALUE", "Curve coordinates must be finite three-dimensional points.");
|
|
34
|
+
const previous = points.at(-1);
|
|
35
|
+
const length = previous === undefined ? 0 : distance(previous, input);
|
|
36
|
+
if (previous !== undefined && length === 0)
|
|
37
|
+
continue;
|
|
38
|
+
total += length;
|
|
39
|
+
if (!Number.isFinite(total))
|
|
40
|
+
curveFailure("NON_FINITE_VALUE", "Polyline length exceeds finite arithmetic.");
|
|
41
|
+
points.push(input);
|
|
42
|
+
lengths.push(total);
|
|
43
|
+
}
|
|
44
|
+
if (closed) {
|
|
45
|
+
if (points.length > 1 && distance(points[0], points.at(-1)) === 0) {
|
|
46
|
+
points.pop();
|
|
47
|
+
lengths.pop();
|
|
48
|
+
total = lengths.at(-1) ?? 0;
|
|
49
|
+
}
|
|
50
|
+
if (points.length < 3)
|
|
51
|
+
curveFailure("DEGENERATE_GEOMETRY", "A closed polyline needs at least three distinct consecutive points.");
|
|
52
|
+
total += distance(points.at(-1), points[0]);
|
|
53
|
+
if (!Number.isFinite(total))
|
|
54
|
+
curveFailure("NON_FINITE_VALUE", "Polyline length exceeds finite arithmetic.");
|
|
55
|
+
points.push(points[0]);
|
|
56
|
+
lengths.push(total);
|
|
57
|
+
}
|
|
58
|
+
if (total === 0)
|
|
59
|
+
curveFailure("DEGENERATE_GEOMETRY", "A zero-length polyline cannot be resampled.");
|
|
60
|
+
const policy = options.segments === undefined
|
|
61
|
+
? resolveGeometryResolution(options.resolution, options.limits)
|
|
62
|
+
: undefined;
|
|
63
|
+
const segments = options.segments ??
|
|
64
|
+
(policy?.maxSegmentLength === undefined
|
|
65
|
+
? points.length - 1
|
|
66
|
+
: Math.max(closed ? 3 : 1, Math.ceil(total / policy.maxSegmentLength)));
|
|
67
|
+
if (closed && segments < 3)
|
|
68
|
+
curveFailure("INVALID_ARGUMENT", "Closed polyline sampling needs at least three segments.");
|
|
69
|
+
const vertexCount = segments + (closed ? 0 : 1);
|
|
70
|
+
assertSampleVertexCount(vertexCount, maximum);
|
|
71
|
+
const writer = createWriter();
|
|
72
|
+
const point = [0, 0, 0], tangent = [0, 0, 0];
|
|
73
|
+
let source = 1;
|
|
74
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
75
|
+
const at = total * (i / segments);
|
|
76
|
+
// At an exact source vertex choose its outgoing edge; the last open endpoint uses its incoming edge.
|
|
77
|
+
while (source < points.length - 1 && lengths[source] <= at)
|
|
78
|
+
source++;
|
|
79
|
+
const a = points[source - 1], b = points[source];
|
|
80
|
+
if (i === 0 || i === segments) {
|
|
81
|
+
const endpoint = i === 0 ? points[0] : points.at(-1);
|
|
82
|
+
point[0] = endpoint[0];
|
|
83
|
+
point[1] = endpoint[1];
|
|
84
|
+
point[2] = z(endpoint);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const t = (at - lengths[source - 1]) / (lengths[source] - lengths[source - 1]);
|
|
88
|
+
// Preserve the original direct interpolation order used by existing mechanical paths.
|
|
89
|
+
point[0] = a[0] + (b[0] - a[0]) * t;
|
|
90
|
+
point[1] = a[1] + (b[1] - a[1]) * t;
|
|
91
|
+
point[2] = z(a) + (z(b) - z(a)) * t;
|
|
92
|
+
}
|
|
93
|
+
validateCurvePoint3(point);
|
|
94
|
+
tangent[0] = b[0] - a[0];
|
|
95
|
+
tangent[1] = b[1] - a[1];
|
|
96
|
+
tangent[2] = z(b) - z(a);
|
|
97
|
+
normalizeCurveDirection(tangent, tangent);
|
|
98
|
+
writer.append(point, tangent);
|
|
99
|
+
}
|
|
100
|
+
return writer.finish();
|
|
101
|
+
}
|
|
102
|
+
/** Resamples a spatial polyline at equal arc lengths with directed outgoing tangents.
|
|
103
|
+
* @remarks Open paths retain both endpoints; closed paths include the closing edge and omit the
|
|
104
|
+
* repeated seam. Repeated adjacent inputs and explicit closing duplicates are removed. Physical
|
|
105
|
+
* maxSegmentLength bounds arc-length intervals; exact counts override that policy. Output edges
|
|
106
|
+
* may bridge original corners. Sampling never silently lowers the requested detail to fit its budget.
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* const path = resamplePolyline3([[0,0,0],[10,0,0],[10,10,0]], {segments: 8});
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export function resamplePolyline3(path, options = {}) {
|
|
113
|
+
return resamplePolyline(path, 3, options, createSpatialSampleWriter);
|
|
114
|
+
}
|
|
115
|
+
/** Resamples a planar polyline with the same point, tangent, seam and budget guarantees as 3D.
|
|
116
|
+
* @remarks Only final 2D output vectors are copied and frozen. No immutable 3D intermediate is built.
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* const loop = resamplePolyline2([[0,0],[5,0],[5,5],[0,5]], {closed: true, segments: 20});
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function resamplePolyline2(path, options = {}) {
|
|
123
|
+
return resamplePolyline(path, 2, options, createPlanarSampleWriter);
|
|
124
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as clippingModule from "polygon-clipping";
|
|
2
|
+
const moduleWithDefault = clippingModule;
|
|
3
|
+
/** @internal Normalizes polygon-clipping's CommonJS and bundled ESM module shapes. */
|
|
4
|
+
export const polygonClipping = moduleWithDefault.default ?? clippingModule;
|
|
5
|
+
const CLIPPING_COORDINATE_GRID = 1e9;
|
|
6
|
+
const snapCoordinate = (value) => Math.round(value * CLIPPING_COORDINATE_GRID) / CLIPPING_COORDINATE_GRID;
|
|
7
|
+
/** Canonicalizes shared endpoints below model tolerance before they enter the sweep-line kernel. */
|
|
8
|
+
function snapGeometry(geometry) {
|
|
9
|
+
if (geometry.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
const multiPolygon = typeof geometry[0]?.[0]?.[0] === "number"
|
|
12
|
+
? [geometry]
|
|
13
|
+
: geometry;
|
|
14
|
+
return multiPolygon.map((polygon) => polygon.map((ring) => ring.map(([x, y]) => [snapCoordinate(x), snapCoordinate(y)])));
|
|
15
|
+
}
|
|
16
|
+
function normalizeGeometry(geometry) {
|
|
17
|
+
return snapGeometry(polygonClipping.union(snapGeometry(geometry)));
|
|
18
|
+
}
|
|
19
|
+
/** Reduces associative clipping operations as a tree to bound each sweep-line workload. */
|
|
20
|
+
function reducePolygonGeometry(geometries, combine) {
|
|
21
|
+
let level = geometries
|
|
22
|
+
.filter((geometry) => geometry.length > 0)
|
|
23
|
+
.map(normalizeGeometry);
|
|
24
|
+
if (level.length === 0)
|
|
25
|
+
return [];
|
|
26
|
+
while (level.length > 1) {
|
|
27
|
+
const next = [];
|
|
28
|
+
for (let index = 0; index < level.length; index += 2) {
|
|
29
|
+
const left = level[index];
|
|
30
|
+
const right = level[index + 1];
|
|
31
|
+
next.push(right === undefined
|
|
32
|
+
? normalizeGeometry(left)
|
|
33
|
+
: snapGeometry(combine(snapGeometry(left), snapGeometry(right))));
|
|
34
|
+
}
|
|
35
|
+
level = next;
|
|
36
|
+
}
|
|
37
|
+
return normalizeGeometry(level[0]);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Unions polygon-clipping inputs through a balanced reduction.
|
|
41
|
+
*
|
|
42
|
+
* Incrementally simplifying overlapping inputs prevents the sweep-line implementation from seeing
|
|
43
|
+
* thousands of redundant segment intersections in one operation. This is especially important for
|
|
44
|
+
* sampled strokes, where every segment rectangle and round join overlaps its neighbors.
|
|
45
|
+
*/
|
|
46
|
+
export function unionPolygonGeometry(geometries) {
|
|
47
|
+
return reducePolygonGeometry(geometries, (left, right) => polygonClipping.union(left, right));
|
|
48
|
+
}
|
|
49
|
+
/** @internal Balanced symmetric difference keeps compound even-odd SVG fills bounded. */
|
|
50
|
+
export function xorPolygonGeometry(geometries) {
|
|
51
|
+
return reducePolygonGeometry(geometries, (left, right) => polygonClipping.xor(left, right));
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { resolveCircularSegments } from "./resolution.js";
|
|
3
|
+
import { assertSamplingCount, resolveGeometrySamplingLimits } from "./sampling-limits.js";
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a circular primitive's exact or adaptive segment count.
|
|
6
|
+
*
|
|
7
|
+
* @internal Exact counts override physical sampling, while explicit allocation ceilings still apply.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveCircularPrimitiveSegments(radius, options, operation) {
|
|
10
|
+
if (options.segments === undefined) {
|
|
11
|
+
return resolveCircularSegments(radius, options.resolution, options.limits);
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isSafeInteger(options.segments) || options.segments < 3)
|
|
14
|
+
throw new GeometryError("INVALID_ARGUMENT", "segments must be a safe integer of at least three.", { operation, details: { value: options.segments } });
|
|
15
|
+
assertSamplingCount(options.segments, operation, resolveGeometrySamplingLimits(options.limits).maxCircularSegments);
|
|
16
|
+
return options.segments;
|
|
17
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { edgeTreatmentPointCount, edgeTreatmentSetbacks, sampleEdgeTreatment, } from "./edge-finishing.js";
|
|
2
|
+
import { GeometryError } from "./errors.js";
|
|
3
|
+
import { assertMeshAllocationWithinLimits, resolveModelingLimits } from "./model-limits.js";
|
|
4
|
+
import { resolveCircularPrimitiveSegments } from "./primitive-resolution.js";
|
|
5
|
+
/** @internal Face pairs define the twelve nominal box edges in deterministic order. */
|
|
6
|
+
export const boxEdges = [
|
|
7
|
+
"top-front",
|
|
8
|
+
"top-back",
|
|
9
|
+
"top-left",
|
|
10
|
+
"top-right",
|
|
11
|
+
"bottom-front",
|
|
12
|
+
"bottom-back",
|
|
13
|
+
"bottom-left",
|
|
14
|
+
"bottom-right",
|
|
15
|
+
"front-left",
|
|
16
|
+
"front-right",
|
|
17
|
+
"back-left",
|
|
18
|
+
"back-right",
|
|
19
|
+
];
|
|
20
|
+
/** @internal Axis and outward sign of one nominal box face. */
|
|
21
|
+
export const boxFaceAxes = {
|
|
22
|
+
top: [2, 1],
|
|
23
|
+
bottom: [2, -1],
|
|
24
|
+
front: [1, 1],
|
|
25
|
+
back: [1, -1],
|
|
26
|
+
left: [0, -1],
|
|
27
|
+
right: [0, 1],
|
|
28
|
+
};
|
|
29
|
+
const expandBoxEdges = (selection) => {
|
|
30
|
+
if (selection === "top" || selection === "bottom")
|
|
31
|
+
return boxEdges.filter((edge) => edge.startsWith(selection + "-"));
|
|
32
|
+
if (selection === "vertical")
|
|
33
|
+
return boxEdges.slice(8);
|
|
34
|
+
if (boxEdges.includes(selection))
|
|
35
|
+
return [selection];
|
|
36
|
+
throw new GeometryError("INVALID_ARGUMENT", "Unsupported box edge selection.", {
|
|
37
|
+
operation: "box",
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
/** @internal Conservative allocation/work bounds include intersections of independently sampled masks. */
|
|
41
|
+
function selectedBoxCounts(cuts) {
|
|
42
|
+
let vertices = 8 + cuts.reduce((sum, cut) => sum + 2 * (cut.count + 1), 0);
|
|
43
|
+
const axes = (edge) => edge.split("-").map((face) => boxFaceAxes[face]);
|
|
44
|
+
for (let index = 0; index < cuts.length; index++)
|
|
45
|
+
for (let other = index + 1; other < cuts.length; other++) {
|
|
46
|
+
const a = axes(cuts[index].edge), b = axes(cuts[other].edge);
|
|
47
|
+
const sameAxis = a.every(([axis]) => b.some(([other]) => other === axis));
|
|
48
|
+
const conflict = a.some(([axis, sign]) => b.some(([other, otherSign]) => axis === other && sign !== otherSign));
|
|
49
|
+
if (!sameAxis && !conflict)
|
|
50
|
+
vertices += 4 * cuts[index].count * cuts[other].count;
|
|
51
|
+
}
|
|
52
|
+
return { vertices, triangles: 2 * vertices - 4 };
|
|
53
|
+
}
|
|
54
|
+
/** @internal Shared option-shape validation for primitive adapters. */
|
|
55
|
+
export function assertPrimitiveOptions(value, operation) {
|
|
56
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
57
|
+
throw new GeometryError("INVALID_ARGUMENT", "options must be an object.", { operation });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** @internal Shared finite, positive dimension validation. */
|
|
61
|
+
export function assertPositivePrimitive(value, name, operation) {
|
|
62
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
63
|
+
throw new GeometryError("INVALID_ARGUMENT", `${name} must be finite and positive.`, {
|
|
64
|
+
operation,
|
|
65
|
+
details: { name, value },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** @internal Resolves one positive scalar radial dimension before sampling or allocation. */
|
|
70
|
+
export function resolvePrimitiveRadius(options, operation) {
|
|
71
|
+
assertPrimitiveOptions(options, operation);
|
|
72
|
+
if ((options.radius === undefined) === (options.diameter === undefined)) {
|
|
73
|
+
throw new GeometryError("INVALID_ARGUMENT", "Specify exactly one radius or diameter.", {
|
|
74
|
+
operation,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const radius = options.radius ?? options.diameter / 2;
|
|
78
|
+
assertPositivePrimitive(radius, "radius", operation);
|
|
79
|
+
return radius;
|
|
80
|
+
}
|
|
81
|
+
/** @internal Resolved pure dimensions and treatment for a box, before kernel allocation. */
|
|
82
|
+
export function resolveBoxPrimitive(options) {
|
|
83
|
+
const operation = "box";
|
|
84
|
+
assertPrimitiveOptions(options, operation);
|
|
85
|
+
if (options.center !== undefined && typeof options.center !== "boolean") {
|
|
86
|
+
throw new GeometryError("INVALID_ARGUMENT", "center must be boolean when supplied.", {
|
|
87
|
+
operation,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (typeof options.size !== "number" &&
|
|
91
|
+
(!Array.isArray(options.size) || options.size.length !== 3)) {
|
|
92
|
+
throw new GeometryError("INVALID_ARGUMENT", "size must be a number or an exact XYZ tuple.", {
|
|
93
|
+
operation,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const dimensions = typeof options.size === "number" ? [options.size, options.size, options.size] : options.size;
|
|
97
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
98
|
+
assertPositivePrimitive(dimensions[axis], `size[${axis}]`, operation);
|
|
99
|
+
}
|
|
100
|
+
const base = { center: options.center ?? true, dimensions };
|
|
101
|
+
if (options.treatment === undefined)
|
|
102
|
+
return base;
|
|
103
|
+
assertPrimitiveOptions(options.treatment, operation);
|
|
104
|
+
const treatment = "kind" in options.treatment ? options.treatment : undefined;
|
|
105
|
+
if (treatment === undefined ||
|
|
106
|
+
treatment.kind === "smooth" ||
|
|
107
|
+
treatment.kind === "profile" ||
|
|
108
|
+
(treatment.kind === "chamfer" && typeof treatment.distance !== "number")) {
|
|
109
|
+
const selections = treatment === undefined
|
|
110
|
+
? Object.entries(options.treatment)
|
|
111
|
+
: boxEdges.map((edge) => [edge, treatment]);
|
|
112
|
+
if (selections.length === 0 || selections.length > 12)
|
|
113
|
+
throw new GeometryError("INVALID_ARGUMENT", "Box treatments require a nonempty map of nominal edges or groups.", { operation });
|
|
114
|
+
const cuts = [];
|
|
115
|
+
for (const [selection, selected] of selections) {
|
|
116
|
+
const setbacks = edgeTreatmentSetbacks(selected, operation, Math.PI / 2, options.limits);
|
|
117
|
+
if (setbacks.some((value) => value <= 0))
|
|
118
|
+
throw new GeometryError("INVALID_ARGUMENT", "Box treatments require positive setbacks.", {
|
|
119
|
+
operation,
|
|
120
|
+
});
|
|
121
|
+
for (const edge of expandBoxEdges(selection)) {
|
|
122
|
+
if (cuts.some((cut) => cut.edge === edge))
|
|
123
|
+
throw new GeometryError("INVALID_ARGUMENT", "Box edge selections must not overlap.", {
|
|
124
|
+
operation,
|
|
125
|
+
});
|
|
126
|
+
const [first, second] = edge.split("-"), a = boxFaceAxes[first][0], b = boxFaceAxes[second][0];
|
|
127
|
+
if (setbacks[0] >= dimensions[b] || setbacks[1] >= dimensions[a])
|
|
128
|
+
throw new GeometryError("INVALID_ARGUMENT", "Box edge setbacks must fit their adjoining dimensions.", { operation });
|
|
129
|
+
cuts.push({
|
|
130
|
+
edge,
|
|
131
|
+
treatment: selected,
|
|
132
|
+
setbacks,
|
|
133
|
+
count: edgeTreatmentPointCount(selected, operation, options.limits === undefined ? {} : { limits: options.limits }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const cut of cuts) {
|
|
138
|
+
const [first, second] = cut.edge.split("-"), [a, sa] = boxFaceAxes[first], [b, sb] = boxFaceAxes[second];
|
|
139
|
+
for (const other of cuts) {
|
|
140
|
+
if (other === cut)
|
|
141
|
+
continue;
|
|
142
|
+
const [of, os] = other.edge.split("-"), [oa, osa] = boxFaceAxes[of], [ob, osb] = boxFaceAxes[os];
|
|
143
|
+
if (a === oa &&
|
|
144
|
+
b === ob &&
|
|
145
|
+
((sa === osa && sb !== osb && cut.setbacks[0] + other.setbacks[0] >= dimensions[b]) ||
|
|
146
|
+
(sb === osb && sa !== osa && cut.setbacks[1] + other.setbacks[1] >= dimensions[a])))
|
|
147
|
+
throw new GeometryError("INVALID_ARGUMENT", "Opposing box edge setbacks must leave positive flat space.", { operation });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
cuts.sort((a, b) => boxEdges.indexOf(a.edge) - boxEdges.indexOf(b.edge));
|
|
151
|
+
const counts = selectedBoxCounts(cuts);
|
|
152
|
+
assertMeshAllocationWithinLimits(counts.vertices, counts.triangles, 12 * (counts.vertices + counts.triangles), operation, resolveModelingLimits(options.limits));
|
|
153
|
+
const profiles = new Map();
|
|
154
|
+
return {
|
|
155
|
+
...base,
|
|
156
|
+
treatment: {
|
|
157
|
+
kind: "selected",
|
|
158
|
+
cuts: cuts.map((cut) => {
|
|
159
|
+
let profile = profiles.get(cut.treatment);
|
|
160
|
+
if (!profile) {
|
|
161
|
+
profile = sampleEdgeTreatment(cut.treatment, operation, options.limits === undefined ? {} : { limits: options.limits });
|
|
162
|
+
profiles.set(cut.treatment, profile);
|
|
163
|
+
}
|
|
164
|
+
return { edge: cut.edge, profile };
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const setbacks = edgeTreatmentSetbacks(treatment, operation, Math.PI / 2, options.limits);
|
|
170
|
+
if (setbacks.some((value) => value <= 0))
|
|
171
|
+
throw new GeometryError("INVALID_ARGUMENT", "Box treatments require positive setbacks.", {
|
|
172
|
+
operation,
|
|
173
|
+
});
|
|
174
|
+
const distance = setbacks[0];
|
|
175
|
+
assertPositivePrimitive(distance, "edge treatment size", operation);
|
|
176
|
+
const inner = [
|
|
177
|
+
dimensions[0] - 2 * distance,
|
|
178
|
+
dimensions[1] - 2 * distance,
|
|
179
|
+
dimensions[2] - 2 * distance,
|
|
180
|
+
];
|
|
181
|
+
if (inner.some((value) => !Number.isFinite(value) || value <= 0)) {
|
|
182
|
+
throw new GeometryError("INVALID_ARGUMENT", "The edge treatment must be less than half every box dimension.", { operation });
|
|
183
|
+
}
|
|
184
|
+
return treatment.kind === "chamfer"
|
|
185
|
+
? { ...base, treatment: { kind: "chamfer", inner } }
|
|
186
|
+
: {
|
|
187
|
+
...base,
|
|
188
|
+
treatment: {
|
|
189
|
+
kind: "round",
|
|
190
|
+
inner,
|
|
191
|
+
radius: distance,
|
|
192
|
+
segments: resolveCircularPrimitiveSegments(distance, treatment, operation),
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** @internal Validates tube dimensions shared by both adapters before allocating cylinders. */
|
|
197
|
+
export function resolveTubePrimitive(options) {
|
|
198
|
+
assertPrimitiveOptions(options, "tube");
|
|
199
|
+
assertPositivePrimitive(options.innerRadius, "innerRadius", "tube");
|
|
200
|
+
assertPositivePrimitive(options.outerRadius, "outerRadius", "tube");
|
|
201
|
+
assertPositivePrimitive(options.height, "height", "tube");
|
|
202
|
+
if (!(options.outerRadius > options.innerRadius)) {
|
|
203
|
+
throw new GeometryError("INVALID_ARGUMENT", "outerRadius must be larger than innerRadius.", {
|
|
204
|
+
operation: "tube",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** @internal Resolves sphere sampling shared by compiled and authored adapters. */
|
|
209
|
+
export function resolveSpherePrimitive(options) {
|
|
210
|
+
const radius = resolvePrimitiveRadius(options, "sphere");
|
|
211
|
+
return { radius, segments: resolveCircularPrimitiveSegments(radius, options, "sphere") };
|
|
212
|
+
}
|
|
213
|
+
/** @internal Resolves torus profile and sweep sampling before allocating kernel geometry. */
|
|
214
|
+
export function resolveTorusPrimitive(options) {
|
|
215
|
+
assertPrimitiveOptions(options, "torus");
|
|
216
|
+
assertPositivePrimitive(options.majorRadius, "majorRadius", "torus");
|
|
217
|
+
assertPositivePrimitive(options.minorRadius, "minorRadius", "torus");
|
|
218
|
+
if (!(options.majorRadius > options.minorRadius)) {
|
|
219
|
+
throw new GeometryError("INVALID_ARGUMENT", "majorRadius must be larger than minorRadius.", {
|
|
220
|
+
operation: "torus",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
profileSegments: resolveCircularPrimitiveSegments(options.minorRadius, {
|
|
225
|
+
...(options.minorSegments === undefined ? {} : { segments: options.minorSegments }),
|
|
226
|
+
...(options.resolution === undefined ? {} : { resolution: options.resolution }),
|
|
227
|
+
...(options.limits === undefined ? {} : { limits: options.limits }),
|
|
228
|
+
}, "torus"),
|
|
229
|
+
sweepSegments: resolveCircularPrimitiveSegments(options.majorRadius + options.minorRadius, {
|
|
230
|
+
...(options.majorSegments === undefined ? {} : { segments: options.majorSegments }),
|
|
231
|
+
...(options.resolution === undefined ? {} : { resolution: options.resolution }),
|
|
232
|
+
...(options.limits === undefined ? {} : { limits: options.limits }),
|
|
233
|
+
}, "torus"),
|
|
234
|
+
};
|
|
235
|
+
}
|