create-forma-extension 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,8 +49,8 @@ Expected errors exit with code 1 and a single-line message; successful commands
49
49
 
50
50
  | Path | Contents |
51
51
  | --- | --- |
52
- | `package.json` | Private ESM package, display name, pinned SDK and development dependencies, development/typecheck/build scripts. |
53
- | `index.html`, `src/` | Native Autodesk controls, responsive panel layouts, proposal and footprint adapters, synthetic preview, loading/empty/error states. |
52
+ | `package.json` | Private ESM package, display name, `forma-extension-kit` dependency, pinned SDK peer and development dependencies, development/typecheck/build scripts. |
53
+ | `index.html`, `src/` | Native Autodesk controls, responsive panel layouts, host reads through `forma-extension-kit`, synthetic preview, loading/empty/error states. |
54
54
  | `forma/buttons.yaml` | Floating-panel button registration. |
55
55
  | `tsconfig.json`, `vite.config.ts` | Strict TypeScript and Vite on port 5173. |
56
56
  | `.editorconfig`, `.gitattributes`, `.gitignore` | Editor, line-ending and Git defaults. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forma-extension",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Create an Autodesk Forma extension with Vite, TypeScript and native Autodesk UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,13 +60,13 @@ Select **Open full panel** in the toolbar for the floating view.
60
60
 
61
61
  ## What you get
62
62
 
63
- - Vite and strict TypeScript. SDK **0.96.0** is the only direct runtime npm dependency.
63
+ - Vite and strict TypeScript. Runtime dependencies are `forma-extension-kit` and SDK **0.96.0**, retained directly to satisfy the kit's peer dependency.
64
64
  - Autodesk [base.css](https://app.autodeskforma.eu/design-system/v2/forma/styles/base.css), Artifakt type and CDN Weave tabs, select, primary button and inline error banner. No Weave npm package.
65
65
  - Local 4/8/16 px spacing, 24 px controls and 11/12 px type roles, accounting for the Design System's 10 px root.
66
66
  - Two tabs, a metric row, a working building-scope select and a locale-safe decimal input. The example limit demonstrates input only; it does not filter buildings.
67
67
  - Compact right-panel and full floating layouts. Each view reads independently; select Refresh after proposal edits. There is no shared mutable state or overlay in this starter.
68
- - `src/forma.ts`: persisted proposal reads, singular `building` and `site_limit` paths, deduplicated building counts and base-group classification.
69
- - An on-demand `readFootprint(path, snapshot)` adapter: graph and floor representations → direct context footprint → complete child footprints → XY triangles → last-resort direct native footprint. It checks the revision and retains failed-provider diagnostics.
68
+ - Host adapters from [`forma-extension-kit`](https://www.npmjs.com/package/forma-extension-kit) ([repository](https://github.com/sharafutdinovdi/forma-extension-kit)): persisted proposal reads, singular `building` and `site_limit` paths, deduplicated building counts and base-group classification. `src/host.ts` selects fixture data or kit reads and prepares the proposal summary.
69
+ - The kit's on-demand `readFootprint(path, snapshot)` adapter: graph and floor representations → direct footprint → complete child footprints → XY triangles → final direct-footprint retry. It checks the revision and retains failed-provider diagnostics.
70
70
  - Ready, loading, actionable empty and retryable error states. Use `?fixture=1&state=empty`, `state=loading` or `state=error`; Retry/Refresh returns the fixture to ready.
71
71
 
72
72
 
@@ -14,7 +14,8 @@
14
14
  "build": "vite build"
15
15
  },
16
16
  "dependencies": {
17
- "forma-embedded-view-sdk": "0.96.0"
17
+ "forma-embedded-view-sdk": "0.96.0",
18
+ "forma-extension-kit": "^0.1.0"
18
19
  },
19
20
  "devDependencies": {
20
21
  "typescript": "5.9.3",
@@ -1,8 +1,9 @@
1
- import type { ProposalSnapshot } from "./forma";
1
+ import type { BuildingKind } from "forma-extension-kit";
2
+ import type { ProposalView } from "./host";
2
3
 
3
4
  export type ViewState = "ready" | "empty" | "loading" | "error";
4
5
 
5
- export function fixtureData(empty = false): ProposalSnapshot {
6
+ export function fixtureData(empty = false): ProposalView {
6
7
  return {
7
8
  rootUrn: "fixture-root",
8
9
  proposalId: "fixture-proposal",
@@ -11,7 +12,7 @@ export function fixtureData(empty = false): ProposalSnapshot {
11
12
  siteLimitPaths: empty ? [] : ["root/site"],
12
13
  buildings: empty ? [] : ["proposal", "proposal", "existing"].map((kind, index) => ({
13
14
  path: index === 2 ? "root/base/context" : `root/proposal-${index === 0 ? "a" : "b"}`,
14
- kind: kind as "proposal" | "existing",
15
+ kind: kind as BuildingKind,
15
16
  })),
16
17
  };
17
18
  }
@@ -0,0 +1,17 @@
1
+ import { classifyBuilding, readProposalSnapshot, type BuildingKind, type ProposalSnapshot } from "forma-extension-kit";
2
+ import { fixtureData } from "./fixture";
3
+
4
+ export type ProposalView = Pick<ProposalSnapshot, "rootUrn" | "proposalId" | "buildingPaths" | "siteLimitPaths"> & {
5
+ name: string;
6
+ buildings: { path: string; kind: BuildingKind }[];
7
+ };
8
+
9
+ export async function readProposal(fixture = false, empty = false): Promise<ProposalView> {
10
+ if (fixture) return fixtureData(empty);
11
+ const snapshot = await readProposalSnapshot();
12
+ const buildings = await Promise.all(snapshot.buildingPaths.map(async path => ({
13
+ path, kind: await classifyBuilding(path, snapshot),
14
+ })));
15
+ const name = snapshot.rootTree.element.properties?.name;
16
+ return { ...snapshot, buildings, name: typeof name === "string" ? name : snapshot.proposalId };
17
+ }
@@ -1,5 +1,5 @@
1
1
  import "./styles.css";
2
- import { fixtureData } from "./fixture";
2
+ import { readProposal } from "./host";
3
3
  import { createApp } from "./ui/app";
4
4
 
5
5
  const params = new URLSearchParams(location.search);
@@ -10,7 +10,7 @@ let generation = 0;
10
10
  async function load(retry = false) {
11
11
  const current = ++generation;
12
12
  app.render("loading", null, "Reading the current proposal…");
13
- // A fixture always wins, even inside an iframe; importing /auto starts the SDK handshake.
13
+ // A fixture always wins, even inside an iframe; host reads start the SDK handshake.
14
14
  if (fixture) {
15
15
  const state = retry ? "ready" : params.get("state");
16
16
  if (state === "loading") return;
@@ -19,7 +19,7 @@ async function load(retry = false) {
19
19
  return;
20
20
  }
21
21
  const empty = state === "empty";
22
- app.render(empty ? "empty" : "ready", fixtureData(empty), empty
22
+ app.render(empty ? "empty" : "ready", await readProposal(true, empty), empty
23
23
  ? "Draw a building or order Overture buildings in Contextual data, then select Refresh."
24
24
  : "Synthetic data. In Forma, select Refresh after editing the proposal.");
25
25
  return;
@@ -28,14 +28,8 @@ async function load(retry = false) {
28
28
  app.render("error", null, "Open this URL in Forma, or add ?fixture=1 for a synthetic preview. Then select Retry.");
29
29
  return;
30
30
  }
31
- let timer: ReturnType<typeof setTimeout> | undefined;
32
31
  try {
33
- const snapshot = await Promise.race([
34
- import("./forma").then(({ readProposal }) => readProposal()),
35
- new Promise<never>((_, reject) => {
36
- timer = setTimeout(() => reject(new Error("Forma did not respond within 8 seconds")), 8000);
37
- }),
38
- ]);
32
+ const snapshot = await readProposal();
39
33
  if (current !== generation) return;
40
34
  app.render(snapshot.buildings.length ? "ready" : "empty", snapshot, snapshot.buildings.length
41
35
  ? "Select Refresh after editing the proposal."
@@ -43,8 +37,6 @@ async function load(retry = false) {
43
37
  } catch (error) {
44
38
  if (current !== generation) return;
45
39
  app.render("error", null, `${error instanceof Error ? error.message : String(error)}. Check the Forma project and select Retry.`);
46
- } finally {
47
- clearTimeout(timer);
48
40
  }
49
41
  }
50
42
 
@@ -1,4 +1,4 @@
1
- import type { ProposalSnapshot } from "../forma";
1
+ import type { ProposalView } from "../host";
2
2
  import type { ViewState } from "../fixture";
3
3
  import { metricRow } from "./metric-row";
4
4
  import { numberInput } from "./number-input";
@@ -43,7 +43,7 @@ export function createApp(root: HTMLElement, fixture: boolean, refresh: () => vo
43
43
  help.textContent = "Decimal input example. It does not change the building count.";
44
44
  controls.append(help);
45
45
 
46
- let snapshot: ProposalSnapshot | null = null;
46
+ let snapshot: ProposalView | null = null;
47
47
  let scope = "all";
48
48
  let state: ViewState = "loading";
49
49
  const paintMetric = () => {
@@ -78,7 +78,7 @@ export function createApp(root: HTMLElement, fixture: boolean, refresh: () => vo
78
78
  });
79
79
 
80
80
  return {
81
- render(next: ViewState, data: ProposalSnapshot | null, message: string) {
81
+ render(next: ViewState, data: ProposalView | null, message: string) {
82
82
  state = next;
83
83
  snapshot = data;
84
84
  root.dataset.state = state;
@@ -1,4 +1,4 @@
1
- const format = new Intl.NumberFormat("en-US");
1
+ import { formatNumber } from "forma-extension-kit";
2
2
 
3
3
  export function metricRow(label: string, unit = "") {
4
4
  const row = document.createElement("div");
@@ -13,5 +13,5 @@ export function metricRow(label: string, unit = "") {
13
13
  suffix.textContent = unit ? ` ${unit}` : "";
14
14
  value.append(amount, suffix);
15
15
  row.append(caption, value);
16
- return { row, set: (number: number | null) => { amount.textContent = number === null ? "—" : format.format(number); } };
16
+ return { row, set: (number: number | null) => { amount.textContent = number === null ? "—" : formatNumber(number); } };
17
17
  }
@@ -1,15 +1,4 @@
1
- const decimal = new Intl.NumberFormat("en-US", {
2
- useGrouping: false,
3
- maximumFractionDigits: 15,
4
- });
5
-
6
- export function parseDecimal(raw: string): number | undefined {
7
- const text = raw.trim();
8
- if (!text) return undefined;
9
- // Grouping is forbidden: a single comma always means the decimal separator.
10
- return /^[+-]?(?:\d+(?:[.,]\d*)?|[.,]\d+)$/.test(text)
11
- ? Number(text.replace(",", ".")) : NaN;
12
- }
1
+ import { formatNumber, parseNumber } from "forma-extension-kit";
13
2
 
14
3
  export function numberInput(id: string, label: string, initial: number) {
15
4
  const field = document.createElement("div");
@@ -21,18 +10,18 @@ export function numberInput(id: string, label: string, initial: number) {
21
10
  input.id = id;
22
11
  input.type = "text";
23
12
  input.inputMode = "decimal";
24
- input.value = decimal.format(initial);
13
+ input.value = formatNumber(initial);
25
14
  input.setAttribute("aria-describedby", "number-help");
26
15
  const validate = () => {
27
- const value = parseDecimal(input.value);
28
- const valid = value !== undefined && Number.isFinite(value) && value >= 0;
16
+ const value = parseNumber(input.value);
17
+ const valid = value !== null && value >= 0;
29
18
  input.setCustomValidity(valid ? "" : "Enter a non-negative number with . or , and no grouping separators.");
30
19
  input.setAttribute("aria-invalid", String(!valid));
31
20
  return valid;
32
21
  };
33
22
  input.addEventListener("input", validate);
34
23
  input.addEventListener("change", () => {
35
- if (validate()) input.value = decimal.format(parseDecimal(input.value)!);
24
+ if (validate()) input.value = formatNumber(parseNumber(input.value)!);
36
25
  else input.reportValidity();
37
26
  });
38
27
  field.append(caption, input);
@@ -1,211 +0,0 @@
1
- import { Forma } from "forma-embedded-view-sdk/auto";
2
-
3
- type Tree = Awaited<ReturnType<typeof Forma.elements.get>>;
4
- type RootUrn = Awaited<ReturnType<typeof Forma.proposal.getRootUrn>>;
5
- export type BuildingKind = "existing" | "proposal";
6
- export type Point = [number, number];
7
- export type Polygon = Point[][];
8
- export interface ProposalSnapshot {
9
- rootUrn: string;
10
- proposalId: string;
11
- name: string;
12
- buildingPaths: string[];
13
- siteLimitPaths: string[];
14
- buildings: { path: string; kind: BuildingKind }[];
15
- }
16
- export interface Footprint {
17
- // Set union of polygons (outer ring, then holes); parts may overlap, so never sum their areas.
18
- operation: "union";
19
- parts: Polygon[];
20
- source: "graphBuilding" | "grossFloorAreaPolygons" | "footprint" | "children" | "triangles";
21
- }
22
- export interface FootprintResult {
23
- footprint: Footprint | null;
24
- attempts: { provider: string; error?: string }[];
25
- }
26
-
27
- export function buildingClassifier(tree: Tree) {
28
- const root = tree.element;
29
- const elements: Tree["elements"] = { ...tree.elements, [root.urn]: root };
30
- const pending = new Map<string, Promise<void>>();
31
- return async (path: string): Promise<BuildingKind> => {
32
- const keys = path.split("/").slice(1, -1);
33
- const base = /:group:[^:]+:base:/;
34
- // The building's own basic/basicbuilding URN does not identify its source.
35
- if (base.test(root.urn) || keys.some(key => root.properties?.flags?.[key]?.base === true)) return "existing";
36
- let parent = root;
37
- for (const key of keys) {
38
- const child = parent.children?.find(item => item.key === key);
39
- if (!child) throw new Error(`Cannot resolve building ancestry: ${path}`);
40
- if (base.test(child.urn)) return "existing";
41
- if (!elements[child.urn]) {
42
- if (!pending.has(child.urn)) pending.set(child.urn, Forma.elements.get({ urn: child.urn }).then(fetched => {
43
- Object.assign(elements, fetched.elements, { [fetched.element.urn]: fetched.element });
44
- }));
45
- await pending.get(child.urn);
46
- }
47
- parent = elements[child.urn];
48
- }
49
- return "proposal";
50
- };
51
- }
52
-
53
- async function assertRevision(rootUrn: string, proposalId: string) {
54
- const [root, id] = await Promise.all([Forma.proposal.getRootUrn(), Forma.proposal.getId()]);
55
- if (root !== rootUrn || id !== proposalId) throw new Error("Proposal changed while reading; refresh");
56
- }
57
-
58
- export async function readProposal(): Promise<ProposalSnapshot> {
59
- // These proposal calls are verified in 0.96.0; that SDK deprecates them in favour of UDM.
60
- await Forma.proposal.awaitProposalPersisted();
61
- const [rootUrn, proposalId] = await Promise.all([Forma.proposal.getRootUrn(), Forma.proposal.getId()]);
62
- const [tree, paths, sitePaths] = await Promise.all([
63
- Forma.elements.get({ urn: rootUrn }),
64
- Forma.geometry.getPathsByCategory({ category: "building", urn: rootUrn }),
65
- Forma.geometry.getPathsByCategory({ category: "site_limit", urn: rootUrn }),
66
- ]);
67
- const unique = [...new Set(paths)];
68
- // Nested category-building paths are parts of a counted parent, not additional buildings.
69
- const buildingPaths = unique.filter(path => !unique.some(parent => path.startsWith(`${parent}/`)));
70
- const classify = buildingClassifier(tree);
71
- const buildings = await Promise.all(buildingPaths.map(async path => ({ path, kind: await classify(path) })));
72
- await assertRevision(rootUrn, proposalId);
73
- return {
74
- rootUrn, proposalId, buildingPaths, buildings,
75
- siteLimitPaths: [...new Set(sitePaths)],
76
- name: typeof tree.element.properties?.name === "string" ? tree.element.properties.name : proposalId,
77
- };
78
- }
79
-
80
- function ring(points: readonly (readonly number[])[]): Point[] {
81
- const result = points.map(([x, y]): Point => {
82
- if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Non-finite footprint coordinate");
83
- return [x, y];
84
- });
85
- if (result.length > 1 && result[0][0] === result.at(-1)![0] && result[0][1] === result.at(-1)![1]) result.pop();
86
- const cross = (a: Point, b: Point, c: Point) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
87
- const area = result.reduce((sum, point, index) => sum + cross(result[0], point, result[(index + 1) % result.length]), 0);
88
- if (result.length < 3 || Math.abs(area) < 1e-8) throw new Error("Empty or degenerate footprint ring");
89
- // Reject self-crossing boundaries rather than inventing a hull for invalid geometry.
90
- const on = (a: Point, b: Point, p: Point) => Math.abs(cross(a, b, p)) < 1e-8 &&
91
- p[0] >= Math.min(a[0], b[0]) && p[0] <= Math.max(a[0], b[0]) && p[1] >= Math.min(a[1], b[1]) && p[1] <= Math.max(a[1], b[1]);
92
- for (let i = 0; i < result.length; i++) {
93
- for (let j = i + 2; j < result.length; j++) {
94
- if (i === 0 && j === result.length - 1) continue;
95
- const a = result[i], b = result[(i + 1) % result.length], c = result[j], d = result[(j + 1) % result.length];
96
- if ((cross(a, b, c) * cross(a, b, d) < 0 && cross(c, d, a) * cross(c, d, b) < 0) ||
97
- on(a, b, c) || on(a, b, d) || on(c, d, a) || on(c, d, b)) throw new Error("Self-intersecting footprint ring");
98
- }
99
- }
100
- return result;
101
- }
102
-
103
- export async function readFootprint(path: string, snapshot: Pick<ProposalSnapshot, "rootUrn" | "proposalId">): Promise<FootprintResult> {
104
- const { rootUrn, proposalId } = snapshot;
105
- await assertRevision(rootUrn, proposalId);
106
- const { element } = await Forma.elements.getByPath({ path, rootUrn: rootUrn as RootUrn });
107
- const attempts: FootprintResult["attempts"] = [];
108
- const attempt = async (provider: string, read: () => Promise<Polygon[]>): Promise<Polygon[] | null> => {
109
- try {
110
- const parts = await read();
111
- if (!parts.length || parts.some(polygon => !polygon.length)) throw new Error("No footprint polygons");
112
- attempts.push({ provider });
113
- return parts;
114
- } catch (error) {
115
- attempts.push({ provider, error: error instanceof Error ? error.message : String(error) });
116
- return null;
117
- }
118
- };
119
- const direct = (target: string) => attempt(`getFootprint:${target}`, async () => {
120
- const value = await Forma.geometry.getFootprint({ path: target, urn: rootUrn });
121
- // SDK footprint coordinates are one flat XY ring, unlike GeoJSON Polygon coordinates.
122
- if (value?.type !== "Polygon") throw new Error(`getFootprint returned ${value === undefined ? "undefined" : "no Polygon"}`);
123
- return [[ring(value.coordinates)]];
124
- });
125
- let transform: number[] | undefined;
126
- const worldPolygon = async (polygon: Polygon): Promise<Polygon> => {
127
- // Representations are element-local; the transform API cannot pin a root, so recheck below.
128
- transform ??= (await Forma.elements.getWorldTransform({ path })).transform;
129
- const t = transform;
130
- if (t.length !== 16 || t.some(n => !Number.isFinite(n)) ||
131
- [2, 3, 6, 7, 8, 9, 11].some(i => Math.abs(t[i]) > 1e-8) || t[10] <= 0 || Math.abs(t[15] - 1) > 1e-8) {
132
- throw new Error("Invalid or tilted floor transform");
133
- }
134
- return polygon.map(points => ring(points.map(([x, y]) => [t[0] * x + t[4] * y + t[12], t[1] * x + t[5] * y + t[13]])));
135
- };
136
- const native = /:basicbuilding:/.test(element.urn);
137
- let parts: Polygon[] | null = null;
138
- let source: Footprint["source"] = "graphBuilding";
139
- if (native || element.representations?.graphBuilding) {
140
- parts = await attempt(source, async () => {
141
- const graph = await Forma.elements.representations.graphBuilding({ urn: element.urn });
142
- if (!graph?.data.levels.length) throw new Error("graphBuilding returned no levels");
143
- const polygons: Polygon[] = [];
144
- for (const level of graph.data.levels) {
145
- if (!Number.isFinite(level.height) || level.height <= 0 || !level.spaces.length) throw new Error("Invalid graph level");
146
- const surfaces = new Map(level.surfaces.map(surface => [surface.id, surface]));
147
- // Graph points are indexed, not an ordered ring; reconstruct directed surface loops.
148
- const loop = (edges: typeof level.spaces[number]["outerLoop"]): Point[] => {
149
- const segments = edges.map(edge => {
150
- const surface = surfaces.get(edge.surfaceId);
151
- if (!surface) throw new Error("Missing graph surface");
152
- return edge.directionAToB ? [surface.pointA, surface.pointB] : [surface.pointB, surface.pointA];
153
- });
154
- return segments.map(([start, end], i) => {
155
- if (end !== segments[(i + 1) % segments.length][0] || !level.points[start]) throw new Error("Disconnected graph loop");
156
- return level.points[start];
157
- });
158
- };
159
- for (const space of level.spaces) polygons.push(await worldPolygon([loop(space.outerLoop), ...(space.innerLoops ?? []).map(loop)]));
160
- }
161
- return polygons;
162
- });
163
- }
164
- if (!parts && (native || element.representations?.grossFloorAreaPolygons)) {
165
- source = "grossFloorAreaPolygons";
166
- parts = await attempt(source, async () => {
167
- const floors = await Forma.elements.representations.grossFloorAreaPolygons({ urn: element.urn });
168
- if (!floors?.data.length) throw new Error("grossFloorAreaPolygons returned no floors");
169
- const polygons: Polygon[] = [];
170
- for (const floor of floors.data) {
171
- if (!Number.isFinite(floor.elevation)) throw new Error("Invalid floor elevation");
172
- polygons.push(await worldPolygon(floor.grossFloorPolygon));
173
- }
174
- return polygons;
175
- });
176
- }
177
- // Context/basic elements have readable direct footprints; authored basicbuilding often does not.
178
- if (!parts && !native) { source = "footprint"; parts = await direct(path); }
179
- if (!parts && element.children?.length) {
180
- source = "children";
181
- parts = await attempt(source, async () => {
182
- const polygons: Polygon[] = [];
183
- let complete = true;
184
- for (const child of element.children!) {
185
- // Child keys are path segments; a partial set must never look like a complete footprint.
186
- const childParts = await direct(`${path}/${child.key}`);
187
- if (childParts) polygons.push(...childParts); else complete = false;
188
- }
189
- if (!complete) throw new Error("Some child footprints are unavailable");
190
- return polygons;
191
- });
192
- }
193
- if (!parts) {
194
- source = "triangles";
195
- parts = await attempt(source, async () => {
196
- const vertices = await Forma.geometry.getTriangles({ path, urn: rootUrn });
197
- if (!vertices.length || vertices.length % 9 || vertices.some(n => !Number.isFinite(n))) throw new Error("Invalid or empty triangle array");
198
- const polygons: Polygon[] = [];
199
- for (let i = 0; i < vertices.length; i += 9) {
200
- const a: Point = [vertices[i], vertices[i + 1]], b: Point = [vertices[i + 3], vertices[i + 4]], c: Point = [vertices[i + 6], vertices[i + 7]];
201
- // Vertical faces have zero XY area; retain all other parts, including disconnected ones.
202
- if (Math.abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) >= 1e-8) polygons.push([[a, b, c]]);
203
- }
204
- return polygons;
205
- });
206
- }
207
- // Preserve a readable native footprint if every preferred provider failed.
208
- if (!parts && native) { source = "footprint"; parts = await direct(path); }
209
- await assertRevision(rootUrn, proposalId);
210
- return { footprint: parts ? { operation: "union", parts, source } : null, attempts };
211
- }