@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.
Files changed (131) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/LICENSE +200 -0
  3. package/README.md +376 -2
  4. package/THIRD_PARTY_NOTICES.md +153 -0
  5. package/dist/api/engine.d.ts +71 -0
  6. package/dist/api/geometry.d.ts +3136 -0
  7. package/dist/api/index.d.ts +2087 -0
  8. package/dist/api/text.d.ts +93 -0
  9. package/dist/engine/construction-operations.js +189 -0
  10. package/dist/engine/execution.js +227 -0
  11. package/dist/engine/export-3mf.js +100 -0
  12. package/dist/engine/extrusion-finishing.js +82 -0
  13. package/dist/engine/finalization.js +214 -0
  14. package/dist/engine/index.js +83 -0
  15. package/dist/engine/inspection-operations.js +52 -0
  16. package/dist/engine/kernel/LICENSE +201 -0
  17. package/dist/engine/kernel/manifold.js +2 -0
  18. package/dist/engine/kernel/manifold.wasm +0 -0
  19. package/dist/engine/loft-operations.js +156 -0
  20. package/dist/engine/mechanical-operations.js +51 -0
  21. package/dist/engine/modeling-context.js +38 -0
  22. package/dist/engine/native/adapter.js +20 -0
  23. package/dist/engine/native/context.js +209 -0
  24. package/dist/engine/native/copied-views.js +46 -0
  25. package/dist/engine/native/index.js +13 -0
  26. package/dist/engine/native/operations.js +115 -0
  27. package/dist/engine/native/ownership.js +98 -0
  28. package/dist/engine/native/regions.js +138 -0
  29. package/dist/engine/native/scoped-primitives.js +127 -0
  30. package/dist/engine/native/scoped-threads.js +34 -0
  31. package/dist/engine/native/threads.js +52 -0
  32. package/dist/engine/native/transforms.js +111 -0
  33. package/dist/engine/operation-environment.js +20 -0
  34. package/dist/engine/pattern-operations.js +36 -0
  35. package/dist/engine/pattern-placements.js +176 -0
  36. package/dist/engine/primitive-anchors.js +70 -0
  37. package/dist/engine/primitive-correspondence.js +74 -0
  38. package/dist/engine/primitive-operations.js +151 -0
  39. package/dist/engine/primitive-profile-types.js +1 -0
  40. package/dist/engine/primitive-profiles.js +250 -0
  41. package/dist/engine/profile-mesh.js +279 -0
  42. package/dist/engine/section-operations.js +93 -0
  43. package/dist/engine/shape-anchors.js +88 -0
  44. package/dist/engine/transform-operations.js +225 -0
  45. package/dist/engine/types.js +1 -0
  46. package/dist/geometry/anchors.js +226 -0
  47. package/dist/geometry/curves/evaluation.js +162 -0
  48. package/dist/geometry/curves/sampling.js +266 -0
  49. package/dist/geometry/curves/types.js +1 -0
  50. package/dist/geometry/cylinder.js +117 -0
  51. package/dist/geometry/edge-finishing.js +160 -0
  52. package/dist/geometry/errors.js +76 -0
  53. package/dist/geometry/extrusion.js +150 -0
  54. package/dist/geometry/heightfield.js +115 -0
  55. package/dist/geometry/index.js +42 -0
  56. package/dist/geometry/loft-correspondence.js +204 -0
  57. package/dist/geometry/loft-geometry.js +184 -0
  58. package/dist/geometry/loft-options.js +56 -0
  59. package/dist/geometry/loft-simple-mesh.js +92 -0
  60. package/dist/geometry/loft-triangulation.js +64 -0
  61. package/dist/geometry/loft.js +95 -0
  62. package/dist/geometry/math/vector3.js +71 -0
  63. package/dist/geometry/mechanical/gear-motion.js +94 -0
  64. package/dist/geometry/mechanical/gears.js +484 -0
  65. package/dist/geometry/mechanical/index.js +13 -0
  66. package/dist/geometry/mechanical/standards.js +67 -0
  67. package/dist/geometry/mechanical/thread-helices.js +44 -0
  68. package/dist/geometry/mechanical/thread-mesh-plan.js +358 -0
  69. package/dist/geometry/mechanical/thread-mesh.js +142 -0
  70. package/dist/geometry/mechanical/thread-profiles.js +373 -0
  71. package/dist/geometry/mechanical/thread-solids.js +230 -0
  72. package/dist/geometry/mechanical/thread-surface.js +433 -0
  73. package/dist/geometry/mechanical/threads.js +138 -0
  74. package/dist/geometry/mesh-edge-table.js +126 -0
  75. package/dist/geometry/mesh-planar.js +408 -0
  76. package/dist/geometry/mesh-quality.js +396 -0
  77. package/dist/geometry/mesh-signature.js +137 -0
  78. package/dist/geometry/mesh.js +283 -0
  79. package/dist/geometry/model-limits.js +50 -0
  80. package/dist/geometry/numeric.js +11 -0
  81. package/dist/geometry/paths/frames.js +118 -0
  82. package/dist/geometry/paths/helix.js +49 -0
  83. package/dist/geometry/paths/path.js +32 -0
  84. package/dist/geometry/paths/planar.js +135 -0
  85. package/dist/geometry/paths/resampling.js +125 -0
  86. package/dist/geometry/polygon-clipping.js +52 -0
  87. package/dist/geometry/primitive-options.js +1 -0
  88. package/dist/geometry/primitive-resolution.js +17 -0
  89. package/dist/geometry/primitive-resolvers.js +235 -0
  90. package/dist/geometry/profiles/matched.js +192 -0
  91. package/dist/geometry/profiles/open-profile.js +137 -0
  92. package/dist/geometry/profiles/profiles.js +91 -0
  93. package/dist/geometry/profiles/rectangle.js +249 -0
  94. package/dist/geometry/profiles/shapes.js +41 -0
  95. package/dist/geometry/profiles/span-sampling.js +28 -0
  96. package/dist/geometry/profiles/types.js +1 -0
  97. package/dist/geometry/profiles/validation.js +20 -0
  98. package/dist/geometry/regions.js +371 -0
  99. package/dist/geometry/resolution.js +236 -0
  100. package/dist/geometry/revolve/revolve-mesh.js +129 -0
  101. package/dist/geometry/sampling-limits.js +29 -0
  102. package/dist/geometry/stroke-path.js +181 -0
  103. package/dist/geometry/svg/errors.js +23 -0
  104. package/dist/geometry/svg/fill.js +172 -0
  105. package/dist/geometry/svg/index.js +1 -0
  106. package/dist/geometry/svg/matrix.js +130 -0
  107. package/dist/geometry/svg/parser.js +536 -0
  108. package/dist/geometry/svg/path.js +349 -0
  109. package/dist/geometry/svg/types.js +1 -0
  110. package/dist/geometry/svg-import.js +662 -0
  111. package/dist/geometry/sweeps/options.js +28 -0
  112. package/dist/geometry/sweeps/orientation.js +106 -0
  113. package/dist/geometry/sweeps/stations.js +161 -0
  114. package/dist/geometry/sweeps/sweep-closed.js +96 -0
  115. package/dist/geometry/sweeps/sweep-mesh.js +30 -0
  116. package/dist/geometry/sweeps/sweep-open.js +76 -0
  117. package/dist/geometry/sweeps/validation.js +116 -0
  118. package/dist/geometry/text-contours.js +32 -0
  119. package/dist/geometry/text-layout.js +1 -0
  120. package/dist/geometry/tolerance.js +53 -0
  121. package/dist/geometry/transform-geometry.js +41 -0
  122. package/dist/geometry/transform3.js +340 -0
  123. package/dist/geometry/types.js +1 -0
  124. package/dist/index.js +1 -0
  125. package/dist/model/execution-binding.js +30 -0
  126. package/dist/model/operations.js +154 -0
  127. package/dist/model/options.js +1 -0
  128. package/dist/model/types.js +1 -0
  129. package/dist/text/index.js +58 -0
  130. package/dist/text/vector-text-layout.js +279 -0
  131. package/package.json +100 -3
@@ -0,0 +1,126 @@
1
+ const MAXIMUM_UINT32 = 0xffff_ffff;
2
+ const MAXIMUM_SLOT_CAPACITY = 0x1_0000_0000;
3
+ const FORWARD = 1;
4
+ const BACKWARD = 2;
5
+ const PAIRED = 3;
6
+ const INVALID = 4;
7
+ function doubledCapacity(capacity) {
8
+ const doubled = capacity * 2;
9
+ if (doubled > MAXIMUM_SLOT_CAPACITY)
10
+ throw new RangeError("Mesh edge table exceeds Uint32 slot capacity.");
11
+ return doubled;
12
+ }
13
+ /**
14
+ * @internal Indexed edge closure and first-face records; coordinates are never welded.
15
+ * Dense record IDs remain stable when the hash slots grow. Inputs are validated Uint32
16
+ * vertex indices with distinct endpoints, and triangle IDs fit Uint32 storage.
17
+ */
18
+ export class IndexedMeshEdgeTable {
19
+ slotCapacity;
20
+ mask;
21
+ slots;
22
+ recordCapacity;
23
+ recordCount = 0;
24
+ lower;
25
+ upper;
26
+ states;
27
+ firstTriangle;
28
+ constructor(indexCount, includeTopology) {
29
+ if (!Number.isSafeInteger(indexCount) || indexCount < 0 || indexCount > MAXIMUM_UINT32)
30
+ throw new RangeError("Mesh edge table requires an index count within Uint32 capacity.");
31
+ let capacity = 16;
32
+ // Multiplication avoids the signed-int32 wrap of bitwise capacity doubling.
33
+ while (capacity < indexCount)
34
+ capacity = doubledCapacity(capacity);
35
+ this.slotCapacity = capacity;
36
+ this.mask = capacity - 1;
37
+ this.slots = new Uint32Array(capacity);
38
+ this.recordCapacity = capacity / 2;
39
+ this.lower = new Uint32Array(this.recordCapacity);
40
+ this.upper = new Uint32Array(this.recordCapacity);
41
+ this.states = new Uint8Array(this.recordCapacity);
42
+ this.firstTriangle = includeTopology ? new Uint32Array(this.recordCapacity) : null;
43
+ }
44
+ /** Returns -1 for a new edge, otherwise its first triangle (or zero without topology). */
45
+ touch(a, b, triangle) {
46
+ const lower = Math.min(a, b);
47
+ const upper = Math.max(a, b);
48
+ const state = a < b ? FORWARD : BACKWARD;
49
+ let slot = (this.hash(lower, upper) & this.mask) >>> 0;
50
+ while (this.slots[slot] !== 0) {
51
+ const record = this.slots[slot] - 1;
52
+ // Hashes wrap to int32; only the complete two Uint32 keys establish identity.
53
+ if (this.lower[record] === lower && this.upper[record] === upper) {
54
+ const previous = this.states[record];
55
+ this.states[record] =
56
+ (previous === FORWARD && state === BACKWARD) ||
57
+ (previous === BACKWARD && state === FORWARD)
58
+ ? PAIRED
59
+ : INVALID;
60
+ return this.firstTriangle?.[record] ?? 0;
61
+ }
62
+ slot = ((slot + 1) & this.mask) >>> 0;
63
+ }
64
+ // Repeat touches cannot increase occupancy; check growth only for an absent key.
65
+ if (this.recordCount + 1 > this.slotCapacity * 0.7) {
66
+ this.growSlots();
67
+ return this.touch(a, b, triangle);
68
+ }
69
+ if (this.recordCount >= MAXIMUM_UINT32)
70
+ throw new RangeError("Mesh edge table exceeds Uint32 record capacity.");
71
+ if (this.recordCount === this.recordCapacity)
72
+ this.growRecords();
73
+ const record = this.recordCount;
74
+ this.slots[slot] = record + 1;
75
+ this.lower[record] = lower;
76
+ this.upper[record] = upper;
77
+ this.states[record] = state;
78
+ if (this.firstTriangle !== null)
79
+ this.firstTriangle[record] = triangle;
80
+ this.recordCount += 1;
81
+ return -1;
82
+ }
83
+ /** Checks exactly two oppositely wound uses after every triangle has been validated. */
84
+ isClosed() {
85
+ for (let record = 0; record < this.recordCount; record += 1)
86
+ if (this.states[record] !== PAIRED)
87
+ return false;
88
+ return true;
89
+ }
90
+ hash(lower, upper) {
91
+ const mixed = Math.imul(lower, 0x9e3779b1) ^ Math.imul(upper, 0x85ebca6b);
92
+ return (mixed ^ (mixed >>> 16)) >>> 0;
93
+ }
94
+ growSlots() {
95
+ const capacity = doubledCapacity(this.slotCapacity);
96
+ const mask = capacity - 1;
97
+ const slots = new Uint32Array(capacity);
98
+ // Paired and invalid records must remain: forgetting either could admit four-face edges.
99
+ for (let record = 0; record < this.recordCount; record += 1) {
100
+ let slot = (this.hash(this.lower[record], this.upper[record]) & mask) >>> 0;
101
+ while (slots[slot] !== 0)
102
+ slot = ((slot + 1) & mask) >>> 0;
103
+ slots[slot] = record + 1;
104
+ }
105
+ this.slotCapacity = capacity;
106
+ this.mask = mask;
107
+ this.slots = slots;
108
+ }
109
+ growRecords() {
110
+ const capacity = doubledCapacity(this.recordCapacity);
111
+ const lower = new Uint32Array(capacity);
112
+ const upper = new Uint32Array(capacity);
113
+ const states = new Uint8Array(capacity);
114
+ const firstTriangle = this.firstTriangle === null ? null : new Uint32Array(capacity);
115
+ lower.set(this.lower);
116
+ upper.set(this.upper);
117
+ states.set(this.states);
118
+ if (firstTriangle !== null)
119
+ firstTriangle.set(this.firstTriangle);
120
+ this.recordCapacity = capacity;
121
+ this.lower = lower;
122
+ this.upper = upper;
123
+ this.states = states;
124
+ this.firstTriangle = firstTriangle;
125
+ }
126
+ }
@@ -0,0 +1,408 @@
1
+ import { GeometryError } from "./errors.js";
2
+ import { buildTriangleMeshQualityAnalysis, } from "./mesh-quality.js";
3
+ const DEFAULT_MAXIMUM_FLIP_ATTEMPTS = 1_000_000;
4
+ const DEFAULT_MAXIMUM_PATCH_TRIANGLE_COUNT = 50_000;
5
+ const DEFAULT_MAXIMUM_PROCESSED_TRIANGLE_COUNT = 200_000;
6
+ const DEFAULT_MAXIMUM_EDGE_REVISITS = 12;
7
+ const DEFAULT_MINIMUM_PATCH_TRIANGLE_COUNT = 8;
8
+ const DEFAULT_TRIGGER_MINIMUM_ANGLE_DEGREES = 8;
9
+ const DEFAULT_TRIGGER_MAXIMUM_ASPECT_RATIO = 12;
10
+ const DEFAULT_TRIGGER_MAXIMUM_VERTEX_TRIANGLE_INCIDENCE = 16;
11
+ const DEFAULT_TRIGGER_MAXIMUM_FAN_RATIO = 0.2;
12
+ const ATTEMPTS_PER_INITIAL_EDGE = 20;
13
+ const edgeKey = (first, second) => first < second ? `${first}:${second}` : `${second}:${first}`;
14
+ const pointAt = (positions, vertex) => [
15
+ positions[vertex * 3],
16
+ positions[vertex * 3 + 1],
17
+ positions[vertex * 3 + 2],
18
+ ];
19
+ const subtract = (left, right) => [
20
+ left[0] - right[0],
21
+ left[1] - right[1],
22
+ left[2] - right[2],
23
+ ];
24
+ const dot = (left, right) => left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
25
+ const cross = (left, right) => [
26
+ left[1] * right[2] - left[2] * right[1],
27
+ left[2] * right[0] - left[0] * right[2],
28
+ left[0] * right[1] - left[1] * right[0],
29
+ ];
30
+ const squaredLength = (vector) => dot(vector, vector);
31
+ const positiveInteger = (value, label) => {
32
+ if (!Number.isSafeInteger(value) || value <= 0) {
33
+ throw new GeometryError("INVALID_ARGUMENT", `${label} must be a positive safe integer.`);
34
+ }
35
+ return value;
36
+ };
37
+ const positiveFinite = (value, label) => {
38
+ if (!Number.isFinite(value) || !(value > 0)) {
39
+ throw new GeometryError("INVALID_ARGUMENT", `${label} must be finite and greater than zero.`);
40
+ }
41
+ return value;
42
+ };
43
+ const resolveOptions = (options) => {
44
+ const triggerMaximumFanRatio = options.triggerMaximumFanRatio ?? DEFAULT_TRIGGER_MAXIMUM_FAN_RATIO;
45
+ if (!Number.isFinite(triggerMaximumFanRatio) ||
46
+ triggerMaximumFanRatio < 0 ||
47
+ triggerMaximumFanRatio > 1) {
48
+ throw new GeometryError("INVALID_ARGUMENT", "Maximum fan ratio trigger must be finite and between zero and one.");
49
+ }
50
+ const triggerMinimumAngleDegrees = positiveFinite(options.triggerMinimumAngleDegrees ?? DEFAULT_TRIGGER_MINIMUM_ANGLE_DEGREES, "Minimum angle trigger");
51
+ if (triggerMinimumAngleDegrees >= 180) {
52
+ throw new GeometryError("INVALID_ARGUMENT", "Minimum angle trigger must be below 180 degrees.");
53
+ }
54
+ return {
55
+ maximumEdgeRevisits: positiveInteger(options.maximumEdgeRevisits ?? DEFAULT_MAXIMUM_EDGE_REVISITS, "Maximum edge revisits"),
56
+ maximumFlipAttempts: options.maximumFlipAttempts === undefined
57
+ ? undefined
58
+ : positiveInteger(options.maximumFlipAttempts, "Maximum flip attempts"),
59
+ maximumPatchTriangleCount: positiveInteger(options.maximumPatchTriangleCount ?? DEFAULT_MAXIMUM_PATCH_TRIANGLE_COUNT, "Maximum patch triangle count"),
60
+ maximumProcessedTriangleCount: positiveInteger(options.maximumProcessedTriangleCount ?? DEFAULT_MAXIMUM_PROCESSED_TRIANGLE_COUNT, "Maximum processed triangle count"),
61
+ minimumPatchTriangleCount: positiveInteger(options.minimumPatchTriangleCount ?? DEFAULT_MINIMUM_PATCH_TRIANGLE_COUNT, "Minimum patch triangle count"),
62
+ triggerMaximumAspectRatio: positiveFinite(options.triggerMaximumAspectRatio ?? DEFAULT_TRIGGER_MAXIMUM_ASPECT_RATIO, "Maximum aspect-ratio trigger"),
63
+ triggerMaximumFanRatio,
64
+ triggerMaximumVertexTriangleIncidence: positiveInteger(options.triggerMaximumVertexTriangleIncidence ??
65
+ DEFAULT_TRIGGER_MAXIMUM_VERTEX_TRIANGLE_INCIDENCE, "Maximum vertex triangle-incidence trigger"),
66
+ triggerMinimumAngleDegrees,
67
+ };
68
+ };
69
+ const pairQuality = (positions, first, second) => {
70
+ const angles = [];
71
+ let maximumAspectRatio = 0;
72
+ for (const triangle of [first, second]) {
73
+ const points = triangle.map((vertex) => pointAt(positions, vertex));
74
+ const edgeVectors = [
75
+ subtract(points[1], points[0]),
76
+ subtract(points[2], points[1]),
77
+ subtract(points[0], points[2]),
78
+ ];
79
+ const edgeSquaredLengths = edgeVectors.map(squaredLength);
80
+ const doubleArea = Math.sqrt(squaredLength(cross(edgeVectors[0], subtract(points[2], points[0]))));
81
+ maximumAspectRatio = Math.max(maximumAspectRatio, doubleArea === 0
82
+ ? Number.POSITIVE_INFINITY
83
+ : (Math.sqrt(3) * Math.max(...edgeSquaredLengths)) / (2 * doubleArea));
84
+ for (let corner = 0; corner < 3; corner += 1) {
85
+ const origin = points[corner];
86
+ const incoming = subtract(points[(corner + 1) % 3], origin);
87
+ const outgoing = subtract(points[(corner + 2) % 3], origin);
88
+ angles.push(Math.atan2(Math.sqrt(squaredLength(cross(incoming, outgoing))), dot(incoming, outgoing)));
89
+ }
90
+ }
91
+ angles.sort((left, right) => left - right);
92
+ return { angles, maximumAspectRatio };
93
+ };
94
+ const comparePairQuality = (before, after, angularTolerance) => {
95
+ for (let index = 0; index < before.angles.length; index += 1) {
96
+ const difference = after.angles[index] - before.angles[index];
97
+ if (difference > angularTolerance)
98
+ return 1;
99
+ if (difference < -angularTolerance)
100
+ return -1;
101
+ }
102
+ const aspectTolerance = Math.max(1, before.maximumAspectRatio) * 1e-12;
103
+ if (after.maximumAspectRatio < before.maximumAspectRatio - aspectTolerance)
104
+ return 1;
105
+ if (after.maximumAspectRatio > before.maximumAspectRatio + aspectTolerance)
106
+ return -1;
107
+ return 0;
108
+ };
109
+ const localConcentrationPotential = (vertices, incidence, valence, changes = new Map()) => {
110
+ let potential = 0;
111
+ for (const vertex of new Set(vertices)) {
112
+ const change = changes.get(vertex) ?? 0;
113
+ const nextIncidence = incidence[vertex] + change;
114
+ const nextValence = valence[vertex] + change;
115
+ potential += nextIncidence ** 2 + nextValence ** 2;
116
+ }
117
+ return potential;
118
+ };
119
+ const isTriggeredPatch = (patch, options) => {
120
+ const report = patch.report;
121
+ return (report.triangleCount >= options.minimumPatchTriangleCount &&
122
+ (report.minimumTriangleAngleDegrees < options.triggerMinimumAngleDegrees ||
123
+ report.maximumTriangleAspectRatio > options.triggerMaximumAspectRatio ||
124
+ report.maximumVertexTriangleIncidence > options.triggerMaximumVertexTriangleIncidence ||
125
+ report.fanRatio > options.triggerMaximumFanRatio));
126
+ };
127
+ const unchangedResult = (mesh, report, skippedPatchCount, budgetExhausted, fallbackReason) => ({
128
+ after: report,
129
+ before: report,
130
+ budgetExhausted,
131
+ changed: false,
132
+ ...(fallbackReason === undefined ? {} : { fallbackReason }),
133
+ flipCount: 0,
134
+ mesh,
135
+ optimizedPatchCount: 0,
136
+ skippedPatchCount,
137
+ });
138
+ const boundsEqual = (first, second) => first.min.every((value, axis) => value === second.min[axis]) &&
139
+ first.max.every((value, axis) => value === second.max[axis]);
140
+ const fallbackReasonForCandidate = (before, after, boundaryEdgeKeys) => {
141
+ if (!after.report.isClosedOrientedManifold)
142
+ return "candidate-not-closed-oriented-manifold";
143
+ for (const key of boundaryEdgeKeys) {
144
+ if (!after.edges.has(key))
145
+ return "candidate-boundary-changed";
146
+ }
147
+ const surfaceTolerance = Math.max(before.tolerance.area * before.report.triangleCount * 8, Math.abs(before.report.surfaceArea) * 1e-10);
148
+ const volumeTolerance = Math.max(before.tolerance.linear ** 3 * before.report.triangleCount * 8, Math.max(1, before.extent ** 3) * 1e-10);
149
+ if (!boundsEqual(before.bounds, after.bounds) ||
150
+ Math.abs(before.report.surfaceArea - after.report.surfaceArea) > surfaceTolerance ||
151
+ Math.abs(before.report.signedVolume - after.report.signedVolume) > volumeTolerance) {
152
+ return "candidate-measurements-changed";
153
+ }
154
+ const angleToleranceDegrees = (before.tolerance.angular * 180) / Math.PI;
155
+ const aspectTolerance = Math.max(1, before.report.maximumTriangleAspectRatio) * 1e-10;
156
+ if (after.report.minimumTriangleAngleDegrees <
157
+ before.report.minimumTriangleAngleDegrees - angleToleranceDegrees ||
158
+ after.report.maximumTriangleAspectRatio >
159
+ before.report.maximumTriangleAspectRatio + aspectTolerance) {
160
+ return "candidate-quality-regressed";
161
+ }
162
+ return undefined;
163
+ };
164
+ /**
165
+ * Improves pathological planar triangulation with bounded legal diagonal flips.
166
+ *
167
+ * The operation changes indices only: it never moves, adds, welds, or removes vertices; never
168
+ * crosses a coplanar-patch boundary; and never changes the face count. Each accepted flip improves
169
+ * a deterministic ordering of triangle angles, aspect ratio, diagonal locality, and local
170
+ * incidence/valence concentration. Unsupported input or a failed final integrity check returns the
171
+ * exact original mesh object. Malformed buffers and the configured analysis complexity limit throw.
172
+ *
173
+ * @param mesh - Closed, consistently wound indexed triangle mesh in arbitrary consistent units.
174
+ * @param options - Patch eligibility, numeric tolerance, and deterministic work bounds.
175
+ * @returns Optimized connectivity and before/after diagnostics, or an unchanged safe fallback.
176
+ */
177
+ export function optimizePlanarPatches(mesh, options = {}) {
178
+ const resolved = resolveOptions(options);
179
+ const before = buildTriangleMeshQualityAnalysis(mesh, options);
180
+ if (!before.report.isClosedOrientedManifold) {
181
+ return unchangedResult(mesh, before.report, 0, false, "input-not-closed-oriented-manifold");
182
+ }
183
+ const selectedPatchIds = new Set();
184
+ const boundaryEdgeKeys = new Set();
185
+ let selectedTriangleCount = 0;
186
+ let skippedPatchCount = 0;
187
+ let budgetExhausted = false;
188
+ for (const patch of before.patches) {
189
+ if (!isTriggeredPatch(patch, resolved))
190
+ continue;
191
+ if (patch.report.triangleCount > resolved.maximumPatchTriangleCount ||
192
+ selectedTriangleCount + patch.report.triangleCount > resolved.maximumProcessedTriangleCount) {
193
+ skippedPatchCount += 1;
194
+ budgetExhausted = true;
195
+ continue;
196
+ }
197
+ selectedPatchIds.add(patch.report.id);
198
+ selectedTriangleCount += patch.report.triangleCount;
199
+ for (const key of patch.boundaryEdgeKeys)
200
+ boundaryEdgeKeys.add(key);
201
+ }
202
+ if (selectedPatchIds.size === 0) {
203
+ return unchangedResult(mesh, before.report, skippedPatchCount, budgetExhausted);
204
+ }
205
+ const triangles = before.faces.map(({ vertices }) => [...vertices]);
206
+ const edges = new Map();
207
+ const vertexEdges = Array.from({ length: before.report.vertexCount }, () => new Set());
208
+ for (const edge of before.edges.values()) {
209
+ const mutable = { ...edge, uses: edge.uses.map((use) => ({ ...use })) };
210
+ edges.set(edge.key, mutable);
211
+ vertexEdges[edge.first].add(edge.key);
212
+ vertexEdges[edge.second].add(edge.key);
213
+ }
214
+ const incidence = new Uint32Array(before.vertexTriangleIncidence);
215
+ const valence = new Uint32Array(before.vertexValence);
216
+ const isCandidateEdge = (edge) => {
217
+ if (edge.uses.length !== 2 || boundaryEdgeKeys.has(edge.key))
218
+ return false;
219
+ const firstPatch = before.patchByFace[edge.uses[0].face];
220
+ return (firstPatch >= 0 &&
221
+ firstPatch === before.patchByFace[edge.uses[1].face] &&
222
+ selectedPatchIds.has(firstPatch));
223
+ };
224
+ const initialEdges = [...edges.values()]
225
+ .filter(isCandidateEdge)
226
+ .sort((left, right) => left.first - right.first || left.second - right.second);
227
+ const maximumFlipAttempts = resolved.maximumFlipAttempts ??
228
+ Math.min(DEFAULT_MAXIMUM_FLIP_ATTEMPTS, initialEdges.length * ATTEMPTS_PER_INITIAL_EDGE);
229
+ if (maximumFlipAttempts === 0) {
230
+ return unchangedResult(mesh, before.report, skippedPatchCount, budgetExhausted);
231
+ }
232
+ const queue = initialEdges.map(({ key }) => key);
233
+ const queued = new Set(queue);
234
+ const revisitCounts = new Map();
235
+ const changedPatchIds = new Set();
236
+ let queueOffset = 0;
237
+ let attemptCount = 0;
238
+ let flipCount = 0;
239
+ const removeFace = (faceIndex, triangle) => {
240
+ for (let localEdge = 0; localEdge < 3; localEdge += 1) {
241
+ const key = edgeKey(triangle[localEdge], triangle[(localEdge + 1) % 3]);
242
+ const edge = edges.get(key);
243
+ const useIndex = edge.uses.findIndex(({ face }) => face === faceIndex);
244
+ edge.uses.splice(useIndex, 1);
245
+ if (edge.uses.length === 0) {
246
+ edges.delete(key);
247
+ vertexEdges[edge.first].delete(key);
248
+ vertexEdges[edge.second].delete(key);
249
+ }
250
+ }
251
+ };
252
+ const addFace = (faceIndex, triangle) => {
253
+ for (let localEdge = 0; localEdge < 3; localEdge += 1) {
254
+ const start = triangle[localEdge];
255
+ const end = triangle[(localEdge + 1) % 3];
256
+ const key = edgeKey(start, end);
257
+ let edge = edges.get(key);
258
+ if (edge === undefined) {
259
+ edge = {
260
+ first: Math.min(start, end),
261
+ key,
262
+ second: Math.max(start, end),
263
+ uses: [],
264
+ };
265
+ edges.set(key, edge);
266
+ vertexEdges[edge.first].add(key);
267
+ vertexEdges[edge.second].add(key);
268
+ }
269
+ edge.uses.push({ end, face: faceIndex, start });
270
+ }
271
+ };
272
+ const enqueueAround = (vertices) => {
273
+ const candidates = new Map();
274
+ for (const vertex of vertices) {
275
+ for (const key of vertexEdges[vertex]) {
276
+ const edge = edges.get(key);
277
+ if (edge !== undefined && isCandidateEdge(edge))
278
+ candidates.set(key, edge);
279
+ }
280
+ }
281
+ const sorted = [...candidates.values()].sort((left, right) => left.first - right.first || left.second - right.second);
282
+ for (const { key } of sorted) {
283
+ if (queued.has(key) || (revisitCounts.get(key) ?? 0) >= resolved.maximumEdgeRevisits) {
284
+ continue;
285
+ }
286
+ queued.add(key);
287
+ queue.push(key);
288
+ }
289
+ };
290
+ while (queueOffset < queue.length && attemptCount < maximumFlipAttempts) {
291
+ const key = queue[queueOffset++];
292
+ queued.delete(key);
293
+ const revisits = (revisitCounts.get(key) ?? 0) + 1;
294
+ revisitCounts.set(key, revisits);
295
+ const edge = edges.get(key);
296
+ if (edge === undefined || !isCandidateEdge(edge))
297
+ continue;
298
+ attemptCount += 1;
299
+ const uses = [...edge.uses].sort((left, right) => left.face - right.face);
300
+ const firstUse = uses[0];
301
+ const secondUse = uses[1];
302
+ if (firstUse.start !== secondUse.end || firstUse.end !== secondUse.start)
303
+ continue;
304
+ const firstTriangle = triangles[firstUse.face];
305
+ const secondTriangle = triangles[secondUse.face];
306
+ const oppositeFirst = firstTriangle.find((vertex) => vertex !== firstUse.start && vertex !== firstUse.end);
307
+ const oppositeSecond = secondTriangle.find((vertex) => vertex !== firstUse.start && vertex !== firstUse.end);
308
+ if (oppositeFirst === undefined || oppositeSecond === undefined)
309
+ continue;
310
+ const vertices = [firstUse.start, firstUse.end, oppositeFirst, oppositeSecond];
311
+ if (new Set(vertices).size !== 4)
312
+ continue;
313
+ const replacementKey = edgeKey(oppositeFirst, oppositeSecond);
314
+ if (edges.has(replacementKey))
315
+ continue;
316
+ const patchId = before.patchByFace[firstUse.face];
317
+ const patch = before.patches[patchId];
318
+ const normal = patch.report.normal;
319
+ const startPoint = pointAt(mesh.positions, firstUse.start);
320
+ const endPoint = pointAt(mesh.positions, firstUse.end);
321
+ const firstPoint = pointAt(mesh.positions, oppositeFirst);
322
+ const secondPoint = pointAt(mesh.positions, oppositeSecond);
323
+ const sideOfReplacementFirst = dot(cross(subtract(secondPoint, firstPoint), subtract(startPoint, firstPoint)), normal);
324
+ const sideOfReplacementSecond = dot(cross(subtract(secondPoint, firstPoint), subtract(endPoint, firstPoint)), normal);
325
+ const sideOfCurrentFirst = dot(cross(subtract(endPoint, startPoint), subtract(firstPoint, startPoint)), normal);
326
+ const sideOfCurrentSecond = dot(cross(subtract(endPoint, startPoint), subtract(secondPoint, startPoint)), normal);
327
+ const minimumDoubleArea = before.tolerance.area;
328
+ if (Math.abs(sideOfReplacementFirst) <= minimumDoubleArea ||
329
+ Math.abs(sideOfReplacementSecond) <= minimumDoubleArea ||
330
+ Math.sign(sideOfReplacementFirst) === Math.sign(sideOfReplacementSecond) ||
331
+ Math.abs(sideOfCurrentFirst) <= minimumDoubleArea ||
332
+ Math.abs(sideOfCurrentSecond) <= minimumDoubleArea ||
333
+ Math.sign(sideOfCurrentFirst) === Math.sign(sideOfCurrentSecond)) {
334
+ continue;
335
+ }
336
+ const replacementFirst = [oppositeFirst, firstUse.start, oppositeSecond];
337
+ const replacementSecond = [oppositeFirst, oppositeSecond, firstUse.end];
338
+ const replacementFirstArea = dot(cross(subtract(pointAt(mesh.positions, replacementFirst[1]), firstPoint), subtract(pointAt(mesh.positions, replacementFirst[2]), firstPoint)), normal);
339
+ const replacementSecondArea = dot(cross(subtract(pointAt(mesh.positions, replacementSecond[1]), firstPoint), subtract(pointAt(mesh.positions, replacementSecond[2]), firstPoint)), normal);
340
+ if (replacementFirstArea <= minimumDoubleArea || replacementSecondArea <= minimumDoubleArea) {
341
+ continue;
342
+ }
343
+ const qualityComparison = comparePairQuality(pairQuality(mesh.positions, firstTriangle, secondTriangle), pairQuality(mesh.positions, replacementFirst, replacementSecond), before.tolerance.angular);
344
+ if (qualityComparison < 0)
345
+ continue;
346
+ if (qualityComparison === 0) {
347
+ const currentDiagonalSquared = squaredLength(subtract(endPoint, startPoint));
348
+ const replacementDiagonalSquared = squaredLength(subtract(secondPoint, firstPoint));
349
+ const squaredLengthTolerance = before.tolerance.linear ** 2;
350
+ if (replacementDiagonalSquared > currentDiagonalSquared + squaredLengthTolerance)
351
+ continue;
352
+ if (Math.abs(replacementDiagonalSquared - currentDiagonalSquared) <= squaredLengthTolerance) {
353
+ const changes = new Map([
354
+ [firstUse.start, -1],
355
+ [firstUse.end, -1],
356
+ [oppositeFirst, 1],
357
+ [oppositeSecond, 1],
358
+ ]);
359
+ if (localConcentrationPotential(vertices, incidence, valence, changes) >=
360
+ localConcentrationPotential(vertices, incidence, valence)) {
361
+ continue;
362
+ }
363
+ }
364
+ }
365
+ removeFace(firstUse.face, firstTriangle);
366
+ removeFace(secondUse.face, secondTriangle);
367
+ triangles[firstUse.face] = replacementFirst;
368
+ triangles[secondUse.face] = replacementSecond;
369
+ addFace(firstUse.face, replacementFirst);
370
+ addFace(secondUse.face, replacementSecond);
371
+ incidence[firstUse.start] -= 1;
372
+ incidence[firstUse.end] -= 1;
373
+ incidence[oppositeFirst] += 1;
374
+ incidence[oppositeSecond] += 1;
375
+ valence[firstUse.start] -= 1;
376
+ valence[firstUse.end] -= 1;
377
+ valence[oppositeFirst] += 1;
378
+ valence[oppositeSecond] += 1;
379
+ flipCount += 1;
380
+ changedPatchIds.add(patchId);
381
+ enqueueAround(vertices);
382
+ }
383
+ if (queueOffset < queue.length)
384
+ budgetExhausted = true;
385
+ if (flipCount === 0) {
386
+ return unchangedResult(mesh, before.report, skippedPatchCount, budgetExhausted);
387
+ }
388
+ const indices = new Uint32Array(mesh.indices.length);
389
+ for (let faceIndex = 0; faceIndex < triangles.length; faceIndex += 1) {
390
+ indices.set(triangles[faceIndex], faceIndex * 3);
391
+ }
392
+ const candidate = { indices, positions: mesh.positions };
393
+ const after = buildTriangleMeshQualityAnalysis(candidate, options);
394
+ const fallbackReason = fallbackReasonForCandidate(before, after, boundaryEdgeKeys);
395
+ if (fallbackReason !== undefined) {
396
+ return unchangedResult(mesh, before.report, skippedPatchCount, budgetExhausted, fallbackReason);
397
+ }
398
+ return {
399
+ after: after.report,
400
+ before: before.report,
401
+ budgetExhausted,
402
+ changed: true,
403
+ flipCount,
404
+ mesh: candidate,
405
+ optimizedPatchCount: changedPatchIds.size,
406
+ skippedPatchCount,
407
+ };
408
+ }