@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.
@@ -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
- function refuseIfRead(asset, part) {
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
- if (readers.some((n) => n.id.startsWith('occupancy.')) || stringsIn(asset.occupancy).some(r => mine.has(r)))
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. What hangs from it, or follows its joint, is named and the removal refused -- a
1730
- * silent cascade would take parts the author did not pick. A part nothing hangs from goes with its own joint.
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 declared room taken out of the graph, for a command that declares it again from the parts once it is done
1734
- * (a part deleted with 「지우고 공간 다시 맞추기」). What it stood on stays: placement and the mounting face.
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 releaseV3Room(asset) {
1948
+ export function spaceOnlyNodesOf(asset) {
1737
1949
  const a = asset;
1738
- if (!a.occupancy?.bounds)
1739
- return null;
1740
- const was = stringsIn(a.occupancy.bounds);
1741
- const placement = a.occupancy.placement;
1742
- a.occupancy = a.occupancy.contact ? { contact: a.occupancy.contact, placement } : undefined;
1743
- if (!a.occupancy)
1744
- delete a.occupancy;
1745
- for (const ref of was)
1746
- dropIfUnused(a, ref);
1747
- return { placement };
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
- export function releaseV3PartJoints(asset, part) {
1750
- refuseIfRead(asset, part);
1751
- const joints = (asset.joints ?? []);
1752
- const hanging = joints.filter(j => 'part' in j.body0 && j.body0.part === part).map(j => j.body1.part);
1753
- if (hanging.length)
1754
- fail('PART_REFERENCED', part, `${part} holds ${hanging.join(', ')}; take ${hanging.length > 1 ? 'them' : 'it'} off first`);
1755
- const own = joints.find(j => j.body1.part === part);
1756
- if (!own)
1757
- return;
1758
- const followers = joints.filter(o => o.mimic?.joint === own.id).map(o => o.body1.part);
1759
- if (followers.length)
1760
- fail('PART_REFERENCED', part, `${followers.join(', ')} ${followers.length > 1 ? 'move' : 'moves'} with ${part}; make ${followers.length > 1 ? 'them' : 'it'} move on ${followers.length > 1 ? 'their' : 'its'} own first`);
1761
- asset.joints = joints.filter(j => j.id !== own.id);
1762
- if (!asset.joints.length)
1763
- delete asset.joints;
1764
- stripJoint(asset, own);
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
- if (!(Number.isFinite(a.pitch) && a.pitch > 0))
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 (a.pitch < smallest)
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] = a.pitch;
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 * a.pitch), round6(authored * V3_INSTANCE_SIZE_DEFAULT.times));
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);