@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
@@ -1,12 +1,57 @@
1
1
  import { type Axis } from './words.ts';
2
- import { type FigureAuthoringAction, type FigureMotionRequest } from './authoring-types.ts';
2
+ import { type FigureJoint, type FigureJointPivot, type FigureMeasure, type FigureMotionRequest } from './authoring-types.ts';
3
3
  type Dynamic = any;
4
- export declare function attach(asset: Dynamic, a: Extract<FigureAuthoringAction, {
5
- kind: 'attach';
6
- }>, twist?: {
7
- axis: Axis;
8
- deg: number;
9
- }): Dynamic;
4
+ /**
5
+ * A joint frame's own words, as `writeJoint` hands them on (ADR-0107, contract 2026-10-03): the pivot, the offset and
6
+ * the parent end's rotation.
7
+ */
8
+ export interface JointFrame {
9
+ pivot?: FigureJointPivot;
10
+ offset?: Partial<Record<Axis, FigureMeasure>>;
11
+ parentRotate?: Partial<Record<Axis, number>>;
12
+ path: string;
13
+ }
14
+ /**
15
+ * A part fastened, in the pose `childRotate` gives it, to a face of its parent or to the mounting plane (ADR-0140 3):
16
+ * its real shape meets the face -- by its outermost point toward it (`meet`), away from it (`flush`), or their middle
17
+ * (`centre`) -- and its real extent lines up across it. Everything is said in the parent's own frame (3.11), and
18
+ * written as graph nodes of the shapes' own values (3.3), so it holds as either is resized.
19
+ *
20
+ * Along the face's outward normal n (a coordinate c, the face at c_f, σ its sign on its axis):
21
+ * meet c = c_f + σ·(gap + e(−n)) flush c = c_f − σ·(gap + e(+n))
22
+ * centre c = c_f + σ·(gap + (e(−n) − e(+n)) / 2)
23
+ * and across it on each axis u, by the reaches e(±u):
24
+ * centre c = mid + mm − (e(+u) − e(−u)) / 2 min c = lo + e(−u) max c = hi − e(+u)
25
+ * where e(d) is how far the part reaches along d in its pose (figureShapeExtremeAlong). The seat -- the point it turns
26
+ * about -- is the point it is lined up by, on the face (3.10): the edge it is level with -- a hinge (ADR-0107 decision 1)
27
+ * -- or the middle of its real extent; or its pivot. The pose is the part's origin from there.
28
+ */
29
+ export declare function placeOnFace(asset: Dynamic, j: FigureJoint, frame: JointFrame): void;
30
+ /**
31
+ * The seat turned by a joint frame's parent-end rotation (`rigid@1` turns X, then Y, then Z). Its angles are named by
32
+ * the rotation the record gives, and by the parent's standing turn when that is not none -- not by their value, which
33
+ * carries float noise -- and rounded to twelve places, as a twist's factors are (JOINT_DRIFT, 2026-09-25).
34
+ */
35
+ export declare function turnSeat(asset: Dynamic, part: string, r: number[][], rotate: Partial<Record<Axis, number>>, back: number[][]): void;
36
+ /**
37
+ * Put a part's pose on a new frame with a new translation, and rebuild the chain that carries it to the asset.
38
+ * The part's own id, its shape and its `placed` reference all stay as they were.
39
+ */
40
+ /**
41
+ * Where a part is fastened, in two translations that are never merged: `seat` is the point it is fastened at, in the
42
+ * frame it is fastened to -- a point on the target's face; `pose` is the part's centre from that point, in its own
43
+ * seat frame. A motion sits between the two, so a turn turns about the point the part is fastened at: a hinge, a
44
+ * joint, a lid (chief architect's ruling 2026-09-25; before this the seat was the target's centre and a fastened
45
+ * part swung about the middle of what it was fastened to).
46
+ *
47
+ * <id>.local --pose--> <id>.seat [--motion--> <id>.joint] --seat.rigid--> <to>
48
+ *
49
+ * Without `place` the part keeps both translations and only its frames are rewired (a motion added or taken away).
50
+ */
51
+ export declare function reseat(asset: Dynamic, id: string, to: string, place?: {
52
+ seat: string[];
53
+ pose: string[];
54
+ }): void;
10
55
  export declare function addMotion(asset: Dynamic, a: FigureMotionRequest, follow?: {
11
56
  terms: Array<{
12
57
  of: string;
@@ -14,7 +59,7 @@ export declare function addMotion(asset: Dynamic, a: FigureMotionRequest, follow
14
59
  }>;
15
60
  c: number;
16
61
  sum: boolean;
17
- }, sign?: 1 | -1): Dynamic;
62
+ }, sign?: 1 | -1 | number[]): Dynamic;
18
63
  /** "This part does not move any more." The control it created goes with it, and so does its clip. */
19
64
  export declare function removeMotion(asset: Dynamic, a: {
20
65
  kind: 'remove-motion';
@@ -1 +1 @@
1
- {"version":3,"file":"authoring-fastening.d.ts","sourceRoot":"","sources":["../src/authoring-fastening.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,KAAK,IAAI,EAAQ,MAAM,YAAY,CAAA;AAC5C,OAAO,EAKL,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACzB,MAAM,sBAAsB,CAAA;AAgC7B,KAAK,OAAO,GAAG,GAAG,CAAA;AAElB,wBAAgB,MAAM,CACpB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,OAAO,CAAC,qBAAqB,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,EACrD,KAAK,CAAC,EAAE;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC,OAAO,CA+NT;AAkLD,wBAAgB,SAAS,CACvB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,mBAAmB,EACtB,MAAM,CAAC,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,EAC7E,IAAI,GAAE,CAAC,GAAG,CAAC,CAAK,GACf,OAAO,CA8KT;AAED,qGAAqG;AACrG,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAsBhG"}
1
+ {"version":3,"file":"authoring-fastening.d.ts","sourceRoot":"","sources":["../src/authoring-fastening.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,KAAK,IAAI,EAAQ,MAAM,YAAY,CAAA;AAC5C,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACzB,MAAM,sBAAsB,CAAA;AAkC7B,KAAK,OAAO,GAAG,GAAG,CAAA;AAElB;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,gBAAgB,CAAA;IACxB,MAAM,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;IAC7C,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAA;IAC5C,IAAI,EAAE,MAAM,CAAA;CACb;AAKD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,WAAW,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CA2GnF;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,OAAO,EACd,IAAI,EAAE,MAAM,EACZ,CAAC,EAAE,MAAM,EAAE,EAAE,EACb,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,EACrC,IAAI,EAAE,MAAM,EAAE,EAAE,QAkBjB;AAED;;;GAGG;AACH;;;;;;;;;;GAUG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAuE/G;AAQD,wBAAgB,SAAS,CACvB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,mBAAmB,EACtB,MAAM,CAAC,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,EAC7E,IAAI,GAAE,CAAC,GAAG,CAAC,CAAC,GAAG,MAAM,EAAM,GAC1B,OAAO,CAiLT;AAED,qGAAqG;AACrG,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAsBhG"}
@@ -7,257 +7,170 @@
7
7
  */
8
8
  import { figurePlacementOf } from "./asset.js";
9
9
  import { AXES } from "./words.js";
10
- import { FIGURE_FACES, SIGN } from "./authoring-types.js";
10
+ import { FIGURE_FACINGS } from "./authoring-types.js";
11
+ import { figureShapeExtremeAlong, lin, plus, times, writeFigureReach } from "./shape-extreme.js";
11
12
  import { fail, nodeById, writerOf, partOf, namer, constantOf, scaled, measureRef, termRefs, sumOf, sourceRef, ownInput, dropIfUnused, parentOf, round6, sizeInputOf } from "./authoring-nodes.js";
12
- import { neg, half, facesOfPart, planeOf, ownSpansOf, standingSpansOf, planeFacesOf, ACROSS } from "./authoring-geometry.js";
13
- export function attach(asset, a, twist) {
13
+ import { half, facesOfPart, planeOf, ownSpansOf, transpose3, isIdentity, eulerOfAny, rpyMatrix, mv3, IDENTITY } from "./authoring-geometry.js";
14
+ const unitAlong = (axis, sign) => AXES.map(x => (x === axis ? sign : 0));
15
+ /**
16
+ * A part fastened, in the pose `childRotate` gives it, to a face of its parent or to the mounting plane (ADR-0140 3):
17
+ * its real shape meets the face -- by its outermost point toward it (`meet`), away from it (`flush`), or their middle
18
+ * (`centre`) -- and its real extent lines up across it. Everything is said in the parent's own frame (3.11), and
19
+ * written as graph nodes of the shapes' own values (3.3), so it holds as either is resized.
20
+ *
21
+ * Along the face's outward normal n (a coordinate c, the face at c_f, σ its sign on its axis):
22
+ * meet c = c_f + σ·(gap + e(−n)) flush c = c_f − σ·(gap + e(+n))
23
+ * centre c = c_f + σ·(gap + (e(−n) − e(+n)) / 2)
24
+ * and across it on each axis u, by the reaches e(±u):
25
+ * centre c = mid + mm − (e(+u) − e(−u)) / 2 min c = lo + e(−u) max c = hi − e(+u)
26
+ * where e(d) is how far the part reaches along d in its pose (figureShapeExtremeAlong). The seat -- the point it turns
27
+ * about -- is the point it is lined up by, on the face (3.10): the edge it is level with -- a hinge (ADR-0107 decision 1)
28
+ * -- or the middle of its real extent; or its pivot. The pose is the part's origin from there.
29
+ */
30
+ export function placeOnFace(asset, j, frame) {
14
31
  const m = asset.document.model;
15
- const to = a.to;
16
- const path = `${a.part} to ${to?.plane ?? to?.part}`;
17
- if (!to || typeof to !== 'object')
18
- fail('SCHEMA', path, 'a target face, or the plane the figure is mounted on, is required');
19
- if (to.plane !== undefined)
20
- return standOnPlane(asset, a, path);
21
- if (a.part === to.part)
22
- fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
23
- const byCentre = a.face === 'centre';
24
- if (!byCentre && !Object.hasOwn(FIGURE_FACES, String(a.face)))
25
- fail('SCHEMA', path, `${String(a.face)} is not a face`);
26
- /* The target's faces are the ones its shape declares: a portal's opening as well as its outer faces. */
27
- const target = facesOfPart(m, to.part, path);
28
- const theirFace = Object.hasOwn(target.faces, String(to.face)) ? target.faces[to.face] : undefined;
29
- if (!theirFace)
30
- fail('SCHEMA', path, `${to.part} has no face ${String(to.face)}; it has ${Object.keys(target.faces).join(', ')}`);
31
- const facing = a.facing ?? 'meet';
32
- if (facing !== 'meet' && facing !== 'flush')
33
- fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
34
- const axis = theirFace.axis;
35
- const sign = theirFace.normal;
36
- if (byCentre && facing !== 'meet')
37
- fail('ATTACH_FACE', path, `${a.part} fastened by its centre sits on ${to.face}; flush has no meaning for a centre`);
38
- /* By its centre, the part is measured as the face that would meet the target's; only its offset differs. */
39
- if (byCentre)
40
- a = {
41
- ...a,
42
- face: Object.keys(FIGURE_FACES).find(f => FIGURE_FACES[f] === axis && SIGN[f] === -sign)
43
- };
44
- if (FIGURE_FACES[a.face] !== axis)
45
- fail('ATTACH_FACE', path, `${a.face} faces along ${FIGURE_FACES[a.face]} and ${to.face} along ${axis}; fastening them would need a turn, which this command does not do`);
46
- const wanted = Object.keys(FIGURE_FACES).find(f => FIGURE_FACES[f] === axis && SIGN[f] === (facing === 'meet' ? -sign : sign));
47
- if (facing === 'meet' && a.face !== wanted)
48
- fail('ATTACH_FACE', path, `to meet ${to.face}, ${a.part} offers its ${wanted}; pass flush to lay ${a.face} in the same plane instead`);
49
- if (facing === 'flush' && a.face !== wanted)
50
- fail('ATTACH_FACE', path, `to lie flush with ${to.face}, ${a.part} offers its ${wanted}`);
51
- if (parentOf(m, a.part) && !a.replace)
52
- fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to move it, or detach it first`);
53
- // A part cannot end up its own ancestor.
54
- for (let up = to.part; up; up = parentOf(m, up))
55
- if (up === a.part)
56
- fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
57
- /*
58
- Measured in the target's own frame, where its faces are the planes its shape declares: the seat is written into
59
- that frame. A target standing turned is met there too -- the joint record's words are turned into that frame
60
- before this runs (`writeJoint`).
61
- */
62
- const theirs = ownSpansOf(m, to.part, path);
63
- const mine = standingSpansOf(m, asset, a.part, path);
64
- const mineAlong = mine[axis];
65
- if ((!mineAlong || !Object.hasOwn(facesOfPart(m, a.part, path).faces, a.face)) && !byCentre)
66
- fail('EDIT_TARGET', path, `${a.part} has no flat ${a.face} face`);
67
- const name = namer(m, `${a.part}.on.${to.part}`);
68
- /*
69
- Along the face's own axis. `meet`: the part's centre is half its own depth past the target's face, plus the
70
- gap. `flush`: the part's centre is half its own depth back from that face, so its named face lies in it.
71
- */
72
- const outward = facing === 'meet' ? sign : -sign;
73
- const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
74
- /*
75
- Split at the point fastened at: the seat is on the target's face (its centre, or level with an edge), the part's
76
- centre is half its own depth and the gap past it. A turn added later turns about that point.
77
- */
78
- const zero = constantOf(m, 'mm', 0, 'd');
79
- const translation = { x: '', y: '', z: '' };
80
- const offset = { x: zero, y: zero, z: zero };
81
- /* By its centre, the gap goes into the seat: the point fastened at -- and turned about -- is the part's middle. */
82
- translation[axis] = sumOf(m, name, [...planeOf(target.shape, theirFace), ...(byCentre ? [{ ref: gap, k: outward }] : [])], `seat.${axis}`);
83
- const ownFace = byCentre
84
- ? mineAlong && !mineAlong.centred
85
- ? [...half(mineAlong.lo), ...half(mineAlong.hi)]
86
- : []
87
- : SIGN[a.face] > 0
88
- ? mineAlong.hi
89
- : mineAlong.lo;
90
- offset[axis] = byCentre
91
- ? ownFace.length
92
- ? sumOf(m, name, neg(ownFace), `along.${axis}`)
93
- : zero
94
- : sumOf(m, name, [...neg(ownFace), { ref: gap, k: outward }], `along.${axis}`);
95
- /*
96
- Across the face. Centred by default; min and max keep the two parts' edges level as either is resized -- and the
97
- point fastened at is where those edges meet: the seat on the target's edge, the part's own edge on it. A door
98
- fastened level with the frame's min edge turns about that edge, as a hinge does (2026-09-25); centred, the point
99
- is the middle of the face. A shape not centred on its origin (a portal stands on its bottom) is centred by its
100
- faces, not by its origin.
101
- */
102
- for (const other of AXES.filter(x => x !== axis)) {
103
- const how = a.align?.[other] ?? 'centre';
104
- const t = theirs[other], o = mine[other];
105
- const middle = (span) => (span && !span.centred ? [...half(span.lo), ...half(span.hi)] : []);
106
- const centreOf = (span, hint) => middle(span).length ? sumOf(m, name, middle(span), hint) : constantOf(m, 'mm', 0, 'd');
107
- const ownCentre = () => (middle(o).length ? sumOf(m, name, neg(middle(o)), `own-centre.${other}`) : zero);
108
- if (how === 'centre') {
109
- translation[other] = centreOf(t, `centre.${other}`);
110
- offset[other] = ownCentre();
111
- }
112
- else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm')) {
113
- const mm = measureRef(m, asset, name, how.mm, `align.${other}`, path);
114
- translation[other] = middle(t).length
115
- ? sumOf(m, name, [...middle(t), { ref: mm, k: 1 }], `align.${other}.from-centre`)
116
- : mm;
117
- offset[other] = ownCentre();
32
+ const child = j.body1.part;
33
+ const path = frame.path;
34
+ const to = j.body0;
35
+ const o = j.origin ?? {};
36
+ const facing = o.facing ?? 'meet';
37
+ if (!FIGURE_FACINGS.includes(facing))
38
+ fail('JOINT_SCHEMA', `${path}.origin.facing`, `${String(facing)} is not a facing; ${FIGURE_FACINGS.join(', ')}`);
39
+ const onPlane = 'plane' in to;
40
+ const name = namer(m, `${child}.on.${onPlane ? 'plane' : to.part}`);
41
+ /* The face: its axis, its outward sign and where it lies, in the parent's own frame; across it, the parent's edges. */
42
+ let axis, sign, plane;
43
+ const edges = {};
44
+ if (onPlane) {
45
+ const placement = figurePlacementOf(asset);
46
+ axis = placement === 'wall' ? 'z' : 'y';
47
+ sign = 1;
48
+ plane = [];
49
+ if (placement === 'wall' && facing !== 'meet')
50
+ fail('ATTACH_FACE', path, `the figure hangs on a wall; ${child} stands out of it (meet), not ${facing}`);
51
+ if (facing === 'centre')
52
+ fail('JOINT_SCHEMA', `${path}.origin.facing`, 'a part stands on the mounting plane or hangs from it; its middle is not on it');
53
+ for (const x of AXES.filter(x => x !== axis))
54
+ if (sizeInputOf(m, x)) {
55
+ const size = sourceRef(m, asset, { of: 'instance', axis: x }, path);
56
+ edges[x] = { lo: [{ ref: size, k: -0.5 }], hi: [{ ref: size, k: 0.5 }] };
57
+ }
58
+ }
59
+ else {
60
+ if (to.part === child)
61
+ fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
62
+ const target = facesOfPart(m, to.part, path);
63
+ const face = Object.hasOwn(target.faces, String(to.face)) ? target.faces[to.face] : undefined;
64
+ if (!face)
65
+ fail('SCHEMA', path, `${to.part} has no face ${String(to.face)}; it has ${Object.keys(target.faces).join(', ')}`);
66
+ axis = face.axis;
67
+ sign = face.normal;
68
+ plane = planeOf(target.shape, face);
69
+ const spans = ownSpansOf(m, to.part, path);
70
+ for (const x of AXES.filter(x => x !== axis))
71
+ if (spans[x])
72
+ edges[x] = { lo: spans[x].lo, hi: spans[x].hi };
73
+ }
74
+ /* The child's pose in the parent's frame, and how far its real shape reaches along a direction there. */
75
+ const rotate = o.childRotate ?? {};
76
+ const C = rpyMatrix(rotate);
77
+ const reach = (d) => figureShapeExtremeAlong(m, child, mv3(transpose3(C), d), path);
78
+ const gap = o.gap === undefined ? lin([]) : lin([{ ref: measureRef(m, asset, name, o.gap, `gap.${axis}`, path), k: 1 }]);
79
+ const [toward, away] = [reach(unitAlong(axis, -sign)), reach(unitAlong(axis, sign))];
80
+ const at = {};
81
+ const seat = {};
82
+ const face = lin(plane);
83
+ at[axis] =
84
+ facing === 'meet'
85
+ ? plus(face, times(plus(gap, toward), sign))
86
+ : facing === 'flush'
87
+ ? plus(face, times(plus(gap, away), -sign))
88
+ : plus(face, times(plus(gap, times(plus(toward, times(away, -1)), 0.5)), sign));
89
+ /* On the face; by its middle (centre), at that middle, gap and all -- it turns in place, a roller spinning. */
90
+ seat[axis] = facing === 'centre' ? plus(at[axis], times(plus(reach(unitAlong(axis, 1)), times(reach(unitAlong(axis, -1)), -1)), 0.5)) : face;
91
+ for (const u of AXES.filter(x => x !== axis)) {
92
+ const [hi, lo] = [reach(unitAlong(u, 1)), reach(unitAlong(u, -1))];
93
+ /* The middle of its real extent, from its origin: (e(+u) − e(−u)) / 2. */
94
+ const middle = times(plus(hi, times(lo, -1)), 0.5);
95
+ const how = o.align?.[u] ?? 'centre';
96
+ const edge = edges[u];
97
+ if (how === 'min' || how === 'max') {
98
+ if (!edge)
99
+ fail(onPlane ? 'SCHEMA' : 'ATTACH_FACE', path, onPlane ? `the plane has no edges to line ${child} up with on ${u}; declare the figure's size, or give the centre or a measured offset` : `${to.part} has no edges along ${u} to line ${child} up with; give the centre or a measured offset`);
100
+ at[u] = how === 'min' ? plus(lin(edge.lo), lo) : plus(lin(edge.hi), times(hi, -1));
101
+ /* Level with an edge, it turns about that edge, as a hinge does (ADR-0107 decision 1). */
102
+ seat[u] = lin(how === 'min' ? edge.lo : edge.hi);
103
+ continue;
118
104
  }
119
- else if (how === 'min' || how === 'max') {
120
- if (!t)
121
- fail('ATTACH_FACE', path, `${to.part} has no edges along ${other} to line ${a.part} up with; give the centre or a measured offset`);
122
- if (!o)
123
- fail('ATTACH_FACE', path, `${a.part} has no edges along ${other} to line up; give the centre or a measured offset`);
124
- translation[other] = sumOf(m, name, how === 'min' ? t.lo : t.hi, `edge.${other}`);
125
- offset[other] = sumOf(m, name, neg(how === 'min' ? o.lo : o.hi), `own-edge.${other}`);
105
+ else if (how === 'centre' || (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))) {
106
+ const mid = edge ? lin([...half(edge.lo), ...half(edge.hi)]) : lin([]);
107
+ const off = typeof how === 'object' ? lin([{ ref: measureRef(m, asset, name, how.mm, `align.${u}`, path), k: 1 }]) : lin([]);
108
+ at[u] = plus(mid, off, times(middle, -1));
126
109
  }
127
110
  else
128
111
  fail('SCHEMA', path, `${String(how)} is not an alignment`);
112
+ seat[u] = plus(at[u], middle);
129
113
  }
130
- /*
131
- Twisted about the face's normal: the seat is the face's centre, and the part's own offset across the face -- its
132
- distance and its own centre -- turns with it, so a blade set out along X and twisted 120° stands out along the
133
- blade, about the hub. With no twist this is the same place written as before.
134
- */
135
- if (twist && twist.deg) {
136
- const [p, q] = ACROSS[axis];
137
- for (const other of [p, q]) {
138
- const how = a.align?.[other] ?? 'centre';
139
- if (how === 'min' || how === 'max')
140
- fail('ATTACH_FACE', path, `${a.part} is twisted ${round6(twist.deg)}° about ${axis}; its sides are not level with ${to.part}'s edges -- place it by the centre or a distance`);
141
- }
142
- const across = (other) => {
143
- const how = a.align?.[other] ?? 'centre';
144
- const o = mine[other];
145
- const own = o && !o.centred ? neg([...half(o.lo), ...half(o.hi)]) : [];
146
- return typeof how === 'object' && how && Object.hasOwn(how, 'mm')
147
- ? [{ ref: measureRef(m, asset, name, how.mm, `twist.${other}`, path), k: 1 }, ...own]
148
- : own;
149
- };
150
- const [vp, vq] = [across(p), across(q)];
151
- /*
152
- The turn's factors are named by the angle, as the record gives it -- not by their value, which may carry float
153
- noise that depends on how it was computed, so a rewrite named them apart (JOINT_DRIFT, the architect's fan,
154
- 2026-09-25). Their values are rounded to twelve places, which is the second defence.
155
- */
156
- const exact = (v) => {
157
- const r = Math.round(v * 1e12) / 1e12;
158
- return r === 0 ? 0 : r;
159
- };
160
- const tag = String(round6(twist.deg)).replace('-', 'm').replace('.', '_');
161
- const factor = (fn) => {
162
- const id = `twist.${fn}.${tag}`;
163
- const rad = (twist.deg * Math.PI) / 180;
164
- if (!m.constants.some((c) => c.id === id))
165
- m.constants.push({
166
- id,
167
- unit: 'ratio',
168
- value: exact(fn === 'cos' ? Math.cos(rad) : fn === 'sin' ? Math.sin(rad) : -Math.sin(rad))
169
- });
170
- return id;
171
- };
172
- const times = (ref, k, hint) => {
173
- const id = name(hint);
174
- m.nodes.push({ id, op: 'mul@1', args: [ref, k], outputs: { value: `${id}.value` } });
175
- return `${id}.value`;
176
- };
177
- const plus = (a, b, hint) => {
178
- const id = name(hint);
179
- m.nodes.push({ id, op: 'add@1', args: [a, b], outputs: { value: `${id}.value` } });
180
- return `${id}.value`;
181
- };
182
- for (const other of [p, q]) {
183
- const t = theirs[other];
184
- translation[other] =
185
- t && !t.centred
186
- ? sumOf(m, name, [...half(t.lo), ...half(t.hi)], `twist-centre.${other}`)
187
- : constantOf(m, 'mm', 0, 'd');
188
- }
189
- const sp = sumOf(m, name, vp, `twist-own.${p}`), sq = sumOf(m, name, vq, `twist-own.${q}`);
190
- offset[p] = plus(times(sp, factor('cos'), `twisted.${p}.cos`), times(sq, factor('nsin'), `twisted.${p}.sin`), `twisted.${p}`);
191
- offset[q] = plus(times(sp, factor('sin'), `twisted.${q}.sin`), times(sq, factor('cos'), `twisted.${q}.cos`), `twisted.${q}`);
114
+ /* Where it turns (3.10): its lined-up middle on the face, the middle of its real extent, or the centre of its flat face on the face. */
115
+ const pivot = frame.pivot ?? 'seat';
116
+ if (pivot === 'child-centre')
117
+ for (const x of AXES)
118
+ seat[x] = plus(at[x], times(plus(reach(unitAlong(x, 1)), times(reach(unitAlong(x, -1)), -1)), 0.5));
119
+ if (pivot === 'child-face') {
120
+ const own = mv3(transpose3(C), unitAlong(axis, -sign));
121
+ const k = own.findIndex(v => Math.abs(Math.abs(v) - 1) < 1e-9);
122
+ const spans = facing === 'meet' && k >= 0 ? ownSpansOf(m, child, path) : {};
123
+ const along = k >= 0 ? spans[AXES[k]] : undefined;
124
+ if (!along)
125
+ fail('JOINT_SCHEMA', `${path}.origin.pivot`, `${child} meets ${onPlane ? 'the plane' : `${to.part}'s ${to.face}`} at a point or a line${facing === 'meet' ? '' : ` (${facing})`}, not by a face of its own; child-face has no face to turn about -- child-centre or seat`);
126
+ /* The face's centre in the child's own frame, turned into the parent's and placed with the child. */
127
+ const centre = AXES.map((x, i) => (i === k ? (own[k] > 0 ? along.hi : along.lo) : spans[x] ? [...half(spans[x].lo), ...half(spans[x].hi)] : []));
128
+ AXES.forEach((x, i) => {
129
+ seat[x] = plus(at[x], ...AXES.map((_, c) => lin(centre[c].map(t => ({ ref: t.ref, k: t.k * C[i][c] })))));
130
+ });
192
131
  }
193
- reseat(asset, a.part, `${to.part}.local`, {
194
- seat: [translation.x, translation.y, translation.z],
195
- pose: [offset.x, offset.y, offset.z]
132
+ /* The pose is the child's origin from the seat; the seat moves by the frame's offset (3.10, ADR-0107). */
133
+ const pose = AXES.map(x => plus(at[x], times(seat[x], -1)));
134
+ const shift = frame.offset ?? {};
135
+ const seatAt = AXES.map(x => (shift[x] === undefined || shift[x] === 0 ? seat[x] : plus(seat[x], lin([{ ref: measureRef(m, asset, name, shift[x], `offset.${x}`, `${path}.origin.offset.${x}`), k: 1 }]))));
136
+ const write = (r, h) => writeFigureReach(m, name, r, h);
137
+ reseat(asset, child, onPlane ? asset.document.capabilities.assetFrame : `${to.part}.local`, {
138
+ seat: seatAt.map((r, i) => write(r, `seat.${AXES[i]}`)),
139
+ pose: pose.map((r, i) => write(r, `pose.${AXES[i]}`))
196
140
  });
197
- return asset;
141
+ /* The child's own pose: childRotate is Rz·Ry·Rx (3.6); rigid@1 takes its angles X then Y then Z, so they are worked out. */
142
+ const { pose: node } = partOf(m, child, path);
143
+ const old = node.args.slice(3, 6);
144
+ node.args = [...node.args.slice(0, 3), ...eulerOfAny(C, true).map(t => constantOf(m, 'deg', t, 'a'))];
145
+ for (const ref of old)
146
+ dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
147
+ if (AXES.some(x => (frame.parentRotate?.[x] ?? 0) !== 0))
148
+ turnSeat(asset, child, rpyMatrix(frame.parentRotate), frame.parentRotate, IDENTITY);
198
149
  }
199
- function standOnPlane(asset, a, path) {
150
+ /**
151
+ * The seat turned by a joint frame's parent-end rotation (`rigid@1` turns X, then Y, then Z). Its angles are named by
152
+ * the rotation the record gives, and by the parent's standing turn when that is not none -- not by their value, which
153
+ * carries float noise -- and rounded to twelve places, as a twist's factors are (JOINT_DRIFT, 2026-09-25).
154
+ */
155
+ export function turnSeat(asset, part, r, rotate, back) {
200
156
  const m = asset.document.model;
201
- const to = a.to;
202
- if (to.plane !== 'mounting-plane')
203
- fail('SCHEMA', path, `${String(to.plane)} is not a plane a part can stand on`);
204
- if (a.facing !== undefined && a.facing !== 'meet')
205
- fail('SCHEMA', path, 'a part standing on the plane meets it; flush says nothing here');
206
- /* Which plane it is, the figure's placement says: the floor or the ceiling is Y = 0, a wall is Z = 0 (ADR-0093). */
207
- const placement = figurePlacementOf(asset);
208
- const takes = planeFacesOf(placement);
209
- const normal = placement === 'wall' ? 'z' : 'y';
210
- if (!takes.includes(a.face))
211
- fail('ATTACH_FACE', path, placement === 'wall'
212
- ? `${String(a.face)} faces along ${FIGURE_FACES[a.face] ?? 'no axis'}; the figure hangs on a wall, the plane Z = 0, so a part is fastened to it by its back`
213
- : `${String(a.face)} faces along ${FIGURE_FACES[a.face] ?? 'no axis'}; the plane faces along y, so a part stands on it by its bottom or hangs from it by its top`);
214
- if (parentOf(m, a.part) && !a.replace)
215
- fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
216
- const mine = standingSpansOf(m, asset, a.part, path);
217
- if (!mine[normal] || !Object.hasOwn(facesOfPart(m, a.part, path).faces, a.face))
218
- fail('EDIT_TARGET', path, `${a.part} has no flat ${a.face} face`);
219
- const name = namer(m, `${a.part}.on.plane`);
220
- /* Out of the plane: a bottom stands up from the floor, a back comes out of the wall, a top hangs down. */
221
- const outward = a.face === 'top' ? -1 : 1;
222
- const gap = measureRef(m, asset, name, a.gap, `gap.${normal}`, path);
223
- const translation = { x: '', y: '', z: '' };
224
- translation[normal] = sumOf(m, name, [...neg(outward > 0 ? mine[normal].lo : mine[normal].hi), { ref: gap, k: outward }], `stand.${normal}`);
225
- const zero = constantOf(m, 'mm', 0, 'd');
226
- const pose = { x: zero, y: zero, z: zero };
227
- pose[normal] = translation[normal];
228
- for (const other of AXES.filter(x => x !== normal)) {
229
- const how = a.align?.[other] ?? 'centre';
230
- const span = mine[other];
231
- if (how === 'centre')
232
- translation[other] = constantOf(m, 'mm', 0, 'd');
233
- else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
234
- translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
235
- else if ((how === 'min' || how === 'max') && sizeInputOf(m, other)) {
236
- /*
237
- The plane has no edges, but a figure that declares its size has a box on it: its sides are half the size
238
- either way of the origin. A part level with one keeps to it when the figure is resized -- a door's post at
239
- the figure's left, a gantry's legs at its corners (2026-09-25).
240
- */
241
- if (!span)
242
- fail('ATTACH_FACE', path, `${a.part} has no edges along ${other} to line up with the figure's side`);
243
- const s = how === 'min' ? -1 : 1;
244
- translation[other] = sumOf(m, name, [{ ref: sourceRef(m, asset, { of: 'instance', axis: other }, path), k: s * 0.5 }], `figure-edge.${other}`);
245
- pose[other] = sumOf(m, name, neg(how === 'min' ? span.lo : span.hi), `own-edge.${other}`);
246
- continue;
247
- }
248
- else
249
- fail('SCHEMA', path, `the plane has no edges to line ${a.part} up with on ${other}; declare the figure's size, or give the centre or a measured offset`);
250
- /* A shape not centred on its origin stands centred by its faces. */
251
- if (span && !span.centred)
252
- pose[other] = sumOf(m, name, neg([...half(span.lo), ...half(span.hi)]), `own-centre.${other}`);
253
- }
254
- // The point it stands on is on the plane, under its centre: a turn about the plane's normal turns it where it stands.
255
- const seat = AXES.map(x => (x === normal ? zero : translation[x]));
256
- reseat(asset, a.part, asset.document.capabilities.assetFrame, {
257
- seat: [seat[0], seat[1], seat[2]],
258
- pose: [pose.x, pose.y, pose.z]
259
- });
260
- return asset;
157
+ const seat = nodeById(m, `${part}.seat.rigid`);
158
+ const tag = (deg) => String(round6(deg)).replace('-', 'm').replace('.', '_');
159
+ const within = isIdentity(back) ? '' : `.in.${eulerOfAny(transpose3(back)).map(tag).join('_')}`;
160
+ const name = `rotate.${AXES.map(x => tag(rotate[x] ?? 0)).join('_')}${within}`;
161
+ const angles = eulerOfAny(r, true);
162
+ const old = seat.args.slice(3, 6);
163
+ seat.args = [
164
+ ...seat.args.slice(0, 3),
165
+ ...AXES.map((x, i) => {
166
+ const id = `${name}.${x}`;
167
+ if (!m.constants.some((c) => c.id === id))
168
+ m.constants.push({ id, unit: 'deg', value: angles[i] });
169
+ return id;
170
+ })
171
+ ];
172
+ for (const ref of old)
173
+ dropIfUnused(asset, ref);
261
174
  }
262
175
  /**
263
176
  * Put a part's pose on a new frame with a new translation, and rebuild the chain that carries it to the asset.
@@ -274,7 +187,7 @@ function standOnPlane(asset, a, path) {
274
187
  *
275
188
  * Without `place` the part keeps both translations and only its frames are rewired (a motion added or taken away).
276
189
  */
277
- function reseat(asset, id, to, place) {
190
+ export function reseat(asset, id, to, place) {
278
191
  const m = asset.document.model;
279
192
  const { place: placed, pose } = partOf(m, id, id);
280
193
  const old = place ? pose.args.slice(0, 3) : [];
@@ -500,7 +413,10 @@ export function addMotion(asset, a, follow, sign = 1) {
500
413
  }
501
414
  else if (a.motion.kind === 'slide' && a.travel)
502
415
  fail('SCHEMA', path, 'a mm control is the distance; travel would say it twice');
503
- const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? sign : 0, 'axis'));
416
+ /* A direction given as a vector (a joint's axis vector) is the motion's unit vector as it is. */
417
+ const unit = Array.isArray(sign)
418
+ ? sign.map(v => constantOf(m, 'ratio', v, 'axis'))
419
+ : AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? sign : 0, 'axis'));
504
420
  m.nodes.push({
505
421
  id: `${a.part}.motion`,
506
422
  op: a.motion.kind === 'slide' ? 'axis-slide@1' : 'axis-turn@1',