@thatopen/services 0.7.0 → 0.8.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.
@@ -10,6 +10,14 @@ export interface AddinInfo {
10
10
  token: string;
11
11
  pid: number;
12
12
  }
13
+ /** The add-in's discovery file, or null. Unlike discoverAddin this never exits. */
14
+ export declare function tryDiscoverAddin(): AddinInfo | null;
15
+ /** Is there an add-in listening right now? Asks it, rather than trusting the file:
16
+ * Revit exiting leaves the file behind. */
17
+ export declare function addinAlive(): Promise<boolean>;
18
+ /** Waits for Revit to finish starting. Revit takes its time, and the add-in's
19
+ * listener comes up well before the splash screen goes away. */
20
+ export declare function waitForAddin(timeoutMs: number): Promise<boolean>;
13
21
  export declare function discoverAddin(): AddinInfo;
14
22
  export declare function callAddin(cmd: string, body?: Record<string, unknown>): Promise<Record<string, unknown>>;
15
23
  /**
package/dist/cli.js CHANGED
@@ -8120,7 +8120,7 @@ const libVersion = (() => {
8120
8120
  }
8121
8121
  })();
8122
8122
  const templatesDir = require$$2.join(__dirname, "..", "src", "cli", "templates");
8123
- const createCommand = new Command2("create").argument("<project-name>", 'Name of the project to create (use "." for current directory)').option("-t, --template <template>", `Template (${TEMPLATES.join(", ")})`, "app").option("--beta", "Use beta engine libraries (@thatopen-platform/*-beta)").description("Scaffold a new ThatOpen app or cloud component project").action(async (projectName, opts) => {
8123
+ const createCommand = new Command2("create").argument("<project-name>", 'Name of the project to create (use "." for current directory)').option("-t, --template <template>", `Template (${TEMPLATES.join(", ")})`, "app").option("--beta", "Use beta engine libraries (@thatopen-platform/*-beta)").option("--history", "App only: include the revit-flow commit history panel, already wired").description("Scaffold a new ThatOpen app or cloud component project").action(async (projectName, opts) => {
8124
8124
  const template = opts.template;
8125
8125
  if (!TEMPLATES.includes(template)) {
8126
8126
  console.error(`Unknown template "${opts.template}". Available: ${TEMPLATES.join(", ")}`);
@@ -8159,6 +8159,16 @@ The templates currently require the beta engine libraries. Public (non-beta) eng
8159
8159
  if (isCloud) {
8160
8160
  require$$3.renameSync(require$$2.join(targetDir, "_thatopen"), require$$2.join(targetDir, ".thatopen"));
8161
8161
  }
8162
+ if (!isCloud) {
8163
+ const variant = require$$2.join(targetDir, "src", "main.history.ts");
8164
+ if (require$$3.existsSync(variant)) {
8165
+ if (opts.history) require$$3.renameSync(variant, require$$2.join(targetDir, "src", "main.ts"));
8166
+ else require$$3.rmSync(variant);
8167
+ }
8168
+ } else if (opts.history) {
8169
+ console.error("--history applies to the app template only.");
8170
+ process.exit(1);
8171
+ }
8162
8172
  const pkgPath = require$$2.join(targetDir, "package.json");
8163
8173
  const pkgContent = require$$3.readFileSync(pkgPath, "utf-8").replace(/\{\{PROJECT_NAME\}\}/g, packageName).replace(/\{\{VERSION\}\}/g, libVersion).replace(/"@thatopen\/services": "file:[^"]*"/, `"@thatopen/services": "^${libVersion}"`);
8164
8174
  require$$3.writeFileSync(pkgPath, pkgContent);
@@ -11733,6 +11743,38 @@ function discoveryFile() {
11733
11743
  const appData = process.env.APPDATA || require$$2.join(node_os.homedir(), "AppData", "Roaming");
11734
11744
  return require$$2.join(appData, "ThatOpen", "revit-addin.json");
11735
11745
  }
11746
+ function tryDiscoverAddin() {
11747
+ const file = discoveryFile();
11748
+ if (!require$$3.existsSync(file)) return null;
11749
+ try {
11750
+ return JSON.parse(require$$3.readFileSync(file, "utf-8"));
11751
+ } catch {
11752
+ return null;
11753
+ }
11754
+ }
11755
+ async function addinAlive() {
11756
+ const info = tryDiscoverAddin();
11757
+ if (!info) return false;
11758
+ try {
11759
+ const res = await fetch(`http://127.0.0.1:${info.port}/status`, {
11760
+ method: "POST",
11761
+ headers: { "Content-Type": "application/json", "X-RevitFlow-Token": info.token },
11762
+ body: "{}",
11763
+ signal: AbortSignal.timeout(4e3)
11764
+ });
11765
+ return res.ok;
11766
+ } catch {
11767
+ return false;
11768
+ }
11769
+ }
11770
+ async function waitForAddin(timeoutMs) {
11771
+ const until = Date.now() + timeoutMs;
11772
+ while (Date.now() < until) {
11773
+ if (await addinAlive()) return true;
11774
+ await new Promise((r) => setTimeout(r, 2e3));
11775
+ }
11776
+ return false;
11777
+ }
11736
11778
  function discoverAddin() {
11737
11779
  const file = discoveryFile();
11738
11780
  if (!require$$3.existsSync(file)) {
@@ -11789,6 +11831,28 @@ async function configureAddin(apiUrl, accessToken) {
11789
11831
  key: accessToken
11790
11832
  });
11791
11833
  }
11834
+ function isRevitRunning() {
11835
+ if (process.platform !== "win32") return false;
11836
+ try {
11837
+ return require$$1.execSync('tasklist /FI "IMAGENAME eq Revit.exe" /NH', { encoding: "utf-8" }).toLowerCase().includes("revit.exe");
11838
+ } catch {
11839
+ return false;
11840
+ }
11841
+ }
11842
+ function startRevit() {
11843
+ const roots = ["C:\\Program Files\\Autodesk"];
11844
+ for (const root of roots) {
11845
+ if (!require$$3.existsSync(root)) continue;
11846
+ for (const dir of require$$3.readdirSync(root)) {
11847
+ if (!/^Revit \d{4}$/.test(dir)) continue;
11848
+ const exe = require$$2.join(root, dir, "Revit.exe");
11849
+ if (!require$$3.existsSync(exe)) continue;
11850
+ require$$1.spawn(exe, [], { detached: true, stdio: "ignore" }).unref();
11851
+ return;
11852
+ }
11853
+ }
11854
+ console.log(" Could not find Revit.exe. Start Revit yourself and this will pick it up.");
11855
+ }
11792
11856
  const REVIT_ADDIN_PACKAGE = "@thatopen-platform/flow-revit";
11793
11857
  const revitCommand = new Command2("revit").description(
11794
11858
  "Revit collaboration — drive the That Open Revit add-in from the CLI"
@@ -11815,6 +11879,64 @@ revitCommand.command("inspect").description("Check whether a .rvt is already a w
11815
11879
  console.log("Ask the user for consent, then run publish-central with --convert.");
11816
11880
  }
11817
11881
  });
11882
+ revitCommand.command("share").description("Share a .rvt with your team: install if needed, publish the central, open your local").requiredOption("--file <path>", "Absolute path to the .rvt on this machine").requiredOption("--project <id>", "Platform project id (or the dashboard URL)").option("--doc <name>", "Short name for the shared central (default: from the file name)").action(async (opts) => {
11883
+ const cfg = requireResolvedConfig();
11884
+ const file = require$$2.resolve(opts.file);
11885
+ if (!require$$3.existsSync(file)) {
11886
+ console.error(`No such file: ${file}`);
11887
+ process.exit(1);
11888
+ }
11889
+ const project = opts.project.match(/projects\/([a-f0-9]{24})/i)?.[1] ?? opts.project;
11890
+ const doc = opts.doc ?? require$$2.basename(file, ".rvt").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
11891
+ console.log(`Sharing "${require$$2.basename(file)}" as "${doc}" in project ${project}.
11892
+ `);
11893
+ if (!await addinAlive()) {
11894
+ if (!isRevitRunning()) {
11895
+ console.log("[1/5] Installing the Revit add-in...");
11896
+ const client = new EngineServicesClient(cfg.accessToken, cfg.apiUrl);
11897
+ const { dir, cleanup } = await fetchPluginPackage(client, REVIT_ADDIN_PACKAGE, "latest");
11898
+ try {
11899
+ const installer = require$$2.join(dir, "install.ps1");
11900
+ if (!require$$3.existsSync(installer)) throw new Error("The package has no install.ps1.");
11901
+ require$$1.execFileSync("powershell", ["-ExecutionPolicy", "Bypass", "-File", installer], {
11902
+ stdio: "inherit"
11903
+ });
11904
+ } finally {
11905
+ cleanup();
11906
+ }
11907
+ console.log("[2/5] Starting Revit...");
11908
+ startRevit();
11909
+ } else {
11910
+ console.log("[1/5] Waiting for Revit...");
11911
+ }
11912
+ if (!await waitForAddin(3e5)) {
11913
+ console.error(
11914
+ '\nRevit did not report in. If it is open, the add-in may not be installed:\n close Revit, run "thatopen revit install", and start it again.'
11915
+ );
11916
+ process.exit(1);
11917
+ }
11918
+ } else {
11919
+ console.log("[1/5] Revit is open and the add-in is answering.");
11920
+ }
11921
+ await configureAddin(cfg.apiUrl, cfg.accessToken);
11922
+ console.log("[3/5] Checking the file...");
11923
+ const info = await callAddin("inspect", { file });
11924
+ console.log(
11925
+ info.isCentral ? " Already a workshared central. Publishing a copy of it." : " Not a central yet. Converting a COPY. Your file is not modified."
11926
+ );
11927
+ console.log("[4/5] Publishing to the platform (Revit is saving the central, this takes a bit)...");
11928
+ const pub = await callAddin("publish-central", { project, doc, file, convert: true });
11929
+ console.log("[5/5] Creating and opening your local...");
11930
+ const joined = await callAddin("join", { project, doc });
11931
+ console.log(`
11932
+ Shared. Central "${doc}" is v${pub.version} in project ${project}.`);
11933
+ console.log(`Your local, now open in Revit:
11934
+ ${joined.local}`);
11935
+ console.log(`
11936
+ Teammates join with:
11937
+ thatopen revit join --project ${project} --doc ${doc}`);
11938
+ console.log(`From here, Revit's own Synchronize with Central publishes to the team.`);
11939
+ });
11818
11940
  revitCommand.command("publish-central").description("Turn a .rvt into a shared central and upload it to a project").requiredOption("--project <id>", "Platform project id").requiredOption("--doc <name>", "A short name for this central (e.g. tower-central)").requiredOption("--file <path>", "Absolute path to the source .rvt on this machine").option("--convert", "Allow converting a non-central .rvt into one (on a copy; original untouched)").action(async (opts) => {
11819
11941
  await connect();
11820
11942
  console.log(`Publishing "${opts.file}" as central "${opts.doc}"...`);
@@ -87,6 +87,16 @@ thatopen create my-app --beta
87
87
  cd my-app
88
88
  ```
89
89
 
90
+ **[app, showing a Revit model's commit history]** If what they asked for is a viewer of the
91
+ revit-flow history, scaffold it directly. Do not build it by hand: the wiring is four additions in
92
+ three places and the ORDER of them is load-bearing (see the boot order section below). This ships
93
+ it already correct, opening on the History layout.
94
+
95
+ ```bash
96
+ thatopen create my-app --beta --history
97
+ cd my-app
98
+ ```
99
+
90
100
  **[component]**
91
101
  ```bash
92
102
  thatopen create my-component -t cloud-component --beta
@@ -110,6 +120,17 @@ npm run dev
110
120
  Serves the app and opens it inside the platform. **You now have a complete, working viewer** —
111
121
  model loading, spatial tree, properties, measurement, sectioning, and more.
112
122
 
123
+ If it does not open by itself, or the user closes the tab, the app you are serving from this
124
+ machine lives at:
125
+
126
+ ```
127
+ https://platform.thatopen.com/dashboard/projects/<PROJECT_ID>/apps/local-app
128
+ ```
129
+
130
+ Substitute the project id. That is the local app running inside the platform's own UI, against
131
+ the project's real data. Send the user there rather than to a bare localhost port, which is the
132
+ app without the platform around it.
133
+
113
134
  > **`npx tsc --noEmit` reports errors in a fresh beta scaffold, and they are not real.** You will see
114
135
  > a handful of `Cannot find module '@thatopen/components'` and friends. A beta project aliases those
115
136
  > imports to the `@thatopen-platform/*-beta` packages in `vite.config.js`, which bare `tsc` does not
@@ -126,6 +147,101 @@ npm run run # executes the component locally against the platform (no bro
126
147
  ```
127
148
  Runs `main()` in a local emulation of the cloud engine so you can iterate before publishing.
128
149
 
150
+ ## THE BOOT ORDER. Read this before you write a line of an app.
151
+
152
+ **This is where apps break, and the error never names the real cause.** An app boots in four
153
+ stages, and a built-in that touches models or the 3D scene must be initialised in the LAST one.
154
+ Call it where it reads naturally, right after `client.setup`, and you get:
155
+
156
+ ```
157
+ Error: FragmentsManager not initialized. Call init() first.
158
+ Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'onHighlight')
159
+ Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'onClear')
160
+ ```
161
+
162
+ None of which says "you called this too early". The app renders, the panel mounts, and it sits
163
+ there inert.
164
+
165
+ **Why.** `FragmentsManager` is initialised inside `top-app`'s own `setup` callback, and the 3D
166
+ world does not exist until `<top-app>` has been added to the DOM and `top-viewer` has built it.
167
+ Both of those happen well after `client.setup` returns. Any manager that reads model files or
168
+ colours elements reaches for them the moment it starts.
169
+
170
+ **The order, and it is not negotiable:**
171
+
172
+ 1. `client.setup(...)` with every built-in you need, then `components.get(UIManager).init()`
173
+ 2. `app.setup = ...`, which is where `FragmentsManager.init` goes, and append `<top-app>`
174
+ 3. `await firstWorld(...)`, so the world exists
175
+ 4. **only now**, `init(client)` on the model-facing managers
176
+
177
+ Copy this skeleton. It is the boot of an app that works.
178
+
179
+ ```ts
180
+ async function main() {
181
+ const client = PlatformClient.fromPlatformContext();
182
+
183
+ // 1 ── built-ins registered, UI shell up
184
+ const { components } = await client.setup<OBC.Components>(
185
+ { OBC, OBF, BUI, THREE, FRAGS, MARKERJS },
186
+ { uuid: UIManager.uuid },
187
+ { uuid: GitHistoryManager.uuid }, // and any other built-in you use
188
+ );
189
+ components.get(UIManager).init();
190
+
191
+ const viewerEl = document.createElement("top-viewer");
192
+ const app = document.createElement("top-app") as any;
193
+
194
+ // 2 ── this callback is what initialises FragmentsManager. Nothing that needs
195
+ // fragments may run before it has resolved.
196
+ app.setup = (waitUntil: (p: Promise<void>, label?: string) => void) => {
197
+ waitUntil(
198
+ (async () => {
199
+ const fragments = components.get(OBC.FragmentsManager);
200
+ const workerUrl = await FRAGS.FragmentsModels.getWorker();
201
+ fragments.init(await FRAGS.toClassicWorker(workerUrl), { classicWorker: true });
202
+ })(),
203
+ "Fragments Core",
204
+ );
205
+ return { components, client };
206
+ };
207
+
208
+ app.elements = { viewer: () => BUI.html`${viewerEl}` };
209
+ app.layouts = { Main: { template: `"viewer" 1fr / 1fr` } };
210
+ app.layout = "Main";
211
+ (document.getElementById("that-open-app") ?? document.body).appendChild(app);
212
+
213
+ // 3 ── the world is created asynchronously by top-viewer. Wait for it.
214
+ await firstWorld(components.get(OBC.Worlds));
215
+
216
+ // 4 ── ONLY NOW. Everything above had to have happened first.
217
+ await components.get(GitHistoryManager).init(client);
218
+
219
+ // ...and from here, the real layouts, panels and elements.
220
+ }
221
+
222
+ /** Resolves with the first world once it exists (top-viewer creates it async). */
223
+ function firstWorld(worlds: any): Promise<any> {
224
+ const existing = [...worlds.list.values()][0];
225
+ if (existing) return Promise.resolve(existing);
226
+ return new Promise((resolve) => {
227
+ const handler = ({ value }: any) => {
228
+ worlds.list.onItemSet.remove(handler);
229
+ resolve(value);
230
+ };
231
+ worlds.list.onItemSet.add(handler);
232
+ });
233
+ }
234
+ ```
235
+
236
+ **Two more traps in the same family:**
237
+
238
+ - **One stable `top-viewer` node, held in a variable and returned by reference** from every
239
+ `elements.viewer()` call. Creating a fresh element on each render disposes the world and
240
+ builds another one, and everything wired to the old one goes quiet.
241
+ - **Registering a built-in is not the same as initialising it.** `client.setup` makes
242
+ `<top-git-history>` resolvable; `init(client)` is what fills it. A panel that mounts and stays
243
+ empty is usually the second one missing, not the first.
244
+
129
245
  ## 5. Then build — read the in-project agent guide
130
246
 
131
247
  The scaffold is real, working code — a complete viewer **[app]** or a runnable `main()`
@@ -163,5 +279,8 @@ component, once added to a project, is then triggered by an app or an automation
163
279
  - **Platform token only.** Never introduce, request, or store an npm token.
164
280
  - **Never echo or persist the user's token.** It belongs only in `~/.thatopen/config.json` /
165
281
  `.npmrc`, both of which the CLI manages and git-ignores.
166
- - **Propose a short plan and get the user's OK** before changing files.
282
+ - **Do the thing they asked for.** If the request already says what to build, build it. Proposing a
283
+ four-point plan and waiting for approval to do what was just requested costs the user a whole turn
284
+ and buys nothing. Stop and ask only when you are about to change files you did not create, when
285
+ the request can be read two ways that mean different work, or when the next step is destructive.
167
286
  - The scaffold already works — **extend it, don't replace it.**
@@ -152,6 +152,32 @@ made every proposal declare its units, which looked careful and was a bug waitin
152
152
  an author state a unit while writing coordinates in an axis convention the target does not use, a
153
153
  mistake no declared unit can catch.
154
154
 
155
+ ### Who wrote it
156
+
157
+ `author` is free text for a timeline to print. `userId` is the one that has to be right: it is what
158
+ the platform's own member component resolves into a real name and face. Get it wrong and the
159
+ proposal shows up under somebody else's avatar, which is worse than showing none.
160
+
161
+ **In a browser app, take it from `getProjectData`.** It answers with `currentUser` already resolved
162
+ server-side, and the members it lists are keyed by exactly the id the history panel indexes:
163
+
164
+ ```ts
165
+ const { currentUser } = await client.getProjectData(projectId);
166
+ const userId = currentUser?.user._id; // what the avatar resolves against
167
+ const author = currentUser?.user.fullName; // what the timeline prints
168
+ ```
169
+
170
+ **Do not look for it on the client's context.** `client.context` is the four fields the platform
171
+ injects into the iframe: `appId`, `projectId`, `accessToken`, `apiUrl`. Reading `context.userId` or
172
+ `context.user.name` yields `undefined`, and both failures are silent: the envelope simply ships
173
+ without a `userId`, and the panel falls back to passing `author` as the email, so the member
174
+ component renders whatever it makes of a string that names nobody. A converter did exactly this, and
175
+ it looked fine until somebody noticed the proposal was wearing another person's face.
176
+
177
+ **In a native plug-in there is no such call**, so read the `u` claim out of the API key's JWT payload
178
+ (base64url, no signature check, since this only decides which name to display). That is what the
179
+ Revit add-in does.
180
+
155
181
  ### The verbs
156
182
 
157
183
  One per item, in the attribute `flow:op`:
@@ -46,6 +46,12 @@ the point.
46
46
 
47
47
  ## Step 0 — Ask what the user wants to do
48
48
 
49
+ **Only if they have not already told you.** "I want to start a new collaborative project in Revit
50
+ and here is my file" is (A), said plainly. Reading it back as a four-way menu costs the user a
51
+ whole turn to answer something they just said, and that is the single most common way this guide
52
+ wastes somebody's time. Work out which one it is, say which one you took it to be in one line, and
53
+ carry on. Ask only for what is genuinely missing.
54
+
49
55
  > "What would you like to do?
50
56
  > (A) **Share** a Revit model with my team for the first time.
51
57
  > (B) **Connect** to a shared central a teammate already uploaded.
@@ -164,27 +170,30 @@ Re-run `thatopen revit status` every ten seconds or so until `"loaded": true`.
164
170
 
165
171
  ## Step 3 — Do the action
166
172
 
167
- ### (A) Share a model — `inspect`, then `publish-central`
173
+ ### (A) Share a model — one command
168
174
 
169
175
  ```
170
- thatopen revit inspect --file "<FILE>"
176
+ thatopen revit share --file "<FILE>" --project <PROJECT>
171
177
  ```
172
178
 
173
- - **`"isCentral": true`** → already a central. Tell the user you will upload a copy and their
174
- original is untouched, then:
175
- ```
176
- thatopen revit publish-central --project <PROJECT> --doc <DOC> --file "<FILE>"
177
- ```
178
- - **`"isCentral": false`** → a plain model. **Ask for consent first:** *"This file isn't a shared
179
- central yet. To share it I need to turn it into one. I'll do that on a COPY, so your original
180
- stays exactly as it is. Shall I go ahead?"* Only then:
181
- ```
182
- thatopen revit publish-central --project <PROJECT> --doc <DOC> --file "<FILE>" --convert
183
- ```
184
-
185
- Publishing uploads the central and opens **the user's own local** in Revit. Success ends with
186
- `Published (…). Central: … (version N). Your original file was not modified.` Teammates connect with
187
- the folder id from the dashboard, or with `--project` and `--doc`.
179
+ That is the whole of it. `share` installs the add-in if nothing is listening, starts Revit and
180
+ waits for it, checks whether the file is already a central, publishes it, and opens the user's own
181
+ local. It prints each of the five steps as it reaches them, so a long silence is always a step you
182
+ can name.
183
+
184
+ - **`--doc <name>`** is optional. Left out, it comes from the file name, kebab-cased.
185
+ - **`--project`** takes the dashboard URL as happily as the id.
186
+ - **The original file is never modified.** Everything happens on a copy, which is why `share` does
187
+ not need to ask before converting: there is nothing to lose. Say so rather than asking.
188
+ - **It joins at the end on purpose.** Sharing a model and then carrying on in the original file is
189
+ the one reliable way to end up outside your own team.
190
+
191
+ Steps 1 and 2 (CLI, login, add-in) still have to have happened: `share` installs the add-in but it
192
+ cannot log in for somebody.
193
+
194
+ If you need the pieces separately — a central that already exists, a different convert decision,
195
+ scripting around it — they are still there: `thatopen revit inspect --file`, then
196
+ `thatopen revit publish-central --project --doc --file [--convert]`, then `thatopen revit join`.
188
197
 
189
198
  ### (B) Connect to a central — `join`
190
199
 
@@ -258,8 +258,25 @@ to `row.data(j)`.
258
258
 
259
259
  ### Units, and why the envelope has no units field
260
260
 
261
- A proposal's numbers are in the terms of **what the target published**. Read Revit's export metadata
262
- and it says so itself:
261
+ **Print both files' metadata BEFORE you write a line of the converter.** This is an ordering
262
+ instruction, not advice. The whole conversion rests on what those two files declare, and it takes
263
+ thirty seconds to find out:
264
+
265
+ ```ts
266
+ for (const id of [rhinoFileId, revitFileId]) {
267
+ const res = await client.downloadFile(id);
268
+ const m = FRAGS.EditUtils.getModelFromBuffer(new Uint8Array(await res.arrayBuffer()), false);
269
+ console.log(id, m.metadata());
270
+ }
271
+ ```
272
+
273
+ Measured on a live demo, 2026-08-05: a converter was written the other way round, four hundred lines
274
+ first and the check afterwards, and the target's metadata turned out to be `{}`. Everything above the
275
+ units gate was correct and none of it could run. Read the two files, then write the converter around
276
+ what is actually in them.
277
+
278
+ A proposal's numbers are in the terms of **what the target published**. When Revit's export declares
279
+ anything, it says so itself:
263
280
 
264
281
  ```json
265
282
  "data": { "units": "ft", "axes": "z-up", "note": "Revit's own terms, untouched. Operations are written in these." }
@@ -269,12 +286,21 @@ Rhino publishes its document units in the same place, `model.metadata()`, under
269
286
  sample project that is `Millimeters`. So the converter divides by **304.8**, and it is the converter's
270
287
  job precisely because it is the only piece that knows both sides.
271
288
 
272
- Check the conversion against something real instead of trusting the constant. An existing column in
273
- the Revit export sits at `-30.19028688187479` ft. Times 304.8 that is **-9202 mm**, which is exactly
274
- the bay edge measured in Rhino. That is a conversion proved, not assumed.
289
+ **When the target declares nothing.** Models published by add-in versions before 1.2.27 carry empty
290
+ metadata on their first commit, so `{}` is a state you will meet on any project created before then.
291
+ Revit's internal length unit is always decimal feet, so the factor is not actually in doubt; what is
292
+ missing is the file saying so. Take feet, say out loud that you are taking it, and then **prove it
293
+ from the data** rather than leaving it as an assumption:
294
+
295
+ - Read one existing column's `Position` out of the target, and compare it against the same column
296
+ measured in the source. On the sample project: `-30.19028688187479` ft times 304.8 is **-9202 mm**,
297
+ which is exactly the bay edge measured in Rhino.
298
+ - Keep a sanity check on every converted point: refuse anything landing more than about ten metres
299
+ from an existing element of the same category. That is what catches a factor-of-1000 error, and it
300
+ works whether or not the file declared anything.
275
301
 
276
- **Refuse unknown units.** Guessing a scale factor puts the columns a thousand times too far away, and
277
- nothing about the result says why.
302
+ **What you must never do is guess silently.** A wrong factor puts the columns a thousand times too
303
+ far away, and nothing about the result says why. Refusing is a worse demo and a better outcome.
278
304
 
279
305
  ### Building the payload
280
306
 
@@ -335,6 +361,38 @@ would have built a converter against a layer name nobody was using any more.
335
361
  The tables above are here so nobody has to do that. If you still want to see real bytes, publish
336
362
  something small and read it back inside the app.
337
363
 
364
+ ### What "it builds" does not tell you
365
+
366
+ The dev build does not typecheck. `npm run build` said **111 modules, built clean** on a converter
367
+ whose `tsc --noEmit` was red in four places, and the run that produced this guide read that line as
368
+ proof and moved on. Run the typechecker separately, and read what it says.
369
+
370
+ Its complaint was worth having. A file that does not exist yet is read with a fallback, and the
371
+ fallback's shape becomes the type:
372
+
373
+ ```ts
374
+ const history = await this._readJson(id, { commits: [] }); // commits: never[]
375
+ const head = history.commits.at(-1); // head: never
376
+ ```
377
+
378
+ Every field read off `head` is then an error, which is the typechecker pointing out that the empty
379
+ case was never really thought about. Type the fallback, and decide what an empty history means.
380
+
381
+ Three more that only fail once somebody presses the button:
382
+
383
+ - **The published file name is a parameter, not a constant.** `/publish` takes `name`. A converter
384
+ that hardcodes `rhino-columns.frag` answers "publish from Rhino first" when the file is right
385
+ there under another name. If you cannot find it, list what the folder actually holds and say so.
386
+ - **A loose folder match picks silently.** Falling back from an exact name to "any folder ending in
387
+ this" is fine with one project and wrong the first time there are two, with nothing on screen to
388
+ say which one it chose.
389
+ - **The platform round trip is not exercised by anything above.** The two failures this cost were
390
+ `parentFolderId` and the version tag, and both compiled, read fine, and only spoke on the first
391
+ real click. Press it once yourself, on something small, before showing it to anybody.
392
+
393
+ Not clicking it in front of the user is still the right call. Queuing a proposal writes into a
394
+ shared project, and that is theirs to trigger. Press it in a scratch document instead.
395
+
338
396
  ## 6. Accept it in Revit
339
397
 
340
398
  The Revit add-in's local API, `%APPDATA%\ThatOpen\revit-addin.json`, header `X-RevitFlow-Token`.
@@ -389,3 +447,12 @@ lands in the history, with the new columns in it, viewable in the app like any o
389
447
  - **Version tags are timestamps, not counts.** Numbering by how many entries there are collides on
390
448
  the very first update.
391
449
  - **Stray `.frag` files on the machine are not the current format.** They are an earlier session.
450
+ - **Read the two files' metadata before writing the converter, not after.** Everything the
451
+ conversion rests on is declared in them, or is not, and finding out costs thirty seconds at the
452
+ start and four hundred lines at the end.
453
+ - **Build only what was asked for.** An extra button and an extra layout are minutes each, and they
454
+ come out of the same hour as the thing that has to work.
455
+ - **A clean build is not a typecheck.** Vite does not run `tsc`. "Built clean" and "correct" are two
456
+ different sentences.
457
+ - **Read these guides verbatim.** A fetch that summarises gives you the shape of the thing and none
458
+ of the numbers, and every number here was paid for. Use the raw URL.
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "access": "public"
16
16
  },
17
17
  "private": false,
18
- "version": "0.7.0",
18
+ "version": "0.8.0",
19
19
  "main": "dist/index.cjs.js",
20
20
  "module": "dist/index.es.js",
21
21
  "types": "dist/index.d.ts",
@@ -1,41 +1,41 @@
1
- # Building on the That Open Platform — Agent Guide
2
-
3
- You are an AI assistant helping a user build a **BIM app** or **cloud component** on the
4
- That Open Platform using **`@thatopen/services`** (the `thatopen` CLI + client library).
5
-
6
- **Read this file first, then immediately load all indexes listed below before doing anything else.**
7
-
8
- > This guide is tool-agnostic: it works with any AI (Claude, Codex, your own). All local docs it
9
- > references ship inside the `@thatopen/services` package under `docs/`.
10
-
11
- ---
12
-
13
- ## Session start — load all indexes in parallel
14
-
15
- Do this before answering any question or writing any code. These are compact description indexes:
16
-
17
- | What | Where |
18
- |---|---|
19
- | Platform built-ins | `node_modules/@thatopen/services/docs/builtin/paths.json` |
20
- | Platform client API | `node_modules/@thatopen/services/docs/client/paths.json` |
21
- | CLI commands | `node_modules/@thatopen/services/docs/cli/paths.json` |
22
- | Engine components (`OBC`, `OBF`) | `https://raw.githubusercontent.com/ThatOpen/engine_components/refs/heads/main/examples/paths.json` |
23
- | Fragments (`FRAGS`) | `https://raw.githubusercontent.com/ThatOpen/engine_fragment/refs/heads/main/examples/paths.json` |
24
- | UI components (`BUI`) | `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/examples/paths.json` — **skip** entries whose path contains `packages/obc` or `bim-grid` |
25
-
26
- Once you have these, you know everything available on the platform. Only then fetch a specific example file when you need implementation details.
27
-
28
- > **Path resolution:** all `path` entries inside the local indexes (`docs/`, `src/`) are relative to the `@thatopen/services` package root — prepend `node_modules/@thatopen/services/` when reading any of those files.
29
-
30
- ---
31
-
32
- ## How to work
33
-
34
- - **The scaffold is already a complete, working viewer** (model loading, spatial tree, properties).
35
- **Run it first** (`npm run dev`) to see it work, *then* extend it — don't rebuild a viewer from scratch.
36
- - **Propose a short plan and get the user's OK** before changing files. If scope is unclear, ask.
37
- - If something already exists in the indexes, use it — don't reimplement it.
38
-
39
- ## Hard rules (always apply)
40
-
1
+ # Building on the That Open Platform — Agent Guide
2
+
3
+ You are an AI assistant helping a user build a **BIM app** or **cloud component** on the
4
+ That Open Platform using **`@thatopen/services`** (the `thatopen` CLI + client library).
5
+
6
+ **Read this file first, then immediately load all indexes listed below before doing anything else.**
7
+
8
+ > This guide is tool-agnostic: it works with any AI (Claude, Codex, your own). All local docs it
9
+ > references ship inside the `@thatopen/services` package under `docs/`.
10
+
11
+ ---
12
+
13
+ ## Session start — load all indexes in parallel
14
+
15
+ Do this before answering any question or writing any code. These are compact description indexes:
16
+
17
+ | What | Where |
18
+ |---|---|
19
+ | Platform built-ins | `node_modules/@thatopen/services/docs/builtin/paths.json` |
20
+ | Platform client API | `node_modules/@thatopen/services/docs/client/paths.json` |
21
+ | CLI commands | `node_modules/@thatopen/services/docs/cli/paths.json` |
22
+ | Engine components (`OBC`, `OBF`) | `https://raw.githubusercontent.com/ThatOpen/engine_components/refs/heads/main/examples/paths.json` |
23
+ | Fragments (`FRAGS`) | `https://raw.githubusercontent.com/ThatOpen/engine_fragment/refs/heads/main/examples/paths.json` |
24
+ | UI components (`BUI`) | `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/examples/paths.json` — **skip** entries whose path contains `packages/obc` or `bim-grid` |
25
+
26
+ Once you have these, you know everything available on the platform. Only then fetch a specific example file when you need implementation details.
27
+
28
+ > **Path resolution:** all `path` entries inside the local indexes (`docs/`, `src/`) are relative to the `@thatopen/services` package root — prepend `node_modules/@thatopen/services/` when reading any of those files.
29
+
30
+ ---
31
+
32
+ ## How to work
33
+
34
+ - **The scaffold is already a complete, working viewer** (model loading, spatial tree, properties).
35
+ **Run it first** (`npm run dev`) to see it work, *then* extend it — don't rebuild a viewer from scratch.
36
+ - **Do the thing they asked for.** If the request already says what to build, build it. Stop and ask only when you are about to change files you did not create, when the request can be read two ways that mean different work, or when the next step is destructive.
37
+ - If something already exists in the indexes, use it — don't reimplement it.
38
+
39
+ ## Hard rules (always apply)
40
+
41
41
  1. **All UI must be built with Lit**, using the web components from `@thatopen/ui` (`BUI`) — `bim-button`, `bim-panel`, `bim-panel-section`, `bim-toolbar`, `bim-dropdown`, `bim-input`, and the rest of `packages/core`. Always consult the design system before writing any UI: `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/DESIGN.md`.
@@ -0,0 +1,191 @@
1
+ import * as THREE from "three";
2
+ import * as OBC from "@thatopen/components";
3
+ import * as OBF from "@thatopen/components-front";
4
+ import * as FRAGS from "@thatopen/fragments";
5
+ // Inlines the fragments worker so it runs inside the platform's sandboxed iframe.
6
+ import "@thatopen/fragments/inline";
7
+ import * as BUI from "@thatopen/ui";
8
+ import * as MARKERJS from "@markerjs/markerjs3";
9
+ import {
10
+ PlatformClient,
11
+ UIManager,
12
+ GitHistoryManager,
13
+ } from "@thatopen/services";
14
+ import { setAppContext } from "./app";
15
+
16
+ // The standard app, plus the revit-flow commit history: a History layout with the
17
+ // timeline beside the viewer. Scaffolded by `thatopen create <name> --beta --history`.
18
+ //
19
+ // THE BOOT ORDER IS THE WHOLE TRICK, and it is why this ships as a file rather than
20
+ // as instructions. GitHistoryManager reads the commit .frag files and colours the
21
+ // elements of a commit, so it reaches for FragmentsManager and for the 3D world the
22
+ // moment it starts. FragmentsManager is initialised inside top-app's own `setup`
23
+ // below, and the world does not exist until <top-app> is in the DOM and top-viewer
24
+ // has built it. Call `init(client)` where it reads naturally, right after
25
+ // `client.setup`, and you get "FragmentsManager not initialized" plus a pair of
26
+ // undefined `onHighlight` / `onClear` errors, none of which say "too early".
27
+ async function main() {
28
+ const client = PlatformClient.fromPlatformContext();
29
+
30
+ // Brand accent (purple) — drives layout-selector active state, dividers, etc.
31
+ document.documentElement.style.setProperty("--bim-ui_accent-base", "#6528d7");
32
+
33
+ // The dev `thatopen serve` wrapper HTML doesn't zero the UA body margin (8px),
34
+ // which insets the whole app inside the platform iframe. Kill it here so it's
35
+ // fixed in both dev and production regardless of the host page.
36
+ document.body.style.margin = "0";
37
+
38
+ // 1 ── UIManager registers the platform web components (top-app, top-viewer, …)
39
+ // before the DOM renders. GitHistoryManager is the headless half of the history
40
+ // panel: <top-git-history> is intent-only and asks the registry for it by uuid,
41
+ // so without this registration the panel mounts and stays inert.
42
+ const { components } = await client.setup<OBC.Components>(
43
+ { OBC, OBF, BUI, THREE, FRAGS, MARKERJS },
44
+ { uuid: UIManager.uuid },
45
+ { uuid: GitHistoryManager.uuid },
46
+ );
47
+ components.get(UIManager).init();
48
+
49
+ // One STABLE top-viewer node, returned by reference so re-rendering top-app
50
+ // reuses it instead of disposing and recreating its world.
51
+ const viewerEl = document.createElement("top-viewer");
52
+ viewerEl.style.border = "1px solid var(--bim-ui_bg-contrast-20)";
53
+ viewerEl.style.borderRadius = "0.75rem";
54
+ viewerEl.style.boxSizing = "border-box";
55
+
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ const app = document.createElement("top-app") as any;
58
+
59
+ // 2 ── this callback is what initialises FragmentsManager. Nothing that needs
60
+ // fragments may run before it has resolved.
61
+ app.setup = (waitUntil: (p: Promise<void>, label?: string) => void) => {
62
+ waitUntil(
63
+ (async () => {
64
+ const fragments = components.get(OBC.FragmentsManager);
65
+ const workerUrl = await FRAGS.FragmentsModels.getWorker();
66
+ fragments.init(await FRAGS.toClassicWorker(workerUrl), {
67
+ classicWorker: true,
68
+ });
69
+ })(),
70
+ "Fragments Core",
71
+ );
72
+ return { components, client };
73
+ };
74
+
75
+ // Mount minimally first (viewer only) so top-viewer creates the world.
76
+ app.elements = { viewer: () => BUI.html`${viewerEl}` };
77
+ app.layouts = {
78
+ Main: {
79
+ label: "Main",
80
+ icon: "solar:3d-square-bold",
81
+ template: `"viewer" 1fr / 1fr`,
82
+ },
83
+ };
84
+ app.layout = "Main";
85
+
86
+ const container = document.getElementById("that-open-app") ?? document.body;
87
+ container.appendChild(app);
88
+
89
+ // 3 ── the world is created asynchronously by top-viewer. Wait for it.
90
+ await firstWorld(components.get(OBC.Worlds));
91
+
92
+ // Platform client + project data for the AppManager-shim consumers.
93
+ const projectId: string | undefined = client?.context?.projectId;
94
+ let projectData;
95
+ try {
96
+ if (projectId) projectData = await client.getProjectData(projectId);
97
+ } catch {
98
+ /* dev/no-project → consumers degrade gracefully */
99
+ }
100
+ setAppContext(client, projectData);
101
+
102
+ // 4 ── ONLY NOW. Everything above had to have happened first. Read the comment
103
+ // at the top of this file before moving this line.
104
+ await components.get(GitHistoryManager).init(client);
105
+
106
+ // Pluggable loaders for <top-models-list>. Heavy, app-specific loaders are
107
+ // registered here so they stay OUT of the built-in's bundle.
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ let rcViewer: any = null;
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ const modelLoaders: Record<string, any> = {
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ "3tz": async (fileId: string, ctx: any) => {
114
+ if (!rcViewer) {
115
+ const { realityCaptureViewer } = await import(
116
+ "./setups/reality-capture-viewer"
117
+ );
118
+ rcViewer = realityCaptureViewer(ctx.components, client);
119
+ }
120
+ const saved = ctx.getAlignment(fileId) as number[] | undefined;
121
+ await rcViewer.loadIntoWorld(fileId, {
122
+ keepPostproduction: true,
123
+ transform: saved ? new THREE.Matrix4().fromArray(saved) : undefined,
124
+ onTransformChange: (m: THREE.Matrix4) =>
125
+ ctx.setAlignment(fileId, m.toArray()),
126
+ });
127
+ },
128
+ };
129
+
130
+ // The stable viewer + the panels, under named layouts with the activity-bar
131
+ // sidebar. All panels are built-ins that self-wire from top-app's contexts.
132
+ app.elements = {
133
+ viewer: () => BUI.html`${viewerEl}`,
134
+ tree: () => BUI.html`<top-model-tree></top-model-tree>`,
135
+ properties: () => BUI.html`<top-properties-panel></top-properties-panel>`,
136
+ files: () =>
137
+ BUI.html`<top-models-list .loaders=${modelLoaders}></top-models-list>`,
138
+ dataTable: () => BUI.html`<top-data-table-panel></top-data-table-panel>`,
139
+ objects: () => BUI.html`<top-objects-panel></top-objects-panel>`,
140
+ settings: () => BUI.html`<top-settings-panel></top-settings-panel>`,
141
+ history: () => BUI.html`<top-git-history></top-git-history>`,
142
+ };
143
+ app.layouts = {
144
+ Explorer: {
145
+ icon: "mdi:file-tree",
146
+ template: `"tree viewer" 1fr "properties viewer" 1fr / 22rem 1fr`,
147
+ },
148
+ Assets: {
149
+ icon: "mdi:folder-multiple-outline",
150
+ template: `"files viewer" 1fr "objects viewer" 1fr / 22rem 1fr`,
151
+ },
152
+ Data: {
153
+ icon: "mdi:table",
154
+ template: `"dataTable viewer" 1fr / 22rem 1fr`,
155
+ },
156
+ Settings: {
157
+ icon: "mdi:cog",
158
+ template: `"settings viewer" 1fr / 22rem 1fr`,
159
+ },
160
+ // Same side, same width and same shape as the others: this only swaps the panel.
161
+ History: {
162
+ icon: "at-icons:git-branch",
163
+ template: `"history viewer" 1fr / 22rem 1fr`,
164
+ },
165
+ };
166
+ // Opens on History, because that is what this app was scaffolded for.
167
+ app.layout = "History";
168
+ app.sidebar = true;
169
+
170
+ viewerEl.appendChild(document.createElement("top-viewer-toolbar"));
171
+
172
+ // The history panel lights up once a revit-flow model is loaded. Load it from the
173
+ // Assets panel, or auto-load it here if this app only ever shows one model.
174
+ }
175
+
176
+ /** Resolves with the first world once it exists (top-viewer creates it async). */
177
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
+ function firstWorld(worlds: any): Promise<any> {
179
+ const existing = [...worlds.list.values()][0];
180
+ if (existing) return Promise.resolve(existing);
181
+ return new Promise((resolve) => {
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
+ const handler = ({ value }: any) => {
184
+ worlds.list.onItemSet.remove(handler);
185
+ resolve(value);
186
+ };
187
+ worlds.list.onItemSet.add(handler);
188
+ });
189
+ }
190
+
191
+ main().catch(console.error);