@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.
- package/dist/built-in/index.d.ts +59 -1
- package/dist/cli.js +1406 -904
- package/dist/cli.js.map +1 -1
- package/dist/core/client.d.ts +14 -0
- package/dist/core/examples/history.d.ts +4 -0
- package/dist/core/history.d.ts +212 -0
- package/dist/core/recipes.d.ts +150 -0
- package/dist/index.cjs.js +2 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +1656 -1205
- package/dist/index.es.js.map +1 -1
- package/docs/ai-quickstart.md +35 -3
- package/docs/client/paths.json +4 -0
- package/docs/flow-plugin-guide.md +7 -0
- package/docs/rhino-interop-quickstart.md +7 -1
- package/package.json +1 -1
- package/src/built-in/GISManager/example.ts +29 -0
- package/src/built-in/UIManager/src/settings-panel/example.ts +4 -1
- package/src/built-in/index.ts +59 -3
- package/src/core/examples/history.ts +103 -0
package/docs/ai-quickstart.md
CHANGED
|
@@ -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**
|
|
301
|
-
|
|
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.
|
|
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
|
package/docs/client/paths.json
CHANGED
|
@@ -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
|
@@ -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.
|
package/src/built-in/index.ts
CHANGED
|
@@ -30,9 +30,7 @@ export interface CDENamingSchema {
|
|
|
30
30
|
separator: string;
|
|
31
31
|
segments: CDENamingSegment[];
|
|
32
32
|
}
|
|
33
|
-
|
|
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>;
|