@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.
- package/dist/authoring-fastening.d.ts +53 -8
- package/dist/authoring-fastening.d.ts.map +1 -1
- package/dist/authoring-fastening.js +158 -242
- package/dist/authoring-fastening.js.map +1 -1
- package/dist/authoring-geometry.d.ts +13 -46
- package/dist/authoring-geometry.d.ts.map +1 -1
- package/dist/authoring-geometry.js +28 -180
- package/dist/authoring-geometry.js.map +1 -1
- package/dist/authoring-occupancy.d.ts +3 -3
- package/dist/authoring-occupancy.d.ts.map +1 -1
- package/dist/authoring-occupancy.js +23 -12
- package/dist/authoring-occupancy.js.map +1 -1
- package/dist/authoring-proportion.d.ts.map +1 -1
- package/dist/authoring-proportion.js +15 -3
- package/dist/authoring-proportion.js.map +1 -1
- package/dist/authoring-types.d.ts +69 -32
- package/dist/authoring-types.d.ts.map +1 -1
- package/dist/authoring-types.js +14 -0
- package/dist/authoring-types.js.map +1 -1
- package/dist/command-preview.d.ts +11 -33
- package/dist/command-preview.d.ts.map +1 -1
- package/dist/command-preview.js +2 -61
- package/dist/command-preview.js.map +1 -1
- package/dist/contract.d.ts +1 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/examples.js +4 -4
- package/dist/examples.js.map +1 -1
- package/dist/face-overhang.d.ts +105 -0
- package/dist/face-overhang.d.ts.map +1 -0
- package/dist/face-overhang.js +357 -0
- package/dist/face-overhang.js.map +1 -0
- package/dist/fasten-place.d.ts +35 -0
- package/dist/fasten-place.d.ts.map +1 -0
- package/dist/fasten-place.js +91 -0
- package/dist/fasten-place.js.map +1 -0
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +7 -4
- package/dist/gate.js.map +1 -1
- package/dist/graph.d.ts.map +1 -1
- package/dist/graph.js +16 -2
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/joint-fields.d.ts +12 -0
- package/dist/joint-fields.d.ts.map +1 -0
- package/dist/joint-fields.js +20 -0
- package/dist/joint-fields.js.map +1 -0
- package/dist/joint-writing.d.ts +7 -2
- package/dist/joint-writing.d.ts.map +1 -1
- package/dist/joint-writing.js +224 -119
- package/dist/joint-writing.js.map +1 -1
- package/dist/kernel-version.d.ts +1 -1
- package/dist/kernel-version.js +1 -1
- package/dist/proposal.js +4 -4
- package/dist/proposal.js.map +1 -1
- package/dist/recipe.js +1 -1
- package/dist/recipe.js.map +1 -1
- package/dist/resize-plan.js +1 -1
- package/dist/resize-plan.js.map +1 -1
- package/dist/robots.d.ts.map +1 -1
- package/dist/robots.js +24 -22
- package/dist/robots.js.map +1 -1
- package/dist/shape-edit.d.ts.map +1 -1
- package/dist/shape-edit.js +7 -3
- package/dist/shape-edit.js.map +1 -1
- package/dist/shape-extreme.d.ts +49 -0
- package/dist/shape-extreme.d.ts.map +1 -0
- package/dist/shape-extreme.js +262 -0
- package/dist/shape-extreme.js.map +1 -0
- package/dist/shape-ops.d.ts +6 -7
- package/dist/shape-ops.d.ts.map +1 -1
- package/dist/shape-ops.js +72 -37
- package/dist/shape-ops.js.map +1 -1
- package/dist/size-edit.d.ts.map +1 -1
- package/dist/size-edit.js +40 -11
- package/dist/size-edit.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,12 +1,57 @@
|
|
|
1
1
|
import { type Axis } from './words.ts';
|
|
2
|
-
import { type
|
|
2
|
+
import { type FigureJoint, type FigureJointPivot, type FigureMeasure, type FigureMotionRequest } from './authoring-types.ts';
|
|
3
3
|
type Dynamic = any;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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,
|
|
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 {
|
|
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 {
|
|
13
|
-
|
|
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
|
|
16
|
-
const path =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
?
|
|
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 === '
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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',
|