@metamesh/modeling 0.6.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 (50) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +157 -22
  3. package/dist/api/engine.d.ts +25 -4
  4. package/dist/api/geometry.d.ts +391 -82
  5. package/dist/api/index.d.ts +732 -108
  6. package/dist/api/text.d.ts +40 -10
  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/index.js +12 -1
  12. package/dist/engine/inspection-operations.js +26 -22
  13. package/dist/engine/loft-operations.js +1 -1
  14. package/dist/engine/mechanical-operations.js +5 -8
  15. package/dist/engine/mesh-view-cache.js +23 -0
  16. package/dist/engine/native/adapter.js +2 -4
  17. package/dist/engine/native/distance.js +42 -0
  18. package/dist/engine/native/status.js +16 -0
  19. package/dist/engine/operation-environment.js +2 -1
  20. package/dist/engine/section-operations.js +3 -3
  21. package/dist/geometry/curves/sampling.js +2 -1
  22. package/dist/geometry/heightfield.js +109 -42
  23. package/dist/geometry/index.js +2 -0
  24. package/dist/geometry/loft-simple-mesh.js +2 -3
  25. package/dist/geometry/loft-triangulation.js +12 -9
  26. package/dist/geometry/loft.js +18 -7
  27. package/dist/geometry/math/vector3.js +35 -5
  28. package/dist/geometry/mechanical/standards.js +2 -3
  29. package/dist/geometry/mechanical/thread-profiles.js +5 -0
  30. package/dist/geometry/mechanical/threads.js +2 -3
  31. package/dist/geometry/mesh-centroid.js +36 -0
  32. package/dist/geometry/mesh-edge-table.js +126 -0
  33. package/dist/geometry/mesh-solid-intersections.js +123 -0
  34. package/dist/geometry/mesh-solid.js +284 -0
  35. package/dist/geometry/mesh.js +8 -25
  36. package/dist/geometry/paths/path.js +3 -1
  37. package/dist/geometry/paths/resampling.js +2 -1
  38. package/dist/geometry/profiles/matched.js +8 -0
  39. package/dist/geometry/profiles/profiles.js +24 -3
  40. package/dist/geometry/profiles/rectangle.js +16 -2
  41. package/dist/geometry/profiles/shapes.js +8 -1
  42. package/dist/geometry/revolve/revolve-mesh.js +6 -0
  43. package/dist/geometry/sweeps/sweep-closed.js +4 -4
  44. package/dist/geometry/sweeps/sweep-mesh.js +13 -7
  45. package/dist/geometry/sweeps/sweep-open.js +4 -4
  46. package/dist/geometry/sweeps/validation.js +1 -2
  47. package/dist/model/operations.js +13 -4
  48. package/dist/text/index.js +11 -1
  49. package/dist/text/vector-text-layout.js +46 -3
  50. package/package.json +1 -1
@@ -1,16 +1,28 @@
1
1
  /** Limits apply before parsing or shaping untrusted font data. */
2
2
  export declare const MAX_VECTOR_FONT_BYTES: number;
3
3
 
4
- /** Parses bounded font bytes using optional fontkit and WOFF2 peers; no font fetching occurs here. */
4
+ /**
5
+ * Parses bounded font bytes using optional fontkit and WOFF2 peers; no font fetching occurs here.
6
+ *
7
+ * @example Prepare a label before building geometry
8
+ * ```ts
9
+ * async function prepareLabel(fontBytes: Uint8Array) {
10
+ * const font = await prepareVectorFont(fontBytes);
11
+ * return font.layout("MetaMesh", { fontSize: 8 });
12
+ * }
13
+ * ```
14
+ */
5
15
  export declare function prepareVectorFont(bytes: Uint8Array, options?: VectorFontOptions): Promise<VectorFont>;
6
16
 
7
- /** A prepared font owns its parsed bytes; keep handles local to their Worker or invocation. */
17
+ /**
18
+ * A prepared font owns its parsed bytes; keep handles local to their Worker or invocation.
19
+ */
8
20
  export declare interface VectorFont {
9
- /** Shape and position up to 512 UTF-16 units and 16 lines; supports empty or whitespace text. */
21
+ /** Shape horizontal lines or one circular line; bounded to 512 UTF-16 units and supports blank text. */
10
22
  readonly layout: (text: string, options: VectorTextLayoutOptions) => VectorTextLayout;
11
23
  /** Measure the same cached vector layout; all returned lengths are in millimetres. */
12
24
  readonly measure: (text: string, options: VectorTextLayoutOptions) => VectorTextMetrics;
13
- /** Serialize visible multiline outlines as bounded path-only SVG in physical millimetres. */
25
+ /** Serialize visible horizontal or circular outlines as bounded path-only SVG in physical millimetres. */
14
26
  readonly toSvg: (text: string, options: VectorTextLayoutOptions) => string;
15
27
  }
16
28
 
@@ -20,6 +32,18 @@ export declare interface VectorFontOptions {
20
32
  readonly staticWeight?: number;
21
33
  }
22
34
 
35
+ /** A circular baseline centered at the XY origin, with rigid shaped glyph groups. */
36
+ export declare interface VectorTextArcBaseline {
37
+ /** Selects a circular baseline; glyph outlines rotate without bending or stretching. */
38
+ readonly kind: "arc";
39
+ /** Baseline radius in millimetres, greater than zero and at most 10000. */
40
+ readonly radius: number;
41
+ /** Alignment datum in degrees counterclockwise from +X; defaults to 90. */
42
+ readonly centerAngleDegrees?: number;
43
+ /** Reading direction around the circle; defaults to clockwise. */
44
+ readonly direction?: "clockwise" | "counterclockwise";
45
+ }
46
+
23
47
  /** Exact curve bounds in millimetres; positive Y points upward. */
24
48
  export declare interface VectorTextBounds {
25
49
  /** Minimum X and Y of visible outlines, including accents, descenders, and overhangs. */
@@ -28,7 +52,9 @@ export declare interface VectorTextBounds {
28
52
  readonly max: readonly [number, number];
29
53
  }
30
54
 
31
- /** Immutable vector layout with no browser, font parser, or kernel ownership. */
55
+ /**
56
+ * Immutable vector layout with no browser, font parser, or kernel ownership.
57
+ */
32
58
  export declare interface VectorTextLayout {
33
59
  /** Nonzero-filled path data in millimetres, positive Y upward; empty for text with no ink. */
34
60
  readonly paths: readonly string[];
@@ -36,13 +62,17 @@ export declare interface VectorTextLayout {
36
62
  readonly metrics: VectorTextMetrics;
37
63
  }
38
64
 
39
- /** Horizontal multiline text, bounded to 512 UTF-16 units and 16 lines without wrapping. */
65
+ /**
66
+ * Text bounded to 512 UTF-16 units: horizontal lines or one circular line without wrapping.
67
+ */
40
68
  export declare interface VectorTextLayoutOptions extends VectorTextOptions {
41
- /** Place each line's advance at the origin; defaults to left. Ink overhang is preserved. */
69
+ /** Place the advance's start, midpoint or end at the datum; defaults to center on arcs, otherwise left. */
42
70
  readonly align?: "left" | "center" | "right";
71
+ /** Omitted uses horizontal lines. Arcs require one line and baseline vertical alignment. */
72
+ readonly baseline?: VectorTextArcBaseline;
43
73
  /** Baseline spacing in ems, 0.5–5; omitted uses the selected face's natural vertical metrics. */
44
74
  readonly lineHeight?: number;
45
- /** Place the first baseline or the complete ink bounds at Y=0; defaults to baseline. */
75
+ /** Place the first baseline or complete ink bounds at Y=0; arcs only accept baseline. */
46
76
  readonly verticalAlign?: "baseline" | "top" | "center" | "bottom";
47
77
  }
48
78
 
@@ -50,9 +80,9 @@ export declare interface VectorTextLayoutOptions extends VectorTextOptions {
50
80
  export declare interface VectorTextMetrics {
51
81
  /** Visible curve bounds after alignment, or null when the text has no ink. */
52
82
  readonly bounds: VectorTextBounds | null;
53
- /** Unaligned pen displacement: final line advance and negative baseline displacement, in mm. */
83
+ /** Unaligned logical advance and negative baseline displacement in mm; arcs retain [arc length, 0]. */
54
84
  readonly advance: readonly [number, number];
55
- /** Maximum line advance in millimetres, including spaces; distinct from visible ink width. */
85
+ /** Maximum logical line advance in millimetres, including spaces; arc length on circular baselines. */
56
86
  readonly logicalWidth: number;
57
87
  /** Selected font's ascender distance above a baseline in millimetres, independent of the text. */
58
88
  readonly ascent: number;
@@ -1,12 +1,12 @@
1
1
  import { GeometryError } from "../geometry/errors.js";
2
2
  import { resolveExtrusionContourDivisions, resolveExtrusionControls, } from "../geometry/extrusion.js";
3
- import { heightfieldMesh, preflightHeightfield } from "../geometry/heightfield.js";
3
+ import { heightfieldMesh } from "../geometry/heightfield.js";
4
4
  import { assertMeshAllocationWithinLimits } from "../geometry/model-limits.js";
5
5
  import { assertPrimitiveOptions } from "../geometry/primitive-resolvers.js";
6
6
  import { matchProfiles } from "../geometry/profiles/matched.js";
7
7
  import { resolveGeometryResolution } from "../geometry/resolution.js";
8
8
  import { assertSamplingCount } from "../geometry/sampling-limits.js";
9
- import { buildSweepMesh } from "../geometry/sweeps/sweep-mesh.js";
9
+ import { sweepMesh } from "../geometry/sweeps/sweep-mesh.js";
10
10
  import { buildFinishedExtrusion } from "./extrusion-finishing.js";
11
11
  import { createLoftOperation } from "./loft-operations.js";
12
12
  import { triangleMeshToManifold } from "./native/adapter.js";
@@ -15,7 +15,18 @@ import * as kernel from "./native/index.js";
15
15
  import { buildProfileSweepMesh } from "./profile-mesh.js";
16
16
  /** Internal host-bound construction operations. */
17
17
  export function createConstructionOperations(environment, sections) {
18
- const { module, scope, limits, assertActive, throwGeometryError, resolveHandle, nativeSolid, nativeSection, wrapSolid, ownSolid, ownSection, validateFiniteNumber, requirePositiveNumber, validateCoordinates, assertOperandCount, assertMeshAllocation, withResolutionDefaults, resolveCircularSegments, } = environment;
18
+ const { module, scope, limits, assertActive, throwGeometryError, resolveHandle, nativeSolid, nativeSection, wrapSolid, wrapSection, ownSolid, ownSection, validateFiniteNumber, requirePositiveNumber, validateCoordinates, assertOperandCount, assertMeshAllocation, withResolutionDefaults, resolveCircularSegments, } = environment;
19
+ const cuttingPlane = (value, operation) => {
20
+ assertPrimitiveOptions(value, operation);
21
+ validateCoordinates(value.normal, 3, operation);
22
+ const magnitude = Math.max(...value.normal.map(Math.abs));
23
+ if (magnitude === 0)
24
+ throwGeometryError("DEGENERATE_GEOMETRY", operation, "Plane normals must be nonzero.");
25
+ const scaled = value.normal.map((coordinate) => coordinate / magnitude);
26
+ const length = Math.hypot(...scaled);
27
+ const normal = [scaled[0] / length, scaled[1] / length, scaled[2] / length];
28
+ return { normal, offset: validateFiniteNumber(value.offset ?? 0, operation) };
29
+ };
19
30
  const fromContours = sections.sectionFromContours;
20
31
  const corresponding = (value) => typeof value === "object" && value !== null && "components" in value;
21
32
  const shapeSection = (shape) => corresponding(shape)
@@ -50,7 +61,6 @@ export function createConstructionOperations(environment, sections) {
50
61
  // a hole also bounds disconnected Sections, without extracting native point data.
51
62
  triangles = Math.max(0, vertices * 2 + 4 * native.numContour() - 8);
52
63
  assertMeshAllocationWithinLimits(vertices, triangles, vertices * 12 + triangles * 12, "extrude", resolved.limits);
53
- assertMeshAllocation(vertices, triangles, vertices * 12 + triangles * 12, "extrude");
54
64
  return ownSolid(native.extrude(value.height, divisions, controls.twistDegrees, controls.scales, controls.center));
55
65
  };
56
66
  const boolean = (values, operation) => {
@@ -90,13 +100,47 @@ export function createConstructionOperations(environment, sections) {
90
100
  union: ((values) => boolean(values, "union")),
91
101
  subtract,
92
102
  intersect: ((values) => boolean(values, "intersect")),
103
+ split: (solid, cutter) => {
104
+ const results = nativeSolid(solid).split(nativeSolid(cutter));
105
+ return Object.freeze([ownSolid(results[0]), ownSolid(results[1])]);
106
+ },
107
+ splitByPlane: (solid, value) => {
108
+ const native = nativeSolid(solid);
109
+ const plane = cuttingPlane(value, "splitByPlane");
110
+ const results = native.splitByPlane(plane.normal, plane.offset);
111
+ return Object.freeze([ownSolid(results[0]), ownSolid(results[1])]);
112
+ },
93
113
  compose: (values) => wrapSolid(kernel.compose(scope, values.map(nativeSolid))),
94
- hull: (value) => {
114
+ hull: ((value) => {
95
115
  assertActive();
96
- return wrapSolid(kernel.hull(scope, value.solids === undefined
116
+ assertPrimitiveOptions(value, "hull");
117
+ const hasSolids = "solids" in value && value.solids !== undefined;
118
+ const hasSections = "sections" in value && value.sections !== undefined;
119
+ if (Number(value.points !== undefined) + Number(hasSolids) + Number(hasSections) !== 1)
120
+ throwGeometryError("INVALID_ARGUMENT", "hull", "Supply exactly one hull input family.");
121
+ if (hasSections) {
122
+ assertOperandCount(value.sections, "hull");
123
+ return ownSection(module.CrossSection.hull(value.sections.map(nativeSection)));
124
+ }
125
+ if (value.points !== undefined && value.points[0]?.length === 2) {
126
+ const points = value.points;
127
+ const pointInputs = points;
128
+ if (!Array.isArray(points) || points.length < 3)
129
+ throwGeometryError("INVALID_ARGUMENT", "hull", "A planar hull needs at least three points.");
130
+ assertSamplingCount(points.length, "hull", Math.min(100_000, limits.maxProfilePoints));
131
+ const copied = pointInputs.map((point) => {
132
+ validateCoordinates(point, 2, "hull");
133
+ return [point[0], point[1]];
134
+ });
135
+ const native = ownScopedManifoldValue(scope, module.CrossSection.hull([copied]));
136
+ if (!(native.area() > 0))
137
+ throwGeometryError("DEGENERATE_GEOMETRY", "hull", "Hull points must span positive area.");
138
+ return wrapSection(native);
139
+ }
140
+ return wrapSolid(kernel.hull(scope, !hasSolids
97
141
  ? { points: value.points }
98
142
  : { solids: value.solids.map(nativeSolid) }));
99
- },
143
+ }),
100
144
  minkowskiSum: (a, b) => wrapSolid(kernel.minkowskiSum(scope, nativeSolid(a), nativeSolid(b))),
101
145
  solidFromMesh,
102
146
  extrude,
@@ -147,13 +191,11 @@ export function createConstructionOperations(environment, sections) {
147
191
  const singleComponent = profile.components.length === 1 ? profile.components[0] : undefined;
148
192
  const contours = singleComponent?.holes.length === 0 ? [singleComponent.outer.points] : [];
149
193
  if (contours.length === 1)
150
- return solidFromMesh(buildSweepMesh(contours[0], path, selected, assertMeshAllocation));
194
+ return solidFromMesh(sweepMesh(contours[0], path, selected));
151
195
  return solidFromMesh(buildProfileSweepMesh(module, profile, path, selected, selected.limits));
152
196
  },
153
197
  heightfield: (value) => {
154
198
  assertActive();
155
- const facts = preflightHeightfield(value, limits);
156
- assertMeshAllocation(facts.vertexCount, facts.triangleCount, facts.byteCount, "heightfield");
157
199
  return solidFromMesh(heightfieldMesh(value, limits));
158
200
  },
159
201
  implicit: (value) => {
@@ -7,6 +7,7 @@ import { createModelingContext } from "./modeling-context.js";
7
7
  import { ownScopedManifoldValue } from "./native/context.js";
8
8
  import { copiedFloat32Array, copiedUint32Array } from "./native/copied-views.js";
9
9
  import * as kernel from "./native/index.js";
10
+ import { assertNativeSolidStatus } from "./native/status.js";
10
11
  import { createShapeAnchorOwner } from "./shape-anchors.js";
11
12
  /** Internal execution owner; no native value or constructor crosses the public facade. */
12
13
  export function createExecutionContext(module, scope, limits, initialResolution = {}) {
@@ -15,6 +16,8 @@ export function createExecutionContext(module, scope, limits, initialResolution
15
16
  // mesh inspection receives independent copies, and this run-owned cache is cleared on exit.
16
17
  const canonicalSolidMeshes = new Map();
17
18
  const canonicalSectionProfiles = new Map();
19
+ let solidInspections;
20
+ let sectionInspections;
18
21
  const primitiveSectionProfiles = new Map();
19
22
  let active = true;
20
23
  const throwGeometryError = (code, operation, message) => {
@@ -60,6 +63,50 @@ export function createExecutionContext(module, scope, limits, initialResolution
60
63
  const owned = ownScopedManifoldValue(scope, value);
61
64
  return wrapSection(owned);
62
65
  };
66
+ // Geometry is immutable for a native value. Validate build ownership before every cache hit,
67
+ // and retain only frozen plain facts so callers cannot corrupt later inspections.
68
+ const solidInspectionFor = (handle) => {
69
+ const native = nativeSolid(handle);
70
+ const cached = solidInspections?.get(native);
71
+ if (cached !== undefined)
72
+ return cached;
73
+ assertNativeSolidStatus(native, "inspectSolid");
74
+ const bounds = native.boundingBox();
75
+ const parts = native.decompose().map((part) => ownScopedManifoldValue(scope, part));
76
+ const facts = Object.freeze({
77
+ min: Object.freeze([...bounds.min]),
78
+ max: Object.freeze([...bounds.max]),
79
+ isEmpty: native.isEmpty(),
80
+ parts: parts.length,
81
+ volume: native.volume(),
82
+ surfaceArea: native.surfaceArea(),
83
+ tolerance: native.tolerance(),
84
+ });
85
+ (solidInspections ??= new Map()).set(native, facts);
86
+ return facts;
87
+ };
88
+ const sectionInspectionFor = (handle) => {
89
+ const native = nativeSection(handle);
90
+ const cached = sectionInspections?.get(native);
91
+ if (cached !== undefined)
92
+ return cached;
93
+ const bounds = native.bounds();
94
+ const empty = native.isEmpty();
95
+ const parts = empty
96
+ ? []
97
+ : native.decompose().map((part) => ownScopedManifoldValue(scope, part));
98
+ const facts = Object.freeze({
99
+ min: Object.freeze([...bounds.min]),
100
+ max: Object.freeze([...bounds.max]),
101
+ isEmpty: empty,
102
+ area: native.area(),
103
+ contours: native.numContour(),
104
+ parts: parts.length,
105
+ vertices: native.numVert(),
106
+ });
107
+ (sectionInspections ??= new Map()).set(native, facts);
108
+ return facts;
109
+ };
63
110
  const shapeAnchors = createShapeAnchorOwner((shape) => {
64
111
  resolveHandle(shape);
65
112
  }, (shape) => {
@@ -94,6 +141,7 @@ export function createExecutionContext(module, scope, limits, initialResolution
94
141
  assertMeshAllocationWithinLimits(vertices, triangles, bytes, operation, limits);
95
142
  };
96
143
  const copyNativeMesh = (native) => {
144
+ assertNativeSolidStatus(native, "toTriangleMesh");
97
145
  const raw = native.getMesh();
98
146
  assertMeshAllocation(raw.numVert, raw.numTri, (raw.numVert + raw.numTri) * 12, "copyMesh");
99
147
  const positions = copiedFloat32Array(raw.numVert * 3);
@@ -194,6 +242,8 @@ export function createExecutionContext(module, scope, limits, initialResolution
194
242
  assertOperandCount,
195
243
  assertMeshAllocation,
196
244
  analyzedMeshFor,
245
+ solidInspectionFor,
246
+ sectionInspectionFor,
197
247
  sectionProfileFor,
198
248
  primitiveProfileFor,
199
249
  rememberPrimitiveProfile,
@@ -213,13 +263,16 @@ export function createExecutionContext(module, scope, limits, initialResolution
213
263
  triVerts: mesh.indices,
214
264
  });
215
265
  const native = ownScopedManifoldValue(scope, new module.Manifold(input));
216
- if (native.status() !== "NoError")
217
- throwGeometryError("INVALID_TOPOLOGY", "finalize", "The geometry kernel rejected the finalized manufacturing mesh.");
266
+ assertNativeSolidStatus(native, "finalize");
218
267
  },
219
268
  dispose: () => {
220
269
  active = false;
221
270
  canonicalSolidMeshes.clear();
222
271
  canonicalSectionProfiles.clear();
272
+ solidInspections?.clear();
273
+ sectionInspections?.clear();
274
+ solidInspections = undefined;
275
+ sectionInspections = undefined;
223
276
  primitiveSectionProfiles.clear();
224
277
  shapeAnchors.dispose();
225
278
  },
@@ -1,12 +1,20 @@
1
1
  import { Zip, ZipDeflate } from "fflate";
2
2
  import { GeometryError } from "../geometry/errors.js";
3
+ import { analyzeSolidMesh } from "../geometry/mesh-solid.js";
3
4
  import { analyzeTriangleMesh } from "../geometry/mesh.js";
5
+ import { MeshViewCache } from "./mesh-view-cache.js";
4
6
  const namespace = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
5
7
  const declaration = '<?xml version="1.0" encoding="UTF-8"?>';
6
8
  const encoder = new TextEncoder();
7
9
  const escapeXml = (value) => value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[character]);
8
10
  /** Private core 3MF serializer. It preserves the selected buffers and streams bounded XML chunks. */
9
11
  export function meshesTo3mf(parts, options) {
12
+ if (options.meshValidation !== undefined &&
13
+ options.meshValidation !== "topology" &&
14
+ options.meshValidation !== "solid")
15
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported mesh validation mode.", {
16
+ operation: "export3mf",
17
+ });
10
18
  const name = options.name ?? "MetaMesh model";
11
19
  if (typeof name !== "string" ||
12
20
  name.length === 0 ||
@@ -14,10 +22,18 @@ export function meshesTo3mf(parts, options) {
14
22
  // eslint-disable-next-line no-control-regex -- XML 1.0 disallows these control characters.
15
23
  /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff\ufffe\uffff]/u.test(name))
16
24
  throw new GeometryError("INVALID_ARGUMENT", "3MF names require one through 1024 valid XML characters.", { operation: "export3mf" });
25
+ const analyzed = new MeshViewCache();
17
26
  for (const part of parts) {
18
- const facts = analyzeTriangleMesh(part, { requireClosed: true, includeTopology: true });
27
+ let facts = analyzed.get(part);
28
+ if (facts !== undefined)
29
+ continue;
30
+ facts =
31
+ options.meshValidation === "solid"
32
+ ? analyzeSolidMesh(part)
33
+ : analyzeTriangleMesh(part, { requireClosed: true, includeTopology: true });
19
34
  if (!(facts.signedVolume > 0) || facts.vertexCount >= 2 ** 31 || facts.triangleCount >= 2 ** 31)
20
35
  throw new GeometryError("INVALID_TOPOLOGY", "3MF parts require positive oriented volume and valid core resource counts.", { operation: "export3mf" });
36
+ analyzed.set(part, facts);
21
37
  }
22
38
  const chunks = [];
23
39
  let bytes = 0, complete = false;
@@ -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),
@@ -61,7 +61,18 @@ function engineFromModule(module, engineLimits) {
61
61
  },
62
62
  });
63
63
  }
64
- /** Initializes one host kernel and returns a lifecycle-safe engine. No generator initializes WASM. */
64
+ /**
65
+ * Initializes one host kernel and returns a lifecycle-safe engine. No generator initializes WASM.
66
+ *
67
+ * @example Build a box in a host application
68
+ * ```ts
69
+ * import { box } from "@metamesh/modeling";
70
+ * import { createModelEngine } from "@metamesh/modeling/engine";
71
+ *
72
+ * const engine = await createModelEngine();
73
+ * const snapshot = engine.build(() => box({ size: [30, 20, 8] }));
74
+ * ```
75
+ */
65
76
  export async function createModelEngine(options = {}) {
66
77
  const limits = resolveModelingLimits(options.limits);
67
78
  const module = await createManifoldModule({
@@ -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
  };