@thatopen/services 0.17.0 → 0.19.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 +4 -0
- package/dist/built-in/index.d.ts +1044 -1
- package/dist/cli/commands/mcp.d.ts +3 -0
- package/dist/cli/lib/cli-client.d.ts +13 -0
- package/dist/cli/lib/config.d.ts +2 -0
- package/dist/cli/lib/zip.test.d.ts +1 -0
- package/dist/cli.js +20929 -392
- package/dist/cli.js.map +1 -0
- package/dist/core/client.d.ts +10 -6
- package/dist/core/platform-client.d.ts +17 -1
- package/dist/index.cjs.js +3 -2
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.es.js +118 -93
- package/dist/index.es.js.map +1 -0
- package/dist/types/base.d.ts +9 -1
- package/dist/types/notifications.d.ts +4 -2
- package/docs/ai-quickstart.md +58 -6
- package/docs/builtin/paths.json +16 -0
- package/docs/cli/mcp.md +41 -0
- package/docs/cli/paths.json +4 -0
- package/docs/platform-token.md +1 -1
- package/package.json +5 -2
- package/src/built-in/GISManager/example.ts +326 -0
- package/src/built-in/UIManager/src/app/examples/Sidebar/example.ts +5 -2
- package/src/built-in/UIManager/src/gis-panel/example.ts +163 -0
- package/src/built-in/UIManager/src/gis-properties-panel/example.ts +168 -0
- package/src/built-in/UIManager/src/map-panel/example.ts +189 -0
- package/src/built-in/UIManager/src/models-list/example.ts +61 -2
- package/src/built-in/UIManager/src/settings-panel/example.ts +5 -3
- package/src/built-in/UIManager/src/viewer-toolbar/example.ts +25 -1
- package/src/built-in/index.ts +1054 -1
- package/src/cli/templates/app/README.md +1 -1
package/dist/types/base.d.ts
CHANGED
|
@@ -2,7 +2,15 @@ export interface Base {
|
|
|
2
2
|
_id: ObjectId;
|
|
3
3
|
createdAt: Date;
|
|
4
4
|
updatedAt?: Date;
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The human user who performed the action (audit trail). This used to be
|
|
7
|
+
* declared as a required `creatingUser`, a field the API has never
|
|
8
|
+
* returned — the server sends `createdBy`, optionally. Matching the wire
|
|
9
|
+
* is what a type is for.
|
|
10
|
+
*/
|
|
11
|
+
createdBy?: ObjectId;
|
|
12
|
+
/** The human user who last updated the entity (audit trail). */
|
|
13
|
+
lastUpdatedBy?: ObjectId;
|
|
6
14
|
creatingToken?: ObjectId;
|
|
7
15
|
archived?: boolean;
|
|
8
16
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export type NotificationCategoryDto = 'automation' | 'invitation';
|
|
2
|
-
export type NotificationTypeDto = 'automation.run.started' | 'automation.run.finished' | 'invitation.added' | 'invitation.accepted' | 'project.role_changed'
|
|
1
|
+
export type NotificationCategoryDto = 'automation' | 'invitation' | 'file';
|
|
2
|
+
export type NotificationTypeDto = 'automation.run.started' | 'automation.run.finished' | 'invitation.added' | 'invitation.accepted' | 'project.role_changed' | 'project.transfer.proposed'
|
|
3
|
+
/** A file version landed in a project the recipient belongs to. */
|
|
4
|
+
| 'file.version_added';
|
|
3
5
|
/**
|
|
4
6
|
* One notification as the API returns it.
|
|
5
7
|
*
|
package/docs/ai-quickstart.md
CHANGED
|
@@ -51,8 +51,8 @@ Steps 0–2 are identical for both; they diverge only at `create` (step 3) and h
|
|
|
51
51
|
|
|
52
52
|
- **Node.js ≥ 18** and **npm** — verify with `node -v`.
|
|
53
53
|
- A **That Open Platform API token**. The user creates it themselves:
|
|
54
|
-
→ **https://platform.thatopen.com/dashboard →
|
|
55
|
-
(
|
|
54
|
+
→ **https://platform.thatopen.com/dashboard/data → API Tokens → Add → copy.**
|
|
55
|
+
(Account Settings — avatar menu → Account Settings → API Tokens — shows the same card.)
|
|
56
56
|
Ask the user to paste it. **Never print it back, never write it into a file, never commit it.**
|
|
57
57
|
|
|
58
58
|
## 1. Install the CLI
|
|
@@ -64,7 +64,7 @@ npm install -g @thatopen/services@latest
|
|
|
64
64
|
## 2. Log in (do this BEFORE creating the project)
|
|
65
65
|
|
|
66
66
|
Get the token from the platform dashboard — **https://platform.thatopen.com/dashboard**
|
|
67
|
-
→ **
|
|
67
|
+
→ **Data → API Tokens** → Add → copy (Account Settings shows the same card). Ask the
|
|
68
68
|
user to paste it, then run:
|
|
69
69
|
|
|
70
70
|
```bash
|
|
@@ -81,6 +81,22 @@ first: the next step's install pulls the private `@thatopen-platform/*-beta` pac
|
|
|
81
81
|
`create` uses your logged-in token to write an authenticated `.npmrc` so that install can
|
|
82
82
|
resolve them. No npm account or manual npm token is involved.
|
|
83
83
|
|
|
84
|
+
### The two credentials, side by side (misusing them is the classic first failure)
|
|
85
|
+
|
|
86
|
+
| | **Platform API token** | **User session JWT** |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| Where it comes from | Dashboard → Data → API Tokens | The platform session (Auth0); inside an app, `PlatformClient.fromPlatformContext()` |
|
|
89
|
+
| Lifetime | Long-lived until revoked | Hours |
|
|
90
|
+
| Client | `EngineServicesClient` | `PlatformClient` (extends the other, so it can do both) |
|
|
91
|
+
| Can | Files, folders, versions, components, executions — anything with an explicit `projectId` | All of that **plus** the JWT-only routes: `listProjects`, `getProject`, `getProjectData`, `checkPermission`, notifications |
|
|
92
|
+
| Cannot | The JWT-only routes above — they identify a *person*, a token identifies a *token* | Run unattended: it expires with the session |
|
|
93
|
+
| How it travels | `Authorization: Bearer` (the CLI does this for you); the `?accessToken=` query form is legacy | `Authorization: Bearer` |
|
|
94
|
+
|
|
95
|
+
The failure mode this table prevents: building a service on an API token and discovering
|
|
96
|
+
that `checkPermission` or `listProjects` answers 401 — not because of a bug, but because
|
|
97
|
+
those questions are only answerable about a person. If a feature needs them unattended,
|
|
98
|
+
that is a platform gap to report, not a header to keep fiddling with.
|
|
99
|
+
|
|
84
100
|
## 3. Scaffold a beta project
|
|
85
101
|
|
|
86
102
|
**[app]**
|
|
@@ -190,7 +206,11 @@ Copy this skeleton. It is the boot of an app that works.
|
|
|
190
206
|
async function main() {
|
|
191
207
|
const client = PlatformClient.fromPlatformContext();
|
|
192
208
|
|
|
193
|
-
// 1 ── built-ins registered, UI shell up
|
|
209
|
+
// 1 ── built-ins registered, UI shell up. This is the FULL globals set a
|
|
210
|
+
// built-in may ask for: OBC, OBF, BUI, THREE, FRAGS, MARKERJS (plus
|
|
211
|
+
// CUI only if your app still uses the deprecated @thatopen/ui-obc).
|
|
212
|
+
// Only OBC and BUI are strictly required, but a built-in that needs
|
|
213
|
+
// a missing one fails at init, so pass everything you import.
|
|
194
214
|
const { components } = await client.setup<OBC.Components>(
|
|
195
215
|
{ OBC, OBF, BUI, THREE, FRAGS, MARKERJS },
|
|
196
216
|
{ uuid: UIManager.uuid },
|
|
@@ -278,7 +298,7 @@ system: the platform relays messages between app tabs and external tools.
|
|
|
278
298
|
To command the app from outside (a script, an MCP server, your own tool):
|
|
279
299
|
|
|
280
300
|
1. The user creates a **platform token** with the **API** permission
|
|
281
|
-
(dashboard →
|
|
301
|
+
(dashboard → Data → API Tokens). Same secrecy rules as always.
|
|
282
302
|
2. Connect with `socket.io-client` and subscribe, then publish:
|
|
283
303
|
|
|
284
304
|
```bash
|
|
@@ -288,7 +308,13 @@ npm i socket.io-client # in the external tool's own project, not the app
|
|
|
288
308
|
```js
|
|
289
309
|
import { io } from "socket.io-client";
|
|
290
310
|
|
|
291
|
-
|
|
311
|
+
// WebSocket-only matters: the default long-polling handshake spreads several
|
|
312
|
+
// HTTP requests across the platform's load-balanced instances and can die
|
|
313
|
+
// before it upgrades. The token rides the auth payload, not the URL.
|
|
314
|
+
const socket = io("https://platform.thatopen.com", {
|
|
315
|
+
transports: ["websocket"],
|
|
316
|
+
auth: { accessToken: process.env.THATOPEN_TOKEN },
|
|
317
|
+
});
|
|
292
318
|
// While iterating with `thatopen serve`, OMIT appId (the dev channel is keyed
|
|
293
319
|
// to the account); pass the appId once the app is published.
|
|
294
320
|
socket.emit("channelSubscribe", { projectId, appId, kind: "cli" });
|
|
@@ -323,6 +349,32 @@ is the reply. The full walkthrough (typed contracts, collaboration events,
|
|
|
323
349
|
presence) is `node_modules/@thatopen/services/src/core/examples/channel.ts`,
|
|
324
350
|
indexed in the client API index you already loaded.
|
|
325
351
|
|
|
352
|
+
## 5c. The desktop MCP server: `thatopen mcp`
|
|
353
|
+
|
|
354
|
+
The raw socket above is for tools you write. For an LLM on the desktop (Claude
|
|
355
|
+
Desktop, Claude Code, anything that speaks MCP), the CLI ships the same bridge
|
|
356
|
+
as a ready-made MCP server over stdio:
|
|
357
|
+
|
|
358
|
+
```json
|
|
359
|
+
{
|
|
360
|
+
"mcpServers": {
|
|
361
|
+
"thatopen-platform": {
|
|
362
|
+
"command": "npx",
|
|
363
|
+
"args": ["-y", "@thatopen/services", "mcp"]
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Credentials come from `thatopen login` (`~/.thatopen/config.json`); to inject
|
|
370
|
+
them instead, set `THATOPEN_TOKEN` and `THATOPEN_API_URL` in the server's
|
|
371
|
+
`env`. Two tools: `platform-status` (can the platform be reached, is the
|
|
372
|
+
subscription alive) and `send-app-command` (publish a command to a running
|
|
373
|
+
app — `ping`, `get-loaded-models`, or whatever the app declares — and return
|
|
374
|
+
its reply). The project is named per call, by id or dashboard URL, so one MCP
|
|
375
|
+
session can drive whichever project the user has open; while iterating with
|
|
376
|
+
`thatopen serve`, `app_id` is omitted, same rule as the raw socket.
|
|
377
|
+
|
|
326
378
|
## 6. Publish (when ready)
|
|
327
379
|
|
|
328
380
|
```bash
|
package/docs/builtin/paths.json
CHANGED
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"path": "src/built-in/ClashesManager/example.ts",
|
|
40
40
|
"description": "ClashesManager — Structural engineers and BIM coordinators routinely detect geometric clashes between disciplines — structural steel penetrating ductwork, pipes clashing with slabs — and need to track the status of each issue through resolution without leaving their BIM environment."
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
"path": "src/built-in/GISManager/example.ts",
|
|
44
|
+
"description": "GISManager — Georeferenced BIM projects need their surroundings: the real terrain and buildings around the model, and the project's GIS layers (parcels, networks, boundaries) drawn at the right place in the 3D scene and on a 2D map."
|
|
45
|
+
},
|
|
42
46
|
{
|
|
43
47
|
"path": "src/built-in/ProjectManager/example.ts",
|
|
44
48
|
"description": "ProjectManager — Project-level configuration and data — GIS origin, georeferencing, app settings, and other project-scoped concerns — need a single canonical place to live. Without it, every builtin that needs to persist something at the project level (not per-file, not per-user) ends up inventing its own file, its own save/load pattern, and its own place to put it."
|
|
@@ -87,10 +91,22 @@
|
|
|
87
91
|
"path": "src/built-in/UIManager/src/clashes-panel/example.ts",
|
|
88
92
|
"description": "clashes-panel — BIM coordinators need a dedicated review environment where they can see all detected clashes, filter by status, navigate to each issue in the 3D viewer, and mark items as resolved — without writing any UI code themselves."
|
|
89
93
|
},
|
|
94
|
+
{
|
|
95
|
+
"path": "src/built-in/UIManager/src/gis-panel/example.ts",
|
|
96
|
+
"description": "gis-panel — A georeferenced project is much easier to review with its real surroundings around the model, and with the GIS layers of the site (networks, parcels, boundaries) switched on and off from a list. Streaming the terrain and buildings needs a Cesium Ion token and a way to load and unload them, and fine-tuning their position needs a few controls."
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"path": "src/built-in/UIManager/src/gis-properties-panel/example.ts",
|
|
100
|
+
"description": "gis-properties-panel — A GIS feature (a pipe, a parcel, a manhole) is useless without its attributes: diameter, material, owner, installation date. Users want to click a feature, in the 3D view or on the map, and read them."
|
|
101
|
+
},
|
|
90
102
|
{
|
|
91
103
|
"path": "src/built-in/UIManager/src/git-history-panel/example.ts",
|
|
92
104
|
"description": "git-history-panel — `top-git-history` is a GitKraken-style commit timeline for the **revit-flow** feature. It lists the commits pushed by the Revit add-in (newest first) and, when you click a commit, drives the shared 3D viewer to color that commit's changed elements — created green, modified blue, deleted red — on the loaded baseline model."
|
|
93
105
|
},
|
|
106
|
+
{
|
|
107
|
+
"path": "src/built-in/UIManager/src/map-panel/example.ts",
|
|
108
|
+
"description": "map-panel — In a large site, the 3D camera alone is a poor way to get oriented: users want a plan view to see where they are, where the GIS features are, and to jump somewhere else with a click."
|
|
109
|
+
},
|
|
94
110
|
{
|
|
95
111
|
"path": "src/built-in/UIManager/src/models-list/example.ts",
|
|
96
112
|
"description": "top-models-list — top-models-list is the project's 3D-viewable-files tree: it mirrors the real storage folder structure, converts IFC to fragments, and loads `.frag`/`.potree`/`.splat` models into the 3D scene. Its loaders are PLUGGABLE — the panel owns the UI and the orchestration (progress, refresh, association), but the HOST decides how each extra file format is loaded, so you can teach it new formats without forking the built-in."
|
package/docs/cli/mcp.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Start an MCP server over stdio that lets an LLM on the desktop (Claude Desktop, Claude Code, any MCP client) drive a running That Open app through the platform channel: send it commands and read the replies."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## thatopen mcp
|
|
6
|
+
|
|
7
|
+
Runs an MCP (Model Context Protocol) server on stdio. Register it in an MCP
|
|
8
|
+
client's config and the LLM can command a running That Open app through the
|
|
9
|
+
platform channel — the same relay the app's `src/setups/channel.ts` listens
|
|
10
|
+
to, as one external channel of kind `mcp`.
|
|
11
|
+
|
|
12
|
+
**Usage (in the MCP client's config, not a terminal):**
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"thatopen-platform": {
|
|
18
|
+
"command": "npx",
|
|
19
|
+
"args": ["-y", "@thatopen/services", "mcp"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Credentials:** resolved like every other CLI command — the project's
|
|
26
|
+
`.thatopen`, then `~/.thatopen/config.json` from `thatopen login`. Or inject
|
|
27
|
+
`THATOPEN_TOKEN` and `THATOPEN_API_URL` through the MCP server's `env`.
|
|
28
|
+
|
|
29
|
+
**Tools exposed:**
|
|
30
|
+
|
|
31
|
+
- `platform-status` — which API the server points at, whether credentials were
|
|
32
|
+
found, and whether the channel subscription works. The first call to make
|
|
33
|
+
when another tool fails.
|
|
34
|
+
- `send-app-command` — publish a command (`ping`, `get-loaded-models`, or
|
|
35
|
+
whatever the app declares in `AppCommands`) to a running app and return its
|
|
36
|
+
reply. Takes the project per call (id or dashboard URL); `app_id` is omitted
|
|
37
|
+
while the app runs under `thatopen serve` and passed once it is published.
|
|
38
|
+
|
|
39
|
+
**The app must be open.** `delivered: 0` means no tab of that app has the
|
|
40
|
+
channel joined — the LLM is told to ask the user to open or reload the app
|
|
41
|
+
tab, the same rule as the raw socket in the quickstart's section 5b.
|
package/docs/cli/paths.json
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"path": "docs/cli/login.md",
|
|
12
12
|
"description": "Authenticate with the ThatOpen platform by validating an access token against the API. Saves credentials globally (~/.thatopen/config.json) by default, or locally to the project's .thatopen file with --local. Local config takes priority over global."
|
|
13
13
|
},
|
|
14
|
+
{
|
|
15
|
+
"path": "docs/cli/mcp.md",
|
|
16
|
+
"description": "Start an MCP server over stdio that lets an LLM on the desktop (Claude Desktop, Claude Code, any MCP client) drive a running That Open app through the platform channel: send it commands and read the replies."
|
|
17
|
+
},
|
|
14
18
|
{
|
|
15
19
|
"path": "docs/cli/publish.md",
|
|
16
20
|
"description": "Build and upload the project to the platform. Automatically detects app vs cloud component from the local .thatopen config. On first publish, saves the assigned item ID back to .thatopen so subsequent runs update the same item instead of creating a new one."
|
package/docs/platform-token.md
CHANGED
|
@@ -8,7 +8,7 @@ here rather than in each of them.
|
|
|
8
8
|
Say this to the user, and wait:
|
|
9
9
|
|
|
10
10
|
> "To log in I need a That Open Platform **access token**. You create it yourself:
|
|
11
|
-
> **https://platform.thatopen.com/dashboard →
|
|
11
|
+
> **https://platform.thatopen.com/dashboard/data → API Tokens → Add → copy** (Account Settings shows the same card), then paste it
|
|
12
12
|
> here."
|
|
13
13
|
|
|
14
14
|
**Never print it back, never write it into a file, never commit it.** Not into a script, not into a
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"access": "public"
|
|
16
16
|
},
|
|
17
17
|
"private": false,
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.19.0",
|
|
19
19
|
"main": "dist/index.cjs.js",
|
|
20
20
|
"module": "dist/index.es.js",
|
|
21
21
|
"types": "dist/index.d.ts",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@changesets/cli": "^2.27.7",
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
51
52
|
"@thatopen/components": "~3.4.0",
|
|
52
53
|
"@thatopen/components-front": "~3.4.0",
|
|
53
54
|
"@thatopen/fragments": "~3.4.0",
|
|
@@ -70,9 +71,11 @@
|
|
|
70
71
|
"vite": "^5.2.0",
|
|
71
72
|
"vite-plugin-dts": "^3.9.1",
|
|
72
73
|
"vite-plugin-eslint": "^1.8.1",
|
|
73
|
-
"vitest": "^4.1.4"
|
|
74
|
+
"vitest": "^4.1.4",
|
|
75
|
+
"zod": "^3.25.76"
|
|
74
76
|
},
|
|
75
77
|
"dependencies": {
|
|
78
|
+
"@types/geojson": "7946.0.16",
|
|
76
79
|
"dotenv": "^16.4.5",
|
|
77
80
|
"socket.io": "^4.8.0",
|
|
78
81
|
"socket.io-client": "^4.8.0"
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/* MD
|
|
2
|
+
## GISManager
|
|
3
|
+
---
|
|
4
|
+
Georeferenced BIM projects need their surroundings: the real terrain and
|
|
5
|
+
buildings around the model, and the project's GIS layers (parcels, networks,
|
|
6
|
+
boundaries) drawn at the right place in the 3D scene and on a 2D map.
|
|
7
|
+
|
|
8
|
+
GISManager is the component that owns all of that. It streams Cesium Ion 3D
|
|
9
|
+
Tiles (Google Photorealistic 3D Tiles by default) around the project origin,
|
|
10
|
+
loads vector layers (shapefile zip or GeoJSON) declared in the project's
|
|
11
|
+
layer manifest, and renders a 2D minimap with markers.
|
|
12
|
+
|
|
13
|
+
This tutorial covers initializing the manager from the platform client;
|
|
14
|
+
enabling the 3D tiles; the layer manifest and how heights, styles and
|
|
15
|
+
coordinate systems are read; selecting and focusing features; and the 2D
|
|
16
|
+
minimap with its markers. The recommended UI is provided by `top-gis-panel`,
|
|
17
|
+
`top-map-panel` and `top-gis-properties-panel`.
|
|
18
|
+
|
|
19
|
+
GISManager reads the project origin from `ProjectManager`, so initialize
|
|
20
|
+
`ProjectManager` first.
|
|
21
|
+
|
|
22
|
+
### 🖖 Importing our libraries
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as OBC from "@thatopen/components";
|
|
26
|
+
import * as OBF from "@thatopen/components-front";
|
|
27
|
+
import * as BUI from "@thatopen/ui";
|
|
28
|
+
import * as THREE from "three";
|
|
29
|
+
import * as FRAGS from "@thatopen/fragments";
|
|
30
|
+
import { PlatformClient, GISManager, ProjectManager } from "@thatopen/services";
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/* MD
|
|
34
|
+
### 🚀 Initializing GISManager
|
|
35
|
+
Both components are set up through the platform client.
|
|
36
|
+
`client.setup(...)` resolves the shared `OBC.Components` instance.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const client = PlatformClient.fromPlatformContext();
|
|
40
|
+
|
|
41
|
+
const { components } = (await client.setup(
|
|
42
|
+
{ OBC, OBF, BUI, THREE, FRAGS },
|
|
43
|
+
{ uuid: ProjectManager.uuid },
|
|
44
|
+
{ uuid: GISManager.uuid },
|
|
45
|
+
)) as { components: OBC.Components };
|
|
46
|
+
|
|
47
|
+
await components.get(ProjectManager).init(client);
|
|
48
|
+
|
|
49
|
+
const gis = components.get(GISManager);
|
|
50
|
+
|
|
51
|
+
/* MD
|
|
52
|
+
`init(client)` restores the project origin from `ProjectManager` and reads
|
|
53
|
+
the project's layer manifest (see below). It never throws: with no project or
|
|
54
|
+
no stored file the manager just keeps its defaults, and an empty layer list.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
await gis.init(client);
|
|
58
|
+
console.log("layers:", gis.vectorLayersConfig);
|
|
59
|
+
|
|
60
|
+
/* MD
|
|
61
|
+
### 🌍 3D tiles
|
|
62
|
+
Streaming the tiles needs a Cesium Ion token. The component does not persist
|
|
63
|
+
the token anywhere: your application provides it (from its own settings, or
|
|
64
|
+
typed by the user in `top-gis-panel`) before enabling the tiles.
|
|
65
|
+
|
|
66
|
+
`enableTiles()` resolves once the first tiles are loaded and rejects with a
|
|
67
|
+
readable message if the token is invalid or has no access to the tileset (the
|
|
68
|
+
same message is also fired through `onLoadError`).
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
gis.onLoadError.add((message) => console.warn("GIS tiles:", message));
|
|
72
|
+
|
|
73
|
+
gis.apiToken = "<your Cesium Ion token>";
|
|
74
|
+
await gis.enableTiles();
|
|
75
|
+
|
|
76
|
+
/* MD
|
|
77
|
+
`onStateChanged` fires whenever the token, origin, tiles visibility, tiles
|
|
78
|
+
offset or tiles opacity change, with a plain snapshot of all of them.
|
|
79
|
+
`setTilesOpacity(0.5)` makes the tiles see-through (1 = opaque), which lets
|
|
80
|
+
you see vector layers that run underground. `disableTiles()` unloads
|
|
81
|
+
everything.
|
|
82
|
+
|
|
83
|
+
On hosts whose engine supports the deferred postproduction pipeline, the
|
|
84
|
+
tiles and the vector layers are drawn through an occlusion pass, which is what
|
|
85
|
+
makes the see-through effect possible. On other hosts they are added to the
|
|
86
|
+
scene directly and stay opaque. You do not need to do anything for this: the
|
|
87
|
+
component detects it.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
gis.onStateChanged.add((state) => console.log("tiles visible:", state.tilesVisible));
|
|
91
|
+
gis.setTilesOpacity(0.6);
|
|
92
|
+
|
|
93
|
+
/* MD
|
|
94
|
+
A small East/North/Up correction (in metres) can be applied to the tiles to
|
|
95
|
+
compensate for the tileset's own positioning error. Persist it with
|
|
96
|
+
`saveTilesOffset()`; it is stored in the same file as the layer manifest and
|
|
97
|
+
restored by `init`.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
gis.onOffsetSaveComplete.add((success) => console.log("offset saved:", success));
|
|
101
|
+
gis.setTilesOffset(0.5, -0.3, 0);
|
|
102
|
+
gis.saveTilesOffset();
|
|
103
|
+
|
|
104
|
+
/* MD
|
|
105
|
+
### 🗂️ The layer manifest
|
|
106
|
+
The vector layers of a project are declared in one JSON file,
|
|
107
|
+
`{GISManager.uuid}.json`, stored loose in the project's `__project_data`
|
|
108
|
+
folder. `init(client)` reads it into `gis.vectorLayersConfig`; it only holds
|
|
109
|
+
metadata (the geometry stays in the layer's own file, referenced by `fileId`
|
|
110
|
+
and downloaded when the layer is loaded).
|
|
111
|
+
|
|
112
|
+
There is no user interface to register layers yet: the manifest is written by
|
|
113
|
+
your application (or by hand). The component only reads it, and rewrites it
|
|
114
|
+
(keeping `layers` as they are) when the tiles offset is saved.
|
|
115
|
+
|
|
116
|
+
Each entry of `layers` has:
|
|
117
|
+
|
|
118
|
+
- `id` — unique id, used by every API below.
|
|
119
|
+
- `name` — display name (shown by the panels).
|
|
120
|
+
- `fileId` — id of the layer's file in the project.
|
|
121
|
+
- `type` — `"shapefile"` (a zip with the `.shp`, `.dbf`, `.prj`… files) or
|
|
122
|
+
`"geojson"`. If it is missing or unrecognised the downloaded bytes decide:
|
|
123
|
+
a zip signature means shapefile, a leading `{` means GeoJSON.
|
|
124
|
+
- `style` — `stroke` (CSS colour), `stroke-width` (pixels), `fill` (CSS
|
|
125
|
+
colour, polygons) and `fill-opacity` (0 to 1). All optional.
|
|
126
|
+
- `height` — optional, how features get their height (next section).
|
|
127
|
+
|
|
128
|
+
Here is a complete manifest with a points layer whose vertices carry their
|
|
129
|
+
own height, and a lines layer whose heights come from its attributes:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"layers": [
|
|
134
|
+
{
|
|
135
|
+
"id": "manholes",
|
|
136
|
+
"name": "Manholes",
|
|
137
|
+
"fileId": "<file id of manholes.zip>",
|
|
138
|
+
"type": "shapefile",
|
|
139
|
+
"style": { "stroke": "#ffb300", "stroke-width": 6 },
|
|
140
|
+
"height": { "source": "geometry" }
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"id": "pipes",
|
|
144
|
+
"name": "Pipes",
|
|
145
|
+
"fileId": "<file id of pipes.geojson>",
|
|
146
|
+
"type": "geojson",
|
|
147
|
+
"style": { "stroke": "#1e88e5", "stroke-width": 3 },
|
|
148
|
+
"height": {
|
|
149
|
+
"source": "attributes",
|
|
150
|
+
"startProperty": "z_start",
|
|
151
|
+
"endProperty": "z_end",
|
|
152
|
+
"startDepthProperty": "depth_start",
|
|
153
|
+
"endDepthProperty": "depth_end",
|
|
154
|
+
"nodeLayers": ["manholes"],
|
|
155
|
+
"nodeSnapDistance": 0.5
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
"tilesOffset": { "east": 0.5, "north": -0.3, "up": 0 }
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Once `init` has read it, every layer can be loaded and unloaded by id.
|
|
164
|
+
`loadVectorLayer` downloads and parses the file; it never throws, failures
|
|
165
|
+
are reported through `onVectorLayerError`.
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
gis.onLayersLoaded.add((layers) => console.log("manifest read:", layers.length, "layers"));
|
|
169
|
+
gis.onVectorLayerError.add(({ id, message }) => console.warn(`Layer ${id}:`, message));
|
|
170
|
+
|
|
171
|
+
await gis.loadVectorLayer("manholes");
|
|
172
|
+
await gis.loadVectorLayer("pipes");
|
|
173
|
+
|
|
174
|
+
console.log("loaded:", gis.vectorLayers.isLayerLoaded("pipes"));
|
|
175
|
+
|
|
176
|
+
gis.unloadVectorLayer("manholes");
|
|
177
|
+
|
|
178
|
+
/* MD
|
|
179
|
+
### 📏 Layer heights
|
|
180
|
+
The `height` option of a layer decides at what height its features are drawn:
|
|
181
|
+
|
|
182
|
+
- **No `height`** — the layer is drawn flat, at the origin's height.
|
|
183
|
+
- **`{ "source": "geometry" }`** — every vertex is placed at its own Z (the
|
|
184
|
+
third GeoJSON coordinate). If any vertex of the layer has no Z, the whole
|
|
185
|
+
layer is drawn flat instead (with a console warning).
|
|
186
|
+
- **`{ "source": "attributes", … }`** — for line features only (other
|
|
187
|
+
geometry types are drawn flat). A line gets a height at its first vertex
|
|
188
|
+
and at its last one, and the vertices in between are interpolated along
|
|
189
|
+
the line. Each end is resolved in this order:
|
|
190
|
+
1. its own height property (`startProperty` / `endProperty`);
|
|
191
|
+
2. otherwise the surface height of the nearest node minus its depth
|
|
192
|
+
property (`startDepthProperty` / `endDepthProperty`). Nodes are the
|
|
193
|
+
points with a Z found in the layers listed in `nodeLayers`, and
|
|
194
|
+
"nearest" means within `nodeSnapDistance` metres (0.5 by default) of
|
|
195
|
+
the line end;
|
|
196
|
+
3. otherwise the height resolved for the other end (the line is drawn
|
|
197
|
+
level);
|
|
198
|
+
4. otherwise the surface height of the nearest node.
|
|
199
|
+
|
|
200
|
+
A property counts as missing when it is not a finite number or is exactly
|
|
201
|
+
`0`. Rules 1 and 2 are tried on both ends before rule 3 is applied, so a
|
|
202
|
+
copied height never comes from another copy. A line for which no rule can
|
|
203
|
+
give a height to both ends is not drawn (one console warning per layer
|
|
204
|
+
summarizes how many).
|
|
205
|
+
|
|
206
|
+
All heights are orthometric (the usual survey heights above the geoid). The
|
|
207
|
+
component adds the project's geoid undulation, taken from the `ProjectManager`
|
|
208
|
+
CRS, to place them in the scene, and re-places the loaded layers if it changes.
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
/* MD
|
|
212
|
+
### 🧭 GeoJSON coordinate systems
|
|
213
|
+
Shapefiles are read with their `.prj`. For GeoJSON layers, the coordinate
|
|
214
|
+
system comes from the legacy `crs` member of the file:
|
|
215
|
+
|
|
216
|
+
- Longitude/latitude (`CRS84`, or `EPSG:4326`) is read as it is.
|
|
217
|
+
- `EPSG:3857` and the UTM zones (`EPSG:326xx`, `327xx`, `258xx` and `269xx`)
|
|
218
|
+
are reprojected to longitude/latitude when the layer loads.
|
|
219
|
+
- Any other declared CRS is rejected, and so are projected coordinates in a
|
|
220
|
+
file that declares no `crs`.
|
|
221
|
+
|
|
222
|
+
A rejected layer is not loaded, and the reason (for example `Unsupported CRS
|
|
223
|
+
"EPSG:2154"…`) is delivered through `onVectorLayerError`, which is what the
|
|
224
|
+
GIS panel shows as a toast.
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
/* MD
|
|
228
|
+
### 🖱️ Selection and focus
|
|
229
|
+
Clicking a feature in the 3D view selects it. `onFeatureSelected` delivers
|
|
230
|
+
the selection (`layerId`, `layerName`, `featureIndex`, `geometryType` and the
|
|
231
|
+
feature's `properties`), or `null` when the selection is cleared. The current
|
|
232
|
+
selection can also be read at any time from `gis.vectorLayers.selection`.
|
|
233
|
+
|
|
234
|
+
Your application can select a feature too, optionally flying the camera to
|
|
235
|
+
it. `onFocusRequested` fires with the bounding sphere the camera is sent to,
|
|
236
|
+
which is how the 2D map follows.
|
|
237
|
+
*/
|
|
238
|
+
|
|
239
|
+
gis.onFeatureSelected.add((selection) => {
|
|
240
|
+
if (!selection) return console.log("selection cleared");
|
|
241
|
+
console.log(selection.layerName, selection.geometryType, selection.properties);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
gis.onFocusRequested.add(({ layerId, featureIndex, sphere }) => {
|
|
245
|
+
console.log("focusing", layerId, featureIndex, "radius:", sphere.radius);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
gis.selectVectorFeature("pipes", 0, { focus: true });
|
|
249
|
+
gis.vectorLayers.clearSelection();
|
|
250
|
+
|
|
251
|
+
/* MD
|
|
252
|
+
### 🗺️ The 2D minimap
|
|
253
|
+
`gis.minimap` renders a top-down, north-up image of the 3D tiles that
|
|
254
|
+
`top-map-panel` draws together with the vector layers, the camera and the
|
|
255
|
+
markers.
|
|
256
|
+
|
|
257
|
+
`captureMap()` takes the picture. The 3D tiles must be loaded (without them
|
|
258
|
+
the image is empty). The captured area is the bounding box of all the
|
|
259
|
+
manifest's layers, whether they are displayed or not; with no usable layers it
|
|
260
|
+
is a square of `2 * halfExtent` metres (250 by default) centred on the origin.
|
|
261
|
+
The image has a fixed side of `resolution` pixels (2048 by default, limited by
|
|
262
|
+
the GPU), so the larger the area, the coarser the map.
|
|
263
|
+
*/
|
|
264
|
+
|
|
265
|
+
gis.minimap.onCaptured.add((capture) => console.log("map captured:", capture.size, "px"));
|
|
266
|
+
|
|
267
|
+
gis.minimap.halfExtent = 400;
|
|
268
|
+
gis.minimap.resolution = 4096;
|
|
269
|
+
const capture = await gis.minimap.captureMap();
|
|
270
|
+
document.body.appendChild(capture.image);
|
|
271
|
+
|
|
272
|
+
/* MD
|
|
273
|
+
`worldToMap` converts a scene position into normalized image coordinates
|
|
274
|
+
(0 to 1, origin at the top-left), and `mapToWorld` does the opposite for a
|
|
275
|
+
horizontal plane at a given height. Both return `null` until a map has been
|
|
276
|
+
captured.
|
|
277
|
+
*/
|
|
278
|
+
|
|
279
|
+
const point = gis.vectorLayers.geoToWorld(gis.latitude, gis.longitude, gis.height);
|
|
280
|
+
const onMap = gis.minimap.worldToMap(point);
|
|
281
|
+
console.log("origin on the map:", onMap);
|
|
282
|
+
console.log("back to the scene:", onMap && gis.minimap.mapToWorld(onMap.x, onMap.y, point.y));
|
|
283
|
+
|
|
284
|
+
/* MD
|
|
285
|
+
### 📍 Minimap markers
|
|
286
|
+
Markers are HTML elements drawn over the map by `top-map-panel`. They live in
|
|
287
|
+
the minimap, not in the panel, so they survive the panel being recreated.
|
|
288
|
+
|
|
289
|
+
A marker has an `id`, a scene `position` (read on every redraw, so changing
|
|
290
|
+
it moves the marker), an optional `label` and an optional `createElement`
|
|
291
|
+
factory. Without the factory the panel draws a default badge with the label
|
|
292
|
+
and reports its clicks through `onMarkerClicked`. With it, the marker is your
|
|
293
|
+
own element and it owns its click behaviour. It is a factory and not an
|
|
294
|
+
element because each panel needs its own DOM node.
|
|
295
|
+
|
|
296
|
+
`geoToWorld(lat, lon, height)` turns a geographic position into a scene
|
|
297
|
+
position.
|
|
298
|
+
*/
|
|
299
|
+
|
|
300
|
+
gis.minimap.onMarkersChanged.add(({ kind, id }) => console.log("markers:", kind, id));
|
|
301
|
+
gis.minimap.onMarkerClicked.add((id) => console.log("clicked:", id));
|
|
302
|
+
|
|
303
|
+
gis.minimap.addMarker({
|
|
304
|
+
id: "site-office",
|
|
305
|
+
label: "Site office",
|
|
306
|
+
position: gis.vectorLayers.geoToWorld(gis.latitude, gis.longitude, gis.height),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
gis.minimap.addMarker({
|
|
310
|
+
id: "crane",
|
|
311
|
+
position: new THREE.Vector3(-20, 0, 35),
|
|
312
|
+
createElement: () => {
|
|
313
|
+
const pin = document.createElement("div");
|
|
314
|
+
pin.textContent = "🏗️";
|
|
315
|
+
pin.style.fontSize = "1.4rem";
|
|
316
|
+
pin.style.cursor = "pointer";
|
|
317
|
+
pin.onclick = () => console.log("crane clicked");
|
|
318
|
+
return pin;
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
gis.minimap.updateMarker("site-office", { label: "Main office" });
|
|
323
|
+
console.log("markers:", [...gis.minimap.markers.keys()]);
|
|
324
|
+
|
|
325
|
+
gis.minimap.removeMarker("crane");
|
|
326
|
+
gis.minimap.clearMarkers();
|
|
@@ -73,8 +73,11 @@ app.elements = {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
app.layouts = {
|
|
76
|
-
// Each layout defines its own CSS grid template.
|
|
77
|
-
//
|
|
76
|
+
// Each layout defines its own CSS grid template. The app mounts the UNION
|
|
77
|
+
// of every layout's areas ONCE and switching layouts toggles which are
|
|
78
|
+
// visible (the rest stay mounted with display:none), so panel state
|
|
79
|
+
// survives a switch - element factories run on mount, not per layout.
|
|
80
|
+
// To react to a switch, listen for the `top:layout-changed` event.
|
|
78
81
|
viewer: {
|
|
79
82
|
label: "Viewer",
|
|
80
83
|
icon: "solar:3d-square-bold",
|