@call-me-sensei/angelo 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hyperbond Studio Pte. Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PROTOCOL.md ADDED
@@ -0,0 +1,243 @@
1
+ <!-- SPDX-FileCopyrightText: 2026 Hyperbond Studio Pte. Ltd. -->
2
+ <!-- SPDX-License-Identifier: MIT -->
3
+
4
+ # Angelo web live link: wire contract
5
+
6
+ This is the contract between Angelo, the dev-server bridge (`@call-me-sensei/angelo/vite`) and the browser SDK
7
+ (`@call-me-sensei/angelo`; the unscoped alias `angelo-studio` re-exports both). Angelo's side is `doc/angelo/web_link.rst`; the engine-neutral link is
8
+ `doc/angelo/engine_link.rst`. Both halves of this package follow this file. A change here changes both.
9
+
10
+ ## 1. Angelo ⇄ bridge: `angelo-link/1`
11
+
12
+ The bridge is an engine editor plugin like Godot's. It runs in Node, inside the developer's dev server.
13
+
14
+ ### Finding Angelo
15
+
16
+ - **The endpoint record.** The record is `angelo-link.json` in Angelo's per-user data folder:
17
+ - macOS: `~/Library/Application Support/Angelo`
18
+ - Windows: `%LOCALAPPDATA%\Angelo`
19
+ - Linux and others: `$XDG_STATE_HOME/Angelo`, or `~/.local/state/Angelo`
20
+ - **The override.** `ANGELO_LINK_RECORD` names the record instead (tests use it).
21
+ - **Reading it.** Read the record only when it is a regular file of at most 4096 bytes. Where modes exist, it must be
22
+ owned by this user and carry no group or other bits. Never follow a link.
23
+ - JSON: `{protocol: "angelo-link/1", port, pid, token: <64 hex>, app, binary}`.
24
+ - Any other content counts as no record.
25
+ - **The token.** The token is secret: never log it, print it, send it to the page or put it in an error.
26
+ - **Polling.** Watch the record about every 2 s while disconnected. A changed record means a new Angelo: reconnect.
27
+
28
+ ### Frames
29
+
30
+ - Each frame is a 4-byte big-endian length `n` (1 ≤ n ≤ 65536), then `n` bytes of UTF-8 JSON (the header), then
31
+ `header.bytes` bytes of payload when the header has `bytes`.
32
+ - Payloads from Angelo are at most 512 MiB.
33
+ - Non-finite numbers are invalid JSON here.
34
+ - A malformed frame ends the connection.
35
+
36
+ ### Authentication (mutual HMAC-SHA256; the token never crosses the socket)
37
+
38
+ 1. Connect to `127.0.0.1:<port>`. Angelo sends `{type: "hello", protocol: "angelo-link/1", nonce: <64 hex>, app}`.
39
+ 2. The client sends `{type: "auth", nonce: <its own 64 hex>, proof, client}`.
40
+ - `proof = hex(HMAC-SHA256(key = bytes.fromhex(token), msg = "angelo-link/1/client\n" + server nonce + "\n" + client nonce))`.
41
+ - The message is ASCII, joined with `\n`.
42
+ 3. Angelo answers `{type: "welcome", proof}`, where `proof` is the same HMAC over `"angelo-link/1/server\n…"`.
43
+ - The client checks it, in constant time, before trusting anything.
44
+ - `{type: "error", code: "auth"}` or a wrong proof means: not Angelo, or a stale token. Stop and wait for a new
45
+ record.
46
+ 4. Auth must finish within 10 s.
47
+
48
+ `client` in `auth` is what the editor says about itself. Angelo keeps at most 8 capabilities and 400 characters a
49
+ field:
50
+
51
+ ```json
52
+ {"engine": "web", "web": "vite 7.1.3; three 0.185.1", "plugin": "@call-me-sensei/angelo 0.1.0",
53
+ "capabilities": ["durable", "live", "acks", "capture"],
54
+ "project": "/abs/path/to/the/project", "project_name": "<package.json name, else the folder name>"}
55
+ ```
56
+
57
+ `project` is the folder holding `package.json` at or above Vite's `root` (Vite's root may be a subfolder); asset paths
58
+ are relative to it.
59
+
60
+ Add `"look"` to `capabilities` only when a tracked page asked for `look: "apply"`. It is reserved: Angelo 1.0.10
61
+ sends no look frames yet.
62
+
63
+ ### Liveness
64
+
65
+ - Send `{type: "ping"}` every 10 s. Angelo answers `pong`.
66
+ - Angelo drops an editor that sends nothing for 45 s.
67
+ - The client counts Angelo lost after 60 s without any frame.
68
+
69
+ ### Requests (client → Angelo; headers only, never a payload except §1.1)
70
+
71
+ - `{type: "open", id, project, asset, project_name, node}`
72
+ - `asset` is the project-relative POSIX path of a `.glb` (`"public/models/chair.glb"`).
73
+ - `node` is the clicked object's name, shown on the card.
74
+ - `id` is unique per request, at most 64 characters.
75
+ - `{type: "close", id, asset}`
76
+ - `{type: "status", asset, code, ...}` acknowledgements:
77
+ - `{code: "previewed", version, instances}`: `instances` is the number of tracked copies now showing that
78
+ version, summed over every tab.
79
+ - `{code: "preview_failed", version, message}`
80
+ - `{code: "reimported", save}` after the page shows the saved file of a `saved` frame, echoing its `save`.
81
+ - `{code: "reimport_failed", save, message}`
82
+ - Messages are English, at most 200 characters, and never hold a path.
83
+
84
+ ### Replies and pushes (Angelo → client)
85
+
86
+ - `pending {id, asset}`: the person decides on a card in Angelo.
87
+ - `opened {id?, asset}`: linked. Without an `id`, it is a broadcast to every editor of the project.
88
+ - `refused {id, code, message}`: show `message` (English).
89
+ - Codes: `not_project`, `outside`, `kind`, `missing`, `engine`, `superseded`, `declined`, `failed`,
90
+ `unreadable`.
91
+ - `closed {id, asset}`
92
+ - `scene {asset, version, export_ms, kind, base?, removed?}` plus a `.glb` payload: a live frame (§1.2). It is
93
+ uncompressed, with images embedded and no animations.
94
+ - `saved {asset, files, scene_version, save}`: the asset file on disk was rewritten. The page re-fetches it.
95
+ - `status {asset, code, message}`: `edit_mode`, `export_failed`, `write_failed`, `baked_*`. Shown as notices.
96
+ - `ended {asset, reason}`: `engine`, `angelo`, `stopped`, `replaced`, `other_file`, `saved_elsewhere`. Every reason
97
+ but `engine` gets a notice.
98
+ - `capture {id, asset, max}` (§1.1).
99
+ - `pong`
100
+
101
+ ### 1.2 Live frames: full and delta
102
+
103
+ The page keeps every object it already has, and a live frame never rebuilds the developer's scene.
104
+
105
+ **Every node** in a frame's `.glb` carries, in `extras`:
106
+
107
+ - `angelo_uid`: the node's **key**. It is unique in the file and the same in every frame of one Angelo process.
108
+ - an object's key is its Blender `session_uid`;
109
+ - a bone's key is `"<armature uid>/<bone name>"`, and the exporter's extra leaf node of a bone adds `"#leaf"`;
110
+ - an object inside a collection instance is keyed `"<instancer key>><uid>"`.
111
+ - `angelo_role`:
112
+ - `"content"`: the page applies this node;
113
+ - `"parent"`: present only so that a content node hangs where it belongs. The page never changes a parent node; it
114
+ finds it by key.
115
+ - `angelo_digest`, on content nodes: the digest of everything the page shows for the node:
116
+ - its name, transform and children's keys;
117
+ - its mesh data and materials, with its images by digest;
118
+ - its skin, camera and extensions.
119
+
120
+ A content node whose digest equals the one the page already has is left untouched: no geometry, material, texture
121
+ or GPU work, and no event.
122
+
123
+ **Images** carry `extras.angelo_digest`, the SHA-256 of their bytes. An image that every receiving page already has
124
+ is sent as a **stub**: a 1 × 1 PNG with `extras.angelo_stub: true`. The page uses its cached texture of that digest.
125
+ The page keeps its textures by digest for the whole link, even after the objects that used them go. A stub whose
126
+ digest the page doesn't have means the page resyncs (below).
127
+
128
+ **`kind: "full"`** holds every node, all `content`. It carries no stubs, except the full frame of a still-scene
129
+ check (Angelo's whole export after edits pause, sent only when a digest moved), which may stub images every
130
+ receiving page has. A page missing a stub's picture asks for a full frame, which comes with no stubs. The page
131
+ applies a full frame by diff:
132
+
133
+ - nodes it has with the same digest stay untouched;
134
+ - changed nodes update in place;
135
+ - new nodes are created;
136
+ - nodes it had under the tracked asset that the frame lacks are removed.
137
+
138
+ Angelo sends a full frame for a page's first frame, a resync, a page that fell behind (`LinkServer.lagging`), after
139
+ an undo, redo or file read, and when most of the scene changed.
140
+
141
+ **`kind: "delta"`** holds only the objects that changed (as `content`) plus their ancestors and the armatures their
142
+ skins need (as `parent`).
143
+
144
+ - `base` is the version it applies on.
145
+ - `removed` lists the keys that are gone.
146
+ - Angelo never drops a delta, and a client more than 8 deltas behind gets a full frame instead.
147
+ - The page applies a delta only when `base` equals the version it shows. Otherwise it applies nothing and asks for
148
+ a full frame.
149
+
150
+ **The page asks for a full frame** (page → bridge `angelo:resync {asset}`, bridge → Angelo
151
+ `{type: "status", asset, code: "resync"}`) when a delta doesn't fit what it shows:
152
+
153
+ - the `base` differs;
154
+ - a parent key it doesn't have and the frame doesn't hold;
155
+ - a stub digest it doesn't have.
156
+
157
+ **Keys can change** across an undo or a reload: Blender may give objects new session uids. In a full frame, the
158
+ page matches a node whose key it doesn't know by its name path from the asset root before it creates anything.
159
+ When one matches, the existing Object3D adopts the new key, so identity survives.
160
+
161
+ **Skinned meshes**: the page keeps its bone Object3Ds, always. A bone's content updates only its rest data:
162
+
163
+ - its transform;
164
+ - the skin's inverse bind matrices.
165
+
166
+ A new or changed SkinnedMesh binds to the existing bones by key, so a playing AnimationMixer keeps driving them.
167
+
168
+ ### 1.1 Captures: the only client payload
169
+
170
+ When Angelo's agent asks to see the page (`angelo_view` `image: "WEB"`), Angelo sends `capture {id, asset, max}` to
171
+ one live editor of the linked asset. The client answers once, within 8 s, with one of:
172
+
173
+ - `{type: "status", asset, code: "captured", id, bytes: <n>}` plus a PNG payload of at most 8 MiB, at most `max`
174
+ pixels on its longer side, from the WebGL canvas only;
175
+ - `{type: "status", asset, code: "capture_failed", id, message}`: capture off in the config, a hidden tab, or no
176
+ tracked copy on screen.
177
+
178
+ A payload is accepted only:
179
+
180
+ - in reply to that client's outstanding `id`;
181
+ - with `code: "captured"`;
182
+ - when it starts with the PNG signature and fits the bound.
183
+
184
+ Angelo ends the connection on any other client payload.
185
+
186
+ ## 2. Bridge ⇄ page
187
+
188
+ The page never talks to Angelo. Events travel over Vite's HMR channel. Bytes travel over same-origin HTTP under
189
+ `/@angelo-live/<token>/`, where `<token>` is 16 random bytes (base64url) made once per dev-server start and given to
190
+ the page in the virtual module `virtual:angelo-live/config`.
191
+
192
+ ### Server → page (`server.hot.send` / `server.ws.send`; event names)
193
+
194
+ - `angelo:state {link: "waiting" | "live" | "lost" | "off", message?, session}`: `session` grows when the bridge
195
+ links to another Angelo process (its keys mean other objects then).
196
+ - `angelo:pending {asset}`, `angelo:opened {asset}`, `angelo:refused {asset, code, message}`
197
+ - `angelo:scene {asset, version, session, kind, base?, removed?, url}`: `url` is that frame's `.glb`
198
+ (`scene/<asset-id>/<version>.glb`). The bridge keeps every delta since the last full frame, at most 8 (a delta is
199
+ never skipped), and passes `kind`, `base` and `removed` through unchanged.
200
+ - `angelo:saved {asset, save, scene_version, url}`: `url` is the asset's own dev-server URL with `?angelo=<save>`.
201
+ A page that already shows `scene_version` changes nothing (no fetch, no upload); otherwise it applies the file by
202
+ the same diff.
203
+ - `angelo:status {asset, code, message}`, `angelo:ended {asset, reason}`
204
+ - `angelo:capture {id, asset, max}`: sent to every tab. The first good answer wins; the bridge relays it.
205
+
206
+ `asset` on the page side is the URL the page loaded (`"/models/chair.glb"`). The bridge maps between that URL and the
207
+ project path (Vite `publicDir` and `root`).
208
+
209
+ ### Page → server (`import.meta.hot.send`)
210
+
211
+ - `angelo:hello {tab, three, tracked: [url], capture: bool, look}`
212
+ - `angelo:track {tab, url}`, `angelo:untrack {tab, url}`
213
+ - `angelo:edit {tab, url, node}`
214
+ - `angelo:ack {tab, asset, code, version|save, instances, message?}`
215
+ - `angelo:capture_failed {tab, id, message}`
216
+ - `angelo:resync {tab, asset}`: the bridge sends Angelo `status {asset, code: "resync"}` (§1.2). A page asks once
217
+ per version it shows; the bridge passes at most 8 a second per asset.
218
+ - `angelo:bye {tab}`: the page stopped its live link (a component unmounted); the bridge stops waiting for that
219
+ tab's answers. A closed socket says the same for every tab on it.
220
+
221
+ ### HTTP, under `/@angelo-live/<token>/`
222
+
223
+ - `GET scene/<asset-id>/<version>.glb`: any frame the bridge still holds (the last full one and the deltas after
224
+ it); 404 otherwise.
225
+ - `POST capture/<id>`: a PNG body of at most 8 MiB.
226
+
227
+ Every request must pass every check, or it gets 403 or 404 with no body detail:
228
+
229
+ - the socket peer is loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`), whatever the `Host` says;
230
+ - `Host` is `localhost`, `127.0.0.1` or `[::1]` with the dev server's port, or one of Vite's `allowedHosts`;
231
+ - `Sec-Fetch-Site` is `same-origin`, or `Origin` equals the dev server's own origin;
232
+ - the path token matches (constant-time).
233
+
234
+ Every response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`
235
+ and `Cross-Origin-Resource-Policy: same-origin`, and never a CORS header.
236
+
237
+ ## 3. Production
238
+
239
+ - `@call-me-sensei/angelo` under the `production` export condition (and its default) is a no-op stub with the same
240
+ API. In a build the plugin also resolves the package to the stub whatever the conditions, so a build run with
241
+ another `NODE_ENV` can't pick up the development SDK.
242
+ - The Vite plugin is `apply: "serve"`.
243
+ - A production build holds no `angelo-live` route, socket or string.
package/README.md CHANGED
@@ -1,3 +1,136 @@
1
- # Temporary Holding Version
1
+ <!-- SPDX-FileCopyrightText: 2026 Hyperbond Studio Pte. Ltd. -->
2
+ <!-- SPDX-License-Identifier: MIT -->
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ # @call-me-sensei/angelo
5
+
6
+ Angelo's live link for the web: edits you make in [Angelo](https://angelo.studio) show up live in **your own**
7
+ Three.js page, inside your lights, shaders, physics and game state. Saving in Angelo rewrites the `.glb` your page
8
+ loads, and the page picks the new file up without reloading.
9
+
10
+ It has two halves:
11
+
12
+ - a **Vite plugin** that runs in your dev server and talks to Angelo on this computer;
13
+ - a **browser SDK** that updates the objects of a tracked glTF in place, by glTF node identity.
14
+
15
+ Your page never talks to Angelo. It talks only to its own dev server, over its own origin.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm i -D @call-me-sensei/angelo
21
+ ```
22
+
23
+ (`angelo-studio` is an unscoped alias of the same package.) Requirements: Vite 6.0.9 or newer, three.js r172 or
24
+ newer, Node 18.18 or newer.
25
+
26
+ ## Use
27
+
28
+ ```js
29
+ // vite.config.js
30
+ import { angeloLive } from "@call-me-sensei/angelo/vite";
31
+ export default { plugins: [angeloLive()] };
32
+ ```
33
+
34
+ ```js
35
+ // your page
36
+ import { createAngeloLive } from "@call-me-sensei/angelo";
37
+
38
+ const loader = new GLTFLoader(); // yours, with your Draco / meshopt / KTX2 decoders
39
+ const live = createAngeloLive({ renderer, scene, loader });
40
+ const gltf = await loader.loadAsync("/models/chair.glb");
41
+ scene.add(gltf.scene);
42
+ live.track(gltf, "/models/chair.glb");
43
+ ```
44
+
45
+ In Angelo, add the link from the chat bar's + menu (Game engine live link ▸ Add Angelo Link to a web project…).
46
+ Then **Alt-click** an object in your canvas and click **Edit chair.glb in Angelo**, or call
47
+ `live.edit("/models/chair.glb")`. Angelo asks you on a card, and from then on its edits show in your page.
48
+
49
+ ### `createAngeloLive(options)`
50
+
51
+ | option | default | |
52
+ |---|---|---|
53
+ | `renderer` | | Your `WebGLRenderer`. Alt-click picking and captures use its canvas. |
54
+ | `loader` | required | Your `GLTFLoader` (three's or three-stdlib's). Live frames and saved files are decoded with it. |
55
+ | `scene` | | Your scene (optional). |
56
+ | `camera` | last camera rendered | The camera Alt-click picks with. |
57
+ | `capture` | `true` | Let Angelo's agent ask for a picture of the canvas. `false` answers "capture is off on this page". |
58
+ | `overlay` | `true` | The small status dot in the corner. `false` hides it. |
59
+ | `look` | `"ignore"` | Reserved: `"apply"` will take Angelo's lighting and environment. Angelo 1.0.10 sends no look yet, so nothing is applied either way. |
60
+
61
+ ### `live.track(target, url)`
62
+
63
+ `target` is a GLTFLoader result (the best choice: its parser tells the SDK which object is which glTF node), its
64
+ scene, or a clone of that scene (`scene.clone()` or `SkeletonUtils.clone`). Track every copy you show: one parse of
65
+ each live frame serves them all. `track` returns `{ untrack() }`.
66
+
67
+ Other methods: `live.untrack(objectOrUrl)`, `live.edit(url, nodeName?)`, `live.on(event, listener)` (returns a function
68
+ that removes the listener), `live.stop()` (puts everything back as saved, then stops), and `live.state`.
69
+
70
+ ### How updates keep your objects
71
+
72
+ - **Nothing is rebuilt and the page never reloads.** Every frame is applied as a diff. An object whose content didn't
73
+ change keeps its geometry, material and textures, with no GPU upload and no event.
74
+ - **Your Object3Ds stay the same objects.** So do your references to them, their `userData`, the children you added,
75
+ and the physics bodies and mixers that hold them. Bones are never replaced, so a playing `AnimationMixer` keeps
76
+ running.
77
+ - **Angelo changes** an object's geometry, its local transform (only when Angelo moved it, so your own animation of an
78
+ object Angelo didn't move continues) and its name.
79
+ - **Materials**: Angelo changes a material only when it changed it. A material you replaced stays yours. So does a
80
+ change you made in place to a loaded material (a colour variant), until Angelo edits that material itself.
81
+ - **A deleted object** is removed. Children you had added to it move to the nearest remaining part of the asset,
82
+ keeping their world position (event `removed`, plus one console warning).
83
+ - **New objects** arrive as the loader makes them (event `added`): apply your per-mesh setup there, for example
84
+ `castShadow`.
85
+ - **Textures** are kept by content, so a picture the page already has is never decoded or uploaded again.
86
+ - **When Angelo stops** (or `live.stop()`), the SDK puts back exactly what it changed, in place, without reloading.
87
+ If the dev server loses Angelo it waits 15 s before that, so a reconnect doesn't flicker.
88
+
89
+ ### Events
90
+
91
+ `live.on(name, listener)`; the `angelo:` prefix is optional.
92
+
93
+ | event | detail |
94
+ |---|---|
95
+ | `state` | `{link: "off" \| "waiting" \| "live" \| "lost", message, session}` |
96
+ | `pending`, `opened` | `{asset}`: Angelo is asking on a card / the asset is open |
97
+ | `refused` | `{asset, code, message}` |
98
+ | `updated` | `{asset, version, frame: "full" \| "delta", instances, changed, timing: {fetch, parse, apply}}` |
99
+ | `renamed` | `{asset, object, from, to}` |
100
+ | `removed` | `{asset, name, moved: [your objects], to}` |
101
+ | `added` | `{asset, objects}` |
102
+ | `replaced` | `{asset, from, to}`: an object changed kind (a mesh became skinned), so a new Object3D took its place |
103
+ | `saved` | `{asset, save, instances, load()}`: `load()` gives the saved file through your loader (with animations) |
104
+ | `restored`, `ended` | `{asset, ...}`: the asset shows as saved again / Angelo stopped editing it |
105
+ | `status`, `resync` | Angelo's notices; a frame the page asked Angelo to send again in full |
106
+
107
+ ## Security
108
+
109
+ - The dev-server routes answer only this computer (loopback peers), whatever the `Host` says. A phone on your LAN
110
+ (`vite --host`) gets nothing, and the SDK switches itself off on a non-local page.
111
+ - Each route also checks the `Host` (a loopback name or one of your `server.allowedHosts`, with the dev server's
112
+ port), that the request comes from the page's own origin (`Sec-Fetch-Site`/`Origin`), and a random path token made
113
+ for each dev-server start. The routes never send CORS headers.
114
+ - Events travel over Vite's own HMR socket, which Vite protects with a token from 6.0.9 on. Don't turn on
115
+ `legacy.skipWebSocketTokenCheck`; the plugin warns if you do.
116
+ - Angelo's endpoint record is read only when it is a small file owned by you, with no group or other permissions,
117
+ and never through a link. Its token never leaves the dev server's process, and the token itself never crosses the
118
+ socket.
119
+ - A capture is the WebGL canvas only, never the page's DOM. It is sent only while Angelo asks, at most one at a time,
120
+ within 8 s, and only as a PNG of at most 8 MiB. Turn it off with `capture: false` here or `angeloLive({ capture:
121
+ false })`.
122
+ - The package has no runtime dependencies and fetches nothing from the network.
123
+
124
+ ## Production
125
+
126
+ The plugin runs on the dev server only. In a build, `@call-me-sensei/angelo` resolves to a no-op stub with the same
127
+ API (the `production` export condition, and the plugin enforces it in builds whatever `NODE_ENV` says). A production
128
+ build holds no Angelo route, socket or string. You can leave `createAngeloLive` and `track` in your code.
129
+
130
+ ## Protocol
131
+
132
+ The wire contract between Angelo, the plugin and the page is [PROTOCOL.md](PROTOCOL.md).
133
+
134
+ ## License
135
+
136
+ MIT © 2026 Hyperbond Studio Pte. Ltd.
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@call-me-sensei/angelo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Angelo's web live link: see Angelo's edits live in your own Three.js page, through your Vite dev server.",
5
+ "license": "MIT",
6
+ "author": "Hyperbond Studio Pte. Ltd.",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "development": "./src/browser/index.js",
13
+ "production": "./src/stub.js",
14
+ "default": "./src/stub.js"
15
+ },
16
+ "./vite": {
17
+ "types": "./src/vite/index.d.ts",
18
+ "default": "./src/vite/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "types": "./src/index.d.ts",
23
+ "files": [
24
+ "src/",
25
+ "LICENSE",
26
+ "README.md",
27
+ "PROTOCOL.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18.18"
31
+ },
32
+ "peerDependencies": {
33
+ "three": ">=0.172.0",
34
+ "vite": ">=6.0.9"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "vite": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "devDependencies": {
42
+ "three": "0.187.0",
43
+ "vite": "7.3.7",
44
+ "vitest": "5.1.0"
45
+ },
46
+ "scripts": {
47
+ "test": "vitest run"
48
+ },
49
+ "keywords": ["three", "threejs", "vite", "vite-plugin", "gltf", "live-link", "angelo"]
50
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 Hyperbond Studio Pte. Ltd.
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // Pictures of the WebGL canvas for Angelo's agent, only when Angelo asks (PROTOCOL.md §1.1), and only the canvas.
5
+ //
6
+ // A WebGL drawing buffer is cleared once the browser presents the frame, unless the page asked for
7
+ // preserveDrawingBuffer. So the SDK wraps renderer.render once: after a render to the canvas, while a capture is
8
+ // pending, a microtask copies the canvas into a scaled 2D canvas. A microtask runs after the developer's frame code
9
+ // (every pass of a post-processing chain, an overlay scene) but before the frame is presented.
10
+
11
+ export const MAX_BYTES = 8 * 1024 * 1024;
12
+ const FORCE_RENDER_MS = 600; // An app that renders only on change gets one render of its last scene and camera.
13
+ const GIVE_UP_MS = 6000;
14
+
15
+ const WRAPPED = Symbol("angelo live render hook");
16
+
17
+ /**
18
+ * Wrap `renderer.render` once per renderer so `onCanvasRender(scene, camera)` runs after each render to the canvas.
19
+ * Several live links may share a renderer (a component that mounts twice); each unhook removes only its own
20
+ * listener, and the original render comes back when the last one goes.
21
+ */
22
+ export function hookRenderer(renderer, onCanvasRender) {
23
+ let hook = renderer[WRAPPED];
24
+ if (!hook) {
25
+ const original = renderer.render;
26
+ hook = { listeners: new Set(), original, wrapped: null };
27
+ hook.wrapped = function render(scene, camera) {
28
+ const result = original.apply(this, arguments);
29
+ const target = typeof this.getRenderTarget === "function" ? this.getRenderTarget() : null;
30
+ if (target === null) for (const listener of hook.listeners) listener(scene, camera);
31
+ return result;
32
+ };
33
+ renderer.render = hook.wrapped;
34
+ renderer[WRAPPED] = hook;
35
+ }
36
+ hook.listeners.add(onCanvasRender);
37
+ return () => {
38
+ hook.listeners.delete(onCanvasRender);
39
+ if (hook.listeners.size || renderer[WRAPPED] !== hook) return;
40
+ delete renderer[WRAPPED];
41
+ if (renderer.render === hook.wrapped) renderer.render = hook.original; // Another wrapper on top stays as it is.
42
+ };
43
+ }
44
+
45
+ function scaledCanvas(source, max) {
46
+ const scale = Math.min(1, max / Math.max(source.width, source.height));
47
+ const canvas = document.createElement("canvas");
48
+ canvas.width = Math.max(1, Math.round(source.width * scale));
49
+ canvas.height = Math.max(1, Math.round(source.height * scale));
50
+ canvas.getContext("2d").drawImage(source, 0, 0, canvas.width, canvas.height);
51
+ return canvas;
52
+ }
53
+
54
+ function pngOf(canvas) {
55
+ return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
56
+ }
57
+
58
+ export class Capturer {
59
+ /**
60
+ * @param {object} o
61
+ * @param {object} o.renderer The developer's WebGLRenderer.
62
+ * @param {(id: string, blob: Blob) => Promise<void>} o.send POSTs the PNG.
63
+ * @param {(id: string, message: string) => void} o.fail
64
+ */
65
+ constructor({ renderer, send, fail }) {
66
+ Object.assign(this, { renderer, send, fail });
67
+ this.pending = null;
68
+ this.lastScene = null;
69
+ this.lastCamera = null;
70
+ }
71
+
72
+ /** Called by the render hook after each render to the canvas. */
73
+ rendered(scene, camera) {
74
+ this.lastScene = scene;
75
+ this.lastCamera = camera;
76
+ const pending = this.pending;
77
+ if (!pending || pending.scheduled) return;
78
+ pending.scheduled = true;
79
+ queueMicrotask(() => this.grab(pending));
80
+ }
81
+
82
+ request(id, max) {
83
+ if (this.pending) this.finish(this.pending, "Another capture replaced this one");
84
+ const pending = { id, max, scheduled: false, done: false };
85
+ pending.force = setTimeout(() => {
86
+ // Nothing rendered: draw the last scene and camera once (an app that renders on demand).
87
+ if (!pending.scheduled && this.lastScene && this.lastCamera) {
88
+ this.renderer.render(this.lastScene, this.lastCamera);
89
+ }
90
+ }, FORCE_RENDER_MS);
91
+ pending.giveUp = setTimeout(() => this.finish(pending, "The page didn't draw a frame in time"), GIVE_UP_MS);
92
+ this.pending = pending;
93
+ }
94
+
95
+ async grab(pending) {
96
+ if (pending.done) return;
97
+ let canvas;
98
+ try {
99
+ canvas = scaledCanvas(this.renderer.domElement, pending.max); // Synchronous: the frame is still there.
100
+ } catch {
101
+ return this.finish(pending, "The canvas couldn't be read");
102
+ }
103
+ let blob = await pngOf(canvas);
104
+ for (let tries = 0; blob && blob.size > MAX_BYTES && tries < 4; tries++) {
105
+ canvas = scaledCanvas(canvas, Math.floor(Math.max(canvas.width, canvas.height) * 0.7));
106
+ blob = await pngOf(canvas);
107
+ }
108
+ if (pending.done) return;
109
+ if (!blob || blob.size > MAX_BYTES) return this.finish(pending, "The picture is larger than 8 MiB");
110
+ this.finish(pending, null);
111
+ try {
112
+ await this.send(pending.id, blob);
113
+ } catch {
114
+ // The bridge answers Angelo itself when no picture arrives in time.
115
+ }
116
+ }
117
+
118
+ finish(pending, failure) {
119
+ if (pending.done) return;
120
+ pending.done = true;
121
+ clearTimeout(pending.force);
122
+ clearTimeout(pending.giveUp);
123
+ if (this.pending === pending) this.pending = null;
124
+ if (failure) this.fail(pending.id, failure);
125
+ }
126
+
127
+ cancel() {
128
+ if (this.pending) this.finish(this.pending, "The page stopped the live link");
129
+ }
130
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: 2026 Hyperbond Studio Pte. Ltd.
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // Delta frames (PROTOCOL.md §1.2): only the objects Angelo changed, as `content` nodes, hung under their real
5
+ // ancestors, which come as `parent` nodes (locators the page finds by key and never changes), plus the keys that
6
+ // are gone. Only content nodes are touched, and one whose digest the page already has is skipped. A delta that
7
+ // doesn't fit what the page shows (a parent key it doesn't have) is not applied at all: the caller asks for a full
8
+ // frame instead.
9
+
10
+ import { skins } from "./matcher.js";
11
+ import { describe } from "./source.js";
12
+ import { Update, sdkDigest } from "./update.js";
13
+
14
+ /** The delta's nodes, parents first, each with `parentUid`: its glb parent's key, or "" under the asset root. */
15
+ export function describeDelta(gltf) {
16
+ const whole = describe(gltf);
17
+ const nodes = whole.nodes.map((node) => ({ ...node, parentUid: node.parent ? node.parent.uid : "" }));
18
+ const byObject = new Map(nodes.map((node) => [node.object, node]));
19
+ for (const node of nodes) node.parent = node.parent ? byObject.get(node.parent.object) : null;
20
+ return { nodes, byObject };
21
+ }
22
+
23
+ /**
24
+ * Apply a delta to one copy: {report} when applied, or {problem} (and nothing changed) when the page must ask
25
+ * for a full frame. `removedKeys` is the header's `removed`.
26
+ */
27
+ export function applyDelta(copy, delta, removedKeys, cache) {
28
+ const byUid = new Map();
29
+ for (const [object, info] of copy.nodes) if (info.uid !== null && !byUid.has(info.uid)) byUid.set(info.uid, object);
30
+
31
+ // Resolve first, so a delta is applied whole or not at all.
32
+ for (const node of delta.nodes) {
33
+ if (node.uid === null) return { problem: "a node without its key" };
34
+ if (node.role === "parent" && !byUid.has(node.uid)) return { problem: "a parent the page doesn't have" };
35
+ }
36
+ const removed = removedKeys.map((key) => byUid.get(key)).filter(Boolean);
37
+ const removing = new Set(removed);
38
+ for (const node of delta.nodes) {
39
+ if (removing.has(byUid.get(node.uid))) return { problem: "a node both sent and removed" };
40
+ }
41
+
42
+ const update = new Update(copy, copy.journal, cache);
43
+ const finish = update.removeNodes(removed);
44
+ const objectOf = new Map();
45
+ const changed = new Set();
46
+ for (const node of delta.nodes) { // Parents before children.
47
+ const existing = byUid.get(node.uid);
48
+ if (node.role === "parent") {
49
+ objectOf.set(node, existing);
50
+ continue;
51
+ }
52
+ const parent = node.parent ? objectOf.get(node.parent) : copy.root;
53
+ if (existing && node.digest && sdkDigest.get(existing) === node.digest) {
54
+ objectOf.set(node, existing);
55
+ continue; // What the page has already: untouched.
56
+ }
57
+ changed.add(node);
58
+ objectOf.set(node, existing ? update.updateNode(existing, node, parent) : update.addNode(node, parent));
59
+ }
60
+ finish();
61
+ // Joints by key: bones (sent as parents when only a skin needs them) are the page's own objects, never replaced.
62
+ skins(update, delta, (bone) => objectOf.get(delta.byObject.get(bone)) ?? null, objectOf, changed);
63
+ return { report: update.report };
64
+ }