@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,41 @@
|
|
|
1
|
+
import { GeometryError } from "../errors.js";
|
|
2
|
+
import { resolveCircularPrimitiveSegments } from "../primitive-resolution.js";
|
|
3
|
+
import { assertPrimitiveOptions } from "../primitive-resolvers.js";
|
|
4
|
+
import { requireFiniteProfileValues, requireProfileVertexCount } from "./validation.js";
|
|
5
|
+
/**
|
|
6
|
+
* Creates an immutable centered CCW ellipse with +X seam within the selected sampling ceiling.
|
|
7
|
+
*
|
|
8
|
+
* @example Ellipse by full dimensions
|
|
9
|
+
* ```ts
|
|
10
|
+
* ellipseLoop({ size: [30, 20] });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export function ellipseLoop(options) {
|
|
14
|
+
assertPrimitiveOptions(options, "ellipseLoop");
|
|
15
|
+
if ((options.radius === undefined) === (options.size === undefined)) {
|
|
16
|
+
throw new GeometryError("INVALID_ARGUMENT", "Specify exactly one radius or size tuple.", {
|
|
17
|
+
operation: "ellipseLoop",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const dimensions = options.radius ?? options.size;
|
|
21
|
+
if (!Array.isArray(dimensions) || dimensions.length !== 2) {
|
|
22
|
+
throw new GeometryError("INVALID_ARGUMENT", "Ellipse dimensions must be an exact XY tuple.", {
|
|
23
|
+
operation: "ellipseLoop",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const factor = options.radius === undefined ? 0.5 : 1;
|
|
27
|
+
const radiusX = dimensions[0] * factor;
|
|
28
|
+
const radiusY = dimensions[1] * factor;
|
|
29
|
+
requireFiniteProfileValues([radiusX, radiusY], "ellipseLoop");
|
|
30
|
+
if (![radiusX, radiusY].every((value) => Number.isFinite(value) && value > 0)) {
|
|
31
|
+
throw new GeometryError("INVALID_ARGUMENT", "Ellipse radii must be finite and positive.", {
|
|
32
|
+
operation: "ellipseLoop",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const segments = resolveCircularPrimitiveSegments(Math.max(radiusX, radiusY), options, "ellipseLoop");
|
|
36
|
+
requireProfileVertexCount(segments, 3, "ellipseLoop", options.limits);
|
|
37
|
+
return Object.freeze(Array.from({ length: segments }, (_, index) => {
|
|
38
|
+
const angle = (index * Math.PI * 2) / segments;
|
|
39
|
+
return Object.freeze([radiusX * Math.cos(angle), radiusY * Math.sin(angle)]);
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CubicEvaluator, planarCubicControl } from "../curves/evaluation.js";
|
|
2
|
+
/** @internal Samples validated profile spans, retaining exact cardinal arc endpoints. */
|
|
3
|
+
export function createProfileSpanSampler(span) {
|
|
4
|
+
if (span.kind === "line")
|
|
5
|
+
return (t) => [
|
|
6
|
+
span.start[0] + (span.end[0] - span.start[0]) * t,
|
|
7
|
+
span.start[1] + (span.end[1] - span.start[1]) * t,
|
|
8
|
+
];
|
|
9
|
+
if (span.kind === "arc")
|
|
10
|
+
return (t) => {
|
|
11
|
+
const angle = ((span.startDegrees + span.sweepDegrees * t) * Math.PI) / 180;
|
|
12
|
+
const snap = (value) => Math.abs(value) < 1e-14
|
|
13
|
+
? 0
|
|
14
|
+
: Math.abs(Math.abs(value) - 1) < 1e-14
|
|
15
|
+
? Math.sign(value)
|
|
16
|
+
: value;
|
|
17
|
+
return [
|
|
18
|
+
span.center[0] + span.radius * snap(Math.cos(angle)),
|
|
19
|
+
span.center[1] + span.radius * snap(Math.sin(angle)),
|
|
20
|
+
];
|
|
21
|
+
};
|
|
22
|
+
const evaluator = new CubicEvaluator(planarCubicControl(span));
|
|
23
|
+
const point = [0, 0, 0];
|
|
24
|
+
return (t) => {
|
|
25
|
+
evaluator.point(t, point);
|
|
26
|
+
return [point[0], point[1]];
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { GeometryError } from "../errors.js";
|
|
2
|
+
import { assertSamplingCount, resolveGeometrySamplingLimits, } from "../sampling-limits.js";
|
|
3
|
+
/** @internal Validates finite dimensions shared by the pure profile factories. */
|
|
4
|
+
export function requireFiniteProfileValues(values, operation) {
|
|
5
|
+
if (!values.every(Number.isFinite)) {
|
|
6
|
+
throw new GeometryError("NON_FINITE_VALUE", "Profile dimensions and angles must be finite.", {
|
|
7
|
+
operation,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** @internal Checks an exact profile count before allocating vertices. */
|
|
12
|
+
export function requireProfileVertexCount(count, minimum, operation, limits = {}) {
|
|
13
|
+
if (!Number.isSafeInteger(count) || count < minimum) {
|
|
14
|
+
throw new GeometryError("INVALID_ARGUMENT", `A profile needs at least ${minimum} samples.`, {
|
|
15
|
+
operation,
|
|
16
|
+
details: { count },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
assertSamplingCount(count, operation, resolveGeometrySamplingLimits(limits).maxProfilePoints);
|
|
20
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { resolveGeometryTolerance } from "./tolerance.js";
|
|
3
|
+
// Only our deeply frozen, canonical outputs enter these registries. Object.isFrozen alone
|
|
4
|
+
// cannot establish validation provenance, and caller-owned inputs must never be cached.
|
|
5
|
+
const normalizedLoops = new WeakMap();
|
|
6
|
+
const normalizedPolygons = new WeakMap();
|
|
7
|
+
const normalizedRegions = new WeakMap();
|
|
8
|
+
const samePolicy = (a, b) => a !== undefined &&
|
|
9
|
+
a.absolute === b.absolute &&
|
|
10
|
+
a.relative === b.relative &&
|
|
11
|
+
a.angular === b.angular;
|
|
12
|
+
const reusableLoop = (loop, counterClockwise, policy) => {
|
|
13
|
+
const known = normalizedLoops.get(loop);
|
|
14
|
+
return known?.counterClockwise === counterClockwise && samePolicy(known.policy, policy);
|
|
15
|
+
};
|
|
16
|
+
const cross2 = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
17
|
+
const distanceSquared = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
|
|
18
|
+
const coordinateScale = (loop) => {
|
|
19
|
+
if (loop.length === 0)
|
|
20
|
+
return 0;
|
|
21
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
22
|
+
for (const [x, y] of loop) {
|
|
23
|
+
minX = Math.min(minX, x);
|
|
24
|
+
minY = Math.min(minY, y);
|
|
25
|
+
maxX = Math.max(maxX, x);
|
|
26
|
+
maxY = Math.max(maxY, y);
|
|
27
|
+
}
|
|
28
|
+
return Math.max(maxX - minX, maxY - minY);
|
|
29
|
+
};
|
|
30
|
+
/** Returns the signed area of a non-duplicated loop; positive means CCW. */
|
|
31
|
+
export function signedAreaLoop2(loop) {
|
|
32
|
+
if (loop.length === 0)
|
|
33
|
+
return 0;
|
|
34
|
+
const origin = loop[0];
|
|
35
|
+
let twiceArea = 0, correction = 0;
|
|
36
|
+
for (let index = 0; index < loop.length; index += 1) {
|
|
37
|
+
const current = loop[index];
|
|
38
|
+
const next = loop[(index + 1) % loop.length];
|
|
39
|
+
const contribution = (current[0] - origin[0]) * (next[1] - origin[1]) -
|
|
40
|
+
(next[0] - origin[0]) * (current[1] - origin[1]);
|
|
41
|
+
const nextArea = twiceArea + contribution;
|
|
42
|
+
correction +=
|
|
43
|
+
Math.abs(twiceArea) >= Math.abs(contribution)
|
|
44
|
+
? twiceArea - nextArea + contribution
|
|
45
|
+
: contribution - nextArea + twiceArea;
|
|
46
|
+
twiceArea = nextArea;
|
|
47
|
+
}
|
|
48
|
+
return (twiceArea + correction) / 2;
|
|
49
|
+
}
|
|
50
|
+
/** Returns true when a point lies inside or on the boundary of a loop. */
|
|
51
|
+
export function loopContainsPoint(loop, point) {
|
|
52
|
+
let inside = false;
|
|
53
|
+
for (let index = 0, previous = loop.length - 1; index < loop.length; previous = index++) {
|
|
54
|
+
const a = loop[index];
|
|
55
|
+
const b = loop[previous];
|
|
56
|
+
const edgeCross = cross2(a, b, point);
|
|
57
|
+
if (Math.abs(edgeCross) <= Number.EPSILON &&
|
|
58
|
+
point[0] >= Math.min(a[0], b[0]) &&
|
|
59
|
+
point[0] <= Math.max(a[0], b[0]) &&
|
|
60
|
+
point[1] >= Math.min(a[1], b[1]) &&
|
|
61
|
+
point[1] <= Math.max(a[1], b[1])) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (a[1] > point[1] !== b[1] > point[1] &&
|
|
65
|
+
point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0]) {
|
|
66
|
+
inside = !inside;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return inside;
|
|
70
|
+
}
|
|
71
|
+
const orientation = (a, b, c, epsilon) => {
|
|
72
|
+
const value = cross2(a, b, c);
|
|
73
|
+
// A cross product is an area: a distance tolerance must be multiplied by edge length.
|
|
74
|
+
const areaTolerance = epsilon * Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
75
|
+
return Math.abs(value) <= areaTolerance ? 0 : value > 0 ? 1 : -1;
|
|
76
|
+
};
|
|
77
|
+
const onSegment = (a, b, point, epsilon) => point[0] >= Math.min(a[0], b[0]) - epsilon &&
|
|
78
|
+
point[0] <= Math.max(a[0], b[0]) + epsilon &&
|
|
79
|
+
point[1] >= Math.min(a[1], b[1]) - epsilon &&
|
|
80
|
+
point[1] <= Math.max(a[1], b[1]) + epsilon;
|
|
81
|
+
const segmentsIntersect = (a, b, c, d, epsilon) => {
|
|
82
|
+
const abC = orientation(a, b, c, epsilon);
|
|
83
|
+
const abD = orientation(a, b, d, epsilon);
|
|
84
|
+
const cdA = orientation(c, d, a, epsilon);
|
|
85
|
+
const cdB = orientation(c, d, b, epsilon);
|
|
86
|
+
if (abC * abD < 0 && cdA * cdB < 0)
|
|
87
|
+
return true;
|
|
88
|
+
return ((abC === 0 && onSegment(a, b, c, epsilon)) ||
|
|
89
|
+
(abD === 0 && onSegment(a, b, d, epsilon)) ||
|
|
90
|
+
(cdA === 0 && onSegment(c, d, a, epsilon)) ||
|
|
91
|
+
(cdB === 0 && onSegment(c, d, b, epsilon)));
|
|
92
|
+
};
|
|
93
|
+
const MAX_INTERSECTION_CANDIDATES = 2_000_000;
|
|
94
|
+
// This private helper only receives the immutable loops produced by normalization.
|
|
95
|
+
const normalizedLoopBounds = new WeakMap();
|
|
96
|
+
const loopBounds = (loop) => {
|
|
97
|
+
const known = normalizedLoopBounds.get(loop);
|
|
98
|
+
if (known !== undefined)
|
|
99
|
+
return known;
|
|
100
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
101
|
+
for (const [x, y] of loop) {
|
|
102
|
+
minX = Math.min(minX, x);
|
|
103
|
+
minY = Math.min(minY, y);
|
|
104
|
+
maxX = Math.max(maxX, x);
|
|
105
|
+
maxY = Math.max(maxY, y);
|
|
106
|
+
}
|
|
107
|
+
const result = { minX, minY, maxX, maxY };
|
|
108
|
+
normalizedLoopBounds.set(loop, result);
|
|
109
|
+
return result;
|
|
110
|
+
};
|
|
111
|
+
const boundsOverlap = (a, b, epsilon) => a.maxX + epsilon >= b.minX &&
|
|
112
|
+
b.maxX + epsilon >= a.minX &&
|
|
113
|
+
a.maxY + epsilon >= b.minY &&
|
|
114
|
+
b.maxY + epsilon >= a.minY;
|
|
115
|
+
const boundedSegments = (loop, epsilon) => loop.map((a, index) => {
|
|
116
|
+
const b = loop[(index + 1) % loop.length];
|
|
117
|
+
return {
|
|
118
|
+
a,
|
|
119
|
+
b,
|
|
120
|
+
index,
|
|
121
|
+
loop,
|
|
122
|
+
minX: Math.min(a[0], b[0]) - epsilon,
|
|
123
|
+
maxX: Math.max(a[0], b[0]) + epsilon,
|
|
124
|
+
minY: Math.min(a[1], b[1]) - epsilon,
|
|
125
|
+
maxY: Math.max(a[1], b[1]) + epsilon,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
const consumeIntersectionCandidate = (budget) => {
|
|
129
|
+
budget.candidates += 1;
|
|
130
|
+
if (budget.candidates > MAX_INTERSECTION_CANDIDATES) {
|
|
131
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "Region validation exceeds the 2000000 intersection-candidate work limit.", { operation: "normalizeRegion2", details: { maximumCandidates: MAX_INTERSECTION_CANDIDATES } });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const boundedIntersections = (segments, epsilon, budget, sameLoop) => {
|
|
135
|
+
segments.sort((a, b) => a.minX - b.minX || a.minY - b.minY || a.index - b.index);
|
|
136
|
+
const firstLoop = segments[0]?.loop;
|
|
137
|
+
const active = [[], []];
|
|
138
|
+
for (const current of segments) {
|
|
139
|
+
const ownGroup = sameLoop || current.loop === firstLoop ? 0 : 1;
|
|
140
|
+
const candidates = active[sameLoop ? 0 : 1 - ownGroup];
|
|
141
|
+
let retained = 0;
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (candidate.maxX < current.minX)
|
|
144
|
+
continue;
|
|
145
|
+
candidates[retained++] = candidate;
|
|
146
|
+
consumeIntersectionCandidate(budget);
|
|
147
|
+
if (candidate.maxY < current.minY || current.maxY < candidate.minY)
|
|
148
|
+
continue;
|
|
149
|
+
if (sameLoop) {
|
|
150
|
+
if ((candidate.index + 1) % current.loop.length === current.index ||
|
|
151
|
+
(current.index + 1) % current.loop.length === candidate.index)
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (segmentsIntersect(candidate.a, candidate.b, current.a, current.b, epsilon))
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
candidates.length = retained;
|
|
158
|
+
active[ownGroup].push(current);
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
};
|
|
162
|
+
const loopsIntersect = (a, b, epsilon, budget) => a === b ||
|
|
163
|
+
(boundsOverlap(loopBounds(a), loopBounds(b), 2 * epsilon) &&
|
|
164
|
+
boundedIntersections([...boundedSegments(a, epsilon), ...boundedSegments(b, epsilon)], epsilon, budget, false));
|
|
165
|
+
/**
|
|
166
|
+
* Cleans and validates one closed loop.
|
|
167
|
+
*
|
|
168
|
+
* Adjacent duplicates and a repeated seam point are removed. Coordinates must
|
|
169
|
+
* be finite, the effective loop must contain at least three points, and the
|
|
170
|
+
* result must be simple and have non-zero area. Previously validated immutable results may be
|
|
171
|
+
* returned unchanged when their winding and tolerance policy match.
|
|
172
|
+
*/
|
|
173
|
+
export function normalizeLoop2(input, counterClockwise, tolerance = {}) {
|
|
174
|
+
return normalizeLoopWithBudget(input, counterClockwise, tolerance, { candidates: 0 });
|
|
175
|
+
}
|
|
176
|
+
const normalizeLoopWithBudget = (input, counterClockwise, tolerance, budget) => {
|
|
177
|
+
const policy = resolveGeometryTolerance(1, tolerance);
|
|
178
|
+
if (reusableLoop(input, counterClockwise, policy))
|
|
179
|
+
return input;
|
|
180
|
+
if (!input.every((point) => Number.isFinite(point[0]) && Number.isFinite(point[1]) && point.every(Number.isFinite))) {
|
|
181
|
+
throw new GeometryError("NON_FINITE_VALUE", "Loop coordinates must be finite.");
|
|
182
|
+
}
|
|
183
|
+
const scale = coordinateScale(input);
|
|
184
|
+
const resolved = resolveGeometryTolerance(scale, policy);
|
|
185
|
+
const epsilonSquared = resolved.linear ** 2;
|
|
186
|
+
const cleaned = [];
|
|
187
|
+
for (const point of input) {
|
|
188
|
+
const copied = Object.freeze([point[0], point[1]]);
|
|
189
|
+
if (cleaned.length === 0 || distanceSquared(cleaned.at(-1), copied) > epsilonSquared) {
|
|
190
|
+
cleaned.push(copied);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
while (cleaned.length > 1 && distanceSquared(cleaned[0], cleaned.at(-1)) <= epsilonSquared) {
|
|
194
|
+
cleaned.pop();
|
|
195
|
+
}
|
|
196
|
+
if (cleaned.length < 3) {
|
|
197
|
+
throw new GeometryError("DEGENERATE_GEOMETRY", "A loop needs at least three distinct points.");
|
|
198
|
+
}
|
|
199
|
+
if (boundedIntersections(boundedSegments(cleaned, resolved.linear), resolved.linear, budget, true)) {
|
|
200
|
+
throw new GeometryError("SELF_INTERSECTION", "A loop must not self-intersect.");
|
|
201
|
+
}
|
|
202
|
+
const area = signedAreaLoop2(cleaned);
|
|
203
|
+
if (!Number.isFinite(area)) {
|
|
204
|
+
throw new GeometryError("NON_FINITE_VALUE", "Loop area must be finite.");
|
|
205
|
+
}
|
|
206
|
+
if (Math.abs(area) <= resolved.area) {
|
|
207
|
+
throw new GeometryError("DEGENERATE_GEOMETRY", "A loop must have non-zero area.");
|
|
208
|
+
}
|
|
209
|
+
const ordered = area > 0 === counterClockwise ? cleaned : [...cleaned].reverse();
|
|
210
|
+
const result = Object.freeze(ordered);
|
|
211
|
+
// Cleanup can change the scale used by relative tolerances, or expose another short seam.
|
|
212
|
+
// Such outputs must take the ordinary path on the next call until normalization is stable.
|
|
213
|
+
if (resolveGeometryTolerance(coordinateScale(result), policy).linear === resolved.linear &&
|
|
214
|
+
distanceSquared(result[0], result.at(-1)) > epsilonSquared) {
|
|
215
|
+
normalizedLoops.set(result, { policy, counterClockwise });
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Validates explicit polygon topology and normalizes its winding.
|
|
221
|
+
*
|
|
222
|
+
* Exteriors become CCW and holes clockwise. Every hole must lie strictly
|
|
223
|
+
* within its exterior and boundaries may not intersect. Previously validated immutable results
|
|
224
|
+
* may be returned unchanged under the same tolerance policy.
|
|
225
|
+
*/
|
|
226
|
+
export function normalizePolygon2(polygon, tolerance = {}) {
|
|
227
|
+
return normalizePolygonWithBudget(polygon, tolerance, { candidates: 0 });
|
|
228
|
+
}
|
|
229
|
+
const normalizePolygonWithBudget = (polygon, tolerance, budget) => {
|
|
230
|
+
const policy = resolveGeometryTolerance(1, tolerance);
|
|
231
|
+
if (samePolicy(normalizedPolygons.get(polygon), policy))
|
|
232
|
+
return polygon;
|
|
233
|
+
const outer = normalizeLoopWithBudget(polygon.outer, true, policy, budget);
|
|
234
|
+
const holes = polygon.holes.map((hole) => normalizeLoopWithBudget(hole, false, policy, budget));
|
|
235
|
+
const scale = coordinateScale(outer);
|
|
236
|
+
const epsilon = resolveGeometryTolerance(scale, policy).linear;
|
|
237
|
+
for (let index = 0; index < holes.length; index += 1) {
|
|
238
|
+
const hole = holes[index];
|
|
239
|
+
if (!loopContainsPoint(outer, hole[0]) || loopsIntersect(outer, hole, epsilon, budget)) {
|
|
240
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Every hole must lie inside its exterior.");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const boundedHoles = holes
|
|
244
|
+
.map((loop, index) => ({ loop, index, ...loopBounds(loop) }))
|
|
245
|
+
.sort((a, b) => a.minX - b.minX || a.minY - b.minY || a.index - b.index);
|
|
246
|
+
const activeHoles = [];
|
|
247
|
+
for (const current of boundedHoles) {
|
|
248
|
+
let retained = 0;
|
|
249
|
+
for (const previous of activeHoles) {
|
|
250
|
+
if (previous.maxX + 2 * epsilon < current.minX)
|
|
251
|
+
continue;
|
|
252
|
+
activeHoles[retained++] = previous;
|
|
253
|
+
consumeIntersectionCandidate(budget);
|
|
254
|
+
if (!boundsOverlap(previous, current, 2 * epsilon))
|
|
255
|
+
continue;
|
|
256
|
+
if (loopsIntersect(previous.loop, current.loop, epsilon, budget) ||
|
|
257
|
+
loopContainsPoint(previous.loop, current.loop[0]) ||
|
|
258
|
+
loopContainsPoint(current.loop, previous.loop[0])) {
|
|
259
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Polygon holes must be disjoint.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
activeHoles.length = retained;
|
|
263
|
+
activeHoles.push(current);
|
|
264
|
+
}
|
|
265
|
+
const result = Object.freeze({ holes: Object.freeze(holes), outer });
|
|
266
|
+
if (reusableLoop(outer, true, policy) &&
|
|
267
|
+
holes.every((hole) => reusableLoop(hole, false, policy))) {
|
|
268
|
+
normalizedPolygons.set(result, policy);
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
};
|
|
272
|
+
/**
|
|
273
|
+
* Validates a disjoint explicit region and returns immutable normalized data.
|
|
274
|
+
* Previously validated immutable results may be returned unchanged under the same tolerance policy.
|
|
275
|
+
*/
|
|
276
|
+
export function normalizeRegion2(region, tolerance = {}) {
|
|
277
|
+
const policy = resolveGeometryTolerance(1, tolerance);
|
|
278
|
+
if (samePolicy(normalizedRegions.get(region), policy))
|
|
279
|
+
return region;
|
|
280
|
+
for (let index = 0; index < region.length; index += 1) {
|
|
281
|
+
if (!Object.hasOwn(region, index)) {
|
|
282
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Region polygons must not contain missing entries.");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const budget = { candidates: 0 };
|
|
286
|
+
const polygons = region.map((polygon) => normalizePolygonWithBudget(polygon, policy, budget));
|
|
287
|
+
const containsFilledPoint = (polygon, point) => loopContainsPoint(polygon.outer, point) &&
|
|
288
|
+
!polygon.holes.some((hole) => loopContainsPoint(hole, point));
|
|
289
|
+
// Broad phase preserves exact contact semantics while avoiding pairwise loop
|
|
290
|
+
// walks for separated polygons (for example many disconnected QR modules).
|
|
291
|
+
const bounded = polygons
|
|
292
|
+
.map((polygon) => {
|
|
293
|
+
let minX = Infinity;
|
|
294
|
+
let minY = Infinity;
|
|
295
|
+
let maxX = -Infinity;
|
|
296
|
+
let maxY = -Infinity;
|
|
297
|
+
for (const [x, y] of polygon.outer) {
|
|
298
|
+
minX = Math.min(minX, x);
|
|
299
|
+
minY = Math.min(minY, y);
|
|
300
|
+
maxX = Math.max(maxX, x);
|
|
301
|
+
maxY = Math.max(maxY, y);
|
|
302
|
+
}
|
|
303
|
+
return { polygon, minX, minY, maxX, maxY };
|
|
304
|
+
})
|
|
305
|
+
.sort((a, b) => a.minX - b.minX);
|
|
306
|
+
const active = [];
|
|
307
|
+
for (const current of bounded) {
|
|
308
|
+
let retained = 0;
|
|
309
|
+
for (const candidate of active) {
|
|
310
|
+
if (candidate.maxX < current.minX)
|
|
311
|
+
continue;
|
|
312
|
+
active[retained++] = candidate;
|
|
313
|
+
consumeIntersectionCandidate(budget);
|
|
314
|
+
if (candidate.maxY < current.minY || current.maxY < candidate.minY)
|
|
315
|
+
continue;
|
|
316
|
+
const a = current.polygon.outer;
|
|
317
|
+
const b = candidate.polygon.outer;
|
|
318
|
+
if (loopsIntersect(a, b, 0, budget) ||
|
|
319
|
+
current.polygon.holes.some((hole) => loopsIntersect(hole, b, 0, budget)) ||
|
|
320
|
+
candidate.polygon.holes.some((hole) => loopsIntersect(a, hole, 0, budget)) ||
|
|
321
|
+
containsFilledPoint(current.polygon, b[0]) ||
|
|
322
|
+
containsFilledPoint(candidate.polygon, a[0])) {
|
|
323
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Region polygons must be disjoint.");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
active.length = retained;
|
|
327
|
+
active.push(current);
|
|
328
|
+
}
|
|
329
|
+
const result = Object.freeze(polygons);
|
|
330
|
+
if (polygons.every((polygon) => samePolicy(normalizedPolygons.get(polygon), policy))) {
|
|
331
|
+
normalizedRegions.set(result, policy);
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Returns the total filled area of a normalized region in square millimetres.
|
|
337
|
+
*
|
|
338
|
+
* Each polygon contributes its outer-loop area minus the areas of its holes. An empty region has
|
|
339
|
+
* zero area. Call {@link normalizeRegion2} first when input winding or topology is not already
|
|
340
|
+
* canonical.
|
|
341
|
+
*
|
|
342
|
+
* @param region - Normalized disjoint polygons with CCW outers and clockwise holes.
|
|
343
|
+
* @returns Total filled area in square millimetres.
|
|
344
|
+
*/
|
|
345
|
+
export function regionArea(region) {
|
|
346
|
+
return region.reduce((total, polygon) => total +
|
|
347
|
+
Math.abs(signedAreaLoop2(polygon.outer)) -
|
|
348
|
+
polygon.holes.reduce((holeTotal, hole) => holeTotal + Math.abs(signedAreaLoop2(hole)), 0), 0);
|
|
349
|
+
}
|
|
350
|
+
/** Returns finite axis-aligned bounds for a non-empty region. */
|
|
351
|
+
export function regionBounds(region) {
|
|
352
|
+
if (region.length === 0) {
|
|
353
|
+
throw new GeometryError("DEGENERATE_GEOMETRY", "An empty region has no bounds.");
|
|
354
|
+
}
|
|
355
|
+
let minX = Infinity;
|
|
356
|
+
let minY = Infinity;
|
|
357
|
+
let maxX = -Infinity;
|
|
358
|
+
let maxY = -Infinity;
|
|
359
|
+
for (const polygon of region) {
|
|
360
|
+
for (const [x, y] of polygon.outer) {
|
|
361
|
+
minX = Math.min(minX, x);
|
|
362
|
+
minY = Math.min(minY, y);
|
|
363
|
+
maxX = Math.max(maxX, x);
|
|
364
|
+
maxY = Math.max(maxY, y);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return Object.freeze({
|
|
368
|
+
max: Object.freeze([maxX, maxY]),
|
|
369
|
+
min: Object.freeze([minX, minY]),
|
|
370
|
+
});
|
|
371
|
+
}
|