@hatiolab/figure-model 0.7.3 → 0.9.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 (79) hide show
  1. package/dist/authoring-fastening.d.ts +53 -8
  2. package/dist/authoring-fastening.d.ts.map +1 -1
  3. package/dist/authoring-fastening.js +158 -242
  4. package/dist/authoring-fastening.js.map +1 -1
  5. package/dist/authoring-geometry.d.ts +13 -46
  6. package/dist/authoring-geometry.d.ts.map +1 -1
  7. package/dist/authoring-geometry.js +28 -180
  8. package/dist/authoring-geometry.js.map +1 -1
  9. package/dist/authoring-occupancy.d.ts +3 -3
  10. package/dist/authoring-occupancy.d.ts.map +1 -1
  11. package/dist/authoring-occupancy.js +23 -12
  12. package/dist/authoring-occupancy.js.map +1 -1
  13. package/dist/authoring-proportion.d.ts.map +1 -1
  14. package/dist/authoring-proportion.js +15 -3
  15. package/dist/authoring-proportion.js.map +1 -1
  16. package/dist/authoring-types.d.ts +69 -32
  17. package/dist/authoring-types.d.ts.map +1 -1
  18. package/dist/authoring-types.js +14 -0
  19. package/dist/authoring-types.js.map +1 -1
  20. package/dist/command-preview.d.ts +11 -33
  21. package/dist/command-preview.d.ts.map +1 -1
  22. package/dist/command-preview.js +2 -61
  23. package/dist/command-preview.js.map +1 -1
  24. package/dist/contract.d.ts +1 -1
  25. package/dist/contract.d.ts.map +1 -1
  26. package/dist/examples.js +4 -4
  27. package/dist/examples.js.map +1 -1
  28. package/dist/face-overhang.d.ts +105 -0
  29. package/dist/face-overhang.d.ts.map +1 -0
  30. package/dist/face-overhang.js +357 -0
  31. package/dist/face-overhang.js.map +1 -0
  32. package/dist/fasten-place.d.ts +35 -0
  33. package/dist/fasten-place.d.ts.map +1 -0
  34. package/dist/fasten-place.js +91 -0
  35. package/dist/fasten-place.js.map +1 -0
  36. package/dist/gate.d.ts.map +1 -1
  37. package/dist/gate.js +7 -4
  38. package/dist/gate.js.map +1 -1
  39. package/dist/graph.d.ts.map +1 -1
  40. package/dist/graph.js +16 -2
  41. package/dist/graph.js.map +1 -1
  42. package/dist/index.d.ts +7 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -3
  45. package/dist/index.js.map +1 -1
  46. package/dist/joint-fields.d.ts +12 -0
  47. package/dist/joint-fields.d.ts.map +1 -0
  48. package/dist/joint-fields.js +20 -0
  49. package/dist/joint-fields.js.map +1 -0
  50. package/dist/joint-writing.d.ts +7 -2
  51. package/dist/joint-writing.d.ts.map +1 -1
  52. package/dist/joint-writing.js +224 -119
  53. package/dist/joint-writing.js.map +1 -1
  54. package/dist/kernel-version.d.ts +1 -1
  55. package/dist/kernel-version.js +1 -1
  56. package/dist/proposal.js +4 -4
  57. package/dist/proposal.js.map +1 -1
  58. package/dist/recipe.js +1 -1
  59. package/dist/recipe.js.map +1 -1
  60. package/dist/resize-plan.js +1 -1
  61. package/dist/resize-plan.js.map +1 -1
  62. package/dist/robots.d.ts.map +1 -1
  63. package/dist/robots.js +24 -22
  64. package/dist/robots.js.map +1 -1
  65. package/dist/shape-edit.d.ts.map +1 -1
  66. package/dist/shape-edit.js +7 -3
  67. package/dist/shape-edit.js.map +1 -1
  68. package/dist/shape-extreme.d.ts +49 -0
  69. package/dist/shape-extreme.d.ts.map +1 -0
  70. package/dist/shape-extreme.js +262 -0
  71. package/dist/shape-extreme.js.map +1 -0
  72. package/dist/shape-ops.d.ts +6 -7
  73. package/dist/shape-ops.d.ts.map +1 -1
  74. package/dist/shape-ops.js +72 -37
  75. package/dist/shape-ops.js.map +1 -1
  76. package/dist/size-edit.d.ts.map +1 -1
  77. package/dist/size-edit.js +40 -11
  78. package/dist/size-edit.js.map +1 -1
  79. package/package.json +1 -1
@@ -5,20 +5,21 @@
5
5
  * The joint records (ADR-0093): checking one, writing its nodes, and writing every joint again after a command.
6
6
  */
7
7
  import { compileFigureAssetUnjointed } from "./asset.js";
8
- import { compileFigureGraph } from "./graph.js";
8
+ import { compileFigureGraph, figureShapeFaces } from "./graph.js";
9
9
  import { remappedFaces } from "./shape-edit.js";
10
10
  import { REPEAT_LIMIT, AXES } from "./words.js";
11
11
  import { FIGURE_INSTANCE_SIZE_DEFAULT } from "./instance-size.js";
12
- import { FIGURE_FACES, FIGURE_JOINT_TYPES, figureMimicLeadersOf, followsSomething, followedTermsOf } from "./authoring-types.js";
12
+ import { FIGURE_FACES, FIGURE_JOINT_TYPES, FIGURE_JOINT_PIVOTS, figureMimicLeadersOf, followsSomething, followedTermsOf } from "./authoring-types.js";
13
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";
14
+ import { mul3, neg, half, ownSpansOf, IDENTITY, transpose3, mv3, eulerOfAny, rpyMatrix, isQuarterMatrix, receivingAxisOf, rpyOf } from "./authoring-geometry.js";
15
+ import { placeOnFace, addMotion, removeMotion, reseat, turnSeat } from "./authoring-fastening.js";
16
+ import { figureShapeExtremeAlong, lin, plus, times, writeFigureReach } from "./shape-extreme.js";
16
17
  const everyKey = () => (...keys) => keys;
17
18
  const JOINT_FIELDS = {
18
19
  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'),
20
+ body0: everyKey()('part', 'face', 'plane', 'at'),
21
+ body1: everyKey()('part', 'at'),
22
+ origin: everyKey()('facing', 'gap', 'align', 'childRotate', 'pivot', 'offset', 'parentRotate'),
22
23
  axes: everyKey()('x', 'y', 'z'),
23
24
  alignment: everyKey()('mm'),
24
25
  measure: everyKey()('from', 'times', 'plus', 'sum'),
@@ -59,13 +60,18 @@ function knownJointFields(j, path) {
59
60
  only(o, JOINT_FIELDS.origin, `${path}.origin`);
60
61
  if (o && typeof o === 'object') {
61
62
  measure(o.gap, `${path}.origin.gap`);
62
- only(o.turn, JOINT_FIELDS.axes, `${path}.origin.turn`);
63
+ only(o.childRotate, JOINT_FIELDS.axes, `${path}.origin.childRotate`);
64
+ only(o.parentRotate, JOINT_FIELDS.axes, `${path}.origin.parentRotate`);
65
+ only(o.offset, JOINT_FIELDS.axes, `${path}.origin.offset`);
66
+ for (const [axis, d] of Object.entries(o.offset && typeof o.offset === 'object' ? o.offset : {}))
67
+ measure(d, `${path}.origin.offset.${axis}`);
63
68
  only(o.align, JOINT_FIELDS.axes, `${path}.origin.align`);
64
69
  for (const [axis, a] of Object.entries(o.align && typeof o.align === 'object' ? o.align : {})) {
65
70
  only(a, JOINT_FIELDS.alignment, `${path}.origin.align.${axis}`);
66
71
  measure(a?.mm, `${path}.origin.align.${axis}.mm`);
67
72
  }
68
73
  }
74
+ only(j.axis, JOINT_FIELDS.axes, `${path}.axis`);
69
75
  if (j.travel && typeof j.travel === 'object') {
70
76
  only(j.travel, JOINT_FIELDS.travel, `${path}.travel`);
71
77
  source(j.travel.source, `${path}.travel.source`);
@@ -97,6 +103,61 @@ export function jointAbove(asset, part) {
97
103
  return asset.joints?.find(j => j.body1.part === part);
98
104
  }
99
105
  export const stateIdOf = (j) => j.state?.id ?? j.id;
106
+ /** Whether a joint is placed at its parent's origin rather than on a face (ADR-0107 decision 4). */
107
+ export const isOriginPlaced = (j) => !!j.body0 && 'at' in j.body0;
108
+ /** A record's axis as a unit vector when it is written as one; undefined for a token. Zero or not finite is refused. */
109
+ export function jointVectorOf(axis, path) {
110
+ if (!axis || typeof axis !== 'object')
111
+ return undefined;
112
+ const v = AXES.map(a => axis[a]);
113
+ if (!v.every(x => typeof x === 'number' && Number.isFinite(x)))
114
+ fail('JOINT_SCHEMA', `${path}.axis`, 'an axis vector is three finite numbers { x, y, z }');
115
+ const n = Math.hypot(...v);
116
+ if (!(n > 1e-9))
117
+ fail('JOINT_SCHEMA', `${path}.axis`, 'an axis vector is not zero');
118
+ return v.map(x => x / n);
119
+ }
120
+ const hasParentRotate = (j) => AXES.some(a => (j.origin?.parentRotate?.[a] ?? 0) !== 0);
121
+ /**
122
+ * The joint frame's own words (ADR-0107, contract 2026-10-03): the pivot, the offset and the parent end's rotation, and
123
+ * the ends placed by an origin. A joint at its parent's origin is placed by offset and parentRotate alone: a face's
124
+ * words are refused by name. A joint frame turned by other than quarter turns turns the child after it is lined up, so
125
+ * its sides are not level with an edge: min or max is refused with it (ADR-0107 Q-B, kept on the frame's side by
126
+ * ADR-0140 3.10).
127
+ */
128
+ function checkJointFrames(j, path) {
129
+ const o = j.origin ?? {};
130
+ const body0 = j.body0;
131
+ const body1 = j.body1;
132
+ if ('at' in body0 && body0.at !== 'origin')
133
+ fail('JOINT_SCHEMA', `${path}.body0`, `${String(body0.at)} is not a place on a part; at: 'origin' is`);
134
+ if ('at' in body1 && body1.at !== 'origin')
135
+ fail('JOINT_SCHEMA', `${path}.body1`, `${String(body1.at)} is not a place on a part; at: 'origin' is`);
136
+ if ('at' in body0 && ('face' in body0 || 'plane' in body0))
137
+ fail('JOINT_SCHEMA', `${path}.body0`, "a joint hangs at a part's origin or on a face, not both");
138
+ if (isOriginPlaced(j)) {
139
+ for (const key of ['align', 'gap', 'facing', 'childRotate', 'pivot'])
140
+ if (o[key] !== undefined)
141
+ fail('JOINT_SCHEMA', `${path}.origin.${key}`, `${key} places a part on a face; ${body0.part}'s origin is a point and a frame, placed by offset and parentRotate`);
142
+ }
143
+ else if ('at' in body1)
144
+ fail('JOINT_SCHEMA', `${path}.body1`, `a child is placed by its origin only at its parent's origin; ${'plane' in body0 ? 'the mounting plane' : `${body0.part}'s ${String(body0.face)}`} is a face`);
145
+ if (o.pivot !== undefined && !FIGURE_JOINT_PIVOTS.includes(o.pivot))
146
+ fail('JOINT_SCHEMA', `${path}.origin.pivot`, `${String(o.pivot)} is not a pivot; ${FIGURE_JOINT_PIVOTS.join(', ')}`);
147
+ if (o.pivot === 'child-face' && o.facing === 'centre')
148
+ fail('JOINT_SCHEMA', `${path}.origin.pivot`, `${body1.part} is fastened by its middle, not by a face; child-face has no face to turn about -- it turns about its middle already (child-centre)`);
149
+ if (o.parentRotate !== undefined) {
150
+ if (!o.parentRotate ||
151
+ typeof o.parentRotate !== 'object' ||
152
+ Object.values(o.parentRotate).some(v => typeof v !== 'number' || !Number.isFinite(v)))
153
+ fail('JOINT_SCHEMA', `${path}.origin.parentRotate`, 'a rotation is degrees about x, y and z');
154
+ const edges = AXES.filter(a => o.align?.[a] === 'min' || o.align?.[a] === 'max');
155
+ if (edges.length && !isQuarterMatrix(rpyMatrix(o.parentRotate)))
156
+ fail('JOINT_SCHEMA', `${path}.origin.parentRotate`, `the joint frame is turned by other than quarter turns; ${body1.part}'s sides are not level with the edges it is lined up with (${edges.join(', ')}) -- place it by the centre or a distance`);
157
+ }
158
+ if (o.offset !== undefined && (!o.offset || typeof o.offset !== 'object'))
159
+ fail('JOINT_SCHEMA', `${path}.origin.offset`, 'an offset is a length along x, y and z');
160
+ }
100
161
  /** A record that is well formed on its own, and fits the tree the others make. */
101
162
  function checkJoint(asset, j) {
102
163
  const m = asset.document.model;
@@ -113,6 +174,7 @@ function checkJoint(asset, j) {
113
174
  fail('JOINT_SCHEMA', path, 'a joint hangs from a part or the mounting plane (body0)');
114
175
  if ('part' in j.body0)
115
176
  partOf(m, j.body0.part, path);
177
+ checkJointFrames(j, path);
116
178
  const others = (asset.joints ?? []).filter(o => o.id !== j.id);
117
179
  const above = others.find(o => o.body1.part === j.body1.part);
118
180
  if (above)
@@ -128,8 +190,8 @@ function checkJoint(asset, j) {
128
190
  }
129
191
  const moving = j.type !== 'fixed';
130
192
  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`);
193
+ if (moving && !['X', 'Y', 'Z'].includes(j.axis) && !jointVectorOf(j.axis, path))
194
+ fail('JOINT_SCHEMA', path, `a ${j.type} joint has an axis, X, Y or Z, or a vector { x, y, z }`);
133
195
  if (!moving && (j.axis !== undefined || limited || j.mimic !== undefined || j.travel !== undefined))
134
196
  fail('JOINT_SCHEMA', path, 'a fixed joint has no axis, limits, mimic or travel');
135
197
  if (j.travel !== undefined && j.type !== 'prismatic')
@@ -176,11 +238,9 @@ function checkJoint(asset, j) {
176
238
  if (j.mimic.offset !== undefined && !Number.isFinite(j.mimic.offset))
177
239
  fail('JOINT_SCHEMA', path, "a mimic's offset is a number in the joint's unit");
178
240
  }
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");
241
+ if (j.origin?.childRotate !== undefined &&
242
+ (!j.origin.childRotate || typeof j.origin.childRotate !== 'object' || Object.values(j.origin.childRotate).some(t => !Number.isFinite(t))))
243
+ fail('JOINT_SCHEMA', `${path}.origin.childRotate`, 'a pose is degrees about x, y and z');
184
244
  /*
185
245
  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
246
  on every copy. A row inside a row is a grid; it runs along another axis than the rows around it.
@@ -210,9 +270,17 @@ function checkJoint(asset, j) {
210
270
  const row = `${path}.repeat`;
211
271
  if (!('part' in j.body0))
212
272
  fail('JOINT_SCHEMA', row, 'a row fills the part it is fastened to; the mounting plane has no length to fill');
273
+ /* A row runs across a face, from edge to edge: a frame moved, turned or placed at an origin is not covered yet. */
274
+ if (isOriginPlaced(j))
275
+ fail('JOINT_SCHEMA', row, 'a row runs across the face it is fastened to; a joint placed at an origin has none');
276
+ for (const key of ['offset', 'parentRotate'])
277
+ if (j.origin?.[key] !== undefined)
278
+ fail('JOINT_SCHEMA', row, `a row with its joint frame moved or turned (origin.${key}) is not covered yet`);
279
+ if ((j.origin?.pivot ?? 'seat') !== 'seat')
280
+ fail('JOINT_SCHEMA', row, `a row turning about ${j.origin.pivot} is not covered yet; its copies turn where they are fastened`);
213
281
  if (!['X', 'Y', 'Z'].includes(r.axis))
214
282
  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)])
283
+ if (r.axis.toLowerCase() === receivingAxisOf(asset.document.model, asset, j))
216
284
  fail('JOINT_SCHEMA', row, `a row runs across the face it is fastened to; ${r.axis} is out of that face`);
217
285
  if (typeof r.pitch === 'number' ? !(Number.isFinite(r.pitch) && r.pitch > 0) : !followsSomething(r.pitch))
218
286
  fail('GEOMETRY_DOMAIN', row, 'a row has a positive pitch, or one that follows a length');
@@ -232,75 +300,59 @@ function checkJoint(asset, j) {
232
300
  * are chosen the same way each time, so writing a record again gives the same nodes.
233
301
  */
234
302
  function writeJoint(asset, j) {
235
- const m = asset.document.model;
236
- const child = j.body1.part;
237
- const { pose } = partOf(m, child, child);
303
+ if (isOriginPlaced(j)) {
304
+ placeAtOrigin(asset, j);
305
+ return moveJoint(asset, j, IDENTITY);
306
+ }
238
307
  /*
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).
308
+ The record speaks in the parent's own frame (ADR-0140 3.11): its face's name, the axes of the alignment and of the
309
+ child's pose are the parent's, whatever way the parent stands -- the seat is written in that frame, so it follows
310
+ the parent as it turns or moves. The child meets the face by its real shape in the pose it is given.
242
311
  */
243
- const parent = 'part' in j.body0 ? j.body0.part : undefined;
312
+ placeOnFace(asset, j, { pivot: j.origin?.pivot, offset: j.origin?.offset, parentRotate: j.origin?.parentRotate, path: `joints.${j.id}` });
313
+ moveJoint(asset, j, IDENTITY);
314
+ }
315
+ /**
316
+ * A joint at its parent's origin (ADR-0107 decision 4, contract 2026-10-03): F_parent = T(δ)·Rpy in the parent's own
317
+ * frame, as URDF reads a joint's origin -- no standing turn put in, no face measured. The child stands by its own origin
318
+ * there (F_child = I), or by its centre (F_child = T(its centre)), unturned.
319
+ */
320
+ function placeAtOrigin(asset, j) {
321
+ const m = asset.document.model;
322
+ const child = j.body1.part;
323
+ const parent = j.body0.part;
244
324
  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);
325
+ const { pose } = partOf(m, child, child);
326
+ const noTurn = constantOf(m, 'deg', 0, 'a');
263
327
  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
- ];
328
+ pose.args = [...pose.args.slice(0, 3), noTurn, noTurn, noTurn];
268
329
  for (const ref of old)
269
330
  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`);
331
+ const name = namer(m, `${child}.on.${parent}`);
332
+ const zero = constantOf(m, 'mm', 0, 'd');
333
+ /* By its middle: the middle of its real extent (ADR-0140 3.4), from its origin -- or by its origin, nothing. */
334
+ const centre = (x) => {
335
+ if ('at' in j.body1)
336
+ return zero;
337
+ const along = (sign) => figureShapeExtremeAlong(m, child, AXES.map(a => (a === x ? sign : 0)), path);
338
+ return writeFigureReach(m, name, times(plus(along(1), times(along(-1), -1)), -0.5), `own-centre.${x}`);
339
+ };
340
+ const shift = j.origin?.offset ?? {};
341
+ const seat = AXES.map(x => shift[x] === undefined ? zero : measureRef(m, asset, name, shift[x], `offset.${x}`, `${path}.origin.offset.${x}`));
342
+ reseat(asset, child, `${parent}.local`, { seat, pose: AXES.map(centre) });
343
+ if (hasParentRotate(j))
344
+ turnSeat(asset, child, rpyMatrix(j.origin.parentRotate), j.origin.parentRotate, IDENTITY);
345
+ }
346
+ /**
347
+ * A moving joint's motion, at the joint frame, about or along its axis turned into the parent's frame by `back`: a token
348
+ * is one of the parent's axes, either way along it (a parent standing twisted refuses one that is not); a vector goes
349
+ * as it is, as its unit vector (URDF's axis). A row is laid out last, once its part's motion is written.
350
+ */
351
+ function moveJoint(asset, j, back) {
352
+ const m = asset.document.model;
353
+ const child = j.body1.part;
354
+ const parent = 'part' in j.body0 ? j.body0.part : undefined;
355
+ const path = `joints.${j.id}`;
304
356
  /* A row is laid out last, once its part's motion is written: every copy moves on it (a roller row spinning). */
305
357
  if (j.type === 'fixed') {
306
358
  if (j.repeat)
@@ -319,10 +371,17 @@ function writeJoint(asset, j) {
319
371
  k: t.multiplier
320
372
  }));
321
373
  /* 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);
374
+ const vector = jointVectorOf(j.axis, path);
375
+ const along = mv3(back, vector ?? AXES.map(a => (a === j.axis.toLowerCase() ? 1 : 0)));
376
+ const axisIndex = vector ? 0 : along.findIndex(v => Math.abs(Math.abs(v) - 1) < 1e-9);
324
377
  if (axisIndex < 0)
325
378
  fail('JOINT_SCHEMA', path, `${parent} stands twisted; the axis ${j.axis} is not level with an axis in it`);
379
+ /* Twelve places, as a twist's factors: the same record writes the same constants however the vector was turned. */
380
+ const direction = vector
381
+ ? along.map(v => (Math.abs(v) < 1e-12 ? 0 : Math.round(v * 1e12) / 1e12))
382
+ : along[axisIndex] > 0
383
+ ? 1
384
+ : -1;
326
385
  addMotion(asset, {
327
386
  kind: 'add-motion',
328
387
  part: child,
@@ -353,7 +412,7 @@ function writeJoint(asset, j) {
353
412
  c: j.mimic.offset ?? 0,
354
413
  sum: Object.hasOwn(j.mimic, 'sum')
355
414
  }
356
- : undefined, along[axisIndex] > 0 ? 1 : -1);
415
+ : undefined, direction);
357
416
  if (j.repeat)
358
417
  repeatPart(asset, j);
359
418
  }
@@ -372,21 +431,20 @@ function repeatPart(asset, j) {
372
431
  const theirs = ownSpansOf(m, parent, path)[axis];
373
432
  if (!theirs)
374
433
  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`);
434
+ /* The part's reach both ways along the row, in its pose (ADR-0140 3.2): its extent, and its middle from its origin. */
435
+ const C = rpyMatrix(j.origin?.childRotate ?? {});
436
+ const along = (sign) => figureShapeExtremeAlong(m, part, mv3(transpose3(C), AXES.map(a => (a === axis ? sign : 0))), path);
437
+ const [ahead, behind] = [along(1), along(-1)];
380
438
  const { place, pose } = partOf(m, part, path);
381
439
  const seat = nodeById(m, `${part}.seat.rigid`);
382
440
  const name = namer(m, `${part}.row`);
383
441
  const zero = constantOf(m, 'mm', 0, 'd');
384
442
  /* The seat moves to the row's start; each copy's centre is where the layout puts it from there. */
385
443
  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');
444
+ const own = writeFigureReach(m, name, times(plus(ahead, times(behind, -1)), -0.5), 'own-centre');
387
445
  pose.args = pose.args.map((ref, i) => (i === k ? own : ref));
388
446
  const length = sumOf(m, name, [...theirs.hi, ...neg(theirs.lo)], 'length');
389
- const extent = widest ?? sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
447
+ const extent = writeFigureReach(m, name, plus(ahead, behind), 'extent');
390
448
  const centred = constantOf(m, 'ratio', 0.5, 'k');
391
449
  const limit = constantOf(m, 'count', REPEAT_LIMIT, 'limit');
392
450
  const count = j.repeat.count;
@@ -512,8 +570,9 @@ export function stripJoint(asset, j) {
512
570
  }
513
571
  /**
514
572
  * 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.
573
+ * gap, a row's pitch, a slide's travel (its one-term form names the source `source`, and without one is `plus` mm),
574
+ * each offset across the face and each of the joint frame's offset (ADR-0107). Renaming a part and taking one away
575
+ * read and write them all alike.
517
576
  */
518
577
  export function jointMeasures(j) {
519
578
  const out = [];
@@ -557,6 +616,9 @@ export function jointMeasures(j) {
557
616
  set: v => (how.mm = v)
558
617
  });
559
618
  }
619
+ for (const axis of Object.keys(j.origin?.offset ?? {}))
620
+ if (j.origin.offset[axis] !== undefined)
621
+ out.push({ at: `offset.${axis}`, get: () => j.origin.offset[axis], set: v => (j.origin.offset[axis] = v) });
560
622
  return out;
561
623
  }
562
624
  /** The same measure with each term's source put through `to`, in the form it was written. */
@@ -610,11 +672,23 @@ export function rewriteJoints(asset) {
610
672
  for (const n of asset.document.model.nodes)
611
673
  if (n.op === 'member@1' && !nodeById(asset.document.model, `${n.id}.repeat`))
612
674
  n.op = 'place@1';
675
+ /*
676
+ What the joints' nodes read before they go: a reach reads a shape's own values -- a pyramid's apex, a drawn
677
+ section's corners -- and an edit that took one away left it while the old nodes still read it. Once the joints are
678
+ written again, a part's own input nothing reads any more goes too.
679
+ */
680
+ const m = asset.document.model;
681
+ const read = new Set(m.nodes.filter(n => joints.some(j => n.id.startsWith(`${j.body1.part}.on.`))).flatMap(n => n.args));
613
682
  for (const j of [...order].reverse())
614
683
  stripJoint(asset, j);
615
684
  for (const j of order)
616
685
  writeJoint(asset, j);
617
686
  gatherRows(asset, joints);
687
+ for (const ref of read) {
688
+ const owner = m.nodes.find(n => n.op === 'place@1' && ref.startsWith(`${n.id}.`))?.id;
689
+ if (owner && ownInput(m, owner, ref))
690
+ dropIfUnused(asset, ref, ref);
691
+ }
618
692
  // A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
619
693
  for (const j of order)
620
694
  if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
@@ -625,20 +699,14 @@ export function setJoint(asset, joint) {
625
699
  // As JSON: a field left undefined is a field not there, which is how the asset stores it.
626
700
  const j = JSON.parse(JSON.stringify(joint ?? null));
627
701
  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
- };
702
+ /*
703
+ The child's pose now, in the parent's frame, becomes the record's childRotate the first time it is fastened -- by a
704
+ face, at any angle (ADR-0140 3.6): it keeps the pose it has. At an origin it stands unturned.
705
+ */
706
+ if (!jointAbove(asset, j.body1.part) && !j.origin?.childRotate && !isOriginPlaced(j)) {
707
+ const pose = childRotateOf(asset, j.body1.part, j.body0);
708
+ if (pose)
709
+ j.origin = { ...(j.origin ?? {}), childRotate: pose };
642
710
  }
643
711
  const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
644
712
  const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === j.id));
@@ -676,10 +744,20 @@ export function applyFaceRemap(asset, part, remap) {
676
744
  continue;
677
745
  }
678
746
  const face = faceOf(r.to, path);
679
- if (j.body0?.part === part)
680
- j.body0.face = face;
681
- else
682
- j.body1.face = face;
747
+ /*
748
+ An end placed by an origin is on no face, so there is no face to move it to (ADR-0107 decision 4): refused by
749
+ name, never skipped -- the caller shows it. At a parent's origin the child is placed by its origin or centre, so
750
+ a face for the child is refused here too, not later by the joint's own check under another path.
751
+ */
752
+ /* The child end is on no face of its own (ADR-0140 3.2): only the end it hangs from has one to move to. */
753
+ if (j.body0?.part !== part)
754
+ fail('EDIT_TARGET', path, `${r.ref.id} fastens ${part} by no face of ${part}'s own; only the end it hangs from, on ${'part' in j.body0 ? j.body0.part : 'the plane'}, is on a face`);
755
+ const end = j.body0;
756
+ if ('at' in end)
757
+ fail('EDIT_TARGET', path, end === j.body0
758
+ ? `${r.ref.id} hangs at ${part}'s origin, not on a face of it`
759
+ : `${part} is placed by its own origin on ${r.ref.id}, not by a face of it`);
760
+ end.face = face;
683
761
  faces.add(face);
684
762
  }
685
763
  else if (r?.ref?.kind === 'feature') {
@@ -731,6 +809,33 @@ export function removeJoint(asset, id) {
731
809
  // Parts hanging from it are written again: they hang from where it stands now.
732
810
  rewriteJoints(asset);
733
811
  }
812
+ /**
813
+ * A part's pose now, in the frame of what it would hang from -- the parent's own frame, or the plane's, after the joint
814
+ * frame's own turn (`parentRotate`) -- as degrees about x, y and z (Rz·Ry·Rx), six places: nothing when it stands
815
+ * square to it. Read from the figure as drawn at rest: every joint at 0, its reference pose.
816
+ */
817
+ function childRotateOf(asset, part, body0, parentRotate) {
818
+ const rest = Object.fromEntries((asset.joints ?? []).filter(j => j.type !== 'fixed' && !j.mimic).map(j => [stateIdOf(j), 0]));
819
+ const compiled = compileFigureAssetUnjointed(asset);
820
+ /* A control whose range does not hold 0 is read where it stands: its reference is still 0, but it cannot be drawn there. */
821
+ const geometry = (() => {
822
+ try {
823
+ return compiled.evaluate(rest).geometry;
824
+ }
825
+ catch {
826
+ return compiled.evaluate().geometry;
827
+ }
828
+ })();
829
+ const of = (id) => geometry.find(g => g.id === id && g.pose)?.pose?.r;
830
+ const mine = of(part);
831
+ if (!mine)
832
+ return undefined;
833
+ const theirs = body0 && 'part' in body0 ? of(body0.part) : IDENTITY;
834
+ if (!theirs)
835
+ return undefined;
836
+ const pose = rpyOf(mul3(transpose3(mul3(theirs, rpyMatrix(parentRotate ?? {}))), mine));
837
+ return Object.keys(pose).length ? pose : undefined;
838
+ }
734
839
  /** `attach` is the modeller's word for a fixed joint: the record says it, whatever joint was there keeps its id and motion. */
735
840
  export function fastenAsJoint(asset, a) {
736
841
  const was = jointAbove(asset, a.part);
@@ -745,19 +850,19 @@ export function fastenAsJoint(asset, a) {
745
850
  let id = a.part;
746
851
  for (let k = 2; !was && taken.has(id); k++)
747
852
  id = `${a.part}-${k}`;
853
+ const body0 = to?.plane !== undefined ? { plane: to.plane } : { part: to?.part, face: to?.face };
854
+ /* Its pose now, in the new parent's frame (ADR-0140 3.6): it is fastened as it stands. */
855
+ const pose = childRotateOf(asset, a.part, body0, was?.origin?.parentRotate);
748
856
  setJoint(asset, {
749
857
  ...(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 },
858
+ body0,
859
+ body1: { part: a.part },
752
860
  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
- : {}),
861
+ ...(pose ? { childRotate: pose } : {}),
862
+ /* The joint frame's own words stay the joint's, as its motion does. */
863
+ ...(was?.origin?.pivot ? { pivot: was.origin.pivot } : {}),
864
+ ...(was?.origin?.offset ? { offset: was.origin.offset } : {}),
865
+ ...(was?.origin?.parentRotate ? { parentRotate: was.origin.parentRotate } : {}),
761
866
  ...(a.facing ? { facing: a.facing } : {}),
762
867
  ...(a.gap !== undefined ? { gap: a.gap } : {}),
763
868
  ...(a.align ? { align: a.align } : {})