@thatopen/services 0.19.0 → 0.21.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.
@@ -297,8 +297,11 @@ system: the platform relays messages between app tabs and external tools.
297
297
 
298
298
  To command the app from outside (a script, an MCP server, your own tool):
299
299
 
300
- 1. The user creates a **platform token** with the **API** permission
301
- (dashboard → Data → API Tokens). Same secrecy rules as always.
300
+ 1. The user creates a **platform token** (dashboard → Data → API Tokens). Same
301
+ secrecy rules as always. The token itself needs no special permission: what
302
+ the channel checks is the token owner's role in the project, which must allow
303
+ **API calls**. Project Admin and Member have it by default; a custom role
304
+ needs it ticked.
302
305
  2. Connect with `socket.io-client` and subscribe, then publish:
303
306
 
304
307
  ```bash
@@ -349,7 +352,36 @@ is the reply. The full walkthrough (typed contracts, collaboration events,
349
352
  presence) is `node_modules/@thatopen/services/src/core/examples/channel.ts`,
350
353
  indexed in the client API index you already loaded.
351
354
 
352
- ## 5c. The desktop MCP server: `thatopen mcp`
355
+ ## 5c. History and recipes: documents, versions, change requests
356
+
357
+ Three sentences hold the whole model. Every connected document (a Revit model,
358
+ an AutoCAD drawing) has its version history: who synced, when, what changed.
359
+ Nobody writes in somebody else's document: changes arrive as SUGGESTIONS
360
+ (create/modify/delete native data) that the owner accepts or dismisses in the
361
+ native app. A RECIPE is a script that writes suggestions in an application's
362
+ own language, from whatever input.
363
+
364
+ ```ts
365
+ const history = client.history(projectId);
366
+ const docs = await history.docs(); // the connected documents
367
+ const doc = history.doc('revit', docs[0].docId);
368
+ const timeline = await doc.timeline(); // versions, oldest first
369
+ const pending = await doc.pendingSuggestions(); // waiting for the owner
370
+
371
+ client.recipes.register(myRecipe); // manifest + one run()
372
+ await client.recipes.run('my-recipe', { // only QUEUES a suggestion:
373
+ projectId, target: { source: 'revit', docId }, // trying is always safe
374
+ input, settings, author,
375
+ });
376
+ ```
377
+
378
+ This surface is the only supported way in: the storage underneath is private
379
+ and will move. Never list or write the `bimterop` files directly. The full
380
+ walkthrough (manifest fields, StaleHistoryError on concurrent syncs, payloads)
381
+ is `node_modules/@thatopen/services/src/core/examples/history.ts`, indexed in
382
+ the client API index you already loaded.
383
+
384
+ ## 5d. The desktop MCP server: `thatopen mcp`
353
385
 
354
386
  The raw socket above is for tools you write. For an LLM on the desktop (Claude
355
387
  Desktop, Claude Code, anything that speaks MCP), the CLI ships the same bridge
@@ -35,6 +35,10 @@
35
35
  "path": "src/core/examples/hidden-files.ts",
36
36
  "description": "Hidden file lifecycle — create, get, list by parent, download, and delete."
37
37
  },
38
+ {
39
+ "path": "src/core/examples/history.ts",
40
+ "description": "History and recipes — a project's connected documents (Revit, AutoCAD…), their version timelines, change requests (suggestions), and recipes: scripts that write suggestions in an app's native language."
41
+ },
38
42
  {
39
43
  "path": "src/core/examples/icons.ts",
40
44
  "description": "Item icon management — upload, download, and remove icons on any item (file, app, or component)."
@@ -3,6 +3,13 @@
3
3
  The concrete contract. [The architecture notes](https://raw.githubusercontent.com/ThatOpen/platform_services/main/docs/flow-architecture.md) say why any of this is
4
4
  shaped the way it is; this says what the bytes look like.
5
5
 
6
+ > **Plug-ins only.** Since `@thatopen/services` 0.20, apps and scripts do not write this layout by
7
+ > hand: `client.history(projectId)` reads and writes documents, version timelines and proposals,
8
+ > and `client.recipes` runs the scripts that author them — see `src/core/examples/history.ts` in
9
+ > the package. The bytes below are the contract for NATIVE PLUG-INS (which carry their own client),
10
+ > and the reference for what the API does underneath. Anything else touching these files directly
11
+ > will break when the layout moves behind the API, which is the plan.
12
+
6
13
  Nothing here is aspirational. Every rule below is what the Revit and Rhino plugins already do, and
7
14
  was written from their implementation rather than ahead of it — which is why the awkward parts are
8
15
  stated as plainly as the tidy ones. Where a rule exists because something broke, it says so.
@@ -195,11 +195,17 @@ happened.
195
195
  Points count as items without geometry. That is correct: they are the data the converter reads, not
196
196
  something to draw.
197
197
 
198
- ## 5. The converter
198
+ ## 5. The converter (a recipe)
199
199
 
200
200
  This is the part that is **not** in either plug-in, and should not be. It reads the published
201
201
  `.frag`, decides what it means, and queues a proposal.
202
202
 
203
+ > A converter is one kind of **recipe**: a script that writes proposals in the target program's
204
+ > own words. Since `@thatopen/services` 0.20, write it against the SDK instead of the raw files:
205
+ > `client.history(projectId)` reads the queue and the timeline and queues the proposal
206
+ > (`doc.suggest`), and `client.recipes` is the registry/runner apps use to list it, render its
207
+ > settings form and run it safely. The four steps below are unchanged.
208
+
203
209
  The worked example is `RhinoColumnsConverter`: points on a named layer become Revit columns of a
204
210
  given family and type. It is written to be read and then rewritten — the next person wants walls
205
211
  from curves, or louvres from blocks — but four steps survive any rewrite:
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "access": "public"
16
16
  },
17
17
  "private": false,
18
- "version": "0.19.0",
18
+ "version": "0.21.0",
19
19
  "main": "dist/index.cjs.js",
20
20
  "module": "dist/index.es.js",
21
21
  "types": "dist/index.d.ts",
@@ -85,8 +85,37 @@ await gis.enableTiles();
85
85
  makes the see-through effect possible. On other hosts they are added to the
86
86
  scene directly and stay opaque. You do not need to do anything for this: the
87
87
  component detects it.
88
+
89
+ On those deferred hosts the tiles also dissolve gradually near the camera's far
90
+ plane instead of ending in a hard edge. `horizonFade` is the fraction of the far
91
+ distance over which this happens: with the default `0.8` and a far plane at
92
+ 1000, the tiles are fully visible up to 200 and fully transparent at 1000. It
93
+ combines with the see-through opacity, and `0` turns it off. While the map is
94
+ loaded the fade is a fraction of `mapFar` (see below), not of the camera's own
95
+ far plane.
96
+
97
+ While the tiles are loaded, the empty background behind them is also painted
98
+ as a sky: a gradient from `skyHorizonColor` (default `#d7e3ee`, a light hazy
99
+ blue) at the horizon to `skyZenithColor` (default `#6a9bd1`) straight up, so
100
+ the tiles dissolve into the horizon colour. Set `sky = false` to keep the
101
+ background as it is. Like the fade, it only exists on deferred hosts.
102
+
103
+ To let the map reach the horizon, `GISManager` takes over the far plane of the
104
+ world's cameras while the tiles are loaded: `mapFar`, `10000` by default. With
105
+ the default `horizonFade` of `0.8` the map is fully visible up to 2 km and
106
+ fades out until 10 km. A far plane the application or the user set is only
107
+ remembered while the map is loaded and comes back when `disableTiles()` runs
108
+ (a larger far set by code that grows it, for example when it loads a large
109
+ point cloud, is respected, and kept). Setting `mapFar` while the map is loaded
110
+ applies immediately. This works on every host, deferred or not.
88
111
  */
89
112
 
113
+ gis.horizonFade = 0.4;
114
+ gis.mapFar = 15000;
115
+ gis.skyHorizonColor = "#e8d9c5";
116
+ gis.skyZenithColor = new THREE.Color(0x4f86c6);
117
+ gis.sky = true;
118
+
90
119
  gis.onStateChanged.add((state) => console.log("tiles visible:", state.tilesVisible));
91
120
  gis.setTilesOpacity(0.6);
92
121
 
@@ -79,7 +79,10 @@ components.get(UIManager).init();
79
79
  ### 🤖 What the default panel manages automatically
80
80
 
81
81
  **Graphics** — point/line/edge styling, background, shadows, ambient
82
- occlusion — whatever `top-viewer` supports, persisted across reloads.
82
+ occlusion — whatever `top-viewer` supports, persisted across reloads. When the
83
+ app uses `GISManager`, a "GIS Map" group also appears, with the sky on/off, the
84
+ horizon fade (how far before the camera's far plane the map dissolves, 0 = hard
85
+ cut) and the sky's horizon and zenith colours.
83
86
 
84
87
  **Clip styling** — fill/edge color, opacity, width for the clip tool,
85
88
  shared live with `top-viewer-toolbar`'s clip button.
@@ -30,9 +30,7 @@ export interface CDENamingSchema {
30
30
  separator: string;
31
31
  segments: CDENamingSegment[];
32
32
  }
33
- // `Base` now declares `createdBy` itself (the field the API actually
34
- // returns); this narrows it to the string the CDE panels render.
35
- export interface CDEFile extends Omit<Item, 'createdBy'> {
33
+ export interface CDEFile extends Omit<Item, 'creatingUser'> {
36
34
  createdBy: string;
37
35
  metadata: Record<string, CDEMetadataValue>;
38
36
  versions?: ItemVersion[];
@@ -1668,6 +1666,64 @@ declare class _GISManager extends OBC.Component {
1668
1666
  private _tilesOpacity;
1669
1667
  private _fadeTarget;
1670
1668
  private _fadeQuad;
1669
+ private _horizonTarget;
1670
+ private _horizonQuad;
1671
+ /**
1672
+ * Fraction (0–1) of the camera's far distance over which the 3D tiles dissolve
1673
+ * into what is behind them, instead of ending in a hard edge at the far plane:
1674
+ * the tiles are fully visible up to `far * (1 - horizonFade)` and fully
1675
+ * transparent at `far`. Default 0.8. `0` turns it off (opaque tiles are drawn
1676
+ * straight into the frame at opacity 1, as before). Combines with
1677
+ * `setTilesOpacity`: final alpha = opacity × fade. Deferred hosts only — where
1678
+ * the tiles are added to the scene directly there is no fade. Vector layers
1679
+ * are not faded.
1680
+ */
1681
+ horizonFade: number;
1682
+ private readonly _far;
1683
+ private _farCameras;
1684
+ /**
1685
+ * Far plane (distance) the world's cameras get while the 3D tiles are loaded, so
1686
+ * the map reaches the horizon. Default `10000`. Changing it while the map is
1687
+ * loaded takes effect immediately. `horizonFade` is a fraction of this far while
1688
+ * the map is loaded (with `0.8`, the map fades from 2 km to 10 km).
1689
+ *
1690
+ * The camera's own far plane (e.g. the Settings panel's "Far plane") is
1691
+ * overridden while the map is loaded: it is remembered, not applied, and comes
1692
+ * back when the map is unloaded. Code that grows the far plane itself while the
1693
+ * map is loaded still works: a larger far than `mapFar` is left alone, and is
1694
+ * kept on unload.
1695
+ */
1696
+ get mapFar(): number;
1697
+ set mapFar(far: number);
1698
+ /** True while the map owns the cameras' far plane (the 3D tiles are loaded). */
1699
+ get farOverridden(): boolean;
1700
+ /** The far plane the user asked for, remembered while `farOverridden` (otherwise the cameras hold it). */
1701
+ get userCameraFar(): number;
1702
+ /** Records the far plane the user wants; not applied until the map is unloaded. Only meaningful while `farOverridden`. */
1703
+ setUserCameraFar(far: number): void;
1704
+ private _resolveFarCameras;
1705
+ /**
1706
+ * Shows a sky gradient behind everything while the 3D tiles are loaded: the
1707
+ * empty background (pixels nothing has drawn to) goes from `skyHorizonColor` at
1708
+ * the horizon to `skyZenithColor` straight up, so the tiles, dissolving with
1709
+ * `horizonFade`, melt into the horizon colour. Below the horizon it is the
1710
+ * horizon colour; an orthographic camera sees one flat colour. Default `true`;
1711
+ * `false` leaves the background untouched. Deferred hosts only — elsewhere
1712
+ * there is no sky. Drawn behind the vector layers and the tiles.
1713
+ */
1714
+ sky: boolean;
1715
+ private readonly _skyHorizon;
1716
+ private readonly _skyZenith;
1717
+ private _skyHorizonColor;
1718
+ private _skyZenithColor;
1719
+ private _skyPass;
1720
+ private static _setSkyRgb;
1721
+ /** Sky colour at the horizon (any CSS colour string, hex number or `THREE.Color`). Default `"#d7e3ee"`, a light hazy blue. */
1722
+ get skyHorizonColor(): THREE.ColorRepresentation;
1723
+ set skyHorizonColor(color: THREE.ColorRepresentation);
1724
+ /** Sky colour straight up. Default `"#6a9bd1"`, a deeper blue. */
1725
+ get skyZenithColor(): THREE.ColorRepresentation;
1726
+ set skyZenithColor(color: THREE.ColorRepresentation);
1671
1727
  private _passRegistered;
1672
1728
  private _ensureOcclusionPass;
1673
1729
  private readonly _occlusion;
@@ -0,0 +1,103 @@
1
+ // description: "History and recipes — a project's connected documents (Revit, AutoCAD…), their version timelines, change requests (suggestions), and recipes: scripts that write suggestions in an app's native language."
2
+ import { EngineServicesClient } from '../client';
3
+ import type { Recipe } from '../recipes';
4
+
5
+ // The mental model, in three sentences:
6
+ // 1. Every connected document (a Revit model, an AutoCAD drawing) has its
7
+ // version history: who synced, when, what changed.
8
+ // 2. Nobody writes in somebody else's document. Changes arrive as SUGGESTIONS
9
+ // (create / modify / delete native data) that the owner accepts or
10
+ // dismisses in the native app. That is why running any of this is safe.
11
+ // 3. A RECIPE is a script that writes suggestions in an application's own
12
+ // language. Anything that wants to change a Revit document uses a Revit
13
+ // recipe to say it in Revit's words.
14
+
15
+ export async function readTheProjectHistory(
16
+ client: EngineServicesClient,
17
+ projectId: string,
18
+ ) {
19
+ const history = client.history(projectId);
20
+
21
+ // Which documents are connected, and from which application.
22
+ const docs = await history.docs();
23
+ for (const { source, docId } of docs) {
24
+ const doc = history.doc(source, docId);
25
+
26
+ // The timeline is ordered by the version graph, oldest first, with
27
+ // `ordinal` filled in. The last entry is the head.
28
+ const timeline = await doc.timeline();
29
+ const head = timeline[timeline.length - 1];
30
+ console.log(
31
+ `${source}-${docId}: ${timeline.length} versions, head by ${head?.author}`,
32
+ );
33
+
34
+ // A version's geometry: full exports ARE the model file; ordinary
35
+ // versions carry a hidden delta.
36
+ if (head?.deltaId) await doc.delta(head);
37
+ else if (head?.full) await doc.model();
38
+
39
+ // The change requests waiting for the document's owner.
40
+ const pending = await doc.pendingSuggestions();
41
+ console.log(`${pending.length} suggestions pending`);
42
+ }
43
+ }
44
+
45
+ // A recipe is its manifest plus one function. The settingsSchema is what apps
46
+ // render as a form — anything naming an EXISTING thing must be an enum the
47
+ // app fills, so people pick instead of typing names.
48
+ const pointsToBlocks: Recipe<{ layer: string; block: string }> = {
49
+ name: 'points-to-autocad-blocks',
50
+ description: 'Places an AutoCAD block on every input point.',
51
+ to: 'autocad',
52
+ inputs: ['points'],
53
+ runtime: 'browser',
54
+ settingsSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ layer: { type: 'string' },
58
+ block: { type: 'string' },
59
+ },
60
+ required: ['layer', 'block'],
61
+ },
62
+ rules: [{ reads: 'input points', proposes: 'one block per point' }],
63
+ async run({ input, settings }) {
64
+ const points = input as { x: number; y: number; z: number }[];
65
+ // Build the suggestion payload (a .frag with envelope revitflow/op@2,
66
+ // one item per change, attributes in the TARGET's own vocabulary).
67
+ // The payload builder lives in the fragments toolkit; omitted here.
68
+ const payload = await buildOpFrag(points, settings);
69
+ return {
70
+ title: `Place ${points.length} ${settings.block} blocks`,
71
+ verb: 'add',
72
+ targets: points.length,
73
+ payload,
74
+ ruleTallies: [{ read: points.length, proposed: points.length }],
75
+ };
76
+ },
77
+ };
78
+
79
+ export async function runARecipe(
80
+ client: EngineServicesClient,
81
+ projectId: string,
82
+ ) {
83
+ client.recipes.register(pointsToBlocks);
84
+
85
+ // Catalogs filter by target application: "who can speak autocad?".
86
+ client.recipes.list({ to: 'autocad' });
87
+
88
+ // Running is always safe: it only queues a suggestion on the target
89
+ // document. Nothing changes in AutoCAD until its owner applies it there.
90
+ const { suggestion } = await client.recipes.run('points-to-autocad-blocks', {
91
+ projectId,
92
+ target: { source: 'autocad', docId: 'site-plan' },
93
+ input: [{ x: 0, y: 0, z: 0 }],
94
+ settings: { layer: 'DETECTORS', block: 'SIEMENS-FDO181' },
95
+ author: 'recipe:points-to-autocad-blocks',
96
+ });
97
+ console.log(`queued suggestion ${suggestion.opId}`);
98
+ }
99
+
100
+ declare function buildOpFrag(
101
+ points: { x: number; y: number; z: number }[],
102
+ settings: { layer: string; block: string },
103
+ ): Promise<Blob>;