@faicad/faijs-fcstd 0.17.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 (87) hide show
  1. package/LICENSE +29 -0
  2. package/NOTICE +20 -0
  3. package/dist/attachment.d.ts +72 -0
  4. package/dist/attachment.d.ts.map +1 -0
  5. package/dist/attachment.js +93 -0
  6. package/dist/attachment.js.map +1 -0
  7. package/dist/bspline.d.ts +58 -0
  8. package/dist/bspline.d.ts.map +1 -0
  9. package/dist/bspline.js +93 -0
  10. package/dist/bspline.js.map +1 -0
  11. package/dist/build-fai-zip.d.ts +40 -0
  12. package/dist/build-fai-zip.d.ts.map +1 -0
  13. package/dist/build-fai-zip.js +111 -0
  14. package/dist/build-fai-zip.js.map +1 -0
  15. package/dist/cli.d.ts +3 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +37 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/codegen.d.ts +83 -0
  20. package/dist/codegen.d.ts.map +1 -0
  21. package/dist/codegen.js +618 -0
  22. package/dist/codegen.js.map +1 -0
  23. package/dist/container.d.ts +75 -0
  24. package/dist/container.d.ts.map +1 -0
  25. package/dist/container.js +36 -0
  26. package/dist/container.js.map +1 -0
  27. package/dist/contour.d.ts +43 -0
  28. package/dist/contour.d.ts.map +1 -0
  29. package/dist/contour.js +132 -0
  30. package/dist/contour.js.map +1 -0
  31. package/dist/convert.d.ts +69 -0
  32. package/dist/convert.d.ts.map +1 -0
  33. package/dist/convert.js +303 -0
  34. package/dist/convert.js.map +1 -0
  35. package/dist/document.d.ts +48 -0
  36. package/dist/document.d.ts.map +1 -0
  37. package/dist/document.js +140 -0
  38. package/dist/document.js.map +1 -0
  39. package/dist/expressions.d.ts +79 -0
  40. package/dist/expressions.d.ts.map +1 -0
  41. package/dist/expressions.js +162 -0
  42. package/dist/expressions.js.map +1 -0
  43. package/dist/external-geo.d.ts +49 -0
  44. package/dist/external-geo.d.ts.map +1 -0
  45. package/dist/external-geo.js +140 -0
  46. package/dist/external-geo.js.map +1 -0
  47. package/dist/feature-translate.d.ts +193 -0
  48. package/dist/feature-translate.d.ts.map +1 -0
  49. package/dist/feature-translate.js +1441 -0
  50. package/dist/feature-translate.js.map +1 -0
  51. package/dist/fillet-edges.d.ts +26 -0
  52. package/dist/fillet-edges.d.ts.map +1 -0
  53. package/dist/fillet-edges.js +36 -0
  54. package/dist/fillet-edges.js.map +1 -0
  55. package/dist/index.d.ts +23 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +19 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/placement.d.ts +93 -0
  60. package/dist/placement.d.ts.map +1 -0
  61. package/dist/placement.js +117 -0
  62. package/dist/placement.js.map +1 -0
  63. package/dist/planegcs-backend.d.ts +34 -0
  64. package/dist/planegcs-backend.d.ts.map +1 -0
  65. package/dist/planegcs-backend.js +665 -0
  66. package/dist/planegcs-backend.js.map +1 -0
  67. package/dist/sketch-parse.d.ts +199 -0
  68. package/dist/sketch-parse.d.ts.map +1 -0
  69. package/dist/sketch-parse.js +263 -0
  70. package/dist/sketch-parse.js.map +1 -0
  71. package/dist/sketch-solver.d.ts +63 -0
  72. package/dist/sketch-solver.d.ts.map +1 -0
  73. package/dist/sketch-solver.js +28 -0
  74. package/dist/sketch-solver.js.map +1 -0
  75. package/dist/sketch-verify.d.ts +53 -0
  76. package/dist/sketch-verify.d.ts.map +1 -0
  77. package/dist/sketch-verify.js +71 -0
  78. package/dist/sketch-verify.js.map +1 -0
  79. package/dist/structural-types.d.ts +96 -0
  80. package/dist/structural-types.d.ts.map +1 -0
  81. package/dist/structural-types.js +155 -0
  82. package/dist/structural-types.js.map +1 -0
  83. package/dist/unpack.d.ts +44 -0
  84. package/dist/unpack.d.ts.map +1 -0
  85. package/dist/unpack.js +64 -0
  86. package/dist/unpack.js.map +1 -0
  87. package/package.json +65 -0
@@ -0,0 +1,618 @@
1
+ import { translateObject, isJsExpr, jsExpr, BODY_CHAIN_BASE } from './feature-translate.js';
2
+ import { isIdentityPlacement, invertApplyPlacement } from './placement.js';
3
+ import { isNonModelingType } from './structural-types.js';
4
+ /** Extract link dependencies relevant for ordering. */
5
+ function depsOf(obj) {
6
+ const out = [];
7
+ const linkProps = ['Base', 'Tool', 'Profile', 'BaseFeature', 'Originals'];
8
+ for (const p of linkProps) {
9
+ const el = obj.properties.get(p)?.children[0];
10
+ const v = el?.attributes['value'];
11
+ if (v)
12
+ out.push(v);
13
+ }
14
+ // GOTCHA (ArchDetail corpus, 2026-09-21): App::PropertyLinkList properties.
15
+ // `Shapes` is the multi-input geometry list (Part::MultiFuse / MultiCommon);
16
+ // `Links` is Part::Compound's member list. Neither is a single-value link
17
+ // property, so missing it here left the Compound with NO dependency on its
18
+ // members — Kahn then placed it at its document position, and ArchDetail's
19
+ // compounds sit at doc index 10-14 while every member sits at 269+ (Draft
20
+ // emits the wire first, the compound last, but the file order is sorted by
21
+ // name). inputVar() found nothing yet → `compound-missing-members` for all
22
+ // five compounds, for a pure ordering reason.
23
+ const linkLists = ['Shapes', 'Links'];
24
+ for (const p of linkLists) {
25
+ const el = obj.properties.get(p)?.children[0];
26
+ if (!el)
27
+ continue;
28
+ for (const link of el.children) {
29
+ const v = link.attributes['value'];
30
+ if (v)
31
+ out.push(v);
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ /**
37
+ * GOTCHA (PadTest V6 failure): a `PartDesign::Body`'s feature list lives in
38
+ * DIFFERENT properties depending on the FreeCAD version —
39
+ * - modern (0.19+): `Model` (App::PropertyLinkList), ordered
40
+ * - legacy: `Group` (App::PropertyLinkList)
41
+ * The Tip is `Body.Tip`. Reading only `Group` on a modern document yields an
42
+ * empty list → no same-Body chaining → features degrade to a loose
43
+ * `cad.group` of overlapping siblings (PadTest rebuilt volume ≈ 5.2× the Tip's,
44
+ * bboxDiag ≈ 2×). Prefer `Model`, fall back to `Group`.
45
+ */
46
+ function bodyFeatureNames(obj) {
47
+ for (const prop of ['Model', 'Group']) {
48
+ const list = obj.properties.get(prop)?.children[0];
49
+ if (!list)
50
+ continue;
51
+ const names = [];
52
+ for (const link of list.children) {
53
+ const v = link.attributes['value'];
54
+ if (v)
55
+ names.push(v);
56
+ }
57
+ if (names.length > 0)
58
+ return names;
59
+ }
60
+ return [];
61
+ }
62
+ /**
63
+ * M5.1/M5.2 — translate every object in dependency order and lower to JS.
64
+ * `sketchVerdict` supplies the M3 outcome per sketch object name; sketches
65
+ * whose contour feeds a Pad/Pocket appear as inputs.
66
+ *
67
+ * @param doc parsed FCStd object graph to translate
68
+ * @param sketchVerdict per-sketch M3 verdict keyed by object name
69
+ * @param sketchContours solved contours per L0 sketch, keyed by object name
70
+ * @param baseName source base name used in generated file headers/labels
71
+ * @param placements per-object Placement used to re-orient placed geometry; missing → identity
72
+ * @param shapeCarriers objects whose Shape is a ZIP .brp member (pure-Shape carriers → shape-asset)
73
+ * @param brokenShapeAssets objects whose Shape `file` attribute points at a missing/empty member (explicit gap)
74
+ * @param filletEdgesData parsed PropertyFilletEdges binaries keyed by object name (Part::Chamfer/Fillet)
75
+ * @returns the lowered call plan, per-object dispositions and generated code
76
+ */
77
+ export function generateModel(doc, sketchVerdict, sketchContours, baseName,
78
+ /** M8.3: per-object Placement (sketches + features); missing → identity */
79
+ placements,
80
+ /** H7: objects whose Shape is a ZIP .brp member (pure-Shape carriers → shape-asset). */
81
+ shapeCarriers,
82
+ /** E4: objects whose Shape `file` attribute points at a missing/empty member (explicit gap). */
83
+ brokenShapeAssets,
84
+ /** P8: parsed PropertyFilletEdges binaries keyed by object name (Part::Chamfer/Fillet). */
85
+ filletEdgesData) {
86
+ const byName = new Map(doc.objects.map((o) => [o.name, o]));
87
+ // GOTCHA (test_geomop corpus, 2026-09-20): a dependency on a Body
88
+ // CONTAINER (Part::Cut with Tool→Body) really waits for the Body's
89
+ // features to have built the chain — expand the dep to the member
90
+ // features so Kahn orders the consumer AFTER the chain exists (and
91
+ // chainVar.get(Body) resolves). Treating the container itself as
92
+ // satisfiable instead let the Cut run before the chain was built.
93
+ const nodes = new Map();
94
+ for (const obj of doc.objects) {
95
+ const deps = new Set();
96
+ for (const d of depsOf(obj)) {
97
+ const target = byName.get(d);
98
+ if (target?.type === 'PartDesign::Body') {
99
+ for (const m of bodyFeatureNames(target))
100
+ if (byName.has(m))
101
+ deps.add(m);
102
+ }
103
+ else {
104
+ deps.add(d);
105
+ }
106
+ }
107
+ nodes.set(obj.name, { name: obj.name, obj, deps });
108
+ }
109
+ // Kahn topological sort; objects with unbuilt deps fall back to insertion
110
+ // order iteration until progress stalls (remaining are baked with reason).
111
+ // M10.1: iteration order is Body.Group sequence FIRST (features of a Body
112
+ // process in Group order so the D-C chain follows PartDesign semantics),
113
+ // then leftover objects in document order.
114
+ const groupSeq = [];
115
+ for (const obj of doc.objects) {
116
+ if (obj.type !== 'PartDesign::Body')
117
+ continue;
118
+ for (const m of bodyFeatureNames(obj)) {
119
+ if (byName.has(m) && !groupSeq.includes(m))
120
+ groupSeq.push(m);
121
+ }
122
+ }
123
+ const iterationOrder = [
124
+ ...groupSeq,
125
+ ...doc.objects.map((o) => o.name).filter((n) => !groupSeq.includes(n)),
126
+ ];
127
+ const order = [];
128
+ const built = new Set();
129
+ let progress = true;
130
+ while (progress) {
131
+ progress = false;
132
+ for (const name of iterationOrder) {
133
+ const node = nodes.get(name);
134
+ if (built.has(node.name))
135
+ continue;
136
+ if ([...node.deps].every((d) => built.has(d) || !byName.has(d) || byName.get(d).type === 'PartDesign::Body')) {
137
+ built.add(node.name);
138
+ order.push(node.name);
139
+ progress = true;
140
+ }
141
+ }
142
+ }
143
+ const variables = new Map();
144
+ const results = [];
145
+ const calls = [];
146
+ let partCounter = 0;
147
+ const newVar = () => `part${partCounter++}`;
148
+ // D-C (M9.4): same-Body features fuse cumulatively in Body.Group order —
149
+ // the first translated feature is the base, every later additive feature
150
+ // (Pad/…) unions with the chain, every subtractive one (Pocket/Cut)
151
+ // subtracts. cad.group is NOT used inside a Body (that is assembly
152
+ // semantics); cross-Body grouping stays in lower()'s root handling (M10).
153
+ const memberToBody = new Map();
154
+ for (const obj of doc.objects) {
155
+ if (obj.type !== 'PartDesign::Body')
156
+ continue;
157
+ for (const m of bodyFeatureNames(obj)) {
158
+ if (!memberToBody.has(m))
159
+ memberToBody.set(m, obj.name);
160
+ }
161
+ }
162
+ const chainVar = new Map(); // body name → accumulated var
163
+ for (const name of order) {
164
+ const node = nodes.get(name);
165
+ const obj = node.obj;
166
+ if (obj.type === 'Sketcher::SketchObject') {
167
+ const verdict = sketchVerdict.get(name);
168
+ const contours = sketchContours.get(name);
169
+ const usable = !!verdict &&
170
+ verdict.level !== 'L2' &&
171
+ (verdict.loopCount ?? 0) > 0 &&
172
+ !!contours &&
173
+ contours.length > 0;
174
+ if (usable) {
175
+ // M6 wiring: emit a real `cad.sketch({contours})` creator so the
176
+ // solved contour becomes a face variable the Pad/Pocket/Extrusion/
177
+ // Revolution features below can consume.
178
+ const v = newVar();
179
+ variables.set(name, v);
180
+ const sketchCall = {
181
+ out: v, op: 'cad.sketch', source: name, inputs: [], params: { contours },
182
+ };
183
+ calls.push(sketchCall);
184
+ results.push({
185
+ name, type: obj.type, variable: v, calls: [sketchCall], disposition: 'translated',
186
+ sketch: verdict,
187
+ });
188
+ }
189
+ else {
190
+ // P3-4 (2026-09-24, Slab adjustable scaffolder): a sketch may solve to
191
+ // L0 yet produce zero closed loops (dangling segments) — `loopCount`
192
+ // 0 then failed `usable` and the fallback labeled it `sketch-not-
193
+ // solved`, which lies about the cause. Give the L0-but-no-loop case
194
+ // its own explicit reason; keep the old fallbacks for genuinely
195
+ // missing verdicts/contours.
196
+ const reason = verdict?.reason
197
+ ?? (verdict?.level === 'L0' && contours && contours.length === 0
198
+ ? 'sketch-solved-no-closed-loop'
199
+ : contours ? 'sketch-not-solved' : 'sketch-no-contours');
200
+ results.push({
201
+ name, type: obj.type, calls: [], disposition: 'baked',
202
+ reason, sketch: verdict,
203
+ });
204
+ }
205
+ continue;
206
+ }
207
+ // Non-modeling objects are preserved-only and MUST be short-circuited
208
+ // BEFORE translation. GOTCHA (PadTest, 2026-09-21): this used to list only
209
+ // `App::Origin/Plane/Line`, so a Body's `PartDesign::Plane` went through
210
+ // the translator, matched the shape-asset rule (datum planes store a
211
+ // `Shape` .brp — the plane face) and was folded into the Body's chain:
212
+ // `cad.union(pad, datumPlane)`. That both corrupts the solid and fails at
213
+ // run time (`cad.load` requires a solid; a plane face has none). The
214
+ // predicate is shared with the C4 audit (`structural-types.ts`) so codegen
215
+ // and the disposition ledger can never disagree about what is non-modeling.
216
+ if (isNonModelingType(obj.type)) {
217
+ results.push({ name, type: obj.type, calls: [], disposition: 'preserved-only', reason: 'structural' });
218
+ continue;
219
+ }
220
+ // Sketches are now real face variables (see the Sketcher::SketchObject
221
+ // branch above), so every dependency that resolves to one flows through.
222
+ const verdict = translateObject(obj, (dep) => {
223
+ // GOTCHA (test_geomop corpus, 2026-09-20): a dependency on a Body
224
+ // CONTAINER (Part::Cut with Tool→Body) resolves against the Body's
225
+ // accumulated chain head, not `variables` — the container name is
226
+ // never registered there (its result lives in chainVar).
227
+ return variables.get(dep) ?? chainVar.get(dep);
228
+ }, doc.objects, shapeCarriers, brokenShapeAssets, filletEdgesData);
229
+ node.verdict = verdict;
230
+ if (verdict.kind === 'translated') {
231
+ // rename output vars to partN sequence
232
+ for (const call of verdict.calls) {
233
+ const v = newVar();
234
+ variables.set(call.out, v);
235
+ // remap inputs that were intermediate (Pocket_cut) or named outputs
236
+ call.inputs = call.inputs.map((i) => variables.get(i) ?? i);
237
+ // GOTCHA (ArchDetail s444, 2026-09-21): `cad.compound` passes its
238
+ // members as LEXICAL VARIABLE NAMES inside `params.members` — they
239
+ // must be remapped like `inputs`, otherwise renderArgs emits them as
240
+ // string literals and the op receives strings (no BREP handle) and
241
+ // throws "compound members are not all on the BREP chain".
242
+ const members = call.params?.members;
243
+ if (Array.isArray(members)) {
244
+ call.params.members = members.map((m) => typeof m === 'string' ? (variables.get(m) ?? m) : m);
245
+ }
246
+ call.out = v;
247
+ calls.push(call);
248
+ }
249
+ // M8.3: features build in sketch-local coordinates (cad.sketch lays the
250
+ // face on local XY; extrude runs along local +Z). Re-orient the final
251
+ // solid by the PROFILE SKETCH's Placement: rotate_euler then translate,
252
+ // so the result lands where FreeCAD puts it. Identity placements emit
253
+ // nothing.
254
+ // GOTCHA (PadTest V6, relErr 3.20%): a PartDesign feature's geometry is
255
+ // built in the SKETCH frame, but FreeCAD may store a DIFFERENT Placement
256
+ // on the feature object itself (Pad001: sketch P=(10,0,0)
257
+ // Q=(0,.7071,0,.7071) vs feature P=0 Q=(0,.7071,.7071,0)) — re-orienting
258
+ // by the feature's own frame drops the sketch origin offset and rotates
259
+ // the extrude axis into the wrong direction (−3.20% volume, centroid
260
+ // off 1.67). The sketch's Placement is the authoritative frame for the
261
+ // built geometry; fall back to the feature's own for sketchless features.
262
+ const lastVar = verdict.calls.at(-1)?.out;
263
+ const sketchLink = obj.properties.get('Sketch')?.children[0]?.attributes['value'];
264
+ const pl = (sketchLink ? placements?.get(sketchLink) : undefined) ?? placements?.get(name);
265
+ // GOTCHA (H13, TO92, 2026-09-23): a shape-asset object's frozen `.brp`
266
+ // member is saved by FreeCAD WITH its Placement already applied (the
267
+ // asset's bbox starts at the placed z). Re-emitting `cad.place` with the
268
+ // object's stored Placement applies the SAME transform twice — volume
269
+ // and solids stay correct, only a bbox/com parity check exposes it.
270
+ // The import already carries the placement; never place it again.
271
+ const isShapeAsset = verdict.reason === 'shape-asset';
272
+ if (lastVar && pl && !isIdentityPlacement(pl) && !isShapeAsset) {
273
+ // 单个刚性放置:旋转(四元数,绕局部原点)+ 平移 = FreeCAD Placement(P,Q)。
274
+ // 直接发 cad.place,避免 euler 往返损失精度(方案 §4.7:两语句合一)。
275
+ const cur = lastVar;
276
+ const rv = newVar();
277
+ variables.set(cur, rv); // map old name → placed var for consumers
278
+ calls.push({
279
+ out: rv, op: 'cad.place', source: name, inputs: [cur],
280
+ params: { rotation: [...pl.q], position: [...pl.p] },
281
+ });
282
+ // the object's variable is now the fully placed result
283
+ variables.set(name, rv);
284
+ }
285
+ // M9.4 (D-C): fold the feature into its Body's chain — AFTER the placement
286
+ // step so the chain accumulates the PLACED feature shape. Pocket/Cut
287
+ // subtract from the chain; everything else unions onto it. The first
288
+ // feature in the Body's feature list (Model/Group) order becomes the
289
+ // chain base — no cad.group inside a Body; consumers (M10) read chainVar.
290
+ // GOTCHA (PadTest V6): folding the UNPLACED feature var (the old order)
291
+ // left the chain head in sketch-local space while each feature's Placement
292
+ // was applied to a separate, unused variable → the exported body spanned
293
+ // both the local and the placed copies (~2× bboxDiag, ~5× volume).
294
+ const body = memberToBody.get(name);
295
+ const isSubtractive = obj.type === 'PartDesign::Pocket' || obj.type === 'Part::Cut';
296
+ if (body) {
297
+ const prev = chainVar.get(body);
298
+ // UpToLast/UpToFirst: FreeCAD's "up to" support is the Body's ACCUMULATED
299
+ // shape, NOT only the immediate BaseFeature link — retarget the kernel
300
+ // ref at the chain head (prev). GOTCHA (PadTest V6): truncating against
301
+ // the immediate BaseFeature (a small Pad001 disc) left an 8.06% volume
302
+ // deficit vs the Tip; against the accumulated chain it drops to 3.20%
303
+ // (bbox already exact at delta 0).
304
+ if (prev) {
305
+ for (const c of verdict.calls) {
306
+ // H7 (hole_puzzle corpus): the Pocket had NO BaseFeature property —
307
+ // the translator emitted BODY_CHAIN_BASE as the subtract's base
308
+ // input; retarget it at the chain head here (after the earlier
309
+ // inputs remap, which leaves the marker untouched since it is not
310
+ // a variable name).
311
+ if (c.inputs) {
312
+ c.inputs = c.inputs.map((i) => (i === BODY_CHAIN_BASE ? prev : i));
313
+ }
314
+ if (c.op === 'cad.extrude' && c.params.baseFeature !== undefined) {
315
+ // GOTCHA (PadTest V6 residual): the up-to extrude builds its
316
+ // prism in the SKETCH-LOCAL frame, but the chain head (prev)
317
+ // lives in the PLACED (global) frame. FreeCAD's boolean runs in
318
+ // one frame — so transform prev back into the sketch frame
319
+ // first: local = R^-1(global - p). Feeding the placed chain var
320
+ // directly silently truncates at the wrong face (Pad002 truth
321
+ // AddShape 48199 vs rebuilt deficit ~9108 mm^3 → 3.2% total).
322
+ const needsInverse = pl !== undefined && !isIdentityPlacement(pl);
323
+ if (needsInverse && pl) {
324
+ // 逆向刚性放置(单点发射,方案 §4.7):rotation 取共轭四元数、
325
+ // position 取 invertApplyPlacement(pl, 0) = -(invQ·p),合成为
326
+ // 一个 cad.place,避免 euler 往返。语义 = invertApplyPlacement(pl, prev)。
327
+ const invQ = [
328
+ -pl.q[0], -pl.q[1], -pl.q[2], pl.q[3],
329
+ ];
330
+ const invPos = invertApplyPlacement(pl, [0, 0, 0]);
331
+ const rv = newVar();
332
+ const rvCall = {
333
+ out: rv, op: 'cad.place', source: name,
334
+ inputs: [prev],
335
+ params: { rotation: invQ, position: invPos },
336
+ };
337
+ // insert BEFORE the extrude call: faijs is a statement
338
+ // language — `baseFeature: partN` referencing a later
339
+ // statement is E_REFERENCE (parser rejects forward refs).
340
+ const at = calls.indexOf(c);
341
+ calls.splice(at < 0 ? calls.length : at, 0, rvCall);
342
+ c.params.baseFeature = jsExpr(rv);
343
+ }
344
+ else {
345
+ c.params.baseFeature = jsExpr(prev);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ const featureVar = variables.get(name) ?? verdict.calls.at(-1).out;
351
+ if (!prev) {
352
+ chainVar.set(body, featureVar); // base feature
353
+ }
354
+ else if (isSubtractive && verdict.calls.at(-1).op === 'cad.subtract' && verdict.calls.at(-1).inputs.includes(prev)) {
355
+ // Pocket already subtracted from the chain var itself (BaseFeature
356
+ // resolved to the chain) — its output IS the new chain head; no
357
+ // extra subtract (would cut twice).
358
+ chainVar.set(body, featureVar);
359
+ }
360
+ else if (isSubtractive) {
361
+ const nv = newVar();
362
+ calls.push({ out: nv, op: 'cad.subtract', source: name, inputs: [prev, featureVar], params: {} });
363
+ chainVar.set(body, nv);
364
+ }
365
+ else {
366
+ const nv = newVar();
367
+ calls.push({ out: nv, op: 'cad.union', source: name, inputs: [prev, featureVar], params: {} });
368
+ chainVar.set(body, nv);
369
+ }
370
+ }
371
+ // H7 guard (motor_mount_inch corpus): a feature outside a Body (or with
372
+ // no chain head yet) never gets its BODY_CHAIN_BASE retargeted — the
373
+ // marker would leak into the generated JS as an illegal identifier
374
+ // (parser kills the whole file). Downgrade to an explicit gap instead.
375
+ if (verdict.calls.some((c) => c.inputs?.includes(BODY_CHAIN_BASE))) {
376
+ results.push({ name, type: obj.type, calls: [], disposition: 'baked', reason: 'pocket-missing-dependency' });
377
+ continue;
378
+ }
379
+ results.push({ name, type: obj.type, variable: verdict.calls.at(-1)?.out, calls: verdict.calls, disposition: 'translated', reason: verdict.reason });
380
+ }
381
+ else if (verdict.kind === 'baked') {
382
+ results.push({ name, type: obj.type, calls: [], disposition: 'baked', reason: verdict.reason });
383
+ }
384
+ else {
385
+ results.push({ name, type: obj.type, calls: [], disposition: 'preserved-only', reason: verdict.reason });
386
+ }
387
+ }
388
+ // also mark unreachable (cycle) objects as baked
389
+ for (const obj of doc.objects) {
390
+ if (!results.some((r) => r.name === obj.name)) {
391
+ results.push({ name: obj.name, type: obj.type, calls: [], disposition: 'baked', reason: 'dependency-cycle' });
392
+ }
393
+ }
394
+ // M10.3/M10.5 — split by Body: calls whose source object belongs to a Body
395
+ // (including the chain union/subtract calls emitted for it) go to
396
+ // model/<BodyName>.fai.js; everything else (loose Part features) stays in
397
+ // main.fai.js. When no Body has geometry, everything lands in main (the
398
+ // single-file shape). Aggregate entry compounds per-file roots via cad.compound.
399
+ const callBody = new Map();
400
+ for (const r of results) {
401
+ const b = memberToBody.get(r.name);
402
+ if (b)
403
+ for (const c of r.calls)
404
+ callBody.set(c.out, b);
405
+ }
406
+ const bodiesWithGeo = new Set();
407
+ for (const b of new Set(memberToBody.values())) {
408
+ if (chainVar.has(b))
409
+ bodiesWithGeo.add(b);
410
+ }
411
+ const files = [];
412
+ let code;
413
+ let rootVar;
414
+ if (bodiesWithGeo.size > 0) {
415
+ const mainCalls = [];
416
+ const perBody = new Map();
417
+ // M10c partition in DEPENDENCY ORDER: `calls` is already topologically
418
+ // sorted, so ONE forward pass assigns each call — to its own source's Body
419
+ // when known (`callBody`), else to the Body its first body-owned input went
420
+ // to. GOTCHA (PadTest V6): the previous implementation re-assigned such
421
+ // calls by APPENDING them to the end of the Body file, which broke
422
+ // dependency order — a Body file ended up referencing a variable declared
423
+ // further down (`let` TDZ ReferenceError at run time: `part8` used `part6`).
424
+ // Filtering `calls` in place preserves the topological order.
425
+ const assigned = new Map(); // call.out → Body file name
426
+ // chain-head var → terminal alias (and owning Body) — needed DURING
427
+ // routing: a foreign chain head is never in `assigned` (it is a Body's
428
+ // accumulated var, not a call output), so input-body checks must consult
429
+ // this map to detect cross-Body references.
430
+ const headToTerminal = new Map();
431
+ const headToBody = new Map();
432
+ for (const b of bodiesWithGeo) {
433
+ headToTerminal.set(chainVar.get(b), `${b}_out`);
434
+ headToBody.set(chainVar.get(b), b);
435
+ }
436
+ const mainPending = [];
437
+ const mainOuts = new Set();
438
+ for (const c of calls) {
439
+ const own = callBody.get(c.out);
440
+ const inputBodies = c.inputs.map((inp) => assigned.get(inp) ?? headToBody.get(inp)).filter((b) => b !== undefined);
441
+ const viaInput = inputBodies[0];
442
+ const b = own !== undefined && bodiesWithGeo.has(own) ? own : viaInput;
443
+ // GOTCHA (test_geomop corpus, 2026-09-20): a call whose own Body
444
+ // differs from an input's Body (Part::Cut with Base in another Body's
445
+ // chain) must NOT be emitted into its own Body file — the input
446
+ // variable lives in ANOTHER module and is not declared there
447
+ // (SEC_FREE_IDENT at check time). Same for inputs that will live in
448
+ // MAIN (loose Part boxes): routing is a forward pass over the
449
+ // topologically sorted calls, so main outputs are already known.
450
+ // NOTE: Body-chain fold calls (union/subtract emitted during folding)
451
+ // are NOT in `results`, so `own` is undefined for them — the main-input
452
+ // check must not depend on `own`.
453
+ const mixed = inputBodies.some((ib) => ib !== b) || (own !== undefined && viaInput !== undefined && viaInput !== own);
454
+ const touchesMain = c.inputs.some((i) => mainOuts.has(i));
455
+ if (b !== undefined && bodiesWithGeo.has(b) && !mixed && !touchesMain && (own === undefined || own === b)) {
456
+ if (!perBody.has(b))
457
+ perBody.set(b, []);
458
+ perBody.get(b).push(c);
459
+ assigned.set(c.out, b);
460
+ }
461
+ else {
462
+ mainPending.push(c);
463
+ mainOuts.add(c.out);
464
+ }
465
+ }
466
+ for (const c of mainPending) {
467
+ mainCalls.push({ ...c, inputs: c.inputs.map((i) => headToTerminal.get(i) ?? i) });
468
+ }
469
+ for (const b of bodiesWithGeo) {
470
+ const bodyCalls = perBody.get(b) ?? [];
471
+ // terminal: alias the chain head to <Body>_out so the aggregate entry
472
+ // has a stable, name-independent reference to each Body's result.
473
+ const head = chainVar.get(b);
474
+ const withTerminal = [
475
+ ...bodyCalls,
476
+ { out: `${b}_out`, op: 'identity', source: b, inputs: [head], params: {} },
477
+ ];
478
+ files.push({ path: `model/${b}.fai.js`, code: lowerBody(withTerminal, `${baseName}/${b}`, b), body: b });
479
+ }
480
+ // main.fai.js: aggregate the per-Body terminals via cad.compound. M10c:
481
+ // cross-file references close through the standard relative-import
482
+ // contract (module-registry D6) — each Body module's terminal alias
483
+ // `<Body>_out` is its live shape, so a named import binds it.
484
+ const lines = [];
485
+ lines.push(`// Generated by faijs FCStd port — ${baseName} (aggregate entry)`);
486
+ lines.push(`// Units: mm (faijs contract; FCStd internal units are mm)`);
487
+ const members = [...bodiesWithGeo].map((b) => `${b}_out`);
488
+ // Always import the per-Body terminals: the aggregate entry references
489
+ // `<Body>_out` in BOTH the cad.compound (multi-Body) and the single-Body alias
490
+ // path, so a missing import is a SEC_FREE_IDENT parse error (GOTCHA: the
491
+ // single-Body branch previously emitted `let part_out = <Body>_out;` with
492
+ // no import). `mainCalls` may still be empty here.
493
+ if (members.length >= 1) {
494
+ for (const b of bodiesWithGeo) {
495
+ lines.push(`import { ${b}_out } from './${b}.fai.js'; // module ${b}`);
496
+ }
497
+ }
498
+ for (const c of mainCalls) {
499
+ const id = `s${lines.length - 2}`;
500
+ lines.push(`let ${c.out} = ${c.op}(${renderArgs(c)}); // ${id} ${c.source}`);
501
+ }
502
+ if (members.length > 1) {
503
+ rootVar = 'part_out';
504
+ lines.push(`let part_out = cad.compound({ members: [${members.join(', ')}] });`);
505
+ }
506
+ else if (members.length === 1) {
507
+ // single Body: the imported terminal IS the result — alias keeps a
508
+ // stable root name for executors
509
+ rootVar = members[0];
510
+ lines.push(`let part_out = ${rootVar}; // single-Body aggregate`);
511
+ rootVar = 'part_out';
512
+ }
513
+ code = lines.join('\n') + '\n';
514
+ }
515
+ else {
516
+ code = lower(calls, baseName);
517
+ }
518
+ return { calls, objects: results, code, files, rootVar };
519
+ }
520
+ /** M10.3 — lower one Body's calls; the last entry is the terminal alias
521
+ * `<Body>_out` (plain JS assignment — faijs has no identity op). */
522
+ function lowerBody(calls, label, body) {
523
+ const lines = [];
524
+ lines.push(`// Generated by faijs FCStd port — ${label}`);
525
+ lines.push(`// Units: mm (faijs contract; FCStd internal units are mm)`);
526
+ for (const call of calls) {
527
+ if (call.op === 'identity') {
528
+ // terminal alias: `let <Body>_out = <chain head>;`
529
+ lines.push(`let ${call.out} = ${call.inputs[0]}; // s? ${body} terminal`);
530
+ continue;
531
+ }
532
+ const id = `s${lines.length - 2}`;
533
+ lines.push(`let ${call.out} = ${call.op}(${renderArgs(call)}); // ${id} ${call.source}`);
534
+ }
535
+ return lines.join('\n') + '\n';
536
+ }
537
+ /** M5.2 — lower the call plan to .fai.js source. faijs syntax: top-level
538
+ * statement flow with `let partN = cad.x(...)`; no wrapper function. */
539
+ function lower(calls, baseName) {
540
+ const lines = [];
541
+ lines.push(`// Generated by faijs FCStd port — ${baseName}`);
542
+ lines.push(`// Units: mm (faijs contract; FCStd internal units are mm)`);
543
+ for (const call of calls) {
544
+ const id = `s${lines.length - 2}`; // statement id sN
545
+ const args = renderArgs(call);
546
+ lines.push(`let ${call.out} = ${call.op}(${args}); // ${id} ${call.source}`);
547
+ }
548
+ // final shape: union of root calls that nobody consumes; faijs scripts
549
+ // end with the output-producing statement (no return, per fixtures).
550
+ const consumed = new Set(calls.flatMap((c) => c.inputs));
551
+ const roots = calls.filter((c) => !consumed.has(c.out)).map((c) => c.out);
552
+ if (roots.length > 1) {
553
+ lines.push(`let part_out = cad.compound({ members: [${roots.join(', ')}] });`);
554
+ }
555
+ else if (roots.length === 0) {
556
+ lines.push(`// no translated geometry (all baked)`);
557
+ }
558
+ return lines.join('\n') + '\n';
559
+ }
560
+ /**
561
+ * M5.2 — render one IR value as JS. Plain values JSON-encode (byte-identical to
562
+ * the pre-JsExpr output); a `JsExpr` marker renders verbatim, and a container
563
+ * holding one renders element-wise so the expression survives into the source.
564
+ */
565
+ function renderValue(v) {
566
+ if (isJsExpr(v))
567
+ return v.__jsExpr;
568
+ if (Array.isArray(v)) {
569
+ if (v.some(isJsExpr))
570
+ return `[${v.map(renderValue).join(', ')}]`;
571
+ return JSON.stringify(v);
572
+ }
573
+ if (v && typeof v === 'object') {
574
+ const entries = Object.entries(v);
575
+ if (entries.some(([, val]) => containsJsExpr(val))) {
576
+ return `{ ${entries.map(([k, val]) => `${k}: ${renderValue(val)}`).join(', ')} }`;
577
+ }
578
+ return JSON.stringify(v);
579
+ }
580
+ return JSON.stringify(v);
581
+ }
582
+ /** True when a value is or (recursively) contains a `JsExpr` marker. */
583
+ function containsJsExpr(v) {
584
+ if (isJsExpr(v))
585
+ return true;
586
+ if (Array.isArray(v))
587
+ return v.some(containsJsExpr);
588
+ if (v && typeof v === 'object')
589
+ return Object.values(v).some(containsJsExpr);
590
+ return false;
591
+ }
592
+ function renderArgs(call) {
593
+ const positional = [
594
+ ...(call.noPositionalArgs ? [] : call.inputs.map((i) => i)),
595
+ ...(call.literals ?? []).map((l) => renderValue(l)),
596
+ ].filter((s) => s.length > 0); // M7.1: drop empty entries so we never emit `(, `
597
+ const named = [];
598
+ for (const [k, v] of Object.entries(call.params ?? {})) {
599
+ if (v === undefined)
600
+ continue;
601
+ // GOTCHA (ArchDetail s444, 2026-09-21): `cad.compound`'s `members` are
602
+ // LEXICAL VARIABLE NAMES, not data strings — render them bare, like
603
+ // positional inputs. renderValue would quote them and the op would
604
+ // receive strings with no BREP handle
605
+ // ("compound members are not all on the BREP chain").
606
+ if (k === 'members' && Array.isArray(v)) {
607
+ named.push(`members: [${v.join(', ')}]`);
608
+ continue;
609
+ }
610
+ named.push(`${k}: ${renderValue(v)}`);
611
+ }
612
+ const namedBlock = named.length ? `{ ${named.join(', ')} }` : '';
613
+ // M7.1: no leading comma when there are no positional args — a call with only
614
+ // named params must render as `cad.sketch({ ... })`, never `cad.sketch(, {...})`.
615
+ const rest = namedBlock ? (positional.length ? `, ${namedBlock}` : namedBlock) : '';
616
+ return `${positional.join(', ')}${rest}`;
617
+ }
618
+ //# sourceMappingURL=codegen.js.map