@metamesh/modeling 0.0.0-stage → 0.7.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 +111 -0
- package/LICENSE +200 -0
- package/README.md +376 -2
- package/THIRD_PARTY_NOTICES.md +153 -0
- package/dist/api/engine.d.ts +71 -0
- package/dist/api/geometry.d.ts +3136 -0
- package/dist/api/index.d.ts +2087 -0
- package/dist/api/text.d.ts +93 -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 +83 -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 +266 -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 +115 -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 +95 -0
- package/dist/geometry/math/vector3.js +71 -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 +373 -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-edge-table.js +126 -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 +283 -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 +32 -0
- package/dist/geometry/paths/planar.js +135 -0
- package/dist/geometry/paths/resampling.js +125 -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 +192 -0
- package/dist/geometry/profiles/open-profile.js +137 -0
- package/dist/geometry/profiles/profiles.js +91 -0
- package/dist/geometry/profiles/rectangle.js +249 -0
- package/dist/geometry/profiles/shapes.js +41 -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 +129 -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 +30 -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 +154 -0
- package/dist/model/options.js +1 -0
- package/dist/model/types.js +1 -0
- package/dist/text/index.js +58 -0
- package/dist/text/vector-text-layout.js +279 -0
- package/package.json +100 -3
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { resolveGeometrySamplingLimits } from "./sampling-limits.js";
|
|
3
|
+
const DEFAULT_MAX_CHORD_ERROR = 0.05;
|
|
4
|
+
const DEFAULT_MINIMUM_SEGMENTS = 16;
|
|
5
|
+
const invalid = (message, details) => {
|
|
6
|
+
throw new GeometryError("INVALID_ARGUMENT", message, {
|
|
7
|
+
operation: "geometryResolution",
|
|
8
|
+
...(details === undefined ? {} : { details }),
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
/** @internal Resolves operation policy over explicit allocation ceilings and defaults. */
|
|
12
|
+
export function resolveGeometryResolution(policy = {}, limits = {}) {
|
|
13
|
+
const merged = policy;
|
|
14
|
+
const circularSegments = merged.circularSegments;
|
|
15
|
+
const allowedMaximum = resolveGeometrySamplingLimits(limits).maxCircularSegments;
|
|
16
|
+
const minimumSegments = merged.minimumSegments ?? Math.min(DEFAULT_MINIMUM_SEGMENTS, allowedMaximum);
|
|
17
|
+
const maximumSegments = merged.maximumSegments ?? allowedMaximum;
|
|
18
|
+
const maxChordError = merged.maxChordError ?? DEFAULT_MAX_CHORD_ERROR;
|
|
19
|
+
const maxSegmentLength = merged.maxSegmentLength;
|
|
20
|
+
if (!Number.isSafeInteger(minimumSegments) || minimumSegments < 3) {
|
|
21
|
+
invalid("minimumSegments must be an integer of at least three.", { minimumSegments });
|
|
22
|
+
}
|
|
23
|
+
if (!Number.isSafeInteger(maximumSegments) ||
|
|
24
|
+
maximumSegments < minimumSegments ||
|
|
25
|
+
maximumSegments > allowedMaximum) {
|
|
26
|
+
invalid("maximumSegments must be a safe integer between minimumSegments and maxCircularSegments.", {
|
|
27
|
+
allowedMaximum,
|
|
28
|
+
maximumSegments,
|
|
29
|
+
minimumSegments,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isFinite(maxChordError) || maxChordError <= 0) {
|
|
33
|
+
invalid("maxChordError must be finite and positive.", { maxChordError });
|
|
34
|
+
}
|
|
35
|
+
if (maxSegmentLength !== undefined &&
|
|
36
|
+
(!Number.isFinite(maxSegmentLength) || maxSegmentLength <= 0)) {
|
|
37
|
+
invalid("maxSegmentLength must be finite and positive when supplied.", {
|
|
38
|
+
maxSegmentLength,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (circularSegments !== undefined &&
|
|
42
|
+
(!Number.isSafeInteger(circularSegments) ||
|
|
43
|
+
circularSegments < 3 ||
|
|
44
|
+
circularSegments > maximumSegments)) {
|
|
45
|
+
invalid("circularSegments must be an integer from three through maximumSegments.", {
|
|
46
|
+
circularSegments,
|
|
47
|
+
maximumSegments,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
...(circularSegments === undefined ? {} : { circularSegments }),
|
|
52
|
+
maxChordError,
|
|
53
|
+
...(maxSegmentLength === undefined ? {} : { maxSegmentLength }),
|
|
54
|
+
maximumSegments,
|
|
55
|
+
minimumSegments,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const requiredFullCircleSegments = (radius, resolution) => {
|
|
59
|
+
const chordAngle = 2 * Math.acos(Math.max(-1, Math.min(1, 1 - resolution.maxChordError / radius)));
|
|
60
|
+
const chordSegments = chordAngle === 0 ? Number.POSITIVE_INFINITY : Math.ceil((Math.PI * 2) / chordAngle);
|
|
61
|
+
const lengthSegments = resolution.maxSegmentLength === undefined
|
|
62
|
+
? 0
|
|
63
|
+
: Math.ceil((Math.PI * 2 * radius) / resolution.maxSegmentLength);
|
|
64
|
+
return Math.max(resolution.minimumSegments, chordSegments, lengthSegments);
|
|
65
|
+
};
|
|
66
|
+
const assertRadius = (radius, operation) => {
|
|
67
|
+
if (!Number.isFinite(radius) || radius <= 0) {
|
|
68
|
+
throw new GeometryError("INVALID_ARGUMENT", "Curve radius must be finite and positive.", {
|
|
69
|
+
operation,
|
|
70
|
+
details: { radius },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Resolves a deterministic full-circle segment count for one radius.
|
|
76
|
+
*
|
|
77
|
+
* An exact `circularSegments` value wins. Otherwise the smallest regular polygon satisfying both
|
|
78
|
+
* physical error criteria is used. A safety cap that would violate either criterion raises
|
|
79
|
+
* `COMPLEXITY_LIMIT` rather than silently reducing quality.
|
|
80
|
+
*
|
|
81
|
+
* @param radius - Circle radius in millimetres.
|
|
82
|
+
* @param policy - Optional operation-level policy under explicit sampling ceilings.
|
|
83
|
+
* @returns Full-circle segment count.
|
|
84
|
+
* @throws {@link GeometryError} when radius or policy is invalid, or the requested tolerance
|
|
85
|
+
* exceeds the configured segment safety limit.
|
|
86
|
+
* @see {@link resolveArcSegments}
|
|
87
|
+
* @see {@link resolveAlignedCircularSegments}
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const circleSegments = resolveCircularSegments(25, { maxChordError: 0.05 });
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function resolveCircularSegments(radius, policy = {}, limits = {}) {
|
|
95
|
+
const operation = "resolveCircularSegments";
|
|
96
|
+
assertRadius(radius, operation);
|
|
97
|
+
const resolution = resolveGeometryResolution(policy, limits);
|
|
98
|
+
if (resolution.circularSegments !== undefined)
|
|
99
|
+
return resolution.circularSegments;
|
|
100
|
+
const required = requiredFullCircleSegments(radius, resolution);
|
|
101
|
+
if (required > resolution.maximumSegments) {
|
|
102
|
+
throw new GeometryError("COMPLEXITY_LIMIT", `The requested physical curve tolerance requires ${required} segments, above the ${resolution.maximumSegments}-segment safety limit.`, {
|
|
103
|
+
operation,
|
|
104
|
+
details: {
|
|
105
|
+
maximumSegments: resolution.maximumSegments,
|
|
106
|
+
radius,
|
|
107
|
+
requiredSegments: required,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return required;
|
|
112
|
+
}
|
|
113
|
+
const greatestCommonDivisor = (left, right) => {
|
|
114
|
+
let a = left;
|
|
115
|
+
let b = right;
|
|
116
|
+
while (b !== 0) {
|
|
117
|
+
const remainder = a % b;
|
|
118
|
+
a = b;
|
|
119
|
+
b = remainder;
|
|
120
|
+
}
|
|
121
|
+
return a;
|
|
122
|
+
};
|
|
123
|
+
const checkedLeastCommonMultiple = (left, right, operation) => {
|
|
124
|
+
const scaledLeft = left / greatestCommonDivisor(left, right);
|
|
125
|
+
if (scaledLeft > Number.MAX_SAFE_INTEGER / right) {
|
|
126
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "The requested segment alignments exceed safe integer complexity.", { operation, details: { left, right } });
|
|
127
|
+
}
|
|
128
|
+
return scaledLeft * right;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Resolves a deterministic full-circle count aligned to reusable topology factors.
|
|
132
|
+
*
|
|
133
|
+
* `alignTo` must be non-empty and contain only positive safe integers. Its checked least common
|
|
134
|
+
* multiple defines the alignment grid. An exact `segments` value wins over
|
|
135
|
+
* `resolution.circularSegments` and adaptive sampling, but is never rounded: it must already be
|
|
136
|
+
* aligned and meet `minimumSegments`. Adaptive counts are rounded upward and fail with
|
|
137
|
+
* `COMPLEXITY_LIMIT` when the aligned result exceeds the configured safety cap.
|
|
138
|
+
*
|
|
139
|
+
* @param radius - Positive circle radius in millimetres used for adaptive physical sampling.
|
|
140
|
+
* @param options - Required alignment factors plus optional floor, exact count, and resolution.
|
|
141
|
+
* @returns An aligned full-circle segment count.
|
|
142
|
+
* @throws {@link GeometryError} when radius, alignment factors, segment floors, or resolution are
|
|
143
|
+
* invalid, or alignment exceeds safe integer or configured complexity limits.
|
|
144
|
+
* @see {@link resolveCircularSegments}
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const alignedSegments = resolveAlignedCircularSegments(20, { alignTo: [4, 6] });
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export function resolveAlignedCircularSegments(radius, options) {
|
|
152
|
+
const operation = "resolveAlignedCircularSegments";
|
|
153
|
+
assertRadius(radius, operation);
|
|
154
|
+
const alignTo = options.alignTo;
|
|
155
|
+
const hasArrayShape = Array.isArray(alignTo);
|
|
156
|
+
if (!hasArrayShape || alignTo.length === 0) {
|
|
157
|
+
throw new GeometryError("INVALID_ARGUMENT", "alignTo must be a non-empty array.", {
|
|
158
|
+
operation,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
let alignment = 1;
|
|
162
|
+
for (const factor of alignTo) {
|
|
163
|
+
if (!Number.isSafeInteger(factor) || factor <= 0) {
|
|
164
|
+
throw new GeometryError("INVALID_ARGUMENT", "Every alignTo value must be a positive safe integer.", { operation, details: { factor } });
|
|
165
|
+
}
|
|
166
|
+
alignment = checkedLeastCommonMultiple(alignment, factor, operation);
|
|
167
|
+
}
|
|
168
|
+
const minimumSegments = options.minimumSegments ?? 3;
|
|
169
|
+
if (!Number.isSafeInteger(minimumSegments) || minimumSegments < 3) {
|
|
170
|
+
throw new GeometryError("INVALID_ARGUMENT", "minimumSegments must be a safe integer of at least three.", { operation, details: { minimumSegments } });
|
|
171
|
+
}
|
|
172
|
+
const resolution = options.segments === undefined
|
|
173
|
+
? resolveGeometryResolution(options.resolution, options.limits)
|
|
174
|
+
: resolveGeometryResolution({
|
|
175
|
+
circularSegments: options.segments,
|
|
176
|
+
}, options.limits);
|
|
177
|
+
if (resolution.circularSegments !== undefined) {
|
|
178
|
+
const exactSegments = resolution.circularSegments;
|
|
179
|
+
if (exactSegments < minimumSegments || exactSegments % alignment !== 0) {
|
|
180
|
+
throw new GeometryError("INVALID_ARGUMENT", "The exact segment count must meet minimumSegments and every alignTo factor.", {
|
|
181
|
+
operation,
|
|
182
|
+
details: { alignment, exactSegments, minimumSegments },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return exactSegments;
|
|
186
|
+
}
|
|
187
|
+
const baseSegments = resolveCircularSegments(radius, {
|
|
188
|
+
...resolution,
|
|
189
|
+
}, options.limits);
|
|
190
|
+
const requiredSegments = Math.max(baseSegments, minimumSegments);
|
|
191
|
+
const multiplier = Math.ceil(requiredSegments / alignment);
|
|
192
|
+
if (!Number.isSafeInteger(multiplier) || multiplier > Number.MAX_SAFE_INTEGER / alignment) {
|
|
193
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "The aligned segment count exceeds safe integer complexity.", { operation, details: { alignment, requiredSegments } });
|
|
194
|
+
}
|
|
195
|
+
const alignedSegments = multiplier * alignment;
|
|
196
|
+
if (alignedSegments > resolution.maximumSegments) {
|
|
197
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "Aligned circular tessellation exceeds the configured segment safety limit.", {
|
|
198
|
+
operation,
|
|
199
|
+
details: {
|
|
200
|
+
alignment,
|
|
201
|
+
maximumSegments: resolution.maximumSegments,
|
|
202
|
+
requiredSegments,
|
|
203
|
+
segments: alignedSegments,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return alignedSegments;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Resolves the number of segments needed for a circular arc.
|
|
211
|
+
*
|
|
212
|
+
* Full-circle limits and exact circular counts are scaled to the requested angular span, matching
|
|
213
|
+
* the reusable BOSL2-style convention while retaining at least one segment.
|
|
214
|
+
*
|
|
215
|
+
* @param radius - Arc radius in millimetres.
|
|
216
|
+
* @param angleDegrees - Non-zero signed sweep angle no greater than 360 degrees in magnitude.
|
|
217
|
+
* @param policy - Optional operation-level policy over the active model scope.
|
|
218
|
+
* @returns Segment count for the requested arc span.
|
|
219
|
+
* @throws {@link GeometryError} when radius, angle, or resolution policy is invalid, or the
|
|
220
|
+
* corresponding full-circle tolerance exceeds the configured safety limit.
|
|
221
|
+
* @see {@link resolveCircularSegments}
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```ts
|
|
225
|
+
* const quarterTurnSegments = resolveArcSegments(10, 90);
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function resolveArcSegments(radius, angleDegrees, policy = {}, limits = {}) {
|
|
229
|
+
const operation = "resolveArcSegments";
|
|
230
|
+
assertRadius(radius, operation);
|
|
231
|
+
if (!Number.isFinite(angleDegrees) || angleDegrees === 0 || Math.abs(angleDegrees) > 360) {
|
|
232
|
+
throw new GeometryError("INVALID_ARGUMENT", "angleDegrees must be finite, non-zero, and no greater than 360 degrees in magnitude.", { operation, details: { angleDegrees } });
|
|
233
|
+
}
|
|
234
|
+
const ratio = Math.abs(angleDegrees) / 360;
|
|
235
|
+
return Math.max(1, Math.ceil(resolveCircularSegments(radius, policy, limits) * ratio));
|
|
236
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { GeometryError } from "../errors.js";
|
|
2
|
+
import { triangulateLoop2 } from "../loft.js";
|
|
3
|
+
import { validateTriangleMesh } from "../mesh.js";
|
|
4
|
+
import { assertMeshAllocationWithinLimits, resolveModelingLimits, } from "../model-limits.js";
|
|
5
|
+
import { writeMeshVertex } from "../numeric.js";
|
|
6
|
+
import { resolveCircularPrimitiveSegments } from "../primitive-resolution.js";
|
|
7
|
+
import { normalizeLoop2 } from "../regions.js";
|
|
8
|
+
import { assertSamplingCount } from "../sampling-limits.js";
|
|
9
|
+
const AXIS_EPSILON = 1e-10;
|
|
10
|
+
/**
|
|
11
|
+
* Revolves a non-duplicated closed `[radius, z]` profile around +Z.
|
|
12
|
+
*
|
|
13
|
+
* The profile is ordered counter-clockwise when radius points right and Z
|
|
14
|
+
* points up. Angular and profile seams wrap by index. A profile point on the
|
|
15
|
+
* axis is emitted once and joined as a triangle fan, avoiding coincident seam
|
|
16
|
+
* vertices and zero-area quads. Profile edges that lie entirely on the axis
|
|
17
|
+
* collapse to the solid's interior and are intentionally omitted.
|
|
18
|
+
* Profile coordinates must fit finite Float32 mesh storage.
|
|
19
|
+
*
|
|
20
|
+
* @param profile - Closed CCW `[radius, z]` profile without a duplicated seam point.
|
|
21
|
+
* @param options - Optional physical or exact circumferential sampling controls. Defaults to `{}`.
|
|
22
|
+
* @returns A closed indexed triangle mesh revolved counter-clockwise around +Z.
|
|
23
|
+
*
|
|
24
|
+
* @example Partial revolution with end caps
|
|
25
|
+
* ```ts
|
|
26
|
+
* // Each point is [radius, Z]; the profile seam is implicit.
|
|
27
|
+
* revolveMesh([[0, 0], [12, 0], [8, 20], [0, 20]], { revolveDegrees: 270 });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export const revolveMesh = (profile, options = {}) => {
|
|
31
|
+
if (typeof options !== "object" || options === null || Array.isArray(options))
|
|
32
|
+
throw new GeometryError("INVALID_ARGUMENT", "Revolution options require an object.", {
|
|
33
|
+
operation: "revolveMesh",
|
|
34
|
+
});
|
|
35
|
+
const degrees = options.revolveDegrees ?? 360;
|
|
36
|
+
if (!Number.isFinite(degrees) || degrees <= 0 || degrees > 360)
|
|
37
|
+
throw new GeometryError("INVALID_ARGUMENT", "revolveDegrees must be in (0, 360].", {
|
|
38
|
+
operation: "revolveMesh",
|
|
39
|
+
});
|
|
40
|
+
if (profile.length < 3) {
|
|
41
|
+
throw new Error("A revolved profile needs at least three points.");
|
|
42
|
+
}
|
|
43
|
+
const limits = resolveModelingLimits(options.limits);
|
|
44
|
+
assertSamplingCount(profile.length, "revolveMesh", limits.maxProfilePoints);
|
|
45
|
+
let maximumRadius = 0;
|
|
46
|
+
for (const [radius, z] of profile) {
|
|
47
|
+
if (!Number.isFinite(radius) || !Number.isFinite(z) || radius < 0) {
|
|
48
|
+
throw new Error("Revolved profile coordinates must be finite and radii non-negative.");
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isFinite(Math.fround(radius)) || !Number.isFinite(Math.fround(z))) {
|
|
51
|
+
throw new GeometryError("NON_FINITE_VALUE", "Revolved profile coordinates exceed finite Float32 mesh storage.", { operation: "revolveMesh" });
|
|
52
|
+
}
|
|
53
|
+
maximumRadius = Math.max(maximumRadius, radius);
|
|
54
|
+
}
|
|
55
|
+
profile = normalizeLoop2(profile, true);
|
|
56
|
+
const circularSegments = resolveCircularPrimitiveSegments(maximumRadius, options, "revolveMesh");
|
|
57
|
+
const closed = degrees === 360;
|
|
58
|
+
const segments = closed
|
|
59
|
+
? circularSegments
|
|
60
|
+
: Math.max(1, Math.ceil((circularSegments * degrees) / 360));
|
|
61
|
+
const rings = closed ? segments : segments + 1;
|
|
62
|
+
const vertexCount = profile.reduce((count, [radius]) => count + (radius <= AXIS_EPSILON ? 1 : rings), 0);
|
|
63
|
+
const triangleCount = profile.reduce((count, [radius], index) => {
|
|
64
|
+
const currentOnAxis = radius <= AXIS_EPSILON;
|
|
65
|
+
const nextOnAxis = profile[(index + 1) % profile.length][0] <= AXIS_EPSILON;
|
|
66
|
+
return (count +
|
|
67
|
+
(currentOnAxis && nextOnAxis ? 0 : currentOnAxis || nextOnAxis ? segments : segments * 2));
|
|
68
|
+
}, 0) + (closed ? 0 : (profile.length - 2) * 2);
|
|
69
|
+
assertMeshAllocationWithinLimits(vertexCount, triangleCount, (vertexCount + triangleCount) * 12, "revolveMesh", limits);
|
|
70
|
+
const vertexIndices = profile.map(([radius]) => radius <= AXIS_EPSILON ? new Uint32Array(1) : new Uint32Array(rings));
|
|
71
|
+
const positions = new Float32Array(vertexCount * 3);
|
|
72
|
+
let vertex = 0;
|
|
73
|
+
for (let profileIndex = 0; profileIndex < profile.length; profileIndex += 1) {
|
|
74
|
+
const [radius, z] = profile[profileIndex];
|
|
75
|
+
const indices = vertexIndices[profileIndex];
|
|
76
|
+
if (radius <= AXIS_EPSILON) {
|
|
77
|
+
indices[0] = vertex;
|
|
78
|
+
writeMeshVertex(positions, vertex++ * 3, [0, 0, z], "revolveMesh");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (let segment = 0; segment < rings; segment += 1) {
|
|
82
|
+
const angle = (segment * degrees * Math.PI) / 180 / segments;
|
|
83
|
+
indices[segment] = vertex;
|
|
84
|
+
writeMeshVertex(positions, vertex++ * 3, [radius * Math.cos(angle), radius * Math.sin(angle), z], "revolveMesh");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const indices = new Uint32Array(triangleCount * 3);
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
const triangle = (a, b, c) => {
|
|
90
|
+
indices[cursor++] = a;
|
|
91
|
+
indices[cursor++] = b;
|
|
92
|
+
indices[cursor++] = c;
|
|
93
|
+
};
|
|
94
|
+
for (let profileIndex = 0; profileIndex < profile.length; profileIndex += 1) {
|
|
95
|
+
const following = (profileIndex + 1) % profile.length;
|
|
96
|
+
const currentRadius = profile[profileIndex][0];
|
|
97
|
+
const followingRadius = profile[following][0];
|
|
98
|
+
const current = vertexIndices[profileIndex];
|
|
99
|
+
const next = vertexIndices[following];
|
|
100
|
+
const currentOnAxis = currentRadius <= AXIS_EPSILON;
|
|
101
|
+
const nextOnAxis = followingRadius <= AXIS_EPSILON;
|
|
102
|
+
if (currentOnAxis && nextOnAxis) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (let segment = 0; segment < segments; segment += 1) {
|
|
106
|
+
const nextSegment = closed ? (segment + 1) % segments : segment + 1;
|
|
107
|
+
if (currentOnAxis) {
|
|
108
|
+
triangle(current[0], next[nextSegment], next[segment]);
|
|
109
|
+
}
|
|
110
|
+
else if (nextOnAxis) {
|
|
111
|
+
triangle(current[segment], current[nextSegment], next[0]);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
triangle(current[segment], current[nextSegment], next[nextSegment]);
|
|
115
|
+
triangle(current[segment], next[nextSegment], next[segment]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!closed) {
|
|
120
|
+
const cap = triangulateLoop2(profile);
|
|
121
|
+
const at = (profileIndex, station) => vertexIndices[profileIndex][profile[profileIndex][0] <= AXIS_EPSILON ? 0 : station];
|
|
122
|
+
for (let offset = 0; offset < cap.length; offset += 3) {
|
|
123
|
+
const a = cap[offset], b = cap[offset + 1], c = cap[offset + 2];
|
|
124
|
+
triangle(at(a, 0), at(b, 0), at(c, 0));
|
|
125
|
+
triangle(at(a, segments), at(c, segments), at(b, segments));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return validateTriangleMesh({ positions, indices });
|
|
129
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
/** Conservative defaults; trusted callers can supply larger explicit ceilings. */
|
|
3
|
+
export const defaultGeometrySamplingLimits = Object.freeze({
|
|
4
|
+
maxPathPoints: 100_000,
|
|
5
|
+
maxProfilePoints: 100_000,
|
|
6
|
+
maxSweepProfilePoints: 4_096,
|
|
7
|
+
maxCircularSegments: 512,
|
|
8
|
+
});
|
|
9
|
+
/** Resolves finite positive safe-integer allocation ceilings without ambient state. */
|
|
10
|
+
export function resolveGeometrySamplingLimits(overrides = {}) {
|
|
11
|
+
if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides))
|
|
12
|
+
throw new GeometryError("INVALID_ARGUMENT", "Sampling limits must be an object.", {
|
|
13
|
+
operation: "geometrySamplingLimits",
|
|
14
|
+
});
|
|
15
|
+
const result = { ...defaultGeometrySamplingLimits, ...overrides };
|
|
16
|
+
for (const key of Object.keys(defaultGeometrySamplingLimits)) {
|
|
17
|
+
if (!Number.isSafeInteger(result[key]) || result[key] < (key === "maxCircularSegments" ? 3 : 1))
|
|
18
|
+
throw new GeometryError("INVALID_ARGUMENT", `${key} must be a positive safe integer${key === "maxCircularSegments" ? " of at least three" : ""}.`, { operation: "geometrySamplingLimits" });
|
|
19
|
+
}
|
|
20
|
+
return Object.freeze(result);
|
|
21
|
+
}
|
|
22
|
+
/** @internal Checks predictable allocations before constructing arrays or evaluating callbacks. */
|
|
23
|
+
export function assertSamplingCount(count, operation, maximum, message = `Sampling requires ${count} items; the configured ceiling and array capacity allow ${Math.min(maximum, 0xffff_ffff)}.`) {
|
|
24
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > maximum || count > 0xffff_ffff)
|
|
25
|
+
throw new GeometryError("COMPLEXITY_LIMIT", message, {
|
|
26
|
+
operation,
|
|
27
|
+
details: { requiredCount: count, allowedCount: Math.min(maximum, 0xffff_ffff) },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { unionPolygonGeometry } from "./polygon-clipping.js";
|
|
3
|
+
import { normalizeRegion2 } from "./regions.js";
|
|
4
|
+
import { resolveCircularSegments } from "./resolution.js";
|
|
5
|
+
import { assertSamplingCount, resolveGeometrySamplingLimits, } from "./sampling-limits.js";
|
|
6
|
+
import { resolveGeometryTolerance } from "./tolerance.js";
|
|
7
|
+
const DEFAULT_MITER_LIMIT = 4;
|
|
8
|
+
function fail(message) {
|
|
9
|
+
throw new GeometryError("INVALID_ARGUMENT", message, { operation: "strokePathRegion" });
|
|
10
|
+
}
|
|
11
|
+
function ring(points) {
|
|
12
|
+
const result = points.map(([x, y]) => [x, y]);
|
|
13
|
+
const first = result[0];
|
|
14
|
+
const last = result.at(-1);
|
|
15
|
+
if (first !== undefined && last !== undefined && (first[0] !== last[0] || first[1] !== last[1])) {
|
|
16
|
+
result.push([...first]);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function ellipsePoints(center, radius, segments) {
|
|
21
|
+
return Array.from({ length: segments }, (_, index) => {
|
|
22
|
+
const angle = (index * Math.PI * 2) / segments;
|
|
23
|
+
return [center[0] + Math.cos(angle) * radius, center[1] + Math.sin(angle) * radius];
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function circlePolygon(center, radius, segments) {
|
|
27
|
+
return [ring(ellipsePoints(center, radius, segments))];
|
|
28
|
+
}
|
|
29
|
+
function cornerPolygon(previous, point, next, halfWidth, join) {
|
|
30
|
+
const incomingLength = Math.hypot(point[0] - previous[0], point[1] - previous[1]);
|
|
31
|
+
const outgoingLength = Math.hypot(next[0] - point[0], next[1] - point[1]);
|
|
32
|
+
const incoming = [
|
|
33
|
+
(point[0] - previous[0]) / incomingLength,
|
|
34
|
+
(point[1] - previous[1]) / incomingLength,
|
|
35
|
+
];
|
|
36
|
+
const outgoing = [
|
|
37
|
+
(next[0] - point[0]) / outgoingLength,
|
|
38
|
+
(next[1] - point[1]) / outgoingLength,
|
|
39
|
+
];
|
|
40
|
+
const turn = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
|
|
41
|
+
if (Math.abs(turn) <= Number.EPSILON * 8)
|
|
42
|
+
return undefined;
|
|
43
|
+
const side = turn > 0 ? -1 : 1;
|
|
44
|
+
const before = [-incoming[1] * halfWidth * side, incoming[0] * halfWidth * side];
|
|
45
|
+
const after = [-outgoing[1] * halfWidth * side, outgoing[0] * halfWidth * side];
|
|
46
|
+
const points = [point, [point[0] + before[0], point[1] + before[1]]];
|
|
47
|
+
const denominator = 1 + incoming[0] * outgoing[0] + incoming[1] * outgoing[1];
|
|
48
|
+
// The outer offset-line intersection has radius halfWidth / cos(turnAngle / 2).
|
|
49
|
+
// Apply SVG's miter ratio before division so sharp reversals cannot create an unbounded tip.
|
|
50
|
+
if (join === "miter" && denominator >= 2 / DEFAULT_MITER_LIMIT ** 2) {
|
|
51
|
+
points.push([
|
|
52
|
+
point[0] + (before[0] + after[0]) / denominator,
|
|
53
|
+
point[1] + (before[1] + after[1]) / denominator,
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
points.push([point[0] + after[0], point[1] + after[1]]);
|
|
57
|
+
return [ring(points)];
|
|
58
|
+
}
|
|
59
|
+
/** Removes consecutive points that cannot define a visible stroke segment at model scale. */
|
|
60
|
+
function deduplicatePath(path, closed, width) {
|
|
61
|
+
const scale = Math.max(width, ...path.flatMap(([x, y]) => [Math.abs(x), Math.abs(y)]));
|
|
62
|
+
const tolerance = resolveGeometryTolerance(scale).linear;
|
|
63
|
+
const points = [];
|
|
64
|
+
for (const point of path) {
|
|
65
|
+
const previous = points.at(-1);
|
|
66
|
+
if (previous === undefined ||
|
|
67
|
+
Math.hypot(point[0] - previous[0], point[1] - previous[1]) > tolerance) {
|
|
68
|
+
points.push(point);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (closed && points.length > 1) {
|
|
72
|
+
const first = points[0];
|
|
73
|
+
const last = points.at(-1);
|
|
74
|
+
if (Math.hypot(first[0] - last[0], first[1] - last[1]) <= tolerance)
|
|
75
|
+
points.pop();
|
|
76
|
+
}
|
|
77
|
+
return Object.freeze(points);
|
|
78
|
+
}
|
|
79
|
+
/** @internal Polygon-clipping representation shared with the bounded SVG importer. */
|
|
80
|
+
export function strokePathMultiPolygon(path, options, circularSegments) {
|
|
81
|
+
const closed = options.closed ?? false;
|
|
82
|
+
const cap = options.cap ?? "butt";
|
|
83
|
+
const join = options.join ?? "miter";
|
|
84
|
+
const half = options.width / 2;
|
|
85
|
+
const limits = resolveGeometrySamplingLimits(options.limits);
|
|
86
|
+
assertSamplingCount(path.length, "strokePathRegion", limits.maxPathPoints);
|
|
87
|
+
assertSamplingCount(circularSegments, "strokePathRegion", limits.maxCircularSegments);
|
|
88
|
+
const normalizedPath = deduplicatePath(path, closed, options.width);
|
|
89
|
+
if (normalizedPath.length < (closed ? 3 : 2))
|
|
90
|
+
return [];
|
|
91
|
+
const polygons = [];
|
|
92
|
+
const segmentCount = closed ? normalizedPath.length : normalizedPath.length - 1;
|
|
93
|
+
const joinCount = closed ? normalizedPath.length : Math.max(0, normalizedPath.length - 2);
|
|
94
|
+
const constructionVertices = segmentCount * 4 +
|
|
95
|
+
joinCount * (join === "round" ? circularSegments : join === "miter" ? 4 : 3) +
|
|
96
|
+
(!closed && cap === "round" ? 2 * circularSegments : 0);
|
|
97
|
+
assertSamplingCount(constructionVertices, "strokePathRegion", limits.maxProfilePoints);
|
|
98
|
+
for (let index = 0; index < segmentCount; index += 1) {
|
|
99
|
+
let start = normalizedPath[index];
|
|
100
|
+
let end = normalizedPath[(index + 1) % normalizedPath.length];
|
|
101
|
+
const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
102
|
+
const tangent = [(end[0] - start[0]) / length, (end[1] - start[1]) / length];
|
|
103
|
+
if (!closed && cap === "square") {
|
|
104
|
+
if (index === 0)
|
|
105
|
+
start = [start[0] - tangent[0] * half, start[1] - tangent[1] * half];
|
|
106
|
+
if (index === segmentCount - 1) {
|
|
107
|
+
end = [end[0] + tangent[0] * half, end[1] + tangent[1] * half];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const normal = [-tangent[1] * half, tangent[0] * half];
|
|
111
|
+
polygons.push([
|
|
112
|
+
ring([
|
|
113
|
+
[start[0] + normal[0], start[1] + normal[1]],
|
|
114
|
+
[end[0] + normal[0], end[1] + normal[1]],
|
|
115
|
+
[end[0] - normal[0], end[1] - normal[1]],
|
|
116
|
+
[start[0] - normal[0], start[1] - normal[1]],
|
|
117
|
+
]),
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
if (join === "round") {
|
|
121
|
+
const joinPoints = closed ? normalizedPath : normalizedPath.slice(1, -1);
|
|
122
|
+
polygons.push(...joinPoints.map((point) => circlePolygon(point, half, circularSegments)));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const first = closed ? 0 : 1;
|
|
126
|
+
const end = closed ? normalizedPath.length : normalizedPath.length - 1;
|
|
127
|
+
for (let index = first; index < end; index += 1) {
|
|
128
|
+
const corner = cornerPolygon(normalizedPath[(index - 1 + normalizedPath.length) % normalizedPath.length], normalizedPath[index], normalizedPath[(index + 1) % normalizedPath.length], half, join);
|
|
129
|
+
if (corner !== undefined)
|
|
130
|
+
polygons.push(corner);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!closed && cap === "round") {
|
|
134
|
+
polygons.push(circlePolygon(normalizedPath[0], half, circularSegments), circlePolygon(normalizedPath.at(-1), half, circularSegments));
|
|
135
|
+
}
|
|
136
|
+
const result = unionPolygonGeometry(polygons);
|
|
137
|
+
const outputVertices = result.reduce((count, polygon) => count + polygon.reduce((total, contour) => total + Math.max(0, contour.length - 1), 0), 0);
|
|
138
|
+
assertSamplingCount(outputVertices, "strokePathRegion", limits.maxProfilePoints);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
function regionFromMultiPolygon(value) {
|
|
142
|
+
return normalizeRegion2(value.map((polygon) => ({
|
|
143
|
+
outer: polygon[0].slice(0, -1).map(([x, y]) => [x, y]),
|
|
144
|
+
holes: polygon.slice(1).map((hole) => hole.slice(0, -1).map(([x, y]) => [x, y])),
|
|
145
|
+
})));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Converts a sampled millimetre path into the filled region covered by a centered stroke.
|
|
149
|
+
*
|
|
150
|
+
* Closed paths omit a duplicated seam point. Zero-length sampled segments are ignored, while the
|
|
151
|
+
* effective path must still contain visible length. Round caps and joins use deterministic physical
|
|
152
|
+
* resolution. Bevels fill the outer corner triangle; miters extend the outer edges to their
|
|
153
|
+
* intersection, falling back to bevels above SVG's default miter limit of 4.
|
|
154
|
+
*/
|
|
155
|
+
export function strokePathRegion(path, options) {
|
|
156
|
+
const closed = options.closed ?? false;
|
|
157
|
+
if (!Number.isFinite(options.width) || options.width <= 0) {
|
|
158
|
+
return fail("Stroke width must be finite and greater than zero.");
|
|
159
|
+
}
|
|
160
|
+
if (path.length < (closed ? 3 : 2)) {
|
|
161
|
+
return fail(`${closed ? "Closed" : "Open"} strokes need more sampled path points.`);
|
|
162
|
+
}
|
|
163
|
+
if (!path.every((point) => Number.isFinite(point[0]) && Number.isFinite(point[1]))) {
|
|
164
|
+
return fail("Stroke path coordinates must be finite.");
|
|
165
|
+
}
|
|
166
|
+
const cap = options.cap ?? "butt";
|
|
167
|
+
const join = options.join ?? "miter";
|
|
168
|
+
if (!["butt", "round", "square"].includes(cap)) {
|
|
169
|
+
return fail("Stroke cap must be butt, round, or square.");
|
|
170
|
+
}
|
|
171
|
+
if (!["bevel", "miter", "round"].includes(join)) {
|
|
172
|
+
return fail("Stroke join must be bevel, miter, or round.");
|
|
173
|
+
}
|
|
174
|
+
const circularSegments = cap === "round" || join === "round"
|
|
175
|
+
? resolveCircularSegments(options.width / 2, options.resolution, options.limits)
|
|
176
|
+
: 3;
|
|
177
|
+
const result = strokePathMultiPolygon(path, { ...options, cap, closed, join }, circularSegments);
|
|
178
|
+
if (result.length === 0)
|
|
179
|
+
return fail("The sampled stroke path has no visible length.");
|
|
180
|
+
return regionFromMultiPolygon(result);
|
|
181
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { GeometryError } from "../errors.js";
|
|
2
|
+
export function invalid(operation, message) {
|
|
3
|
+
throw new GeometryError("INVALID_ARGUMENT", message, { operation });
|
|
4
|
+
}
|
|
5
|
+
export function unsupported(operation, message) {
|
|
6
|
+
throw new GeometryError("UNSUPPORTED_RUNTIME", message, { operation });
|
|
7
|
+
}
|
|
8
|
+
export function emptyGeometry(operation, message) {
|
|
9
|
+
throw new GeometryError("DEGENERATE_GEOMETRY", message, { operation });
|
|
10
|
+
}
|
|
11
|
+
export function complexity(operation, message, details) {
|
|
12
|
+
throw new GeometryError("COMPLEXITY_LIMIT", message, {
|
|
13
|
+
operation,
|
|
14
|
+
...(details === undefined ? {} : { details }),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function rethrowAsInvalid(operation, error) {
|
|
18
|
+
if (error instanceof GeometryError) {
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
22
|
+
invalid(operation, `Invalid SVG input: ${detail}`);
|
|
23
|
+
}
|