@hatiolab/figure-model 0.1.89 → 0.1.91
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/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/v3-asset-types.d.ts +14 -0
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.d.ts.map +1 -1
- package/dist/v3-asset.js +20 -1
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-authoring-actions.d.ts +86 -9
- package/dist/v3-authoring-actions.d.ts.map +1 -1
- package/dist/v3-authoring-actions.js +319 -49
- package/dist/v3-authoring-actions.js.map +1 -1
- package/dist/v3-editor-workspace.d.ts +32 -0
- package/dist/v3-editor-workspace.d.ts.map +1 -1
- package/dist/v3-editor-workspace.js +5 -3
- package/dist/v3-editor-workspace.js.map +1 -1
- package/dist/v3-gate.d.ts +8 -2
- package/dist/v3-gate.d.ts.map +1 -1
- package/dist/v3-gate.js +80 -9
- package/dist/v3-gate.js.map +1 -1
- package/dist/v3-kernel-version.d.ts +1 -1
- package/dist/v3-kernel-version.js +1 -1
- package/dist/v3-part-edit.d.ts +13 -0
- package/dist/v3-part-edit.d.ts.map +1 -1
- package/dist/v3-part-edit.js +194 -61
- package/dist/v3-part-edit.js.map +1 -1
- package/dist/v3-proposal.d.ts +1 -1
- package/dist/v3-proposal.d.ts.map +1 -1
- package/dist/v3-proposal.js +13 -1
- package/dist/v3-proposal.js.map +1 -1
- package/dist/v3-public-inputs.d.ts +27 -0
- package/dist/v3-public-inputs.d.ts.map +1 -0
- package/dist/v3-public-inputs.js +51 -0
- package/dist/v3-public-inputs.js.map +1 -0
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* parent carries the child with it. A part that is fastened to nothing poses straight into the asset frame.
|
|
23
23
|
*/
|
|
24
24
|
import { compileV3Asset } from "./v3-asset.js";
|
|
25
|
+
import { checkPublicInput, keepPublicInputs } from "./v3-public-inputs.js";
|
|
25
26
|
import { v3PartsExtentOf } from "./v3-gate.js";
|
|
26
27
|
import { compileV3Graph, v3ShapeFaces } from "./v3-graph.js";
|
|
27
28
|
import { V3ContractError } from "./v3-contract.js";
|
|
@@ -924,6 +925,55 @@ export function applyV3Authoring(source, action) {
|
|
|
924
925
|
case 'remove-binding':
|
|
925
926
|
removeBinding(asset, action);
|
|
926
927
|
break;
|
|
928
|
+
case 'add-design-input': {
|
|
929
|
+
const m = asset.document.model;
|
|
930
|
+
const path = `inputs.${String(action.id)}`;
|
|
931
|
+
if (typeof action.id !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(action.id))
|
|
932
|
+
fail('SCHEMA', path, 'an id of letters, digits, dot, dash or underscore');
|
|
933
|
+
if (m.inputs.some((i) => i.id === action.id) || m.constants.some((c) => c.id === action.id) || m.nodes.some((n) => n.id === action.id || Object.values(n.outputs).includes(action.id)))
|
|
934
|
+
fail('DUPLICATE_WRITER', path, `${action.id} is already a name in the figure`);
|
|
935
|
+
if (!['mm', 'ratio', 'deg', 'count'].includes(action.unit))
|
|
936
|
+
fail('SCHEMA', `${path}.unit`, 'mm, ratio, deg or count');
|
|
937
|
+
if (![action.min, action.max, action.value].every(Number.isFinite) || !(action.min < action.max))
|
|
938
|
+
fail('INPUT_RANGE', path, 'a finite range, its least below its most');
|
|
939
|
+
if (action.value < action.min || action.value > action.max)
|
|
940
|
+
fail('INPUT_RANGE', path, `${action.value} is outside ${action.min}…${action.max}`);
|
|
941
|
+
m.inputs.push({ id: action.id, unit: action.unit, min: action.min, max: action.max, role: 'design' });
|
|
942
|
+
asset.designInputs[action.id] = action.value;
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
case 'set-input-range': {
|
|
946
|
+
const path = `inputs.${String(action.input)}`;
|
|
947
|
+
const input = asset.document.model.inputs.find((i) => i.id === action.input);
|
|
948
|
+
if (!input)
|
|
949
|
+
fail('TARGET_ABSENT', path, `${String(action.input)} is not an input of the figure`);
|
|
950
|
+
if (input.role === 'state' || /^(sizing\.)?size\.[xyz]$/.test(action.input))
|
|
951
|
+
fail('EDIT_TARGET', path, `${action.input} is ${input.role === 'state' ? 'a state input; its range is its joint\'s' : 'the figure\'s size; declare-instance-size sets its range'}`);
|
|
952
|
+
if (![action.min, action.max].every(Number.isFinite) || !(action.min < action.max))
|
|
953
|
+
fail('INPUT_RANGE', path, 'a finite range, its least below its most');
|
|
954
|
+
const value = asset.designInputs[action.input];
|
|
955
|
+
if (value < action.min || value > action.max)
|
|
956
|
+
fail('INPUT_RANGE', path, `the value now, ${value}, is outside ${action.min}…${action.max}`);
|
|
957
|
+
input.min = action.min;
|
|
958
|
+
input.max = action.max;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
case 'publish-input': {
|
|
962
|
+
checkPublicInput(asset.document.model.inputs, action.input, `publicInputs.${String(action.input)}`);
|
|
963
|
+
const entry = { input: action.input, label: typeof action.label === 'string' ? action.label.trim() : action.label, ...(action.hint !== undefined ? { hint: action.hint } : {}) };
|
|
964
|
+
const list = (asset.publicInputs ?? []).filter(p => p.input !== action.input);
|
|
965
|
+
asset.publicInputs = [...list, entry];
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
case 'unpublish-input': {
|
|
969
|
+
const list = (asset.publicInputs ?? []);
|
|
970
|
+
if (!list.some(p => p.input === action.input))
|
|
971
|
+
fail('EDIT_TARGET', `publicInputs.${String(action.input)}`, `${String(action.input)} is not set by a board`);
|
|
972
|
+
asset.publicInputs = list.filter(p => p.input !== action.input);
|
|
973
|
+
if (!asset.publicInputs.length)
|
|
974
|
+
delete asset.publicInputs;
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
927
977
|
case 'rename': {
|
|
928
978
|
const name = typeof action.name === 'string' ? action.name.trim() : '';
|
|
929
979
|
if (!name)
|
|
@@ -937,6 +987,8 @@ export function applyV3Authoring(source, action) {
|
|
|
937
987
|
/* The joint records are the canon: whatever the command reshaped, their nodes are made again from them. */
|
|
938
988
|
if (asset.joints?.length && action.kind !== 'set-joint' && action.kind !== 'remove-joint')
|
|
939
989
|
rewriteJoints(asset);
|
|
990
|
+
/* An input a command took away takes its place on the board with it (ADR-0096). */
|
|
991
|
+
keepPublicInputs(asset);
|
|
940
992
|
compileV3Asset(asset);
|
|
941
993
|
if ((action.kind === 'link-dimension' || action.kind === 'make-repeat' || action.kind === 'set-joint') && action.narrowRange)
|
|
942
994
|
narrowDeclaredSize(asset);
|
|
@@ -1675,7 +1727,8 @@ export function renameV3PartInJoints(asset, from, to) {
|
|
|
1675
1727
|
* What reads a part that is to be taken away, said in the author's words: the space declared from the parts, another
|
|
1676
1728
|
* part's size or place that follows it. Taking it would leave them reading nothing (빈틈 H, 2026-09-25).
|
|
1677
1729
|
*/
|
|
1678
|
-
|
|
1730
|
+
/** `spaceFittedAgain`: the space is declared again once the part is gone, so its reading the part is no refusal. */
|
|
1731
|
+
function refuseIfRead(asset, part, spaceFittedAgain = false) {
|
|
1679
1732
|
const m = asset.document.model;
|
|
1680
1733
|
const own = (id) => id === part || id.startsWith(`${part}.`);
|
|
1681
1734
|
const mine = new Set([
|
|
@@ -1685,20 +1738,9 @@ function refuseIfRead(asset, part) {
|
|
|
1685
1738
|
]);
|
|
1686
1739
|
const readsOf = (refs) => (n) => [...n.args, ...Object.values(n.params ?? {})].some((r) => typeof r === 'string' && refs.has(r));
|
|
1687
1740
|
const readers = m.nodes.filter((n) => !own(n.id) && readsOf(mine)(n));
|
|
1688
|
-
|
|
1741
|
+
const space = spaceOnlyNodesOf(asset);
|
|
1742
|
+
if (!spaceFittedAgain && (readers.some((n) => space.has(n.id)) || stringsIn(asset.occupancy).some(r => mine.has(r))))
|
|
1689
1743
|
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`);
|
|
1690
|
-
/*
|
|
1691
|
-
Values another part shares stay when this one goes (the pruning keeps what is still read). The joint's own nodes
|
|
1692
|
-
do not: they go with the joint, so a part reading them -- following where this one is fastened -- is named.
|
|
1693
|
-
*/
|
|
1694
|
-
const jointOwned = new Set(m.nodes
|
|
1695
|
-
.filter((n) => n.id.startsWith(`${part}.on.`) || [`${part}.seat.rigid`, `${part}.chain`, `${part}.world`, `${part}.turned`, `${part}.motion`].includes(n.id))
|
|
1696
|
-
.flatMap((n) => Object.values(n.outputs)));
|
|
1697
|
-
const followers = [...new Set(m.nodes.filter((n) => !own(n.id) && readsOf(jointOwned)(n)).map((n) => String(n.id).split('.')[0]))].filter(p => p !== part && !String(p).startsWith('occupancy'));
|
|
1698
|
-
const children = new Set(((asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part)).map(j => j.body1.part));
|
|
1699
|
-
const others = followers.filter(p => !children.has(p));
|
|
1700
|
-
if (others.length)
|
|
1701
|
-
fail('PART_REFERENCED', part, `${others.join(', ')} ${others.length > 1 ? 'follow' : 'follows'} ${part}'s size or place; make ${others.length > 1 ? 'them' : 'it'} ${others.length > 1 ? 'their' : 'its'} own number first`);
|
|
1702
1744
|
}
|
|
1703
1745
|
/**
|
|
1704
1746
|
* A copied part is fastened as the original is: its own record, the same faces and place, so it is not held by nodes
|
|
@@ -1725,43 +1767,267 @@ export function copyV3PartJoint(asset, from, to) {
|
|
|
1725
1767
|
asset.joints = [...joints, copy];
|
|
1726
1768
|
rewriteJoints(asset);
|
|
1727
1769
|
}
|
|
1770
|
+
/** XYZ angles in degrees of a rotation written as rigid@1 writes it, Rx·Ry·Rz. */
|
|
1771
|
+
function eulerXYZ(r) {
|
|
1772
|
+
const deg = (v) => round6((v * 180) / Math.PI) + 0;
|
|
1773
|
+
const sy = Math.max(-1, Math.min(1, r[0][2]));
|
|
1774
|
+
const ry = Math.asin(sy);
|
|
1775
|
+
if (Math.abs(Math.cos(ry)) < 1e-9)
|
|
1776
|
+
return [deg(Math.atan2(r[2][1], r[1][1])), deg(ry), 0];
|
|
1777
|
+
return [deg(Math.atan2(-r[1][2], r[2][2])), deg(ry), deg(Math.atan2(-r[0][1], r[0][0]))];
|
|
1778
|
+
}
|
|
1779
|
+
/** 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. */
|
|
1780
|
+
function baseUnitOf(type) {
|
|
1781
|
+
const t = JSON.stringify(type);
|
|
1782
|
+
if (t === '[1,0,0]')
|
|
1783
|
+
return 'mm';
|
|
1784
|
+
if (t === '[0,1,0]')
|
|
1785
|
+
return 'rad';
|
|
1786
|
+
if (t === '[0,0,1]')
|
|
1787
|
+
return 's';
|
|
1788
|
+
if (t === '[0,0,0]')
|
|
1789
|
+
return 'ratio';
|
|
1790
|
+
if (t === '{"kind":"count"}')
|
|
1791
|
+
return 'count';
|
|
1792
|
+
return undefined;
|
|
1793
|
+
}
|
|
1728
1794
|
/**
|
|
1729
|
-
* Before a part is taken away
|
|
1730
|
-
*
|
|
1795
|
+
* Before a part is taken away (chief architect's ruling 2026-09-27, replacing the refusals of 2026-09-25). What hung
|
|
1796
|
+
* from it stays where it stood at rest -- every control at zero -- fastened to nothing: its joint, and the motion and
|
|
1797
|
+
* control the joint gave it, go. A part that moved with its joint gets a control of its own, named after the part,
|
|
1798
|
+
* over the leader's range put through the multiplier and offset, starting where it stood. A value that followed its
|
|
1799
|
+
* size or place becomes the number it is now. One thing is refused, by name: a row laid along it, or hanging in it --
|
|
1800
|
+
* a row is made from its record, and a record needs the part (ADR-0093).
|
|
1731
1801
|
*/
|
|
1802
|
+
export function releaseV3PartJoints(asset, part, options = {}) {
|
|
1803
|
+
const a = asset;
|
|
1804
|
+
const m = a.document.model;
|
|
1805
|
+
const notice = { released: [], fixed: [], controls: [] };
|
|
1806
|
+
refuseIfRead(a, part, options.spaceFittedAgain);
|
|
1807
|
+
let joints = (a.joints ?? []);
|
|
1808
|
+
const rows = joints.filter(j => 'part' in j.body0 && j.body0.part === part && j.repeat).map(j => j.body1.part);
|
|
1809
|
+
if (rows.length)
|
|
1810
|
+
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`);
|
|
1811
|
+
const own = joints.find(j => j.body1.part === part);
|
|
1812
|
+
const children = joints.filter(j => 'part' in j.body0 && j.body0.part === part);
|
|
1813
|
+
if (own?.repeat && children.length)
|
|
1814
|
+
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`);
|
|
1815
|
+
const inputs = (over = {}) => ({ ...a.designInputs, ...a.stateDefaults, ...over });
|
|
1816
|
+
const controls = m.inputs.filter((i) => i.role === 'state');
|
|
1817
|
+
const rest = Object.fromEntries(controls.map((i) => [i.id, Math.min(i.max, Math.max(i.min, 0))]));
|
|
1818
|
+
const graph = compileV3Graph(m);
|
|
1819
|
+
const atRest = graph.evaluate(inputs(rest)).values;
|
|
1820
|
+
const now = graph.evaluate(inputs()).values;
|
|
1821
|
+
/* A part moving with a joint that goes: its own control, over what the leader's distance or angle came to. */
|
|
1822
|
+
const going = new Set([...(own ? [own.id] : []), ...children.map(j => j.id)]);
|
|
1823
|
+
/* A follower has no control of its own, so its own record's nominal id is not taken. */
|
|
1824
|
+
const taken = new Set([...m.inputs.map((i) => i.id), ...joints.filter(j => !j.mimic).map(stateIdOf)]);
|
|
1825
|
+
for (const f of joints.filter(o => o.mimic && going.has(o.mimic.joint) && !going.has(o.id))) {
|
|
1826
|
+
const leader = joints.find(o => o.id === f.mimic.joint);
|
|
1827
|
+
const leaderPart = leader.body1.part;
|
|
1828
|
+
const quantity = nodeById(m, `${leaderPart}.motion`)?.args[3] ?? stateIdOf(leader);
|
|
1829
|
+
const state = m.inputs.find((i) => i.id === stateIdOf(leader));
|
|
1830
|
+
const distance = (value) => Number(graph.evaluate(inputs({ [stateIdOf(leader)]: value })).values[quantity]);
|
|
1831
|
+
const turn = f.type === 'revolute';
|
|
1832
|
+
const toUnit = (base) => round6(turn ? (base * 180) / Math.PI : base);
|
|
1833
|
+
const offset = (f.mimic.offset ?? 0) * (turn ? Math.PI / 180 : 1);
|
|
1834
|
+
const follow = (base) => toUnit(base * f.mimic.multiplier + offset);
|
|
1835
|
+
const [lo, hi] = [follow(distance(state?.min ?? 0)), follow(distance(state?.max ?? 1))].sort((x, y) => x - y);
|
|
1836
|
+
let id = f.body1.part;
|
|
1837
|
+
for (let n = 2; taken.has(id); n++)
|
|
1838
|
+
id = `${f.body1.part}-${n}`;
|
|
1839
|
+
taken.add(id);
|
|
1840
|
+
const start = Math.min(hi, Math.max(lo, follow(Number(now[quantity]))));
|
|
1841
|
+
const { mimic: _led, travel: _share, ...alone } = f;
|
|
1842
|
+
joints = joints.map(o => (o.id === f.id ? { ...alone, lowerLimit: lo, upperLimit: hi, state: { ...(f.state ?? {}), id, start } } : o));
|
|
1843
|
+
notice.controls.push({ part: f.body1.part, control: id, min: lo, max: hi });
|
|
1844
|
+
}
|
|
1845
|
+
/* A value in a record that followed the part: the number it is now. */
|
|
1846
|
+
const freezeMeasure = (measure, where) => {
|
|
1847
|
+
if (!measure || typeof measure !== 'object' || measure.from.of !== 'part' || measure.from.part !== part)
|
|
1848
|
+
return measure;
|
|
1849
|
+
const probe = structuredClone(m);
|
|
1850
|
+
const ref = stableDimension(probe, part, measure.from.axis, where);
|
|
1851
|
+
const value = round6(Number(compileV3Graph(probe).evaluate(inputs()).values[ref]) * (measure.times ?? 1) + (measure.plus ?? 0));
|
|
1852
|
+
notice.fixed.push({ part: where, value, unit: 'mm' });
|
|
1853
|
+
return value;
|
|
1854
|
+
};
|
|
1855
|
+
joints = joints.map(j => {
|
|
1856
|
+
if (j.id === own?.id || children.includes(j))
|
|
1857
|
+
return j;
|
|
1858
|
+
const out = structuredClone(j);
|
|
1859
|
+
if (out.origin?.gap !== undefined)
|
|
1860
|
+
out.origin.gap = freezeMeasure(out.origin.gap, j.body1.part);
|
|
1861
|
+
if (out.repeat)
|
|
1862
|
+
out.repeat.pitch = freezeMeasure(out.repeat.pitch, j.body1.part);
|
|
1863
|
+
if (out.travel?.source?.of === 'part' && out.travel.source.part === part) {
|
|
1864
|
+
const plus = freezeMeasure({ from: out.travel.source, times: out.travel.times, plus: out.travel.plus }, j.body1.part);
|
|
1865
|
+
out.travel = { plus };
|
|
1866
|
+
}
|
|
1867
|
+
return out;
|
|
1868
|
+
});
|
|
1869
|
+
/*
|
|
1870
|
+
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
|
|
1871
|
+
drawn -- and is not the space (fitted again, or refused above): the number it is now. Values the part shares with
|
|
1872
|
+
another (a shape two placements draw) stay, as before; they do not go.
|
|
1873
|
+
*/
|
|
1874
|
+
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);
|
|
1875
|
+
const leaving = [part, ...children.map(j => j.body1.part)];
|
|
1876
|
+
const goingNodes = new Set(m.nodes.filter(n => leaving.some(p => jointNodes(p)(n))).map(n => n.id));
|
|
1877
|
+
const goingRefs = new Set(m.nodes.filter(n => goingNodes.has(n.id)).flatMap(n => Object.values(n.outputs)));
|
|
1878
|
+
const types = graph.outputTypes;
|
|
1879
|
+
const live = liveRefsOf(a);
|
|
1880
|
+
const space = spaceOnlyNodesOf(a);
|
|
1881
|
+
const partOf = (id) => String(id).split('.')[0];
|
|
1882
|
+
for (const n of m.nodes) {
|
|
1883
|
+
/* The parts leaving are released below; a row's copies are gathered again from the records. */
|
|
1884
|
+
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)))
|
|
1885
|
+
continue;
|
|
1886
|
+
/* A joint's own nodes are made again from its record, from where its parent stands then. */
|
|
1887
|
+
if (joints.some(j => jointNodes(j.body1.part)(n)))
|
|
1888
|
+
continue;
|
|
1889
|
+
n.args = n.args.map((ref, i) => {
|
|
1890
|
+
if (!goingRefs.has(ref))
|
|
1891
|
+
return ref;
|
|
1892
|
+
const unit = baseUnitOf(types[ref]);
|
|
1893
|
+
const value = now[ref];
|
|
1894
|
+
if (typeof value === 'number' && unit) {
|
|
1895
|
+
notice.fixed.push({ part: partOf(n.id), value: round6(unit === 'rad' ? (value * 180) / Math.PI : value), unit: unit === 'rad' ? 'deg' : unit });
|
|
1896
|
+
return constantOf(m, unit, round6(value) + 0, 'fixed');
|
|
1897
|
+
}
|
|
1898
|
+
const pose = value;
|
|
1899
|
+
const type = types[ref];
|
|
1900
|
+
if (pose?.r && pose.t && type?.kind === 'transform') {
|
|
1901
|
+
const id = namer(m, `${n.id}.fixed`)(`pose.${i}`);
|
|
1902
|
+
const [rx, ry, rz] = eulerXYZ(pose.r);
|
|
1903
|
+
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 } });
|
|
1904
|
+
notice.fixed.push({ part: partOf(n.id), value: 0, unit: 'pose' });
|
|
1905
|
+
return `${id}.value`;
|
|
1906
|
+
}
|
|
1907
|
+
fail('PART_REFERENCED', part, `${partOf(n.id)} reads ${part}'s ${ref}, which cannot be written as a number`);
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
/* What hung from it: where it stood at rest, fastened to nothing. */
|
|
1911
|
+
const standing = new Map(children.map(j => [j.body1.part, atRest[nodeById(m, j.body1.part).args[1]]]));
|
|
1912
|
+
a.joints = joints.filter(j => j.id !== own?.id && !children.includes(j));
|
|
1913
|
+
if (!a.joints.length)
|
|
1914
|
+
delete a.joints;
|
|
1915
|
+
for (const j of children) {
|
|
1916
|
+
const child = j.body1.part;
|
|
1917
|
+
const control = j.type !== 'fixed' && !j.mimic ? stateIdOf(j) : undefined;
|
|
1918
|
+
stripJoint(a, j);
|
|
1919
|
+
const at = standing.get(child);
|
|
1920
|
+
const pose = nodeById(m, `${child}.pose`);
|
|
1921
|
+
const turn = eulerXYZ(at.r);
|
|
1922
|
+
const values = [...at.t.map(v => round6(v) + 0), ...turn];
|
|
1923
|
+
const old = pose.args.slice(0, 6);
|
|
1924
|
+
pose.args = [...AXES.map(axis => `${child}.position.${axis}`), ...AXES.map(axis => `${child}.rotation.${axis}`)].map((id, i) => {
|
|
1925
|
+
if (!m.inputs.some((n) => n.id === id))
|
|
1926
|
+
m.inputs.push({ id, unit: i < 3 ? 'mm' : 'deg', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
1927
|
+
a.designInputs[id] = values[i];
|
|
1928
|
+
return id;
|
|
1929
|
+
});
|
|
1930
|
+
for (const ref of old)
|
|
1931
|
+
if (!pose.args.includes(ref))
|
|
1932
|
+
dropIfUnused(a, ref, ownInput(m, child, ref) ? ref : undefined);
|
|
1933
|
+
notice.released.push({ part: child, ...(control && !m.inputs.some((i) => i.id === control) ? { control } : {}) });
|
|
1934
|
+
}
|
|
1935
|
+
if (own)
|
|
1936
|
+
stripJoint(a, own);
|
|
1937
|
+
dropDeadDanglers(a);
|
|
1938
|
+
if (a.joints?.length)
|
|
1939
|
+
rewriteJoints(a);
|
|
1940
|
+
return notice;
|
|
1941
|
+
}
|
|
1732
1942
|
/**
|
|
1733
|
-
* The
|
|
1734
|
-
*
|
|
1943
|
+
* The nodes that make the declared space and nothing else: reached from what \`occupancy\` names, not from any drawn
|
|
1944
|
+
* body, record or binding. Named by what they do, not by their names -- an older fitting named them \`occ.*\`
|
|
1945
|
+
* (FORKLIFT), a still older one left some the declaration no longer reaches (WALL_LAMP), and a part's own chain the
|
|
1946
|
+
* space also reads is the part's (2026-09-27).
|
|
1735
1947
|
*/
|
|
1736
|
-
export function
|
|
1948
|
+
export function spaceOnlyNodesOf(asset) {
|
|
1737
1949
|
const a = asset;
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1950
|
+
const m = a.document.model;
|
|
1951
|
+
const reach = (from) => {
|
|
1952
|
+
const seen = new Set();
|
|
1953
|
+
for (const pending = [...from]; pending.length;) {
|
|
1954
|
+
const writer = writerOf(m, pending.pop());
|
|
1955
|
+
if (!writer || seen.has(writer.id))
|
|
1956
|
+
continue;
|
|
1957
|
+
seen.add(writer.id);
|
|
1958
|
+
pending.push(...writer.args, ...stringsIn(writer.params));
|
|
1959
|
+
}
|
|
1960
|
+
return seen;
|
|
1961
|
+
};
|
|
1962
|
+
const drawn = reach([
|
|
1963
|
+
...m.nodes.filter((n) => n.op === 'place@1' || n.op === 'repeat@1').flatMap((n) => Object.values(n.outputs)),
|
|
1964
|
+
...stringsIn(a.appearance),
|
|
1965
|
+
...stringsIn(a.document.capabilities),
|
|
1966
|
+
...stringsIn(a.drivers),
|
|
1967
|
+
...stringsIn(a.publicInputs),
|
|
1968
|
+
...stringsIn(a.joints)
|
|
1969
|
+
]);
|
|
1970
|
+
return new Set([...reach(stringsIn(a.occupancy))].filter(id => !drawn.has(id)));
|
|
1748
1971
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1972
|
+
/**
|
|
1973
|
+
* A node no drawn body, declaration or record reaches, reading a value that is gone: an earlier fitting's space left
|
|
1974
|
+
* 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
|
|
1975
|
+
* goes with what it read. A node something does reach is left, and the figure is refused as broken.
|
|
1976
|
+
*/
|
|
1977
|
+
export function dropDeadDanglers(asset) {
|
|
1978
|
+
const model = asset.document.model;
|
|
1979
|
+
const strings = (v) => typeof v === 'string' ? [v] : Array.isArray(v) ? v.flatMap(strings) : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : [];
|
|
1980
|
+
for (;;) {
|
|
1981
|
+
const written = new Set([...model.nodes.flatMap(n => Object.values(n.outputs)), ...model.inputs.map(i => i.id), ...model.constants.map(c => c.id)]);
|
|
1982
|
+
const live = new Set();
|
|
1983
|
+
const pending = [
|
|
1984
|
+
...model.nodes.filter(n => n.op === 'place@1' || n.op === 'repeat@1').flatMap(n => Object.values(n.outputs)),
|
|
1985
|
+
...strings(asset.appearance),
|
|
1986
|
+
...strings(asset.occupancy),
|
|
1987
|
+
...strings(asset.document.capabilities),
|
|
1988
|
+
...strings(asset.drivers),
|
|
1989
|
+
...strings(asset.publicInputs),
|
|
1990
|
+
...strings(asset.joints)
|
|
1991
|
+
];
|
|
1992
|
+
while (pending.length) {
|
|
1993
|
+
const ref = pending.pop();
|
|
1994
|
+
if (live.has(ref))
|
|
1995
|
+
continue;
|
|
1996
|
+
live.add(ref);
|
|
1997
|
+
const writer = model.nodes.find(n => Object.values(n.outputs).includes(ref));
|
|
1998
|
+
if (writer)
|
|
1999
|
+
pending.push(...writer.args, ...strings(writer.params));
|
|
2000
|
+
}
|
|
2001
|
+
const dead = model.nodes.filter(n => n.args.some(a => !written.has(a)) && !Object.values(n.outputs).some(o => live.has(o)));
|
|
2002
|
+
if (!dead.length)
|
|
2003
|
+
return;
|
|
2004
|
+
model.nodes = model.nodes.filter(n => !dead.includes(n));
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
/** Every value a drawn body, a declaration or a record reaches, however far up. */
|
|
2008
|
+
export function liveRefsOf(asset) {
|
|
2009
|
+
const a = asset;
|
|
2010
|
+
const m = a.document.model;
|
|
2011
|
+
const live = new Set();
|
|
2012
|
+
const pending = [
|
|
2013
|
+
...m.nodes.filter((n) => n.op === 'place@1' || n.op === 'repeat@1').flatMap((n) => Object.values(n.outputs)),
|
|
2014
|
+
...stringsIn(a.appearance),
|
|
2015
|
+
...stringsIn(a.occupancy),
|
|
2016
|
+
...stringsIn(a.document.capabilities),
|
|
2017
|
+
...stringsIn(a.drivers),
|
|
2018
|
+
...stringsIn(a.publicInputs),
|
|
2019
|
+
...stringsIn(a.joints)
|
|
2020
|
+
];
|
|
2021
|
+
while (pending.length) {
|
|
2022
|
+
const ref = pending.pop();
|
|
2023
|
+
if (live.has(ref))
|
|
2024
|
+
continue;
|
|
2025
|
+
live.add(ref);
|
|
2026
|
+
const writer = writerOf(m, ref);
|
|
2027
|
+
if (writer)
|
|
2028
|
+
pending.push(...writer.args, ...stringsIn(writer.params));
|
|
2029
|
+
}
|
|
2030
|
+
return live;
|
|
1765
2031
|
}
|
|
1766
2032
|
function setJoint(asset, joint) {
|
|
1767
2033
|
// As JSON: a field left undefined is a field not there, which is how the asset stores it.
|
|
@@ -2907,7 +3173,11 @@ function setRepeat(asset, a) {
|
|
|
2907
3173
|
fail('EDIT_TARGET', path, `${a.part} repeats a fixed count; its pitch is built into its length and cannot be set yet`);
|
|
2908
3174
|
if (layout.op !== 'fit-pitch@1')
|
|
2909
3175
|
fail('EDIT_TARGET', path, `${a.part} lays its copies out by ${layout.op}, which has no pitch`);
|
|
2910
|
-
|
|
3176
|
+
/* A pitch read from an input is a record's: a row without one takes a number (ADR-0096). */
|
|
3177
|
+
if (typeof a.pitch !== 'number')
|
|
3178
|
+
fail('EDIT_TARGET', path, `${a.part}'s row has no record to read its pitch from an input; make it again with make-repeat`);
|
|
3179
|
+
const pitch = a.pitch;
|
|
3180
|
+
if (!(Number.isFinite(pitch) && pitch > 0))
|
|
2911
3181
|
fail('GEOMETRY_DOMAIN', path, 'a positive pitch is required');
|
|
2912
3182
|
/*
|
|
2913
3183
|
One number, the pitch: copies = floor(row length / pitch), at most `limit` of them (the kernel's copy cap).
|
|
@@ -2919,7 +3189,7 @@ function setRepeat(asset, a) {
|
|
|
2919
3189
|
const authored = values[lengthRef];
|
|
2920
3190
|
const limit = (values[layout.args[6]] ?? m.constants.find((c) => c.id === layout.args[6])?.value);
|
|
2921
3191
|
const smallest = Math.ceil((authored / limit) * 1000) / 1000;
|
|
2922
|
-
if (
|
|
3192
|
+
if (pitch < smallest)
|
|
2923
3193
|
fail('INPUT_RANGE', path, `at the length it is authored (${round6(authored)} mm) a pitch under ${smallest} mm makes more than ${limit} copies`);
|
|
2924
3194
|
const id = `${a.part}.repeat.pitch`;
|
|
2925
3195
|
if (!m.inputs.some((i) => i.id === id)) {
|
|
@@ -2931,11 +3201,11 @@ function setRepeat(asset, a) {
|
|
|
2931
3201
|
for (const ref of new Set(was))
|
|
2932
3202
|
dropIfUnused(asset, ref);
|
|
2933
3203
|
}
|
|
2934
|
-
asset.designInputs[id] =
|
|
3204
|
+
asset.designInputs[id] = pitch;
|
|
2935
3205
|
/* The longest length follows the pitch, within the default range (ten times the authored length). */
|
|
2936
3206
|
const length = m.inputs.find((i) => i.id === lengthRef && i.role === 'design');
|
|
2937
3207
|
if (length) {
|
|
2938
|
-
const longest = Math.min(round6(limit *
|
|
3208
|
+
const longest = Math.min(round6(limit * pitch), round6(authored * V3_INSTANCE_SIZE_DEFAULT.times));
|
|
2939
3209
|
length.max = Math.max(longest, authored);
|
|
2940
3210
|
if (length.min > length.max)
|
|
2941
3211
|
length.min = Math.min(length.min, authored);
|