@hatiolab/figure-model 0.4.6 → 0.5.1
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/asset-types.d.ts +1 -1
- package/dist/asset-types.d.ts.map +1 -1
- package/dist/asset.d.ts +5 -0
- package/dist/asset.d.ts.map +1 -1
- package/dist/asset.js +11 -5
- package/dist/asset.js.map +1 -1
- package/dist/authoring-actions.d.ts +2 -795
- package/dist/authoring-actions.d.ts.map +1 -1
- package/dist/authoring-actions.js +66 -3748
- package/dist/authoring-actions.js.map +1 -1
- package/dist/authoring-bindings.d.ts +35 -0
- package/dist/authoring-bindings.d.ts.map +1 -0
- package/dist/authoring-bindings.js +241 -0
- package/dist/authoring-bindings.js.map +1 -0
- package/dist/authoring-dimensions.d.ts +10 -0
- package/dist/authoring-dimensions.d.ts.map +1 -0
- package/dist/authoring-dimensions.js +88 -0
- package/dist/authoring-dimensions.js.map +1 -0
- package/dist/authoring-fastening.d.ts +24 -0
- package/dist/authoring-fastening.d.ts.map +1 -0
- package/dist/authoring-fastening.js +562 -0
- package/dist/authoring-fastening.js.map +1 -0
- package/dist/authoring-geometry.d.ts +119 -0
- package/dist/authoring-geometry.d.ts.map +1 -0
- package/dist/authoring-geometry.js +419 -0
- package/dist/authoring-geometry.js.map +1 -0
- package/dist/authoring-nodes.d.ts +77 -0
- package/dist/authoring-nodes.d.ts.map +1 -0
- package/dist/authoring-nodes.js +310 -0
- package/dist/authoring-nodes.js.map +1 -0
- package/dist/authoring-occupancy.d.ts +28 -0
- package/dist/authoring-occupancy.d.ts.map +1 -0
- package/dist/authoring-occupancy.js +784 -0
- package/dist/authoring-occupancy.js.map +1 -0
- package/dist/authoring-proportion.d.ts +27 -0
- package/dist/authoring-proportion.d.ts.map +1 -0
- package/dist/authoring-proportion.js +378 -0
- package/dist/authoring-proportion.js.map +1 -0
- package/dist/authoring-size.d.ts +17 -0
- package/dist/authoring-size.d.ts.map +1 -0
- package/dist/authoring-size.js +140 -0
- package/dist/authoring-size.js.map +1 -0
- package/dist/authoring-types.d.ts +753 -0
- package/dist/authoring-types.d.ts.map +1 -0
- package/dist/authoring-types.js +42 -0
- package/dist/authoring-types.js.map +1 -0
- package/dist/command-preview.d.ts +5 -5
- package/dist/command-preview.d.ts.map +1 -1
- package/dist/command-preview.js +11 -5
- package/dist/command-preview.js.map +1 -1
- package/dist/contract.d.ts +129 -3
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/cost.d.ts.map +1 -1
- package/dist/cost.js +3 -0
- package/dist/cost.js.map +1 -1
- package/dist/create.d.ts +36 -0
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +60 -0
- package/dist/create.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +4 -3
- package/dist/document.js.map +1 -1
- package/dist/editor-workspace.d.ts +12 -3
- package/dist/editor-workspace.d.ts.map +1 -1
- package/dist/editor-workspace.js +40 -8
- package/dist/editor-workspace.js.map +1 -1
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +3 -1
- package/dist/examples.js.map +1 -1
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +2 -0
- package/dist/gate.js.map +1 -1
- package/dist/graph-types.d.ts +3 -2
- package/dist/graph-types.d.ts.map +1 -1
- package/dist/graph.d.ts.map +1 -1
- package/dist/graph.js +3 -3
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/joint-list.js +1 -1
- package/dist/joint-list.js.map +1 -1
- package/dist/joint-part-edits.d.ts +40 -0
- package/dist/joint-part-edits.d.ts.map +1 -0
- package/dist/joint-part-edits.js +488 -0
- package/dist/joint-part-edits.js.map +1 -0
- package/dist/joint-writing.d.ts +50 -0
- package/dist/joint-writing.d.ts.map +1 -0
- package/dist/joint-writing.js +847 -0
- package/dist/joint-writing.js.map +1 -0
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +11 -8
- package/dist/json.js.map +1 -1
- package/dist/kernel-version.d.ts +1 -1
- package/dist/kernel-version.js +1 -1
- package/dist/package.d.ts +15 -3
- package/dist/package.d.ts.map +1 -1
- package/dist/package.js +58 -0
- package/dist/package.js.map +1 -1
- package/dist/part-edit.d.ts +1 -1
- package/dist/part-edit.d.ts.map +1 -1
- package/dist/part-edit.js +7 -4
- package/dist/part-edit.js.map +1 -1
- package/dist/proposal.d.ts +1 -1
- package/dist/proposal.d.ts.map +1 -1
- package/dist/proposal.js +5 -2
- package/dist/proposal.js.map +1 -1
- package/dist/render-batches.d.ts +10 -2
- package/dist/render-batches.d.ts.map +1 -1
- package/dist/render-batches.js +19 -5
- package/dist/render-batches.js.map +1 -1
- package/dist/resize-plan.d.ts +1 -1
- package/dist/resize-plan.d.ts.map +1 -1
- package/dist/robots.d.ts.map +1 -1
- package/dist/robots.js +3 -1
- package/dist/robots.js.map +1 -1
- package/dist/room.d.ts +1 -1
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +2 -1
- package/dist/room.js.map +1 -1
- package/dist/shape-edit.js +4 -4
- package/dist/shape-edit.js.map +1 -1
- package/dist/shape-ops.d.ts +62 -7
- package/dist/shape-ops.d.ts.map +1 -1
- package/dist/shape-ops.js +117 -8
- package/dist/shape-ops.js.map +1 -1
- package/dist/slot-layout-presets.d.ts +102 -0
- package/dist/slot-layout-presets.d.ts.map +1 -0
- package/dist/slot-layout-presets.js +185 -0
- package/dist/slot-layout-presets.js.map +1 -0
- package/dist/slot-layout.d.ts +16 -1
- package/dist/slot-layout.d.ts.map +1 -1
- package/dist/slot-layout.js.map +1 -1
- package/docs/modeller-interaction-performance-2026-10-07.md +60 -8
- package/docs/shape-contract.md +46 -0
- package/package.json +1 -1
|
@@ -21,985 +21,24 @@
|
|
|
21
21
|
* absent until something makes it move. `<parent>.world` is the parent's own chain, so moving or resizing the
|
|
22
22
|
* parent carries the child with it. A part that is fastened to nothing poses straight into the asset frame.
|
|
23
23
|
*/
|
|
24
|
-
import { compileAndSealFigureAsset } from "./asset.js";
|
|
24
|
+
import { upgradeFigurePlacement, compileFigureAssetUnjointed, compileFigureAsset, compileAndSealFigureAsset } from "./asset.js";
|
|
25
25
|
import { settleFigureClips } from "./clip-keeping.js";
|
|
26
|
-
import { compileFigureAsset, compileFigureAssetUnjointed, figurePlacementOf, upgradeFigurePlacement } from "./asset.js";
|
|
27
26
|
import { checkPublicInput, keepPublicInputs } from "./public-inputs.js";
|
|
28
27
|
import { figurePartsExtentOf } from "./gate.js";
|
|
29
|
-
import { compileFigureGraph
|
|
28
|
+
import { compileFigureGraph } from "./graph.js";
|
|
30
29
|
import { figureFollowableAxesOf, figureShapeProviderOf } from "./shape-provider.js";
|
|
31
|
-
import {
|
|
32
|
-
import { figureSectionSizeChanges, setFigureCavity, setFigureSectionCorner, setFigureSectionApex, setFigureSectionPoints, remappedFaces } from "./shape-edit.js";
|
|
30
|
+
import { setFigureSectionCorner, setFigureCavity, setFigureSectionPoints, setFigureSectionApex, figureSectionSizeChanges } from "./shape-edit.js";
|
|
33
31
|
import { figureSizeEditChanges } from "./size-edit.js";
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import { persistenceIssues, validateSlotLayout } from "./slot-layout.js";
|
|
37
|
-
import { AXES, REPEAT_LIMIT, isDetailLevel } from "./words.js";
|
|
38
|
-
import { defaultInstanceSizeRange, foldInstanceSizeRange, figureInstanceSizeLimitsOf, FIGURE_INSTANCE_SIZE_DEFAULT, FIGURE_INSTANCE_SIZE_INPUTS } from "./instance-size.js";
|
|
39
|
-
import { FIGURE_PLACEMENTS } from "./asset-types.js";
|
|
32
|
+
import { isDetailLevel, AXES } from "./words.js";
|
|
33
|
+
import { defaultInstanceSizeRange, foldInstanceSizeRange, figureInstanceSizeLimitsOf, FIGURE_INSTANCE_SIZE_INPUTS } from "./instance-size.js";
|
|
40
34
|
import { carrierDeclarationOf } from "./carrier.js";
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* `revolute` joint turns -- with no limits it turns without end, its value a phase (-180..180), as USD's revolute
|
|
49
|
-
* joint without limits does; a `prismatic` joint slides.
|
|
50
|
-
*/
|
|
51
|
-
export const FIGURE_JOINT_TYPES = ['fixed', 'revolute', 'prismatic'];
|
|
52
|
-
const SIGN = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 };
|
|
53
|
-
const OPPOSITE = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' };
|
|
54
|
-
/** The joints a follower follows, each with its multiplier: one for a one-leader mimic, none for a joint that leads itself. */
|
|
55
|
-
export function figureMimicLeadersOf(j) {
|
|
56
|
-
const mimic = j.mimic;
|
|
57
|
-
if (!mimic || typeof mimic !== 'object')
|
|
58
|
-
return [];
|
|
59
|
-
if (Object.hasOwn(mimic, 'sum'))
|
|
60
|
-
return Array.isArray(mimic.sum) ? mimic.sum : [];
|
|
61
|
-
return [{ joint: mimic.joint, multiplier: mimic.multiplier }];
|
|
62
|
-
}
|
|
63
|
-
/** Whether a length is written as something to follow: one source, or a sum of one term or more, each with a source. */
|
|
64
|
-
const followsSomething = (measure) => {
|
|
65
|
-
const terms = followedTermsOf(measure)?.terms;
|
|
66
|
-
return Array.isArray(terms) && terms.length > 0 && terms.every(t => !!t?.from);
|
|
67
|
-
};
|
|
68
|
-
/** A length that follows something, as its terms and its margin; undefined for a number (or nothing). */
|
|
69
|
-
function followedTermsOf(measure) {
|
|
70
|
-
if (!measure || typeof measure !== 'object')
|
|
71
|
-
return undefined;
|
|
72
|
-
if (Object.hasOwn(measure, 'sum')) {
|
|
73
|
-
const m = measure;
|
|
74
|
-
return { terms: m.sum, plus: m.plus ?? 0, sum: true };
|
|
75
|
-
}
|
|
76
|
-
const m = measure;
|
|
77
|
-
return { terms: [{ from: m.from, ...(m.times !== undefined ? { times: m.times } : {}) }], plus: m.plus ?? 0, sum: false };
|
|
78
|
-
}
|
|
79
|
-
const everyKey = () => (...keys) => keys;
|
|
80
|
-
const JOINT_FIELDS = {
|
|
81
|
-
joint: everyKey()('id', 'type', 'body0', 'body1', 'origin', 'axis', 'lowerLimit', 'upperLimit', 'travel', 'state', 'mimic', 'repeat'),
|
|
82
|
-
body0: everyKey()('part', 'face', 'plane'),
|
|
83
|
-
body1: everyKey()('part', 'face'),
|
|
84
|
-
origin: everyKey()('facing', 'gap', 'align', 'turn', 'twist'),
|
|
85
|
-
axes: everyKey()('x', 'y', 'z'),
|
|
86
|
-
alignment: everyKey()('mm'),
|
|
87
|
-
measure: everyKey()('from', 'times', 'plus', 'sum'),
|
|
88
|
-
term: everyKey()('from', 'times'),
|
|
89
|
-
source: everyKey()('of', 'axis', 'input', 'part'),
|
|
90
|
-
travel: everyKey()('source', 'times', 'plus', 'sum'),
|
|
91
|
-
state: everyKey()('id', 'label', 'start', 'sweep'),
|
|
92
|
-
mimic: everyKey()('joint', 'multiplier', 'offset', 'sum'),
|
|
93
|
-
leader: everyKey()('joint', 'multiplier'),
|
|
94
|
-
repeat: everyKey()('axis', 'pitch', 'count', 'rise'),
|
|
95
|
-
rise: everyKey()('axis', 'mm')
|
|
96
|
-
};
|
|
97
|
-
/** Refuses, by name, a key of a joint record or of an object in it that this kernel does not know. A key left undefined is no field. */
|
|
98
|
-
function knownJointFields(j, path) {
|
|
99
|
-
const only = (o, allowed, at) => {
|
|
100
|
-
if (!o || typeof o !== 'object' || Array.isArray(o))
|
|
101
|
-
return;
|
|
102
|
-
for (const [k, v] of Object.entries(o))
|
|
103
|
-
if (v !== undefined && !allowed.includes(k))
|
|
104
|
-
fail('JOINT_SCHEMA', at, `${k} is a field this kernel does not know`);
|
|
105
|
-
};
|
|
106
|
-
const source = (s, at) => only(s, JOINT_FIELDS.source, at);
|
|
107
|
-
const measure = (m, at) => {
|
|
108
|
-
if (!m || typeof m !== 'object')
|
|
109
|
-
return;
|
|
110
|
-
only(m, JOINT_FIELDS.measure, at);
|
|
111
|
-
source(m.from, `${at}.from`);
|
|
112
|
-
if (Array.isArray(m.sum))
|
|
113
|
-
for (const [i, t] of m.sum.entries()) {
|
|
114
|
-
only(t, JOINT_FIELDS.term, `${at}.sum.${i}`);
|
|
115
|
-
source(t?.from, `${at}.sum.${i}.from`);
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
only(j, JOINT_FIELDS.joint, path);
|
|
119
|
-
only(j.body0, JOINT_FIELDS.body0, `${path}.body0`);
|
|
120
|
-
only(j.body1, JOINT_FIELDS.body1, `${path}.body1`);
|
|
121
|
-
const o = j.origin;
|
|
122
|
-
only(o, JOINT_FIELDS.origin, `${path}.origin`);
|
|
123
|
-
if (o && typeof o === 'object') {
|
|
124
|
-
measure(o.gap, `${path}.origin.gap`);
|
|
125
|
-
only(o.turn, JOINT_FIELDS.axes, `${path}.origin.turn`);
|
|
126
|
-
only(o.align, JOINT_FIELDS.axes, `${path}.origin.align`);
|
|
127
|
-
for (const [axis, a] of Object.entries(o.align && typeof o.align === 'object' ? o.align : {})) {
|
|
128
|
-
only(a, JOINT_FIELDS.alignment, `${path}.origin.align.${axis}`);
|
|
129
|
-
measure(a?.mm, `${path}.origin.align.${axis}.mm`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
if (j.travel && typeof j.travel === 'object') {
|
|
133
|
-
only(j.travel, JOINT_FIELDS.travel, `${path}.travel`);
|
|
134
|
-
source(j.travel.source, `${path}.travel.source`);
|
|
135
|
-
if (Array.isArray(j.travel.sum))
|
|
136
|
-
measure({ sum: j.travel.sum }, `${path}.travel`);
|
|
137
|
-
}
|
|
138
|
-
only(j.state, JOINT_FIELDS.state, `${path}.state`);
|
|
139
|
-
only(j.mimic, JOINT_FIELDS.mimic, `${path}.mimic`);
|
|
140
|
-
if (Array.isArray(j.mimic?.sum))
|
|
141
|
-
for (const [i, t] of j.mimic.sum.entries())
|
|
142
|
-
only(t, JOINT_FIELDS.leader, `${path}.mimic.sum.${i}`);
|
|
143
|
-
only(j.repeat, JOINT_FIELDS.repeat, `${path}.repeat`);
|
|
144
|
-
if (j.repeat && typeof j.repeat === 'object') {
|
|
145
|
-
measure(j.repeat.pitch, `${path}.repeat.pitch`);
|
|
146
|
-
only(j.repeat.rise, JOINT_FIELDS.rise, `${path}.repeat.rise`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
const clone = (asset) => structuredClone(asset);
|
|
150
|
-
const nodeById = (m, id) => m.nodes.find((n) => n.id === id);
|
|
151
|
-
const writerOf = (m, ref) => m.nodes.find((n) => Object.values(n.outputs).includes(ref));
|
|
152
|
-
/** A part an authoring command can work with, and the pieces of it those commands need. */
|
|
153
|
-
/** The part whose row carries this one -- fastened to it, however far down -- or undefined. */
|
|
154
|
-
function rowHolding(m, id) {
|
|
155
|
-
const place = nodeById(m, id);
|
|
156
|
-
if (place?.op !== 'member@1' || nodeById(m, `${id}.repeat`))
|
|
157
|
-
return undefined;
|
|
158
|
-
const assembly = m.nodes.find((n) => n.op === 'assembly@1' && n.args.includes(place.outputs.placed));
|
|
159
|
-
return assembly ? String(assembly.id).replace(/\.assembly$/, '') : undefined;
|
|
160
|
-
}
|
|
161
|
-
function partOf(m, id, path) {
|
|
162
|
-
const place = nodeById(m, id);
|
|
163
|
-
/* A row's template is the part too: it is placed through its row (`repeatPart`). */
|
|
164
|
-
const rowTemplate = place?.op === 'member@1' && (!!nodeById(m, `${id}.repeat`) || rowHolding(m, id) !== undefined);
|
|
165
|
-
if (!place || (place.op !== 'place@1' && !rowTemplate))
|
|
166
|
-
fail('TARGET_ABSENT', path, `${id} is not a placed part`);
|
|
167
|
-
const shape = writerOf(m, place.args[0]);
|
|
168
|
-
if (!shape)
|
|
169
|
-
fail('TARGET_ABSENT', path, `${id} has no shape`);
|
|
170
|
-
const pose = nodeById(m, `${id}.pose`);
|
|
171
|
-
if (!pose || pose.op !== 'rigid@1')
|
|
172
|
-
fail('EDIT_TARGET', path, `${id} is not posed by a rigid@1 this command can read`);
|
|
173
|
-
return { place, shape, pose };
|
|
174
|
-
}
|
|
175
|
-
const isReach = (e) => !Array.isArray(e);
|
|
176
|
-
/** A drawn section's points (and a pyramid's apex) along one plane axis, as graph values. */
|
|
177
|
-
function drawnOffsetsOf(shape, axis) {
|
|
178
|
-
const L = shape.op === 'loft@1' ? figureLoftLayoutOf(shape.params, shape.id) : figureExtrudeLayoutOf(shape.params, shape.id);
|
|
179
|
-
const at = Object.values(L.arg.point ?? {}).map(p => p[axis]).filter(i => i !== undefined);
|
|
180
|
-
const apex = L.apex?.[axis];
|
|
181
|
-
return [...at, ...(apex !== undefined ? [apex] : [])].map(i => ({ ref: shape.args[i], k: 1 }));
|
|
182
|
-
}
|
|
183
|
-
function sizeRefsOf(m, id, path) {
|
|
184
|
-
const { shape } = partOf(m, id, path);
|
|
185
|
-
/* A shape operation says itself which argument is each axis's measure (ADR-0100). */
|
|
186
|
-
const described = figureShapeDescriptionOf(shape);
|
|
187
|
-
if (described) {
|
|
188
|
-
const out = {};
|
|
189
|
-
for (const axis of AXES) {
|
|
190
|
-
const e = described.edit[axis];
|
|
191
|
-
if ('refused' in e)
|
|
192
|
-
return fail('EDIT_TARGET', path, `${id} is a drawn section; its reach across is its points, not a measure this command can read`);
|
|
193
|
-
out[axis] = { ref: shape.args[e.arg], k: e.k };
|
|
194
|
-
}
|
|
195
|
-
return out;
|
|
196
|
-
}
|
|
197
|
-
return fail('EDIT_TARGET', path, `${id} is a ${shape.op}; it is not a shape this command can measure`);
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* What a part takes up along each of its own axes, as candidates whose largest is the answer. A taper is as wide as
|
|
201
|
-
* the larger of its two ends, and which end that is changes when a radius follows a size -- so both are candidates, and
|
|
202
|
-
* the room's bounds take the larger when the graph is evaluated (G5, chief architect 2026-09-26). Every other part
|
|
203
|
-
* has one candidate: its size.
|
|
204
|
-
*/
|
|
205
|
-
function occupiedExtentsOf(m, id, path, single = false) {
|
|
206
|
-
const { shape } = partOf(m, id, path);
|
|
207
|
-
const described = figureShapeDescriptionOf(shape);
|
|
208
|
-
if (described && !single) {
|
|
209
|
-
const out = {};
|
|
210
|
-
for (const axis of AXES) {
|
|
211
|
-
const terms = described.measure[axis];
|
|
212
|
-
/* A drawn section across its plane: its points, which need not sit about its origin (a wedge drawn off centre). */
|
|
213
|
-
if (terms === 'profile')
|
|
214
|
-
out[axis] = { offsets: drawnOffsetsOf(shape, axis) };
|
|
215
|
-
else
|
|
216
|
-
out[axis] = terms.map(t => ({ ref: shape.args[t.arg], k: t.k }));
|
|
217
|
-
}
|
|
218
|
-
return out;
|
|
219
|
-
}
|
|
220
|
-
const own = sizeRefsOf(m, id, path);
|
|
221
|
-
return { x: [own.x], y: [own.y], z: [own.z] };
|
|
222
|
-
}
|
|
223
|
-
/** The angle a pose argument holds, whether it is a constant or a design input, or null when it is neither. */
|
|
224
|
-
function angleOf(m, asset, ref) {
|
|
225
|
-
const constant = m.constants.find((c) => c.id === ref);
|
|
226
|
-
if (constant)
|
|
227
|
-
return constant.value;
|
|
228
|
-
const input = m.inputs.find((i) => i.id === ref);
|
|
229
|
-
if (input && input.role !== 'state')
|
|
230
|
-
return asset.designInputs[ref] ?? null;
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
/** The turn in a part's pose, in degrees about X, Y and Z, or null for an axis whose turn is neither a number nor a design value. */
|
|
234
|
-
function turnOf(m, asset, id, path) {
|
|
235
|
-
return partOf(m, id, path).pose.args.slice(3, 6).map((ref) => angleOf(m, asset, ref));
|
|
236
|
-
}
|
|
237
|
-
const saidTurn = (turn) => AXES.map((a, i) => `${a.toUpperCase()} ${turn[i] ?? '?'}°`).join(', ');
|
|
238
|
-
/** A part with no turn in its pose: a face of a turned body is not the plane this command assumes. */
|
|
239
|
-
function requireUnturned(m, asset, id, path) {
|
|
240
|
-
const turn = turnOf(m, asset, id, path);
|
|
241
|
-
if (turn.some(t => t !== 0))
|
|
242
|
-
fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is not covered by this command`);
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* The part's size along each axis as it stands in its seat -- after its own turn. A turn by a whole number of
|
|
246
|
-
* quarter turns only swaps the axes (a cylinder laid on its side is 2r tall and its length deep), so its faces are
|
|
247
|
-
* still axis-aligned planes; any other turn is refused, and says the angle. The turn is then fixed as it is: a part
|
|
248
|
-
* fastened by its faces keeps the turn its faces were measured in (chief architect's ruling 2026-09-25).
|
|
249
|
-
*/
|
|
250
|
-
/**
|
|
251
|
-
* The part's own turn as a signed axis permutation, when it is a whole number of quarter turns; any other turn is
|
|
252
|
-
* refused and says the angle. With `freeze` the turn is fixed as it is: a part fastened by its faces keeps the turn
|
|
253
|
-
* its faces were measured in (chief architect's ruling 2026-09-25).
|
|
254
|
-
*/
|
|
255
|
-
function quarterTurnOf(m, asset, id, path, freeze) {
|
|
256
|
-
const turn = turnOf(m, asset, id, path);
|
|
257
|
-
if (turn.every(t => t === 0))
|
|
258
|
-
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
259
|
-
if (turn.some(t => t === null || !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
|
|
260
|
-
fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); only quarter turns keep its faces level with the axes`);
|
|
261
|
-
const r = turnMatrix(turn);
|
|
262
|
-
if (freeze) {
|
|
263
|
-
const pose = partOf(m, id, path).pose;
|
|
264
|
-
const old = pose.args.slice(3, 6);
|
|
265
|
-
pose.args = [...pose.args.slice(0, 3), ...turn.map(t => constantOf(m, 'deg', t, 'a'))];
|
|
266
|
-
// Its turn fields go with it: typing into them would change nothing.
|
|
267
|
-
for (const ref of old)
|
|
268
|
-
dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
|
|
269
|
-
}
|
|
270
|
-
return r;
|
|
271
|
-
}
|
|
272
|
-
/** A quarter turn in degrees about X, Y and Z as `rigid@1` turns (X, then Y, then Z), as a signed axis permutation. */
|
|
273
|
-
function turnMatrix(turn) {
|
|
274
|
-
const [rx, ry, rz] = turn.map(t => (t * Math.PI) / 180);
|
|
275
|
-
const cos = (a) => Math.round(Math.cos(a)), sin = (a) => Math.round(Math.sin(a));
|
|
276
|
-
const X = [[1, 0, 0], [0, cos(rx), -sin(rx)], [0, sin(rx), cos(rx)]];
|
|
277
|
-
const Y = [[cos(ry), 0, sin(ry)], [0, 1, 0], [-sin(ry), 0, cos(ry)]];
|
|
278
|
-
const Z = [[cos(rz), -sin(rz), 0], [sin(rz), cos(rz), 0], [0, 0, 1]];
|
|
279
|
-
return mul3(mul3(X, Y), Z);
|
|
280
|
-
}
|
|
281
|
-
const mul3 = (a, b) => a.map(row => b[0].map((_, j) => row.reduce((sum, v, k) => sum + v * b[k][j], 0)));
|
|
282
|
-
function standingRefsOf(m, asset, id, path, freeze = true) {
|
|
283
|
-
const own = sizeRefsOf(m, id, path);
|
|
284
|
-
const r = quarterTurnOf(m, asset, id, path, freeze);
|
|
285
|
-
const standing = {};
|
|
286
|
-
AXES.forEach((axis, i) => (standing[axis] = own[AXES[r[i].findIndex(v => v !== 0)]]));
|
|
287
|
-
return standing;
|
|
288
|
-
}
|
|
289
|
-
const neg = (lin) => lin.map(t => ({ ref: t.ref, k: -t.k }));
|
|
290
|
-
const half = (lin) => lin.map(t => ({ ref: t.ref, k: t.k / 2 }));
|
|
291
|
-
const OUTER = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] };
|
|
292
|
-
/** A part's declared faces, from its shape's own definition, or a refusal saying the shape has none. */
|
|
293
|
-
function facesOfPart(m, id, path) {
|
|
294
|
-
const { shape } = partOf(m, id, path);
|
|
295
|
-
/* With the graph: a centred push's ends are ±half its length, the planes fastenings have always been made from. */
|
|
296
|
-
const faces = figureShapeFaces(shape, m);
|
|
297
|
-
if (!faces)
|
|
298
|
-
fail('EDIT_TARGET', path, `${id} is a ${shape.op}; it has no flat face to fasten by`);
|
|
299
|
-
return { shape, faces: faces };
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* The face a joint's child is measured by: its own, or for `centre` the face that would meet the parent's -- the part is
|
|
303
|
-
* measured and turned as that face, and only where its middle sits differs (\`attach\`).
|
|
304
|
-
*/
|
|
305
|
-
function childFaceOf(m, j) {
|
|
306
|
-
if (j.body1.face !== 'centre')
|
|
307
|
-
return j.body1.face;
|
|
308
|
-
const path = `joints.${j.id}.body1.face`;
|
|
309
|
-
if (!('part' in j.body0))
|
|
310
|
-
fail('JOINT_SCHEMA', path, 'a part stands on the mounting plane by a face of its own, not by its centre');
|
|
311
|
-
/* One of the six, in the record's words: the face across from it. An opening's face, read from the parent's shape. */
|
|
312
|
-
const named = j.body0.face;
|
|
313
|
-
if (Object.hasOwn(FIGURE_FACES, named))
|
|
314
|
-
return Object.keys(FIGURE_FACES).find(f => FIGURE_FACES[f] === FIGURE_FACES[named] && SIGN[f] === -SIGN[named]);
|
|
315
|
-
const theirs = facesOfPart(m, j.body0.part, path).faces[j.body0.face];
|
|
316
|
-
if (!theirs)
|
|
317
|
-
fail('JOINT_SCHEMA', path, `${j.body0.part} has no face ${j.body0.face}`);
|
|
318
|
-
return Object.keys(FIGURE_FACES).find(f => FIGURE_FACES[f] === theirs.axis && SIGN[f] === -theirs.normal);
|
|
319
|
-
}
|
|
320
|
-
const planeOf = (shape, face) => face.plane.map(p => ({ ref: shape.args[p.arg], k: p.k }));
|
|
321
|
-
/** The part's outer faces on each axis in its own frame; an axis without a pair of flat faces has none. */
|
|
322
|
-
function ownSpansOf(m, id, path) {
|
|
323
|
-
const { shape, faces } = facesOfPart(m, id, path);
|
|
324
|
-
const spans = {};
|
|
325
|
-
const measure = figureShapeDescriptionOf(shape, m).measure;
|
|
326
|
-
for (const axis of AXES) {
|
|
327
|
-
const [lo, hi] = OUTER[axis].map(n => faces[n]);
|
|
328
|
-
/* One face on an axis and a measure along it (a pyramid's base): the span's other end is the face plus the measure. */
|
|
329
|
-
const reach = measure[axis];
|
|
330
|
-
const along = reach && reach !== 'profile' ? planeOf(shape, { axis, normal: 1, plane: reach }) : undefined;
|
|
331
|
-
if (!lo && !hi)
|
|
332
|
-
continue;
|
|
333
|
-
if ((!lo || !hi) && !along)
|
|
334
|
-
continue;
|
|
335
|
-
const l = lo ? planeOf(shape, lo) : [...planeOf(shape, hi), ...neg(along)], h = hi ? planeOf(shape, hi) : [...planeOf(shape, lo), ...along];
|
|
336
|
-
spans[axis] = { lo: l, hi: h, centred: JSON.stringify(l) === JSON.stringify(neg(h)) };
|
|
337
|
-
}
|
|
338
|
-
return spans;
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* The part's spans as it stands in its seat, after its own quarter turn (the turn is fixed as `standingRefsOf` fixes
|
|
342
|
-
* it). A turn that flips an axis swaps its two faces: the part's own right face becomes the standing left.
|
|
343
|
-
*/
|
|
344
|
-
function standingSpansOf(m, asset, id, path) {
|
|
345
|
-
const own = ownSpansOf(m, id, path);
|
|
346
|
-
const r = quarterTurnOf(m, asset, id, path, true);
|
|
347
|
-
const standing = {};
|
|
348
|
-
AXES.forEach((axis, i) => {
|
|
349
|
-
const j = r[i].findIndex(v => v !== 0);
|
|
350
|
-
const span = own[AXES[j]];
|
|
351
|
-
if (!span)
|
|
352
|
-
return;
|
|
353
|
-
standing[axis] = r[i][j] > 0 ? span : { lo: neg(span.hi), hi: neg(span.lo), centred: span.centred };
|
|
354
|
-
});
|
|
355
|
-
return standing;
|
|
356
|
-
}
|
|
357
|
-
/** Fresh node ids under a part, so two commands never claim one id. */
|
|
358
|
-
function namer(m, prefix) {
|
|
359
|
-
const taken = new Set([...m.nodes.map((n) => n.id), ...m.nodes.flatMap((n) => Object.values(n.outputs)), ...m.constants.map((c) => c.id), ...m.inputs.map((i) => i.id)]);
|
|
360
|
-
return (hint) => {
|
|
361
|
-
let id = `${prefix}.${hint}`;
|
|
362
|
-
for (let k = 2; taken.has(id) || taken.has(`${id}.value`); k++)
|
|
363
|
-
id = `${prefix}.${hint}.${k}`;
|
|
364
|
-
taken.add(id);
|
|
365
|
-
taken.add(`${id}.value`);
|
|
366
|
-
return id;
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
/** A constant, reusing one that already holds the same number in the same unit. */
|
|
370
|
-
function constantOf(m, unit, value, hint) {
|
|
371
|
-
const found = m.constants.find((c) => c.unit === unit && c.value === value);
|
|
372
|
-
if (found)
|
|
373
|
-
return found.id;
|
|
374
|
-
const id = `${hint}.${unit}.${String(value).replace(/[^0-9a-zA-Z]/g, '_')}`;
|
|
375
|
-
m.constants.push({ id, unit, value });
|
|
376
|
-
return id;
|
|
377
|
-
}
|
|
378
|
-
/** value = ref × times + plus, written as nodes, and folded away when there is nothing to do. */
|
|
379
|
-
function scaled(m, name, ref, times, plus, hint) {
|
|
380
|
-
let out = ref;
|
|
381
|
-
if (times !== 1) {
|
|
382
|
-
const id = name(`${hint}.times`);
|
|
383
|
-
m.nodes.push({ id, op: 'mul@1', args: [out, constantOf(m, 'ratio', times, 'k')], outputs: { value: `${id}.value` } });
|
|
384
|
-
out = `${id}.value`;
|
|
385
|
-
}
|
|
386
|
-
if (plus !== 0) {
|
|
387
|
-
const id = name(`${hint}.plus`);
|
|
388
|
-
m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', plus, 'd')], outputs: { value: `${id}.value` } });
|
|
389
|
-
out = `${id}.value`;
|
|
390
|
-
}
|
|
391
|
-
return out;
|
|
392
|
-
}
|
|
393
|
-
/** A measure as one graph reference: a constant, or the source it follows times a multiple plus a margin. */
|
|
394
|
-
function measureRef(m, asset, name, measure, hint, path) {
|
|
395
|
-
if (measure === undefined)
|
|
396
|
-
return constantOf(m, 'mm', 0, 'd');
|
|
397
|
-
if (typeof measure === 'number') {
|
|
398
|
-
if (!Number.isFinite(measure))
|
|
399
|
-
fail('SCHEMA', path, 'a finite length is required');
|
|
400
|
-
return constantOf(m, 'mm', measure, 'd');
|
|
401
|
-
}
|
|
402
|
-
if (!measure || typeof measure !== 'object' || !(Object.hasOwn(measure, 'sum') || measure.from))
|
|
403
|
-
fail('SCHEMA', path, 'a length is a number of millimetres, or something to follow');
|
|
404
|
-
const { terms, plus, sum } = followedTermsOf(measure);
|
|
405
|
-
const refs = termRefs(m, asset, terms, plus, path);
|
|
406
|
-
/* One term is written as it always was; a sum adds its terms, then the margin (the same add@1 and mul@1 nodes). */
|
|
407
|
-
if (!sum)
|
|
408
|
-
return scaled(m, name, refs[0].ref, refs[0].k, plus, hint);
|
|
409
|
-
return scaled(m, name, sumOf(m, name, refs, `${hint}.sum`), 1, plus, hint);
|
|
410
|
-
}
|
|
411
|
-
/** Each term of a followed length as a graph reference and its multiple, every one checked as a one-term length is. */
|
|
412
|
-
function termRefs(m, asset, terms, plus, path) {
|
|
413
|
-
if (!Array.isArray(terms) || !terms.length)
|
|
414
|
-
fail('SCHEMA', path, 'a sum has one term or more');
|
|
415
|
-
if (!Number.isFinite(plus))
|
|
416
|
-
fail('SCHEMA', path, 'finite times and plus required');
|
|
417
|
-
return terms.map((t, i) => {
|
|
418
|
-
const at = terms.length > 1 ? `${path}.sum.${i}` : path;
|
|
419
|
-
if (!t || typeof t !== 'object' || !t.from)
|
|
420
|
-
fail('SCHEMA', at, 'a length is a number of millimetres, or something to follow');
|
|
421
|
-
if (!Number.isFinite(t.times ?? 1))
|
|
422
|
-
fail('SCHEMA', at, 'finite times and plus required');
|
|
423
|
-
return { ref: sourceRef(m, asset, t.from, at), k: t.times ?? 1 };
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
/** Σ k·ref, written as nodes, with nothing emitted for a weight of zero and no node for a single term. */
|
|
427
|
-
function sumOf(m, name, terms, hint) {
|
|
428
|
-
const live = terms.filter(t => t.k !== 0);
|
|
429
|
-
if (!live.length)
|
|
430
|
-
return constantOf(m, 'mm', 0, 'd');
|
|
431
|
-
let out = '';
|
|
432
|
-
for (const [i, t] of live.entries()) {
|
|
433
|
-
const piece = scaled(m, name, t.ref, t.k, 0, `${hint}.${i}`);
|
|
434
|
-
if (!out)
|
|
435
|
-
out = piece;
|
|
436
|
-
else {
|
|
437
|
-
const id = name(`${hint}.${i}.sum`);
|
|
438
|
-
m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
|
|
439
|
-
out = `${id}.value`;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
return out;
|
|
443
|
-
}
|
|
444
|
-
/** The size one axis reads once the figure grows by one ratio: u times its authored size (`scaleUniformly`). */
|
|
445
|
-
function uniformSizeOf(m, axis, path) {
|
|
446
|
-
const node = nodeById(m, `sizing.size.${axis}`);
|
|
447
|
-
if (!node)
|
|
448
|
-
fail('TARGET_ABSENT', path, `the figure grows by one ratio but has no sizing.size.${axis}`);
|
|
449
|
-
return node.outputs.value;
|
|
450
|
-
}
|
|
451
|
-
function sourceRef(m, asset, source, path) {
|
|
452
|
-
if (!source || typeof source !== 'object')
|
|
453
|
-
fail('SCHEMA', path, 'a dimension source is required');
|
|
454
|
-
if (source.of === 'instance') {
|
|
455
|
-
const id = `size.${source.axis}`;
|
|
456
|
-
if (!m.inputs.some((i) => i.id === id))
|
|
457
|
-
fail('TARGET_ABSENT', path, `the instance has no ${id}; declare it before following it`);
|
|
458
|
-
/* A figure that grows by one ratio is read at that ratio's size: a link made after it had read the raw size. */
|
|
459
|
-
return asset.sizing === 'uniform' ? uniformSizeOf(m, source.axis, path) : id;
|
|
460
|
-
}
|
|
461
|
-
if (source.of === 'input') {
|
|
462
|
-
const input = m.inputs.find((i) => i.id === source.input);
|
|
463
|
-
if (!input)
|
|
464
|
-
fail('TARGET_ABSENT', path, `${source.input} is not an input of this asset`);
|
|
465
|
-
if (input.role === 'state')
|
|
466
|
-
fail('EDIT_TARGET', path, `${source.input} is a state input; a dimension that follows state is a different declaration`);
|
|
467
|
-
return source.input;
|
|
468
|
-
}
|
|
469
|
-
if (source.of === 'part')
|
|
470
|
-
return stableDimension(m, source.part, source.axis, path);
|
|
471
|
-
return fail('SCHEMA', path, `${String(source.of)} is not a dimension source`);
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* The name of a part's dimension, which stays the same name however that dimension is later worked out.
|
|
475
|
-
*
|
|
476
|
-
* "B is as wide as A" has to keep meaning that when A is told to follow something else. Reading A's shape
|
|
477
|
-
* argument at the moment of linking gave B whatever A happened to read then — A's own control — so relinking A
|
|
478
|
-
* left B on the old value (designer's counterexample 2026-09-23: A went to 300 and B stayed at 100). Every
|
|
479
|
-
* dimension a command touches gets a node of its own, `<part>.dimension.<axis>`, and that is what the shape
|
|
480
|
-
* reads and what anything following it reads. Relinking rewrites what that node computes, and everyone pointing
|
|
481
|
-
* at it comes along — fastenings included, since they measure the same references.
|
|
482
|
-
*/
|
|
483
|
-
function stableDimension(m, part, axis, path) {
|
|
484
|
-
const shape = partOf(m, part, path).shape;
|
|
485
|
-
const described = figureShapeDescriptionOf(shape);
|
|
486
|
-
if (described) {
|
|
487
|
-
const e = described.edit[axis];
|
|
488
|
-
if ('refused' in e)
|
|
489
|
-
return fail('EDIT_TARGET', path, `${part} is a drawn section; its measures are its points, which no measure can follow`);
|
|
490
|
-
/* An axis whose argument is another axis's value (a circle's two radii) is that axis's dimension. */
|
|
491
|
-
if (e.sameAs)
|
|
492
|
-
return stableDimension(m, part, e.sameAs, path);
|
|
493
|
-
const id = `${part}.dimension.${axis}`;
|
|
494
|
-
const ref = `${id}.value`;
|
|
495
|
-
if (nodeById(m, id))
|
|
496
|
-
return ref;
|
|
497
|
-
const was = shape.args[e.arg];
|
|
498
|
-
const one = constantOf(m, 'ratio', 1, 'k');
|
|
499
|
-
m.nodes.push({ id, op: 'mul@1', args: [was, one], outputs: { value: ref } });
|
|
500
|
-
/* Every argument that is this value goes with it, and so does a start written from this length (a centred push). */
|
|
501
|
-
shape.args = shape.args.map((r) => (r === was ? ref : r));
|
|
502
|
-
const start = nodeById(m, `${part}.start`);
|
|
503
|
-
if (start)
|
|
504
|
-
start.args = start.args.map((r) => (r === was ? ref : r));
|
|
505
|
-
return ref;
|
|
506
|
-
}
|
|
507
|
-
return fail('EDIT_TARGET', path, `${part} is a ${shape.op}; it is not a shape with measures`);
|
|
508
|
-
}
|
|
509
|
-
/** What a part's dimension is worked out from right now: the first argument of its own dimension node. */
|
|
510
|
-
const dimensionSource = (m, part, axis) => nodeById(m, `${part}.dimension.${axis}`)?.args?.[0];
|
|
511
|
-
/** Whether this side of the part already follows something, rather than reading its own control. */
|
|
512
|
-
/**
|
|
513
|
-
* A part's own number for a dimension: a design input named under the part. `addFigureBox` names it `<part>.size.<axis>`,
|
|
514
|
-
* the editor's shape palette `<part>.dimension.width` (and height, depth); both are the part's own, and a dimension
|
|
515
|
-
* read from one follows nothing. Reading only the first name made a palette box look linked as soon as any command
|
|
516
|
-
* had routed its dimension, and a link was refused as a replacement (2026-09-24, :3300).
|
|
517
|
-
*/
|
|
518
|
-
const ownInput = (m, part, ref) => !!ref && ref.startsWith(`${part}.`) && m.inputs.some((i) => i.id === ref && i.role === 'design');
|
|
519
|
-
const followsAlready = (m, part, axis) => {
|
|
520
|
-
const from = dimensionSource(m, part, axis);
|
|
521
|
-
return from !== undefined && !ownInput(m, part, from);
|
|
522
|
-
};
|
|
523
|
-
function linkDimension(asset, a) {
|
|
524
|
-
const m = asset.document.model;
|
|
525
|
-
const path = `${a.part}.${a.axis}`;
|
|
526
|
-
const mine = stableDimension(m, a.part, a.axis, path);
|
|
527
|
-
if (followsAlready(m, a.part, a.axis) && !a.replace)
|
|
528
|
-
fail('DIMENSION_LINKED', path, `${a.part}'s ${a.axis} already follows something; pass replace to change what it follows, or unlink it first`);
|
|
529
|
-
const sum = Object.hasOwn(a, 'sum');
|
|
530
|
-
const terms = sum ? a.sum : [{ from: a.source, ...(a.times !== undefined ? { times: a.times } : {}) }];
|
|
531
|
-
const plus = a.plus ?? 0;
|
|
532
|
-
/* Each term is checked for a loop as a one-term link is: none may read the measure it would set. */
|
|
533
|
-
for (const t of Array.isArray(terms) ? terms : [])
|
|
534
|
-
if (t?.from?.of === 'part' && t.from.part === a.part && t.from.axis === a.axis)
|
|
535
|
-
fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
|
|
536
|
-
const refs = termRefs(m, asset, terms, plus, path);
|
|
537
|
-
for (const [i, { ref }] of refs.entries()) {
|
|
538
|
-
const source = terms[i].from;
|
|
539
|
-
if (ref === mine)
|
|
540
|
-
fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
|
|
541
|
-
if (reaches(m, ref, mine))
|
|
542
|
-
fail('DIMENSION_CYCLE', path, `${String(source.of === 'part' ? source.part : source.of)} already follows ${a.part}'s ${a.axis}`);
|
|
543
|
-
}
|
|
544
|
-
const name = namer(m, `${a.part}.${a.axis}`);
|
|
545
|
-
const value = sum ? scaled(m, name, sumOf(m, name, refs, 'follows.sum'), 1, plus, 'follows') : scaled(m, name, refs[0].ref, refs[0].k, plus, 'follows');
|
|
546
|
-
const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
|
|
547
|
-
const was = node.args[0];
|
|
548
|
-
const owned = ownInput(m, a.part, was) ? was : `${a.part}.size.${a.axis}`;
|
|
549
|
-
node.args = [value, node.args[1]];
|
|
550
|
-
// Joints read this part's size; made again from their records, they read the size as it now is.
|
|
551
|
-
if (asset.joints?.length)
|
|
552
|
-
rewriteJoints(asset);
|
|
553
|
-
// The part's own control is no longer read by anything; leaving it would show a dial that changes nothing.
|
|
554
|
-
dropIfUnused(asset, was, owned);
|
|
555
|
-
return asset;
|
|
556
|
-
}
|
|
557
|
-
/** Whether one value is worked out from another, so a link cannot be made to close a loop. */
|
|
558
|
-
function reaches(m, from, target, seen = new Set()) {
|
|
559
|
-
if (from === target)
|
|
560
|
-
return true;
|
|
561
|
-
if (seen.has(from))
|
|
562
|
-
return false;
|
|
563
|
-
seen.add(from);
|
|
564
|
-
const writer = writerOf(m, from);
|
|
565
|
-
return !!writer && writer.args.some((ref) => reaches(m, ref, target, seen));
|
|
566
|
-
}
|
|
567
|
-
function unlinkDimension(asset, a) {
|
|
568
|
-
const m = asset.document.model;
|
|
569
|
-
const path = `${a.part}.${a.axis}`;
|
|
570
|
-
if (!(Number.isFinite(a.mm) && a.mm > 0))
|
|
571
|
-
fail('GEOMETRY_DOMAIN', path, 'a positive length is required');
|
|
572
|
-
stableDimension(m, a.part, a.axis, path);
|
|
573
|
-
if (!followsAlready(m, a.part, a.axis))
|
|
574
|
-
fail('EDIT_TARGET', path, `${a.part}'s ${a.axis} does not follow anything`);
|
|
575
|
-
const id = `${a.part}.size.${a.axis}`;
|
|
576
|
-
/*
|
|
577
|
-
The part's own field may still be there: the declared room was measured from it and still reads it, so it stayed
|
|
578
|
-
when the measure began to follow (ADR-0097). Setting the measure back to fixed takes that field back -- it is the
|
|
579
|
-
part's own number -- rather than refusing (found 2026-09-27: 「고정으로 되돌리기」 failed on any figure with a room).
|
|
580
|
-
Only a field of the part's own kind: anything else by that name is refused as before.
|
|
581
|
-
*/
|
|
582
|
-
const kept = m.inputs.find((i) => i.id === id);
|
|
583
|
-
if (kept && !(kept.role === 'design' && kept.unit === 'mm'))
|
|
584
|
-
fail('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
585
|
-
if (!kept)
|
|
586
|
-
m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
587
|
-
asset.designInputs[id] = a.mm;
|
|
588
|
-
const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
|
|
589
|
-
const was = node.args[0];
|
|
590
|
-
node.args = [id, node.args[1]];
|
|
591
|
-
if (asset.joints?.length)
|
|
592
|
-
rewriteJoints(asset);
|
|
593
|
-
dropIfUnused(asset, was);
|
|
594
|
-
return asset;
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* Remove a value nothing reads any more, and whatever fed only it. An input is removed only when it is the one
|
|
598
|
-
* this command owns — the part's own dimension control. A shared input, such as the instance's size, stays even
|
|
599
|
-
* when the command that stopped reading it was the last reader: it is the author's, not this command's.
|
|
600
|
-
*/
|
|
601
|
-
function dropIfUnused(asset, ref, ownedInput) {
|
|
602
|
-
const m = asset.document.model;
|
|
603
|
-
/* The graph, the declared room, the face it is mounted on and the places things are held all read values. */
|
|
604
|
-
// Joint records are consumers too. Their generated nodes are temporarily removed
|
|
605
|
-
// during a rewrite; alignment inputs must survive until those nodes are rebuilt.
|
|
606
|
-
const declared = new Set([...stringsIn(asset.occupancy), ...stringsIn(asset.document.capabilities), ...stringsIn(asset.joints)]);
|
|
607
|
-
const referenced = (r) => m.nodes.some((n) => n.args.includes(r) || Object.values(n.params ?? {}).includes(r)) || declared.has(r);
|
|
608
|
-
if (referenced(ref))
|
|
609
|
-
return;
|
|
610
|
-
const input = m.inputs.findIndex((i) => i.id === ref);
|
|
611
|
-
if (input >= 0) {
|
|
612
|
-
if (ref !== ownedInput)
|
|
613
|
-
return;
|
|
614
|
-
m.inputs.splice(input, 1);
|
|
615
|
-
delete asset.designInputs[ref];
|
|
616
|
-
delete asset.stateDefaults[ref];
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
const writer = writerOf(m, ref);
|
|
620
|
-
if (!writer || writer.op === 'place@1' || Object.values(writer.outputs).some((r) => r !== ref && referenced(r)))
|
|
621
|
-
return;
|
|
622
|
-
m.nodes = m.nodes.filter((n) => n !== writer);
|
|
623
|
-
for (const arg of writer.args)
|
|
624
|
-
dropIfUnused(asset, arg);
|
|
625
|
-
}
|
|
626
|
-
/** Which part each part is fastened to, read from the frames rather than from a second record of it. */
|
|
627
|
-
function parentOf(m, id) {
|
|
628
|
-
/* A row's template has no chain of its own -- its copies are composed by the row -- but it is fastened all the same. */
|
|
629
|
-
if (!nodeById(m, `${id}.chain`) && !nodeById(m, `${id}.repeat`))
|
|
630
|
-
return null;
|
|
631
|
-
// The seat rigid carries the part into the frame it is fastened to, moving or not (an old asset may have only a motion).
|
|
632
|
-
const above = nodeById(m, `${id}.seat.rigid`) ?? nodeById(m, `${id}.motion`);
|
|
633
|
-
const match = /^(.*)\.local$/.exec(String(above?.params?.to));
|
|
634
|
-
return match && match[1] !== id ? match[1] : null;
|
|
635
|
-
}
|
|
636
|
-
function attach(asset, a, twist) {
|
|
637
|
-
const m = asset.document.model;
|
|
638
|
-
const to = a.to;
|
|
639
|
-
const path = `${a.part} to ${to?.plane ?? to?.part}`;
|
|
640
|
-
if (!to || typeof to !== 'object')
|
|
641
|
-
fail('SCHEMA', path, 'a target face, or the plane the figure is mounted on, is required');
|
|
642
|
-
if (to.plane !== undefined)
|
|
643
|
-
return standOnPlane(asset, a, path);
|
|
644
|
-
if (a.part === to.part)
|
|
645
|
-
fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
|
|
646
|
-
const byCentre = a.face === 'centre';
|
|
647
|
-
if (!byCentre && !Object.hasOwn(FIGURE_FACES, String(a.face)))
|
|
648
|
-
fail('SCHEMA', path, `${String(a.face)} is not a face`);
|
|
649
|
-
/* The target's faces are the ones its shape declares: a portal's opening as well as its outer faces. */
|
|
650
|
-
const target = facesOfPart(m, to.part, path);
|
|
651
|
-
const theirFace = Object.hasOwn(target.faces, String(to.face)) ? target.faces[to.face] : undefined;
|
|
652
|
-
if (!theirFace)
|
|
653
|
-
fail('SCHEMA', path, `${to.part} has no face ${String(to.face)}; it has ${Object.keys(target.faces).join(', ')}`);
|
|
654
|
-
const facing = a.facing ?? 'meet';
|
|
655
|
-
if (facing !== 'meet' && facing !== 'flush')
|
|
656
|
-
fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
|
|
657
|
-
const axis = theirFace.axis;
|
|
658
|
-
const sign = theirFace.normal;
|
|
659
|
-
if (byCentre && facing !== 'meet')
|
|
660
|
-
fail('ATTACH_FACE', path, `${a.part} fastened by its centre sits on ${to.face}; flush has no meaning for a centre`);
|
|
661
|
-
/* By its centre, the part is measured as the face that would meet the target's; only its offset differs. */
|
|
662
|
-
if (byCentre)
|
|
663
|
-
a = { ...a, face: Object.keys(FIGURE_FACES).find(f => FIGURE_FACES[f] === axis && SIGN[f] === -sign) };
|
|
664
|
-
if (FIGURE_FACES[a.face] !== axis)
|
|
665
|
-
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`);
|
|
666
|
-
const wanted = Object.keys(FIGURE_FACES).find(f => FIGURE_FACES[f] === axis && SIGN[f] === (facing === 'meet' ? -sign : sign));
|
|
667
|
-
if (facing === 'meet' && a.face !== wanted)
|
|
668
|
-
fail('ATTACH_FACE', path, `to meet ${to.face}, ${a.part} offers its ${wanted}; pass flush to lay ${a.face} in the same plane instead`);
|
|
669
|
-
if (facing === 'flush' && a.face !== wanted)
|
|
670
|
-
fail('ATTACH_FACE', path, `to lie flush with ${to.face}, ${a.part} offers its ${wanted}`);
|
|
671
|
-
if (parentOf(m, a.part) && !a.replace)
|
|
672
|
-
fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to move it, or detach it first`);
|
|
673
|
-
// A part cannot end up its own ancestor.
|
|
674
|
-
for (let up = to.part; up; up = parentOf(m, up))
|
|
675
|
-
if (up === a.part)
|
|
676
|
-
fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
|
|
677
|
-
/*
|
|
678
|
-
Measured in the target's own frame, where its faces are the planes its shape declares: the seat is written into
|
|
679
|
-
that frame. A target standing turned is met there too -- the joint record's words are turned into that frame
|
|
680
|
-
before this runs (`writeJoint`).
|
|
681
|
-
*/
|
|
682
|
-
const theirs = ownSpansOf(m, to.part, path);
|
|
683
|
-
const mine = standingSpansOf(m, asset, a.part, path);
|
|
684
|
-
const mineAlong = mine[axis];
|
|
685
|
-
if ((!mineAlong || !Object.hasOwn(facesOfPart(m, a.part, path).faces, a.face)) && !byCentre)
|
|
686
|
-
fail('EDIT_TARGET', path, `${a.part} has no flat ${a.face} face`);
|
|
687
|
-
const name = namer(m, `${a.part}.on.${to.part}`);
|
|
688
|
-
/*
|
|
689
|
-
Along the face's own axis. `meet`: the part's centre is half its own depth past the target's face, plus the
|
|
690
|
-
gap. `flush`: the part's centre is half its own depth back from that face, so its named face lies in it.
|
|
691
|
-
*/
|
|
692
|
-
const outward = facing === 'meet' ? sign : -sign;
|
|
693
|
-
const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
|
|
694
|
-
/*
|
|
695
|
-
Split at the point fastened at: the seat is on the target's face (its centre, or level with an edge), the part's
|
|
696
|
-
centre is half its own depth and the gap past it. A turn added later turns about that point.
|
|
697
|
-
*/
|
|
698
|
-
const zero = constantOf(m, 'mm', 0, 'd');
|
|
699
|
-
const translation = { x: '', y: '', z: '' };
|
|
700
|
-
const offset = { x: zero, y: zero, z: zero };
|
|
701
|
-
/* By its centre, the gap goes into the seat: the point fastened at -- and turned about -- is the part's middle. */
|
|
702
|
-
translation[axis] = sumOf(m, name, [...planeOf(target.shape, theirFace), ...(byCentre ? [{ ref: gap, k: outward }] : [])], `seat.${axis}`);
|
|
703
|
-
const ownFace = byCentre ? mineAlong && !mineAlong.centred ? [...half(mineAlong.lo), ...half(mineAlong.hi)] : [] : SIGN[a.face] > 0 ? mineAlong.hi : mineAlong.lo;
|
|
704
|
-
offset[axis] = byCentre ? ownFace.length ? sumOf(m, name, neg(ownFace), `along.${axis}`) : zero : sumOf(m, name, [...neg(ownFace), { ref: gap, k: outward }], `along.${axis}`);
|
|
705
|
-
/*
|
|
706
|
-
Across the face. Centred by default; min and max keep the two parts' edges level as either is resized -- and the
|
|
707
|
-
point fastened at is where those edges meet: the seat on the target's edge, the part's own edge on it. A door
|
|
708
|
-
fastened level with the frame's min edge turns about that edge, as a hinge does (2026-09-25); centred, the point
|
|
709
|
-
is the middle of the face. A shape not centred on its origin (a portal stands on its bottom) is centred by its
|
|
710
|
-
faces, not by its origin.
|
|
711
|
-
*/
|
|
712
|
-
for (const other of AXES.filter(x => x !== axis)) {
|
|
713
|
-
const how = a.align?.[other] ?? 'centre';
|
|
714
|
-
const t = theirs[other], o = mine[other];
|
|
715
|
-
const middle = (span) => (span && !span.centred ? [...half(span.lo), ...half(span.hi)] : []);
|
|
716
|
-
const centreOf = (span, hint) => middle(span).length ? sumOf(m, name, middle(span), hint) : constantOf(m, 'mm', 0, 'd');
|
|
717
|
-
const ownCentre = () => (middle(o).length ? sumOf(m, name, neg(middle(o)), `own-centre.${other}`) : zero);
|
|
718
|
-
if (how === 'centre') {
|
|
719
|
-
translation[other] = centreOf(t, `centre.${other}`);
|
|
720
|
-
offset[other] = ownCentre();
|
|
721
|
-
}
|
|
722
|
-
else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm')) {
|
|
723
|
-
const mm = measureRef(m, asset, name, how.mm, `align.${other}`, path);
|
|
724
|
-
translation[other] = middle(t).length ? sumOf(m, name, [...middle(t), { ref: mm, k: 1 }], `align.${other}.from-centre`) : mm;
|
|
725
|
-
offset[other] = ownCentre();
|
|
726
|
-
}
|
|
727
|
-
else if (how === 'min' || how === 'max') {
|
|
728
|
-
if (!t)
|
|
729
|
-
fail('ATTACH_FACE', path, `${to.part} has no edges along ${other} to line ${a.part} up with; give the centre or a measured offset`);
|
|
730
|
-
if (!o)
|
|
731
|
-
fail('ATTACH_FACE', path, `${a.part} has no edges along ${other} to line up; give the centre or a measured offset`);
|
|
732
|
-
translation[other] = sumOf(m, name, how === 'min' ? t.lo : t.hi, `edge.${other}`);
|
|
733
|
-
offset[other] = sumOf(m, name, neg(how === 'min' ? o.lo : o.hi), `own-edge.${other}`);
|
|
734
|
-
}
|
|
735
|
-
else
|
|
736
|
-
fail('SCHEMA', path, `${String(how)} is not an alignment`);
|
|
737
|
-
}
|
|
738
|
-
/*
|
|
739
|
-
Twisted about the face's normal: the seat is the face's centre, and the part's own offset across the face -- its
|
|
740
|
-
distance and its own centre -- turns with it, so a blade set out along X and twisted 120° stands out along the
|
|
741
|
-
blade, about the hub. With no twist this is the same place written as before.
|
|
742
|
-
*/
|
|
743
|
-
if (twist && twist.deg) {
|
|
744
|
-
const [p, q] = ACROSS[axis];
|
|
745
|
-
for (const other of [p, q]) {
|
|
746
|
-
const how = a.align?.[other] ?? 'centre';
|
|
747
|
-
if (how === 'min' || how === 'max')
|
|
748
|
-
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`);
|
|
749
|
-
}
|
|
750
|
-
const across = (other) => {
|
|
751
|
-
const how = a.align?.[other] ?? 'centre';
|
|
752
|
-
const o = mine[other];
|
|
753
|
-
const own = o && !o.centred ? neg([...half(o.lo), ...half(o.hi)]) : [];
|
|
754
|
-
return typeof how === 'object' && how && Object.hasOwn(how, 'mm') ? [{ ref: measureRef(m, asset, name, how.mm, `twist.${other}`, path), k: 1 }, ...own] : own;
|
|
755
|
-
};
|
|
756
|
-
const [vp, vq] = [across(p), across(q)];
|
|
757
|
-
/*
|
|
758
|
-
The turn's factors are named by the angle, as the record gives it -- not by their value, which may carry float
|
|
759
|
-
noise that depends on how it was computed, so a rewrite named them apart (JOINT_DRIFT, the architect's fan,
|
|
760
|
-
2026-09-25). Their values are rounded to twelve places, which is the second defence.
|
|
761
|
-
*/
|
|
762
|
-
const exact = (v) => {
|
|
763
|
-
const r = Math.round(v * 1e12) / 1e12;
|
|
764
|
-
return r === 0 ? 0 : r;
|
|
765
|
-
};
|
|
766
|
-
const tag = String(round6(twist.deg)).replace('-', 'm').replace('.', '_');
|
|
767
|
-
const factor = (fn) => {
|
|
768
|
-
const id = `twist.${fn}.${tag}`;
|
|
769
|
-
const rad = (twist.deg * Math.PI) / 180;
|
|
770
|
-
if (!m.constants.some((c) => c.id === id))
|
|
771
|
-
m.constants.push({ id, unit: 'ratio', value: exact(fn === 'cos' ? Math.cos(rad) : fn === 'sin' ? Math.sin(rad) : -Math.sin(rad)) });
|
|
772
|
-
return id;
|
|
773
|
-
};
|
|
774
|
-
const times = (ref, k, hint) => {
|
|
775
|
-
const id = name(hint);
|
|
776
|
-
m.nodes.push({ id, op: 'mul@1', args: [ref, k], outputs: { value: `${id}.value` } });
|
|
777
|
-
return `${id}.value`;
|
|
778
|
-
};
|
|
779
|
-
const plus = (a, b, hint) => {
|
|
780
|
-
const id = name(hint);
|
|
781
|
-
m.nodes.push({ id, op: 'add@1', args: [a, b], outputs: { value: `${id}.value` } });
|
|
782
|
-
return `${id}.value`;
|
|
783
|
-
};
|
|
784
|
-
for (const other of [p, q]) {
|
|
785
|
-
const t = theirs[other];
|
|
786
|
-
translation[other] = t && !t.centred ? sumOf(m, name, [...half(t.lo), ...half(t.hi)], `twist-centre.${other}`) : constantOf(m, 'mm', 0, 'd');
|
|
787
|
-
}
|
|
788
|
-
const sp = sumOf(m, name, vp, `twist-own.${p}`), sq = sumOf(m, name, vq, `twist-own.${q}`);
|
|
789
|
-
offset[p] = plus(times(sp, factor('cos'), `twisted.${p}.cos`), times(sq, factor('nsin'), `twisted.${p}.sin`), `twisted.${p}`);
|
|
790
|
-
offset[q] = plus(times(sp, factor('sin'), `twisted.${q}.sin`), times(sq, factor('cos'), `twisted.${q}.cos`), `twisted.${q}`);
|
|
791
|
-
}
|
|
792
|
-
reseat(asset, a.part, `${to.part}.local`, { seat: [translation.x, translation.y, translation.z], pose: [offset.x, offset.y, offset.z] });
|
|
793
|
-
return asset;
|
|
794
|
-
}
|
|
795
|
-
/**
|
|
796
|
-
* Standing a part on the plane the figure is mounted on. The plane has no width and no depth, so there is
|
|
797
|
-
* nothing to line the part's edges up with: across the plane, only the centre or a measured offset.
|
|
798
|
-
*/
|
|
799
|
-
/** The faces a part is fastened to the mounting plane by, for a figure placed so (ADR-0093). */
|
|
800
|
-
function planeFacesOf(placement) {
|
|
801
|
-
return placement === 'wall' ? ['back'] : ['bottom', 'top'];
|
|
802
|
-
}
|
|
803
|
-
function standOnPlane(asset, a, path) {
|
|
804
|
-
const m = asset.document.model;
|
|
805
|
-
const to = a.to;
|
|
806
|
-
if (to.plane !== 'mounting-plane')
|
|
807
|
-
fail('SCHEMA', path, `${String(to.plane)} is not a plane a part can stand on`);
|
|
808
|
-
if (a.facing !== undefined && a.facing !== 'meet')
|
|
809
|
-
fail('SCHEMA', path, 'a part standing on the plane meets it; flush says nothing here');
|
|
810
|
-
/* Which plane it is, the figure's placement says: the floor or the ceiling is Y = 0, a wall is Z = 0 (ADR-0093). */
|
|
811
|
-
const placement = figurePlacementOf(asset);
|
|
812
|
-
const takes = planeFacesOf(placement);
|
|
813
|
-
const normal = placement === 'wall' ? 'z' : 'y';
|
|
814
|
-
if (!takes.includes(a.face))
|
|
815
|
-
fail('ATTACH_FACE', path, placement === 'wall'
|
|
816
|
-
? `${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`
|
|
817
|
-
: `${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`);
|
|
818
|
-
if (parentOf(m, a.part) && !a.replace)
|
|
819
|
-
fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
|
|
820
|
-
const mine = standingSpansOf(m, asset, a.part, path);
|
|
821
|
-
if (!mine[normal] || !Object.hasOwn(facesOfPart(m, a.part, path).faces, a.face))
|
|
822
|
-
fail('EDIT_TARGET', path, `${a.part} has no flat ${a.face} face`);
|
|
823
|
-
const name = namer(m, `${a.part}.on.plane`);
|
|
824
|
-
/* Out of the plane: a bottom stands up from the floor, a back comes out of the wall, a top hangs down. */
|
|
825
|
-
const outward = a.face === 'top' ? -1 : 1;
|
|
826
|
-
const gap = measureRef(m, asset, name, a.gap, `gap.${normal}`, path);
|
|
827
|
-
const translation = { x: '', y: '', z: '' };
|
|
828
|
-
translation[normal] = sumOf(m, name, [...neg(outward > 0 ? mine[normal].lo : mine[normal].hi), { ref: gap, k: outward }], `stand.${normal}`);
|
|
829
|
-
const zero = constantOf(m, 'mm', 0, 'd');
|
|
830
|
-
const pose = { x: zero, y: zero, z: zero };
|
|
831
|
-
pose[normal] = translation[normal];
|
|
832
|
-
for (const other of AXES.filter(x => x !== normal)) {
|
|
833
|
-
const how = a.align?.[other] ?? 'centre';
|
|
834
|
-
const span = mine[other];
|
|
835
|
-
if (how === 'centre')
|
|
836
|
-
translation[other] = constantOf(m, 'mm', 0, 'd');
|
|
837
|
-
else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
|
|
838
|
-
translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
|
|
839
|
-
else if ((how === 'min' || how === 'max') && sizeInputOf(m, other)) {
|
|
840
|
-
/*
|
|
841
|
-
The plane has no edges, but a figure that declares its size has a box on it: its sides are half the size
|
|
842
|
-
either way of the origin. A part level with one keeps to it when the figure is resized -- a door's post at
|
|
843
|
-
the figure's left, a gantry's legs at its corners (2026-09-25).
|
|
844
|
-
*/
|
|
845
|
-
if (!span)
|
|
846
|
-
fail('ATTACH_FACE', path, `${a.part} has no edges along ${other} to line up with the figure's side`);
|
|
847
|
-
const s = how === 'min' ? -1 : 1;
|
|
848
|
-
translation[other] = sumOf(m, name, [{ ref: sourceRef(m, asset, { of: 'instance', axis: other }, path), k: s * 0.5 }], `figure-edge.${other}`);
|
|
849
|
-
pose[other] = sumOf(m, name, neg(how === 'min' ? span.lo : span.hi), `own-edge.${other}`);
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
else
|
|
853
|
-
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`);
|
|
854
|
-
/* A shape not centred on its origin stands centred by its faces. */
|
|
855
|
-
if (span && !span.centred)
|
|
856
|
-
pose[other] = sumOf(m, name, neg([...half(span.lo), ...half(span.hi)]), `own-centre.${other}`);
|
|
857
|
-
}
|
|
858
|
-
// The point it stands on is on the plane, under its centre: a turn about the plane's normal turns it where it stands.
|
|
859
|
-
const seat = AXES.map(x => (x === normal ? zero : translation[x]));
|
|
860
|
-
reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat: [seat[0], seat[1], seat[2]], pose: [pose.x, pose.y, pose.z] });
|
|
861
|
-
return asset;
|
|
862
|
-
}
|
|
863
|
-
function detach(asset, a) {
|
|
864
|
-
const m = asset.document.model;
|
|
865
|
-
const path = a.part;
|
|
866
|
-
const parent = parentOf(m, a.part);
|
|
867
|
-
if (!parent)
|
|
868
|
-
fail('EDIT_TARGET', path, `${a.part} is not fastened to anything`);
|
|
869
|
-
/*
|
|
870
|
-
Detaching keeps the part where it stands, and keeps it moving the way it moved. Taking the world pose and
|
|
871
|
-
writing it in as the part's own pose did neither: the part's motion was already in that pose and was then
|
|
872
|
-
applied a second time, so a part with 20 mm of travel jumped to 40 (designer's counterexample
|
|
873
|
-
2026-09-23).
|
|
874
|
-
|
|
875
|
-
What the part's pose has to gain is exactly what the parent's chain was contributing — and that is a plain
|
|
876
|
-
translation only while nothing above the part is turned. Where something is, the two cannot be separated
|
|
877
|
-
into a translation the pose can absorb, and this refuses rather than moving the part.
|
|
878
|
-
*/
|
|
879
|
-
for (let up = parent; up; up = parentOf(m, up))
|
|
880
|
-
requireUnturned(m, asset, up, path);
|
|
881
|
-
const evaluated = compileFigureAssetUnjointed(asset).evaluate();
|
|
882
|
-
const above = evaluated.values?.[nodeById(m, `${parent}.world`)?.outputs?.pose ?? nodeById(m, `${parent}.chain`)?.outputs?.pose ?? nodeById(m, `${parent}.pose`).outputs.pose];
|
|
883
|
-
if (!above || !above.t)
|
|
884
|
-
fail('TARGET_ABSENT', path, `${parent} has no pose to read`);
|
|
885
|
-
const turned = above.r.some((row, i) => row.some((v, j) => Math.abs(v - (i === j ? 1 : 0)) > 1e-9));
|
|
886
|
-
if (turned)
|
|
887
|
-
fail('EDIT_TARGET', path, `${parent} is turned; taking ${a.part} off it would move it, and this command does not move a part`);
|
|
888
|
-
const { pose } = partOf(m, a.part, path);
|
|
889
|
-
const own = evaluated.values?.[pose.outputs.pose];
|
|
890
|
-
if (!own || !own.t)
|
|
891
|
-
fail('TARGET_ABSENT', path, `${a.part} has no pose to read`);
|
|
892
|
-
/*
|
|
893
|
-
Where it stands, as plain numbers: what the parent was contributing plus what the part's own pose was. Its
|
|
894
|
-
motion is left out, because the motion node stays and would otherwise be counted twice. Numbers rather than
|
|
895
|
-
references, because a part that is no longer fastened to the plate should not still grow with it.
|
|
896
|
-
*/
|
|
897
|
-
/*
|
|
898
|
-
The part gets its own position numbers back, as a part made in the modeller has them, so it can be moved again
|
|
899
|
-
by hand or by the gizmo. Where one of those names is taken, that axis stays a plain number.
|
|
900
|
-
*/
|
|
901
|
-
/*
|
|
902
|
-
A part that moves keeps turning about the point it was fastened at: that point becomes its seat in the asset, its
|
|
903
|
-
own numbers the offset from it. A part that does not move gets its place back as plain position numbers.
|
|
904
|
-
*/
|
|
905
|
-
const seatAt = evaluated.values?.[`${a.part}.seat.value`]?.t ?? [0, 0, 0];
|
|
906
|
-
const moving = !!nodeById(m, `${a.part}.motion`);
|
|
907
|
-
const pivot = AXES.map((_, i) => round6(above.t[i] + seatAt[i]));
|
|
908
|
-
const translation = AXES.map((axis, i) => {
|
|
909
|
-
const value = moving ? round6(own.t[i]) : round6(pivot[i] + own.t[i]);
|
|
910
|
-
const id = `${a.part}.position.${axis}`;
|
|
911
|
-
if (m.inputs.some((input) => input.id === id) || m.nodes.some((n) => Object.values(n.outputs).includes(id)))
|
|
912
|
-
return constantOf(m, 'mm', value, 'd');
|
|
913
|
-
m.inputs.push({ id, unit: 'mm', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
914
|
-
asset.designInputs[id] = value;
|
|
915
|
-
return id;
|
|
916
|
-
});
|
|
917
|
-
const seat = AXES.map((_, i) => constantOf(m, 'mm', moving ? pivot[i] : 0, 'd'));
|
|
918
|
-
reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat, pose: translation });
|
|
919
|
-
return asset;
|
|
920
|
-
}
|
|
921
|
-
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
922
|
-
/**
|
|
923
|
-
* Put a part's pose on a new frame with a new translation, and rebuild the chain that carries it to the asset.
|
|
924
|
-
* The part's own id, its shape and its `placed` reference all stay as they were.
|
|
925
|
-
*/
|
|
926
|
-
/**
|
|
927
|
-
* Where a part is fastened, in two translations that are never merged: `seat` is the point it is fastened at, in the
|
|
928
|
-
* frame it is fastened to -- a point on the target's face; `pose` is the part's centre from that point, in its own
|
|
929
|
-
* seat frame. A motion sits between the two, so a turn turns about the point the part is fastened at: a hinge, a
|
|
930
|
-
* joint, a lid (chief architect's ruling 2026-09-25; before this the seat was the target's centre and a fastened
|
|
931
|
-
* part swung about the middle of what it was fastened to).
|
|
932
|
-
*
|
|
933
|
-
* <id>.local --pose--> <id>.seat [--motion--> <id>.joint] --seat.rigid--> <to>
|
|
934
|
-
*
|
|
935
|
-
* Without `place` the part keeps both translations and only its frames are rewired (a motion added or taken away).
|
|
936
|
-
*/
|
|
937
|
-
function reseat(asset, id, to, place) {
|
|
938
|
-
const m = asset.document.model;
|
|
939
|
-
const { place: placed, pose } = partOf(m, id, id);
|
|
940
|
-
const old = place ? pose.args.slice(0, 3) : [];
|
|
941
|
-
if (place)
|
|
942
|
-
pose.args = [...place.pose, ...pose.args.slice(3)];
|
|
943
|
-
pose.params = { ...pose.params, to: `${id}.seat` };
|
|
944
|
-
const motion = nodeById(m, `${id}.motion`);
|
|
945
|
-
const zero = constantOf(m, 'mm', 0, 'd');
|
|
946
|
-
const noTurn = constantOf(m, 'deg', 0, 'a');
|
|
947
|
-
let seat = nodeById(m, `${id}.seat.rigid`);
|
|
948
|
-
if (!seat) {
|
|
949
|
-
seat = { id: `${id}.seat.rigid`, op: 'rigid@1', args: [zero, zero, zero, noTurn, noTurn, noTurn], outputs: { pose: `${id}.seat.value` }, params: {} };
|
|
950
|
-
m.nodes.push(seat);
|
|
951
|
-
}
|
|
952
|
-
const oldSeat = place ? seat.args.slice(0, 3) : [];
|
|
953
|
-
if (place)
|
|
954
|
-
seat.args = [...place.seat, ...seat.args.slice(3)];
|
|
955
|
-
seat.params = { from: motion ? `${id}.joint` : `${id}.seat`, to };
|
|
956
|
-
// The motion turns the seat frame about its origin -- the point fastened at -- into the joint frame.
|
|
957
|
-
let turned = nodeById(m, `${id}.turned`);
|
|
958
|
-
if (motion) {
|
|
959
|
-
motion.params = { ...motion.params, from: `${id}.seat`, to: `${id}.joint` };
|
|
960
|
-
if (!turned) {
|
|
961
|
-
turned = { id: `${id}.turned`, op: 'compose@1', args: [motion.outputs.pose, pose.outputs.pose], outputs: { pose: `${id}.turned.value` } };
|
|
962
|
-
m.nodes.push(turned);
|
|
963
|
-
}
|
|
964
|
-
else
|
|
965
|
-
turned.args = [motion.outputs.pose, pose.outputs.pose];
|
|
966
|
-
}
|
|
967
|
-
else if (turned)
|
|
968
|
-
m.nodes = m.nodes.filter((n) => n !== turned);
|
|
969
|
-
let chain = nodeById(m, `${id}.chain`);
|
|
970
|
-
if (!chain) {
|
|
971
|
-
chain = { id: `${id}.chain`, op: 'compose@1', args: ['', ''], outputs: { pose: `${id}.chain.value` } };
|
|
972
|
-
m.nodes.push(chain);
|
|
973
|
-
}
|
|
974
|
-
chain.args = [seat.outputs.pose, motion ? turned.outputs.pose : pose.outputs.pose];
|
|
975
|
-
const parent = /^(.*)\.local$/.exec(to);
|
|
976
|
-
let world = nodeById(m, `${id}.world`);
|
|
977
|
-
if (parent && parent[1] !== id) {
|
|
978
|
-
/* A row's template has no chain of its own: what hangs from it hangs in its copy's frame, turned as it turns. */
|
|
979
|
-
const above = nodeById(m, `${parent[1]}.world`)?.outputs?.pose ??
|
|
980
|
-
nodeById(m, `${parent[1]}.chain`)?.outputs?.pose ??
|
|
981
|
-
nodeById(m, `${parent[1]}.turned`)?.outputs?.pose ??
|
|
982
|
-
nodeById(m, `${parent[1]}.pose`).outputs.pose;
|
|
983
|
-
if (!world) {
|
|
984
|
-
world = { id: `${id}.world`, op: 'compose@1', args: [above, chain.outputs.pose], outputs: { pose: `${id}.world.value` } };
|
|
985
|
-
m.nodes.push(world);
|
|
986
|
-
}
|
|
987
|
-
else
|
|
988
|
-
world.args = [above, chain.outputs.pose];
|
|
989
|
-
placed.args = [placed.args[0], world.outputs.pose];
|
|
990
|
-
}
|
|
991
|
-
else {
|
|
992
|
-
if (world)
|
|
993
|
-
m.nodes = m.nodes.filter((n) => n !== world);
|
|
994
|
-
placed.args = [placed.args[0], chain.outputs.pose];
|
|
995
|
-
}
|
|
996
|
-
/*
|
|
997
|
-
The part's own position numbers, once nothing reads them, go with the old pose: a fastened part left its
|
|
998
|
-
position fields in the editor, and typing into them changed nothing (seen on :3300, 2026-09-24).
|
|
999
|
-
*/
|
|
1000
|
-
for (const ref of [...old, ...oldSeat])
|
|
1001
|
-
dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
|
|
1002
|
-
}
|
|
35
|
+
import { fail, clone, dropIfUnused, sizeInputOf } from "./authoring-nodes.js";
|
|
36
|
+
import { jointAbove, rewriteJoints, setJoint, applyFaceRemap, removeJoint, fastenAsJoint, setRepeat } from "./joint-writing.js";
|
|
37
|
+
import { linkDimension, unlinkDimension } from "./authoring-dimensions.js";
|
|
38
|
+
import { declareInstanceSize, narrowDeclaredSize, holdsOverDeclaredSize } from "./authoring-size.js";
|
|
39
|
+
import { nudgePart, scaleUniformly, followSizeInProportion, scalePosition, fixPosition } from "./authoring-proportion.js";
|
|
40
|
+
import { setPlacement, declareOccupancy, setMountingFace, clearMountingFace } from "./authoring-occupancy.js";
|
|
41
|
+
import { addBinding, removeBinding, setSlotLayout, removeSlotLayout } from "./authoring-bindings.js";
|
|
1003
42
|
/** Apply one authoring command. Throws on any refusal, leaving the asset it was given untouched. */
|
|
1004
43
|
export function applyFigureAuthoring(source, action, consent) {
|
|
1005
44
|
/*
|
|
@@ -1085,8 +124,18 @@ function authoring(source, action) {
|
|
|
1085
124
|
if (!j)
|
|
1086
125
|
fail('EDIT_TARGET', `${action.part}.repeat`, `${action.part} is not fastened to anything; fasten it to the part it repeats across first`);
|
|
1087
126
|
const count = action.count === undefined || action.count === 'fills' ? undefined : action.count;
|
|
1088
|
-
const rise = action.rise
|
|
1089
|
-
|
|
127
|
+
const rise = action.rise
|
|
128
|
+
? { rise: { axis: String(action.rise.axis).toUpperCase(), mm: action.rise.mm } }
|
|
129
|
+
: {};
|
|
130
|
+
setJoint(asset, {
|
|
131
|
+
...j,
|
|
132
|
+
repeat: {
|
|
133
|
+
axis: String(action.axis).toUpperCase(),
|
|
134
|
+
pitch: action.pitch,
|
|
135
|
+
...(count !== undefined ? { count } : {}),
|
|
136
|
+
...rise
|
|
137
|
+
}
|
|
138
|
+
});
|
|
1090
139
|
break;
|
|
1091
140
|
}
|
|
1092
141
|
case 'remove-repeat': {
|
|
@@ -1126,14 +175,22 @@ function authoring(source, action) {
|
|
|
1126
175
|
const path = `inputs.${String(action.id)}`;
|
|
1127
176
|
if (typeof action.id !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(action.id))
|
|
1128
177
|
fail('SCHEMA', path, 'an id of letters, digits, dot, dash or underscore');
|
|
1129
|
-
if (m.inputs.some((i) => i.id === action.id) ||
|
|
178
|
+
if (m.inputs.some((i) => i.id === action.id) ||
|
|
179
|
+
m.constants.some((c) => c.id === action.id) ||
|
|
180
|
+
m.nodes.some((n) => n.id === action.id || Object.values(n.outputs).includes(action.id)))
|
|
1130
181
|
fail('DUPLICATE_WRITER', path, `${action.id} is already a name in the figure`);
|
|
1131
182
|
if (!['mm', 'ratio', 'deg', 'count'].includes(action.unit))
|
|
1132
183
|
fail('SCHEMA', `${path}.unit`, 'mm, ratio, deg or count');
|
|
1133
184
|
if (![action.min, action.max, action.value].every(Number.isFinite) || !(action.min < action.max))
|
|
1134
185
|
fail('INPUT_RANGE', path, 'a finite range, its least below its most');
|
|
1135
186
|
if (action.value < action.min || action.value > action.max)
|
|
1136
|
-
fail('INPUT_RANGE', path, `${action.value} is outside ${action.min}…${action.max}
|
|
187
|
+
fail('INPUT_RANGE', path, `${action.value} is outside ${action.min}…${action.max}`, {
|
|
188
|
+
reason: 'value-outside-range',
|
|
189
|
+
input: action.id,
|
|
190
|
+
value: action.value,
|
|
191
|
+
min: action.min,
|
|
192
|
+
max: action.max
|
|
193
|
+
});
|
|
1137
194
|
m.inputs.push({ id: action.id, unit: action.unit, min: action.min, max: action.max, role: 'design' });
|
|
1138
195
|
asset.designInputs[action.id] = action.value;
|
|
1139
196
|
break;
|
|
@@ -1149,14 +206,24 @@ function authoring(source, action) {
|
|
|
1149
206
|
fail('INPUT_RANGE', path, 'a finite range, its least below its most');
|
|
1150
207
|
const value = asset.designInputs[action.input];
|
|
1151
208
|
if (value < action.min || value > action.max)
|
|
1152
|
-
fail('INPUT_RANGE', path, `the value now, ${value}, is outside ${action.min}…${action.max}
|
|
209
|
+
fail('INPUT_RANGE', path, `the value now, ${value}, is outside ${action.min}…${action.max}`, {
|
|
210
|
+
reason: 'value-outside-range',
|
|
211
|
+
input: action.input,
|
|
212
|
+
value,
|
|
213
|
+
min: action.min,
|
|
214
|
+
max: action.max
|
|
215
|
+
});
|
|
1153
216
|
input.min = action.min;
|
|
1154
217
|
input.max = action.max;
|
|
1155
218
|
break;
|
|
1156
219
|
}
|
|
1157
220
|
case 'publish-input': {
|
|
1158
221
|
checkPublicInput(asset.document.model.inputs, action.input, `publicInputs.${String(action.input)}`);
|
|
1159
|
-
const entry = {
|
|
222
|
+
const entry = {
|
|
223
|
+
input: action.input,
|
|
224
|
+
label: typeof action.label === 'string' ? action.label.trim() : action.label,
|
|
225
|
+
...(action.hint !== undefined ? { hint: action.hint } : {})
|
|
226
|
+
};
|
|
1160
227
|
const list = (asset.publicInputs ?? []).filter(p => p.input !== action.input);
|
|
1161
228
|
asset.publicInputs = [...list, entry];
|
|
1162
229
|
break;
|
|
@@ -1216,7 +283,8 @@ function authoring(source, action) {
|
|
|
1216
283
|
pays for the second compile.
|
|
1217
284
|
*/
|
|
1218
285
|
let result = asset;
|
|
1219
|
-
if ((action.kind === 'link-dimension' || action.kind === 'make-repeat' || action.kind === 'set-joint') &&
|
|
286
|
+
if ((action.kind === 'link-dimension' || action.kind === 'make-repeat' || action.kind === 'set-joint') &&
|
|
287
|
+
action.narrowRange) {
|
|
1220
288
|
compileFigureAsset(asset);
|
|
1221
289
|
result = clone(asset);
|
|
1222
290
|
narrowDeclaredSize(result);
|
|
@@ -1225,7 +293,6 @@ function authoring(source, action) {
|
|
|
1225
293
|
holdsOverDeclaredSize(result, source);
|
|
1226
294
|
return result;
|
|
1227
295
|
}
|
|
1228
|
-
/* ----------------------------------------------------------- instance size */
|
|
1229
296
|
function followSize(source, a) {
|
|
1230
297
|
const path = `${a.part}.follow-size`;
|
|
1231
298
|
const shape = source.document.model.nodes.find((n) => n.id === `${a.part}.shape`);
|
|
@@ -1244,9 +311,17 @@ function followSize(source, a) {
|
|
|
1244
311
|
const at = figurePartsExtentOf(asset);
|
|
1245
312
|
const start = defaultInstanceSizeRange(at);
|
|
1246
313
|
const range = foldInstanceSizeRange(start, figureInstanceSizeLimitsOf(asset, start)).range;
|
|
1247
|
-
asset = applyFigureAuthoring(asset, {
|
|
314
|
+
asset = applyFigureAuthoring(asset, {
|
|
315
|
+
kind: 'declare-instance-size',
|
|
316
|
+
range,
|
|
317
|
+
at,
|
|
318
|
+
replace: AXES.some(axis => sizeInputOf(m, axis))
|
|
319
|
+
});
|
|
1248
320
|
}
|
|
1249
|
-
const values = compileFigureGraph(asset.document.model).evaluate({
|
|
321
|
+
const values = compileFigureGraph(asset.document.model).evaluate({
|
|
322
|
+
...asset.designInputs,
|
|
323
|
+
...asset.stateDefaults
|
|
324
|
+
}).values;
|
|
1250
325
|
const shapeNow = asset.document.model.nodes.find((n) => n.id === `${a.part}.shape`);
|
|
1251
326
|
const argsNow = shapeNow.args;
|
|
1252
327
|
const editNow = figureShapeProviderOf(shapeNow)?.edit;
|
|
@@ -1256,2773 +331,16 @@ function followSize(source, a) {
|
|
|
1256
331
|
const size = asset.designInputs[FIGURE_INSTANCE_SIZE_INPUTS[axis]];
|
|
1257
332
|
if (!(typeof measure === 'number' && measure > 0 && typeof size === 'number' && size > 0))
|
|
1258
333
|
fail('EDIT_TARGET', `${path}.${axis}`, `${a.part}'s ${axis} or the figure's size is not a length to follow`);
|
|
1259
|
-
asset = applyFigureAuthoring(asset, {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
if (declared.length && !a.replace)
|
|
1268
|
-
fail('INSTANCE_SIZE_DECLARED', 'size', `the instance size is already declared (${declared.map(x => FIGURE_INSTANCE_SIZE_INPUTS[x]).join(', ')}); pass replace to change its range`);
|
|
1269
|
-
if (!a.range || typeof a.range !== 'object')
|
|
1270
|
-
fail('SCHEMA', 'size', 'a range per axis is required');
|
|
1271
|
-
let measured;
|
|
1272
|
-
const authoredOf = (axis) => {
|
|
1273
|
-
const given = a.at?.[axis];
|
|
1274
|
-
if (given !== undefined)
|
|
1275
|
-
return given;
|
|
1276
|
-
const existing = asset.designInputs[FIGURE_INSTANCE_SIZE_INPUTS[axis]];
|
|
1277
|
-
if (typeof existing === 'number')
|
|
1278
|
-
return existing;
|
|
1279
|
-
/* The parts as they rest, not the room their joints carry them through (a door's swing). */
|
|
1280
|
-
if (!measured)
|
|
1281
|
-
measured = figurePartsExtentOf(asset);
|
|
1282
|
-
return measured[axis];
|
|
1283
|
-
};
|
|
1284
|
-
for (const axis of AXES) {
|
|
1285
|
-
const r = a.range[axis];
|
|
1286
|
-
const at = authoredOf(axis);
|
|
1287
|
-
const path = FIGURE_INSTANCE_SIZE_INPUTS[axis];
|
|
1288
|
-
if (!r || ![r.min, r.max, at].every(Number.isFinite))
|
|
1289
|
-
fail('SCHEMA', path, 'finite min, max and size required');
|
|
1290
|
-
if (!(r.min > 0))
|
|
1291
|
-
fail('INSTANCE_SIZE_RANGE', path, `the smallest size must be above zero; got ${r.min}`);
|
|
1292
|
-
if (!(r.min <= at && at <= r.max))
|
|
1293
|
-
fail('INSTANCE_SIZE_RANGE', path, `the authored size ${at} mm must lie within ${r.min}…${r.max} mm`);
|
|
1294
|
-
const input = sizeInputOf(m, axis);
|
|
1295
|
-
if (input)
|
|
1296
|
-
Object.assign(input, { min: r.min, max: r.max });
|
|
1297
|
-
else
|
|
1298
|
-
m.inputs.push({ id: path, unit: 'mm', min: r.min, max: r.max, role: 'design' });
|
|
1299
|
-
asset.designInputs[path] = at;
|
|
1300
|
-
}
|
|
1301
|
-
return asset;
|
|
1302
|
-
}
|
|
1303
|
-
/**
|
|
1304
|
-
* Every command leaves a figure that can take each size it declares: the kernel is asked at the ends of the declared
|
|
1305
|
-
* range, and a size the figure cannot take is refused by name with the size it can go to. This is where a link that
|
|
1306
|
-
* makes a part 200 mm shorter than the instance is caught when the instance may be 1 mm wide — the kernel alone
|
|
1307
|
-
* accepts it, and the board then fails to draw the figure at small sizes.
|
|
1308
|
-
*/
|
|
1309
|
-
/** Where the declared size range reaches past what the figure holds: per axis, the bound it would need, and who sets it. */
|
|
1310
|
-
function sizeRangeGaps(asset) {
|
|
1311
|
-
const m = asset.document.model;
|
|
1312
|
-
if (!AXES.every(axis => sizeInputOf(m, axis)))
|
|
1313
|
-
return [];
|
|
1314
|
-
const range = Object.fromEntries(AXES.map(axis => [axis, { min: sizeInputOf(m, axis).min, max: sizeInputOf(m, axis).max }]));
|
|
1315
|
-
const limits = figureInstanceSizeLimitsOf(asset, range);
|
|
1316
|
-
const { range: holds } = foldInstanceSizeRange(range, limits);
|
|
1317
|
-
const gaps = [];
|
|
1318
|
-
for (const axis of AXES) {
|
|
1319
|
-
const who = limits.find(l => l.axis === axis)?.part;
|
|
1320
|
-
if (holds[axis].min > range[axis].min)
|
|
1321
|
-
gaps.push({ axis, bound: 'min', need: holds[axis].min, declared: range[axis].min, who });
|
|
1322
|
-
if (holds[axis].max < range[axis].max)
|
|
1323
|
-
gaps.push({ axis, bound: 'max', need: holds[axis].max, declared: range[axis].max, who });
|
|
1324
|
-
}
|
|
1325
|
-
return gaps;
|
|
1326
|
-
}
|
|
1327
|
-
/** The declared size range folded to what the figure allows, as asked; the authored size must stay inside. */
|
|
1328
|
-
function narrowDeclaredSize(asset) {
|
|
1329
|
-
const m = asset.document.model;
|
|
1330
|
-
/* A figure that declares no size has no range to reach past: there is nothing to narrow, and the link stands as it is. */
|
|
1331
|
-
if (!AXES.every(axis => sizeInputOf(m, axis)))
|
|
1332
|
-
return;
|
|
1333
|
-
for (const gap of sizeRangeGaps(asset)) {
|
|
1334
|
-
const input = sizeInputOf(m, gap.axis);
|
|
1335
|
-
const at = asset.designInputs[FIGURE_INSTANCE_SIZE_INPUTS[gap.axis]];
|
|
1336
|
-
if (gap.bound === 'min' ? at < gap.need : at > gap.need)
|
|
1337
|
-
fail('INSTANCE_SIZE_RANGE', FIGURE_INSTANCE_SIZE_INPUTS[gap.axis], `the figure is authored at ${at} mm, where a length is already gone; change the link or the size`);
|
|
1338
|
-
if (gap.bound === 'min')
|
|
1339
|
-
input.min = gap.need;
|
|
1340
|
-
else
|
|
1341
|
-
input.max = gap.need;
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
/**
|
|
1345
|
-
* After a command, the declared size range must still hold at its ends. A gap the figure already had before the
|
|
1346
|
-
* command -- a range the conversion declared too wide (FORMATION_TRAY: X from 1 mm, its cells gone below 8.8 mm) -- is
|
|
1347
|
-
* not the command's doing: it is refused only if it made a gap or widened one. Refusing every command for it had
|
|
1348
|
-
* blocked even choosing a detail level (user, 2026-09-24).
|
|
1349
|
-
*/
|
|
1350
|
-
function holdsOverDeclaredSize(asset, before) {
|
|
1351
|
-
const now = sizeRangeGaps(asset);
|
|
1352
|
-
if (!now.length)
|
|
1353
|
-
return;
|
|
1354
|
-
const was = before ? sizeRangeGaps(before) : [];
|
|
1355
|
-
for (const gap of now) {
|
|
1356
|
-
const prior = was.find(g => g.axis === gap.axis && g.bound === gap.bound);
|
|
1357
|
-
const worse = !prior || (gap.bound === 'min' ? gap.need > prior.need + 1e-9 || gap.declared < prior.declared : gap.need < prior.need - 1e-9 || gap.declared > prior.declared);
|
|
1358
|
-
if (!worse)
|
|
1359
|
-
continue;
|
|
1360
|
-
const path = FIGURE_INSTANCE_SIZE_INPUTS[gap.axis];
|
|
1361
|
-
const by = gap.who ? ` (${gap.who})` : '';
|
|
1362
|
-
if (gap.bound === 'min')
|
|
1363
|
-
fail('INSTANCE_SIZE_RANGE', path, `below ${round6(gap.need)} mm a length goes to zero${by}; declare the smallest size at ${round6(gap.need)} mm or more`);
|
|
1364
|
-
fail('INSTANCE_SIZE_RANGE', path, `above ${round6(gap.need)} mm the figure cannot be drawn${by}; declare the largest size at ${round6(gap.need)} mm or less`);
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
/** What each part is fastened to, for a reader that wants the structure rather than the graph. */
|
|
1368
|
-
export function figureAttachmentsOf(asset) {
|
|
1369
|
-
const m = asset.document.model;
|
|
1370
|
-
const out = {};
|
|
1371
|
-
for (const n of m.nodes)
|
|
1372
|
-
if (n.op === 'place@1')
|
|
1373
|
-
out[n.id] = parentOf(m, n.id);
|
|
1374
|
-
return out;
|
|
1375
|
-
}
|
|
1376
|
-
/* ------------------------------------------------------------------ joints */
|
|
1377
|
-
/** The joint whose child is this part, if any. */
|
|
1378
|
-
function jointAbove(asset, part) {
|
|
1379
|
-
return asset.joints?.find(j => j.body1.part === part);
|
|
1380
|
-
}
|
|
1381
|
-
const stateIdOf = (j) => j.state?.id ?? j.id;
|
|
1382
|
-
/** A record that is well formed on its own, and fits the tree the others make. */
|
|
1383
|
-
function checkJoint(asset, j) {
|
|
1384
|
-
const m = asset.document.model;
|
|
1385
|
-
const path = `joints.${String(j?.id)}`;
|
|
1386
|
-
if (!j || typeof j.id !== 'string' || !j.id.trim())
|
|
1387
|
-
fail('JOINT_SCHEMA', 'joints', 'a joint has an id');
|
|
1388
|
-
knownJointFields(j, path);
|
|
1389
|
-
if (!FIGURE_JOINT_TYPES.includes(j.type))
|
|
1390
|
-
fail('JOINT_SCHEMA', path, `${String(j.type)} is not a joint type; ${FIGURE_JOINT_TYPES.join(', ')}`);
|
|
1391
|
-
if (!j.body1 || typeof j.body1.part !== 'string')
|
|
1392
|
-
fail('JOINT_SCHEMA', path, 'a joint carries a part (body1)');
|
|
1393
|
-
partOf(m, j.body1.part, path);
|
|
1394
|
-
if (!j.body0 || (!('plane' in j.body0) && typeof j.body0.part !== 'string'))
|
|
1395
|
-
fail('JOINT_SCHEMA', path, 'a joint hangs from a part or the mounting plane (body0)');
|
|
1396
|
-
if ('part' in j.body0)
|
|
1397
|
-
partOf(m, j.body0.part, path);
|
|
1398
|
-
const others = (asset.joints ?? []).filter(o => o.id !== j.id);
|
|
1399
|
-
const above = others.find(o => o.body1.part === j.body1.part);
|
|
1400
|
-
if (above)
|
|
1401
|
-
fail('JOINT_TREE', path, `${j.body1.part} already hangs from ${'part' in above.body0 ? above.body0.part : 'the mounting plane'} by ${above.id}; a part has one joint above it`);
|
|
1402
|
-
if ('part' in j.body0) {
|
|
1403
|
-
const parentOfPart = (part) => others.find(o => o.body1.part === part)?.body0;
|
|
1404
|
-
for (let up = j.body0.part; up;) {
|
|
1405
|
-
if (up === j.body1.part)
|
|
1406
|
-
fail('ATTACH_CYCLE', path, `${j.body0.part} already hangs from ${j.body1.part}; ${j.body1.part} would hang from itself`);
|
|
1407
|
-
const next = parentOfPart(up);
|
|
1408
|
-
up = next && 'part' in next ? next.part : undefined;
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
const moving = j.type !== 'fixed';
|
|
1412
|
-
const limited = j.lowerLimit !== undefined || j.upperLimit !== undefined;
|
|
1413
|
-
if (moving && !['X', 'Y', 'Z'].includes(j.axis))
|
|
1414
|
-
fail('JOINT_SCHEMA', path, `a ${j.type} joint has an axis, X, Y or Z`);
|
|
1415
|
-
if (!moving && (j.axis !== undefined || limited || j.mimic !== undefined || j.travel !== undefined))
|
|
1416
|
-
fail('JOINT_SCHEMA', path, 'a fixed joint has no axis, limits, mimic or travel');
|
|
1417
|
-
if (j.travel !== undefined && j.type !== 'prismatic')
|
|
1418
|
-
fail('JOINT_SCHEMA', path, 'only a prismatic joint travels a length');
|
|
1419
|
-
if (j.type === 'prismatic' && j.travel && !limited) {
|
|
1420
|
-
j.lowerLimit = 0;
|
|
1421
|
-
j.upperLimit = 1;
|
|
1422
|
-
}
|
|
1423
|
-
/* A revolute joint without limits turns without end (USD); with them, both are given. A slide always has them. */
|
|
1424
|
-
if (moving && !j.mimic && (limited || j.type === 'prismatic')) {
|
|
1425
|
-
const [lo, hi] = [j.lowerLimit, j.upperLimit];
|
|
1426
|
-
if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
|
|
1427
|
-
fail('JOINT_SCHEMA', path, `a ${j.type} joint has both limits, lower below upper`);
|
|
1428
|
-
/*
|
|
1429
|
-
The rest pose is every joint at 0 -- the kinematic reference its axis is read at (joint-list.ts) -- and 0 need not
|
|
1430
|
-
be a value the joint may take: a joint limited to 10..80 still has its reference at 0 (ADR-0107 J4). Its starting
|
|
1431
|
-
value, the stored state default, is held to its limits as every control's is (addMotion).
|
|
1432
|
-
*/
|
|
1433
|
-
}
|
|
1434
|
-
if (j.mimic) {
|
|
1435
|
-
/*
|
|
1436
|
-
One leader or a sum of them (ADR-0107 decision 2), each held to the same rules: a moving joint that leads itself
|
|
1437
|
-
-- so a follower is never a leader and no loop can close -- that moves the way this one does (degrees are not
|
|
1438
|
-
added to millimetres), named once, with a finite multiplier.
|
|
1439
|
-
*/
|
|
1440
|
-
const sum = Object.hasOwn(j.mimic, 'sum');
|
|
1441
|
-
if (sum && !(Array.isArray(j.mimic.sum) && j.mimic.sum.length))
|
|
1442
|
-
fail('JOINT_SCHEMA', path, 'a mimic sum follows one joint or more');
|
|
1443
|
-
const seen = new Set();
|
|
1444
|
-
for (const t of figureMimicLeadersOf(j)) {
|
|
1445
|
-
const leader = others.find(o => o.id === t?.joint);
|
|
1446
|
-
if (!leader || leader.type === 'fixed')
|
|
1447
|
-
fail('JOINT_SCHEMA', path, `${String(t?.joint)} is not a moving joint to follow`);
|
|
1448
|
-
if (seen.has(leader.id))
|
|
1449
|
-
fail('JOINT_SCHEMA', path, `${leader.id} is followed twice; a sum names each joint once`);
|
|
1450
|
-
seen.add(leader.id);
|
|
1451
|
-
if (leader.mimic)
|
|
1452
|
-
fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
|
|
1453
|
-
if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
|
|
1454
|
-
fail('JOINT_SCHEMA', path, `${leader.id} ${leader.type === 'prismatic' ? 'slides' : 'turns'}; a joint follows ones that move the same way: a slide a slide, a turn a turn`);
|
|
1455
|
-
if (!Number.isFinite(t.multiplier))
|
|
1456
|
-
fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
|
|
1457
|
-
}
|
|
1458
|
-
if (j.mimic.offset !== undefined && !Number.isFinite(j.mimic.offset))
|
|
1459
|
-
fail('JOINT_SCHEMA', path, "a mimic's offset is a number in the joint's unit");
|
|
1460
|
-
}
|
|
1461
|
-
if (j.origin?.twist !== undefined && !Number.isFinite(j.origin.twist))
|
|
1462
|
-
fail('JOINT_SCHEMA', `${path}.origin.twist`, 'a twist is a number of degrees');
|
|
1463
|
-
if (j.origin?.turn && Object.values(j.origin.turn).some(t => !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
|
|
1464
|
-
fail('JOINT_SCHEMA', `${path}.origin.turn`, "a turn is in quarter turns; an angle about the face's normal is its twist");
|
|
1465
|
-
/*
|
|
1466
|
-
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
|
|
1467
|
-
on every copy. A row inside a row is a grid; it runs along another axis than the rows around it.
|
|
1468
|
-
*/
|
|
1469
|
-
const all = [...others, j];
|
|
1470
|
-
const upOf = (part) => all.find(o => o.body1.part === part && 'part' in o.body0);
|
|
1471
|
-
const rowAbove = (part) => {
|
|
1472
|
-
for (let up = upOf(part); up; up = upOf(up.body0.part))
|
|
1473
|
-
if (up.repeat)
|
|
1474
|
-
return up;
|
|
1475
|
-
return undefined;
|
|
1476
|
-
};
|
|
1477
|
-
if (j.repeat) {
|
|
1478
|
-
const below = (part) => all.filter(o => 'part' in o.body0 && o.body0.part === part).flatMap(o => [o.body1.part, ...below(o.body1.part)]);
|
|
1479
|
-
const around = [];
|
|
1480
|
-
for (let up = ('part' in j.body0) ? rowAbove(j.body0.part) : undefined; up; up = 'part' in up.body0 ? rowAbove(up.body0.part) : undefined)
|
|
1481
|
-
around.push(up);
|
|
1482
|
-
const inside = below(j.body1.part).map(p => all.find(o => o.body1.part === p)).filter(o => o.repeat);
|
|
1483
|
-
const same = [...around, ...inside].find(o => o.repeat.axis === j.repeat.axis);
|
|
1484
|
-
if (same)
|
|
1485
|
-
fail('JOINT_SCHEMA', `${path}.repeat`, `${same.body1.part}'s row runs along ${same.repeat.axis} too; a row inside a row runs along another axis`);
|
|
1486
|
-
}
|
|
1487
|
-
if (j.repeat) {
|
|
1488
|
-
const r = j.repeat;
|
|
1489
|
-
const row = `${path}.repeat`;
|
|
1490
|
-
if (!('part' in j.body0))
|
|
1491
|
-
fail('JOINT_SCHEMA', row, 'a row fills the part it is fastened to; the mounting plane has no length to fill');
|
|
1492
|
-
if (!['X', 'Y', 'Z'].includes(r.axis))
|
|
1493
|
-
fail('JOINT_SCHEMA', row, `a row runs along X, Y or Z, not ${String(r.axis)}`);
|
|
1494
|
-
if (r.axis.toLowerCase() === FIGURE_FACES[childFaceOf(asset.document.model, j)])
|
|
1495
|
-
fail('JOINT_SCHEMA', row, `a row runs across the face it is fastened to; ${r.axis} is out of that face`);
|
|
1496
|
-
if (typeof r.pitch === 'number' ? !(Number.isFinite(r.pitch) && r.pitch > 0) : !followsSomething(r.pitch))
|
|
1497
|
-
fail('GEOMETRY_DOMAIN', row, 'a row has a positive pitch, or one that follows a length');
|
|
1498
|
-
if (r.count !== undefined && !(Number.isSafeInteger(r.count) && r.count >= 1 && r.count <= REPEAT_LIMIT))
|
|
1499
|
-
fail('GEOMETRY_DOMAIN', row, `a row has 1 to ${REPEAT_LIMIT} copies`);
|
|
1500
|
-
if (r.rise !== undefined) {
|
|
1501
|
-
if (!r.rise || !['X', 'Y', 'Z'].includes(r.rise.axis) || r.rise.axis === r.axis)
|
|
1502
|
-
fail('JOINT_SCHEMA', `${row}.rise`, `a row rises along X, Y or Z other than the one it runs along (${r.axis})`);
|
|
1503
|
-
if (!(Number.isFinite(r.rise.mm) && r.rise.mm !== 0))
|
|
1504
|
-
fail('GEOMETRY_DOMAIN', `${row}.rise`, 'a rise is a length in mm, not zero');
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
/**
|
|
1509
|
-
* Write a joint's nodes from its record: the child's quarter turn, the fastening (the seat on the parent's face and
|
|
1510
|
-
* the child's offset from it), and for a moving joint the turn or slide at the seat. The node ids are the child's and
|
|
1511
|
-
* are chosen the same way each time, so writing a record again gives the same nodes.
|
|
1512
|
-
*/
|
|
1513
|
-
function writeJoint(asset, j) {
|
|
1514
|
-
const m = asset.document.model;
|
|
1515
|
-
const child = j.body1.part;
|
|
1516
|
-
const { pose } = partOf(m, child, child);
|
|
1517
|
-
/*
|
|
1518
|
-
The record speaks as the parts stand: `top` is up, the axis is the figure's. The seat is written in the parent's
|
|
1519
|
-
own frame, so under a parent standing a quarter turn the record's words are turned into that frame, and the child
|
|
1520
|
-
is given the turn back so it keeps its own (빈틈 A, 2026-09-25: a body on a pipe laid on its side).
|
|
1521
|
-
*/
|
|
1522
|
-
const parent = 'part' in j.body0 ? j.body0.part : undefined;
|
|
1523
|
-
const path = `joints.${j.id}`;
|
|
1524
|
-
const back = parent ? transpose3(standingTurnOf(asset, parent, path)) : IDENTITY;
|
|
1525
|
-
const turned = !isIdentity(back);
|
|
1526
|
-
const own = AXES.map(a => j.origin?.turn?.[a] ?? 0);
|
|
1527
|
-
/* The child as it stands: its quarter turn, then its twist about the normal of the face it is fastened by. */
|
|
1528
|
-
const childFace = childFaceOf(m, j);
|
|
1529
|
-
const normal = FIGURE_FACES[childFace];
|
|
1530
|
-
const standing = j.origin?.twist ? mul3(axisTurn(normal, j.origin.twist), turnMatrix(own)) : turnMatrix(own);
|
|
1531
|
-
/* In the parent's frame: its twist about the face's normal there, and the quarter turn that is left. */
|
|
1532
|
-
const inParent = mul3(back, standing);
|
|
1533
|
-
const localNormal = axisOf(mv3(back, FACE_VECTOR[childFace]), `${j.body1.part}'s ${childFace}`, parent ?? '', back, path);
|
|
1534
|
-
/* The record's twist, about the normal as it lies in the parent's frame; what is left is a quarter turn. */
|
|
1535
|
-
/*
|
|
1536
|
-
In the parent's frame the child stands twisted about the face's normal -- by its own twist, and back by its
|
|
1537
|
-
parent's -- after a quarter turn. The twist is the one nearest what the record asks for.
|
|
1538
|
-
*/
|
|
1539
|
-
const alongNormal = mv3(back, AXES.map(a => (a === normal ? 1 : 0)));
|
|
1540
|
-
const asked = (j.origin?.twist ?? 0) * Math.sign(alongNormal[AXES.indexOf(localNormal.axis)]);
|
|
1541
|
-
const { quarter, deg } = splitTwist(inParent, localNormal.axis, asked, path);
|
|
1542
|
-
const old = pose.args.slice(3, 6);
|
|
1543
|
-
pose.args = [...pose.args.slice(0, 3), ...(turned || j.origin?.twist ? eulerOf(quarter) : own).map(t => constantOf(m, 'deg', t, 'a'))];
|
|
1544
|
-
for (const ref of old)
|
|
1545
|
-
dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
|
|
1546
|
-
const face = (name) => turned && Object.hasOwn(FACE_VECTOR, name) ? faceAlong(axisOf(mv3(back, FACE_VECTOR[name]), name, parent ?? '', back, path).vector) : name;
|
|
1547
|
-
attach(asset, {
|
|
1548
|
-
kind: 'attach',
|
|
1549
|
-
part: child,
|
|
1550
|
-
face: face(j.body1.face),
|
|
1551
|
-
to: 'plane' in j.body0 ? { plane: 'mounting-plane' } : { part: j.body0.part, face: face(j.body0.face) },
|
|
1552
|
-
...(j.origin?.facing ? { facing: j.origin.facing } : {}),
|
|
1553
|
-
...(j.origin?.gap !== undefined ? { gap: j.origin.gap } : {}),
|
|
1554
|
-
/*
|
|
1555
|
-
A distance across the face is measured in the twisted frame (see `attach`), so under a twisted parent it is
|
|
1556
|
-
carried by the parent's quarter turn only; the twists turn it after.
|
|
1557
|
-
*/
|
|
1558
|
-
...(j.origin?.align ? { align: turned ? alignInto(parent ? transpose3(standingQuarterOf(asset, parent, path)) : back, j.origin.align, path) : j.origin.align } : {}),
|
|
1559
|
-
replace: true
|
|
1560
|
-
}, deg ? { axis: localNormal.axis, deg } : undefined);
|
|
1561
|
-
/* The measuring was done on the quarter turn; the part stands with its twist. */
|
|
1562
|
-
if (deg) {
|
|
1563
|
-
const now = pose.args.slice(3, 6);
|
|
1564
|
-
pose.args = [...pose.args.slice(0, 3), ...eulerOfAny(inParent).map(t => constantOf(m, 'deg', t, 'a'))];
|
|
1565
|
-
for (const ref of now)
|
|
1566
|
-
dropIfUnused(asset, ref);
|
|
1567
|
-
}
|
|
1568
|
-
if (j.repeat && turned)
|
|
1569
|
-
fail('JOINT_SCHEMA', `joints.${j.id}.repeat`, `${parent} stands turned; a row across a turned part is not covered yet`);
|
|
1570
|
-
/* A row is laid out last, once its part's motion is written: every copy moves on it (a roller row spinning). */
|
|
1571
|
-
if (j.type === 'fixed') {
|
|
1572
|
-
if (j.repeat)
|
|
1573
|
-
repeatPart(asset, j);
|
|
1574
|
-
return;
|
|
1575
|
-
}
|
|
1576
|
-
const kind = j.type === 'prismatic' ? 'slide' : 'turn';
|
|
1577
|
-
const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
|
|
1578
|
-
const range = j.lowerLimit !== undefined && j.upperLimit !== undefined ? { min: j.lowerLimit, max: j.upperLimit } : j.type === 'revolute' ? { min: -180, max: 180 } : { min: 0, max: 1 };
|
|
1579
|
-
const leaders = figureMimicLeadersOf(j).map(t => ({ leader: (asset.joints ?? []).find(o => o.id === t.joint), k: t.multiplier }));
|
|
1580
|
-
/* The axis turned into the parent's frame too: one of its axes, either way along it. */
|
|
1581
|
-
const along = mv3(back, AXES.map(a => (a === j.axis.toLowerCase() ? 1 : 0)));
|
|
1582
|
-
const axisIndex = along.findIndex(v => Math.abs(Math.abs(v) - 1) < 1e-9);
|
|
1583
|
-
if (axisIndex < 0)
|
|
1584
|
-
fail('JOINT_SCHEMA', path, `${parent} stands twisted; the axis ${j.axis} is not level with an axis in it`);
|
|
1585
|
-
addMotion(asset, {
|
|
1586
|
-
kind: 'add-motion',
|
|
1587
|
-
part: child,
|
|
1588
|
-
motion: { kind, axis: AXES[axisIndex] },
|
|
1589
|
-
state: { id: stateIdOf(j), unit, min: range.min, max: range.max, start: j.state?.start ?? Math.min(range.max, Math.max(range.min, 0)), ...(j.state?.label !== undefined ? { label: j.state.label } : {}), ...(j.state?.sweep !== undefined ? { sweep: j.state.sweep } : {}) },
|
|
1590
|
-
...(j.travel ? { travel: j.travel } : {}),
|
|
1591
|
-
replace: true
|
|
1592
|
-
},
|
|
1593
|
-
/*
|
|
1594
|
-
A follower follows how far its leader has gone -- its distance or angle -- not the leader's control: a leader whose
|
|
1595
|
-
stroke is a share of a length has a control of 0..1, and multiplying that gave a follower a number with no length
|
|
1596
|
-
(the telescopic conveyor, 2026-09-25). For a leader whose control is its distance the two are one value.
|
|
1597
|
-
*/
|
|
1598
|
-
j.mimic
|
|
1599
|
-
? {
|
|
1600
|
-
terms: leaders.map(({ leader, k }) => ({ of: nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader), k })),
|
|
1601
|
-
c: j.mimic.offset ?? 0,
|
|
1602
|
-
sum: Object.hasOwn(j.mimic, 'sum')
|
|
1603
|
-
}
|
|
1604
|
-
: undefined, along[axisIndex] > 0 ? 1 : -1);
|
|
1605
|
-
if (j.repeat)
|
|
1606
|
-
repeatPart(asset, j);
|
|
1607
|
-
}
|
|
1608
|
-
const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
1609
|
-
const isIdentity = (r) => r.every((row, i) => row.every((v, k) => Math.abs(v - (i === k ? 1 : 0)) < 1e-12));
|
|
1610
|
-
const transpose3 = (r) => r[0].map((_, i) => r.map(row => row[i]));
|
|
1611
|
-
const mv3 = (r, v) => r.map(row => row.reduce((sum, x, k) => sum + x * v[k], 0));
|
|
1612
|
-
const FACE_VECTOR = { left: [-1, 0, 0], right: [1, 0, 0], bottom: [0, -1, 0], top: [0, 1, 0], back: [0, 0, -1], front: [0, 0, 1] };
|
|
1613
|
-
const faceAlong = (v) => Object.keys(FACE_VECTOR).find(f => FACE_VECTOR[f].every((x, i) => x === v[i]));
|
|
1614
|
-
/**
|
|
1615
|
-
* How a part stands, as a signed axis permutation: its own quarter turn, which its joint record keeps (the pose may
|
|
1616
|
-
* carry the turn back given under a turned parent). A part with no joint stands as its pose turns it. Any turn but
|
|
1617
|
-
* quarter turns is refused and says the angle: nothing can be fastened level with its faces.
|
|
1618
|
-
*/
|
|
1619
|
-
function standingTurnOf(asset, part, path) {
|
|
1620
|
-
const record = (asset.joints ?? []).find(o => o.body1.part === part);
|
|
1621
|
-
const turn = record ? AXES.map(a => record.origin?.turn?.[a] ?? 0) : turnOf(asset.document.model, asset, part, path);
|
|
1622
|
-
if (turn.some(t => t === null || !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
|
|
1623
|
-
fail('EDIT_TARGET', path, `${part} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is covered only for quarter turns`);
|
|
1624
|
-
const quarter = turn.every(t => t === 0) ? IDENTITY : turnMatrix(turn);
|
|
1625
|
-
/* A twisted part stands twisted about the normal of the face it is fastened by. */
|
|
1626
|
-
return record?.origin?.twist ? mul3(axisTurn(FIGURE_FACES[childFaceOf(asset.document.model, record)], record.origin.twist), quarter) : quarter;
|
|
1627
|
-
}
|
|
1628
|
-
/** How a part stands before its twist: its quarter turn only. */
|
|
1629
|
-
function standingQuarterOf(asset, part, path) {
|
|
1630
|
-
const record = (asset.joints ?? []).find(o => o.body1.part === part);
|
|
1631
|
-
if (!record?.origin?.twist)
|
|
1632
|
-
return standingTurnOf(asset, part, path);
|
|
1633
|
-
const turn = AXES.map(a => record.origin?.turn?.[a] ?? 0);
|
|
1634
|
-
return turn.every(t => t === 0) ? IDENTITY : turnMatrix(turn);
|
|
1635
|
-
}
|
|
1636
|
-
/** A quarter-turn matrix back as degrees about X, Y and Z in the order `rigid@1` turns. */
|
|
1637
|
-
function eulerOf(r) {
|
|
1638
|
-
const deg = (a) => {
|
|
1639
|
-
const d = Math.round((a * 180) / Math.PI / 90) * 90;
|
|
1640
|
-
return d === 0 ? 0 : d;
|
|
1641
|
-
};
|
|
1642
|
-
/*
|
|
1643
|
-
About Y by a quarter turn, X and Z turn about the same axis and only their sum counts; Z is taken as 0. At +90 the X
|
|
1644
|
-
turn reads off as atan2(r10, r11), at -90 as atan2(-r10, r11) -- the -90 case had used the +90 reading, so two of the 24
|
|
1645
|
-
quarter turns (Y at -90 with X at +-90) did not come back and a host turned that way refused every fastening.
|
|
1646
|
-
*/
|
|
1647
|
-
const out = Math.abs(r[0][2]) < 1
|
|
1648
|
-
? [deg(Math.atan2(-r[1][2], r[2][2])), deg(Math.asin(r[0][2])), deg(Math.atan2(-r[0][1], r[0][0]))]
|
|
1649
|
-
: [deg(Math.atan2(Math.sign(r[0][2]) * r[1][0], r[1][1])), deg(Math.asin(r[0][2])), 0];
|
|
1650
|
-
if (!turnMatrix(out).every((row, i) => row.every((v, k) => v === r[i][k])))
|
|
1651
|
-
fail('EDIT_TARGET', 'turn', `${JSON.stringify(r)} is not a quarter turn; a fastening is covered only for quarter turns`);
|
|
1652
|
-
return out;
|
|
1653
|
-
}
|
|
1654
|
-
/** The two axes across a face along `axis`, in the order a right-handed turn about it takes one into the other. */
|
|
1655
|
-
const ACROSS = { x: ['y', 'z'], y: ['z', 'x'], z: ['x', 'y'] };
|
|
1656
|
-
/** A turn of `deg` degrees about one axis. */
|
|
1657
|
-
function axisTurn(axis, deg) {
|
|
1658
|
-
const c = Math.cos((deg * Math.PI) / 180), s = Math.sin((deg * Math.PI) / 180);
|
|
1659
|
-
const [p, q] = ACROSS[axis].map(a => AXES.indexOf(a));
|
|
1660
|
-
const r = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
1661
|
-
r[p][p] = c;
|
|
1662
|
-
r[p][q] = -s;
|
|
1663
|
-
r[q][p] = s;
|
|
1664
|
-
r[q][q] = c;
|
|
1665
|
-
return r;
|
|
1666
|
-
}
|
|
1667
|
-
/** A direction that must lie along an axis to name a face or an edge there; otherwise refused with the parent's twist. */
|
|
1668
|
-
function axisOf(v, what, parent, back, path) {
|
|
1669
|
-
const k = v.findIndex(x => Math.abs(Math.abs(x) - 1) < 1e-9);
|
|
1670
|
-
if (k < 0)
|
|
1671
|
-
fail('ATTACH_FACE', path, `${parent} stands twisted (${saidTurn(eulerOfAny(transpose3(back)))}); ${what} is not level with an axis in it`);
|
|
1672
|
-
return { axis: AXES[k], vector: v.map(x => (Math.abs(x) < 1e-9 ? 0 : Math.sign(x))) };
|
|
1673
|
-
}
|
|
1674
|
-
/**
|
|
1675
|
-
* A turn as a twist about one axis after a quarter turn: r = twist(axis, deg) · quarter, with the twist nearest
|
|
1676
|
-
* `asked`. A turn that is no such thing (a face not level with the axis) is refused.
|
|
1677
|
-
*/
|
|
1678
|
-
function splitTwist(r, axis, asked, path) {
|
|
1679
|
-
const k = AXES.indexOf(axis);
|
|
1680
|
-
const [p, q] = ACROSS[axis].map(a => AXES.indexOf(a));
|
|
1681
|
-
let best;
|
|
1682
|
-
for (const quarter of QUARTER_TURNS) {
|
|
1683
|
-
const t = mul3(r, transpose3(quarter));
|
|
1684
|
-
if (Math.abs(t[k][k] - 1) > 1e-9)
|
|
1685
|
-
continue;
|
|
1686
|
-
const d = (Math.atan2(t[q][p], t[p][p]) * 180) / Math.PI;
|
|
1687
|
-
const off = Math.abs(((((d - asked) % 360) + 540) % 360) - 180);
|
|
1688
|
-
if (!best || off < best.off - 1e-9)
|
|
1689
|
-
best = { quarter, deg: Math.abs(d) < 1e-9 ? 0 : d, off };
|
|
1690
|
-
}
|
|
1691
|
-
if (!best)
|
|
1692
|
-
fail('ATTACH_FACE', path, `the part would stand turned about more than the face's normal (${saidTurn(eulerOfAny(r))})`);
|
|
1693
|
-
/*
|
|
1694
|
-
The angle as asked when that is what the turn is: taken back out of a matrix it carries float noise that depends
|
|
1695
|
-
on the path it was computed by, and the numbers made from it are named by their value -- the first write and the
|
|
1696
|
-
parse's rewrite then named them apart (JOINT_DRIFT, the architect's fan, 2026-09-25).
|
|
1697
|
-
*/
|
|
1698
|
-
return { quarter: best.quarter, deg: best.off < 1e-6 ? asked : round6(best.deg) };
|
|
1699
|
-
}
|
|
1700
|
-
const QUARTER_TURNS = (() => {
|
|
1701
|
-
const out = [];
|
|
1702
|
-
for (const x of [0, 90, 180, 270])
|
|
1703
|
-
for (const y of [0, 90, 180, 270])
|
|
1704
|
-
for (const z of [0, 90, 180, 270]) {
|
|
1705
|
-
const r = turnMatrix([x, y, z]);
|
|
1706
|
-
if (!out.some(o => o.every((row, i) => row.every((v, j) => v === r[i][j]))))
|
|
1707
|
-
out.push(r);
|
|
1708
|
-
}
|
|
1709
|
-
return out;
|
|
1710
|
-
})();
|
|
1711
|
-
/** Any turn back as degrees about X, Y and Z in the order `rigid@1` turns (X, then Y, then Z). */
|
|
1712
|
-
function eulerOfAny(r) {
|
|
1713
|
-
const deg = (a) => {
|
|
1714
|
-
const d = round6((a * 180) / Math.PI);
|
|
1715
|
-
return d === 0 ? 0 : d;
|
|
1716
|
-
};
|
|
1717
|
-
const sy = Math.max(-1, Math.min(1, r[0][2]));
|
|
1718
|
-
if (Math.abs(sy) < 1 - 1e-12)
|
|
1719
|
-
return [deg(Math.atan2(-r[1][2], r[2][2])), deg(Math.asin(sy)), deg(Math.atan2(-r[0][1], r[0][0]))];
|
|
1720
|
-
return [deg(Math.atan2(r[1][0], r[1][1])), deg(Math.asin(sy)), 0];
|
|
1721
|
-
}
|
|
1722
|
-
/** Where a part sits across a face, said on the standing axes, turned onto the parent's own: an edge flips where an axis does. */
|
|
1723
|
-
function alignInto(back, align, path) {
|
|
1724
|
-
const out = {};
|
|
1725
|
-
for (const [a, how] of Object.entries(align)) {
|
|
1726
|
-
const v = mv3(back, AXES.map(x => (x === a ? 1 : 0)));
|
|
1727
|
-
const k = v.findIndex(x => Math.abs(Math.abs(x) - 1) < 1e-9);
|
|
1728
|
-
if (k < 0)
|
|
1729
|
-
fail('ATTACH_FACE', path, `the parent stands twisted; placing along ${a} is not level with an axis in it -- place it by the centre`);
|
|
1730
|
-
const s = Math.sign(v[k]);
|
|
1731
|
-
const to = AXES[k];
|
|
1732
|
-
if (how === 'centre' || s > 0)
|
|
1733
|
-
out[to] = how;
|
|
1734
|
-
else if (how === 'min' || how === 'max')
|
|
1735
|
-
out[to] = how === 'min' ? 'max' : 'min';
|
|
1736
|
-
else if (typeof how.mm === 'number')
|
|
1737
|
-
out[to] = { mm: -how.mm };
|
|
1738
|
-
else
|
|
1739
|
-
fail('SCHEMA', path, `an offset that follows another value cannot be turned over onto ${to}; give it as a number`);
|
|
1740
|
-
}
|
|
1741
|
-
return out;
|
|
1742
|
-
}
|
|
1743
|
-
/**
|
|
1744
|
-
* A fastened part made a row (r3 §8): its placement becomes the template of a `repeat@1`. The row starts at the
|
|
1745
|
-
* parent's lower edge along the axis and runs to its upper edge, so its length is the parent's and follows it; each
|
|
1746
|
-
* copy stands as the fastening put the part, moved along the axis. The part keeps its id, as the row's template.
|
|
1747
|
-
*/
|
|
1748
|
-
function repeatPart(asset, j) {
|
|
1749
|
-
const m = asset.document.model;
|
|
1750
|
-
const part = j.body1.part;
|
|
1751
|
-
const parent = j.body0.part;
|
|
1752
|
-
const path = `joints.${j.id}.repeat`;
|
|
1753
|
-
const axis = j.repeat.axis.toLowerCase();
|
|
1754
|
-
const k = AXES.indexOf(axis);
|
|
1755
|
-
const theirs = ownSpansOf(m, parent, path)[axis];
|
|
1756
|
-
if (!theirs)
|
|
1757
|
-
fail('EDIT_TARGET', path, `${parent} has no two faces along ${axis}; there is no length to fill`);
|
|
1758
|
-
const mine = standingSpansOf(m, asset, part, path)[axis];
|
|
1759
|
-
/* A frustum has no faces across its axis; it is as wide there as its wider end (G6). */
|
|
1760
|
-
const widest = mine ? undefined : frustumWidthAlong(m, asset, part, axis, path);
|
|
1761
|
-
if (!mine && !widest)
|
|
1762
|
-
fail('EDIT_TARGET', path, `${part} has no two faces along ${axis}; its copies cannot be spaced`);
|
|
1763
|
-
const { place, pose } = partOf(m, part, path);
|
|
1764
|
-
const seat = nodeById(m, `${part}.seat.rigid`);
|
|
1765
|
-
const name = namer(m, `${part}.row`);
|
|
1766
|
-
const zero = constantOf(m, 'mm', 0, 'd');
|
|
1767
|
-
/* The seat moves to the row's start; each copy's centre is where the layout puts it from there. */
|
|
1768
|
-
seat.args = seat.args.map((ref, i) => (i === k ? sumOf(m, name, theirs.lo, 'start') : ref));
|
|
1769
|
-
const own = widest || mine.centred ? zero : sumOf(m, name, neg([...half(mine.lo), ...half(mine.hi)]), 'own-centre');
|
|
1770
|
-
pose.args = pose.args.map((ref, i) => (i === k ? own : ref));
|
|
1771
|
-
const length = sumOf(m, name, [...theirs.hi, ...neg(theirs.lo)], 'length');
|
|
1772
|
-
const extent = widest ?? sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
|
|
1773
|
-
const centred = constantOf(m, 'ratio', 0.5, 'k');
|
|
1774
|
-
const limit = constantOf(m, 'count', REPEAT_LIMIT, 'limit');
|
|
1775
|
-
const count = j.repeat.count;
|
|
1776
|
-
m.nodes.push(count === undefined
|
|
1777
|
-
? { id: `${part}.layout`, op: 'fit-pitch@1', args: [length, zero, zero, extent, measureRef(m, asset, name, j.repeat.pitch, 'pitch', path), centred, limit], outputs: { layout: `${part}.layout.value` } }
|
|
1778
|
-
: { id: `${part}.layout`, op: 'fixed-count@1', args: [constantOf(m, 'count', count, 'count'), length, zero, zero, extent, centred, limit], outputs: { layout: `${part}.layout.value` } });
|
|
1779
|
-
/* The part is the row's template now: a member, drawn only through the row -- turned by its motion, if it moves. */
|
|
1780
|
-
place.op = 'member@1';
|
|
1781
|
-
place.args = [place.args[0], nodeById(m, `${part}.turned`)?.outputs?.pose ?? pose.outputs.pose];
|
|
1782
|
-
m.nodes.push({ id: `${part}.assembly`, op: 'assembly@1', args: [place.outputs.placed], outputs: { assembly: `${part}.assembly.value` } });
|
|
1783
|
-
/* Where the parent stands, as `attach` finds it: fastened (its world or chain), or placed by its own numbers. */
|
|
1784
|
-
const above = nodeById(m, `${parent}.world`)?.outputs?.pose ??
|
|
1785
|
-
nodeById(m, `${parent}.chain`)?.outputs?.pose ??
|
|
1786
|
-
nodeById(m, `${parent}.turned`)?.outputs?.pose ??
|
|
1787
|
-
nodeById(m, `${parent}.pose`)?.outputs?.pose;
|
|
1788
|
-
let root = seat.outputs.pose;
|
|
1789
|
-
if (above) {
|
|
1790
|
-
m.nodes.push({ id: `${part}.row`, op: 'compose@1', args: [above, seat.outputs.pose], outputs: { pose: `${part}.row.value` } });
|
|
1791
|
-
root = `${part}.row.value`;
|
|
1792
|
-
}
|
|
1793
|
-
m.nodes = m.nodes.filter((n) => n.id !== `${part}.chain` && n.id !== `${part}.world`);
|
|
1794
|
-
const rise = j.repeat.rise;
|
|
1795
|
-
m.nodes.push({
|
|
1796
|
-
id: `${part}.repeat`,
|
|
1797
|
-
op: 'repeat@1',
|
|
1798
|
-
args: [`${part}.layout.value`, `${part}.assembly.value`, root, ...(rise ? [constantOf(m, 'mm', rise.mm, 'rise')] : [])],
|
|
1799
|
-
outputs: { collection: `${part}.repeat.value` },
|
|
1800
|
-
params: { axis, ...(rise ? { riseAxis: rise.axis.toLowerCase() } : {}) }
|
|
1801
|
-
});
|
|
1802
|
-
}
|
|
1803
|
-
/**
|
|
1804
|
-
* Everything fastened to a row's part, however far down, goes into the row's assembly: each copy carries it (ADR-0093,
|
|
1805
|
-
* 2026-09-26). Its placement already reads its copy's frame -- it hangs from the part's pose, not from a chain.
|
|
1806
|
-
*/
|
|
1807
|
-
function gatherRows(asset, joints) {
|
|
1808
|
-
const m = asset.document.model;
|
|
1809
|
-
for (const row of joints.filter(j => j.repeat)) {
|
|
1810
|
-
const assembly = nodeById(m, `${row.body1.part}.assembly`);
|
|
1811
|
-
/* Down to the next row: a row inside this one joins as its copies, and what hangs from its part is its own. */
|
|
1812
|
-
const gather = (part) => {
|
|
1813
|
-
for (const o of joints.filter(o => 'part' in o.body0 && o.body0.part === part)) {
|
|
1814
|
-
if (o.repeat) {
|
|
1815
|
-
const inner = nodeById(m, `${o.body1.part}.repeat`);
|
|
1816
|
-
inner.params = { ...inner.params, within: `${row.body1.part}.repeat` };
|
|
1817
|
-
assembly.args.push(inner.outputs.collection);
|
|
1818
|
-
continue;
|
|
1819
|
-
}
|
|
1820
|
-
const place = nodeById(m, o.body1.part);
|
|
1821
|
-
place.op = 'member@1';
|
|
1822
|
-
assembly.args.push(place.outputs.placed);
|
|
1823
|
-
gather(o.body1.part);
|
|
1824
|
-
}
|
|
1825
|
-
};
|
|
1826
|
-
gather(row.body1.part);
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
/**
|
|
1830
|
-
* How wide a frustum is across its own axis, where the row runs: the larger of its two diameters, as the bounds of two
|
|
1831
|
-
* candidates (\`affine-bounds@1\`) -- the room's own way to take the larger of sums, so the gate still reads it as a sum
|
|
1832
|
-
* (chief architect, 2026-09-26). Undefined for any other shape, or a row along the frustum's own axis.
|
|
1833
|
-
*/
|
|
1834
|
-
function frustumWidthAlong(m, asset, part, axis, path) {
|
|
1835
|
-
const { shape } = partOf(m, part, path);
|
|
1836
|
-
/* A taper's two radii, end then start -- read from the node, not assumed. */
|
|
1837
|
-
const taper = shape.op === 'revolve@1' && shape.params?.profile === 'taper' ? { along: shape.params.axis, radii: [shape.args[3], shape.args[2]] } : undefined;
|
|
1838
|
-
if (!taper)
|
|
1839
|
-
return undefined;
|
|
1840
|
-
const r = quarterTurnOf(m, asset, part, path, true);
|
|
1841
|
-
const own = AXES[r[AXES.indexOf(axis)].findIndex(v => v !== 0)];
|
|
1842
|
-
if (own === taper.along)
|
|
1843
|
-
return undefined;
|
|
1844
|
-
const id = `${part}.width.${own}`;
|
|
1845
|
-
if (!nodeById(m, id)) {
|
|
1846
|
-
const zero = constantOf(m, 'mm', 0, 'd');
|
|
1847
|
-
const two = constantOf(m, 'ratio', 2, 'k');
|
|
1848
|
-
const [top, bottom] = taper.radii;
|
|
1849
|
-
/* xmin, xmax, ymin, ymax, zmin, zmax: only the one maximum is read; the others hold a zero each. */
|
|
1850
|
-
m.nodes.push({
|
|
1851
|
-
id,
|
|
1852
|
-
op: 'affine-bounds@1',
|
|
1853
|
-
args: [zero, zero, top, two, zero, bottom, two, zero, zero, zero, zero],
|
|
1854
|
-
outputs: { xmin: `${id}.unused.xmin`, xmax: `${id}.value`, ymin: `${id}.unused.ymin`, ymax: `${id}.unused.ymax`, zmin: `${id}.unused.zmin`, zmax: `${id}.unused.zmax` },
|
|
1855
|
-
params: { layout: 'L0.1_1.0.0.0.0' }
|
|
1856
|
-
});
|
|
1857
|
-
}
|
|
1858
|
-
return `${id}.value`;
|
|
1859
|
-
}
|
|
1860
|
-
/** A row made back into its one part, so its fastening can be taken away or written again. */
|
|
1861
|
-
function unrepeatPart(asset, part) {
|
|
1862
|
-
const m = asset.document.model;
|
|
1863
|
-
const template = nodeById(m, part);
|
|
1864
|
-
if (template?.op !== 'member@1' || !nodeById(m, `${part}.repeat`))
|
|
1865
|
-
return;
|
|
1866
|
-
const layout = nodeById(m, `${part}.layout`);
|
|
1867
|
-
const rise = nodeById(m, `${part}.repeat`)?.args[3];
|
|
1868
|
-
const row = new Set([`${part}.layout`, `${part}.assembly`, `${part}.repeat`, `${part}.row`]);
|
|
1869
|
-
const gone = m.nodes.filter((n) => row.has(n.id) || n.id.startsWith(`${part}.row.`));
|
|
1870
|
-
m.nodes = m.nodes.filter((n) => !gone.includes(n));
|
|
1871
|
-
template.op = 'place@1';
|
|
1872
|
-
for (const ref of [...(layout?.args ?? []), ...(rise ? [rise] : [])])
|
|
1873
|
-
dropIfUnused(asset, ref);
|
|
1874
|
-
}
|
|
1875
|
-
/** Take a joint's nodes away, leaving its child standing in the asset frame at the origin. */
|
|
1876
|
-
function stripJoint(asset, j) {
|
|
1877
|
-
const m = asset.document.model;
|
|
1878
|
-
const child = j.body1.part;
|
|
1879
|
-
unrepeatPart(asset, child);
|
|
1880
|
-
if (nodeById(m, `${child}.motion`))
|
|
1881
|
-
removeMotion(asset, { kind: 'remove-motion', part: child });
|
|
1882
|
-
const { pose, place } = partOf(m, child, child);
|
|
1883
|
-
const owned = new Set([`${child}.seat.rigid`, `${child}.chain`, `${child}.world`, `${child}.turned`]);
|
|
1884
|
-
m.nodes = m.nodes.filter((n) => !owned.has(n.id) && !n.id.startsWith(`${child}.on.`));
|
|
1885
|
-
const zero = constantOf(m, 'mm', 0, 'd');
|
|
1886
|
-
const old = pose.args.slice(0, 3);
|
|
1887
|
-
pose.args = [zero, zero, zero, ...pose.args.slice(3)];
|
|
1888
|
-
pose.params = { ...pose.params, to: asset.document.capabilities.assetFrame };
|
|
1889
|
-
// Its own position numbers, if it still had them, place nothing now: the joint places it.
|
|
1890
|
-
for (const ref of old)
|
|
1891
|
-
dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
|
|
1892
|
-
place.args = [place.args[0], pose.outputs.pose];
|
|
1893
|
-
}
|
|
1894
|
-
/**
|
|
1895
|
-
* Every length a joint record holds that may follow something, read as a measure, with a way to write it back: the
|
|
1896
|
-
* gap, a row's pitch, a slide's travel (its one-term form names the source `source`, and without one is `plus` mm)
|
|
1897
|
-
* and each offset across the face. Renaming a part and taking one away read and write them all alike.
|
|
1898
|
-
*/
|
|
1899
|
-
function jointMeasures(j) {
|
|
1900
|
-
const out = [];
|
|
1901
|
-
if (j.origin?.gap !== undefined)
|
|
1902
|
-
out.push({ at: 'gap', get: () => j.origin.gap, set: v => (j.origin.gap = v) });
|
|
1903
|
-
if (j.repeat)
|
|
1904
|
-
out.push({ at: 'pitch', get: () => j.repeat.pitch, set: v => (j.repeat.pitch = v) });
|
|
1905
|
-
if (j.travel) {
|
|
1906
|
-
const t = j.travel;
|
|
1907
|
-
out.push({
|
|
1908
|
-
at: 'travel',
|
|
1909
|
-
get: () => (Object.hasOwn(t, 'sum') ? { sum: t.sum, ...(t.plus !== undefined ? { plus: t.plus } : {}) } : t.source ? { from: t.source, ...(t.times !== undefined ? { times: t.times } : {}), ...(t.plus !== undefined ? { plus: t.plus } : {}) } : (t.plus ?? 0)),
|
|
1910
|
-
set: v => {
|
|
1911
|
-
const f = followedTermsOf(v);
|
|
1912
|
-
j.travel = !f ? { plus: v } : f.sum ? { sum: f.terms, ...(f.plus ? { plus: f.plus } : {}) } : { source: f.terms[0].from, ...(f.terms[0].times !== undefined ? { times: f.terms[0].times } : {}), ...(v.plus !== undefined ? { plus: v.plus } : {}) };
|
|
1913
|
-
}
|
|
1914
|
-
});
|
|
1915
|
-
}
|
|
1916
|
-
for (const axis of Object.keys(j.origin?.align ?? {})) {
|
|
1917
|
-
const how = j.origin.align[axis];
|
|
1918
|
-
if (how && typeof how === 'object' && Object.hasOwn(how, 'mm'))
|
|
1919
|
-
out.push({ at: `align.${axis}`, get: () => how.mm, set: v => (how.mm = v) });
|
|
1920
|
-
}
|
|
1921
|
-
return out;
|
|
1922
|
-
}
|
|
1923
|
-
/** The same measure with each term's source put through `to`, in the form it was written. */
|
|
1924
|
-
function mapMeasureSources(measure, to) {
|
|
1925
|
-
const f = followedTermsOf(measure);
|
|
1926
|
-
if (!f)
|
|
1927
|
-
return measure;
|
|
1928
|
-
if (f.sum)
|
|
1929
|
-
return { ...measure, sum: f.terms.map(t => ({ ...t, from: to(t.from) })) };
|
|
1930
|
-
return { ...measure, from: to(f.terms[0].from) };
|
|
1931
|
-
}
|
|
1932
|
-
/** Joints in an order each can be written in: its parent's joint and the joints it mimics first. */
|
|
1933
|
-
function writingOrder(joints) {
|
|
1934
|
-
const done = new Set();
|
|
1935
|
-
const out = [];
|
|
1936
|
-
const byChild = new Map(joints.map(j => [j.body1.part, j]));
|
|
1937
|
-
while (out.length < joints.length) {
|
|
1938
|
-
const ready = joints.filter(j => {
|
|
1939
|
-
if (done.has(j.id))
|
|
1940
|
-
return false;
|
|
1941
|
-
const up = 'part' in j.body0 ? byChild.get(j.body0.part) : undefined;
|
|
1942
|
-
return (!up || done.has(up.id)) && figureMimicLeadersOf(j).every(t => done.has(t.joint));
|
|
334
|
+
asset = applyFigureAuthoring(asset, {
|
|
335
|
+
kind: 'link-dimension',
|
|
336
|
+
part: a.part,
|
|
337
|
+
axis,
|
|
338
|
+
source: { of: 'instance', axis },
|
|
339
|
+
times: measure / size,
|
|
340
|
+
replace: true,
|
|
341
|
+
narrowRange: true
|
|
1943
342
|
});
|
|
1944
|
-
if (!ready.length)
|
|
1945
|
-
fail('JOINT_TREE', 'joints', 'the joints do not make a tree');
|
|
1946
|
-
for (const j of ready) {
|
|
1947
|
-
done.add(j.id);
|
|
1948
|
-
out.push(j);
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
return out;
|
|
1952
|
-
}
|
|
1953
|
-
/** Every joint's nodes again, from the records: the ones below first away, then all written parents first. */
|
|
1954
|
-
function rewriteJoints(asset) {
|
|
1955
|
-
const joints = (asset.joints ?? []);
|
|
1956
|
-
/*
|
|
1957
|
-
A stroke that follows a part's length reads that length by its stable name (`<part>.dimension.<axis>`), and naming
|
|
1958
|
-
it rewires the shape to read it. Done in the middle of the writing, the joints already written measured the part by
|
|
1959
|
-
its old reference and the parse's rewrite measured it by the new one (JOINT_DRIFT, the telescopic conveyor,
|
|
1960
|
-
2026-09-25). The names are made first, so every joint measures the same references.
|
|
1961
|
-
*/
|
|
1962
|
-
for (const j of joints)
|
|
1963
|
-
for (const { at, get } of jointMeasures(j))
|
|
1964
|
-
for (const t of followedTermsOf(get())?.terms ?? [])
|
|
1965
|
-
if (t?.from?.of === 'part')
|
|
1966
|
-
stableDimension(asset.document.model, t.from.part, t.from.axis, `joints.${j.id}.${at}`);
|
|
1967
|
-
const order = writingOrder(joints);
|
|
1968
|
-
const keep = { ...asset.stateDefaults };
|
|
1969
|
-
/* What hangs from a row's part is drawn through the row; it is a part of its own again while the joints are written. */
|
|
1970
|
-
for (const n of asset.document.model.nodes)
|
|
1971
|
-
if (n.op === 'member@1' && !nodeById(asset.document.model, `${n.id}.repeat`))
|
|
1972
|
-
n.op = 'place@1';
|
|
1973
|
-
for (const j of [...order].reverse())
|
|
1974
|
-
stripJoint(asset, j);
|
|
1975
|
-
for (const j of order)
|
|
1976
|
-
writeJoint(asset, j);
|
|
1977
|
-
gatherRows(asset, joints);
|
|
1978
|
-
// A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
|
|
1979
|
-
for (const j of order)
|
|
1980
|
-
if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
|
|
1981
|
-
asset.stateDefaults[stateIdOf(j)] = keep[stateIdOf(j)];
|
|
1982
|
-
}
|
|
1983
|
-
/*
|
|
1984
|
-
* A part edit that renames or removes a part reads the joint records first: the records are the canon and the joint
|
|
1985
|
-
* nodes are made from them (ADR-0093). Renaming or deleting a part in the graph alone left records naming a part that
|
|
1986
|
-
* was no longer there, and the author could not save (TARGET_ABSENT on joints.*, 2026-09-25).
|
|
1987
|
-
*/
|
|
1988
|
-
/**
|
|
1989
|
-
* The records follow a part's new name -- as a joint's parent, as its child, and as the joint a follower follows -- and
|
|
1990
|
-
* the nodes are made again from them. Nothing is frozen: a length that followed the part still follows it, and the
|
|
1991
|
-
* stored design inputs keep their values under the carried names, so the figure stands as it did at every size.
|
|
1992
|
-
*/
|
|
1993
|
-
export function renameFigurePartInJoints(asset, from, to) {
|
|
1994
|
-
const joints = asset.joints;
|
|
1995
|
-
if (!joints?.length)
|
|
1996
|
-
return;
|
|
1997
|
-
const name = (id) => (id === from ? to : id);
|
|
1998
|
-
/*
|
|
1999
|
-
A joint's nodes carry its parent's name (`leg-2.on.top.*`); what reads them -- the space declared from the parts,
|
|
2000
|
-
a size that follows a fastened place -- follows them to the new name (빈틈 H, 2026-09-25).
|
|
2001
|
-
*/
|
|
2002
|
-
const infix = `.on.${from}.`;
|
|
2003
|
-
const carried = (value) => typeof value === 'string'
|
|
2004
|
-
? value.includes(infix)
|
|
2005
|
-
? value.split(infix).join(`.on.${to}.`)
|
|
2006
|
-
: value
|
|
2007
|
-
: Array.isArray(value)
|
|
2008
|
-
? value.map(carried)
|
|
2009
|
-
: value && typeof value === 'object'
|
|
2010
|
-
? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, carried(v)]))
|
|
2011
|
-
: value;
|
|
2012
|
-
const out = asset;
|
|
2013
|
-
out.document = carried(out.document);
|
|
2014
|
-
if (out.occupancy)
|
|
2015
|
-
out.occupancy = carried(out.occupancy);
|
|
2016
|
-
out.designInputs = Object.fromEntries(Object.entries(out.designInputs ?? {}).map(([k, v]) => [carried(k), v]));
|
|
2017
|
-
/* A length that follows the part's measure follows it under its new name, in each term of a sum too. */
|
|
2018
|
-
const source = (s) => (s?.of === 'part' ? { ...s, part: name(s.part) } : s);
|
|
2019
|
-
asset.joints = joints.map(was => {
|
|
2020
|
-
const j = structuredClone(was);
|
|
2021
|
-
for (const { get, set } of jointMeasures(j))
|
|
2022
|
-
set(mapMeasureSources(get(), source));
|
|
2023
|
-
return {
|
|
2024
|
-
...j,
|
|
2025
|
-
id: name(j.id),
|
|
2026
|
-
body0: 'part' in j.body0 ? { ...j.body0, part: name(j.body0.part) } : j.body0,
|
|
2027
|
-
body1: { ...j.body1, part: name(j.body1.part) },
|
|
2028
|
-
...(j.mimic
|
|
2029
|
-
? { mimic: Object.hasOwn(j.mimic, 'sum') ? { ...j.mimic, sum: figureMimicLeadersOf(j).map(t => ({ ...t, joint: name(t.joint) })) } : { ...j.mimic, joint: name(j.mimic.joint) } }
|
|
2030
|
-
: {}),
|
|
2031
|
-
...(j.state?.id !== undefined ? { state: { ...j.state, id: name(j.state.id) } } : {})
|
|
2032
|
-
};
|
|
2033
|
-
});
|
|
2034
|
-
rewriteJoints(asset);
|
|
2035
|
-
}
|
|
2036
|
-
/**
|
|
2037
|
-
* What reads a part that is to be taken away, said in the author's words: the space declared from the parts, another
|
|
2038
|
-
* part's size or place that follows it. Taking it would leave them reading nothing (빈틈 H, 2026-09-25).
|
|
2039
|
-
*/
|
|
2040
|
-
/** `spaceFittedAgain`: the space is declared again once the part is gone, so its reading the part is no refusal. */
|
|
2041
|
-
function refuseIfRead(asset, part, spaceFittedAgain = false) {
|
|
2042
|
-
const m = asset.document.model;
|
|
2043
|
-
const own = (id) => id === part || id.startsWith(`${part}.`);
|
|
2044
|
-
const mine = new Set([
|
|
2045
|
-
...m.nodes.filter((n) => own(n.id)).flatMap((n) => Object.values(n.outputs)),
|
|
2046
|
-
...m.inputs.filter((i) => own(i.id)).map((i) => i.id),
|
|
2047
|
-
...m.constants.filter((c) => own(c.id)).map((c) => c.id)
|
|
2048
|
-
]);
|
|
2049
|
-
const readsOf = (refs) => (n) => [...n.args, ...Object.values(n.params ?? {})].some((r) => typeof r === 'string' && refs.has(r));
|
|
2050
|
-
const readers = m.nodes.filter((n) => !own(n.id) && readsOf(mine)(n));
|
|
2051
|
-
const space = spaceOnlyNodesOf(asset);
|
|
2052
|
-
if (!spaceFittedAgain && (readers.some((n) => space.has(n.id)) || stringsIn(asset.occupancy).some(r => mine.has(r))))
|
|
2053
|
-
fail('PART_REFERENCED', part, `${part} is in the space the figure declares; declare the space again, or fit it to the parts, without it first`);
|
|
2054
|
-
}
|
|
2055
|
-
/**
|
|
2056
|
-
* A copied part is fastened as the original is: its own record, the same faces and place, so it is not held by nodes
|
|
2057
|
-
* no record makes. A moving joint gets its own control -- a copy is a second thing, not the original's shadow.
|
|
2058
|
-
*/
|
|
2059
|
-
export function copyFigurePartJoint(asset, from, to) {
|
|
2060
|
-
const joints = (asset.joints ?? []);
|
|
2061
|
-
const j = joints.find(o => o.body1.part === from);
|
|
2062
|
-
if (!j)
|
|
2063
|
-
return;
|
|
2064
|
-
const m = asset.document.model;
|
|
2065
|
-
const ids = new Set(joints.map(o => o.id));
|
|
2066
|
-
const inputs = new Set(m.inputs.map((i) => i.id));
|
|
2067
|
-
const fresh = (base, taken) => {
|
|
2068
|
-
let id = base;
|
|
2069
|
-
for (let n = 2; taken.has(id); n++)
|
|
2070
|
-
id = `${base}-${n}`;
|
|
2071
|
-
return id;
|
|
2072
|
-
};
|
|
2073
|
-
const id = fresh(j.id === from ? to : `${j.id}-copy`, ids);
|
|
2074
|
-
const copy = { ...structuredClone(j), id, body1: { ...j.body1, part: to } };
|
|
2075
|
-
if (j.type !== 'fixed' && !j.mimic)
|
|
2076
|
-
copy.state = { ...(j.state ?? {}), id: fresh(j.state?.id ? `${j.state.id}-copy` : id, inputs) };
|
|
2077
|
-
asset.joints = [...joints, copy];
|
|
2078
|
-
rewriteJoints(asset);
|
|
2079
|
-
}
|
|
2080
|
-
/** XYZ angles in degrees of a rotation written as rigid@1 writes it, Rx·Ry·Rz. */
|
|
2081
|
-
function eulerXYZ(r) {
|
|
2082
|
-
const deg = (v) => round6((v * 180) / Math.PI) + 0;
|
|
2083
|
-
const sy = Math.max(-1, Math.min(1, r[0][2]));
|
|
2084
|
-
const ry = Math.asin(sy);
|
|
2085
|
-
if (Math.abs(Math.cos(ry)) < 1e-9)
|
|
2086
|
-
return [deg(Math.atan2(r[2][1], r[1][1])), deg(ry), 0];
|
|
2087
|
-
return [deg(Math.atan2(-r[1][2], r[2][2])), deg(ry), deg(Math.atan2(-r[0][1], r[0][0]))];
|
|
2088
|
-
}
|
|
2089
|
-
/** The unit a value of this type is written in, as the graph keeps it (base units), or undefined for a pose and the rest. */
|
|
2090
|
-
function baseUnitOf(type) {
|
|
2091
|
-
const t = JSON.stringify(type);
|
|
2092
|
-
if (t === '[1,0,0]')
|
|
2093
|
-
return 'mm';
|
|
2094
|
-
if (t === '[0,1,0]')
|
|
2095
|
-
return 'rad';
|
|
2096
|
-
if (t === '[0,0,1]')
|
|
2097
|
-
return 's';
|
|
2098
|
-
if (t === '[0,0,0]')
|
|
2099
|
-
return 'ratio';
|
|
2100
|
-
if (t === '{"kind":"count"}')
|
|
2101
|
-
return 'count';
|
|
2102
|
-
return undefined;
|
|
2103
|
-
}
|
|
2104
|
-
/**
|
|
2105
|
-
* Before a part is taken away (chief architect's ruling 2026-09-27, replacing the refusals of 2026-09-25). What hung
|
|
2106
|
-
* from it stays where it stood at rest -- every control at zero -- fastened to nothing: its joint, and the motion and
|
|
2107
|
-
* control the joint gave it, go. A part that moved with its joint gets a control of its own, named after the part,
|
|
2108
|
-
* over the leader's range put through the multiplier and offset, starting where it stood. A value that followed its
|
|
2109
|
-
* size or place becomes the number it is now. One thing is refused, by name: a row laid along it, or hanging in it --
|
|
2110
|
-
* a row is made from its record, and a record needs the part (ADR-0093).
|
|
2111
|
-
*/
|
|
2112
|
-
export function releaseFigurePartJoints(asset, part, options = {}) {
|
|
2113
|
-
const a = asset;
|
|
2114
|
-
const m = a.document.model;
|
|
2115
|
-
const notice = { released: [], fixed: [], controls: [] };
|
|
2116
|
-
refuseIfRead(a, part, options.spaceFittedAgain);
|
|
2117
|
-
let joints = (a.joints ?? []);
|
|
2118
|
-
const rows = joints.filter(j => 'part' in j.body0 && j.body0.part === part && j.repeat).map(j => j.body1.part);
|
|
2119
|
-
if (rows.length)
|
|
2120
|
-
fail('PART_ROW', part, `${rows.join(', ')} ${rows.length > 1 ? 'are rows' : 'is a row'} laid along ${part}; fasten ${rows.length > 1 ? 'them' : 'it'} to another part, or make ${rows.length > 1 ? 'them' : 'it'} one part, first`);
|
|
2121
|
-
const own = joints.find(j => j.body1.part === part);
|
|
2122
|
-
const children = joints.filter(j => 'part' in j.body0 && j.body0.part === part);
|
|
2123
|
-
if (own?.repeat && children.length)
|
|
2124
|
-
fail('PART_ROW', part, `${children.map(j => j.body1.part).join(', ')} ${children.length > 1 ? 'hang' : 'hangs'} in the row ${part}, on every copy; take ${children.length > 1 ? 'them' : 'it'} off first`);
|
|
2125
|
-
/*
|
|
2126
|
-
Every number frozen below -- a follower's range and start, a measure that followed the part, a value read from it --
|
|
2127
|
-
is evaluated at the asset's stored design inputs and state defaults: the figure as it is authored, so its placement
|
|
2128
|
-
there is what it was. At another instance size the frozen numbers no longer follow.
|
|
2129
|
-
*/
|
|
2130
|
-
const inputs = (over = {}) => ({ ...a.designInputs, ...a.stateDefaults, ...over });
|
|
2131
|
-
const controls = m.inputs.filter((i) => i.role === 'state');
|
|
2132
|
-
const rest = Object.fromEntries(controls.map((i) => [i.id, Math.min(i.max, Math.max(i.min, 0))]));
|
|
2133
|
-
const graph = compileFigureGraph(m);
|
|
2134
|
-
const atRest = graph.evaluate(inputs(rest)).values;
|
|
2135
|
-
const now = graph.evaluate(inputs()).values;
|
|
2136
|
-
/* A part moving with a joint that goes: its own control, over what the leader's distance or angle came to. */
|
|
2137
|
-
const going = new Set([...(own ? [own.id] : []), ...children.map(j => j.id)]);
|
|
2138
|
-
/* A follower has no control of its own, so its own record's nominal id is not taken. */
|
|
2139
|
-
const taken = new Set([...m.inputs.map((i) => i.id), ...joints.filter(j => !j.mimic).map(stateIdOf)]);
|
|
2140
|
-
for (const f of joints.filter(o => figureMimicLeadersOf(o).some(t => going.has(t.joint)) && !going.has(o.id))) {
|
|
2141
|
-
/*
|
|
2142
|
-
A sum whose leaders all go is a control of its own over what the sum came to, as one leader's follower is. One
|
|
2143
|
-
whose other leaders stay would follow them and stand still by the rest -- what it should then be is not said, so
|
|
2144
|
-
it is refused by name rather than made up.
|
|
2145
|
-
*/
|
|
2146
|
-
const staying = figureMimicLeadersOf(f).filter(t => !going.has(t.joint));
|
|
2147
|
-
if (staying.length)
|
|
2148
|
-
fail('PART_FOLLOWED', part, `${f.id} follows ${staying.map(t => t.joint).join(', ')} as well as a joint that goes with ${part}; make ${f.id} follow only the joints that stay first`);
|
|
2149
|
-
const terms = figureMimicLeadersOf(f).map(t => {
|
|
2150
|
-
const leader = joints.find(o => o.id === t.joint);
|
|
2151
|
-
const quantity = nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader);
|
|
2152
|
-
const state = m.inputs.find((i) => i.id === stateIdOf(leader));
|
|
2153
|
-
const distance = (value) => Number(graph.evaluate(inputs({ [stateIdOf(leader)]: value })).values[quantity]);
|
|
2154
|
-
const [a, b] = [distance(state?.min ?? 0) * t.multiplier, distance(state?.max ?? 1) * t.multiplier];
|
|
2155
|
-
return { lo: Math.min(a, b), hi: Math.max(a, b), now: Number(now[quantity]) * t.multiplier };
|
|
2156
|
-
});
|
|
2157
|
-
const turn = f.type === 'revolute';
|
|
2158
|
-
const toUnit = (base) => round6(turn ? (base * 180) / Math.PI : base);
|
|
2159
|
-
const offset = (f.mimic.offset ?? 0) * (turn ? Math.PI / 180 : 1);
|
|
2160
|
-
/*
|
|
2161
|
-
Each leader moves on its own control, so the sum's least and greatest are each term's, added (a negative multiplier
|
|
2162
|
-
swaps a term's ends, above). When leaders are driven by one upstream input -- not the case today, a leader leads
|
|
2163
|
-
itself -- the summed range is conservative: it holds every value the sum can take, not the exact reach.
|
|
2164
|
-
*/
|
|
2165
|
-
const total = (pick) => toUnit(terms.reduce((sum, t, i) => (i ? sum + pick(t) : pick(t)), 0) + offset);
|
|
2166
|
-
const [lo, hi] = [total(t => t.lo), total(t => t.hi)];
|
|
2167
|
-
let id = f.body1.part;
|
|
2168
|
-
for (let n = 2; taken.has(id); n++)
|
|
2169
|
-
id = `${f.body1.part}-${n}`;
|
|
2170
|
-
taken.add(id);
|
|
2171
|
-
const start = Math.min(hi, Math.max(lo, total(t => t.now)));
|
|
2172
|
-
const { mimic: _led, travel: _share, ...alone } = f;
|
|
2173
|
-
joints = joints.map(o => o.id === f.id ? { ...alone, lowerLimit: lo, upperLimit: hi, state: { ...(f.state ?? {}), id, start } } : o);
|
|
2174
|
-
notice.controls.push({ part: f.body1.part, control: id, min: lo, max: hi });
|
|
2175
|
-
}
|
|
2176
|
-
/*
|
|
2177
|
-
A value in a record that followed the part: the number it is now -- at the stored design inputs (above). In a sum, each term that reads the part becomes
|
|
2178
|
-
its number, added to the margin, and the terms that read something else still follow; no term left, the number.
|
|
2179
|
-
*/
|
|
2180
|
-
const freezeMeasure = (measure, where) => {
|
|
2181
|
-
const f = followedTermsOf(measure);
|
|
2182
|
-
if (!f || !f.terms.some(t => t.from?.of === 'part' && t.from.part === part))
|
|
2183
|
-
return measure;
|
|
2184
|
-
const probe = structuredClone(m);
|
|
2185
|
-
let frozen = 0;
|
|
2186
|
-
const rest = [];
|
|
2187
|
-
for (const t of f.terms) {
|
|
2188
|
-
if (!(t.from.of === 'part' && t.from.part === part)) {
|
|
2189
|
-
rest.push(t);
|
|
2190
|
-
continue;
|
|
2191
|
-
}
|
|
2192
|
-
const ref = stableDimension(probe, part, t.from.axis, where);
|
|
2193
|
-
frozen += Number(compileFigureGraph(probe).evaluate(inputs()).values[ref]) * (t.times ?? 1);
|
|
2194
|
-
}
|
|
2195
|
-
if (!rest.length) {
|
|
2196
|
-
const value = round6(frozen + f.plus);
|
|
2197
|
-
notice.fixed.push({ part: where, value, unit: 'mm' });
|
|
2198
|
-
return value;
|
|
2199
|
-
}
|
|
2200
|
-
notice.fixed.push({ part: where, value: round6(frozen), unit: 'mm' });
|
|
2201
|
-
return { sum: rest, ...(round6(frozen + f.plus) ? { plus: round6(frozen + f.plus) } : {}) };
|
|
2202
|
-
};
|
|
2203
|
-
joints = joints.map(j => {
|
|
2204
|
-
if (j.id === own?.id || children.includes(j))
|
|
2205
|
-
return j;
|
|
2206
|
-
const out = structuredClone(j);
|
|
2207
|
-
for (const { get, set } of jointMeasures(out)) {
|
|
2208
|
-
const was = get();
|
|
2209
|
-
const fixed = freezeMeasure(was, j.body1.part);
|
|
2210
|
-
if (fixed !== was)
|
|
2211
|
-
set(fixed);
|
|
2212
|
-
}
|
|
2213
|
-
return out;
|
|
2214
|
-
});
|
|
2215
|
-
/*
|
|
2216
|
-
What reads a value that goes -- where the part, or a part let go of, is fastened (its joint's nodes), or where it is
|
|
2217
|
-
drawn -- and is not the space (fitted again, or refused above): the number it is now. Values the part shares with
|
|
2218
|
-
another (a shape two placements draw) stay, as before; they do not go.
|
|
2219
|
-
*/
|
|
2220
|
-
const jointNodes = (p) => (n) => n.id.startsWith(`${p}.on.`) || n.id.startsWith(`${p}.row`) || [`${p}.seat.rigid`, `${p}.chain`, `${p}.world`, `${p}.turned`, `${p}.motion`, `${p}.layout`, `${p}.assembly`, `${p}.repeat`].includes(n.id);
|
|
2221
|
-
const leaving = [part, ...children.map(j => j.body1.part)];
|
|
2222
|
-
const goingNodes = new Set(m.nodes.filter(n => leaving.some(p => jointNodes(p)(n))).map(n => n.id));
|
|
2223
|
-
const goingRefs = new Set(m.nodes.filter(n => goingNodes.has(n.id)).flatMap(n => Object.values(n.outputs)));
|
|
2224
|
-
const types = graph.outputTypes;
|
|
2225
|
-
const live = liveRefsOf(a);
|
|
2226
|
-
const space = spaceOnlyNodesOf(a);
|
|
2227
|
-
const partOf = (id) => String(id).split('.')[0];
|
|
2228
|
-
for (const n of m.nodes) {
|
|
2229
|
-
/* The parts leaving are released below; a row's copies are gathered again from the records. */
|
|
2230
|
-
if (goingNodes.has(n.id) || leaving.includes(partOf(n.id)) || n.op === 'assembly@1' || space.has(n.id) || !Object.values(n.outputs).some(o => live.has(o)))
|
|
2231
|
-
continue;
|
|
2232
|
-
/* A joint's own nodes are made again from its record, from where its parent stands then. */
|
|
2233
|
-
if (joints.some(j => jointNodes(j.body1.part)(n)))
|
|
2234
|
-
continue;
|
|
2235
|
-
n.args = n.args.map((ref, i) => {
|
|
2236
|
-
if (!goingRefs.has(ref))
|
|
2237
|
-
return ref;
|
|
2238
|
-
const unit = baseUnitOf(types[ref]);
|
|
2239
|
-
const value = now[ref];
|
|
2240
|
-
if (typeof value === 'number' && unit) {
|
|
2241
|
-
notice.fixed.push({ part: partOf(n.id), value: round6(unit === 'rad' ? (value * 180) / Math.PI : value), unit: unit === 'rad' ? 'deg' : unit });
|
|
2242
|
-
return constantOf(m, unit, round6(value) + 0, 'fixed');
|
|
2243
|
-
}
|
|
2244
|
-
const pose = value;
|
|
2245
|
-
const type = types[ref];
|
|
2246
|
-
if (pose?.r && pose.t && type?.kind === 'transform') {
|
|
2247
|
-
const id = namer(m, `${n.id}.fixed`)(`pose.${i}`);
|
|
2248
|
-
const [rx, ry, rz] = eulerXYZ(pose.r);
|
|
2249
|
-
m.nodes.push({ id, op: 'rigid@1', args: [...pose.t.map(v => constantOf(m, 'mm', round6(v) + 0, 'fixed')), ...[rx, ry, rz].map(v => constantOf(m, 'rad', round6((v * Math.PI) / 180), 'fixed'))], outputs: { pose: `${id}.value` }, params: { from: type.from, to: type.to } });
|
|
2250
|
-
notice.fixed.push({ part: partOf(n.id), value: 0, unit: 'pose' });
|
|
2251
|
-
return `${id}.value`;
|
|
2252
|
-
}
|
|
2253
|
-
fail('PART_REFERENCED', part, `${partOf(n.id)} reads ${part}'s ${ref}, which cannot be written as a number`);
|
|
2254
|
-
});
|
|
2255
|
-
}
|
|
2256
|
-
/* What hung from it: where it stood at rest, fastened to nothing. */
|
|
2257
|
-
const standing = new Map(children.map(j => [j.body1.part, atRest[nodeById(m, j.body1.part).args[1]]]));
|
|
2258
|
-
a.joints = joints.filter(j => j.id !== own?.id && !children.includes(j));
|
|
2259
|
-
if (!a.joints.length)
|
|
2260
|
-
delete a.joints;
|
|
2261
|
-
for (const j of children) {
|
|
2262
|
-
const child = j.body1.part;
|
|
2263
|
-
const control = j.type !== 'fixed' && !j.mimic ? stateIdOf(j) : undefined;
|
|
2264
|
-
stripJoint(a, j);
|
|
2265
|
-
const at = standing.get(child);
|
|
2266
|
-
const pose = nodeById(m, `${child}.pose`);
|
|
2267
|
-
const turn = eulerXYZ(at.r);
|
|
2268
|
-
const values = [...at.t.map(v => round6(v) + 0), ...turn];
|
|
2269
|
-
const old = pose.args.slice(0, 6);
|
|
2270
|
-
pose.args = [...AXES.map(axis => `${child}.position.${axis}`), ...AXES.map(axis => `${child}.rotation.${axis}`)].map((id, i) => {
|
|
2271
|
-
if (!m.inputs.some((n) => n.id === id))
|
|
2272
|
-
m.inputs.push({ id, unit: i < 3 ? 'mm' : 'deg', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
2273
|
-
a.designInputs[id] = values[i];
|
|
2274
|
-
return id;
|
|
2275
|
-
});
|
|
2276
|
-
for (const ref of old)
|
|
2277
|
-
if (!pose.args.includes(ref))
|
|
2278
|
-
dropIfUnused(a, ref, ownInput(m, child, ref) ? ref : undefined);
|
|
2279
|
-
notice.released.push({ part: child, ...(control ? { control } : {}) });
|
|
2280
|
-
}
|
|
2281
|
-
/*
|
|
2282
|
-
A control goes with its joint once nothing reads it -- read only after every part is let go of: a lamp and the shade
|
|
2283
|
-
that followed it, both released, left the lamp's control behind while the shade still read it (2026-09-27).
|
|
2284
|
-
*/
|
|
2285
|
-
const readAnywhere = (id) => m.nodes.some((n) => n.args.includes(id) || stringsIn(n.params).includes(id)) || stringsIn(a.occupancy).includes(id) || stringsIn(a.document.capabilities).includes(id);
|
|
2286
|
-
for (const r of notice.released) {
|
|
2287
|
-
if (!r.control)
|
|
2288
|
-
continue;
|
|
2289
|
-
if (readAnywhere(r.control)) {
|
|
2290
|
-
delete r.control;
|
|
2291
|
-
continue;
|
|
2292
|
-
}
|
|
2293
|
-
const id = r.control;
|
|
2294
|
-
m.inputs = m.inputs.filter((i) => i.id !== id);
|
|
2295
|
-
delete a.stateDefaults[id];
|
|
2296
|
-
delete a.designInputs[id];
|
|
2297
|
-
/* Its clip and its label go with it, as a motion's do (removeMotion). */
|
|
2298
|
-
if (a.drivers)
|
|
2299
|
-
a.drivers = a.drivers.filter((d) => d.state !== id);
|
|
2300
|
-
if (a.drivers && !a.drivers.length)
|
|
2301
|
-
delete a.drivers;
|
|
2302
|
-
if (a.stateInputs?.[id]) {
|
|
2303
|
-
const { [id]: _gone, ...rest } = a.stateInputs;
|
|
2304
|
-
if (Object.keys(rest).length)
|
|
2305
|
-
a.stateInputs = rest;
|
|
2306
|
-
else
|
|
2307
|
-
delete a.stateInputs;
|
|
2308
|
-
}
|
|
2309
|
-
}
|
|
2310
|
-
if (own)
|
|
2311
|
-
stripJoint(a, own);
|
|
2312
|
-
dropDeadDanglers(a);
|
|
2313
|
-
if (a.joints?.length)
|
|
2314
|
-
rewriteJoints(a);
|
|
2315
|
-
return notice;
|
|
2316
|
-
}
|
|
2317
|
-
/**
|
|
2318
|
-
* The nodes that make the declared space and nothing else: reached from what \`occupancy\` names, not from any drawn
|
|
2319
|
-
* body, record or binding. Named by what they do, not by their names -- an older fitting named them \`occ.*\`
|
|
2320
|
-
* (FORKLIFT), a still older one left some the declaration no longer reaches (WALL_LAMP), and a part's own chain the
|
|
2321
|
-
* space also reads is the part's (2026-09-27).
|
|
2322
|
-
*/
|
|
2323
|
-
export function spaceOnlyNodesOf(asset) {
|
|
2324
|
-
const a = asset;
|
|
2325
|
-
const m = a.document.model;
|
|
2326
|
-
const reach = (from) => {
|
|
2327
|
-
const seen = new Set();
|
|
2328
|
-
for (const pending = [...from]; pending.length;) {
|
|
2329
|
-
const writer = writerOf(m, pending.pop());
|
|
2330
|
-
if (!writer || seen.has(writer.id))
|
|
2331
|
-
continue;
|
|
2332
|
-
seen.add(writer.id);
|
|
2333
|
-
pending.push(...writer.args, ...stringsIn(writer.params));
|
|
2334
|
-
}
|
|
2335
|
-
return seen;
|
|
2336
|
-
};
|
|
2337
|
-
const drawn = reach([
|
|
2338
|
-
...m.nodes.filter((n) => n.op === 'place@1' || n.op === 'repeat@1').flatMap((n) => Object.values(n.outputs)),
|
|
2339
|
-
...stringsIn(a.appearance),
|
|
2340
|
-
...stringsIn(a.document.capabilities),
|
|
2341
|
-
...stringsIn(a.drivers),
|
|
2342
|
-
...stringsIn(a.publicInputs),
|
|
2343
|
-
...stringsIn(a.joints)
|
|
2344
|
-
]);
|
|
2345
|
-
return new Set([...reach(stringsIn(a.occupancy))].filter(id => !drawn.has(id)));
|
|
2346
|
-
}
|
|
2347
|
-
/**
|
|
2348
|
-
* A node no drawn body, declaration or record reaches, reading a value that is gone: an earlier fitting's space left
|
|
2349
|
-
* reading a joint's node that went with its part (WALL_LAMP's shade, 2026-09-27). It was read by nothing before, and it
|
|
2350
|
-
* goes with what it read. A node something does reach is left, and the figure is refused as broken.
|
|
2351
|
-
*/
|
|
2352
|
-
export function dropDeadDanglers(asset) {
|
|
2353
|
-
const model = asset.document.model;
|
|
2354
|
-
const strings = (v) => typeof v === 'string' ? [v] : Array.isArray(v) ? v.flatMap(strings) : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : [];
|
|
2355
|
-
for (;;) {
|
|
2356
|
-
const written = new Set([...model.nodes.flatMap(n => Object.values(n.outputs)), ...model.inputs.map(i => i.id), ...model.constants.map(c => c.id)]);
|
|
2357
|
-
const live = new Set();
|
|
2358
|
-
const pending = [
|
|
2359
|
-
...model.nodes.filter(n => n.op === 'place@1' || n.op === 'repeat@1').flatMap(n => Object.values(n.outputs)),
|
|
2360
|
-
...strings(asset.appearance),
|
|
2361
|
-
...strings(asset.occupancy),
|
|
2362
|
-
...strings(asset.document.capabilities),
|
|
2363
|
-
...strings(asset.drivers),
|
|
2364
|
-
...strings(asset.publicInputs),
|
|
2365
|
-
...strings(asset.joints)
|
|
2366
|
-
];
|
|
2367
|
-
while (pending.length) {
|
|
2368
|
-
const ref = pending.pop();
|
|
2369
|
-
if (live.has(ref))
|
|
2370
|
-
continue;
|
|
2371
|
-
live.add(ref);
|
|
2372
|
-
const writer = model.nodes.find(n => Object.values(n.outputs).includes(ref));
|
|
2373
|
-
if (writer)
|
|
2374
|
-
pending.push(...writer.args, ...strings(writer.params));
|
|
2375
|
-
}
|
|
2376
|
-
const dead = model.nodes.filter(n => n.args.some(a => !written.has(a)) && !Object.values(n.outputs).some(o => live.has(o)));
|
|
2377
|
-
if (!dead.length)
|
|
2378
|
-
return;
|
|
2379
|
-
model.nodes = model.nodes.filter(n => !dead.includes(n));
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
/** Every value a drawn body, a declaration or a record reaches, however far up. */
|
|
2383
|
-
export function liveRefsOf(asset) {
|
|
2384
|
-
const a = asset;
|
|
2385
|
-
const m = a.document.model;
|
|
2386
|
-
const live = new Set();
|
|
2387
|
-
const pending = [
|
|
2388
|
-
...m.nodes.filter((n) => n.op === 'place@1' || n.op === 'repeat@1').flatMap((n) => Object.values(n.outputs)),
|
|
2389
|
-
...stringsIn(a.appearance),
|
|
2390
|
-
...stringsIn(a.occupancy),
|
|
2391
|
-
...stringsIn(a.document.capabilities),
|
|
2392
|
-
...stringsIn(a.drivers),
|
|
2393
|
-
...stringsIn(a.publicInputs),
|
|
2394
|
-
...stringsIn(a.joints)
|
|
2395
|
-
];
|
|
2396
|
-
while (pending.length) {
|
|
2397
|
-
const ref = pending.pop();
|
|
2398
|
-
if (live.has(ref))
|
|
2399
|
-
continue;
|
|
2400
|
-
live.add(ref);
|
|
2401
|
-
const writer = writerOf(m, ref);
|
|
2402
|
-
if (writer)
|
|
2403
|
-
pending.push(...writer.args, ...stringsIn(writer.params));
|
|
2404
|
-
}
|
|
2405
|
-
return live;
|
|
2406
|
-
}
|
|
2407
|
-
function setJoint(asset, joint) {
|
|
2408
|
-
// As JSON: a field left undefined is a field not there, which is how the asset stores it.
|
|
2409
|
-
const j = JSON.parse(JSON.stringify(joint ?? null));
|
|
2410
|
-
checkJoint(asset, j);
|
|
2411
|
-
/* The child's own quarter turn becomes the record's, the first time it is fastened. */
|
|
2412
|
-
if (!jointAbove(asset, j.body1.part) && !j.origin?.turn) {
|
|
2413
|
-
const turn = turnOf(asset.document.model, asset, j.body1.part, j.body1.part);
|
|
2414
|
-
/* A part turned by any angle about the normal of the face it is fastened by keeps that angle as its twist. */
|
|
2415
|
-
const n = AXES.indexOf(FIGURE_FACES[childFaceOf(asset.document.model, j)]);
|
|
2416
|
-
const quarter = (t) => t !== null && Number.isFinite(t) && Math.abs(t / 90 - Math.round(t / 90)) < 1e-9;
|
|
2417
|
-
const twist = !quarter(turn[n]) && turn.every((t, i) => i === n || t === 0) ? turn[n] : undefined;
|
|
2418
|
-
if (twist !== undefined && !j.origin?.twist)
|
|
2419
|
-
j.origin = { ...(j.origin ?? {}), twist };
|
|
2420
|
-
else if (turn.some(t => t !== 0))
|
|
2421
|
-
j.origin = { ...(j.origin ?? {}), turn: Object.fromEntries(AXES.map((a, i) => [a, turn[i]])) };
|
|
2422
|
-
}
|
|
2423
|
-
const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
|
|
2424
|
-
const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === j.id));
|
|
2425
|
-
if (followers.length && j.type === 'fixed')
|
|
2426
|
-
fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${j.id}; it cannot stop moving`);
|
|
2427
|
-
asset.joints = [...joints, j];
|
|
2428
|
-
rewriteJoints(asset);
|
|
2429
|
-
}
|
|
2430
|
-
/** The joint goes; its child keeps where it stands now, as plain position numbers. */
|
|
2431
|
-
/**
|
|
2432
|
-
* Where what refers to a face an edit takes away goes instead (ADR-0100 A8): a joint re-pointed to another face of
|
|
2433
|
-
* the part, or let go where it stands (`detach`, a joint only); a named face moved to another face -- a capability
|
|
2434
|
-
* hangs on it, so it is never let go. Applied before the edit, in the same command: one step of the history. The new
|
|
2435
|
-
* faces must be the part's after the edit; `keepReferencedFaces` refuses one that is not.
|
|
2436
|
-
*/
|
|
2437
|
-
function applyFaceRemap(asset, part, remap) {
|
|
2438
|
-
if (!remap?.length)
|
|
2439
|
-
return;
|
|
2440
|
-
const faces = new Set();
|
|
2441
|
-
const shapeOutput = asset.document.model.nodes.find((o) => o.id === `${part}.shape`)?.outputs?.shape;
|
|
2442
|
-
const faceOf = (to, path) => {
|
|
2443
|
-
const face = typeof to?.face === 'string' ? to.face.trim() : '';
|
|
2444
|
-
if (!face)
|
|
2445
|
-
fail('SCHEMA', `${path}.to`, `a face of ${part}, or 'detach'`);
|
|
2446
|
-
return face;
|
|
2447
|
-
};
|
|
2448
|
-
for (const [i, r] of remap.entries()) {
|
|
2449
|
-
const path = `remap.${i}`;
|
|
2450
|
-
if (r?.ref?.kind === 'joint') {
|
|
2451
|
-
const j = (asset.joints ?? []).find(o => o.id === r.ref.id);
|
|
2452
|
-
if (!j || !(j.body0?.part === part || j.body1?.part === part))
|
|
2453
|
-
fail('EDIT_TARGET', path, `${r.ref.id} is not a joint on ${part}`);
|
|
2454
|
-
if (r.to === 'detach') {
|
|
2455
|
-
removeJoint(asset, j.id);
|
|
2456
|
-
continue;
|
|
2457
|
-
}
|
|
2458
|
-
const face = faceOf(r.to, path);
|
|
2459
|
-
if (j.body0?.part === part)
|
|
2460
|
-
j.body0.face = face;
|
|
2461
|
-
else
|
|
2462
|
-
j.body1.face = face;
|
|
2463
|
-
faces.add(face);
|
|
2464
|
-
}
|
|
2465
|
-
else if (r?.ref?.kind === 'feature') {
|
|
2466
|
-
const n = asset.document.model.nodes.find((o) => o.id === r.ref.id && o.op === 'feature@1');
|
|
2467
|
-
if (!n || n.args[0] !== shapeOutput)
|
|
2468
|
-
fail('EDIT_TARGET', path, `${r.ref.id} is not a named face of ${part}`);
|
|
2469
|
-
if (r.to === 'detach')
|
|
2470
|
-
fail('SCHEMA', `${path}.to`, `a named face moves to another face; a capability hangs on it, so it is not let go`);
|
|
2471
|
-
const face = faceOf(r.to, path);
|
|
2472
|
-
n.params = { ...n.params, name: face };
|
|
2473
|
-
faces.add(face);
|
|
2474
|
-
}
|
|
2475
|
-
else
|
|
2476
|
-
fail('SCHEMA', `${path}.ref`, `{ kind: 'joint' | 'feature', id }`);
|
|
2477
|
-
}
|
|
2478
|
-
remappedFaces.set(asset, faces);
|
|
2479
|
-
}
|
|
2480
|
-
function removeJoint(asset, id) {
|
|
2481
|
-
const joints = (asset.joints ?? []);
|
|
2482
|
-
const j = joints.find(o => o.id === id);
|
|
2483
|
-
if (!j)
|
|
2484
|
-
fail('EDIT_TARGET', `joints.${id}`, `there is no joint ${id}`);
|
|
2485
|
-
const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === id));
|
|
2486
|
-
if (followers.length)
|
|
2487
|
-
fail('JOINT_SCHEMA', `joints.${id}`, `${followers.map(f => f.id).join(', ')} follow ${id}; remove them first`);
|
|
2488
|
-
const m = asset.document.model;
|
|
2489
|
-
const child = j.body1.part;
|
|
2490
|
-
const evaluated = compileFigureAssetUnjointed(asset).evaluate();
|
|
2491
|
-
const placed = nodeById(m, child);
|
|
2492
|
-
const at = evaluated.values?.[placed.args[1]];
|
|
2493
|
-
if (!at?.t)
|
|
2494
|
-
fail('TARGET_ABSENT', child, `${child} has no pose to read`);
|
|
2495
|
-
const own = m.nodes.find((n) => n.id === `${child}.pose`);
|
|
2496
|
-
const turned = (r, want) => r.some((row, i) => row.some((v, k) => Math.abs(v - want[i][k]) > 1e-9));
|
|
2497
|
-
const ownTurn = evaluated.values?.[own.outputs.pose]?.r;
|
|
2498
|
-
if (ownTurn && turned(at.r, ownTurn))
|
|
2499
|
-
fail('EDIT_TARGET', child, `${child} stands turned by what it hangs from; taking the joint away would turn it`);
|
|
2500
|
-
asset.joints = joints.filter(o => o.id !== id);
|
|
2501
|
-
stripJoint(asset, j);
|
|
2502
|
-
const translation = AXES.map((axis, i) => {
|
|
2503
|
-
const input = `${child}.position.${axis}`;
|
|
2504
|
-
const value = round6(at.t[i]);
|
|
2505
|
-
if (!m.inputs.some((n) => n.id === input))
|
|
2506
|
-
m.inputs.push({ id: input, unit: 'mm', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
2507
|
-
asset.designInputs[input] = value;
|
|
2508
|
-
return input;
|
|
2509
|
-
});
|
|
2510
|
-
own.args = [...translation, ...own.args.slice(3)];
|
|
2511
|
-
// Parts hanging from it are written again: they hang from where it stands now.
|
|
2512
|
-
rewriteJoints(asset);
|
|
2513
|
-
}
|
|
2514
|
-
/** `attach` is the modeller's word for a fixed joint: the record says it, whatever joint was there keeps its id and motion. */
|
|
2515
|
-
function fastenAsJoint(asset, a) {
|
|
2516
|
-
const was = jointAbove(asset, a.part);
|
|
2517
|
-
if (was && !a.replace)
|
|
2518
|
-
fail('ATTACH_REPLACED', a.part, `${a.part} is already fastened by ${was.id}; pass replace to move it, or detach it first`);
|
|
2519
|
-
const to = a.to;
|
|
2520
|
-
/*
|
|
2521
|
-
A new record is named after its part -- unless another joint already has that name: a robot arm's finger joint
|
|
2522
|
-
was named `gripper`, and fastening the gripper wrote over it (2026-09-25). The first free name after it is taken.
|
|
2523
|
-
*/
|
|
2524
|
-
const taken = new Set((asset.joints ?? []).map(j => j.id));
|
|
2525
|
-
let id = a.part;
|
|
2526
|
-
for (let k = 2; !was && taken.has(id); k++)
|
|
2527
|
-
id = `${a.part}-${k}`;
|
|
2528
|
-
setJoint(asset, {
|
|
2529
|
-
...(was ?? { id, type: 'fixed' }),
|
|
2530
|
-
body0: to?.plane !== undefined ? { plane: to.plane } : { part: to?.part, face: to?.face },
|
|
2531
|
-
body1: { part: a.part, face: a.face },
|
|
2532
|
-
origin: {
|
|
2533
|
-
...(was?.origin?.turn ? { turn: was.origin.turn } : {}),
|
|
2534
|
-
...(a.twist !== undefined ? a.twist ? { twist: a.twist } : {} : was?.origin?.twist ? { twist: was.origin.twist } : {}),
|
|
2535
|
-
...(a.facing ? { facing: a.facing } : {}),
|
|
2536
|
-
...(a.gap !== undefined ? { gap: a.gap } : {}),
|
|
2537
|
-
...(a.align ? { align: a.align } : {})
|
|
2538
|
-
}
|
|
2539
|
-
});
|
|
2540
|
-
}
|
|
2541
|
-
/**
|
|
2542
|
-
* The records are the canon: made again from them, the joints' nodes must be the nodes the asset holds. An asset
|
|
2543
|
-
* whose nodes or records were edited apart is refused, not quietly rebuilt (ADR-0093, chief architect's condition).
|
|
2544
|
-
*/
|
|
2545
|
-
export function checkFigureJoints(asset) {
|
|
2546
|
-
const joints = asset.joints;
|
|
2547
|
-
if (!joints?.length)
|
|
2548
|
-
return;
|
|
2549
|
-
const again = structuredClone(asset);
|
|
2550
|
-
for (const j of joints)
|
|
2551
|
-
checkJoint({ ...again, joints: joints.filter(o => o.id !== j.id).concat(j) }, j);
|
|
2552
|
-
rewriteJoints(again);
|
|
2553
|
-
const canon = (model) => JSON.stringify({
|
|
2554
|
-
nodes: [...model.nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
2555
|
-
inputs: [...model.inputs].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
2556
|
-
});
|
|
2557
|
-
if (canon(again.document.model) !== canon(asset.document.model))
|
|
2558
|
-
fail('JOINT_DRIFT', 'joints', "the joints' nodes are not what their records make; the asset was edited apart from its joints");
|
|
2559
|
-
}
|
|
2560
|
-
/** The frame a part is fastened into: its parent's, or the asset's. */
|
|
2561
|
-
const attachmentFrameOf = (asset, id) => {
|
|
2562
|
-
const parent = parentOf(asset.document.model, id);
|
|
2563
|
-
return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
|
|
2564
|
-
};
|
|
2565
|
-
function addMotion(asset, a, follow, sign = 1) {
|
|
2566
|
-
const m = asset.document.model;
|
|
2567
|
-
const path = a.part;
|
|
2568
|
-
partOf(m, a.part, path);
|
|
2569
|
-
if (!a.motion || (a.motion.kind !== 'slide' && a.motion.kind !== 'turn'))
|
|
2570
|
-
fail('SCHEMA', path, 'a slide or a turn is required');
|
|
2571
|
-
if (!AXES.includes(a.motion.axis))
|
|
2572
|
-
fail('SCHEMA', path, `${String(a.motion.axis)} is not an axis`);
|
|
2573
|
-
if (nodeById(m, `${a.part}.motion`) && !a.replace)
|
|
2574
|
-
fail('MOTION_REPLACED', path, `${a.part} already moves; pass replace to change how, or remove the motion first`);
|
|
2575
|
-
if (nodeById(m, `${a.part}.motion`))
|
|
2576
|
-
removeMotion(asset, { kind: 'remove-motion', part: a.part });
|
|
2577
|
-
const frame = a.motion.frame ?? 'attachment';
|
|
2578
|
-
const seatedIn = attachmentFrameOf(asset, a.part);
|
|
2579
|
-
if (frame === 'asset' && seatedIn !== asset.document.capabilities.assetFrame)
|
|
2580
|
-
fail('MOTION_FRAME', path, `${a.part} is fastened into ${seatedIn}; an axis in the asset frame would have to be re-expressed there, which this command does not do`);
|
|
2581
|
-
else if (frame !== 'attachment' && frame !== 'asset')
|
|
2582
|
-
fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
|
|
2583
|
-
const st = a.state;
|
|
2584
|
-
const name = namer(m, `${a.part}.motion`);
|
|
2585
|
-
let quantity = st?.id;
|
|
2586
|
-
if (follow) {
|
|
2587
|
-
/*
|
|
2588
|
-
A mimic: the leader's value times k plus c, and no control of its own. A sum is each leader's value times its own
|
|
2589
|
-
k, added up, then c -- written with the same mul@1 and add@1 a one-leader mimic is, so a pose step runs it as it
|
|
2590
|
-
runs that (ADR-0108), and the gate reads it by the same operators.
|
|
2591
|
-
*/
|
|
2592
|
-
if (!follow.sum) {
|
|
2593
|
-
const scaledId = name('mimic');
|
|
2594
|
-
m.nodes.push({ id: scaledId, op: 'mul@1', args: [follow.terms[0].of, constantOf(m, 'ratio', follow.terms[0].k, 'k')], outputs: { value: `${scaledId}.value` } });
|
|
2595
|
-
quantity = `${scaledId}.value`;
|
|
2596
|
-
}
|
|
2597
|
-
else
|
|
2598
|
-
for (const [i, t] of follow.terms.entries()) {
|
|
2599
|
-
const scaledId = name(`mimic.${i}`);
|
|
2600
|
-
m.nodes.push({ id: scaledId, op: 'mul@1', args: [t.of, constantOf(m, 'ratio', t.k, 'k')], outputs: { value: `${scaledId}.value` } });
|
|
2601
|
-
if (!i)
|
|
2602
|
-
quantity = `${scaledId}.value`;
|
|
2603
|
-
else {
|
|
2604
|
-
const added = name(`mimic.${i}.sum`);
|
|
2605
|
-
m.nodes.push({ id: added, op: 'add@1', args: [quantity, `${scaledId}.value`], outputs: { value: `${added}.value` } });
|
|
2606
|
-
quantity = `${added}.value`;
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
if (follow.c) {
|
|
2610
|
-
const plus = name('mimic.offset');
|
|
2611
|
-
/* The offset is in the follower's own unit: millimetres for a slide, degrees for a turn. */
|
|
2612
|
-
const unitOf = a.motion.kind === 'slide' ? 'mm' : 'deg';
|
|
2613
|
-
m.nodes.push({ id: plus, op: 'add@1', args: [quantity, constantOf(m, unitOf, follow.c, 'd')], outputs: { value: `${plus}.value` } });
|
|
2614
|
-
quantity = `${plus}.value`;
|
|
2615
|
-
}
|
|
2616
|
-
}
|
|
2617
|
-
else {
|
|
2618
|
-
if (!st || typeof st.id !== 'string' || !st.id.trim())
|
|
2619
|
-
fail('SCHEMA', path, 'a state input id is required');
|
|
2620
|
-
const same = m.inputs.find((i) => i.id === st.id && i.role === 'state' && i.unit === st.unit && i.min === st.min && i.max === st.max);
|
|
2621
|
-
if (same) {
|
|
2622
|
-
/*
|
|
2623
|
-
The same control, written again: the joint's nodes are made again from its record, and the control stays -- a
|
|
2624
|
-
lamp shown while it is at least so much still reads it, and a value a board wrote keeps its meaning.
|
|
2625
|
-
*/
|
|
2626
|
-
}
|
|
2627
|
-
else if (m.inputs.some((i) => i.id === st.id)) {
|
|
2628
|
-
/*
|
|
2629
|
-
A control nothing reads any more -- its part re-made, its lamp moved to another -- is taken over under its name,
|
|
2630
|
-
so what a connector or a board writes to it keeps arriving. One something still reads is refused.
|
|
2631
|
-
*/
|
|
2632
|
-
const read = m.nodes.some((n) => n.args.includes(st.id)) ||
|
|
2633
|
-
(asset.drivers ?? []).some((d) => d.state === st.id) ||
|
|
2634
|
-
asset.appearance.some((look) => look.visibility === st.id);
|
|
2635
|
-
if (read)
|
|
2636
|
-
fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
|
|
2637
|
-
m.inputs = m.inputs.filter((i) => i.id !== st.id);
|
|
2638
|
-
delete asset.stateDefaults[st.id];
|
|
2639
|
-
if (asset.stateInputs?.[st.id]) {
|
|
2640
|
-
const { [st.id]: _gone, ...rest } = asset.stateInputs;
|
|
2641
|
-
asset.stateInputs = rest;
|
|
2642
|
-
}
|
|
2643
|
-
}
|
|
2644
|
-
const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
|
|
2645
|
-
if (!wanted.includes(st.unit))
|
|
2646
|
-
fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
|
|
2647
|
-
if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
|
|
2648
|
-
fail('SCHEMA', path, 'a range with max above min is required');
|
|
2649
|
-
const start = st.start ?? st.min;
|
|
2650
|
-
if (!(start >= st.min && start <= st.max))
|
|
2651
|
-
fail('SCHEMA', path, 'the starting value is outside the range');
|
|
2652
|
-
if (!same) {
|
|
2653
|
-
m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
|
|
2654
|
-
asset.stateDefaults[st.id] = start;
|
|
2655
|
-
}
|
|
2656
|
-
if (st.label !== undefined || st.sweep !== undefined) {
|
|
2657
|
-
asset.stateInputs = { ...(asset.stateInputs ?? {}) };
|
|
2658
|
-
asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
|
|
2659
|
-
}
|
|
2660
|
-
}
|
|
2661
|
-
if (a.motion.kind === 'slide' && st.unit === 'ratio') {
|
|
2662
|
-
if (!a.travel)
|
|
2663
|
-
fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
|
|
2664
|
-
const travel = a.travel;
|
|
2665
|
-
let length;
|
|
2666
|
-
if (Object.hasOwn(travel, 'sum')) {
|
|
2667
|
-
/* A stroke that is a sum of lengths: the terms added, then the margin, as any followed length (ADR-0107). */
|
|
2668
|
-
const plus = travel.plus ?? 0;
|
|
2669
|
-
length = scaled(m, name, sumOf(m, name, termRefs(m, asset, travel.sum, plus, `${path}.travel`), 'travel.sum'), 1, plus, 'travel');
|
|
2670
|
-
}
|
|
2671
|
-
else {
|
|
2672
|
-
const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
|
|
2673
|
-
length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
|
|
2674
|
-
}
|
|
2675
|
-
const id = name('distance');
|
|
2676
|
-
m.nodes.push({ id, op: 'mul@1', args: [quantity, length], outputs: { value: `${id}.value` } });
|
|
2677
|
-
quantity = `${id}.value`;
|
|
2678
|
-
}
|
|
2679
|
-
else if (a.motion.kind === 'slide' && a.travel)
|
|
2680
|
-
fail('SCHEMA', path, 'a mm control is the distance; travel would say it twice');
|
|
2681
|
-
const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? sign : 0, 'axis'));
|
|
2682
|
-
m.nodes.push({
|
|
2683
|
-
id: `${a.part}.motion`,
|
|
2684
|
-
op: a.motion.kind === 'slide' ? 'axis-slide@1' : 'axis-turn@1',
|
|
2685
|
-
args: [...unit, quantity],
|
|
2686
|
-
outputs: { pose: `${a.part}.motion.value` },
|
|
2687
|
-
params: { from: `${a.part}.seat`, to: seatedIn }
|
|
2688
|
-
});
|
|
2689
|
-
reseat(asset, a.part, seatedIn);
|
|
2690
|
-
if (a.clip) {
|
|
2691
|
-
const c = a.clip;
|
|
2692
|
-
if (typeof c.name !== 'string' || !c.name.trim())
|
|
2693
|
-
fail('SCHEMA', path, 'a clip is named');
|
|
2694
|
-
if (!(Number.isFinite(c.duration) && c.duration > 0))
|
|
2695
|
-
fail('SCHEMA', path, 'a clip runs for a positive number of seconds');
|
|
2696
|
-
asset.drivers = [...(asset.drivers ?? []), { id: `${a.part}/${c.name}`, clip: c.name, state: st.id, time: { unit: 's', duration: c.duration }, keys: structuredClone(c.keys), interpolation: 'linear', loop: c.loop ?? 'wrap', accumulates: c.accumulates ?? false }];
|
|
2697
|
-
}
|
|
2698
|
-
return asset;
|
|
2699
|
-
}
|
|
2700
|
-
/** "This part does not move any more." The control it created goes with it, and so does its clip. */
|
|
2701
|
-
function removeMotion(asset, a) {
|
|
2702
|
-
const m = asset.document.model;
|
|
2703
|
-
const motion = nodeById(m, `${a.part}.motion`);
|
|
2704
|
-
if (!motion)
|
|
2705
|
-
fail('EDIT_TARGET', a.part, `${a.part} does not move`);
|
|
2706
|
-
const quantity = motion.args[3];
|
|
2707
|
-
const to = nodeById(m, `${a.part}.seat.rigid`)?.params?.to ?? motion.params.to;
|
|
2708
|
-
// Which control this motion introduced, read before the nodes that name it are taken away.
|
|
2709
|
-
const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
|
|
2710
|
-
?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
|
|
2711
|
-
m.nodes = m.nodes.filter((n) => n !== motion);
|
|
2712
|
-
reseat(asset, a.part, to);
|
|
2713
|
-
dropIfUnused(asset, quantity, quantity);
|
|
2714
|
-
if (state && !m.nodes.some((n) => n.args.includes(state.id))) {
|
|
2715
|
-
m.inputs = m.inputs.filter((i) => i !== state);
|
|
2716
|
-
delete asset.stateDefaults[state.id];
|
|
2717
|
-
if (asset.stateInputs)
|
|
2718
|
-
delete asset.stateInputs[state.id];
|
|
2719
|
-
if (asset.drivers)
|
|
2720
|
-
asset.drivers = asset.drivers.filter((d) => d.state !== state.id);
|
|
2721
|
-
if (asset.drivers && !asset.drivers.length)
|
|
2722
|
-
delete asset.drivers;
|
|
2723
|
-
if (asset.stateInputs && !Object.keys(asset.stateInputs).length)
|
|
2724
|
-
delete asset.stateInputs;
|
|
2725
|
-
}
|
|
2726
|
-
return asset;
|
|
2727
|
-
}
|
|
2728
|
-
/** A figure as one value, for saying "this is the figure I read". The declaration itself is left out of it. */
|
|
2729
|
-
function fingerprintOf(asset) {
|
|
2730
|
-
/* The room and the placement are left out: neither changes what the room would measure (ADR-0097). */
|
|
2731
|
-
const text = JSON.stringify({ ...asset, placement: undefined, occupancy: asset.occupancy ? { ...asset.occupancy, bounds: null } : undefined });
|
|
2732
|
-
let h1 = 0x811c9dc5;
|
|
2733
|
-
let h2 = 0x01000193;
|
|
2734
|
-
for (let i = 0; i < text.length; i++) {
|
|
2735
|
-
h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
|
|
2736
|
-
h2 = Math.imul(h2 + text.charCodeAt(i), 0x85ebca6b) >>> 0;
|
|
2737
|
-
}
|
|
2738
|
-
return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
|
|
2739
|
-
}
|
|
2740
|
-
const linAdd = (a, b, k = 1) => ({ terms: [...a.terms, ...b.terms.map(t => ({ ref: t.ref, k: t.k * k }))], c: a.c + b.c * k });
|
|
2741
|
-
/** The same sum with each reference once. */
|
|
2742
|
-
const linNorm = (a) => {
|
|
2743
|
-
const by = new Map();
|
|
2744
|
-
for (const t of a.terms)
|
|
2745
|
-
by.set(t.ref, (by.get(t.ref) ?? 0) + t.k);
|
|
2746
|
-
return { terms: [...by].filter(([, k]) => k !== 0).map(([ref, k]) => ({ ref, k })), c: a.c };
|
|
2747
|
-
};
|
|
2748
|
-
/** A sum at least as large as either, where every reference is a size (never below zero): term by term, the larger. */
|
|
2749
|
-
const linAtLeast = (a, b) => {
|
|
2750
|
-
const by = new Map();
|
|
2751
|
-
for (const t of [...linNorm(a).terms, ...linNorm(b).terms])
|
|
2752
|
-
by.set(t.ref, Math.max(by.get(t.ref) ?? 0, t.k));
|
|
2753
|
-
return { terms: [...by].map(([ref, k]) => ({ ref, k })), c: Math.max(a.c, b.c) };
|
|
2754
|
-
};
|
|
2755
|
-
const linOf = (m, ref) => {
|
|
2756
|
-
const constant = m.constants.find((c) => c.id === ref);
|
|
2757
|
-
return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
|
|
2758
|
-
};
|
|
2759
|
-
/**
|
|
2760
|
-
* The same sum read back through the sums and scalings that make it up, down to what no command rewrites: the inputs,
|
|
2761
|
-
* and values that are not a sum (a row's count). A declared room that read a node a joint writes -- where a leg stands,
|
|
2762
|
-
* a share of the figure's depth -- was broken by the next fastening, which writes that joint's nodes afresh: the gantry
|
|
2763
|
-
* could not be re-fastened once its room was declared (UNKNOWN_REFERENCE, 2026-09-25).
|
|
2764
|
-
*/
|
|
2765
|
-
function linThroughSums(m, lin) {
|
|
2766
|
-
const constant = (ref) => m.constants.find((c) => c.id === ref)?.value;
|
|
2767
|
-
const ratio = (ref) => m.constants.find((c) => c.id === ref && c.unit === 'ratio')?.value;
|
|
2768
|
-
const expand = (ref, k, depth) => {
|
|
2769
|
-
const c = constant(ref);
|
|
2770
|
-
if (c !== undefined)
|
|
2771
|
-
return { terms: [], c: c * k };
|
|
2772
|
-
const w = depth < 256 ? writerOf(m, ref) : undefined;
|
|
2773
|
-
if (w?.op === 'add@1')
|
|
2774
|
-
return w.args.reduce((sum, arg) => linAdd(sum, expand(arg, k, depth + 1)), { terms: [], c: 0 });
|
|
2775
|
-
/* Only a length times a ratio: what is left is then a length, as each term of the room must be. */
|
|
2776
|
-
if (w?.op === 'mul@1') {
|
|
2777
|
-
const [a, b] = w.args;
|
|
2778
|
-
if (ratio(b) !== undefined)
|
|
2779
|
-
return expand(a, k * ratio(b), depth + 1);
|
|
2780
|
-
if (ratio(a) !== undefined)
|
|
2781
|
-
return expand(b, k * ratio(a), depth + 1);
|
|
2782
|
-
}
|
|
2783
|
-
return { terms: [{ ref, k }], c: 0 };
|
|
2784
|
-
};
|
|
2785
|
-
return linNorm(lin.terms.reduce((sum, t) => linAdd(sum, expand(t.ref, t.k, 0)), { terms: [], c: lin.c }));
|
|
2786
|
-
}
|
|
2787
|
-
/**
|
|
2788
|
-
* How far a slide has gone when its control is at one end of its range, as a sum: each control at that end, what the
|
|
2789
|
-
* figure's sizes make of the rest. A mimic of a leader whose stroke follows a length is the leader's ratio times that
|
|
2790
|
-
* length times its multiplier -- read through, not refused: the telescopic conveyor's second and third stages were
|
|
2791
|
-
* left out of its declared room, 2600 and 5200 mm past it (2026-09-25).
|
|
2792
|
-
*/
|
|
2793
|
-
function travelAt(m, asset, motion, bound) {
|
|
2794
|
-
const pick = (i) => bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max;
|
|
2795
|
-
const constant = (r) => m.constants.find((c) => c.id === r)?.value;
|
|
2796
|
-
const stateOf = (r) => m.inputs.find((i) => i.id === r && i.role === 'state');
|
|
2797
|
-
/* A value no control reaches: it is the same at either end, so it stays a term of the sum. */
|
|
2798
|
-
const still = (ref, depth = 0) => {
|
|
2799
|
-
if (stateOf(ref))
|
|
2800
|
-
return false;
|
|
2801
|
-
const w = depth < 256 ? writerOf(m, ref) : undefined;
|
|
2802
|
-
return !w || w.args.every((a) => typeof a !== 'string' || still(a, depth + 1));
|
|
2803
|
-
};
|
|
2804
|
-
const read = (ref, depth = 0) => {
|
|
2805
|
-
const c = constant(ref);
|
|
2806
|
-
if (c !== undefined)
|
|
2807
|
-
return { terms: [], c };
|
|
2808
|
-
const state = stateOf(ref);
|
|
2809
|
-
if (state)
|
|
2810
|
-
return { terms: [], c: pick(state) };
|
|
2811
|
-
const w = depth < 256 ? writerOf(m, ref) : undefined;
|
|
2812
|
-
if (w?.op === 'add@1') {
|
|
2813
|
-
let out = { terms: [], c: 0 };
|
|
2814
|
-
for (const arg of w.args) {
|
|
2815
|
-
const l = read(arg, depth + 1);
|
|
2816
|
-
if (!l)
|
|
2817
|
-
return null;
|
|
2818
|
-
out = linAdd(out, l);
|
|
2819
|
-
}
|
|
2820
|
-
return out;
|
|
2821
|
-
}
|
|
2822
|
-
if (w?.op === 'mul@1' && w.args.length === 2) {
|
|
2823
|
-
/* A ratio times a length (the one ratio's size, u × x₀) is kept whole: the sum's terms stay lengths. */
|
|
2824
|
-
const known = (arg) => m.constants.find((c) => c.id === arg);
|
|
2825
|
-
if (!w.args.every(known) && w.args.some(arg => known(arg) && known(arg).unit !== 'ratio'))
|
|
2826
|
-
return still(ref) ? { terms: [{ ref, k: 1 }], c: 0 } : null;
|
|
2827
|
-
const [a, b] = w.args.map(arg => read(arg, depth + 1));
|
|
2828
|
-
if (!a || !b)
|
|
2829
|
-
return null;
|
|
2830
|
-
// One side a number: a product of two sizes is not a length.
|
|
2831
|
-
if (!a.terms.length)
|
|
2832
|
-
return linAdd({ terms: [], c: 0 }, b, a.c);
|
|
2833
|
-
if (!b.terms.length)
|
|
2834
|
-
return linAdd({ terms: [], c: 0 }, a, b.c);
|
|
2835
|
-
return null;
|
|
2836
|
-
}
|
|
2837
|
-
return still(ref) ? { terms: [{ ref, k: 1 }], c: 0 } : null;
|
|
2838
|
-
};
|
|
2839
|
-
return read(motion.args[3]);
|
|
2840
|
-
}
|
|
2841
|
-
const zeroVec = () => ({ x: { terms: [], c: 0 }, y: { terms: [], c: 0 }, z: { terms: [], c: 0 } });
|
|
2842
|
-
/** A vector of sums turned by a quarter turn (a signed axis permutation). */
|
|
2843
|
-
const turnVec = (r, v) => Object.fromEntries(AXES.map((a, i) => [a, AXES.reduce((sum, b, j) => (r[i][j] ? linAdd(sum, v[b], r[i][j]) : sum), { terms: [], c: 0 })]));
|
|
2844
|
-
const isQuarter = (t) => t !== null && Number.isFinite(t) && Math.abs(t / 90 - Math.round(t / 90)) < 1e-9;
|
|
2845
|
-
/** A part's own turn: none, a quarter turn, or one this command cannot add up (any other angle, or not a number). */
|
|
2846
|
-
function poseTurnOf(m, asset, part) {
|
|
2847
|
-
const pose = nodeById(m, `${part}.pose`);
|
|
2848
|
-
const turn = (pose?.args.slice(3, 6) ?? []).map((ref) => angleOf(m, asset, ref));
|
|
2849
|
-
if (turn.every((t) => t === 0))
|
|
2850
|
-
return null;
|
|
2851
|
-
return turn.every(isQuarter) ? turnMatrix(turn) : 'other';
|
|
2852
|
-
}
|
|
2853
|
-
/** Whether a part, or anything carrying it, turns on a joint or stands turned by other than quarter turns. */
|
|
2854
|
-
function carriedByATurn(m, asset, id) {
|
|
2855
|
-
for (let part = id; part; part = parentOf(m, part)) {
|
|
2856
|
-
if (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1')
|
|
2857
|
-
return true;
|
|
2858
|
-
if (poseTurnOf(m, asset, part) === 'other')
|
|
2859
|
-
return true;
|
|
2860
|
-
}
|
|
2861
|
-
return false;
|
|
2862
|
-
}
|
|
2863
|
-
/** How a part's own axes lie in the figure's: the quarter turns of it and everything carrying it, composed. */
|
|
2864
|
-
function worldTurnOf(m, asset, id) {
|
|
2865
|
-
let c = IDENTITY;
|
|
2866
|
-
for (let part = id; part; part = parentOf(m, part)) {
|
|
2867
|
-
const r = poseTurnOf(m, asset, part);
|
|
2868
|
-
if (r === 'other')
|
|
2869
|
-
return null;
|
|
2870
|
-
if (r)
|
|
2871
|
-
c = mul3(r, c);
|
|
2872
|
-
}
|
|
2873
|
-
return c;
|
|
2874
|
-
}
|
|
2875
|
-
/**
|
|
2876
|
-
* Where a point of a part lies in the figure, as sums, following whatever it is fastened to: its centre, or `from` in
|
|
2877
|
-
* its own frame. A part standing a quarter turned turns what it carries -- a valve body on a lying pipe, a hub on its
|
|
2878
|
-
* post -- and was refused, those figures left without a room (2026-09-25); another angle still is.
|
|
2879
|
-
*/
|
|
2880
|
-
function centreVec(m, asset, id, bound, from) {
|
|
2881
|
-
let v = from ?? zeroVec();
|
|
2882
|
-
for (let part = id; part; part = parentOf(m, part)) {
|
|
2883
|
-
const pose = nodeById(m, `${part}.pose`);
|
|
2884
|
-
if (!pose)
|
|
2885
|
-
return `${part} has no pose this command can read`;
|
|
2886
|
-
/* A part's own turn turns it about its centre: its centre does not move by it. */
|
|
2887
|
-
if (part !== id || from) {
|
|
2888
|
-
const r = poseTurnOf(m, asset, part);
|
|
2889
|
-
if (r === 'other')
|
|
2890
|
-
return `${part} is turned by other than quarter turns, so what it carries is not a box this command can add up`;
|
|
2891
|
-
if (r)
|
|
2892
|
-
v = turnVec(r, v);
|
|
2893
|
-
}
|
|
2894
|
-
for (const [i, a] of AXES.entries())
|
|
2895
|
-
v[a] = linAdd(v[a], linOf(m, pose.args[i]));
|
|
2896
|
-
const motion = nodeById(m, `${part}.motion`);
|
|
2897
|
-
if (motion) {
|
|
2898
|
-
if (motion.op !== 'axis-slide@1')
|
|
2899
|
-
return `${part} turns, so its reach is not a box this command can add up`;
|
|
2900
|
-
const unit = motion.args.slice(0, 3).map((ref) => m.constants.find((k) => k.id === ref)?.value ?? null);
|
|
2901
|
-
if (unit.some((u) => u === null))
|
|
2902
|
-
return `${part}'s slide axis is not a constant`;
|
|
2903
|
-
if (unit.some((u) => u !== 0)) {
|
|
2904
|
-
const travel = travelAt(m, asset, motion, bound);
|
|
2905
|
-
if (!travel)
|
|
2906
|
-
return `${part}'s travel is not a distance this command can read`;
|
|
2907
|
-
for (const [i, a] of AXES.entries())
|
|
2908
|
-
if (unit[i])
|
|
2909
|
-
v[a] = linAdd(v[a], travel, unit[i]);
|
|
2910
|
-
}
|
|
2911
|
-
}
|
|
2912
|
-
const seat = nodeById(m, `${part}.seat.rigid`);
|
|
2913
|
-
if (seat) {
|
|
2914
|
-
if (seat.args.slice(3, 6).some((ref) => angleOf(m, asset, ref) !== 0))
|
|
2915
|
-
return `${part} is seated turned, which this command does not add up`;
|
|
2916
|
-
for (const [i, a] of AXES.entries())
|
|
2917
|
-
v[a] = linAdd(v[a], linOf(m, seat.args[i]));
|
|
2918
|
-
}
|
|
2919
|
-
}
|
|
2920
|
-
return v;
|
|
2921
|
-
}
|
|
2922
|
-
/** Where a part's centre is on one axis, as a sum, following whatever it is fastened to. */
|
|
2923
|
-
function centreLin(m, asset, id, axis, bound) {
|
|
2924
|
-
const v = centreVec(m, asset, id, bound);
|
|
2925
|
-
return typeof v === 'string' ? v : v[axis];
|
|
2926
|
-
}
|
|
2927
|
-
/** A value written as a sum of design inputs times constants, or null where it is anything else. */
|
|
2928
|
-
function affineOf(m, ref, seen = 0) {
|
|
2929
|
-
if (seen > 64)
|
|
2930
|
-
return null;
|
|
2931
|
-
const constant = m.constants.find((c) => c.id === ref);
|
|
2932
|
-
if (constant)
|
|
2933
|
-
return { terms: [], c: constant.value };
|
|
2934
|
-
if (m.inputs.some((i) => i.id === ref))
|
|
2935
|
-
return { terms: [{ ref, k: 1 }], c: 0 };
|
|
2936
|
-
const writer = writerOf(m, ref);
|
|
2937
|
-
if (writer?.op === 'add@1') {
|
|
2938
|
-
let out = { terms: [], c: 0 };
|
|
2939
|
-
for (const arg of writer.args) {
|
|
2940
|
-
const part = affineOf(m, arg, seen + 1);
|
|
2941
|
-
if (!part)
|
|
2942
|
-
return null;
|
|
2943
|
-
out = linAdd(out, part);
|
|
2944
|
-
}
|
|
2945
|
-
return out;
|
|
2946
|
-
}
|
|
2947
|
-
if (writer?.op === 'mul@1' && writer.args.length === 2) {
|
|
2948
|
-
/* A ratio times a length is kept whole: its terms must stay lengths (the one ratio's size, u × x₀). */
|
|
2949
|
-
const constant = (arg) => m.constants.find((c) => c.id === arg);
|
|
2950
|
-
const ratioTimesLength = !writer.args.every(constant) && writer.args.some((arg) => constant(arg) && constant(arg).unit !== 'ratio');
|
|
2951
|
-
if (ratioTimesLength)
|
|
2952
|
-
return sizeValue(m, ref) ? { terms: [{ ref, k: 1 }], c: 0 } : null;
|
|
2953
|
-
const [a, b] = writer.args.map((arg) => affineOf(m, arg, seen + 1));
|
|
2954
|
-
if (!a || !b)
|
|
2955
|
-
return null;
|
|
2956
|
-
if (!a.terms.length)
|
|
2957
|
-
return linAdd({ terms: [], c: 0 }, b, a.c);
|
|
2958
|
-
if (!b.terms.length)
|
|
2959
|
-
return linAdd({ terms: [], c: 0 }, a, b.c);
|
|
2960
|
-
}
|
|
2961
|
-
/* Not a sum, but a size all the same: the one ratio a figure grows by, read as it is. */
|
|
2962
|
-
if (sizeValue(m, ref))
|
|
2963
|
-
return { terms: [{ ref, k: 1 }], c: 0 };
|
|
2964
|
-
return null;
|
|
2965
|
-
}
|
|
2966
|
-
/**
|
|
2967
|
-
* A value never below zero that is not a sum of inputs: the mean of sizes, or it over a positive number -- the ratio
|
|
2968
|
-
* a figure that grows by one ratio grows by (`scaleUniformly`). A turntable made to follow it had its platter and
|
|
2969
|
-
* clamps left out of the room, their reach no longer a sum of inputs (2026-09-25).
|
|
2970
|
-
*/
|
|
2971
|
-
function sizeValue(m, ref, seen = 0) {
|
|
2972
|
-
if (seen > 64)
|
|
2973
|
-
return false;
|
|
2974
|
-
const input = m.inputs.find((i) => i.id === ref);
|
|
2975
|
-
if (input)
|
|
2976
|
-
return input.role !== 'state' && input.min >= 0;
|
|
2977
|
-
const w = writerOf(m, ref);
|
|
2978
|
-
const positive = (r) => (m.constants.find((c) => c.id === r)?.value ?? -1) > 0;
|
|
2979
|
-
if (w?.op === 'geomean@1')
|
|
2980
|
-
return w.args.every((a) => sizeValue(m, a, seen + 1));
|
|
2981
|
-
if (w?.op === 'div@1')
|
|
2982
|
-
return sizeValue(m, w.args[0], seen + 1) && positive(w.args[1]);
|
|
2983
|
-
if (w?.op === 'mul@1')
|
|
2984
|
-
return (w.args.length === 2 && ((sizeValue(m, w.args[0], seen + 1) && positive(w.args[1])) || (positive(w.args[0]) && sizeValue(m, w.args[1], seen + 1))));
|
|
2985
|
-
return false;
|
|
2986
|
-
}
|
|
2987
|
-
/**
|
|
2988
|
-
* How long a value can be, at most, as a sum that holds at every design size: each input's coefficient taken
|
|
2989
|
-
* positive. Sound only over inputs that are never below zero (sizes), so any other input makes it unreadable.
|
|
2990
|
-
*/
|
|
2991
|
-
function magnitudeOf(m, lin) {
|
|
2992
|
-
let out = { terms: [], c: 0 };
|
|
2993
|
-
for (const t of lin.terms) {
|
|
2994
|
-
const a = affineOf(m, t.ref);
|
|
2995
|
-
if (!a)
|
|
2996
|
-
return null;
|
|
2997
|
-
for (const u of a.terms) {
|
|
2998
|
-
if (!sizeValue(m, u.ref))
|
|
2999
|
-
return null;
|
|
3000
|
-
out = linAdd(out, { terms: [{ ref: u.ref, k: Math.abs(u.k * t.k) }], c: 0 });
|
|
3001
|
-
}
|
|
3002
|
-
out.c += Math.abs(a.c * t.k);
|
|
3003
|
-
}
|
|
3004
|
-
out.c += Math.abs(lin.c);
|
|
3005
|
-
return out;
|
|
3006
|
-
}
|
|
3007
|
-
/**
|
|
3008
|
-
* Where a part carried by a turn can reach: a ball about the point the first turn above it turns about, as wide as
|
|
3009
|
-
* every step from there to the part laid end to end, plus the part's own half size on each axis (the triangle
|
|
3010
|
-
* inequality -- it holds at every angle and every design size, so it is a bound, not a sample). As a box, that
|
|
3011
|
-
* ball is the pivot plus and minus its radius on each axis. Read by the proposal; the release gate still checks
|
|
3012
|
-
* the parts against it.
|
|
3013
|
-
*/
|
|
3014
|
-
function reachOf(m, asset, id, ends, freezeless = true) {
|
|
3015
|
-
const chain = [];
|
|
3016
|
-
for (let part = id; part; part = parentOf(m, part))
|
|
3017
|
-
chain.push(part);
|
|
3018
|
-
/* The topmost part that turns on a joint, or stands turned by an angle a box cannot follow (a fan's blade at 120°). */
|
|
3019
|
-
const top = chain.reduce((found, part, i) => nodeById(m, `${part}.motion`)?.op === 'axis-turn@1' || poseTurnOf(m, asset, part) === 'other' ? i : found, -1);
|
|
3020
|
-
if (top < 0)
|
|
3021
|
-
return `${id} is carried by no turn`;
|
|
3022
|
-
let radius = { terms: [], c: 0 };
|
|
3023
|
-
const grow = (lin, what) => {
|
|
3024
|
-
const size = lin && magnitudeOf(m, lin);
|
|
3025
|
-
if (!size)
|
|
3026
|
-
return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
|
|
3027
|
-
radius = linAdd(radius, size);
|
|
3028
|
-
return null;
|
|
3029
|
-
};
|
|
3030
|
-
/* Its half size on every axis of its own, whichever way it stands: the ball holds it at any angle. */
|
|
3031
|
-
const sizes = sizeRefsOf(m, id, id);
|
|
3032
|
-
void freezeless;
|
|
3033
|
-
for (const axis of AXES) {
|
|
3034
|
-
const bad = grow({ terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 }, `${id}'s size`);
|
|
3035
|
-
if (bad)
|
|
3036
|
-
return bad;
|
|
3037
|
-
}
|
|
3038
|
-
for (let i = 0; i <= top; i++) {
|
|
3039
|
-
const part = chain[i];
|
|
3040
|
-
const pose = nodeById(m, `${part}.pose`);
|
|
3041
|
-
if (!pose)
|
|
3042
|
-
return `${part} has no pose this command can read`;
|
|
3043
|
-
for (const ref of pose.args.slice(0, 3)) {
|
|
3044
|
-
const bad = grow(linOf(m, ref), `${part}'s place`);
|
|
3045
|
-
if (bad)
|
|
3046
|
-
return bad;
|
|
3047
|
-
}
|
|
3048
|
-
if (i < top) {
|
|
3049
|
-
const seat = nodeById(m, `${part}.seat.rigid`);
|
|
3050
|
-
for (const ref of seat?.args.slice(0, 3) ?? []) {
|
|
3051
|
-
const bad = grow(linOf(m, ref), `where ${part} is fastened`);
|
|
3052
|
-
if (bad)
|
|
3053
|
-
return bad;
|
|
3054
|
-
}
|
|
3055
|
-
const motion = nodeById(m, `${part}.motion`);
|
|
3056
|
-
if (motion?.op === 'axis-slide@1')
|
|
3057
|
-
for (const end of ['min', 'max']) {
|
|
3058
|
-
const bad = grow(travelAt(m, asset, motion, end), `${part}'s travel`);
|
|
3059
|
-
if (bad)
|
|
3060
|
-
return bad;
|
|
3061
|
-
}
|
|
3062
|
-
}
|
|
3063
|
-
}
|
|
3064
|
-
/*
|
|
3065
|
-
The pivot: where the topmost turn is fastened, and everything above it. Above it nothing turns, but a slide may
|
|
3066
|
-
carry it -- the telescopic conveyor's nose turns at the end of its third stage -- so the pivot is read at each
|
|
3067
|
-
end of the controls, a ball at each: a slide is a straight line, so the balls at its two ends bound it all the
|
|
3068
|
-
way (it had been refused, and the nose left out of the room).
|
|
3069
|
-
*/
|
|
3070
|
-
const pivotPart = chain[top];
|
|
3071
|
-
const parent = parentOf(m, pivotPart);
|
|
3072
|
-
const seat = nodeById(m, `${pivotPart}.seat.rigid`);
|
|
3073
|
-
const seatAt = Object.fromEntries(AXES.map((a, i) => [a, seat ? linOf(m, seat.args[i]) : { terms: [], c: 0 }]));
|
|
3074
|
-
const centres = [];
|
|
3075
|
-
for (const end of ends) {
|
|
3076
|
-
/* The point it turns about, in the frame of what carries it, carried there -- turned with it if that stands turned. */
|
|
3077
|
-
const at = parent ? centreVec(m, asset, parent, end, structuredClone(seatAt)) : seatAt;
|
|
3078
|
-
if (typeof at === 'string')
|
|
3079
|
-
return at;
|
|
3080
|
-
centres.push(at);
|
|
3081
|
-
}
|
|
3082
|
-
return { centres, radius };
|
|
3083
|
-
}
|
|
3084
|
-
/** A sum written as graph nodes: each term scaled, then added up, then the constant. */
|
|
3085
|
-
function emitLin(m, name, l, hint) {
|
|
3086
|
-
if (!l.terms.length)
|
|
3087
|
-
return constantOf(m, 'mm', round6(l.c), 'd');
|
|
3088
|
-
let out = '';
|
|
3089
|
-
for (const t of l.terms) {
|
|
3090
|
-
const piece = scaled(m, name, t.ref, t.k, 0, hint);
|
|
3091
|
-
if (!out)
|
|
3092
|
-
out = piece;
|
|
3093
|
-
else {
|
|
3094
|
-
const id = name(`${hint}.sum`);
|
|
3095
|
-
m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
|
|
3096
|
-
out = `${id}.value`;
|
|
3097
|
-
}
|
|
3098
|
-
}
|
|
3099
|
-
if (l.c !== 0) {
|
|
3100
|
-
const id = name(`${hint}.offset`);
|
|
3101
|
-
m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
|
|
3102
|
-
out = `${id}.value`;
|
|
3103
|
-
}
|
|
3104
|
-
return out;
|
|
3105
|
-
}
|
|
3106
|
-
/** The bounds the parts come to, as sums, with what could not be read named. */
|
|
3107
|
-
function occupancyLins(asset, over) {
|
|
3108
|
-
const m = asset.document.model;
|
|
3109
|
-
const parts = [];
|
|
3110
|
-
const skipped = [];
|
|
3111
|
-
const low = { x: [], y: [], z: [] };
|
|
3112
|
-
const high = { x: [], y: [], z: [] };
|
|
3113
|
-
/* Parts carried by the same turn share one ball: its radius the larger of theirs, term by term. */
|
|
3114
|
-
const balls = new Map();
|
|
3115
|
-
/* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
|
|
3116
|
-
const rowOf = (id) => nodeById(m, `${id}.repeat`);
|
|
3117
|
-
const ends = over === 'rest' ? ['start'] : ['min', 'max'];
|
|
3118
|
-
/* A part's size along the figure's axes, whichever quarter turns it and what carries it stand in. */
|
|
3119
|
-
const sizesOf = (id, turns) => {
|
|
3120
|
-
/* A turned chain's reach is read from one size per axis (reachOf), so a part there must have one. */
|
|
3121
|
-
const own = occupiedExtentsOf(m, id, id, turns);
|
|
3122
|
-
const c = turns ? IDENTITY : worldTurnOf(m, asset, id);
|
|
3123
|
-
if (!c)
|
|
3124
|
-
throw new FigureContractError('EDIT_TARGET', id, `${id} is turned by other than quarter turns`);
|
|
3125
|
-
return Object.fromEntries(AXES.map((axis, i) => {
|
|
3126
|
-
const j = c[i].findIndex(v => v !== 0);
|
|
3127
|
-
const e = own[AXES[j]];
|
|
3128
|
-
/* A size is the same either way round; a reach turned half about flips: its offsets change sign. */
|
|
3129
|
-
return [axis, isReach(e) && c[i][j] < 0 ? { offsets: e.offsets.map(o => ({ ...o, k: -o.k })) } : e];
|
|
3130
|
-
}));
|
|
3131
|
-
};
|
|
3132
|
-
/*
|
|
3133
|
-
A row runs from its parent's one edge to the other along its axis, every copy inside (fit-pitch and fixed-count lay
|
|
3134
|
-
them out within that length). What it spans there, how long its part is along it, and how far a rising row climbs.
|
|
3135
|
-
*/
|
|
3136
|
-
const runOf = (part) => {
|
|
3137
|
-
const row = rowOf(part);
|
|
3138
|
-
const axis = String(row.params?.axis ?? '').toLowerCase();
|
|
3139
|
-
const parent = parentOf(m, part);
|
|
3140
|
-
const straight = parent && worldTurnOf(m, asset, parent);
|
|
3141
|
-
if (!parent || !AXES.includes(axis) || !straight || AXES.some((_, i) => straight[i][i] !== 1))
|
|
3142
|
-
return `${part} is a row whose run this command cannot read`;
|
|
3143
|
-
const theirs = standingRefsOf(m, asset, parent, parent, false)[axis];
|
|
3144
|
-
const half = { terms: [{ ref: theirs.ref, k: 0.5 * theirs.k }], c: 0 };
|
|
3145
|
-
const centres = ends.map(end => centreLin(m, asset, parent, axis, end));
|
|
3146
|
-
const bad = centres.find(c => typeof c === 'string');
|
|
3147
|
-
if (bad)
|
|
3148
|
-
return String(bad);
|
|
3149
|
-
const along = sizesOf(part, false)[axis];
|
|
3150
|
-
if (isReach(along))
|
|
3151
|
-
return `${part} is a row of a drawn section along its section; how far each copy reaches is not added up`;
|
|
3152
|
-
const mine = along[0];
|
|
3153
|
-
const extent = { terms: [{ ref: mine.ref, k: mine.k }], c: 0 };
|
|
3154
|
-
const record = (asset.joints ?? []).find(o => o.body1.part === part && o.repeat)?.repeat;
|
|
3155
|
-
let climb;
|
|
3156
|
-
if (record?.rise) {
|
|
3157
|
-
/*
|
|
3158
|
-
The last copy is (count − 1) rises past the first. A row that fills its length has at most
|
|
3159
|
-
(length − one copy) / pitch copies past the first, so that many rises bound it and follow the parent's size;
|
|
3160
|
-
a counted row has exactly count − 1.
|
|
3161
|
-
*/
|
|
3162
|
-
const per = record.rise.mm;
|
|
3163
|
-
let reach;
|
|
3164
|
-
if (record.count !== undefined)
|
|
3165
|
-
reach = { terms: [], c: (record.count - 1) * per };
|
|
3166
|
-
else if (typeof record.pitch === 'number')
|
|
3167
|
-
reach = { terms: [{ ref: theirs.ref, k: (theirs.k * per) / record.pitch }, { ref: mine.ref, k: (-mine.k * per) / record.pitch }], c: 0 };
|
|
3168
|
-
else
|
|
3169
|
-
return `${part} is a rising row whose pitch follows a length; how far it rises is not added up`;
|
|
3170
|
-
climb = { axis: record.rise.axis.toLowerCase(), up: per > 0, reach };
|
|
3171
|
-
}
|
|
3172
|
-
return { axis, lows: centres.map(c => linAdd(c, half, -1)), highs: centres.map(c => linAdd(c, half, 1)), extent, climb };
|
|
3173
|
-
};
|
|
3174
|
-
/* A part in a row, or fastened to one: each copy carries it (ADR-0093, 2026-09-26). */
|
|
3175
|
-
for (const place of m.nodes.filter((n) => n.op === 'place@1' || (n.op === 'member@1' && (rowOf(n.id) || rowHolding(m, n.id))))) {
|
|
3176
|
-
const turns = carriedByATurn(m, asset, place.id);
|
|
3177
|
-
let sizes = {};
|
|
3178
|
-
try {
|
|
3179
|
-
sizes = sizesOf(place.id, turns);
|
|
3180
|
-
}
|
|
3181
|
-
catch (e) {
|
|
3182
|
-
skipped.push({ part: place.id, reason: e instanceof FigureContractError ? e.message : 'not a part whose size this command can read' });
|
|
3183
|
-
continue;
|
|
3184
|
-
}
|
|
3185
|
-
/*
|
|
3186
|
-
The rows around this part, nearest first: its own if it is a row's part, then the row of each part it hangs from
|
|
3187
|
-
(a grid is a row inside a row, each along its own axis).
|
|
3188
|
-
*/
|
|
3189
|
-
const rows = [];
|
|
3190
|
-
let unread;
|
|
3191
|
-
for (let up = place.op === 'member@1' ? place.id : null; up; up = parentOf(m, up)) {
|
|
3192
|
-
if (!rowOf(up))
|
|
3193
|
-
continue;
|
|
3194
|
-
const run = runOf(up);
|
|
3195
|
-
if (typeof run === 'string') {
|
|
3196
|
-
unread = run;
|
|
3197
|
-
break;
|
|
3198
|
-
}
|
|
3199
|
-
rows.push({ part: up, run });
|
|
3200
|
-
}
|
|
3201
|
-
if (unread) {
|
|
3202
|
-
skipped.push({ part: place.id, reason: unread });
|
|
3203
|
-
continue;
|
|
3204
|
-
}
|
|
3205
|
-
const rowAxes = new Set(rows.map(r => r.run.axis));
|
|
3206
|
-
/* How far the rows around it climb, along an axis and a way, added up: each copy of each row is further up. */
|
|
3207
|
-
const climbed = (at, side, onto, axis) => {
|
|
3208
|
-
const climbs = rows.map(r => r.run.climb).filter(c => c && c.axis === axis && (c.up ? 1 : -1) === side);
|
|
3209
|
-
if (climbs.length)
|
|
3210
|
-
onto[axis].push(climbs.reduce((sum, c) => linAdd(sum, c.reach), at));
|
|
3211
|
-
};
|
|
3212
|
-
if (turns) {
|
|
3213
|
-
const reach = reachOf(m, asset, place.id, ends);
|
|
3214
|
-
if (typeof reach === 'string') {
|
|
3215
|
-
skipped.push({ part: place.id, reason: reach });
|
|
3216
|
-
continue;
|
|
3217
|
-
}
|
|
3218
|
-
parts.push(place.id);
|
|
3219
|
-
if (rows.length) {
|
|
3220
|
-
/*
|
|
3221
|
-
Each copy turns about its own pivot, which lies on its rows' runs: along a row the copies reach the run's
|
|
3222
|
-
ends and the turn's reach past them; across them, the turn's reach about the pivot.
|
|
3223
|
-
*/
|
|
3224
|
-
for (const { run } of rows) {
|
|
3225
|
-
for (const l of run.lows)
|
|
3226
|
-
low[run.axis].push(linAdd(l, reach.radius, -1));
|
|
3227
|
-
for (const h of run.highs)
|
|
3228
|
-
high[run.axis].push(linAdd(h, reach.radius, 1));
|
|
3229
|
-
}
|
|
3230
|
-
for (const at of reach.centres)
|
|
3231
|
-
for (const axis of AXES.filter(a => !rowAxes.has(a))) {
|
|
3232
|
-
const lo = linAdd(at[axis], reach.radius, -1);
|
|
3233
|
-
const hi = linAdd(at[axis], reach.radius, 1);
|
|
3234
|
-
low[axis].push(lo);
|
|
3235
|
-
high[axis].push(hi);
|
|
3236
|
-
climbed(lo, -1, low, axis);
|
|
3237
|
-
climbed(hi, 1, high, axis);
|
|
3238
|
-
}
|
|
3239
|
-
continue;
|
|
3240
|
-
}
|
|
3241
|
-
for (const at of reach.centres) {
|
|
3242
|
-
const centre = Object.fromEntries(AXES.map(a => [a, linNorm(at[a])]));
|
|
3243
|
-
const key = JSON.stringify(centre);
|
|
3244
|
-
const ball = balls.get(key);
|
|
3245
|
-
balls.set(key, { centre, radius: ball ? linAtLeast(ball.radius, reach.radius) : linNorm(reach.radius) });
|
|
3246
|
-
}
|
|
3247
|
-
continue;
|
|
3248
|
-
}
|
|
3249
|
-
/*
|
|
3250
|
-
Both ends of every control are candidates, and which one is the low end is not decided here: a travel of
|
|
3251
|
-
−40 mm puts the low end at the control's maximum (designer's counterexample 2026-09-23, where the
|
|
3252
|
-
proposal read −50..10 for a part that needs −90..50). Both candidates go into the same min and the same
|
|
3253
|
-
max, so the answer holds whichever way the travel runs, and holds at every design size, because the
|
|
3254
|
-
choice is made when the graph is evaluated rather than when the proposal is written.
|
|
3255
|
-
*/
|
|
3256
|
-
const readings = AXES.map(axis => ({ axis, at: ends.map(end => centreLin(m, asset, place.id, axis, end)) }));
|
|
3257
|
-
const bad = readings.flatMap(r => r.at).find(v => typeof v === 'string');
|
|
3258
|
-
if (bad) {
|
|
3259
|
-
skipped.push({ part: place.id, reason: String(bad) });
|
|
3260
|
-
continue;
|
|
3261
|
-
}
|
|
3262
|
-
let offRun;
|
|
3263
|
-
for (const { part: template, run } of rows) {
|
|
3264
|
-
if (template === place.id) {
|
|
3265
|
-
/* The row's own part: along the row it takes the run. */
|
|
3266
|
-
low[run.axis].push(...run.lows);
|
|
3267
|
-
high[run.axis].push(...run.highs);
|
|
3268
|
-
continue;
|
|
3269
|
-
}
|
|
3270
|
-
/*
|
|
3271
|
-
Carried by a row's part: its copies stand where that part's copies do, offset as it is from it. That part's
|
|
3272
|
-
centres lie half its length in from the run's ends.
|
|
3273
|
-
*/
|
|
3274
|
-
const theirs = ends.map(end => centreLin(m, asset, template, run.axis, end));
|
|
3275
|
-
const bad = theirs.find(c => typeof c === 'string');
|
|
3276
|
-
if (bad) {
|
|
3277
|
-
offRun = String(bad);
|
|
3278
|
-
break;
|
|
3279
|
-
}
|
|
3280
|
-
const along = readings.find(r => r.axis === run.axis).at;
|
|
3281
|
-
const inset = linAdd({ terms: [], c: 0 }, run.extent, 0.5);
|
|
3282
|
-
const across = sizes[run.axis];
|
|
3283
|
-
if (isReach(across)) {
|
|
3284
|
-
offRun = `${place.id} is a drawn section carried along a row; how far each copy reaches is not added up`;
|
|
3285
|
-
break;
|
|
3286
|
-
}
|
|
3287
|
-
for (const [i, mine] of along.entries()) {
|
|
3288
|
-
const offset = linAdd(mine, theirs[i], -1);
|
|
3289
|
-
for (const e of across) {
|
|
3290
|
-
const half = { terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 };
|
|
3291
|
-
for (const l of run.lows)
|
|
3292
|
-
low[run.axis].push(linAdd(linAdd(linAdd(l, inset), offset), half, -1));
|
|
3293
|
-
for (const h of run.highs)
|
|
3294
|
-
high[run.axis].push(linAdd(linAdd(linAdd(h, inset, -1), offset), half));
|
|
3295
|
-
}
|
|
3296
|
-
}
|
|
3297
|
-
}
|
|
3298
|
-
if (offRun) {
|
|
3299
|
-
skipped.push({ part: place.id, reason: offRun });
|
|
3300
|
-
continue;
|
|
3301
|
-
}
|
|
3302
|
-
parts.push(place.id);
|
|
3303
|
-
for (const r of readings) {
|
|
3304
|
-
if (rowAxes.has(r.axis))
|
|
3305
|
-
continue;
|
|
3306
|
-
const extent = sizes[r.axis];
|
|
3307
|
-
const seen = new Set();
|
|
3308
|
-
for (const centre of r.at) {
|
|
3309
|
-
const key = JSON.stringify([centre.terms.map(t => [t.ref, t.k]).sort(), centre.c]);
|
|
3310
|
-
if (seen.has(key))
|
|
3311
|
-
continue; // a part that does not move gives the same candidate twice
|
|
3312
|
-
seen.add(key);
|
|
3313
|
-
if (isReach(extent)) {
|
|
3314
|
-
/* Every point is a candidate for both ends: the least is the low end, the greatest the high one. */
|
|
3315
|
-
for (const o of extent.offsets) {
|
|
3316
|
-
const at = linAdd(centre, { terms: [{ ref: o.ref, k: o.k }], c: 0 });
|
|
3317
|
-
low[r.axis].push(at);
|
|
3318
|
-
high[r.axis].push(at);
|
|
3319
|
-
climbed(at, -1, low, r.axis);
|
|
3320
|
-
climbed(at, 1, high, r.axis);
|
|
3321
|
-
}
|
|
3322
|
-
continue;
|
|
3323
|
-
}
|
|
3324
|
-
const halves = extent.map(e => ({ terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 }));
|
|
3325
|
-
for (const half of halves) {
|
|
3326
|
-
const lo = linAdd(centre, half, -1);
|
|
3327
|
-
const hi = linAdd(centre, half, 1);
|
|
3328
|
-
low[r.axis].push(lo);
|
|
3329
|
-
high[r.axis].push(hi);
|
|
3330
|
-
climbed(lo, -1, low, r.axis);
|
|
3331
|
-
climbed(hi, 1, high, r.axis);
|
|
3332
|
-
}
|
|
3333
|
-
}
|
|
3334
|
-
}
|
|
3335
|
-
}
|
|
3336
|
-
return { parts, skipped, low, high, balls: [...balls.values()] };
|
|
3337
|
-
}
|
|
3338
|
-
const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
|
|
3339
|
-
/** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
|
|
3340
|
-
export function proposeFigureOccupancy(asset, options = {}) {
|
|
3341
|
-
asset = upgradeFigurePlacement(asset);
|
|
3342
|
-
const over = options.over ?? 'range';
|
|
3343
|
-
if (over !== 'rest' && over !== 'range')
|
|
3344
|
-
fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
|
|
3345
|
-
const source = structuredClone(asset);
|
|
3346
|
-
const { parts, skipped, low, high, balls } = occupancyLins(source, over);
|
|
3347
|
-
for (const ball of balls)
|
|
3348
|
-
for (const axis of AXES) {
|
|
3349
|
-
low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
|
|
3350
|
-
high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
|
|
3351
|
-
}
|
|
3352
|
-
if (!parts.length)
|
|
3353
|
-
fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
|
|
3354
|
-
const values = compileFigureGraph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
|
|
3355
|
-
const at = Object.fromEntries(AXES.map(axis => [axis, { min: round6(Math.min(...low[axis].map(l => valueOfLin(values, l)))), max: round6(Math.max(...high[axis].map(l => valueOfLin(values, l)))) }]));
|
|
3356
|
-
return { over, at, parts, skipped, basis: fingerprintOf(source) };
|
|
3357
|
-
}
|
|
3358
|
-
/**
|
|
3359
|
-
* The placement says which plane the mounting plane is. A part already fastened to it by a face the new plane does not
|
|
3360
|
-
* take -- a bottom on the floor, when the figure is to hang on a wall -- is named, not seated again without a word
|
|
3361
|
-
* (chief architect's condition, 2026-09-25): fasten it again by the face the new plane takes.
|
|
3362
|
-
*/
|
|
3363
|
-
function setPlacement(asset, a) {
|
|
3364
|
-
if (!FIGURE_PLACEMENTS.includes(a.placement))
|
|
3365
|
-
fail('SCHEMA', 'placement', `${String(a.placement)} is not a placement`);
|
|
3366
|
-
const takes = planeFacesOf(a.placement);
|
|
3367
|
-
const misfit = (asset.joints ?? []).filter(j => 'plane' in j.body0 && !takes.includes(j.body1.face));
|
|
3368
|
-
if (misfit.length)
|
|
3369
|
-
fail('PLACEMENT_PLANE', 'placement', `${misfit.map(j => `${j.body1.part} (${j.body1.face})`).join(', ')} ${misfit.length > 1 ? 'are' : 'is'} fastened to the mounting plane by a face a ${a.placement} does not take (${takes.join(' or ')}); detach ${misfit.length > 1 ? 'them' : 'it'} or fasten ${misfit.length > 1 ? 'them' : 'it'} by ${takes.join(' or ')} first`);
|
|
3370
|
-
asset.placement = a.placement;
|
|
3371
|
-
return asset;
|
|
3372
|
-
}
|
|
3373
|
-
function declareOccupancy(asset, a) {
|
|
3374
|
-
const m = asset.document.model;
|
|
3375
|
-
if (!a.proposal || (a.proposal.over !== 'rest' && a.proposal.over !== 'range'))
|
|
3376
|
-
fail('SCHEMA', 'occupancy', 'a proposal is required; propose first, then confirm');
|
|
3377
|
-
const now = fingerprintOf(asset);
|
|
3378
|
-
if (a.proposal.basis !== now)
|
|
3379
|
-
fail('OCCUPANCY_STALE', 'occupancy', 'the figure changed since this was proposed, so the volume it would declare is not the one that was looked at; propose again and confirm that');
|
|
3380
|
-
/* Declared with a placement, the placement is set by the same command as on its own: one value, one check. */
|
|
3381
|
-
if (a.placement !== undefined)
|
|
3382
|
-
setPlacement(asset, { kind: 'set-placement', placement: a.placement });
|
|
3383
|
-
const padding = {};
|
|
3384
|
-
for (const axis of AXES) {
|
|
3385
|
-
const pad = a.padding?.[axis] ?? 0;
|
|
3386
|
-
if (!(Number.isFinite(pad) && pad >= 0))
|
|
3387
|
-
fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
|
|
3388
|
-
padding[axis] = pad;
|
|
3389
|
-
}
|
|
3390
|
-
const { parts, low, high, balls } = occupancyLins(asset, a.proposal.over);
|
|
3391
|
-
/* A turning chain's ball, its reach either way about its pivot on each axis. */
|
|
3392
|
-
for (const ball of balls)
|
|
3393
|
-
for (const axis of AXES) {
|
|
3394
|
-
low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
|
|
3395
|
-
high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
|
|
3396
|
-
}
|
|
3397
|
-
for (const axis of AXES) {
|
|
3398
|
-
if (!padding[axis])
|
|
3399
|
-
continue;
|
|
3400
|
-
low[axis] = low[axis].map((l) => ({ ...l, c: l.c - padding[axis] }));
|
|
3401
|
-
high[axis] = high[axis].map((l) => ({ ...l, c: l.c + padding[axis] }));
|
|
3402
|
-
}
|
|
3403
|
-
if (parts.join('|') !== a.proposal.parts.join('|'))
|
|
3404
|
-
fail('OCCUPANCY_STALE', 'occupancy', `the figure changed since the proposal was made (${a.proposal.parts.join(', ')} then, ${parts.join(', ')} now); propose again and confirm that`);
|
|
3405
|
-
/* One node for the six bounds (affine-bounds@1): each candidate its constant, then a value and a ratio per term. */
|
|
3406
|
-
const args = [];
|
|
3407
|
-
const layout = AXES.flatMap(axis => [low[axis], high[axis]]).map(side => side
|
|
3408
|
-
.map((l) => {
|
|
3409
|
-
const n = linThroughSums(m, l);
|
|
3410
|
-
args.push(constantOf(m, 'mm', round6(n.c), 'd'));
|
|
3411
|
-
for (const t of n.terms)
|
|
3412
|
-
args.push(t.ref, constantOf(m, 'ratio', t.k, 'k'));
|
|
3413
|
-
return n.terms.length;
|
|
3414
|
-
})
|
|
3415
|
-
.join('_'));
|
|
3416
|
-
const id = namer(m, 'occupancy')('bounds');
|
|
3417
|
-
const port = (p) => `${id}.${p}`;
|
|
3418
|
-
m.nodes.push({ id, op: 'affine-bounds@1', args, params: { layout: `L${layout.join('.')}` }, outputs: Object.fromEntries(['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax'].map(p => [p, port(p)])) });
|
|
3419
|
-
const bounds = Object.fromEntries(AXES.map(axis => [axis, { min: port(`${axis}min`), max: port(`${axis}max`) }]));
|
|
3420
|
-
const was = stringsIn(asset.occupancy?.bounds);
|
|
3421
|
-
asset.occupancy = { ...(asset.occupancy?.contact ? { contact: asset.occupancy.contact } : {}), bounds };
|
|
3422
|
-
/*
|
|
3423
|
-
The room declared before goes with what fed only it. Left in the graph, it kept reading where parts had been
|
|
3424
|
-
fastened, and the next fastening was refused over a room no one read (the gantry, 2026-09-25).
|
|
3425
|
-
*/
|
|
3426
|
-
for (const ref of was)
|
|
3427
|
-
dropIfUnused(asset, ref);
|
|
3428
|
-
/*
|
|
3429
|
-
A part's own measure the old room alone still read -- the part follows something else now -- goes too: kept, it
|
|
3430
|
-
was a dial that changed nothing (the turntable after every part followed the one ratio, 2026-09-25). So does its
|
|
3431
|
-
own position or turn, once a fastening places it: a floor part's height position stayed as a field that moved
|
|
3432
|
-
nothing (browser check, ADR-0097, 2026-09-27). The room's refit names them before it is confirmed (figureRoomRefitOf).
|
|
3433
|
-
*/
|
|
3434
|
-
const placed = new Set(m.nodes.filter((n) => n.op === 'place@1' || n.op === 'member@1').map((n) => n.id));
|
|
3435
|
-
for (const input of [...m.inputs])
|
|
3436
|
-
if (input.role === 'design' && placed.has(String(input.id).split('.')[0]) && /\.(dimension|size|position|rotation)\.[a-z]+$/.test(input.id))
|
|
3437
|
-
dropIfUnused(asset, input.id, input.id);
|
|
3438
|
-
return asset;
|
|
3439
|
-
}
|
|
3440
|
-
/** "This face is what the figure is mounted on." Kept apart from how much room the figure takes up. */
|
|
3441
|
-
function setMountingFace(asset, a) {
|
|
3442
|
-
const m = asset.document.model;
|
|
3443
|
-
if (!asset.occupancy)
|
|
3444
|
-
fail('OCCUPANCY_UNDECLARED', 'occupancy', 'declare the room the figure takes up before naming the face it stands on');
|
|
3445
|
-
if (!Object.hasOwn(FIGURE_FACES, String(a.face)))
|
|
3446
|
-
fail('SCHEMA', a.part, `${String(a.face)} is not a face`);
|
|
3447
|
-
const { place } = partOf(m, a.part, a.part);
|
|
3448
|
-
const id = `${a.part}.mount.${a.face}`;
|
|
3449
|
-
if (!nodeById(m, id))
|
|
3450
|
-
m.nodes.push({ id, op: 'feature@1', args: [place.args[0]], outputs: { pose: `${id}.value` }, params: { frame: `${id}.frame`, name: a.face } });
|
|
3451
|
-
asset.occupancy.contact = { plane: 'mounting-plane', surface: { placement: place.outputs.placed, feature: `${id}.value` } };
|
|
3452
|
-
return asset;
|
|
3453
|
-
}
|
|
3454
|
-
/** Every string inside a declaration -- the references it may hold, with its names and labels (which name nothing). */
|
|
3455
|
-
const stringsIn = (value) => {
|
|
3456
|
-
const out = [];
|
|
3457
|
-
const walk = (v) => {
|
|
3458
|
-
if (typeof v === 'string')
|
|
3459
|
-
out.push(v);
|
|
3460
|
-
else if (Array.isArray(v))
|
|
3461
|
-
for (const one of v)
|
|
3462
|
-
walk(one);
|
|
3463
|
-
else if (v && typeof v === 'object')
|
|
3464
|
-
for (const one of Object.values(v))
|
|
3465
|
-
walk(one);
|
|
3466
|
-
};
|
|
3467
|
-
walk(value);
|
|
3468
|
-
return out;
|
|
3469
|
-
};
|
|
3470
|
-
/** The feature node for one face of a placed part: the one already there, or a new one. */
|
|
3471
|
-
function faceFeatureOf(m, place, face) {
|
|
3472
|
-
const found = m.nodes.find((n) => n.op === 'feature@1' && n.args[0] === place.args[0] && n.params?.name === face);
|
|
3473
|
-
if (found)
|
|
3474
|
-
return found;
|
|
3475
|
-
const id = `${place.id}.face.${face}`;
|
|
3476
|
-
if (nodeById(m, id))
|
|
3477
|
-
fail('SCHEMA', place.id, `${id} is already a node that is not this face`);
|
|
3478
|
-
const node = { id, op: 'feature@1', args: [place.args[0]], outputs: { pose: `${id}.value` }, params: { frame: `${id}.frame`, name: face } };
|
|
3479
|
-
m.nodes.push(node);
|
|
3480
|
-
return node;
|
|
3481
|
-
}
|
|
3482
|
-
function addBinding(asset, a) {
|
|
3483
|
-
const m = asset.document.model;
|
|
3484
|
-
const bindings = asset.document.capabilities.bindings;
|
|
3485
|
-
const id = typeof a.id === 'string' ? a.id.trim() : '';
|
|
3486
|
-
if (!id)
|
|
3487
|
-
fail('SCHEMA', 'binding', 'a place needs an id');
|
|
3488
|
-
const label = typeof a.label === 'string' ? a.label.trim() : '';
|
|
3489
|
-
if (!label)
|
|
3490
|
-
fail('SCHEMA', id, 'a place needs a name a person reads');
|
|
3491
|
-
const at = bindings.findIndex(b => b.id === id);
|
|
3492
|
-
if (at >= 0 && !a.replace)
|
|
3493
|
-
fail('DUPLICATE_BINDING', id, 'a place with this id exists; replace it explicitly');
|
|
3494
|
-
if (a.role !== 'seat' && a.role !== 'grip')
|
|
3495
|
-
fail('ROLE', id, 'a place is a seat or a grip');
|
|
3496
|
-
if (!Object.hasOwn(FIGURE_FACES, String(a.face)))
|
|
3497
|
-
fail('SCHEMA', id, `${String(a.face)} is not a face`);
|
|
3498
|
-
const place = nodeById(m, a.part);
|
|
3499
|
-
if (!place || place.op !== 'place@1')
|
|
3500
|
-
fail('TARGET_ABSENT', id, `${String(a.part)} is not a placed part`);
|
|
3501
|
-
const accepts = [...new Set((Array.isArray(a.accepts) ? a.accepts : []).map(one => String(one).trim()).filter(Boolean))];
|
|
3502
|
-
if (!accepts.length)
|
|
3503
|
-
fail('SCHEMA', id, 'name at least one type it takes');
|
|
3504
|
-
if (!Number.isSafeInteger(a.capacity) || a.capacity < 1)
|
|
3505
|
-
fail('CAPACITY', id, 'how many it holds is a whole number from 1');
|
|
3506
|
-
const feature = faceFeatureOf(m, place, a.face);
|
|
3507
|
-
const binding = {
|
|
3508
|
-
id,
|
|
3509
|
-
label,
|
|
3510
|
-
role: a.role,
|
|
3511
|
-
frame: feature.params.frame,
|
|
3512
|
-
pose: { placement: place.outputs.placed, feature: feature.outputs.pose },
|
|
3513
|
-
accepts,
|
|
3514
|
-
capacity: a.capacity
|
|
3515
|
-
};
|
|
3516
|
-
if (a.region) {
|
|
3517
|
-
const sizes = AXES.map(axis => a.region[axis]);
|
|
3518
|
-
if (sizes.some(mm => typeof mm !== 'number' || !Number.isFinite(mm) || mm <= 0))
|
|
3519
|
-
fail('REGION', id, 'the room is three lengths above 0 mm');
|
|
3520
|
-
binding.region = { kind: 'centered-box', dimensions: sizes.map((mm, i) => constantOf(m, 'mm', mm, `${id}.region.${AXES[i]}`)) };
|
|
3521
|
-
}
|
|
3522
|
-
const before = at >= 0 ? bindings[at] : null;
|
|
3523
|
-
if (at >= 0)
|
|
3524
|
-
bindings[at] = binding;
|
|
3525
|
-
else
|
|
3526
|
-
bindings.push(binding);
|
|
3527
|
-
if (before)
|
|
3528
|
-
dropBindingRefs(asset, before);
|
|
3529
|
-
return asset;
|
|
3530
|
-
}
|
|
3531
|
-
function removeBinding(asset, a) {
|
|
3532
|
-
const bindings = asset.document.capabilities.bindings;
|
|
3533
|
-
const at = bindings.findIndex(b => b.id === a.id);
|
|
3534
|
-
if (at < 0)
|
|
3535
|
-
fail('TARGET_ABSENT', String(a.id), 'no place has this id');
|
|
3536
|
-
const [gone] = bindings.splice(at, 1);
|
|
3537
|
-
dropBindingRefs(asset, gone);
|
|
3538
|
-
return asset;
|
|
3539
|
-
}
|
|
3540
|
-
function setSlotLayout(asset, a) {
|
|
3541
|
-
const m = asset.document.model;
|
|
3542
|
-
const label = typeof a.label === 'string' ? a.label.trim() : '';
|
|
3543
|
-
if (!label)
|
|
3544
|
-
fail('SCHEMA', 'slotLayout', 'a slot layout needs a name a person reads');
|
|
3545
|
-
if (a.role !== 'seat' && a.role !== 'grip')
|
|
3546
|
-
fail('ROLE', 'slotLayout', 'its slots are seats or grips');
|
|
3547
|
-
if (!Array.isArray(a.anchors) || !a.anchors.length)
|
|
3548
|
-
fail('SCHEMA', 'slotLayout', 'name at least one anchor');
|
|
3549
|
-
const seen = new Set();
|
|
3550
|
-
const anchors = a.anchors.map(w => {
|
|
3551
|
-
const id = typeof w?.id === 'string' ? w.id.trim() : '';
|
|
3552
|
-
if (!id)
|
|
3553
|
-
fail('SCHEMA', 'slotLayout', 'an anchor needs an id');
|
|
3554
|
-
if (seen.has(id))
|
|
3555
|
-
fail('SCHEMA', 'slotLayout', `two anchors are called ${id}`);
|
|
3556
|
-
seen.add(id);
|
|
3557
|
-
if (!Object.hasOwn(FIGURE_FACES, String(w.face)))
|
|
3558
|
-
fail('SCHEMA', 'slotLayout', `${String(w.face)} is not a face`);
|
|
3559
|
-
if (FIGURE_FACES[w.face] !== 'y')
|
|
3560
|
-
fail('SLOT_LAYOUT_FACE', 'slotLayout', 'an anchor is the top or the bottom of a part');
|
|
3561
|
-
const place = nodeById(m, w.part);
|
|
3562
|
-
if (!place || place.op !== 'place@1')
|
|
3563
|
-
fail('TARGET_ABSENT', 'slotLayout', `${String(w.part)} is not a placed part`);
|
|
3564
|
-
return { id, place, face: w.face };
|
|
3565
|
-
});
|
|
3566
|
-
const layout = structuredClone(a.layout);
|
|
3567
|
-
const issues = validateSlotLayout(layout, { anchors: seen });
|
|
3568
|
-
const problems = issues.length ? issues : persistenceIssues(layout);
|
|
3569
|
-
if (problems.length)
|
|
3570
|
-
fail('SLOT_LAYOUT', `slotLayout.layout.${problems[0].path}`, problems.map(i => `${i.code} at ${i.path}`).join('; '));
|
|
3571
|
-
const declaration = {
|
|
3572
|
-
label,
|
|
3573
|
-
role: a.role,
|
|
3574
|
-
anchors: anchors.map(x => ({ id: x.id, frame: '', pose: { placement: x.place.outputs.placed, feature: '' } })),
|
|
3575
|
-
layout
|
|
3576
|
-
};
|
|
3577
|
-
const taken = new Set(asset.document.capabilities.bindings.map((b) => b.id));
|
|
3578
|
-
for (const slot of figureSlotPlacesOf(declaration)) {
|
|
3579
|
-
if (!slot.accepts.length)
|
|
3580
|
-
fail('SCHEMA', slot.id, 'name at least one type each slot takes');
|
|
3581
|
-
if (taken.has(slot.id))
|
|
3582
|
-
fail('DUPLICATE_BINDING', slot.id, 'a place with this id exists');
|
|
3583
|
-
}
|
|
3584
|
-
// The faces are added to the graph only once the layout is sound, so a refused one leaves the figure as it was.
|
|
3585
|
-
anchors.forEach((x, i) => {
|
|
3586
|
-
const feature = faceFeatureOf(m, x.place, x.face);
|
|
3587
|
-
declaration.anchors[i].frame = feature.params.frame;
|
|
3588
|
-
declaration.anchors[i].pose.feature = feature.outputs.pose;
|
|
3589
|
-
});
|
|
3590
|
-
const before = asset.document.capabilities.slotLayout;
|
|
3591
|
-
asset.document.capabilities.slotLayout = declaration;
|
|
3592
|
-
const kept = new Set(declaration.anchors.map(x => x.pose.feature));
|
|
3593
|
-
for (const old of (before?.anchors ?? []))
|
|
3594
|
-
if (!kept.has(old.pose.feature))
|
|
3595
|
-
dropIfUnused(asset, old.pose.feature);
|
|
3596
|
-
return asset;
|
|
3597
|
-
}
|
|
3598
|
-
function removeSlotLayout(asset) {
|
|
3599
|
-
const before = asset.document.capabilities.slotLayout;
|
|
3600
|
-
if (!before)
|
|
3601
|
-
fail('TARGET_ABSENT', 'slotLayout', 'this figure has no slot layout');
|
|
3602
|
-
delete asset.document.capabilities.slotLayout;
|
|
3603
|
-
for (const old of before.anchors)
|
|
3604
|
-
dropIfUnused(asset, old.pose.feature);
|
|
3605
|
-
return asset;
|
|
3606
|
-
}
|
|
3607
|
-
/** The face and the room a place read, once nothing else reads them. */
|
|
3608
|
-
function dropBindingRefs(asset, binding) {
|
|
3609
|
-
const m = asset.document.model;
|
|
3610
|
-
const refs = [binding.pose?.feature, ...(binding.contacts ?? []).map((c) => c.feature)].filter(Boolean);
|
|
3611
|
-
for (const ref of refs)
|
|
3612
|
-
dropIfUnused(asset, ref);
|
|
3613
|
-
const declared = new Set([...stringsIn(asset.occupancy), ...stringsIn(asset.document.capabilities)]);
|
|
3614
|
-
for (const ref of binding.region?.dimensions ?? [])
|
|
3615
|
-
if (!declared.has(ref) && !m.nodes.some((n) => n.args.includes(ref)))
|
|
3616
|
-
m.constants = m.constants.filter((c) => c.id !== ref);
|
|
3617
|
-
}
|
|
3618
|
-
/** The places a figure has: its declared bindings, then the slots its slot layout unfolds into. */
|
|
3619
|
-
export function figureBindingsOf(asset) {
|
|
3620
|
-
const m = asset.document.model;
|
|
3621
|
-
const constant = (ref) => m.constants.find((c) => c.id === ref)?.value;
|
|
3622
|
-
const declared = asset.document.capabilities.bindings.map((b) => {
|
|
3623
|
-
const surface = typeof b.pose === 'string' ? null : b.pose;
|
|
3624
|
-
const place = surface ? m.nodes.find((n) => n.op === 'place@1' && n.outputs.placed === surface.placement) : null;
|
|
3625
|
-
const feature = surface ? writerOf(m, surface.feature) : null;
|
|
3626
|
-
const sizes = (b.region?.dimensions ?? []).map(constant);
|
|
3627
|
-
return {
|
|
3628
|
-
id: b.id,
|
|
3629
|
-
label: b.label,
|
|
3630
|
-
role: b.role,
|
|
3631
|
-
part: place?.id ?? null,
|
|
3632
|
-
face: feature?.params?.name ?? null,
|
|
3633
|
-
accepts: [...b.accepts],
|
|
3634
|
-
capacity: b.capacity,
|
|
3635
|
-
region: sizes.length === 3 && sizes.every((v) => typeof v === 'number') ? { x: sizes[0], y: sizes[1], z: sizes[2] } : null
|
|
3636
|
-
};
|
|
3637
|
-
});
|
|
3638
|
-
const slots = figureSlotPlacesOf(asset.document.capabilities.slotLayout).map(p => {
|
|
3639
|
-
const place = m.nodes.find((n) => n.op === 'place@1' && n.outputs.placed === p.pose.placement);
|
|
3640
|
-
const feature = writerOf(m, p.pose.feature);
|
|
3641
|
-
return {
|
|
3642
|
-
id: p.id,
|
|
3643
|
-
label: p.label,
|
|
3644
|
-
role: p.role,
|
|
3645
|
-
part: place?.id ?? null,
|
|
3646
|
-
face: feature?.params?.name ?? null,
|
|
3647
|
-
accepts: [...p.accepts],
|
|
3648
|
-
capacity: p.capacity,
|
|
3649
|
-
region: { x: p.allowed.width, y: p.allowed.height, z: p.allowed.depth },
|
|
3650
|
-
slot: {
|
|
3651
|
-
tier: p.tier,
|
|
3652
|
-
slot: p.slot,
|
|
3653
|
-
anchorId: p.anchorId,
|
|
3654
|
-
tierPosition: { ...p.tierPosition },
|
|
3655
|
-
tierRotation: p.tierRotation,
|
|
3656
|
-
offset: { ...p.offset },
|
|
3657
|
-
rotation: p.rotation
|
|
3658
|
-
}
|
|
3659
|
-
};
|
|
3660
|
-
});
|
|
3661
|
-
return [...declared, ...slots];
|
|
3662
|
-
}
|
|
3663
|
-
function clearMountingFace(asset) {
|
|
3664
|
-
if (!asset.occupancy?.contact)
|
|
3665
|
-
fail('EDIT_TARGET', 'occupancy', 'this figure names no mounting face');
|
|
3666
|
-
delete asset.occupancy.contact;
|
|
3667
|
-
return asset;
|
|
3668
|
-
}
|
|
3669
|
-
function nudgePart(asset, a) {
|
|
3670
|
-
const m = asset.document.model;
|
|
3671
|
-
const path = a.part;
|
|
3672
|
-
const { pose } = partOf(m, a.part, path);
|
|
3673
|
-
if (pose.op !== 'rigid@1')
|
|
3674
|
-
fail('EDIT_TARGET', path, `${a.part} is not posed by a rigid@1 this command can move`);
|
|
3675
|
-
if (pose.params?.to !== 'asset')
|
|
3676
|
-
fail('EDIT_TARGET', path, `${a.part} is carried by a joint or a seat, which places it`);
|
|
3677
|
-
if (parentOf(m, a.part))
|
|
3678
|
-
fail('EDIT_TARGET', path, `${a.part} is fastened to ${parentOf(m, a.part)}, which places it; detach it first`);
|
|
3679
|
-
const shift = (slot, by, unit, kind, axis) => {
|
|
3680
|
-
if (!by)
|
|
3681
|
-
return;
|
|
3682
|
-
if (!Number.isFinite(by))
|
|
3683
|
-
fail('SCHEMA', `${path}.${kind}.${axis}`, 'a finite amount required');
|
|
3684
|
-
const was = pose.args[slot];
|
|
3685
|
-
const own = `${a.part}.${kind}.${axis}`;
|
|
3686
|
-
/* Already the part's own number: add to it. */
|
|
3687
|
-
if (was === own && m.inputs.some((i) => i.id === own && i.role === 'design')) {
|
|
3688
|
-
asset.designInputs[own] = round6(asset.designInputs[own] + by);
|
|
3689
|
-
return;
|
|
3690
|
-
}
|
|
3691
|
-
const constant = m.constants.find((c) => c.id === was);
|
|
3692
|
-
const free = (id) => !m.inputs.some((i) => i.id === id) && !m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id));
|
|
3693
|
-
const input = (id, value) => {
|
|
3694
|
-
m.inputs.push({ id, unit, min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
3695
|
-
asset.designInputs[id] = round6(value);
|
|
3696
|
-
};
|
|
3697
|
-
/* A plain number becomes the part's own, moved. */
|
|
3698
|
-
if (constant && free(own)) {
|
|
3699
|
-
input(own, constant.value + by);
|
|
3700
|
-
pose.args = pose.args.map((r, k) => (k === slot ? own : r));
|
|
3701
|
-
return;
|
|
3702
|
-
}
|
|
3703
|
-
/* A worked-out place keeps its rule and takes the drag beside it. */
|
|
3704
|
-
const nudge = `${a.part}.${kind}.nudge.${axis}`;
|
|
3705
|
-
if (m.inputs.some((i) => i.id === nudge)) {
|
|
3706
|
-
asset.designInputs[nudge] = round6(asset.designInputs[nudge] + by);
|
|
3707
|
-
return;
|
|
3708
|
-
}
|
|
3709
|
-
input(nudge, by);
|
|
3710
|
-
const id = namer(m, `${a.part}.${kind}`)(`nudged.${axis}`);
|
|
3711
|
-
m.nodes.push({ id, op: 'add@1', args: [was, nudge], outputs: { value: `${id}.value` } });
|
|
3712
|
-
pose.args = pose.args.map((r, k) => (k === slot ? `${id}.value` : r));
|
|
3713
|
-
};
|
|
3714
|
-
AXES.forEach((axis, i) => shift(i, a.by?.[axis] ?? 0, 'mm', 'position', axis));
|
|
3715
|
-
AXES.forEach((axis, i) => shift(3 + i, a.turn?.[axis] ?? 0, 'deg', 'rotation', axis));
|
|
3716
|
-
/* Sizes: the measure's own node carries a factor beside its rule (`stableDimension` routes every measure through one). */
|
|
3717
|
-
const shape = partOf(m, a.part, path).shape;
|
|
3718
|
-
const described = figureShapeDescriptionOf(shape);
|
|
3719
|
-
const edits = described?.edit;
|
|
3720
|
-
if (!edits)
|
|
3721
|
-
fail('EDIT_TARGET', path, `${a.part} is a ${shape.op}; it is not a shape with measures`);
|
|
3722
|
-
const measures = AXES.filter(axis => !('refused' in edits[axis]) && !edits[axis].sameAs);
|
|
3723
|
-
for (const axis of measures) {
|
|
3724
|
-
/* A round section's one value answers to the cross axis the drag moved more. */
|
|
3725
|
-
const partner = AXES.find(b => edits[b].sameAs === axis);
|
|
3726
|
-
const factor = partner ? mostChanged(a.grow?.[axis], a.grow?.[partner]) : a.grow?.[axis];
|
|
3727
|
-
if (factor === undefined || factor === 1)
|
|
3728
|
-
continue;
|
|
3729
|
-
if (!(Number.isFinite(factor) && factor > 0))
|
|
3730
|
-
fail('GEOMETRY_DOMAIN', `${path}.dimension.${axis}`, 'a positive factor required');
|
|
3731
|
-
stableDimension(m, a.part, axis, `${path}.dimension.${axis}`);
|
|
3732
|
-
const node = nodeById(m, `${a.part}.dimension.${axis}`);
|
|
3733
|
-
const id = `${a.part}.dimension.scale.${axis}`;
|
|
3734
|
-
if (node.args[1] === id)
|
|
3735
|
-
asset.designInputs[id] = round6(asset.designInputs[id] * factor);
|
|
3736
|
-
else {
|
|
3737
|
-
if (m.inputs.some((i) => i.id === id))
|
|
3738
|
-
fail('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
3739
|
-
m.inputs.push({ id, unit: 'ratio', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
3740
|
-
const was = m.constants.find((c) => c.id === node.args[1])?.value ?? 1;
|
|
3741
|
-
asset.designInputs[id] = round6(was * factor);
|
|
3742
|
-
node.args = [node.args[0], id];
|
|
3743
|
-
}
|
|
3744
|
-
/* A taper's other end keeps its proportion: it is scaled by the same factor, through the same input. */
|
|
3745
|
-
const e = edits?.[axis];
|
|
3746
|
-
for (const i of e?.with ?? []) {
|
|
3747
|
-
const own = `${a.part}.dimension.${axis}.with.${i}`;
|
|
3748
|
-
if (!nodeById(m, own)) {
|
|
3749
|
-
const ref = `${own}.value`;
|
|
3750
|
-
m.nodes.push({ id: own, op: 'mul@1', args: [shape.args[i], id], outputs: { value: ref } });
|
|
3751
|
-
shape.args = shape.args.map((r, k) => (k === i ? ref : r));
|
|
3752
|
-
}
|
|
3753
|
-
}
|
|
3754
|
-
}
|
|
3755
|
-
return asset;
|
|
3756
|
-
}
|
|
3757
|
-
const mostChanged = (a, b) => {
|
|
3758
|
-
const pick = [a, b].filter((v) => v !== undefined);
|
|
3759
|
-
return pick.length ? pick.reduce((x, y) => (Math.abs(y - 1) > Math.abs(x - 1) ? y : x), 1) : undefined;
|
|
3760
|
-
};
|
|
3761
|
-
function setRepeat(asset, a) {
|
|
3762
|
-
const m = asset.document.model;
|
|
3763
|
-
const path = `${a.part}.repeat`;
|
|
3764
|
-
/* A row made in the modeller lives in its fastening's record: the pitch changes there, and the row is made again. */
|
|
3765
|
-
const record = (asset.joints ?? []).find(o => o.body1.part === a.part && o.repeat);
|
|
3766
|
-
if (record) {
|
|
3767
|
-
setJoint(asset, { ...record, repeat: { ...record.repeat, pitch: a.pitch } });
|
|
3768
|
-
return asset;
|
|
3769
|
-
}
|
|
3770
|
-
const layout = nodeById(m, `${a.part}.layout`);
|
|
3771
|
-
if (!layout || !nodeById(m, `${a.part}.repeat`))
|
|
3772
|
-
fail('EDIT_TARGET', path, `${a.part} is not a repeated part`);
|
|
3773
|
-
if (layout.op === 'fixed-count@1')
|
|
3774
|
-
fail('EDIT_TARGET', path, `${a.part} repeats a fixed count; its pitch is built into its length and cannot be set yet`);
|
|
3775
|
-
if (layout.op !== 'fit-pitch@1')
|
|
3776
|
-
fail('EDIT_TARGET', path, `${a.part} lays its copies out by ${layout.op}, which has no pitch`);
|
|
3777
|
-
/* A pitch read from an input is a record's: a row without one takes a number (ADR-0096). */
|
|
3778
|
-
if (typeof a.pitch !== 'number')
|
|
3779
|
-
fail('EDIT_TARGET', path, `${a.part}'s row has no record to read its pitch from an input; make it again with make-repeat`);
|
|
3780
|
-
const pitch = a.pitch;
|
|
3781
|
-
if (!(Number.isFinite(pitch) && pitch > 0))
|
|
3782
|
-
fail('GEOMETRY_DOMAIN', path, 'a positive pitch is required');
|
|
3783
|
-
/*
|
|
3784
|
-
One number, the pitch: copies = floor(row length / pitch), at most `limit` of them (the kernel's copy cap).
|
|
3785
|
-
So the longest row the figure may be placed at is limit x pitch. A new pitch moves that bound with it; the pitch is refused only when the length the figure
|
|
3786
|
-
is authored at would already hold more copies than the cap.
|
|
3787
|
-
*/
|
|
3788
|
-
const values = compileFigureGraph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
|
|
3789
|
-
const lengthRef = layout.args[0];
|
|
3790
|
-
const authored = values[lengthRef];
|
|
3791
|
-
const limit = (values[layout.args[6]] ?? m.constants.find((c) => c.id === layout.args[6])?.value);
|
|
3792
|
-
const smallest = Math.ceil((authored / limit) * 1000) / 1000;
|
|
3793
|
-
if (pitch < smallest)
|
|
3794
|
-
fail('INPUT_RANGE', path, `at the length it is authored (${round6(authored)} mm) a pitch under ${smallest} mm makes more than ${limit} copies`);
|
|
3795
|
-
const id = `${a.part}.repeat.pitch`;
|
|
3796
|
-
if (!m.inputs.some((i) => i.id === id)) {
|
|
3797
|
-
if (m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id)))
|
|
3798
|
-
fail('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
3799
|
-
m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
3800
|
-
const was = [layout.args[3], layout.args[4]];
|
|
3801
|
-
layout.args = layout.args.map((r, k) => (k === 3 || k === 4 ? id : r));
|
|
3802
|
-
for (const ref of new Set(was))
|
|
3803
|
-
dropIfUnused(asset, ref);
|
|
3804
|
-
}
|
|
3805
|
-
asset.designInputs[id] = pitch;
|
|
3806
|
-
/* The longest length follows the pitch, within the default range (ten times the authored length). */
|
|
3807
|
-
const length = m.inputs.find((i) => i.id === lengthRef && i.role === 'design');
|
|
3808
|
-
if (length) {
|
|
3809
|
-
const longest = Math.min(round6(limit * pitch), round6(authored * FIGURE_INSTANCE_SIZE_DEFAULT.times));
|
|
3810
|
-
length.max = Math.max(longest, authored);
|
|
3811
|
-
if (length.min > length.max)
|
|
3812
|
-
length.min = Math.min(length.min, authored);
|
|
3813
|
-
}
|
|
3814
|
-
return asset;
|
|
3815
|
-
}
|
|
3816
|
-
function scaleUniformly(asset) {
|
|
3817
|
-
const m = asset.document.model;
|
|
3818
|
-
if (asset.sizing === 'uniform')
|
|
3819
|
-
return asset;
|
|
3820
|
-
const authored = {};
|
|
3821
|
-
for (const axis of AXES) {
|
|
3822
|
-
const id = `size.${axis}`;
|
|
3823
|
-
if (!m.inputs.some((i) => i.id === id && i.role === 'design'))
|
|
3824
|
-
fail('TARGET_ABSENT', 'sizing', `the instance has no ${id}; declare the instance size first`);
|
|
3825
|
-
const at = asset.designInputs[id];
|
|
3826
|
-
if (!(Number.isFinite(at) && at > 0))
|
|
3827
|
-
fail('GEOMETRY_DOMAIN', 'sizing', `${id} must be positive`);
|
|
3828
|
-
authored[axis] = at;
|
|
3829
|
-
}
|
|
3830
|
-
/*
|
|
3831
|
-
u = geomean(size.x, size.y, size.z) ÷ geomean(x₀, y₀, z₀) -- the same ratio as the mean of the three axes' ratios,
|
|
3832
|
-
in two nodes; a figure near the document's node limit (ROBOT_ARM: 509 of 512) has little room.
|
|
3833
|
-
*/
|
|
3834
|
-
const name = namer(m, 'sizing');
|
|
3835
|
-
const mean = name('mean');
|
|
3836
|
-
m.nodes.push({ id: mean, op: 'geomean@1', args: AXES.map(axis => `size.${axis}`), outputs: { value: `${mean}.value` } });
|
|
3837
|
-
const u = name('u');
|
|
3838
|
-
const g0 = round6(Math.cbrt(AXES.reduce((p, axis) => p * authored[axis], 1)));
|
|
3839
|
-
m.nodes.push({ id: u, op: 'div@1', args: [`${mean}.value`, constantOf(m, 'mm', g0, 'd')], outputs: { value: `${u}.value` } });
|
|
3840
|
-
const uniform = {};
|
|
3841
|
-
for (const axis of AXES) {
|
|
3842
|
-
const id = name(`size.${axis}`);
|
|
3843
|
-
m.nodes.push({ id, op: 'mul@1', args: [`${u}.value`, constantOf(m, 'mm', authored[axis], 'd')], outputs: { value: `${id}.value` } });
|
|
3844
|
-
uniform[`size.${axis}`] = `${id}.value`;
|
|
3845
|
-
}
|
|
3846
|
-
/* Every reader of the instance size reads the uniform size instead -- but the mean, which is what makes it. */
|
|
3847
|
-
for (const n of m.nodes)
|
|
3848
|
-
if (n.id !== mean)
|
|
3849
|
-
n.args = n.args.map((a) => uniform[a] ?? a);
|
|
3850
|
-
/*
|
|
3851
|
-
A ratio of a size to its own authored length (`scale.x` · `scale.y` · `scale.z`) is now u itself:
|
|
3852
|
-
its readers read u and it goes, which gives back the nodes the rule took.
|
|
3853
|
-
*/
|
|
3854
|
-
const sameAsU = m.nodes.filter((n) => n.op === 'div@1' &&
|
|
3855
|
-
AXES.some(axis => n.args[0] === uniform[`size.${axis}`] && m.constants.find((c) => c.id === n.args[1])?.value === authored[axis]));
|
|
3856
|
-
for (const gone of sameAsU) {
|
|
3857
|
-
const out = Object.values(gone.outputs)[0];
|
|
3858
|
-
for (const n of m.nodes)
|
|
3859
|
-
n.args = n.args.map((a) => (a === out ? `${u}.value` : a));
|
|
3860
|
-
}
|
|
3861
|
-
m.nodes = m.nodes.filter((n) => !sameAsU.includes(n));
|
|
3862
|
-
const swap = (value) => typeof value === 'string' ? (uniform[value] ?? value) : Array.isArray(value) ? value.map(swap) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, swap(v)])) : value;
|
|
3863
|
-
if (asset.occupancy)
|
|
3864
|
-
asset.occupancy = swap(asset.occupancy);
|
|
3865
|
-
asset.document.capabilities = swap(asset.document.capabilities);
|
|
3866
|
-
asset.sizing = 'uniform';
|
|
3867
|
-
return asset;
|
|
3868
|
-
}
|
|
3869
|
-
/** Reads the asset; changes nothing. Refused for a figure that does not grow by one ratio. */
|
|
3870
|
-
export function proposeFigureProportion(asset) {
|
|
3871
|
-
asset = upgradeFigurePlacement(asset);
|
|
3872
|
-
return proportionPlan(asset);
|
|
3873
|
-
}
|
|
3874
|
-
function proportionPlan(asset) {
|
|
3875
|
-
const m = asset.document.model;
|
|
3876
|
-
if (asset.sizing !== 'uniform')
|
|
3877
|
-
fail('SIZING_UNDECLARED', 'sizing', 'the figure does not grow by one ratio; declare that first (scale-uniformly), then every length can follow it');
|
|
3878
|
-
const values = compileFigureGraph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
|
|
3879
|
-
const sizeNow = Object.fromEntries(AXES.map(axis => [axis, values[uniformSizeOf(m, axis, 'sizing')]]));
|
|
3880
|
-
const plan = { dimensions: [], lengths: [], skipped: [] };
|
|
3881
|
-
/* To nine places: the ratio's own mean is kept to six, and its noise is not the author's (0.1, not 0.0999999999787). */
|
|
3882
|
-
const share = (value, axis) => Math.round((value / sizeNow[axis]) * 1e9) / 1e9;
|
|
3883
|
-
const describe = (ref) => {
|
|
3884
|
-
const w = writerOf(m, ref);
|
|
3885
|
-
return String(w?.args?.[0] ?? ref).replace(/\.value$/, '');
|
|
3886
|
-
};
|
|
3887
|
-
const parts = m.nodes.filter((n) => n.op === 'place@1' || (n.op === 'member@1' && nodeById(m, `${n.id}.repeat`))).map((n) => n.id);
|
|
3888
|
-
for (const part of parts) {
|
|
3889
|
-
const shape = writerOf(m, nodeById(m, part).args[0]);
|
|
3890
|
-
const described = shape && figureShapeDescriptionOf(shape);
|
|
3891
|
-
const along = figureFollowableAxesOf(shape);
|
|
3892
|
-
if (!described || !along.length) {
|
|
3893
|
-
plan.skipped.push({ what: part, reason: `a ${described?.kind ?? String(shape?.op ?? 'shape')}'s measures are not linked by this command` });
|
|
3894
|
-
continue;
|
|
3895
|
-
}
|
|
3896
|
-
for (const axis of along) {
|
|
3897
|
-
const e = described.edit[axis];
|
|
3898
|
-
const from = dimensionSource(m, part, axis) ?? shape.args[e.arg];
|
|
3899
|
-
if (!ownInput(m, part, from)) {
|
|
3900
|
-
plan.skipped.push({ what: `${part}.${axis}`, reason: `follows ${describe(from)}`, follows: true });
|
|
3901
|
-
continue;
|
|
3902
|
-
}
|
|
3903
|
-
plan.dimensions.push({ part, axis, times: share(values[from], axis) });
|
|
3904
|
-
}
|
|
3905
|
-
}
|
|
3906
|
-
/* A part standing by no record keeps its millimetres: said, not left behind without a word (the robot arm, 2026-09-25). */
|
|
3907
|
-
for (const part of parts) {
|
|
3908
|
-
if (jointAbove(asset, part) || nodeById(m, `${part}.repeat`))
|
|
3909
|
-
continue;
|
|
3910
|
-
const pose = nodeById(m, `${part}.pose`);
|
|
3911
|
-
const moved = (pose?.args.slice(0, 3) ?? []).some((ref) => m.constants.find((c) => c.id === ref)?.value !== 0);
|
|
3912
|
-
if (moved)
|
|
3913
|
-
plan.skipped.push({ what: `${part}.place`, reason: 'stands by no fastening; fasten it for its place to follow' });
|
|
3914
|
-
}
|
|
3915
|
-
for (const j of (asset.joints ?? [])) {
|
|
3916
|
-
const faceAxis = FIGURE_FACES[childFaceOf(m, j)];
|
|
3917
|
-
const gap = j.origin?.gap;
|
|
3918
|
-
if (typeof gap === 'number' && gap !== 0)
|
|
3919
|
-
plan.lengths.push({ joint: j.id, at: 'gap', axis: faceAxis, times: share(gap, faceAxis) });
|
|
3920
|
-
else if (gap && typeof gap === 'object')
|
|
3921
|
-
plan.skipped.push({ what: `${j.id}.gap`, reason: 'follows a length already', follows: true });
|
|
3922
|
-
for (const [axis, how] of Object.entries(j.origin?.align ?? {})) {
|
|
3923
|
-
if (!how || typeof how !== 'object')
|
|
3924
|
-
continue;
|
|
3925
|
-
if (typeof how.mm === 'number') {
|
|
3926
|
-
if (how.mm !== 0)
|
|
3927
|
-
plan.lengths.push({ joint: j.id, at: axis, axis, times: share(how.mm, axis) });
|
|
3928
|
-
}
|
|
3929
|
-
else
|
|
3930
|
-
plan.skipped.push({ what: `${j.id}.${axis}`, reason: 'follows a length already', follows: true });
|
|
3931
|
-
}
|
|
3932
|
-
if (j.repeat && j.repeat.count === undefined) {
|
|
3933
|
-
const axis = j.repeat.axis.toLowerCase();
|
|
3934
|
-
if (typeof j.repeat.pitch === 'number')
|
|
3935
|
-
plan.lengths.push({ joint: j.id, at: 'pitch', axis, times: share(j.repeat.pitch, axis) });
|
|
3936
|
-
else
|
|
3937
|
-
plan.skipped.push({ what: `${j.id}.pitch`, reason: 'follows a length already', follows: true });
|
|
3938
|
-
}
|
|
3939
|
-
if (j.type === 'prismatic' && !j.travel && !j.mimic)
|
|
3940
|
-
plan.skipped.push({ what: `${j.id}.motion`, reason: 'a slide in millimetres; make its travel follow the size where the motion is changed' });
|
|
3941
|
-
if (j.type === 'prismatic' && j.mimic?.offset)
|
|
3942
|
-
plan.skipped.push({ what: `${j.id}.offset`, reason: "a follower's offset in millimetres" });
|
|
3943
|
-
}
|
|
3944
|
-
return plan;
|
|
3945
|
-
}
|
|
3946
|
-
function followSizeInProportion(asset) {
|
|
3947
|
-
const plan = proportionPlan(asset);
|
|
3948
|
-
for (const d of plan.dimensions)
|
|
3949
|
-
linkDimension(asset, { kind: 'link-dimension', part: d.part, axis: d.axis, source: { of: 'instance', axis: d.axis }, times: d.times });
|
|
3950
|
-
const follows = (axis, times) => ({ from: { of: 'instance', axis }, times });
|
|
3951
|
-
for (const l of plan.lengths) {
|
|
3952
|
-
const j = asset.joints.find(o => o.id === l.joint);
|
|
3953
|
-
if (l.at === 'gap')
|
|
3954
|
-
j.origin = { ...j.origin, gap: follows(l.axis, l.times) };
|
|
3955
|
-
else if (l.at === 'pitch')
|
|
3956
|
-
j.repeat = { ...j.repeat, pitch: follows(l.axis, l.times) };
|
|
3957
|
-
else
|
|
3958
|
-
j.origin = { ...j.origin, align: { ...j.origin.align, [l.at]: { mm: follows(l.axis, l.times) } } };
|
|
3959
|
-
}
|
|
3960
|
-
// The joints' nodes are made again from these records after the command, as after every command.
|
|
3961
|
-
return asset;
|
|
3962
|
-
}
|
|
3963
|
-
function scalePosition(asset, a) {
|
|
3964
|
-
const m = asset.document.model;
|
|
3965
|
-
const path = `${a.part}.${a.axis}`;
|
|
3966
|
-
if (!AXES.includes(a.axis))
|
|
3967
|
-
fail('SCHEMA', path, `${String(a.axis)} is not an axis`);
|
|
3968
|
-
const { pose } = partOf(m, a.part, path);
|
|
3969
|
-
if (pose.op !== 'rigid@1')
|
|
3970
|
-
fail('EDIT_TARGET', path, `${a.part} is not posed by a rigid@1 this command can read`);
|
|
3971
|
-
if (parentOf(m, a.part))
|
|
3972
|
-
fail('EDIT_TARGET', path, `${a.part} is fastened to ${parentOf(m, a.part)}, which places it; detach it first`);
|
|
3973
|
-
const size = `size.${a.axis}`;
|
|
3974
|
-
if (!m.inputs.some((i) => i.id === size && i.role === 'design'))
|
|
3975
|
-
fail('TARGET_ABSENT', path, `the instance has no ${size}; declare it before keeping a place in proportion to it`);
|
|
3976
|
-
const i = AXES.indexOf(a.axis);
|
|
3977
|
-
const was = pose.args[i];
|
|
3978
|
-
const values = compileFigureGraph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
|
|
3979
|
-
const value = values[was];
|
|
3980
|
-
const of = values[size];
|
|
3981
|
-
if (!Number.isFinite(value))
|
|
3982
|
-
fail('TARGET_ABSENT', path, `${was} has no value to keep`);
|
|
3983
|
-
if (!(Number.isFinite(of) && of > 0))
|
|
3984
|
-
fail('GEOMETRY_DOMAIN', path, `${size} must be positive to take a share of it`);
|
|
3985
|
-
const share = round6(value / of);
|
|
3986
|
-
const ref = share === 0 ? constantOf(m, 'mm', 0, 'd') : scaled(m, namer(m, `${a.part}.position`), size, share, 0, a.axis);
|
|
3987
|
-
pose.args = pose.args.map((r, k) => (k === i ? ref : r));
|
|
3988
|
-
// The part's own position number is read by nothing now; left behind it would be a field that moves nothing.
|
|
3989
|
-
if (was !== ref)
|
|
3990
|
-
dropIfUnused(asset, was, ownInput(m, a.part, was) ? was : undefined);
|
|
3991
|
-
return asset;
|
|
3992
|
-
}
|
|
3993
|
-
function fixPosition(asset, a) {
|
|
3994
|
-
const m = asset.document.model;
|
|
3995
|
-
const path = `${a.part}.${a.axis}`;
|
|
3996
|
-
if (!AXES.includes(a.axis))
|
|
3997
|
-
fail('SCHEMA', path, `${String(a.axis)} is not an axis`);
|
|
3998
|
-
const { pose } = partOf(m, a.part, path);
|
|
3999
|
-
if (pose.op !== 'rigid@1')
|
|
4000
|
-
fail('EDIT_TARGET', path, `${a.part} is not posed by a rigid@1 this command can read`);
|
|
4001
|
-
if (parentOf(m, a.part))
|
|
4002
|
-
fail('EDIT_TARGET', path, `${a.part} is fastened to ${parentOf(m, a.part)}, which places it; detach it first`);
|
|
4003
|
-
const i = AXES.indexOf(a.axis);
|
|
4004
|
-
const was = pose.args[i];
|
|
4005
|
-
if (ownInput(m, a.part, was))
|
|
4006
|
-
return asset;
|
|
4007
|
-
const values = compileFigureGraph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
|
|
4008
|
-
const value = round6(values[was]);
|
|
4009
|
-
if (!Number.isFinite(value))
|
|
4010
|
-
fail('TARGET_ABSENT', path, `${was} has no value to keep`);
|
|
4011
|
-
const id = `${a.part}.position.${a.axis}`;
|
|
4012
|
-
/* The part's own position number, if it has one (an expression may read it), takes the value; else it is made. */
|
|
4013
|
-
const own = m.inputs.find((input) => input.id === id && input.role === 'design' && input.unit === 'mm');
|
|
4014
|
-
const taken = !own && (m.inputs.some((input) => input.id === id) || m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id)));
|
|
4015
|
-
let ref;
|
|
4016
|
-
if (taken)
|
|
4017
|
-
ref = constantOf(m, 'mm', value, 'd');
|
|
4018
|
-
else {
|
|
4019
|
-
if (!own)
|
|
4020
|
-
m.inputs.push({ id, unit: 'mm', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
4021
|
-
asset.designInputs[id] = value;
|
|
4022
|
-
ref = id;
|
|
4023
343
|
}
|
|
4024
|
-
pose.args = pose.args.map((r, k) => (k === i ? ref : r));
|
|
4025
|
-
dropIfUnused(asset, was);
|
|
4026
344
|
return asset;
|
|
4027
345
|
}
|
|
4028
346
|
//# sourceMappingURL=authoring-actions.js.map
|