@hatiolab/figure-model 0.5.0 → 0.5.2

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 (136) hide show
  1. package/dist/asset-types.d.ts +1 -1
  2. package/dist/asset-types.d.ts.map +1 -1
  3. package/dist/asset.d.ts.map +1 -1
  4. package/dist/asset.js +6 -5
  5. package/dist/asset.js.map +1 -1
  6. package/dist/authoring-actions.d.ts +2 -795
  7. package/dist/authoring-actions.d.ts.map +1 -1
  8. package/dist/authoring-actions.js +66 -3777
  9. package/dist/authoring-actions.js.map +1 -1
  10. package/dist/authoring-bindings.d.ts +35 -0
  11. package/dist/authoring-bindings.d.ts.map +1 -0
  12. package/dist/authoring-bindings.js +241 -0
  13. package/dist/authoring-bindings.js.map +1 -0
  14. package/dist/authoring-dimensions.d.ts +10 -0
  15. package/dist/authoring-dimensions.d.ts.map +1 -0
  16. package/dist/authoring-dimensions.js +88 -0
  17. package/dist/authoring-dimensions.js.map +1 -0
  18. package/dist/authoring-fastening.d.ts +24 -0
  19. package/dist/authoring-fastening.d.ts.map +1 -0
  20. package/dist/authoring-fastening.js +562 -0
  21. package/dist/authoring-fastening.js.map +1 -0
  22. package/dist/authoring-geometry.d.ts +119 -0
  23. package/dist/authoring-geometry.d.ts.map +1 -0
  24. package/dist/authoring-geometry.js +419 -0
  25. package/dist/authoring-geometry.js.map +1 -0
  26. package/dist/authoring-nodes.d.ts +77 -0
  27. package/dist/authoring-nodes.d.ts.map +1 -0
  28. package/dist/authoring-nodes.js +310 -0
  29. package/dist/authoring-nodes.js.map +1 -0
  30. package/dist/authoring-occupancy.d.ts +28 -0
  31. package/dist/authoring-occupancy.d.ts.map +1 -0
  32. package/dist/authoring-occupancy.js +784 -0
  33. package/dist/authoring-occupancy.js.map +1 -0
  34. package/dist/authoring-proportion.d.ts +27 -0
  35. package/dist/authoring-proportion.d.ts.map +1 -0
  36. package/dist/authoring-proportion.js +378 -0
  37. package/dist/authoring-proportion.js.map +1 -0
  38. package/dist/authoring-size.d.ts +17 -0
  39. package/dist/authoring-size.d.ts.map +1 -0
  40. package/dist/authoring-size.js +140 -0
  41. package/dist/authoring-size.js.map +1 -0
  42. package/dist/authoring-types.d.ts +753 -0
  43. package/dist/authoring-types.d.ts.map +1 -0
  44. package/dist/authoring-types.js +42 -0
  45. package/dist/authoring-types.js.map +1 -0
  46. package/dist/command-preview.d.ts +1 -1
  47. package/dist/command-preview.d.ts.map +1 -1
  48. package/dist/command-preview.js +1 -0
  49. package/dist/command-preview.js.map +1 -1
  50. package/dist/contract.d.ts +142 -3
  51. package/dist/contract.d.ts.map +1 -1
  52. package/dist/contract.js +1 -1
  53. package/dist/contract.js.map +1 -1
  54. package/dist/cost.d.ts.map +1 -1
  55. package/dist/cost.js +3 -0
  56. package/dist/cost.js.map +1 -1
  57. package/dist/create.d.ts +36 -0
  58. package/dist/create.d.ts.map +1 -1
  59. package/dist/create.js +60 -0
  60. package/dist/create.js.map +1 -1
  61. package/dist/document.d.ts.map +1 -1
  62. package/dist/document.js +4 -3
  63. package/dist/document.js.map +1 -1
  64. package/dist/editor-workspace.d.ts +3 -3
  65. package/dist/editor-workspace.d.ts.map +1 -1
  66. package/dist/examples.d.ts.map +1 -1
  67. package/dist/examples.js +3 -1
  68. package/dist/examples.js.map +1 -1
  69. package/dist/gate.d.ts.map +1 -1
  70. package/dist/gate.js +2 -0
  71. package/dist/gate.js.map +1 -1
  72. package/dist/graph-types.d.ts +3 -2
  73. package/dist/graph-types.d.ts.map +1 -1
  74. package/dist/graph.d.ts.map +1 -1
  75. package/dist/graph.js +13 -7
  76. package/dist/graph.js.map +1 -1
  77. package/dist/index.d.ts +10 -4
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +8 -2
  80. package/dist/index.js.map +1 -1
  81. package/dist/joint-list.js +1 -1
  82. package/dist/joint-list.js.map +1 -1
  83. package/dist/joint-part-edits.d.ts +40 -0
  84. package/dist/joint-part-edits.d.ts.map +1 -0
  85. package/dist/joint-part-edits.js +488 -0
  86. package/dist/joint-part-edits.js.map +1 -0
  87. package/dist/joint-writing.d.ts +50 -0
  88. package/dist/joint-writing.d.ts.map +1 -0
  89. package/dist/joint-writing.js +847 -0
  90. package/dist/joint-writing.js.map +1 -0
  91. package/dist/json.d.ts.map +1 -1
  92. package/dist/json.js +11 -8
  93. package/dist/json.js.map +1 -1
  94. package/dist/kernel-version.d.ts +1 -1
  95. package/dist/kernel-version.js +1 -1
  96. package/dist/package.d.ts +15 -3
  97. package/dist/package.d.ts.map +1 -1
  98. package/dist/package.js +58 -0
  99. package/dist/package.js.map +1 -1
  100. package/dist/part-edit.d.ts +1 -1
  101. package/dist/part-edit.d.ts.map +1 -1
  102. package/dist/part-edit.js +7 -4
  103. package/dist/part-edit.js.map +1 -1
  104. package/dist/proposal.d.ts +1 -1
  105. package/dist/proposal.d.ts.map +1 -1
  106. package/dist/proposal.js +5 -2
  107. package/dist/proposal.js.map +1 -1
  108. package/dist/render-batches.d.ts +10 -2
  109. package/dist/render-batches.d.ts.map +1 -1
  110. package/dist/render-batches.js +19 -5
  111. package/dist/render-batches.js.map +1 -1
  112. package/dist/resize-plan.d.ts +1 -1
  113. package/dist/resize-plan.d.ts.map +1 -1
  114. package/dist/robots.d.ts.map +1 -1
  115. package/dist/robots.js +3 -1
  116. package/dist/robots.js.map +1 -1
  117. package/dist/room.d.ts +1 -1
  118. package/dist/room.d.ts.map +1 -1
  119. package/dist/room.js +2 -1
  120. package/dist/room.js.map +1 -1
  121. package/dist/shape-edit.js +4 -4
  122. package/dist/shape-edit.js.map +1 -1
  123. package/dist/shape-ops.d.ts +62 -7
  124. package/dist/shape-ops.d.ts.map +1 -1
  125. package/dist/shape-ops.js +117 -8
  126. package/dist/shape-ops.js.map +1 -1
  127. package/dist/slot-layout-presets.d.ts +102 -0
  128. package/dist/slot-layout-presets.d.ts.map +1 -0
  129. package/dist/slot-layout-presets.js +185 -0
  130. package/dist/slot-layout-presets.js.map +1 -0
  131. package/dist/slot-layout.d.ts +16 -1
  132. package/dist/slot-layout.d.ts.map +1 -1
  133. package/dist/slot-layout.js.map +1 -1
  134. package/docs/modeller-interaction-performance-2026-10-07.md +40 -8
  135. package/docs/shape-contract.md +46 -0
  136. package/package.json +1 -1
@@ -0,0 +1,847 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ /*
5
+ * The joint records (ADR-0093): checking one, writing its nodes, and writing every joint again after a command.
6
+ */
7
+ import { compileFigureAssetUnjointed } from "./asset.js";
8
+ import { compileFigureGraph } from "./graph.js";
9
+ import { remappedFaces } from "./shape-edit.js";
10
+ import { REPEAT_LIMIT, AXES } from "./words.js";
11
+ import { FIGURE_INSTANCE_SIZE_DEFAULT } from "./instance-size.js";
12
+ import { FIGURE_FACES, FIGURE_JOINT_TYPES, figureMimicLeadersOf, followsSomething, followedTermsOf } from "./authoring-types.js";
13
+ import { fail, nodeById, partOf, namer, constantOf, measureRef, sumOf, stableDimension, ownInput, holdingDeclared, dropIfUnused, parentOf, round6 } from "./authoring-nodes.js";
14
+ import { turnOf, turnMatrix, mul3, neg, half, childFaceOf, ownSpansOf, standingSpansOf, IDENTITY, isIdentity, transpose3, mv3, FACE_VECTOR, faceAlong, standingTurnOf, standingQuarterOf, eulerOf, axisTurn, axisOf, splitTwist, eulerOfAny, alignInto, frustumWidthAlong } from "./authoring-geometry.js";
15
+ import { attach, addMotion, removeMotion } from "./authoring-fastening.js";
16
+ const everyKey = () => (...keys) => keys;
17
+ const JOINT_FIELDS = {
18
+ joint: everyKey()('id', 'type', 'body0', 'body1', 'origin', 'axis', 'lowerLimit', 'upperLimit', 'travel', 'state', 'mimic', 'repeat'),
19
+ body0: everyKey()('part', 'face', 'plane'),
20
+ body1: everyKey()('part', 'face'),
21
+ origin: everyKey()('facing', 'gap', 'align', 'turn', 'twist'),
22
+ axes: everyKey()('x', 'y', 'z'),
23
+ alignment: everyKey()('mm'),
24
+ measure: everyKey()('from', 'times', 'plus', 'sum'),
25
+ term: everyKey()('from', 'times'),
26
+ source: everyKey()('of', 'axis', 'input', 'part'),
27
+ travel: everyKey()('source', 'times', 'plus', 'sum'),
28
+ state: everyKey()('id', 'label', 'start', 'sweep'),
29
+ mimic: everyKey()('joint', 'multiplier', 'offset', 'sum'),
30
+ leader: everyKey()('joint', 'multiplier'),
31
+ repeat: everyKey()('axis', 'pitch', 'count', 'rise'),
32
+ rise: everyKey()('axis', 'mm')
33
+ };
34
+ /** Refuses, by name, a key of a joint record or of an object in it that this kernel does not know. A key left undefined is no field. */
35
+ function knownJointFields(j, path) {
36
+ const only = (o, allowed, at) => {
37
+ if (!o || typeof o !== 'object' || Array.isArray(o))
38
+ return;
39
+ for (const [k, v] of Object.entries(o))
40
+ if (v !== undefined && !allowed.includes(k))
41
+ fail('JOINT_SCHEMA', at, `${k} is a field this kernel does not know`);
42
+ };
43
+ const source = (s, at) => only(s, JOINT_FIELDS.source, at);
44
+ const measure = (m, at) => {
45
+ if (!m || typeof m !== 'object')
46
+ return;
47
+ only(m, JOINT_FIELDS.measure, at);
48
+ source(m.from, `${at}.from`);
49
+ if (Array.isArray(m.sum))
50
+ for (const [i, t] of m.sum.entries()) {
51
+ only(t, JOINT_FIELDS.term, `${at}.sum.${i}`);
52
+ source(t?.from, `${at}.sum.${i}.from`);
53
+ }
54
+ };
55
+ only(j, JOINT_FIELDS.joint, path);
56
+ only(j.body0, JOINT_FIELDS.body0, `${path}.body0`);
57
+ only(j.body1, JOINT_FIELDS.body1, `${path}.body1`);
58
+ const o = j.origin;
59
+ only(o, JOINT_FIELDS.origin, `${path}.origin`);
60
+ if (o && typeof o === 'object') {
61
+ measure(o.gap, `${path}.origin.gap`);
62
+ only(o.turn, JOINT_FIELDS.axes, `${path}.origin.turn`);
63
+ only(o.align, JOINT_FIELDS.axes, `${path}.origin.align`);
64
+ for (const [axis, a] of Object.entries(o.align && typeof o.align === 'object' ? o.align : {})) {
65
+ only(a, JOINT_FIELDS.alignment, `${path}.origin.align.${axis}`);
66
+ measure(a?.mm, `${path}.origin.align.${axis}.mm`);
67
+ }
68
+ }
69
+ if (j.travel && typeof j.travel === 'object') {
70
+ only(j.travel, JOINT_FIELDS.travel, `${path}.travel`);
71
+ source(j.travel.source, `${path}.travel.source`);
72
+ if (Array.isArray(j.travel.sum))
73
+ measure({ sum: j.travel.sum }, `${path}.travel`);
74
+ }
75
+ only(j.state, JOINT_FIELDS.state, `${path}.state`);
76
+ only(j.mimic, JOINT_FIELDS.mimic, `${path}.mimic`);
77
+ if (Array.isArray(j.mimic?.sum))
78
+ for (const [i, t] of j.mimic.sum.entries())
79
+ only(t, JOINT_FIELDS.leader, `${path}.mimic.sum.${i}`);
80
+ only(j.repeat, JOINT_FIELDS.repeat, `${path}.repeat`);
81
+ if (j.repeat && typeof j.repeat === 'object') {
82
+ measure(j.repeat.pitch, `${path}.repeat.pitch`);
83
+ only(j.repeat.rise, JOINT_FIELDS.rise, `${path}.repeat.rise`);
84
+ }
85
+ }
86
+ /** What each part is fastened to, for a reader that wants the structure rather than the graph. */
87
+ export function figureAttachmentsOf(asset) {
88
+ const m = asset.document.model;
89
+ const out = {};
90
+ for (const n of m.nodes)
91
+ if (n.op === 'place@1')
92
+ out[n.id] = parentOf(m, n.id);
93
+ return out;
94
+ }
95
+ /** The joint whose child is this part, if any. */
96
+ export function jointAbove(asset, part) {
97
+ return asset.joints?.find(j => j.body1.part === part);
98
+ }
99
+ export const stateIdOf = (j) => j.state?.id ?? j.id;
100
+ /** A record that is well formed on its own, and fits the tree the others make. */
101
+ function checkJoint(asset, j) {
102
+ const m = asset.document.model;
103
+ const path = `joints.${String(j?.id)}`;
104
+ if (!j || typeof j.id !== 'string' || !j.id.trim())
105
+ fail('JOINT_SCHEMA', 'joints', 'a joint has an id');
106
+ knownJointFields(j, path);
107
+ if (!FIGURE_JOINT_TYPES.includes(j.type))
108
+ fail('JOINT_SCHEMA', path, `${String(j.type)} is not a joint type; ${FIGURE_JOINT_TYPES.join(', ')}`);
109
+ if (!j.body1 || typeof j.body1.part !== 'string')
110
+ fail('JOINT_SCHEMA', path, 'a joint carries a part (body1)');
111
+ partOf(m, j.body1.part, path);
112
+ if (!j.body0 || (!('plane' in j.body0) && typeof j.body0.part !== 'string'))
113
+ fail('JOINT_SCHEMA', path, 'a joint hangs from a part or the mounting plane (body0)');
114
+ if ('part' in j.body0)
115
+ partOf(m, j.body0.part, path);
116
+ const others = (asset.joints ?? []).filter(o => o.id !== j.id);
117
+ const above = others.find(o => o.body1.part === j.body1.part);
118
+ if (above)
119
+ fail('JOINT_TREE', path, `${j.body1.part} already hangs from ${'part' in above.body0 ? above.body0.part : 'the mounting plane'} by ${above.id}; a part has one joint above it`);
120
+ if ('part' in j.body0) {
121
+ const parentOfPart = (part) => others.find(o => o.body1.part === part)?.body0;
122
+ for (let up = j.body0.part; up;) {
123
+ if (up === j.body1.part)
124
+ fail('ATTACH_CYCLE', path, `${j.body0.part} already hangs from ${j.body1.part}; ${j.body1.part} would hang from itself`);
125
+ const next = parentOfPart(up);
126
+ up = next && 'part' in next ? next.part : undefined;
127
+ }
128
+ }
129
+ const moving = j.type !== 'fixed';
130
+ const limited = j.lowerLimit !== undefined || j.upperLimit !== undefined;
131
+ if (moving && !['X', 'Y', 'Z'].includes(j.axis))
132
+ fail('JOINT_SCHEMA', path, `a ${j.type} joint has an axis, X, Y or Z`);
133
+ if (!moving && (j.axis !== undefined || limited || j.mimic !== undefined || j.travel !== undefined))
134
+ fail('JOINT_SCHEMA', path, 'a fixed joint has no axis, limits, mimic or travel');
135
+ if (j.travel !== undefined && j.type !== 'prismatic')
136
+ fail('JOINT_SCHEMA', path, 'only a prismatic joint travels a length');
137
+ if (j.type === 'prismatic' && j.travel && !limited) {
138
+ j.lowerLimit = 0;
139
+ j.upperLimit = 1;
140
+ }
141
+ /* A revolute joint without limits turns without end (USD); with them, both are given. A slide always has them. */
142
+ if (moving && !j.mimic && (limited || j.type === 'prismatic')) {
143
+ const [lo, hi] = [j.lowerLimit, j.upperLimit];
144
+ if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
145
+ fail('JOINT_SCHEMA', path, `a ${j.type} joint has both limits, lower below upper`);
146
+ /*
147
+ The rest pose is every joint at 0 -- the kinematic reference its axis is read at (joint-list.ts) -- and 0 need not
148
+ be a value the joint may take: a joint limited to 10..80 still has its reference at 0 (ADR-0107 J4). Its starting
149
+ value, the stored state default, is held to its limits as every control's is (addMotion).
150
+ */
151
+ }
152
+ if (j.mimic) {
153
+ /*
154
+ One leader or a sum of them (ADR-0107 decision 2), each held to the same rules: a moving joint that leads itself
155
+ -- so a follower is never a leader and no loop can close -- that moves the way this one does (degrees are not
156
+ added to millimetres), named once, with a finite multiplier.
157
+ */
158
+ const sum = Object.hasOwn(j.mimic, 'sum');
159
+ if (sum && !(Array.isArray(j.mimic.sum) && j.mimic.sum.length))
160
+ fail('JOINT_SCHEMA', path, 'a mimic sum follows one joint or more');
161
+ const seen = new Set();
162
+ for (const t of figureMimicLeadersOf(j)) {
163
+ const leader = others.find(o => o.id === t?.joint);
164
+ if (!leader || leader.type === 'fixed')
165
+ fail('JOINT_SCHEMA', path, `${String(t?.joint)} is not a moving joint to follow`);
166
+ if (seen.has(leader.id))
167
+ fail('JOINT_SCHEMA', path, `${leader.id} is followed twice; a sum names each joint once`);
168
+ seen.add(leader.id);
169
+ if (leader.mimic)
170
+ fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
171
+ if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
172
+ fail('JOINT_SCHEMA', path, `${leader.id} ${leader.type === 'prismatic' ? 'slides' : 'turns'}; a joint follows ones that move the same way: a slide a slide, a turn a turn`);
173
+ if (!Number.isFinite(t.multiplier))
174
+ fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
175
+ }
176
+ if (j.mimic.offset !== undefined && !Number.isFinite(j.mimic.offset))
177
+ fail('JOINT_SCHEMA', path, "a mimic's offset is a number in the joint's unit");
178
+ }
179
+ if (j.origin?.twist !== undefined && !Number.isFinite(j.origin.twist))
180
+ fail('JOINT_SCHEMA', `${path}.origin.twist`, 'a twist is a number of degrees');
181
+ if (j.origin?.turn &&
182
+ Object.values(j.origin.turn).some(t => !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
183
+ fail('JOINT_SCHEMA', `${path}.origin.turn`, "a turn is in quarter turns; an angle about the face's normal is its twist");
184
+ /*
185
+ A row repeats its part and everything fastened to it (ADR-0093, 2026-09-26): a part fastened to a part in a row is
186
+ on every copy. A row inside a row is a grid; it runs along another axis than the rows around it.
187
+ */
188
+ const all = [...others, j];
189
+ const upOf = (part) => all.find(o => o.body1.part === part && 'part' in o.body0);
190
+ const rowAbove = (part) => {
191
+ for (let up = upOf(part); up; up = upOf(up.body0.part))
192
+ if (up.repeat)
193
+ return up;
194
+ return undefined;
195
+ };
196
+ if (j.repeat) {
197
+ const below = (part) => all.filter(o => 'part' in o.body0 && o.body0.part === part).flatMap(o => [o.body1.part, ...below(o.body1.part)]);
198
+ const around = [];
199
+ for (let up = ('part' in j.body0) ? rowAbove(j.body0.part) : undefined; up; up = 'part' in up.body0 ? rowAbove(up.body0.part) : undefined)
200
+ around.push(up);
201
+ const inside = below(j.body1.part)
202
+ .map(p => all.find(o => o.body1.part === p))
203
+ .filter(o => o.repeat);
204
+ const same = [...around, ...inside].find(o => o.repeat.axis === j.repeat.axis);
205
+ if (same)
206
+ fail('JOINT_SCHEMA', `${path}.repeat`, `${same.body1.part}'s row runs along ${same.repeat.axis} too; a row inside a row runs along another axis`);
207
+ }
208
+ if (j.repeat) {
209
+ const r = j.repeat;
210
+ const row = `${path}.repeat`;
211
+ if (!('part' in j.body0))
212
+ fail('JOINT_SCHEMA', row, 'a row fills the part it is fastened to; the mounting plane has no length to fill');
213
+ if (!['X', 'Y', 'Z'].includes(r.axis))
214
+ fail('JOINT_SCHEMA', row, `a row runs along X, Y or Z, not ${String(r.axis)}`);
215
+ if (r.axis.toLowerCase() === FIGURE_FACES[childFaceOf(asset.document.model, j)])
216
+ fail('JOINT_SCHEMA', row, `a row runs across the face it is fastened to; ${r.axis} is out of that face`);
217
+ if (typeof r.pitch === 'number' ? !(Number.isFinite(r.pitch) && r.pitch > 0) : !followsSomething(r.pitch))
218
+ fail('GEOMETRY_DOMAIN', row, 'a row has a positive pitch, or one that follows a length');
219
+ if (r.count !== undefined && !(Number.isSafeInteger(r.count) && r.count >= 1 && r.count <= REPEAT_LIMIT))
220
+ fail('GEOMETRY_DOMAIN', row, `a row has 1 to ${REPEAT_LIMIT} copies`, { reason: 'row-copies-out-of-range', count: r.count, min: 1, max: REPEAT_LIMIT });
221
+ if (r.rise !== undefined) {
222
+ if (!r.rise || !['X', 'Y', 'Z'].includes(r.rise.axis) || r.rise.axis === r.axis)
223
+ fail('JOINT_SCHEMA', `${row}.rise`, `a row rises along X, Y or Z other than the one it runs along (${r.axis})`);
224
+ if (!(Number.isFinite(r.rise.mm) && r.rise.mm !== 0))
225
+ fail('GEOMETRY_DOMAIN', `${row}.rise`, 'a rise is a length in mm, not zero');
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * Write a joint's nodes from its record: the child's quarter turn, the fastening (the seat on the parent's face and
231
+ * the child's offset from it), and for a moving joint the turn or slide at the seat. The node ids are the child's and
232
+ * are chosen the same way each time, so writing a record again gives the same nodes.
233
+ */
234
+ function writeJoint(asset, j) {
235
+ const m = asset.document.model;
236
+ const child = j.body1.part;
237
+ const { pose } = partOf(m, child, child);
238
+ /*
239
+ The record speaks as the parts stand: `top` is up, the axis is the figure's. The seat is written in the parent's
240
+ own frame, so under a parent standing a quarter turn the record's words are turned into that frame, and the child
241
+ is given the turn back so it keeps its own (빈틈 A, 2026-09-25: a body on a pipe laid on its side).
242
+ */
243
+ const parent = 'part' in j.body0 ? j.body0.part : undefined;
244
+ const path = `joints.${j.id}`;
245
+ const back = parent ? transpose3(standingTurnOf(asset, parent, path)) : IDENTITY;
246
+ const turned = !isIdentity(back);
247
+ const own = AXES.map(a => j.origin?.turn?.[a] ?? 0);
248
+ /* The child as it stands: its quarter turn, then its twist about the normal of the face it is fastened by. */
249
+ const childFace = childFaceOf(m, j);
250
+ const normal = FIGURE_FACES[childFace];
251
+ const standing = j.origin?.twist ? mul3(axisTurn(normal, j.origin.twist), turnMatrix(own)) : turnMatrix(own);
252
+ /* In the parent's frame: its twist about the face's normal there, and the quarter turn that is left. */
253
+ const inParent = mul3(back, standing);
254
+ const localNormal = axisOf(mv3(back, FACE_VECTOR[childFace]), `${j.body1.part}'s ${childFace}`, parent ?? '', back, path);
255
+ /* The record's twist, about the normal as it lies in the parent's frame; what is left is a quarter turn. */
256
+ /*
257
+ In the parent's frame the child stands twisted about the face's normal -- by its own twist, and back by its
258
+ parent's -- after a quarter turn. The twist is the one nearest what the record asks for.
259
+ */
260
+ const alongNormal = mv3(back, AXES.map(a => (a === normal ? 1 : 0)));
261
+ const asked = (j.origin?.twist ?? 0) * Math.sign(alongNormal[AXES.indexOf(localNormal.axis)]);
262
+ const { quarter, deg } = splitTwist(inParent, localNormal.axis, asked, path);
263
+ const old = pose.args.slice(3, 6);
264
+ pose.args = [
265
+ ...pose.args.slice(0, 3),
266
+ ...(turned || j.origin?.twist ? eulerOf(quarter) : own).map(t => constantOf(m, 'deg', t, 'a'))
267
+ ];
268
+ for (const ref of old)
269
+ dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
270
+ const face = (name) => turned && Object.hasOwn(FACE_VECTOR, name)
271
+ ? faceAlong(axisOf(mv3(back, FACE_VECTOR[name]), name, parent ?? '', back, path).vector)
272
+ : name;
273
+ attach(asset, {
274
+ kind: 'attach',
275
+ part: child,
276
+ face: face(j.body1.face),
277
+ to: 'plane' in j.body0
278
+ ? { plane: 'mounting-plane' }
279
+ : { part: j.body0.part, face: face(j.body0.face) },
280
+ ...(j.origin?.facing ? { facing: j.origin.facing } : {}),
281
+ ...(j.origin?.gap !== undefined ? { gap: j.origin.gap } : {}),
282
+ /*
283
+ A distance across the face is measured in the twisted frame (see `attach`), so under a twisted parent it is
284
+ carried by the parent's quarter turn only; the twists turn it after.
285
+ */
286
+ ...(j.origin?.align
287
+ ? {
288
+ align: turned
289
+ ? alignInto(parent ? transpose3(standingQuarterOf(asset, parent, path)) : back, j.origin.align, path)
290
+ : j.origin.align
291
+ }
292
+ : {}),
293
+ replace: true
294
+ }, deg ? { axis: localNormal.axis, deg } : undefined);
295
+ /* The measuring was done on the quarter turn; the part stands with its twist. */
296
+ if (deg) {
297
+ const now = pose.args.slice(3, 6);
298
+ pose.args = [...pose.args.slice(0, 3), ...eulerOfAny(inParent).map(t => constantOf(m, 'deg', t, 'a'))];
299
+ for (const ref of now)
300
+ dropIfUnused(asset, ref);
301
+ }
302
+ if (j.repeat && turned)
303
+ fail('JOINT_SCHEMA', `joints.${j.id}.repeat`, `${parent} stands turned; a row across a turned part is not covered yet`);
304
+ /* A row is laid out last, once its part's motion is written: every copy moves on it (a roller row spinning). */
305
+ if (j.type === 'fixed') {
306
+ if (j.repeat)
307
+ repeatPart(asset, j);
308
+ return;
309
+ }
310
+ const kind = j.type === 'prismatic' ? 'slide' : 'turn';
311
+ const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
312
+ const range = j.lowerLimit !== undefined && j.upperLimit !== undefined
313
+ ? { min: j.lowerLimit, max: j.upperLimit }
314
+ : j.type === 'revolute'
315
+ ? { min: -180, max: 180 }
316
+ : { min: 0, max: 1 };
317
+ const leaders = figureMimicLeadersOf(j).map(t => ({
318
+ leader: (asset.joints ?? []).find(o => o.id === t.joint),
319
+ k: t.multiplier
320
+ }));
321
+ /* The axis turned into the parent's frame too: one of its axes, either way along it. */
322
+ const along = mv3(back, AXES.map(a => (a === j.axis.toLowerCase() ? 1 : 0)));
323
+ const axisIndex = along.findIndex(v => Math.abs(Math.abs(v) - 1) < 1e-9);
324
+ if (axisIndex < 0)
325
+ fail('JOINT_SCHEMA', path, `${parent} stands twisted; the axis ${j.axis} is not level with an axis in it`);
326
+ addMotion(asset, {
327
+ kind: 'add-motion',
328
+ part: child,
329
+ motion: { kind, axis: AXES[axisIndex] },
330
+ state: {
331
+ id: stateIdOf(j),
332
+ unit,
333
+ min: range.min,
334
+ max: range.max,
335
+ start: j.state?.start ?? Math.min(range.max, Math.max(range.min, 0)),
336
+ ...(j.state?.label !== undefined ? { label: j.state.label } : {}),
337
+ ...(j.state?.sweep !== undefined ? { sweep: j.state.sweep } : {})
338
+ },
339
+ ...(j.travel ? { travel: j.travel } : {}),
340
+ replace: true
341
+ },
342
+ /*
343
+ A follower follows how far its leader has gone -- its distance or angle -- not the leader's control: a leader whose
344
+ stroke is a share of a length has a control of 0..1, and multiplying that gave a follower a number with no length
345
+ (the telescopic conveyor, 2026-09-25). For a leader whose control is its distance the two are one value.
346
+ */
347
+ j.mimic
348
+ ? {
349
+ terms: leaders.map(({ leader, k }) => ({
350
+ of: nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader),
351
+ k
352
+ })),
353
+ c: j.mimic.offset ?? 0,
354
+ sum: Object.hasOwn(j.mimic, 'sum')
355
+ }
356
+ : undefined, along[axisIndex] > 0 ? 1 : -1);
357
+ if (j.repeat)
358
+ repeatPart(asset, j);
359
+ }
360
+ /**
361
+ * A fastened part made a row (r3 §8): its placement becomes the template of a `repeat@1`. The row starts at the
362
+ * parent's lower edge along the axis and runs to its upper edge, so its length is the parent's and follows it; each
363
+ * copy stands as the fastening put the part, moved along the axis. The part keeps its id, as the row's template.
364
+ */
365
+ function repeatPart(asset, j) {
366
+ const m = asset.document.model;
367
+ const part = j.body1.part;
368
+ const parent = j.body0.part;
369
+ const path = `joints.${j.id}.repeat`;
370
+ const axis = j.repeat.axis.toLowerCase();
371
+ const k = AXES.indexOf(axis);
372
+ const theirs = ownSpansOf(m, parent, path)[axis];
373
+ if (!theirs)
374
+ fail('EDIT_TARGET', path, `${parent} has no two faces along ${axis}; there is no length to fill`);
375
+ const mine = standingSpansOf(m, asset, part, path)[axis];
376
+ /* A frustum has no faces across its axis; it is as wide there as its wider end (G6). */
377
+ const widest = mine ? undefined : frustumWidthAlong(m, asset, part, axis, path);
378
+ if (!mine && !widest)
379
+ fail('EDIT_TARGET', path, `${part} has no two faces along ${axis}; its copies cannot be spaced`);
380
+ const { place, pose } = partOf(m, part, path);
381
+ const seat = nodeById(m, `${part}.seat.rigid`);
382
+ const name = namer(m, `${part}.row`);
383
+ const zero = constantOf(m, 'mm', 0, 'd');
384
+ /* The seat moves to the row's start; each copy's centre is where the layout puts it from there. */
385
+ seat.args = seat.args.map((ref, i) => (i === k ? sumOf(m, name, theirs.lo, 'start') : ref));
386
+ const own = widest || mine.centred ? zero : sumOf(m, name, neg([...half(mine.lo), ...half(mine.hi)]), 'own-centre');
387
+ pose.args = pose.args.map((ref, i) => (i === k ? own : ref));
388
+ const length = sumOf(m, name, [...theirs.hi, ...neg(theirs.lo)], 'length');
389
+ const extent = widest ?? sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
390
+ const centred = constantOf(m, 'ratio', 0.5, 'k');
391
+ const limit = constantOf(m, 'count', REPEAT_LIMIT, 'limit');
392
+ const count = j.repeat.count;
393
+ m.nodes.push(count === undefined
394
+ ? {
395
+ id: `${part}.layout`,
396
+ op: 'fit-pitch@1',
397
+ args: [
398
+ length,
399
+ zero,
400
+ zero,
401
+ extent,
402
+ measureRef(m, asset, name, j.repeat.pitch, 'pitch', path),
403
+ centred,
404
+ limit
405
+ ],
406
+ outputs: { layout: `${part}.layout.value` }
407
+ }
408
+ : {
409
+ id: `${part}.layout`,
410
+ op: 'fixed-count@1',
411
+ args: [constantOf(m, 'count', count, 'count'), length, zero, zero, extent, centred, limit],
412
+ outputs: { layout: `${part}.layout.value` }
413
+ });
414
+ /* The part is the row's template now: a member, drawn only through the row -- turned by its motion, if it moves. */
415
+ place.op = 'member@1';
416
+ place.args = [place.args[0], nodeById(m, `${part}.turned`)?.outputs?.pose ?? pose.outputs.pose];
417
+ m.nodes.push({
418
+ id: `${part}.assembly`,
419
+ op: 'assembly@1',
420
+ args: [place.outputs.placed],
421
+ outputs: { assembly: `${part}.assembly.value` }
422
+ });
423
+ /* Where the parent stands, as `attach` finds it: fastened (its world or chain), or placed by its own numbers. */
424
+ const above = nodeById(m, `${parent}.world`)?.outputs?.pose ??
425
+ nodeById(m, `${parent}.chain`)?.outputs?.pose ??
426
+ nodeById(m, `${parent}.turned`)?.outputs?.pose ??
427
+ nodeById(m, `${parent}.pose`)?.outputs?.pose;
428
+ let root = seat.outputs.pose;
429
+ if (above) {
430
+ m.nodes.push({
431
+ id: `${part}.row`,
432
+ op: 'compose@1',
433
+ args: [above, seat.outputs.pose],
434
+ outputs: { pose: `${part}.row.value` }
435
+ });
436
+ root = `${part}.row.value`;
437
+ }
438
+ m.nodes = m.nodes.filter((n) => n.id !== `${part}.chain` && n.id !== `${part}.world`);
439
+ const rise = j.repeat.rise;
440
+ m.nodes.push({
441
+ id: `${part}.repeat`,
442
+ op: 'repeat@1',
443
+ args: [
444
+ `${part}.layout.value`,
445
+ `${part}.assembly.value`,
446
+ root,
447
+ ...(rise ? [constantOf(m, 'mm', rise.mm, 'rise')] : [])
448
+ ],
449
+ outputs: { collection: `${part}.repeat.value` },
450
+ params: { axis, ...(rise ? { riseAxis: rise.axis.toLowerCase() } : {}) }
451
+ });
452
+ }
453
+ /**
454
+ * Everything fastened to a row's part, however far down, goes into the row's assembly: each copy carries it (ADR-0093,
455
+ * 2026-09-26). Its placement already reads its copy's frame -- it hangs from the part's pose, not from a chain.
456
+ */
457
+ function gatherRows(asset, joints) {
458
+ const m = asset.document.model;
459
+ for (const row of joints.filter(j => j.repeat)) {
460
+ const assembly = nodeById(m, `${row.body1.part}.assembly`);
461
+ /* Down to the next row: a row inside this one joins as its copies, and what hangs from its part is its own. */
462
+ const gather = (part) => {
463
+ for (const o of joints.filter(o => 'part' in o.body0 && o.body0.part === part)) {
464
+ if (o.repeat) {
465
+ const inner = nodeById(m, `${o.body1.part}.repeat`);
466
+ inner.params = { ...inner.params, within: `${row.body1.part}.repeat` };
467
+ assembly.args.push(inner.outputs.collection);
468
+ continue;
469
+ }
470
+ const place = nodeById(m, o.body1.part);
471
+ place.op = 'member@1';
472
+ assembly.args.push(place.outputs.placed);
473
+ gather(o.body1.part);
474
+ }
475
+ };
476
+ gather(row.body1.part);
477
+ }
478
+ }
479
+ /** A row made back into its one part, so its fastening can be taken away or written again. */
480
+ function unrepeatPart(asset, part) {
481
+ const m = asset.document.model;
482
+ const template = nodeById(m, part);
483
+ if (template?.op !== 'member@1' || !nodeById(m, `${part}.repeat`))
484
+ return;
485
+ const layout = nodeById(m, `${part}.layout`);
486
+ const rise = nodeById(m, `${part}.repeat`)?.args[3];
487
+ const row = new Set([`${part}.layout`, `${part}.assembly`, `${part}.repeat`, `${part}.row`]);
488
+ const gone = m.nodes.filter((n) => row.has(n.id) || n.id.startsWith(`${part}.row.`));
489
+ m.nodes = m.nodes.filter((n) => !gone.includes(n));
490
+ template.op = 'place@1';
491
+ for (const ref of [...(layout?.args ?? []), ...(rise ? [rise] : [])])
492
+ dropIfUnused(asset, ref);
493
+ }
494
+ /** Take a joint's nodes away, leaving its child standing in the asset frame at the origin. */
495
+ export function stripJoint(asset, j) {
496
+ const m = asset.document.model;
497
+ const child = j.body1.part;
498
+ unrepeatPart(asset, child);
499
+ if (nodeById(m, `${child}.motion`))
500
+ removeMotion(asset, { kind: 'remove-motion', part: child });
501
+ const { pose, place } = partOf(m, child, child);
502
+ const owned = new Set([`${child}.seat.rigid`, `${child}.chain`, `${child}.world`, `${child}.turned`]);
503
+ m.nodes = m.nodes.filter((n) => !owned.has(n.id) && !n.id.startsWith(`${child}.on.`));
504
+ const zero = constantOf(m, 'mm', 0, 'd');
505
+ const old = pose.args.slice(0, 3);
506
+ pose.args = [zero, zero, zero, ...pose.args.slice(3)];
507
+ pose.params = { ...pose.params, to: asset.document.capabilities.assetFrame };
508
+ // Its own position numbers, if it still had them, place nothing now: the joint places it.
509
+ for (const ref of old)
510
+ dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
511
+ place.args = [place.args[0], pose.outputs.pose];
512
+ }
513
+ /**
514
+ * Every length a joint record holds that may follow something, read as a measure, with a way to write it back: the
515
+ * gap, a row's pitch, a slide's travel (its one-term form names the source `source`, and without one is `plus` mm)
516
+ * and each offset across the face. Renaming a part and taking one away read and write them all alike.
517
+ */
518
+ export function jointMeasures(j) {
519
+ const out = [];
520
+ if (j.origin?.gap !== undefined)
521
+ out.push({ at: 'gap', get: () => j.origin.gap, set: v => (j.origin.gap = v) });
522
+ if (j.repeat)
523
+ out.push({ at: 'pitch', get: () => j.repeat.pitch, set: v => (j.repeat.pitch = v) });
524
+ if (j.travel) {
525
+ const t = j.travel;
526
+ out.push({
527
+ at: 'travel',
528
+ get: () => Object.hasOwn(t, 'sum')
529
+ ? { sum: t.sum, ...(t.plus !== undefined ? { plus: t.plus } : {}) }
530
+ : t.source
531
+ ? {
532
+ from: t.source,
533
+ ...(t.times !== undefined ? { times: t.times } : {}),
534
+ ...(t.plus !== undefined ? { plus: t.plus } : {})
535
+ }
536
+ : (t.plus ?? 0),
537
+ set: v => {
538
+ const f = followedTermsOf(v);
539
+ j.travel = !f
540
+ ? { plus: v }
541
+ : f.sum
542
+ ? { sum: f.terms, ...(f.plus ? { plus: f.plus } : {}) }
543
+ : {
544
+ source: f.terms[0].from,
545
+ ...(f.terms[0].times !== undefined ? { times: f.terms[0].times } : {}),
546
+ ...(v.plus !== undefined ? { plus: v.plus } : {})
547
+ };
548
+ }
549
+ });
550
+ }
551
+ for (const axis of Object.keys(j.origin?.align ?? {})) {
552
+ const how = j.origin.align[axis];
553
+ if (how && typeof how === 'object' && Object.hasOwn(how, 'mm'))
554
+ out.push({
555
+ at: `align.${axis}`,
556
+ get: () => how.mm,
557
+ set: v => (how.mm = v)
558
+ });
559
+ }
560
+ return out;
561
+ }
562
+ /** The same measure with each term's source put through `to`, in the form it was written. */
563
+ export function mapMeasureSources(measure, to) {
564
+ const f = followedTermsOf(measure);
565
+ if (!f)
566
+ return measure;
567
+ if (f.sum)
568
+ return { ...measure, sum: f.terms.map(t => ({ ...t, from: to(t.from) })) };
569
+ return { ...measure, from: to(f.terms[0].from) };
570
+ }
571
+ /** Joints in an order each can be written in: its parent's joint and the joints it mimics first. */
572
+ function writingOrder(joints) {
573
+ const done = new Set();
574
+ const out = [];
575
+ const byChild = new Map(joints.map(j => [j.body1.part, j]));
576
+ while (out.length < joints.length) {
577
+ const ready = joints.filter(j => {
578
+ if (done.has(j.id))
579
+ return false;
580
+ const up = 'part' in j.body0 ? byChild.get(j.body0.part) : undefined;
581
+ return (!up || done.has(up.id)) && figureMimicLeadersOf(j).every(t => done.has(t.joint));
582
+ });
583
+ if (!ready.length)
584
+ fail('JOINT_TREE', 'joints', 'the joints do not make a tree');
585
+ for (const j of ready) {
586
+ done.add(j.id);
587
+ out.push(j);
588
+ }
589
+ }
590
+ return out;
591
+ }
592
+ /** Every joint's nodes again, from the records: the ones below first away, then all written parents first. */
593
+ export function rewriteJoints(asset) {
594
+ const joints = (asset.joints ?? []);
595
+ /*
596
+ A stroke that follows a part's length reads that length by its stable name (`<part>.dimension.<axis>`), and naming
597
+ it rewires the shape to read it. Done in the middle of the writing, the joints already written measured the part by
598
+ its old reference and the parse's rewrite measured it by the new one (JOINT_DRIFT, the telescopic conveyor,
599
+ 2026-09-25). The names are made first, so every joint measures the same references.
600
+ */
601
+ for (const j of joints)
602
+ for (const { at, get } of jointMeasures(j))
603
+ for (const t of followedTermsOf(get())?.terms ?? [])
604
+ if (t?.from?.of === 'part')
605
+ stableDimension(asset.document.model, t.from.part, t.from.axis, `joints.${j.id}.${at}`);
606
+ const order = writingOrder(joints);
607
+ const keep = { ...asset.stateDefaults };
608
+ holdingDeclared(asset, () => {
609
+ /* What hangs from a row's part is drawn through the row; it is a part of its own again while the joints are written. */
610
+ for (const n of asset.document.model.nodes)
611
+ if (n.op === 'member@1' && !nodeById(asset.document.model, `${n.id}.repeat`))
612
+ n.op = 'place@1';
613
+ for (const j of [...order].reverse())
614
+ stripJoint(asset, j);
615
+ for (const j of order)
616
+ writeJoint(asset, j);
617
+ gatherRows(asset, joints);
618
+ // A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
619
+ for (const j of order)
620
+ if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
621
+ asset.stateDefaults[stateIdOf(j)] = keep[stateIdOf(j)];
622
+ });
623
+ }
624
+ export function setJoint(asset, joint) {
625
+ // As JSON: a field left undefined is a field not there, which is how the asset stores it.
626
+ const j = JSON.parse(JSON.stringify(joint ?? null));
627
+ checkJoint(asset, j);
628
+ /* The child's own quarter turn becomes the record's, the first time it is fastened. */
629
+ if (!jointAbove(asset, j.body1.part) && !j.origin?.turn) {
630
+ const turn = turnOf(asset.document.model, asset, j.body1.part, j.body1.part);
631
+ /* A part turned by any angle about the normal of the face it is fastened by keeps that angle as its twist. */
632
+ const n = AXES.indexOf(FIGURE_FACES[childFaceOf(asset.document.model, j)]);
633
+ const quarter = (t) => t !== null && Number.isFinite(t) && Math.abs(t / 90 - Math.round(t / 90)) < 1e-9;
634
+ const twist = !quarter(turn[n]) && turn.every((t, i) => i === n || t === 0) ? turn[n] : undefined;
635
+ if (twist !== undefined && !j.origin?.twist)
636
+ j.origin = { ...(j.origin ?? {}), twist };
637
+ else if (turn.some(t => t !== 0))
638
+ j.origin = {
639
+ ...(j.origin ?? {}),
640
+ turn: Object.fromEntries(AXES.map((a, i) => [a, turn[i]]))
641
+ };
642
+ }
643
+ const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
644
+ const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === j.id));
645
+ if (followers.length && j.type === 'fixed')
646
+ fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${j.id}; it cannot stop moving`);
647
+ asset.joints = [...joints, j];
648
+ rewriteJoints(asset);
649
+ }
650
+ /** The joint goes; its child keeps where it stands now, as plain position numbers. */
651
+ /**
652
+ * Where what refers to a face an edit takes away goes instead (ADR-0100 A8): a joint re-pointed to another face of
653
+ * the part, or let go where it stands (`detach`, a joint only); a named face moved to another face -- a capability
654
+ * hangs on it, so it is never let go. Applied before the edit, in the same command: one step of the history. The new
655
+ * faces must be the part's after the edit; `keepReferencedFaces` refuses one that is not.
656
+ */
657
+ export function applyFaceRemap(asset, part, remap) {
658
+ if (!remap?.length)
659
+ return;
660
+ const faces = new Set();
661
+ const shapeOutput = asset.document.model.nodes.find((o) => o.id === `${part}.shape`)?.outputs?.shape;
662
+ const faceOf = (to, path) => {
663
+ const face = typeof to?.face === 'string' ? to.face.trim() : '';
664
+ if (!face)
665
+ fail('SCHEMA', `${path}.to`, `a face of ${part}, or 'detach'`);
666
+ return face;
667
+ };
668
+ for (const [i, r] of remap.entries()) {
669
+ const path = `remap.${i}`;
670
+ if (r?.ref?.kind === 'joint') {
671
+ const j = (asset.joints ?? []).find(o => o.id === r.ref.id);
672
+ if (!j || !(j.body0?.part === part || j.body1?.part === part))
673
+ fail('EDIT_TARGET', path, `${r.ref.id} is not a joint on ${part}`);
674
+ if (r.to === 'detach') {
675
+ removeJoint(asset, j.id);
676
+ continue;
677
+ }
678
+ const face = faceOf(r.to, path);
679
+ if (j.body0?.part === part)
680
+ j.body0.face = face;
681
+ else
682
+ j.body1.face = face;
683
+ faces.add(face);
684
+ }
685
+ else if (r?.ref?.kind === 'feature') {
686
+ const n = asset.document.model.nodes.find((o) => o.id === r.ref.id && o.op === 'feature@1');
687
+ if (!n || n.args[0] !== shapeOutput)
688
+ fail('EDIT_TARGET', path, `${r.ref.id} is not a named face of ${part}`);
689
+ if (r.to === 'detach')
690
+ fail('SCHEMA', `${path}.to`, `a named face moves to another face; a capability hangs on it, so it is not let go`);
691
+ const face = faceOf(r.to, path);
692
+ n.params = { ...n.params, name: face };
693
+ faces.add(face);
694
+ }
695
+ else
696
+ fail('SCHEMA', `${path}.ref`, `{ kind: 'joint' | 'feature', id }`);
697
+ }
698
+ remappedFaces.set(asset, faces);
699
+ }
700
+ export function removeJoint(asset, id) {
701
+ const joints = (asset.joints ?? []);
702
+ const j = joints.find(o => o.id === id);
703
+ if (!j)
704
+ fail('EDIT_TARGET', `joints.${id}`, `there is no joint ${id}`);
705
+ const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === id));
706
+ if (followers.length)
707
+ fail('JOINT_SCHEMA', `joints.${id}`, `${followers.map(f => f.id).join(', ')} follow ${id}; remove them first`);
708
+ const m = asset.document.model;
709
+ const child = j.body1.part;
710
+ const evaluated = compileFigureAssetUnjointed(asset).evaluate();
711
+ const placed = nodeById(m, child);
712
+ const at = evaluated.values?.[placed.args[1]];
713
+ if (!at?.t)
714
+ fail('TARGET_ABSENT', child, `${child} has no pose to read`);
715
+ const own = m.nodes.find((n) => n.id === `${child}.pose`);
716
+ const turned = (r, want) => r.some((row, i) => row.some((v, k) => Math.abs(v - want[i][k]) > 1e-9));
717
+ const ownTurn = evaluated.values?.[own.outputs.pose]?.r;
718
+ if (ownTurn && turned(at.r, ownTurn))
719
+ fail('EDIT_TARGET', child, `${child} stands turned by what it hangs from; taking the joint away would turn it`);
720
+ asset.joints = joints.filter(o => o.id !== id);
721
+ stripJoint(asset, j);
722
+ const translation = AXES.map((axis, i) => {
723
+ const input = `${child}.position.${axis}`;
724
+ const value = round6(at.t[i]);
725
+ if (!m.inputs.some((n) => n.id === input))
726
+ m.inputs.push({ id: input, unit: 'mm', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
727
+ asset.designInputs[input] = value;
728
+ return input;
729
+ });
730
+ own.args = [...translation, ...own.args.slice(3)];
731
+ // Parts hanging from it are written again: they hang from where it stands now.
732
+ rewriteJoints(asset);
733
+ }
734
+ /** `attach` is the modeller's word for a fixed joint: the record says it, whatever joint was there keeps its id and motion. */
735
+ export function fastenAsJoint(asset, a) {
736
+ const was = jointAbove(asset, a.part);
737
+ if (was && !a.replace)
738
+ fail('ATTACH_REPLACED', a.part, `${a.part} is already fastened by ${was.id}; pass replace to move it, or detach it first`);
739
+ const to = a.to;
740
+ /*
741
+ A new record is named after its part -- unless another joint already has that name: a robot arm's finger joint
742
+ was named `gripper`, and fastening the gripper wrote over it (2026-09-25). The first free name after it is taken.
743
+ */
744
+ const taken = new Set((asset.joints ?? []).map(j => j.id));
745
+ let id = a.part;
746
+ for (let k = 2; !was && taken.has(id); k++)
747
+ id = `${a.part}-${k}`;
748
+ setJoint(asset, {
749
+ ...(was ?? { id, type: 'fixed' }),
750
+ body0: to?.plane !== undefined ? { plane: to.plane } : { part: to?.part, face: to?.face },
751
+ body1: { part: a.part, face: a.face },
752
+ origin: {
753
+ ...(was?.origin?.turn ? { turn: was.origin.turn } : {}),
754
+ ...(a.twist !== undefined
755
+ ? a.twist
756
+ ? { twist: a.twist }
757
+ : {}
758
+ : was?.origin?.twist
759
+ ? { twist: was.origin.twist }
760
+ : {}),
761
+ ...(a.facing ? { facing: a.facing } : {}),
762
+ ...(a.gap !== undefined ? { gap: a.gap } : {}),
763
+ ...(a.align ? { align: a.align } : {})
764
+ }
765
+ });
766
+ }
767
+ /**
768
+ * The records are the canon: made again from them, the joints' nodes must be the nodes the asset holds. An asset
769
+ * whose nodes or records were edited apart is refused, not quietly rebuilt (ADR-0093, chief architect's condition).
770
+ */
771
+ export function checkFigureJoints(asset) {
772
+ const joints = asset.joints;
773
+ if (!joints?.length)
774
+ return;
775
+ const again = structuredClone(asset);
776
+ for (const j of joints)
777
+ checkJoint({ ...again, joints: joints.filter(o => o.id !== j.id).concat(j) }, j);
778
+ rewriteJoints(again);
779
+ const canon = (model) => JSON.stringify({
780
+ nodes: [...model.nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
781
+ inputs: [...model.inputs].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
782
+ });
783
+ if (canon(again.document.model) !== canon(asset.document.model))
784
+ fail('JOINT_DRIFT', 'joints', "the joints' nodes are not what their records make; the asset was edited apart from its joints");
785
+ }
786
+ export function setRepeat(asset, a) {
787
+ const m = asset.document.model;
788
+ const path = `${a.part}.repeat`;
789
+ /* A row made in the modeller lives in its fastening's record: the pitch changes there, and the row is made again. */
790
+ const record = (asset.joints ?? []).find(o => o.body1.part === a.part && o.repeat);
791
+ if (record) {
792
+ setJoint(asset, { ...record, repeat: { ...record.repeat, pitch: a.pitch } });
793
+ return asset;
794
+ }
795
+ const layout = nodeById(m, `${a.part}.layout`);
796
+ if (!layout || !nodeById(m, `${a.part}.repeat`))
797
+ fail('EDIT_TARGET', path, `${a.part} is not a repeated part`);
798
+ if (layout.op === 'fixed-count@1')
799
+ fail('EDIT_TARGET', path, `${a.part} repeats a fixed count; its pitch is built into its length and cannot be set yet`);
800
+ if (layout.op !== 'fit-pitch@1')
801
+ fail('EDIT_TARGET', path, `${a.part} lays its copies out by ${layout.op}, which has no pitch`);
802
+ /* A pitch read from an input is a record's: a row without one takes a number (ADR-0096). */
803
+ if (typeof a.pitch !== 'number')
804
+ fail('EDIT_TARGET', path, `${a.part}'s row has no record to read its pitch from an input; make it again with make-repeat`);
805
+ const pitch = a.pitch;
806
+ if (!(Number.isFinite(pitch) && pitch > 0))
807
+ fail('GEOMETRY_DOMAIN', path, 'a positive pitch is required');
808
+ /*
809
+ One number, the pitch: copies = floor(row length / pitch), at most `limit` of them (the kernel's copy cap).
810
+ So the longest row the figure may be placed at is limit x pitch. A new pitch moves that bound with it; the pitch is refused only when the length the figure
811
+ is authored at would already hold more copies than the cap.
812
+ */
813
+ const values = compileFigureGraph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
814
+ const lengthRef = layout.args[0];
815
+ const authored = values[lengthRef];
816
+ const limit = (values[layout.args[6]] ?? m.constants.find((c) => c.id === layout.args[6])?.value);
817
+ const smallest = Math.ceil((authored / limit) * 1000) / 1000;
818
+ if (pitch < smallest)
819
+ fail('INPUT_RANGE', path, `at the length it is authored (${round6(authored)} mm) a pitch under ${smallest} mm makes more than ${limit} copies`, {
820
+ reason: 'pitch-too-many-copies',
821
+ part: a.part,
822
+ length: round6(authored),
823
+ least: smallest,
824
+ limit
825
+ });
826
+ const id = `${a.part}.repeat.pitch`;
827
+ if (!m.inputs.some((i) => i.id === id)) {
828
+ if (m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id)))
829
+ fail('DUPLICATE_WRITER', path, `${id} already exists`);
830
+ m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
831
+ const was = [layout.args[3], layout.args[4]];
832
+ layout.args = layout.args.map((r, k) => (k === 3 || k === 4 ? id : r));
833
+ for (const ref of new Set(was))
834
+ dropIfUnused(asset, ref);
835
+ }
836
+ asset.designInputs[id] = pitch;
837
+ /* The longest length follows the pitch, within the default range (ten times the authored length). */
838
+ const length = m.inputs.find((i) => i.id === lengthRef && i.role === 'design');
839
+ if (length) {
840
+ const longest = Math.min(round6(limit * pitch), round6(authored * FIGURE_INSTANCE_SIZE_DEFAULT.times));
841
+ length.max = Math.max(longest, authored);
842
+ if (length.min > length.max)
843
+ length.min = Math.min(length.min, authored);
844
+ }
845
+ return asset;
846
+ }
847
+ //# sourceMappingURL=joint-writing.js.map