@snaptrude/plugin-core 0.0.0-dev-20260908074328 → 0.0.0-surfaces-20260921090636

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 (73) hide show
  1. package/CHANGELOG.md +0 -23
  2. package/api-manifest.json +151 -12
  3. package/dist/api/core/geom/create/index.d.ts +918 -93
  4. package/dist/api/core/geom/create/index.d.ts.map +1 -1
  5. package/dist/api/core/geom/query/brep.d.ts +50 -0
  6. package/dist/api/core/geom/query/brep.d.ts.map +1 -1
  7. package/dist/api/core/geom/query/curve.d.ts +58 -35
  8. package/dist/api/core/geom/query/curve.d.ts.map +1 -1
  9. package/dist/api/core/geom/query/edge.d.ts +2 -2
  10. package/dist/api/core/geom/query/face.d.ts +105 -0
  11. package/dist/api/core/geom/query/face.d.ts.map +1 -1
  12. package/dist/api/core/geom/update/curve.d.ts +5 -5
  13. package/dist/api/core/geom/update/profile.d.ts +1 -1
  14. package/dist/api/design/create/index.d.ts +6 -29
  15. package/dist/api/design/create/index.d.ts.map +1 -1
  16. package/dist/api/design/delete/index.d.ts +2 -20
  17. package/dist/api/design/delete/index.d.ts.map +1 -1
  18. package/dist/api/design/query/geometry/index.d.ts +27 -1
  19. package/dist/api/design/query/geometry/index.d.ts.map +1 -1
  20. package/dist/api/design/query/index.d.ts +47 -2
  21. package/dist/api/design/query/index.d.ts.map +1 -1
  22. package/dist/api/design/query/spaces.d.ts +5 -66
  23. package/dist/api/design/query/spaces.d.ts.map +1 -1
  24. package/dist/api/design/update/index.d.ts +87 -0
  25. package/dist/api/design/update/index.d.ts.map +1 -1
  26. package/dist/api/entity/referenceLine.d.ts +2 -2
  27. package/dist/api/entity/referenceLine.d.ts.map +1 -1
  28. package/dist/api/entity/space.d.ts +5 -7
  29. package/dist/api/entity/space.d.ts.map +1 -1
  30. package/dist/api/entity/story.d.ts +4 -4
  31. package/dist/api/presentation/placedViews.d.ts +1 -35
  32. package/dist/api/presentation/placedViews.d.ts.map +1 -1
  33. package/dist/api/presentation/sheets.d.ts +2 -20
  34. package/dist/api/presentation/sheets.d.ts.map +1 -1
  35. package/dist/api/program/site.d.ts +4 -4
  36. package/dist/api/program/spreadsheet.d.ts +4 -4
  37. package/dist/errors/codes.d.ts +3 -8
  38. package/dist/errors/codes.d.ts.map +1 -1
  39. package/dist/handles.d.ts +12 -3
  40. package/dist/handles.d.ts.map +1 -1
  41. package/dist/index.cjs +3188 -2769
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3141 -2764
  46. package/dist/index.js.map +1 -1
  47. package/dist/massParameters.d.ts +280 -0
  48. package/dist/massParameters.d.ts.map +1 -0
  49. package/dist/massParameters.ops.d.ts +135 -0
  50. package/dist/massParameters.ops.d.ts.map +1 -0
  51. package/package.json +1 -1
  52. package/src/api/core/geom/create/index.ts +1011 -94
  53. package/src/api/core/geom/query/brep.ts +51 -0
  54. package/src/api/core/geom/query/curve.ts +25 -2
  55. package/src/api/core/geom/query/edge.ts +2 -2
  56. package/src/api/core/geom/query/face.ts +109 -4
  57. package/src/api/design/create/index.ts +8 -34
  58. package/src/api/design/delete/index.ts +1 -20
  59. package/src/api/design/query/geometry/index.ts +29 -0
  60. package/src/api/design/query/index.ts +50 -2
  61. package/src/api/design/query/spaces.ts +0 -68
  62. package/src/api/design/update/index.ts +94 -0
  63. package/src/api/entity/space.ts +0 -2
  64. package/src/api/presentation/placedViews.ts +1 -29
  65. package/src/api/presentation/sheets.ts +3 -19
  66. package/src/errors/codes.ts +2 -30
  67. package/src/handles.ts +15 -3
  68. package/src/index.ts +2 -0
  69. package/src/massParameters.ops.ts +243 -0
  70. package/src/massParameters.ts +418 -0
  71. package/test/errors.test.mjs +0 -4
  72. package/test/massParameters.test.mjs +936 -0
  73. package/snaptrude-plugin-core-0.11.0.tgz +0 -0
@@ -0,0 +1,936 @@
1
+ // Node built-in test runner suite for the shared mass-parameter record schemas.
2
+ // Run: node --test test/
3
+ // Imports the BUILT package (dist) — run `pnpm build` first.
4
+ import test from "node:test"
5
+ import assert from "node:assert/strict"
6
+ import { readFileSync } from "node:fs"
7
+ import {
8
+ MassParametersRecord,
9
+ MassParametersPatch,
10
+ OperationRecord,
11
+ ContourRecord,
12
+ ProfileRecord,
13
+ CurveRecord,
14
+ EdgeSelector,
15
+ FaceSelector,
16
+ ParameterPatchValue,
17
+ SweepAlongCurveRecordOptions,
18
+ MAX_PROFILE_CURVES,
19
+ MAX_CONTOUR_HOLES,
20
+ MAX_SPLINE_THROUGH_POINTS,
21
+ MAX_SWEEP_PATH_POINTS,
22
+ MAX_LOFT_SECTIONS,
23
+ MAX_OPERATIONS,
24
+ MAX_OPERATION_EDGES,
25
+ MAX_OPERATION_FACES,
26
+ MAX_PATH_CURVES,
27
+ MAX_TWIST_LEVELS,
28
+ MAX_FILLET_LAW_SAMPLES,
29
+ MAX_PATCH_BOUNDARY_CURVES,
30
+ MAX_PATCH_INTERIOR_POINTS,
31
+ CurveHandle,
32
+ SplineHandle,
33
+ PluginGeomCreateBrepFromSweepArgs,
34
+ PluginGeomCreateBrepFromLoftArgs,
35
+ PluginGeomCreateBrepFromFilletArgs,
36
+ PluginGeomCreateBrepFromChamferArgs,
37
+ PluginGeomCreateBrepFromShellArgs,
38
+ PluginGeomCreateBrepFromExtrusionArgs,
39
+ PluginGeomCreateBrepFromPatchArgs,
40
+ PluginGeomCreateBrepFromSweepAlongCurveArgs,
41
+ PluginGeomCreateBrepsFromSplitByFaceArgs,
42
+ PluginGeomCreateCurvesFromProjectionArgs,
43
+ PluginGeomCreateSplineFromControlPointsArgs,
44
+ PluginDesignUpdateParametersArgs,
45
+ PluginGeomQueryFaceGetAreaArgs,
46
+ PluginGeomQueryFaceGetCentroidArgs,
47
+ } from "../dist/index.js"
48
+
49
+ const p = (x, y, z) => ({ x, y, z })
50
+
51
+ /** A square outer loop, no holes — the simplest legal contour record. */
52
+ const squareContour = {
53
+ outer: {
54
+ curves: [
55
+ { type: "line", start: p(0, 0, 0), end: p(4, 0, 0) },
56
+ { type: "line", start: p(4, 0, 0), end: p(4, 0, 4) },
57
+ { type: "line", start: p(4, 0, 4), end: p(0, 0, 4) },
58
+ { type: "line", start: p(0, 0, 4), end: p(0, 0, 0) },
59
+ ],
60
+ },
61
+ holes: [],
62
+ }
63
+
64
+ test("accepts one record of every kind", () => {
65
+ const records = [
66
+ { kind: "sphere", version: 1, values: { centre: p(0, 5, 0), radius: 5 } },
67
+ {
68
+ kind: "cylinder",
69
+ version: 1,
70
+ values: { base: p(0, 0, 0), axis: p(0, 1, 0), radius: 1, height: 4 },
71
+ },
72
+ {
73
+ kind: "cone",
74
+ version: 1,
75
+ values: { base: p(0, 0, 0), axis: p(0, 1, 0), baseRadius: 2, topRadius: 0, height: 3 },
76
+ },
77
+ {
78
+ kind: "torus",
79
+ version: 1,
80
+ values: { centre: p(0, 6, 0), axis: p(0, 1, 0), majorRadius: 5, minorRadius: 1 },
81
+ },
82
+ {
83
+ kind: "extrusion",
84
+ version: 1,
85
+ values: { contour: squareContour, direction: p(0, 1, 0), amount: 3 },
86
+ },
87
+ {
88
+ kind: "revolution",
89
+ version: 1,
90
+ values: {
91
+ contour: squareContour,
92
+ axisOrigin: p(0, 0, 0),
93
+ axisDirection: p(0, 1, 0),
94
+ angleInDegrees: 360,
95
+ },
96
+ },
97
+ {
98
+ kind: "loft",
99
+ version: 1,
100
+ values: { sections: [squareContour, squareContour], options: { compatibility: "auto" } },
101
+ },
102
+ {
103
+ kind: "sweep",
104
+ version: 1,
105
+ values: {
106
+ profile: squareContour,
107
+ path: [p(0, 0, 0), p(0, 3, 0)],
108
+ options: { transition: "round" },
109
+ },
110
+ },
111
+ ]
112
+ for (const record of records) {
113
+ const parsed = MassParametersRecord.safeParse(record)
114
+ assert.equal(parsed.success, true, `${record.kind}: ${JSON.stringify(parsed.error?.issues)}`)
115
+ assert.deepEqual(parsed.data, record)
116
+ }
117
+ })
118
+
119
+ test("both spline curve forms round-trip inside a contour", () => {
120
+ const contour = {
121
+ outer: {
122
+ curves: [
123
+ { type: "splineThrough", fitVersion: 1, throughPoints: [p(0, 0, 0), p(1, 0, 2), p(3, 0, 1)] },
124
+ {
125
+ type: "splinePoles",
126
+ degree: 3,
127
+ knots: [0, 1],
128
+ mults: [4, 4],
129
+ poles: [p(3, 0, 1), p(2, 0, 0), p(1, 0, -1), p(0, 0, 0)],
130
+ },
131
+ ],
132
+ },
133
+ holes: [],
134
+ }
135
+ assert.equal(ContourRecord.safeParse(contour).success, true)
136
+ })
137
+
138
+ test("rejects a record that is not the truth", () => {
139
+ // Unknown key — the host names it back to the caller, never silently drops it.
140
+ assert.equal(
141
+ MassParametersRecord.safeParse({
142
+ kind: "torus",
143
+ version: 1,
144
+ values: { centre: p(0, 0, 0), axis: p(0, 1, 0), majorRadius: 5, minorRadius: 1, radius: 5 },
145
+ }).success,
146
+ false,
147
+ )
148
+ // Unknown schema version — restore degrades the mass to non-parametric.
149
+ assert.equal(
150
+ MassParametersRecord.safeParse({
151
+ kind: "sphere",
152
+ version: 2,
153
+ values: { centre: p(0, 0, 0), radius: 5 },
154
+ }).success,
155
+ false,
156
+ )
157
+ // Non-finite / non-positive values.
158
+ for (const radius of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
159
+ assert.equal(
160
+ MassParametersRecord.safeParse({
161
+ kind: "sphere",
162
+ version: 1,
163
+ values: { centre: p(0, 0, 0), radius },
164
+ }).success,
165
+ false,
166
+ `radius ${radius} must be rejected`,
167
+ )
168
+ }
169
+ // A spline record with neither form's fields.
170
+ assert.equal(CurveRecord.safeParse({ type: "splineThrough", throughPoints: [] }).success, false)
171
+ })
172
+
173
+ test("array caps are contract, not truncation", () => {
174
+ const points = (n) => Array.from({ length: n }, (_, i) => p(i, 0, 0))
175
+ const spline = (n) => ({ type: "splineThrough", fitVersion: 1, throughPoints: points(n) })
176
+ assert.equal(CurveRecord.safeParse(spline(MAX_SPLINE_THROUGH_POINTS)).success, true)
177
+ assert.equal(CurveRecord.safeParse(spline(MAX_SPLINE_THROUGH_POINTS + 1)).success, false)
178
+
179
+ const sweep = (n) => ({
180
+ kind: "sweep",
181
+ version: 1,
182
+ values: { profile: squareContour, path: points(n) },
183
+ })
184
+ assert.equal(MassParametersRecord.safeParse(sweep(MAX_SWEEP_PATH_POINTS)).success, true)
185
+ assert.equal(MassParametersRecord.safeParse(sweep(MAX_SWEEP_PATH_POINTS + 1)).success, false)
186
+
187
+ const loft = (n) => ({
188
+ kind: "loft",
189
+ version: 1,
190
+ values: { sections: Array.from({ length: n }, () => squareContour) },
191
+ })
192
+ assert.equal(MassParametersRecord.safeParse(loft(MAX_LOFT_SECTIONS)).success, true)
193
+ assert.equal(MassParametersRecord.safeParse(loft(MAX_LOFT_SECTIONS + 1)).success, false)
194
+
195
+ // The caps bind the RECORD only. The creator args (shipped in 0.9.6 without
196
+ // any cap) keep accepting an over-cap call — the host builds it and mints no
197
+ // record (the recipe-less fallback). See "wire schemas keep 0.9.6 acceptance".
198
+ const sweepArgs = (n) => ({ profile: "contour_1", path: points(n) })
199
+ assert.equal(
200
+ PluginGeomCreateBrepFromSweepArgs.safeParse(sweepArgs(MAX_SWEEP_PATH_POINTS)).success,
201
+ true,
202
+ )
203
+ const loftArgs = (n) => ({
204
+ bottomContour: "contour_1",
205
+ topContour: "contour_2",
206
+ intermediateContours: Array.from({ length: n }, (_, i) => `contour_${i + 3}`),
207
+ })
208
+ assert.equal(
209
+ PluginGeomCreateBrepFromLoftArgs.safeParse(loftArgs(MAX_LOFT_SECTIONS - 2)).success,
210
+ true,
211
+ )
212
+
213
+ // A contour's own arrays are bounded too — a 20 000-curve loop or a
214
+ // 5 000-hole contour must not ride the save/collab payload as a record.
215
+ const loop = (n) => ({
216
+ curves: Array.from({ length: n }, (_, i) => ({
217
+ type: "line",
218
+ start: p(i, 0, 0),
219
+ end: p(i + 1, 0, 0),
220
+ })),
221
+ })
222
+ assert.equal(ProfileRecord.safeParse(loop(MAX_PROFILE_CURVES)).success, true)
223
+ assert.equal(ProfileRecord.safeParse(loop(MAX_PROFILE_CURVES + 1)).success, false)
224
+
225
+ const holed = (n) => ({
226
+ outer: squareContour.outer,
227
+ holes: Array.from({ length: n }, () => squareContour.outer),
228
+ })
229
+ assert.equal(ContourRecord.safeParse(holed(MAX_CONTOUR_HOLES)).success, true)
230
+ assert.equal(ContourRecord.safeParse(holed(MAX_CONTOUR_HOLES + 1)).success, false)
231
+ })
232
+
233
+ /** sphere → shell → fillet: the chained record of binding-design-4b §5.2. */
234
+ const chainedRecord = {
235
+ kind: "sphere",
236
+ version: 1,
237
+ values: { centre: p(0, 23.6, 0), radius: 23.622 },
238
+ operations: [
239
+ {
240
+ op: "shell",
241
+ openFaces: [{ index: 3, kind: "plane", edgeCount: 2 }],
242
+ thickness: 1.181,
243
+ input: { faceCount: 4, edgeCount: 6 },
244
+ },
245
+ {
246
+ op: "fillet",
247
+ edges: [
248
+ { index: 2, faces: [0, 3], curve: "arc" },
249
+ { index: 5, faces: [1, 3], curve: "arc" },
250
+ ],
251
+ radius: 0.787,
252
+ input: { faceCount: 8, edgeCount: 14 },
253
+ },
254
+ ],
255
+ }
256
+
257
+ test("a chained operations record round-trips", () => {
258
+ const parsed = MassParametersRecord.safeParse(chainedRecord)
259
+ assert.equal(parsed.success, true, JSON.stringify(parsed.error?.issues))
260
+ assert.deepEqual(parsed.data, chainedRecord)
261
+
262
+ // `operations` is additive and optional — a 4A-era record still parses.
263
+ const { operations, ...base } = chainedRecord
264
+ assert.equal(MassParametersRecord.safeParse(base).success, true)
265
+
266
+ // Every op kind on its own.
267
+ const chamfer = {
268
+ op: "chamfer",
269
+ edges: [{ index: 0, faces: [0, 1], curve: "line" }],
270
+ distance: 0.1,
271
+ input: { faceCount: 6, edgeCount: 12 },
272
+ }
273
+ const offset = { op: "offset", distance: -0.4, input: { faceCount: 4, edgeCount: 6 } }
274
+ for (const op of [operations[0], operations[1], chamfer, offset]) {
275
+ assert.deepEqual(OperationRecord.parse(op), op)
276
+ }
277
+ })
278
+
279
+ test("operation records reject what cannot be replayed", () => {
280
+ const counts = { faceCount: 4, edgeCount: 6 }
281
+ // A selector list is never empty — an op that selected nothing is not a record.
282
+ assert.equal(OperationRecord.safeParse({ op: "fillet", edges: [], radius: 1, input: counts }).success, false)
283
+ // Fractional / negative indices cannot address a face or an edge.
284
+ assert.equal(
285
+ OperationRecord.safeParse({
286
+ op: "fillet",
287
+ edges: [{ index: 1.5, faces: [0, 1], curve: "arc" }],
288
+ radius: 1,
289
+ input: counts,
290
+ }).success,
291
+ false,
292
+ )
293
+ assert.equal(
294
+ OperationRecord.safeParse({
295
+ op: "fillet",
296
+ edges: [{ index: 0, faces: [0, -1], curve: "arc" }],
297
+ radius: 1,
298
+ input: counts,
299
+ }).success,
300
+ false,
301
+ )
302
+ // A face pair is exactly two faces.
303
+ assert.equal(
304
+ OperationRecord.safeParse({
305
+ op: "fillet",
306
+ edges: [{ index: 0, faces: [0, 1, 2], curve: "arc" }],
307
+ radius: 1,
308
+ input: counts,
309
+ }).success,
310
+ false,
311
+ )
312
+ // Unknown surface / curve kinds, unknown op, and a zero offset.
313
+ assert.equal(
314
+ OperationRecord.safeParse({
315
+ op: "shell",
316
+ openFaces: [{ index: 0, kind: "nurbs", edgeCount: 2 }],
317
+ thickness: 1,
318
+ input: counts,
319
+ }).success,
320
+ false,
321
+ )
322
+ assert.equal(OperationRecord.safeParse({ op: "draft", angle: 3, input: counts }).success, false)
323
+ assert.equal(OperationRecord.safeParse({ op: "offset", distance: 0, input: counts }).success, false)
324
+ // Non-positive scalars.
325
+ for (const radius of [0, -1, Number.NaN]) {
326
+ assert.equal(
327
+ OperationRecord.safeParse({
328
+ op: "fillet",
329
+ edges: [{ index: 0, faces: [0, 1], curve: "arc" }],
330
+ radius,
331
+ input: counts,
332
+ }).success,
333
+ false,
334
+ `radius ${radius} must be rejected`,
335
+ )
336
+ }
337
+ })
338
+
339
+ test("operation caps are contract, not truncation", () => {
340
+ const counts = { faceCount: 4, edgeCount: 6 }
341
+ const fillet = (n) => ({
342
+ op: "fillet",
343
+ edges: Array.from({ length: n }, (_, i) => ({ index: i, faces: [0, 1], curve: "line" })),
344
+ radius: 1,
345
+ input: counts,
346
+ })
347
+ assert.equal(OperationRecord.safeParse(fillet(MAX_OPERATION_EDGES)).success, true)
348
+ assert.equal(OperationRecord.safeParse(fillet(MAX_OPERATION_EDGES + 1)).success, false)
349
+
350
+ const shell = (n) => ({
351
+ op: "shell",
352
+ openFaces: Array.from({ length: n }, (_, i) => ({ index: i, kind: "plane", edgeCount: 4 })),
353
+ thickness: 1,
354
+ input: counts,
355
+ })
356
+ assert.equal(OperationRecord.safeParse(shell(MAX_OPERATION_FACES)).success, true)
357
+ assert.equal(OperationRecord.safeParse(shell(MAX_OPERATION_FACES + 1)).success, false)
358
+
359
+ const chain = (n) => ({ ...chainedRecord, operations: Array.from({ length: n }, () => fillet(1)) })
360
+ assert.equal(MassParametersRecord.safeParse(chain(MAX_OPERATIONS)).success, true)
361
+ assert.equal(MassParametersRecord.safeParse(chain(MAX_OPERATIONS + 1)).success, false)
362
+
363
+ // The creator args are NOT capped (see "wire schemas keep 0.9.6 acceptance").
364
+ const edges = (n) => Array.from({ length: n }, (_, i) => `edge_${i}`)
365
+ const faces = (n) => Array.from({ length: n }, (_, i) => `face_${i}`)
366
+ const filletArgs = (n) => ({ brep: "brep_1", edges: edges(n), radius: 1 })
367
+ assert.equal(PluginGeomCreateBrepFromFilletArgs.safeParse(filletArgs(MAX_OPERATION_EDGES)).success, true)
368
+ const shellArgs = (n) => ({ brep: "brep_1", openFaces: faces(n), thickness: 1 })
369
+ assert.equal(PluginGeomCreateBrepFromShellArgs.safeParse(shellArgs(MAX_OPERATION_FACES)).success, true)
370
+ })
371
+
372
+ test("wire schemas keep 0.9.6 acceptance: record caps never reach the call", () => {
373
+ // The five inputs the external review (2026-09-06, #5) ran against the
374
+ // published 0.9.6 schemas: all accepted there, so all must stay accepted
375
+ // (feedback-no-breaking-api-changes — a shipped member never narrows). The
376
+ // record caps above still hold; the host answers an over-cap call with a
377
+ // solid and no record, never with VALIDATION.
378
+ const pts = (n) => Array.from({ length: n }, (_, i) => p(i, 0, 0))
379
+ const ids = (prefix, n) => Array.from({ length: n }, (_, i) => `${prefix}_${i}`)
380
+ const accepted = [
381
+ [
382
+ "loft with 31 intermediates",
383
+ PluginGeomCreateBrepFromLoftArgs.safeParse({
384
+ bottomContour: "contour_1",
385
+ topContour: "contour_2",
386
+ intermediateContours: ids("contour", MAX_LOFT_SECTIONS - 1),
387
+ }),
388
+ ],
389
+ [
390
+ "sweep with 513 path points",
391
+ PluginGeomCreateBrepFromSweepArgs.safeParse({
392
+ profile: "contour_1",
393
+ path: pts(MAX_SWEEP_PATH_POINTS + 1),
394
+ }),
395
+ ],
396
+ [
397
+ "fillet with 65 edges",
398
+ PluginGeomCreateBrepFromFilletArgs.safeParse({
399
+ brep: "brep_1",
400
+ edges: ids("edge", MAX_OPERATION_EDGES + 1),
401
+ radius: 1,
402
+ }),
403
+ ],
404
+ [
405
+ "chamfer with 65 edges",
406
+ PluginGeomCreateBrepFromChamferArgs.safeParse({
407
+ brep: "brep_1",
408
+ edges: ids("edge", MAX_OPERATION_EDGES + 1),
409
+ distance: 1,
410
+ }),
411
+ ],
412
+ [
413
+ "shell with 33 open faces",
414
+ PluginGeomCreateBrepFromShellArgs.safeParse({
415
+ brep: "brep_1",
416
+ openFaces: ids("face", MAX_OPERATION_FACES + 1),
417
+ thickness: 1,
418
+ }),
419
+ ],
420
+ ]
421
+ for (const [what, result] of accepted) {
422
+ assert.equal(result.success, true, `${what} must stay schema-valid: ${JSON.stringify(result.error?.issues)}`)
423
+ }
424
+ })
425
+
426
+ test("MassParametersPatch takes both a partial and a full record", () => {
427
+ // Partial forms: a scalar, a point, a whole array, and the empty op list.
428
+ for (const patch of [
429
+ { radius: 31.496 },
430
+ { centre: p(1, 2, 3) },
431
+ { path: [p(0, 0, 0), p(0, 3, 0)] },
432
+ { sections: [squareContour, squareContour] },
433
+ { contour: squareContour },
434
+ { options: { seamAlignment: "authored" } },
435
+ { options: { startScale: 1, endScale: 2, transition: { bevel: 0.2 } } },
436
+ { operations: chainedRecord.operations },
437
+ { operations: [] },
438
+ ]) {
439
+ const parsed = MassParametersPatch.safeParse(patch)
440
+ assert.equal(parsed.success, true, `${JSON.stringify(patch)}: ${JSON.stringify(parsed.error?.issues)}`)
441
+ assert.deepEqual(parsed.data, patch)
442
+ }
443
+
444
+ // Full-record form: read → edit → send the whole thing back.
445
+ const echoed = MassParametersPatch.safeParse(chainedRecord)
446
+ assert.equal(echoed.success, true)
447
+ assert.deepEqual(echoed.data, chainedRecord)
448
+
449
+ // …including the record edited to drop every operation. `{ operations: [] }`
450
+ // is the documented drop idiom, so the whole-record route must take it too
451
+ // (the host normalises the empty list to an absent key before storing).
452
+ const dropped = { ...chainedRecord, operations: [] }
453
+ const droppedParse = MassParametersPatch.safeParse(dropped)
454
+ assert.equal(droppedParse.success, true, JSON.stringify(droppedParse.error?.issues))
455
+ assert.deepEqual(droppedParse.data, dropped)
456
+ assert.equal(MassParametersRecord.safeParse(dropped).success, true)
457
+
458
+ // Values outside the closed union are refused at the wire, not written on.
459
+ for (const patch of [
460
+ { radius: "8" },
461
+ { radius: Number.POSITIVE_INFINITY },
462
+ { centre: { x: 0, y: 0 } },
463
+ { operations: [{ op: "fillet", edges: [], radius: 1, input: { faceCount: 4, edgeCount: 6 } }] },
464
+ { nested: { deeply: { wrong: true } } },
465
+ ]) {
466
+ assert.equal(MassParametersPatch.safeParse(patch).success, false, JSON.stringify(patch))
467
+ }
468
+ })
469
+
470
+ test("the 4B call args parse", () => {
471
+ assert.equal(
472
+ PluginDesignUpdateParametersArgs.safeParse({ component: "component_1", patch: { radius: 8 } })
473
+ .success,
474
+ true,
475
+ )
476
+ assert.equal(
477
+ PluginDesignUpdateParametersArgs.safeParse({ component: "component_1", patch: { radius: null } })
478
+ .success,
479
+ false,
480
+ )
481
+
482
+ const poles = (n) => Array.from({ length: n }, (_, i) => `vec3_${i}`)
483
+ assert.equal(
484
+ PluginGeomCreateSplineFromControlPointsArgs.safeParse({ controlPoints: poles(4) }).success,
485
+ true,
486
+ )
487
+ assert.equal(
488
+ PluginGeomCreateSplineFromControlPointsArgs.safeParse({
489
+ controlPoints: poles(4),
490
+ options: { degree: 2 },
491
+ }).success,
492
+ true,
493
+ )
494
+ // degree is 2 or 3 — 1 is a polyline, 4+ is not offered.
495
+ for (const degree of [1, 4, 2.5]) {
496
+ assert.equal(
497
+ PluginGeomCreateSplineFromControlPointsArgs.safeParse({
498
+ controlPoints: poles(4),
499
+ options: { degree },
500
+ }).success,
501
+ false,
502
+ `degree ${degree} must be rejected`,
503
+ )
504
+ }
505
+ assert.equal(
506
+ PluginGeomCreateSplineFromControlPointsArgs.safeParse({ controlPoints: poles(2) }).success,
507
+ false,
508
+ )
509
+ })
510
+
511
+ test("CurveHandle accepts a spline handle", () => {
512
+ assert.equal(SplineHandle.parse("spline_1").id, "spline_1")
513
+ for (const id of ["line_1", "arc_1", "spline_1"]) {
514
+ assert.equal(CurveHandle.parse(id).id, id)
515
+ }
516
+ assert.equal(CurveHandle.safeParse("circle_1").success, false)
517
+ })
518
+
519
+ // ── Freeform surfaces, sweeps, patches and variable blends ──────────────────
520
+
521
+ const line = (a, b) => ({ type: "line", start: a, end: b })
522
+ const topology = { faceCount: 6, edgeCount: 12 }
523
+ const edgeSel = { index: 3, faces: [0, 1], curve: "spline" }
524
+
525
+ test("a sweep-along-a-curve record round-trips, and its path is curves", () => {
526
+ const record = {
527
+ kind: "sweepAlongCurve",
528
+ version: 1,
529
+ values: {
530
+ profile: squareContour,
531
+ path: [
532
+ line(p(0, 0, 0), p(0, 10, 0)),
533
+ {
534
+ type: "arc",
535
+ start: p(0, 10, 0),
536
+ end: p(5, 15, 0),
537
+ centre: p(5, 10, 0),
538
+ axis: p(0, 0, 1),
539
+ },
540
+ ],
541
+ options: { orientation: "upright" },
542
+ },
543
+ }
544
+ const parsed = MassParametersRecord.safeParse(record)
545
+ assert.equal(parsed.success, true, JSON.stringify(parsed.error?.issues))
546
+ assert.deepEqual(parsed.data, record)
547
+
548
+ // The options bag is the RECORD's: pure JSON, no rail handle.
549
+ assert.equal(SweepAlongCurveRecordOptions.safeParse({ orientation: "fixed" }).success, true)
550
+ assert.equal(SweepAlongCurveRecordOptions.safeParse({ orientation: "banking" }).success, false)
551
+ assert.equal(SweepAlongCurveRecordOptions.safeParse({ rail: "line_1" }).success, false)
552
+
553
+ // Caps and vocabulary are contract: 1..64 curves, no XYZ path.
554
+ const withPath = (path) => ({ ...record, values: { ...record.values, path } })
555
+ assert.equal(
556
+ MassParametersRecord.safeParse(
557
+ withPath(Array.from({ length: MAX_PATH_CURVES }, () => line(p(0, 0, 0), p(1, 0, 0)))),
558
+ ).success,
559
+ true,
560
+ )
561
+ assert.equal(
562
+ MassParametersRecord.safeParse(
563
+ withPath(Array.from({ length: MAX_PATH_CURVES + 1 }, () => line(p(0, 0, 0), p(1, 0, 0)))),
564
+ ).success,
565
+ false,
566
+ )
567
+ assert.equal(MassParametersRecord.safeParse(withPath([])).success, false)
568
+ assert.equal(MassParametersRecord.safeParse(withPath([p(0, 0, 0), p(1, 0, 0)])).success, false)
569
+ // The shipped polyline `sweep` kind is untouched — its path is still points.
570
+ assert.equal(
571
+ MassParametersRecord.safeParse({
572
+ kind: "sweep",
573
+ version: 1,
574
+ values: { profile: squareContour, path: [p(0, 0, 0), p(0, 3, 0)] },
575
+ }).success,
576
+ true,
577
+ )
578
+ })
579
+
580
+ test("an extrusion record carries twist, taper and levels", () => {
581
+ const straight = {
582
+ kind: "extrusion",
583
+ version: 1,
584
+ values: { contour: squareContour, direction: p(0, 1, 0), amount: 40 },
585
+ }
586
+ const twisted = {
587
+ ...straight,
588
+ values: { ...straight.values, twistInDegrees: 90, topScale: 0.6, levels: 6 },
589
+ }
590
+ const parsed = MassParametersRecord.safeParse(twisted)
591
+ assert.equal(parsed.success, true, JSON.stringify(parsed.error?.issues))
592
+ assert.deepEqual(parsed.data, twisted)
593
+ // Absent keys are still the shipped straight extrusion, byte for byte.
594
+ assert.deepEqual(MassParametersRecord.parse(straight), straight)
595
+
596
+ const values = (extra) => ({ ...straight, values: { ...straight.values, ...extra } })
597
+ assert.equal(MassParametersRecord.safeParse(values({ levels: MAX_TWIST_LEVELS })).success, true)
598
+ assert.equal(
599
+ MassParametersRecord.safeParse(values({ levels: MAX_TWIST_LEVELS + 1 })).success,
600
+ false,
601
+ )
602
+ assert.equal(MassParametersRecord.safeParse(values({ levels: 1 })).success, false)
603
+ assert.equal(MassParametersRecord.safeParse(values({ topScale: -1 })).success, false)
604
+ assert.equal(MassParametersRecord.safeParse(values({ twistInDegrees: "90" })).success, false)
605
+ })
606
+
607
+ test("a loft record chooses its smoothing", () => {
608
+ const record = {
609
+ kind: "loft",
610
+ version: 1,
611
+ values: { sections: [squareContour, squareContour], options: { smoothing: "smooth" } },
612
+ }
613
+ assert.deepEqual(MassParametersRecord.parse(record), record)
614
+ assert.equal(
615
+ MassParametersRecord.safeParse({
616
+ ...record,
617
+ values: { ...record.values, options: { smoothing: "curvy" } },
618
+ }).success,
619
+ false,
620
+ )
621
+ })
622
+
623
+ test("selectors carry an optional normalised position", () => {
624
+ const face = { index: 2, kind: "spline", edgeCount: 4, at: { x: 0.13, y: 1, z: 0.87 } }
625
+ assert.deepEqual(FaceSelector.parse(face), face)
626
+ const edge = { ...edgeSel, at: { x: 0.5, y: 0, z: 0.5 } }
627
+ assert.deepEqual(EdgeSelector.parse(edge), edge)
628
+ // A selector recorded before `at` existed still parses unchanged — the key
629
+ // only widens.
630
+ assert.deepEqual(EdgeSelector.parse(edgeSel), edgeSel)
631
+ assert.equal(FaceSelector.safeParse({ ...face, at: { x: 0, y: 0 } }).success, false)
632
+ assert.equal(EdgeSelector.safeParse({ ...edgeSel, at: [0, 0, 0] }).success, false)
633
+ })
634
+
635
+ test("a variable fillet operation names one edge and one law", () => {
636
+ const constant = { op: "fillet", edges: [edgeSel], radius: 0.5, input: topology }
637
+ const tapered = { ...constant, endRadius: 1.5 }
638
+ const sampled = {
639
+ ...constant,
640
+ radii: [
641
+ { position: 0, radius: 0.5 },
642
+ { position: 0.5, radius: 2 },
643
+ { position: 1, radius: 0.5 },
644
+ ],
645
+ }
646
+ assert.deepEqual(OperationRecord.parse(constant), constant)
647
+ assert.deepEqual(OperationRecord.parse(tapered), tapered)
648
+ assert.deepEqual(OperationRecord.parse(sampled), sampled)
649
+
650
+ // Both laws at once, more than one edge, a table that does not run forward,
651
+ // a single row, a position outside 0..1, and an over-long table are refused.
652
+ const twoEdges = [edgeSel, { ...edgeSel, index: 4 }]
653
+ for (const operation of [
654
+ { ...tapered, radii: sampled.radii },
655
+ { ...tapered, edges: twoEdges },
656
+ { ...sampled, edges: twoEdges },
657
+ {
658
+ ...constant,
659
+ radii: [
660
+ { position: 0, radius: 1 },
661
+ { position: 0, radius: 2 },
662
+ ],
663
+ },
664
+ { ...constant, radii: [{ position: 0, radius: 1 }] },
665
+ {
666
+ ...constant,
667
+ radii: [
668
+ { position: 0, radius: 1 },
669
+ { position: 1.5, radius: 2 },
670
+ ],
671
+ },
672
+ {
673
+ ...constant,
674
+ radii: Array.from({ length: MAX_FILLET_LAW_SAMPLES + 1 }, (_, i) => ({
675
+ position: i / MAX_FILLET_LAW_SAMPLES,
676
+ radius: 1,
677
+ })),
678
+ },
679
+ ]) {
680
+ assert.equal(OperationRecord.safeParse(operation).success, false, JSON.stringify(operation))
681
+ }
682
+ // A full table of the maximum length still parses.
683
+ assert.equal(
684
+ OperationRecord.safeParse({
685
+ ...constant,
686
+ radii: Array.from({ length: MAX_FILLET_LAW_SAMPLES }, (_, i) => ({
687
+ position: i / (MAX_FILLET_LAW_SAMPLES - 1),
688
+ radius: 1,
689
+ })),
690
+ }).success,
691
+ true,
692
+ )
693
+ })
694
+
695
+ test("the patch union widens to the new option bag and curve paths", () => {
696
+ assert.equal(ParameterPatchValue.safeParse({ orientation: "frenet" }).success, true)
697
+ assert.equal(ParameterPatchValue.safeParse([line(p(0, 0, 0), p(1, 0, 0))]).success, true)
698
+ assert.equal(ParameterPatchValue.safeParse({ smoothing: "smooth" }).success, true)
699
+ assert.equal(ParameterPatchValue.safeParse(90).success, true)
700
+ // A radius law is a key INSIDE an operation, never a top-level `values` key.
701
+ assert.equal(ParameterPatchValue.safeParse([{ position: 0, radius: 1 }]).success, false)
702
+
703
+ assert.equal(
704
+ MassParametersPatch.safeParse({
705
+ path: [line(p(0, 0, 0), p(0, 10, 0))],
706
+ options: { orientation: "fixed" },
707
+ }).success,
708
+ true,
709
+ )
710
+ assert.equal(MassParametersPatch.safeParse({ twistInDegrees: 120, topScale: 0.5 }).success, true)
711
+ assert.equal(
712
+ MassParametersPatch.safeParse({
713
+ operations: [{ op: "fillet", edges: [edgeSel], radius: 0.5, endRadius: 2, input: topology }],
714
+ }).success,
715
+ true,
716
+ )
717
+ assert.equal(MassParametersPatch.safeParse({ orientation: "sideways" }).success, false)
718
+ })
719
+
720
+ test("the freeform call args parse", () => {
721
+ // brepFromExtrusion keeps its three-positional form and gains a trailing bag.
722
+ const extrusion = { contour: "contour_1", direction: p(0, 1, 0), amount: 40 }
723
+ assert.equal(PluginGeomCreateBrepFromExtrusionArgs.safeParse(extrusion).success, true)
724
+ assert.equal(
725
+ PluginGeomCreateBrepFromExtrusionArgs.safeParse({
726
+ ...extrusion,
727
+ options: { twistInDegrees: 90, topScale: 0.6, levels: 6 },
728
+ }).success,
729
+ true,
730
+ )
731
+ assert.equal(
732
+ PluginGeomCreateBrepFromExtrusionArgs.safeParse({ ...extrusion, options: { levels: 1 } })
733
+ .success,
734
+ false,
735
+ )
736
+
737
+ // brepFromLoft gains smoothing beside the two shipped switches.
738
+ const loft = { bottomContour: "contour_1", topContour: "contour_2" }
739
+ assert.equal(PluginGeomCreateBrepFromLoftArgs.safeParse(loft).success, true)
740
+ assert.equal(
741
+ PluginGeomCreateBrepFromLoftArgs.safeParse({ ...loft, options: { smoothing: "smooth" } })
742
+ .success,
743
+ true,
744
+ )
745
+ assert.equal(
746
+ PluginGeomCreateBrepFromLoftArgs.safeParse({ ...loft, options: { smoothing: "silky" } })
747
+ .success,
748
+ false,
749
+ )
750
+
751
+ // brepFromFillet: the shipped three-argument call is untouched; a variable
752
+ // blend takes exactly one edge and exactly one law.
753
+ const fillet = { brep: "brep_1", edges: ["edge_1"], radius: 0.5 }
754
+ assert.equal(PluginGeomCreateBrepFromFilletArgs.safeParse(fillet).success, true)
755
+ assert.equal(
756
+ PluginGeomCreateBrepFromFilletArgs.safeParse({ ...fillet, edges: ["edge_1", "edge_2"] })
757
+ .success,
758
+ true,
759
+ )
760
+ assert.equal(
761
+ PluginGeomCreateBrepFromFilletArgs.safeParse({ ...fillet, options: { endRadius: 2 } }).success,
762
+ true,
763
+ )
764
+ assert.equal(
765
+ PluginGeomCreateBrepFromFilletArgs.safeParse({
766
+ ...fillet,
767
+ options: {
768
+ radii: [
769
+ { position: 0, radius: 0.5 },
770
+ { position: 1, radius: 2 },
771
+ ],
772
+ },
773
+ }).success,
774
+ true,
775
+ )
776
+ for (const bad of [
777
+ { ...fillet, edges: ["edge_1", "edge_2"], options: { endRadius: 2 } },
778
+ {
779
+ ...fillet,
780
+ options: {
781
+ endRadius: 2,
782
+ radii: [
783
+ { position: 0, radius: 1 },
784
+ { position: 1, radius: 2 },
785
+ ],
786
+ },
787
+ },
788
+ {
789
+ ...fillet,
790
+ options: {
791
+ radii: [
792
+ { position: 1, radius: 1 },
793
+ { position: 0, radius: 2 },
794
+ ],
795
+ },
796
+ },
797
+ { ...fillet, options: { radii: [{ position: 0, radius: 1 }] } },
798
+ ]) {
799
+ assert.equal(
800
+ PluginGeomCreateBrepFromFilletArgs.safeParse(bad).success,
801
+ false,
802
+ JSON.stringify(bad),
803
+ )
804
+ }
805
+
806
+ // brepFromPatch: curves, owned edges, and the tangent rule.
807
+ const boundary = ["line_1", "arc_1", { edge: "edge_1", brep: "brep_1" }]
808
+ assert.equal(
809
+ PluginGeomCreateBrepFromPatchArgs.safeParse({ boundary, thickness: 0.25 }).success,
810
+ true,
811
+ )
812
+ assert.equal(
813
+ PluginGeomCreateBrepFromPatchArgs.safeParse({
814
+ boundary,
815
+ thickness: 0.25,
816
+ options: { interiorPoints: [p(1, 1, 1)], side: "above" },
817
+ }).success,
818
+ true,
819
+ )
820
+ assert.equal(
821
+ PluginGeomCreateBrepFromPatchArgs.safeParse({
822
+ boundary: [
823
+ { edge: "edge_1", brep: "brep_1", face: "face_1" },
824
+ { edge: "edge_2", brep: "brep_2", face: "face_2" },
825
+ { edge: "edge_3", brep: "brep_1", face: "face_3" },
826
+ ],
827
+ thickness: 0.25,
828
+ options: { continuity: "tangent" },
829
+ }).success,
830
+ true,
831
+ )
832
+ for (const bad of [
833
+ // tangent with a curve item, and with an edge item that names no face
834
+ { boundary, thickness: 0.25, options: { continuity: "tangent" } },
835
+ {
836
+ boundary: [
837
+ { edge: "edge_1", brep: "brep_1" },
838
+ { edge: "edge_2", brep: "brep_1" },
839
+ { edge: "edge_3", brep: "brep_1" },
840
+ ],
841
+ thickness: 0.25,
842
+ options: { continuity: "tangent" },
843
+ },
844
+ { boundary: ["line_1", "line_2"], thickness: 0.25 },
845
+ { boundary, thickness: 0 },
846
+ {
847
+ boundary: Array.from({ length: MAX_PATCH_BOUNDARY_CURVES + 1 }, () => "line_1"),
848
+ thickness: 0.25,
849
+ },
850
+ {
851
+ boundary,
852
+ thickness: 0.25,
853
+ options: {
854
+ interiorPoints: Array.from({ length: MAX_PATCH_INTERIOR_POINTS + 1 }, () => p(0, 0, 0)),
855
+ },
856
+ },
857
+ ]) {
858
+ assert.equal(
859
+ PluginGeomCreateBrepFromPatchArgs.safeParse(bad).success,
860
+ false,
861
+ JSON.stringify(bad),
862
+ )
863
+ }
864
+
865
+ // brepFromSweepAlongCurve: 1..64 curve handles, and the creator's own rail.
866
+ const sweep = { profile: "contour_1", path: ["spline_1"] }
867
+ assert.equal(PluginGeomCreateBrepFromSweepAlongCurveArgs.safeParse(sweep).success, true)
868
+ assert.equal(
869
+ PluginGeomCreateBrepFromSweepAlongCurveArgs.safeParse({
870
+ ...sweep,
871
+ options: { orientation: "fixed", rail: "spline_2" },
872
+ }).success,
873
+ true,
874
+ )
875
+ for (const bad of [
876
+ { ...sweep, path: [] },
877
+ { ...sweep, path: Array.from({ length: MAX_PATH_CURVES + 1 }, () => "spline_1") },
878
+ { ...sweep, path: [p(0, 0, 0)] },
879
+ { ...sweep, options: { orientation: "banking" } },
880
+ ]) {
881
+ assert.equal(
882
+ PluginGeomCreateBrepFromSweepAlongCurveArgs.safeParse(bad).success,
883
+ false,
884
+ JSON.stringify(bad),
885
+ )
886
+ }
887
+
888
+ // The two owner-last members.
889
+ assert.equal(
890
+ PluginGeomCreateBrepsFromSplitByFaceArgs.safeParse({
891
+ brep: "brep_1",
892
+ toolFace: "face_1",
893
+ toolBrep: "brep_2",
894
+ }).success,
895
+ true,
896
+ )
897
+ assert.equal(
898
+ PluginGeomCreateBrepsFromSplitByFaceArgs.safeParse({
899
+ brep: "brep_1",
900
+ toolFace: "brep_2",
901
+ toolBrep: "brep_2",
902
+ }).success,
903
+ false,
904
+ )
905
+ assert.equal(
906
+ PluginGeomCreateCurvesFromProjectionArgs.safeParse({
907
+ curve: "spline_1",
908
+ face: "face_1",
909
+ faceBrep: "brep_1",
910
+ }).success,
911
+ true,
912
+ )
913
+ assert.equal(
914
+ PluginGeomCreateCurvesFromProjectionArgs.safeParse({
915
+ curve: "circle_1",
916
+ face: "face_1",
917
+ faceBrep: "brep_1",
918
+ }).success,
919
+ false,
920
+ )
921
+
922
+ // The two face reads that need the owning solid for their coordinates.
923
+ for (const args of [PluginGeomQueryFaceGetAreaArgs, PluginGeomQueryFaceGetCentroidArgs]) {
924
+ assert.equal(args.safeParse({ face: "face_1", brep: "brep_1" }).success, true)
925
+ assert.equal(args.safeParse({ face: "face_1", brep: "face_1" }).success, false)
926
+ }
927
+ })
928
+
929
+ test("both record modules stay inside the one-shot read budget", () => {
930
+ // The agent reads a one-shot module whole; 12,000 B is the ceiling asserted
931
+ // over ONE_SHOT_MODULES in the agent runtime.
932
+ for (const name of ["handles", "massParameters", "massParameters.ops"]) {
933
+ const bytes = readFileSync(new URL(`../dist/${name}.d.ts`, import.meta.url)).byteLength
934
+ assert.ok(bytes < 12_000, `dist/${name}.d.ts is ${bytes} B, over the 12,000 B one-shot ceiling`)
935
+ }
936
+ })