@metamesh/modeling 0.7.0 → 0.8.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 (36) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +76 -3
  3. package/dist/api/engine.d.ts +4 -0
  4. package/dist/api/geometry.d.ts +104 -19
  5. package/dist/api/index.d.ts +203 -18
  6. package/dist/api/text.d.ts +21 -7
  7. package/dist/engine/construction-operations.js +52 -10
  8. package/dist/engine/execution.js +55 -2
  9. package/dist/engine/export-3mf.js +17 -1
  10. package/dist/engine/finalization.js +27 -18
  11. package/dist/engine/inspection-operations.js +26 -22
  12. package/dist/engine/loft-operations.js +1 -1
  13. package/dist/engine/mechanical-operations.js +5 -8
  14. package/dist/engine/mesh-view-cache.js +23 -0
  15. package/dist/engine/native/adapter.js +2 -4
  16. package/dist/engine/native/distance.js +42 -0
  17. package/dist/engine/native/status.js +16 -0
  18. package/dist/engine/operation-environment.js +2 -1
  19. package/dist/engine/section-operations.js +3 -3
  20. package/dist/geometry/heightfield.js +101 -42
  21. package/dist/geometry/index.js +2 -0
  22. package/dist/geometry/loft-simple-mesh.js +2 -3
  23. package/dist/geometry/loft-triangulation.js +12 -9
  24. package/dist/geometry/loft.js +2 -6
  25. package/dist/geometry/mechanical/standards.js +2 -3
  26. package/dist/geometry/mechanical/threads.js +2 -3
  27. package/dist/geometry/mesh-centroid.js +36 -0
  28. package/dist/geometry/mesh-solid-intersections.js +123 -0
  29. package/dist/geometry/mesh-solid.js +284 -0
  30. package/dist/geometry/sweeps/sweep-closed.js +4 -4
  31. package/dist/geometry/sweeps/sweep-mesh.js +2 -6
  32. package/dist/geometry/sweeps/sweep-open.js +4 -4
  33. package/dist/geometry/sweeps/validation.js +1 -2
  34. package/dist/model/operations.js +10 -3
  35. package/dist/text/vector-text-layout.js +46 -3
  36. package/package.json +1 -1
@@ -1,6 +1,8 @@
1
1
  import { GeometryError } from "../geometry/errors.js";
2
2
  import { optimizePlanarPatches } from "../geometry/mesh-planar.js";
3
- import { analyzeTriangleMesh } from "../geometry/mesh.js";
3
+ import { validateAnalyzedSolidMesh } from "../geometry/mesh-solid.js";
4
+ import { analyzeTriangleMesh, } from "../geometry/mesh.js";
5
+ import { MeshViewCache } from "./mesh-view-cache.js";
4
6
  import { copiedMeshViews, copiedUint32Array } from "./native/copied-views.js";
5
7
  const isArray = (value) => Array.isArray(value);
6
8
  function finalPartTotals(parts) {
@@ -26,6 +28,12 @@ function fitsLimits(totals, limits) {
26
28
  }
27
29
  /** Finalizes one complete build under the selected limits, preserving usable originals when optional cleanup fails. */
28
30
  export function finalizeModel(execution, output, limits, options, startedAt, builtAt) {
31
+ if (options.meshValidation !== undefined &&
32
+ options.meshValidation !== "topology" &&
33
+ options.meshValidation !== "solid")
34
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported mesh validation mode.", {
35
+ operation: "finalize",
36
+ });
29
37
  const descriptors = execution.isSolid(output) ? [{ id: "model", solid: output }] : output;
30
38
  if (!isArray(descriptors) || descriptors.length === 0)
31
39
  throw new GeometryError("INVALID_ARGUMENT", "Build must return a solid or at least one named part.", { operation: "finalize" });
@@ -36,6 +44,7 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
36
44
  const ids = new Set();
37
45
  const backingBuffers = new Set();
38
46
  const meshCopies = new Map();
47
+ const directMeasurements = new MeshViewCache();
39
48
  const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
40
49
  let vertices = 0, triangles = 0, bytes = 0;
41
50
  let parts = descriptors.map((descriptor) => {
@@ -76,10 +85,12 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
76
85
  throw new GeometryError("COMPLEXITY_LIMIT", "Aggregate output exceeds the execution limits.", { operation: "finalize" });
77
86
  // Direct meshes are copied before analysis so later caller mutation cannot invalidate facts.
78
87
  const mesh = descriptor.mesh === undefined ? source : copiedMeshViews(source, meshCopies);
79
- const measurements = nativeAnalysis?.measurements ??
80
- analyzeTriangleMesh(mesh, { requireClosed: true, includeTopology: true });
81
- if (descriptor.mesh !== undefined)
88
+ let measurements = nativeAnalysis?.measurements ?? directMeasurements.get(mesh);
89
+ if (measurements === undefined) {
90
+ measurements = analyzeTriangleMesh(mesh, { requireClosed: true, includeTopology: true });
82
91
  execution.validateMesh(mesh);
92
+ directMeasurements.set(mesh, measurements);
93
+ }
83
94
  if (!(measurements.signedVolume > 0))
84
95
  throw new GeometryError("INVALID_TOPOLOGY", "Manufacturing parts must enclose positive oriented volume.", { operation: "finalize" });
85
96
  for (let axis = 0; axis < 3; axis += 1) {
@@ -95,20 +106,9 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
95
106
  });
96
107
  const originalParts = parts;
97
108
  if (options.meshTopologyMode === "optimized" && triangles <= 200_000) {
98
- const candidates = new Map();
109
+ const candidates = new MeshViewCache();
99
110
  parts = parts.map((part) => {
100
- let indexBuffers = candidates.get(part.positions.buffer);
101
- if (indexBuffers === undefined) {
102
- indexBuffers = new Map();
103
- candidates.set(part.positions.buffer, indexBuffers);
104
- }
105
- let views = indexBuffers.get(part.indices.buffer);
106
- if (views === undefined) {
107
- views = new Map();
108
- indexBuffers.set(part.indices.buffer, views);
109
- }
110
- const key = `${part.positions.byteOffset}:${part.positions.length}:${part.indices.byteOffset}:${part.indices.length}`;
111
- const cached = views.get(key);
111
+ const cached = candidates.get(part);
112
112
  if (cached !== undefined)
113
113
  return Object.freeze({ ...part, ...cached });
114
114
  let selected = {
@@ -134,7 +134,7 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
134
134
  catch {
135
135
  // Optional optimization preserves the usable original on unsupported geometry.
136
136
  }
137
- views.set(key, selected);
137
+ candidates.set(part, selected);
138
138
  return Object.freeze({ ...part, ...selected });
139
139
  });
140
140
  }
@@ -148,6 +148,15 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
148
148
  throw new GeometryError("COMPLEXITY_LIMIT", "Selected output exceeds the execution limits.", {
149
149
  operation: "finalize",
150
150
  });
151
+ if (options.meshValidation === "solid") {
152
+ const checked = new MeshViewCache();
153
+ for (const part of parts) {
154
+ if (checked.get(part) !== undefined)
155
+ continue;
156
+ validateAnalyzedSolidMesh(part, part.measurements);
157
+ checked.set(part, true);
158
+ }
159
+ }
151
160
  const finalizedAt = performance.now();
152
161
  return Object.freeze({
153
162
  parts: Object.freeze(parts),
@@ -1,26 +1,21 @@
1
1
  import { ownScopedManifoldValue } from "./native/context.js";
2
2
  import { copiedMeshViews } from "./native/copied-views.js";
3
+ import { measureSolidDistance } from "./native/distance.js";
4
+ import { assertNativeSolidStatus } from "./native/status.js";
3
5
  /** Internal host-bound inspection operations. */
4
6
  export function createInspectionOperations(environment) {
5
- const { scope, throwGeometryError, nativeSolid, nativeSection, ownSolid, validateFiniteNumber, requirePositiveNumber, validateCoordinates, analyzedMeshFor, } = environment;
7
+ const { scope, throwGeometryError, nativeSolid, resolveHandle, ownSolid, ownSection, validateFiniteNumber, requirePositiveNumber, validateCoordinates, analyzedMeshFor, solidInspectionFor, sectionInspectionFor, } = environment;
6
8
  const operations = {
7
9
  toTriangleMesh: (value) => copiedMeshViews(analyzedMeshFor(value).mesh, new Map()),
8
- inspectSection: (handle) => {
9
- const native = nativeSection(handle), bounds = native.bounds();
10
- const parts = native.decompose().map((part) => ownScopedManifoldValue(scope, part));
11
- return {
12
- ...bounds,
13
- area: native.area(),
14
- contours: native.toPolygons().length,
15
- parts: parts.length,
16
- vertices: native.numVert(),
17
- };
18
- },
19
- inspectSolid: (handle) => {
20
- const native = nativeSolid(handle), bounds = native.boundingBox();
21
- const parts = native.decompose().map((part) => ownScopedManifoldValue(scope, part));
22
- return { ...bounds, parts: parts.length, volume: native.volume() };
10
+ inspectSection: sectionInspectionFor,
11
+ inspectSolid: solidInspectionFor,
12
+ isEmpty: (handle) => {
13
+ const native = resolveHandle(handle);
14
+ if (native.kind === "solid")
15
+ assertNativeSolidStatus(native.value, "isEmpty");
16
+ return native.value.isEmpty();
23
17
  },
18
+ distance: (handle, other) => measureSolidDistance(nativeSolid(handle), nativeSolid(other)),
24
19
  rayCast: (handle, origin, endpoint) => {
25
20
  validateCoordinates(origin, 3, "rayCast");
26
21
  validateCoordinates(endpoint, 3, "rayCast");
@@ -36,12 +31,21 @@ export function createInspectionOperations(environment) {
36
31
  normal: Object.freeze([...hit.normal]),
37
32
  })));
38
33
  },
39
- components: (handle) => nativeSolid(handle).decompose().map(ownSolid),
40
- simplify: (handle, tolerance) => {
41
- const native = nativeSolid(handle);
42
- const original = ownScopedManifoldValue(scope, native.asOriginal());
43
- return ownSolid(original.simplify(requirePositiveNumber(tolerance, "simplify")));
44
- },
34
+ components: ((handle) => {
35
+ const native = resolveHandle(handle);
36
+ if (native.kind === "section")
37
+ return Object.freeze(native.value.isEmpty() ? [] : native.value.decompose().map(ownSection));
38
+ assertNativeSolidStatus(native.value, "components");
39
+ return Object.freeze(native.value.decompose().map(ownSolid));
40
+ }),
41
+ simplify: ((handle, tolerance) => {
42
+ const native = resolveHandle(handle);
43
+ const distance = requirePositiveNumber(tolerance, "simplify");
44
+ if (native.kind === "section")
45
+ return ownSection(native.value.simplify(distance));
46
+ const original = ownScopedManifoldValue(scope, native.value.asOriginal());
47
+ return ownSolid(original.simplify(distance));
48
+ }),
45
49
  setTolerance: (handle, tolerance) => {
46
50
  if (validateFiniteNumber(tolerance, "setTolerance") < 0)
47
51
  throwGeometryError("INVALID_ARGUMENT", "setTolerance", "Tolerance must be nonnegative millimetres.");
@@ -143,7 +143,7 @@ export function createLoftOperation(environment, solidFromMesh) {
143
143
  const geometry = prepareLoftGeometry(input, selected, selected.limits, authoritative, recipeSampled);
144
144
  if (geometry.stations.every((station) => station.profile.components.length === 1 &&
145
145
  station.profile.components[0].holes.length === 0))
146
- return solidFromMesh(simpleLoftMesh(geometry, selected.limits, environment.assertMeshAllocation));
146
+ return solidFromMesh(simpleLoftMesh(geometry, selected.limits));
147
147
  const mesh = buildProfileLoftMesh(environment.module, geometry.stations, selected.limits, false, 0, (station, contour, slot) => geometry.world[station][contour][slot]);
148
148
  if (measureTriangleMesh(mesh).signedVolume < 0)
149
149
  for (let index = 0; index < mesh.indices.length; index += 3) {
@@ -2,15 +2,12 @@ import * as kernel from "./native/index.js";
2
2
  /** Internal host-bound mechanical operations. */
3
3
  export function createMechanicalOperations(environment) {
4
4
  const { resolution } = environment;
5
- const { scope, assertActive, wrapSolid, assertMeshAllocation, withResolutionDefaults } = environment;
5
+ const { scope, assertActive, wrapSolid, withResolutionDefaults } = environment;
6
6
  const nutOptions = (value) => ({
7
7
  ...withResolutionDefaults(value),
8
8
  bodyResolution: { ...resolution, ...value.bodyResolution },
9
9
  });
10
- const threadBudget = (preflight, operation) => {
11
- const vertices = (preflight.estimatedRows + 2) * preflight.circularSegments;
12
- assertMeshAllocation(vertices, preflight.estimatedTriangles, (vertices + preflight.estimatedTriangles) * 12, operation);
13
- };
10
+ // Explicit preflight preserves the stable error operation names exposed by these constructors.
14
11
  const operations = {
15
12
  threadRidge: (value) => {
16
13
  assertActive();
@@ -19,19 +16,19 @@ export function createMechanicalOperations(environment) {
19
16
  threadedRod: (value) => {
20
17
  assertActive();
21
18
  const resolved = withResolutionDefaults(value);
22
- threadBudget(kernel.preflightThreadedRod(resolved), "threadedRod");
19
+ kernel.preflightThreadedRod(resolved);
23
20
  return wrapSolid(kernel.threadedRod(scope, resolved));
24
21
  },
25
22
  internalThreadCutter: (value) => {
26
23
  assertActive();
27
24
  const resolved = withResolutionDefaults(value);
28
- threadBudget(kernel.preflightInternalThreadCutter(resolved), "internalThreadCutter");
25
+ kernel.preflightInternalThreadCutter(resolved);
29
26
  return wrapSolid(kernel.internalThreadCutter(scope, resolved));
30
27
  },
31
28
  threadedNut: (value) => {
32
29
  assertActive();
33
30
  const resolved = nutOptions(value);
34
- threadBudget(kernel.preflightThreadedNut(resolved).thread, "threadedNut");
31
+ kernel.preflightThreadedNut(resolved);
35
32
  return wrapSolid(kernel.threadedNut(scope, resolved));
36
33
  },
37
34
  preflightThreadedRod: (value) => {
@@ -0,0 +1,23 @@
1
+ /** @internal Operation-local facts for identical privately owned mesh buffer ranges. */
2
+ export class MeshViewCache {
3
+ positions = new Map();
4
+ key(mesh) {
5
+ return `${mesh.positions.byteOffset}:${mesh.positions.length}:${mesh.indices.byteOffset}:${mesh.indices.length}`;
6
+ }
7
+ get(mesh) {
8
+ return this.positions.get(mesh.positions.buffer)?.get(mesh.indices.buffer)?.get(this.key(mesh));
9
+ }
10
+ set(mesh, value) {
11
+ let indices = this.positions.get(mesh.positions.buffer);
12
+ if (indices === undefined) {
13
+ indices = new Map();
14
+ this.positions.set(mesh.positions.buffer, indices);
15
+ }
16
+ let ranges = indices.get(mesh.indices.buffer);
17
+ if (ranges === undefined) {
18
+ ranges = new Map();
19
+ indices.set(mesh.indices.buffer, ranges);
20
+ }
21
+ ranges.set(this.key(mesh), value);
22
+ }
23
+ }
@@ -1,4 +1,5 @@
1
1
  import { validateTriangleMesh } from "../../geometry/mesh.js";
2
+ import { assertNativeSolidStatus } from "./status.js";
2
3
  /**
3
4
  * Constructs a live Manifold solid from a copied indexed triangle mesh.
4
5
  *
@@ -12,9 +13,6 @@ export const triangleMeshToManifold = (module, mesh, scope) => {
12
13
  triVerts: mesh.indices,
13
14
  });
14
15
  const manifold = scope.own(new module.Manifold(manifoldMesh));
15
- const status = manifold.status();
16
- if (status !== "NoError") {
17
- throw new Error(`Manifold rejected generated mesh: ${status}.`);
18
- }
16
+ assertNativeSolidStatus(manifold, "solidFromMesh");
19
17
  return manifold;
20
18
  };
@@ -0,0 +1,42 @@
1
+ import { GeometryError } from "../../geometry/errors.js";
2
+ import { assertNativeSolidStatus } from "./status.js";
3
+ /** Internal minimum separation query; never exposes the kernel's capped search result. */
4
+ export function measureSolidDistance(left, right) {
5
+ assertNativeSolidStatus(left, "distance");
6
+ assertNativeSolidStatus(right, "distance");
7
+ if (left.isEmpty() || right.isEmpty())
8
+ throw new GeometryError("DEGENERATE_GEOMETRY", "Distance requires nonempty solids.", {
9
+ operation: "distance",
10
+ });
11
+ const a = left.boundingBox(), b = right.boundingBox();
12
+ const axes = [0, 1, 2];
13
+ const lower = Math.hypot(...axes.map((i) => Math.max(0, a.min[i] - b.max[i], b.min[i] - a.max[i])));
14
+ const upper = Math.hypot(...axes.map((i) => Math.max(Math.abs(a.min[i] - b.max[i]), Math.abs(a.max[i] - b.min[i]))));
15
+ const diagonal = Math.max(Math.hypot(...axes.map((i) => a.max[i] - a.min[i])), Math.hypot(...axes.map((i) => b.max[i] - b.min[i])));
16
+ const scale = Math.max(upper, ...[...a.min, ...a.max, ...b.min, ...b.max].map(Math.abs));
17
+ const padding = Math.max(Math.sqrt(Number.MIN_VALUE) * 2, scale * Number.EPSILON * 8);
18
+ const maximum = upper + padding;
19
+ if (!Number.isFinite(maximum * maximum) || maximum <= 0)
20
+ throw new GeometryError("NON_FINITE_VALUE", "Distance exceeds the kernel's numeric range.", {
21
+ operation: "distance",
22
+ });
23
+ // A modest extent-based seed avoids many tiny searches for very unequal shapes.
24
+ // A full bounding-box diagonal would make the kernel visit unnecessary triangle pairs.
25
+ let radius = Math.min(maximum, Math.max(lower + padding, diagonal / 32, padding));
26
+ for (;;) {
27
+ const value = left.minGap(right, radius);
28
+ // The kernel takes sqrt(radius²) when capped; that can round below radius.
29
+ const capSlack = Math.max(Math.sqrt(Number.MIN_VALUE), radius * Number.EPSILON * 4);
30
+ if (!Number.isFinite(value) || value < 0 || value > radius + capSlack)
31
+ throw new GeometryError("NON_FINITE_VALUE", "The kernel returned an invalid distance.", {
32
+ operation: "distance",
33
+ });
34
+ if (value < radius - capSlack)
35
+ return value;
36
+ if (radius === maximum)
37
+ throw new GeometryError("INVALID_MESH", "The kernel could not resolve the minimum distance.", {
38
+ operation: "distance",
39
+ });
40
+ radius = Math.min(maximum, radius * 2);
41
+ }
42
+ }
@@ -0,0 +1,16 @@
1
+ import { GeometryError } from "../../geometry/errors.js";
2
+ /** Checks an already required native evaluation without exposing upstream status types publicly. */
3
+ export function assertNativeSolidStatus(native, operation) {
4
+ const status = native.status();
5
+ if (status === "NoError")
6
+ return;
7
+ const code = status === "NonFiniteVertex"
8
+ ? "NON_FINITE_VALUE"
9
+ : status === "ResultTooLarge"
10
+ ? "COMPLEXITY_LIMIT"
11
+ : "INVALID_TOPOLOGY";
12
+ throw new GeometryError(code, `The geometry kernel rejected the solid: ${status}.`, {
13
+ operation,
14
+ details: { nativeStatus: status },
15
+ });
16
+ }
@@ -3,7 +3,8 @@ import { resolveCircularPrimitiveSegments } from "../geometry/primitive-resoluti
3
3
  /** Applies explicit resolution inheritance once for this bound context. */
4
4
  export function createOperationEnvironment(owner, resolution) {
5
5
  const withResolutionDefaults = (value) => {
6
- const limits = resolveModelingLimits(value.limits, owner.limits);
6
+ const overrides = value.limits;
7
+ const limits = overrides === undefined ? owner.limits : resolveModelingLimits(overrides, owner.limits);
7
8
  return {
8
9
  ...value,
9
10
  resolution: {
@@ -5,7 +5,7 @@ import { sampleTextContours } from "../geometry/text-contours.js";
5
5
  import * as kernel from "./native/index.js";
6
6
  /** Internal host-bound section operations. */
7
7
  export function createSectionOperations(environment, transforms) {
8
- const { module, limits, scope, assertActive, throwGeometryError, nativeSolid, nativeSection, wrapSection, ownSection, validateFiniteNumber, requirePositiveNumber, validateCoordinates, assertOperandCount, withResolutionDefaults, resolveCircularSegments, } = environment;
8
+ const { module, limits, scope, assertActive, throwGeometryError, nativeSolid, nativeSection, wrapSection, ownSection, validateFiniteNumber, validateCoordinates, assertOperandCount, withResolutionDefaults, resolveCircularSegments, } = environment;
9
9
  const { translate } = transforms.operations;
10
10
  const fromContours = (contours, value = {}) => {
11
11
  assertActive();
@@ -51,8 +51,8 @@ export function createSectionOperations(environment, transforms) {
51
51
  const join = value.join ?? "round";
52
52
  if (!["round", "miter", "square"].includes(join))
53
53
  throwGeometryError("INVALID_ARGUMENT", "offset", "Unsupported offset join.");
54
- if (value.miterLimit !== undefined)
55
- requirePositiveNumber(value.miterLimit, "offset");
54
+ if (value.miterLimit !== undefined && validateFiniteNumber(value.miterLimit, "offset") < 2)
55
+ throwGeometryError("INVALID_ARGUMENT", "offset", "miterLimit must be at least two.");
56
56
  return ownSection(native.offset(distance, join === "round" ? "Round" : join === "miter" ? "Miter" : "Square", value.miterLimit, resolveCircularSegments(Math.max(Math.abs(distance), 0.001), {
57
57
  ...(value.circularSegments === undefined ? {} : { segments: value.circularSegments }),
58
58
  ...(value.resolution === undefined ? {} : { resolution: value.resolution }),
@@ -1,50 +1,91 @@
1
1
  import { GeometryError } from "./errors.js";
2
2
  import { validateTriangleMesh } from "./mesh.js";
3
- import { defaultModelingLimits } from "./model-limits.js";
3
+ import { assertMeshAllocationWithinLimits, resolveModelingLimits, } from "./model-limits.js";
4
4
  import { writeMeshVertex } from "./numeric.js";
5
- const isArray = (value) => Array.isArray(value);
6
- /** Checks regular-grid dimensions and bounds allocation before evaluating any height samples. */
7
- export function preflightHeightfield(options, limits = {}) {
5
+ function planHeightfield(options, limits) {
8
6
  const operation = "preflightHeightfield";
9
7
  if (typeof options !== "object" ||
10
8
  options === null ||
11
- !isArray(options.size) ||
12
- options.size.length !== 2 ||
13
- !options.size.every((value) => Number.isFinite(value) && value > 0) ||
14
- !isArray(options.segments) ||
15
- options.segments.length !== 2 ||
16
- !options.segments.every((value) => Number.isSafeInteger(value) && value > 0) ||
17
9
  typeof options.height !== "function" ||
18
10
  !Number.isFinite(options.baseZ ?? 0) ||
19
- (options.origin !== undefined &&
20
- (!isArray(options.origin) ||
21
- options.origin.length !== 2 ||
22
- !options.origin.every(Number.isFinite)))) {
23
- throw new GeometryError("INVALID_ARGUMENT", "Heightfields need a finite positive footprint, integer intervals, finite base, and a height callback.", { operation });
11
+ (options.triangulation !== undefined &&
12
+ !["fixed", "min-edge", "center-error"].includes(options.triangulation))) {
13
+ throw new GeometryError("INVALID_ARGUMENT", "Heightfields need a finite base, height callback, and supported triangulation policy.", { operation });
14
+ }
15
+ let x, y;
16
+ let xAt, yAt;
17
+ if (options.coordinates !== undefined) {
18
+ const coordinates = options.coordinates;
19
+ if (typeof coordinates !== "object" ||
20
+ coordinates === null ||
21
+ !Array.isArray(coordinates.x) ||
22
+ !Array.isArray(coordinates.y) ||
23
+ coordinates.x.length < 2 ||
24
+ coordinates.y.length < 2 ||
25
+ options.size !== undefined ||
26
+ options.segments !== undefined ||
27
+ options.origin !== undefined) {
28
+ throw new GeometryError("INVALID_ARGUMENT", "Explicit heightfield coordinates need two or more samples per axis and cannot include size, segments, or origin.", { operation });
29
+ }
30
+ x = coordinates.x.length - 1;
31
+ y = coordinates.y.length - 1;
32
+ xAt = (index) => coordinates.x[index];
33
+ yAt = (index) => coordinates.y[index];
34
+ }
35
+ else {
36
+ if (!Array.isArray(options.size) ||
37
+ options.size.length !== 2 ||
38
+ !options.size.every((value) => Number.isFinite(value) && value > 0) ||
39
+ !Array.isArray(options.segments) ||
40
+ options.segments.length !== 2 ||
41
+ !options.segments.every((value) => Number.isSafeInteger(value) && value > 0) ||
42
+ (options.origin !== undefined &&
43
+ (!Array.isArray(options.origin) ||
44
+ options.origin.length !== 2 ||
45
+ !options.origin.every(Number.isFinite)))) {
46
+ throw new GeometryError("INVALID_ARGUMENT", "Uniform heightfields need a finite positive footprint, integer intervals, and finite origin.", { operation });
47
+ }
48
+ [x, y] = options.segments;
49
+ const [width, depth] = options.size;
50
+ const [originX, originY] = options.origin ?? [-width / 2, -depth / 2];
51
+ xAt = (index) => originX + (width * index) / x;
52
+ yAt = (index) => originY + (depth * index) / y;
24
53
  }
25
- const [x, y] = options.segments;
26
54
  const sampleCount = (x + 1) * (y + 1);
27
55
  const perimeter = 2 * (x + y);
28
56
  const vertexCount = sampleCount + perimeter + 1;
29
57
  const triangleCount = 2 * x * y + 3 * perimeter;
30
58
  const byteCount = 12 * (vertexCount + triangleCount);
31
- const policy = { ...defaultModelingLimits, ...limits };
32
- if (![policy.maxVertices, policy.maxTriangles, policy.maxBytes].every((value) => Number.isSafeInteger(value) && value > 0))
33
- throw new GeometryError("INVALID_ARGUMENT", "Heightfield ceilings must be positive safe integers.", { operation });
34
- if (!Number.isSafeInteger(byteCount) ||
35
- vertexCount > 0xffff_ffff ||
36
- triangleCount * 3 > 0xffff_ffff ||
37
- vertexCount > policy.maxVertices ||
38
- triangleCount > policy.maxTriangles ||
39
- byteCount > policy.maxBytes) {
40
- throw new GeometryError("COMPLEXITY_LIMIT", "Heightfield grid exceeds the configured output limits or numeric index ceiling.", { operation });
59
+ assertMeshAllocationWithinLimits(vertexCount, triangleCount, byteCount, operation, resolveModelingLimits(limits));
60
+ for (const [axis, count, at] of [
61
+ ["x", x, xAt],
62
+ ["y", y, yAt],
63
+ ]) {
64
+ let previous = -Infinity;
65
+ for (let index = 0; index <= count; index += 1) {
66
+ const coordinate = Math.fround(at(index));
67
+ if (!Number.isFinite(coordinate) || !(coordinate > previous))
68
+ throw new GeometryError("INVALID_ARGUMENT", "Heightfield axes must be finite, strictly increasing, and distinct in Float32 storage.", { operation, details: { axis, index } });
69
+ previous = coordinate;
70
+ }
41
71
  }
42
- return Object.freeze({ sampleCount, vertexCount, triangleCount, byteCount });
72
+ return {
73
+ x,
74
+ y,
75
+ xAt,
76
+ yAt,
77
+ facts: Object.freeze({ sampleCount, vertexCount, triangleCount, byteCount }),
78
+ };
79
+ }
80
+ /** Checks grid dimensions, coordinate precision, and allocation bounds before evaluating heights. */
81
+ export function preflightHeightfield(options, limits = {}) {
82
+ return planHeightfield(options, limits).facts;
43
83
  }
44
84
  /**
45
- * Builds a closed indexed solid from a regularly sampled top, matched boundary walls, and flat base.
46
- * Heights are evaluated once per grid point after allocation preflight. Physical XY sizes and all
47
- * output coordinates must fit Float32 storage. Shared wall vertices prevent cracks at the base.
85
+ * Builds a closed indexed mesh from a rectilinear top, matched boundary walls, and flat base.
86
+ * Heights run once per grid point after preflight; center-error adds one evaluation per cell.
87
+ * Top winding is +Z, the base is -Z, and sides face outward. Coordinates and heights must fit
88
+ * Float32 storage. Explicit axes are captured before sampling; shared wall vertices prevent cracks.
48
89
  *
49
90
  * @example Sample a closed wavy mesh
50
91
  * ```ts
@@ -55,11 +96,13 @@ export function preflightHeightfield(options, limits = {}) {
55
96
  * ```
56
97
  */
57
98
  export function heightfieldMesh(options, limits = {}) {
58
- const facts = preflightHeightfield(options, limits);
99
+ const plan = planHeightfield(options, limits);
100
+ const { facts, x: nx, y: ny } = plan;
59
101
  const operation = "heightfieldMesh";
60
- const [nx, ny] = options.segments;
61
- const [width, depth] = options.size;
62
- const [originX, originY] = options.origin ?? [-width / 2, -depth / 2];
102
+ const xs = Array.from({ length: nx + 1 }, (_, index) => plan.xAt(index));
103
+ const ys = Array.from({ length: ny + 1 }, (_, index) => plan.yAt(index));
104
+ const triangulation = options.triangulation ?? "fixed";
105
+ const heightAt = options.height;
63
106
  const baseZ = Math.fround(options.baseZ ?? 0);
64
107
  if (!Number.isFinite(baseZ))
65
108
  throw new GeometryError("NON_FINITE_VALUE", "Heightfield base must fit Float32 storage.", {
@@ -68,13 +111,16 @@ export function heightfieldMesh(options, limits = {}) {
68
111
  const positions = new Float32Array(facts.vertexCount * 3);
69
112
  const indices = new Uint32Array(facts.triangleCount * 3);
70
113
  const grid = (x, y) => y * (nx + 1) + x;
114
+ const sampleHeight = (point) => {
115
+ const height = Math.fround(heightAt(point));
116
+ if (!Number.isFinite(height) || !(height > baseZ))
117
+ throw new GeometryError("INVALID_ARGUMENT", "Every finite Float32 height must be strictly above the Float32 base.", { operation, details: { x: point[0], y: point[1] } });
118
+ return height;
119
+ };
71
120
  for (let y = 0; y <= ny; y += 1) {
72
121
  for (let x = 0; x <= nx; x += 1) {
73
- const point = [originX + (width * x) / nx, originY + (depth * y) / ny];
74
- const height = Math.fround(options.height(point));
75
- if (!Number.isFinite(height) || !(height > baseZ)) {
76
- throw new GeometryError("INVALID_ARGUMENT", "Every finite Float32 height must be strictly above the Float32 base.", { operation, details: { x, y } });
77
- }
122
+ const point = [xs[x], ys[y]];
123
+ const height = sampleHeight(point);
78
124
  writeMeshVertex(positions, grid(x, y) * 3, [point[0], point[1], height], operation);
79
125
  }
80
126
  }
@@ -87,8 +133,21 @@ export function heightfieldMesh(options, limits = {}) {
87
133
  for (let y = 0; y < ny; y += 1) {
88
134
  for (let x = 0; x < nx; x += 1) {
89
135
  const a = grid(x, y), b = grid(x + 1, y), c = grid(x + 1, y + 1), d = grid(x, y + 1);
90
- triangle(a, b, c);
91
- triangle(a, c, d);
136
+ const za = positions[a * 3 + 2], zb = positions[b * 3 + 2], zc = positions[c * 3 + 2], zd = positions[d * 3 + 2];
137
+ // Rectangular XY cells have equal XY diagonal lengths, so only Z differs.
138
+ let alternate = triangulation === "min-edge" && Math.abs(zb - zd) < Math.abs(za - zc);
139
+ if (triangulation === "center-error") {
140
+ const centre = sampleHeight([(xs[x] + xs[x + 1]) / 2, (ys[y] + ys[y + 1]) / 2]);
141
+ alternate = Math.abs((zb + zd) / 2 - centre) < Math.abs((za + zc) / 2 - centre);
142
+ }
143
+ if (alternate) {
144
+ triangle(a, b, d);
145
+ triangle(b, c, d);
146
+ }
147
+ else {
148
+ triangle(a, b, c);
149
+ triangle(a, c, d);
150
+ }
92
151
  }
93
152
  }
94
153
  const boundary = [];
@@ -102,7 +161,7 @@ export function heightfieldMesh(options, limits = {}) {
102
161
  boundary.push(grid(0, y));
103
162
  const baseStart = facts.sampleCount;
104
163
  const baseCenter = facts.vertexCount - 1;
105
- writeMeshVertex(positions, baseCenter * 3, [originX + width / 2, originY + depth / 2, baseZ], operation);
164
+ writeMeshVertex(positions, baseCenter * 3, [(xs[0] + xs[nx]) / 2, (ys[0] + ys[ny]) / 2, baseZ], operation);
106
165
  for (let offset = 0; offset < boundary.length; offset += 1) {
107
166
  const top = boundary[offset];
108
167
  writeMeshVertex(positions, (baseStart + offset) * 3, [positions[top * 3], positions[top * 3 + 1], baseZ], operation);
@@ -1,4 +1,6 @@
1
1
  /** Pure geometry foundations without host initialization. @packageDocumentation */
2
+ export { validateSolidMesh } from "./mesh-solid.js";
3
+ export { computeTriangleMeshCentroid } from "./mesh-centroid.js";
2
4
  export { matchProfiles, } from "./profiles/matched.js";
3
5
  export { heightfieldMesh, preflightHeightfield, } from "./heightfield.js";
4
6
  export { preflightThreadedRodSolid as preflightThreadedRod, preflightInternalThreadCutterSolid as preflightInternalThreadCutter, preflightThreadedNutSolid as preflightThreadedNut, } from "./mechanical/thread-solids.js";
@@ -1,15 +1,14 @@
1
1
  import { GeometryError } from "./errors.js";
2
2
  import { triangulateLoop2 } from "./loft-triangulation.js";
3
3
  import { measureTriangleMesh, validateTriangleMesh } from "./mesh.js";
4
- import { assertMeshAllocationWithinLimits, } from "./model-limits.js";
4
+ import { assertMeshAllocationWithinLimits } from "./model-limits.js";
5
5
  import { writeMeshVertex } from "./numeric.js";
6
6
  /** @internal Pure closed simple-contour strips, retaining repeated correspondence slots as shared physical vertices. */
7
- export function simpleLoftMesh(geometry, limits, allocationGuard) {
7
+ export function simpleLoftMesh(geometry, limits) {
8
8
  const count = geometry.world[0][0].length, rowCount = geometry.world.length;
9
9
  const maximumVertices = count * rowCount, maximumTriangles = 2 * count * (rowCount - 1) + 2 * (count - 2);
10
10
  const bytes = 12 * (maximumVertices + maximumTriangles);
11
11
  assertMeshAllocationWithinLimits(maximumVertices, maximumTriangles, bytes, "loftMesh", limits);
12
- allocationGuard?.(maximumVertices, maximumTriangles, bytes, "loftMesh");
13
12
  const positions = new Float32Array(maximumVertices * 3), indices = new Uint32Array(maximumTriangles * 3);
14
13
  let vertexCount = 0, cursor = 0;
15
14
  const rows = geometry.world.map((contours, row) => {
@@ -11,6 +11,16 @@ const pointInTriangle = (point, a, b, c, epsilon) => {
11
11
  /** Ear-clips one normalized simple CCW loop into stable local vertex indices. */
12
12
  export function triangulateLoop2(loop) {
13
13
  const normalized = normalizeLoop2(loop, true);
14
+ const assertComparisonLimit = (requiredCount) => {
15
+ if (requiredCount > maximumComparisons)
16
+ throw new GeometryError("COMPLEXITY_LIMIT", "Profile cap triangulation exceeds its eight-million-comparison work limit.", {
17
+ operation: "triangulateLoop2",
18
+ details: { requiredCount, allowedCount: maximumComparisons },
19
+ });
20
+ };
21
+ // Every accepted ear scans all remaining vertices plus its outer-loop comparison.
22
+ // Reject impossible workloads before clipping; unsuccessful ears still spend the live budget.
23
+ assertComparisonLimit(((normalized.length - 3) * (normalized.length + 6)) / 2);
14
24
  // Subdividing a straight edge introduces roundoff-sized turns. Treat them consistently
15
25
  // in both ear convexity and containment so they cannot consume a false ear or block the cap.
16
26
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -25,24 +35,17 @@ export function triangulateLoop2(loop) {
25
35
  const remaining = normalized.map((_, index) => index);
26
36
  const triangles = [];
27
37
  let comparisons = 0;
28
- const consume = () => {
29
- if (++comparisons > maximumComparisons)
30
- throw new GeometryError("COMPLEXITY_LIMIT", "Profile cap triangulation exceeds its eight-million-comparison work limit.", {
31
- operation: "triangulateLoop2",
32
- details: { requiredCount: comparisons, allowedCount: maximumComparisons },
33
- });
34
- };
35
38
  while (remaining.length > 3) {
36
39
  let clipped = false;
37
40
  for (let offset = 0; offset < remaining.length; offset += 1) {
38
- consume();
41
+ assertComparisonLimit(++comparisons);
39
42
  const previous = remaining[(offset - 1 + remaining.length) % remaining.length];
40
43
  const current = remaining[offset];
41
44
  const next = remaining[(offset + 1) % remaining.length];
42
45
  if (cross(normalized[previous], normalized[current], normalized[next]) <= epsilon)
43
46
  continue;
44
47
  if (remaining.some((candidate) => {
45
- consume();
48
+ assertComparisonLimit(++comparisons);
46
49
  return (candidate !== previous &&
47
50
  candidate !== current &&
48
51
  candidate !== next &&