@poe2-toolkit/tree-core 0.1.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/LICENSE +21 -0
- package/README.md +451 -0
- package/dist/geometry/centre.d.ts +17 -0
- package/dist/geometry/centre.d.ts.map +1 -0
- package/dist/geometry/centre.js +59 -0
- package/dist/geometry/centre.js.map +1 -0
- package/dist/geometry/framing.d.ts +27 -0
- package/dist/geometry/framing.d.ts.map +1 -0
- package/dist/geometry/framing.js +101 -0
- package/dist/geometry/framing.js.map +1 -0
- package/dist/geometry/orbit.d.ts +23 -0
- package/dist/geometry/orbit.d.ts.map +1 -0
- package/dist/geometry/orbit.js +28 -0
- package/dist/geometry/orbit.js.map +1 -0
- package/dist/geometry/project.d.ts +20 -0
- package/dist/geometry/project.d.ts.map +1 -0
- package/dist/geometry/project.js +149 -0
- package/dist/geometry/project.js.map +1 -0
- package/dist/ggg/normalize.d.ts +109 -0
- package/dist/ggg/normalize.d.ts.map +1 -0
- package/dist/ggg/normalize.js +279 -0
- package/dist/ggg/normalize.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/scene/allocate.d.ts +61 -0
- package/dist/scene/allocate.d.ts.map +1 -0
- package/dist/scene/allocate.js +239 -0
- package/dist/scene/allocate.js.map +1 -0
- package/dist/scene/buildScene.d.ts +21 -0
- package/dist/scene/buildScene.d.ts.map +1 -0
- package/dist/scene/buildScene.js +212 -0
- package/dist/scene/buildScene.js.map +1 -0
- package/dist/scene/connections.d.ts +13 -0
- package/dist/scene/connections.d.ts.map +1 -0
- package/dist/scene/connections.js +75 -0
- package/dist/scene/connections.js.map +1 -0
- package/dist/scene/nodeSize.d.ts +26 -0
- package/dist/scene/nodeSize.d.ts.map +1 -0
- package/dist/scene/nodeSize.js +72 -0
- package/dist/scene/nodeSize.js.map +1 -0
- package/dist/types.d.ts +426 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladislav Rajtmajer
|
|
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/README.md
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# @poe2-toolkit/tree-core
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@poe2-toolkit/tree-core)
|
|
4
|
+
[](#)
|
|
5
|
+
[](#)
|
|
6
|
+
[](#)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
Headless, framework-agnostic geometry engine for the **Path of Exile 2 passive
|
|
10
|
+
tree**. Feed it the official tree data and a build's allocation; get back a fully
|
|
11
|
+
positioned, correctly sized `Scene` that any renderer can draw.
|
|
12
|
+
|
|
13
|
+
Pure TypeScript, zero runtime dependencies, no DOM, no canvas, no framework.
|
|
14
|
+
|
|
15
|
+
> **Live demo:** this engine drives the passive tree at
|
|
16
|
+
> [poe.rajtik.com/tree](https://poe.rajtik.com/tree) (rendered via
|
|
17
|
+
> [`@poe2-toolkit/tree-react`](../poe2-tree-react)).
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { buildScene, project } from '@poe2-toolkit/tree-core';
|
|
21
|
+
import { normalizeGggTree } from '@poe2-toolkit/tree-core/ggg'; // data adapter, opt-in
|
|
22
|
+
|
|
23
|
+
const data = normalizeGggTree(rawDataJson, '0_5');
|
|
24
|
+
const scene = buildScene(data, { allocation });
|
|
25
|
+
const screen = project(scene, viewport, { width: 1280, height: 720 });
|
|
26
|
+
// `screen` is pixel-space: walk the arrays and blit. No math left for the view.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The engine entry point is **source-agnostic** — it works against the `TreeData`
|
|
30
|
+
contract and never imports anything GGG-specific. Turning a particular export
|
|
31
|
+
into `TreeData` is a separate, swappable adapter; the one for GGG's official
|
|
32
|
+
`data.json` lives in the `@poe2-toolkit/tree-core/ggg` subpath.
|
|
33
|
+
|
|
34
|
+
## Contents
|
|
35
|
+
|
|
36
|
+
- [Why](#why)
|
|
37
|
+
- [Install](#install)
|
|
38
|
+
- [How it works](#how-it-works)
|
|
39
|
+
- [Quick start](#quick-start)
|
|
40
|
+
- [The data model (`TreeData`)](#the-data-model-treedata)
|
|
41
|
+
- [The output (`Scene`)](#the-output-scene)
|
|
42
|
+
- [Coordinate spaces and projection](#coordinate-spaces-and-projection)
|
|
43
|
+
- [Build allocation](#build-allocation)
|
|
44
|
+
- [Interactive editing](#interactive-editing)
|
|
45
|
+
- [Graphics: the sprite manifest](#graphics-the-sprite-manifest)
|
|
46
|
+
- [Geometry rules](#geometry-rules)
|
|
47
|
+
- [What gets filtered out](#what-gets-filtered-out)
|
|
48
|
+
- [API reference](#api-reference)
|
|
49
|
+
- [Design principles](#design-principles)
|
|
50
|
+
- [License and credits](#license-and-credits)
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
Existing PoE2 tree renderers ship as whole applications, not reusable libraries,
|
|
55
|
+
and they tend to hard-code node sizes and positions as magic constants. The goal
|
|
56
|
+
here is the opposite: **from the input data alone, the engine knows exactly where
|
|
57
|
+
everything is and how big it is.** Sizes, connections, and the central hub
|
|
58
|
+
geometry are derived from the source data, never hand-tuned. "Looks like the
|
|
59
|
+
game" becomes a property of the data, not of each author's eyeballing.
|
|
60
|
+
|
|
61
|
+
The package is deliberately split into two halves so the geometry can be reused
|
|
62
|
+
anywhere:
|
|
63
|
+
|
|
64
|
+
- **`@poe2-toolkit/tree-core`** (this package) does all the math and owns no pixels.
|
|
65
|
+
- A thin view adapter (e.g. `@poe2-toolkit/tree-react`) draws what core computed and owns
|
|
66
|
+
no math. The same `Scene` contract is open to Vue, Svelte, or any other view.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npm install @poe2-toolkit/tree-core
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
ESM only. Node 18+. Ships its own `.d.ts`.
|
|
75
|
+
|
|
76
|
+
## How it works
|
|
77
|
+
|
|
78
|
+
The engine is a small pipeline. Each stage has one job and a plain data contract
|
|
79
|
+
between it and the next:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
your data ──adapter──▶ TreeData ──buildScene──▶ Scene ──project──▶ ScreenScene
|
|
83
|
+
(source) (world) (pixels)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
1. **Adapter** — turn a source export into the clean
|
|
87
|
+
[`TreeData`](#the-data-model-treedata) contract. The engine ships one adapter,
|
|
88
|
+
`normalizeGggTree(raw, version)` from `@poe2-toolkit/tree-core/ggg`, for GGG's official
|
|
89
|
+
`data.json`. It is the only code that knows GGG's field names and quirks, and
|
|
90
|
+
it is tolerant by design: optional fields come and go across patches, and
|
|
91
|
+
missing data never throws. To support another source, write another adapter
|
|
92
|
+
that returns `TreeData` — nothing downstream changes.
|
|
93
|
+
|
|
94
|
+
2. **`buildScene(data, { allocation })`** assembles a render-ready
|
|
95
|
+
[`Scene`](#the-output-scene): every node positioned and sized in world space,
|
|
96
|
+
every edge resolved to a line or an arc, every effect pattern placed, the
|
|
97
|
+
central hub laid out, and each node marked allocated or not for the given
|
|
98
|
+
build. Nothing geometric is left for the renderer.
|
|
99
|
+
|
|
100
|
+
3. **`project(scene, viewport, size)`** maps the world-space `Scene` to a
|
|
101
|
+
`ScreenScene` of pixel coordinates, culled to the viewport. The renderer walks
|
|
102
|
+
the resulting arrays and blits, with no `* scale` or `+ offset` of its own.
|
|
103
|
+
|
|
104
|
+
`nodeAt`, `toggleAllocation`, `allocatedBounds`, and friends sit alongside the
|
|
105
|
+
pipeline to cover what an interactive UI needs (hit-testing, click-to-allocate,
|
|
106
|
+
view framing).
|
|
107
|
+
|
|
108
|
+
## Quick start
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { buildScene, project, nodeAt, toggleAllocation } from '@poe2-toolkit/tree-core';
|
|
112
|
+
import { normalizeGggTree } from '@poe2-toolkit/tree-core/ggg';
|
|
113
|
+
|
|
114
|
+
// 1. Normalize the official export once (cache the result per tree version).
|
|
115
|
+
const data = normalizeGggTree(rawDataJson, '0_5');
|
|
116
|
+
|
|
117
|
+
// 2. Build a scene for the current allocation. Omit `allocation` for an
|
|
118
|
+
// unallocated tree.
|
|
119
|
+
const allocation = { classId: 1, ascendId: 'Lich', allocated: [/* skill ids */] };
|
|
120
|
+
const scene = buildScene(data, { allocation });
|
|
121
|
+
|
|
122
|
+
// 3. Project to the viewport, then draw `screen.nodes` / `.connections` /
|
|
123
|
+
// `.masteryEffects` however you like.
|
|
124
|
+
const viewport = { tx: 640, ty: 360, scale: 0.1 };
|
|
125
|
+
const screen = project(scene, viewport, { width: 1280, height: 720 });
|
|
126
|
+
|
|
127
|
+
// 4. Hit-test a click and toggle that node in a manual build.
|
|
128
|
+
const hit = nodeAt(scene, viewport, mouseX, mouseY);
|
|
129
|
+
if (hit !== null) {
|
|
130
|
+
const next = toggleAllocation(data, data.classes[1].startNode, new Set(allocation.allocated), hit);
|
|
131
|
+
// re-run buildScene with the new `next` array.
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## The data model (`TreeData`)
|
|
136
|
+
|
|
137
|
+
`TreeData` is the normalized source shape. Everything downstream reads from it;
|
|
138
|
+
nothing downstream knows about GGG's raw field names.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
interface TreeData {
|
|
142
|
+
version: string; // tree/patch version, e.g. "0_5"
|
|
143
|
+
constants: { centreInnerRadius: number };
|
|
144
|
+
groups: Record<number, Group>; // orbit clusters, keyed by group id
|
|
145
|
+
nodes: Record<number, TreeNode>; // every node, keyed by skill id
|
|
146
|
+
classes: ClassDef[]; // central art + ascendancy metadata per class
|
|
147
|
+
jewelSlots: number[]; // skill ids that are jewel sockets
|
|
148
|
+
bounds: WorldRect; // world extent of the whole tree
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A `TreeNode` carries baked world coordinates plus the flags and metadata the
|
|
153
|
+
engine needs:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
interface TreeNode {
|
|
157
|
+
skill: number;
|
|
158
|
+
group: number; // direct index into `groups`
|
|
159
|
+
orbit: number;
|
|
160
|
+
orbitIndex: number;
|
|
161
|
+
x: number; y: number; // world position, baked by GGG's export
|
|
162
|
+
connections: NodeConnection[]; // neighbours (in + out, merged)
|
|
163
|
+
name: string;
|
|
164
|
+
icon: string; // atlas key into the sprite manifest
|
|
165
|
+
stats: string[];
|
|
166
|
+
|
|
167
|
+
// kind flags (optional; absence = plain small node)
|
|
168
|
+
isNotable?: boolean;
|
|
169
|
+
isKeystone?: boolean;
|
|
170
|
+
isJewelSocket?: boolean;
|
|
171
|
+
isMastery?: boolean;
|
|
172
|
+
isAscendancyStart?: boolean;
|
|
173
|
+
isAttribute?: boolean;
|
|
174
|
+
noRadius?: boolean; // hidden special socket (Sinister sockets)
|
|
175
|
+
|
|
176
|
+
// metadata
|
|
177
|
+
activeEffectImage?: string; // background pattern key (notables + masteries)
|
|
178
|
+
ascendancyName?: string; // e.g. "Deadeye"
|
|
179
|
+
flavourText?: string; // keystone lore
|
|
180
|
+
options?: NodeOption[]; // attribute nodes: Str / Dex / Int choices
|
|
181
|
+
conditional?: boolean; // hidden unless unlocked (e.g. by ascendancy)
|
|
182
|
+
unlockAscendancy?: string;
|
|
183
|
+
classesStart?: string[]; // class-start node: which classes start here
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The source of all this is GGG's official skill-tree export. Two things matter for
|
|
188
|
+
understanding the shape:
|
|
189
|
+
|
|
190
|
+
- **Positions are baked.** GGG ships each node's world `x`/`y` directly and omits
|
|
191
|
+
the orbit-radius constants needed to recompute them, so the engine reads the
|
|
192
|
+
coordinates as-is. `orbit`/`orbitIndex` are kept for reference but positions do
|
|
193
|
+
not depend on them.
|
|
194
|
+
- **Sizes are derived.** GGG ships no per-node sizes, so the engine derives them
|
|
195
|
+
from a fixed size per node type (see [Geometry rules](#geometry-rules)).
|
|
196
|
+
|
|
197
|
+
## The output (`Scene`)
|
|
198
|
+
|
|
199
|
+
`buildScene` returns a `Scene`: everything positioned and sized in world space,
|
|
200
|
+
ready to project and draw.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
interface Scene {
|
|
204
|
+
nodes: PlacedNode[]; // positioned + sized + allocation state
|
|
205
|
+
connections: PlacedConnection[]; // each resolved to a line or an arc
|
|
206
|
+
masteryEffects: PlacedEffect[]; // background patterns (notables + masteries)
|
|
207
|
+
centre: CentreLayout; // hub geometry + per-class ring rotation
|
|
208
|
+
bounds: WorldRect; // whole tree, including far ascendancy discs
|
|
209
|
+
mainBounds: WorldRect; // main tree only (use this to fit the view)
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Each `PlacedNode` carries its world centre, icon and frame diameters, a hit-test
|
|
214
|
+
radius, its `kind` (`normal`, `notable`, `keystone`, `mastery`, `jewel`,
|
|
215
|
+
`attribute`, `classStart`, `ascendancyStart`, `ascendancyNormal`,
|
|
216
|
+
`ascendancyNotable`), whether it is allocated, and its owning ascendancy if any.
|
|
217
|
+
|
|
218
|
+
> Use `mainBounds`, not `bounds`, to frame the initial view. Ascendancy discs sit
|
|
219
|
+
> thousands of world units out from the main tree, so fitting `bounds` would
|
|
220
|
+
> leave the main tree a tiny speck in the middle.
|
|
221
|
+
|
|
222
|
+
## Coordinate spaces and projection
|
|
223
|
+
|
|
224
|
+
There are two spaces, and the boundary between them is `project`:
|
|
225
|
+
|
|
226
|
+
- **World space** — the tree's own coordinate system, as it comes out of
|
|
227
|
+
`buildScene`. Stable, view-independent.
|
|
228
|
+
- **Screen space** — pixels, after applying a `Viewport`
|
|
229
|
+
(`screen = world * scale + (tx, ty)`).
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const screen = project(scene, viewport, { width, height });
|
|
233
|
+
// screen.nodes / .connections / .masteryEffects are in pixels and culled
|
|
234
|
+
// to the viewport. screen.scale is provided for line widths and LOD.
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Going the other way:
|
|
238
|
+
|
|
239
|
+
- `projectPoint(viewport, point)` — world point to pixels.
|
|
240
|
+
- `screenToWorld(viewport, sx, sy)` — pixels to a world point.
|
|
241
|
+
- `nodeAt(scene, viewport, sx, sy)` — the skill id under a pixel, or `null`
|
|
242
|
+
(topmost node whose footprint contains the point). Masteries and ascendancy
|
|
243
|
+
nodes are excluded from hit-testing.
|
|
244
|
+
|
|
245
|
+
`project` culls to the viewport and drops nodes whose projected radius is below
|
|
246
|
+
a fraction of a pixel (a cheap level-of-detail pass), so it stays fast even fully
|
|
247
|
+
zoomed out.
|
|
248
|
+
|
|
249
|
+
## Build allocation
|
|
250
|
+
|
|
251
|
+
A character's tree state is the only build input the engine needs. Whatever
|
|
252
|
+
produces it (a Path of Building export decoder, the GGG OAuth API, a manual
|
|
253
|
+
editor) is none of the engine's concern. Gems and items do not affect the tree
|
|
254
|
+
and are deliberately absent.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
interface BuildAllocation {
|
|
258
|
+
classId?: number; // matches ClassDef.id; only the editable planner needs it (class start node for pathing)
|
|
259
|
+
ascendId?: string; // matches AscendancyDef.id, e.g. "Lich"
|
|
260
|
+
allocated: number[]; // allocated skill ids
|
|
261
|
+
attributeChoices?: Record<number, 'str' | 'dex' | 'int'>;
|
|
262
|
+
jewels?: Record<number, JewelInfo>; // display-only, keyed by socket node id
|
|
263
|
+
treeVersion?: string;
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`attributeChoices` lets a generic `+attribute` node render its chosen Str/Dex/Int
|
|
268
|
+
icon and stat instead of the generic "any". `jewels` is display-only metadata
|
|
269
|
+
keyed by socket node id; the engine never applies a jewel's radius to nearby
|
|
270
|
+
nodes (PoE2 jewels grant global stats).
|
|
271
|
+
|
|
272
|
+
## Interactive editing
|
|
273
|
+
|
|
274
|
+
For a build editor, the allocation helpers turn clicks into a new allocated set.
|
|
275
|
+
They are pure graph functions, free of any rendering concern.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { buildTreeGraph, toggleAllocation } from '@poe2-toolkit/tree-core';
|
|
279
|
+
|
|
280
|
+
// Build the walkable adjacency graph once and reuse it across clicks.
|
|
281
|
+
const graph = buildTreeGraph(data);
|
|
282
|
+
|
|
283
|
+
// Click a node: allocate the shortest path to it from the class start, or, if it
|
|
284
|
+
// is already allocated, remove it and everything beyond it.
|
|
285
|
+
const next = toggleAllocation(data, classStartNode, new Set(allocated), clickedSkill, graph);
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The model:
|
|
289
|
+
|
|
290
|
+
- **Allocate** — clicking an unallocated node allocates the shortest path to it
|
|
291
|
+
from the class start (plus the current allocation). `pathToNode` is the
|
|
292
|
+
underlying breadth-first search.
|
|
293
|
+
- **Remove** — clicking an allocated node removes everything beyond it (the nodes
|
|
294
|
+
that lose their connection to the start once it is cut) and keeps the clicked
|
|
295
|
+
node, which shortens the path back to that node. If nothing lies beyond it (the
|
|
296
|
+
node is a tip), the node itself is removed. `removalSet` computes exactly which
|
|
297
|
+
nodes a click removes, so a UI can preview the removal before committing it.
|
|
298
|
+
|
|
299
|
+
Ascendancy points are a separate pool. `toggleAscendancyAllocation` paths within
|
|
300
|
+
a single ascendancy's own subgraph (rooted at its start node) and leaves the
|
|
301
|
+
main-tree allocation untouched, using `buildAscendancyGraph` and
|
|
302
|
+
`ascendancyStartNode`.
|
|
303
|
+
|
|
304
|
+
For framing the view, `allocatedBounds(scene)` returns the world bounds of the
|
|
305
|
+
allocated nodes (handy to zoom to a freshly imported build), and
|
|
306
|
+
`classBounds(scene, classId)` returns the bounds of one class's sector of the
|
|
307
|
+
tree.
|
|
308
|
+
|
|
309
|
+
## Graphics: the sprite manifest
|
|
310
|
+
|
|
311
|
+
Core owns no pixels. It produces atlas **keys** (`node.icon`, the centre art
|
|
312
|
+
keys, effect pattern keys) and leaves the bitmaps to the renderer. The renderer
|
|
313
|
+
supplies a `SpriteManifest` that maps each key to a sub-rect inside an atlas, and
|
|
314
|
+
resolves atlas ids to actual images itself:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
interface SpriteManifest {
|
|
318
|
+
frames: Record<string, { atlas: string; x: number; y: number; w: number; h: number }>;
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
This keeps the engine atlas-agnostic and lets the consumer ship whatever
|
|
323
|
+
graphics it has rights to. Art is never bundled with the package.
|
|
324
|
+
|
|
325
|
+
## Geometry rules
|
|
326
|
+
|
|
327
|
+
Everything the engine computes is derived from the data. The rules worth knowing:
|
|
328
|
+
|
|
329
|
+
- **Positions are read, not computed.** GGG bakes world `x`/`y` into every node;
|
|
330
|
+
`buildScene` reads them. (The convention behind the baked coordinates: angle 0
|
|
331
|
+
points up and increases clockwise, so a node sits at
|
|
332
|
+
`x = group.x + r·sin(angle)`, `y = group.y − r·cos(angle)`.)
|
|
333
|
+
- **Sizes follow a fixed per-type table.** Icon, overlay frame, and effect-pattern
|
|
334
|
+
diameters come from `nodeTargetSize`, whose constants and cascade order match
|
|
335
|
+
Path of Building's `GetNodeTargetSize` (the reference renderer). The first
|
|
336
|
+
matching type wins.
|
|
337
|
+
- **The 2× rule.** The game draws each sprite at twice its native width, centred
|
|
338
|
+
on the node, so the world diameter is `2 × targetWidth`. `buildScene` folds the
|
|
339
|
+
factor in; the renderer just scales by the viewport.
|
|
340
|
+
- **Connections are lines or arcs.** GGG's `edges` table marks which edges are
|
|
341
|
+
arcs and gives the arc centre directly; those become a minor arc around that
|
|
342
|
+
centre. Edges whose endpoints share a group orbit fall back to an arc around
|
|
343
|
+
the group centre. Everything else is a straight line.
|
|
344
|
+
- **The hub rotates per class.** The central gold ring rotates to point at the
|
|
345
|
+
active class. The rotation is derived from the direction of the class's start
|
|
346
|
+
node: `ringRotation = π/2 + atan2(start.y − cy, start.x − cx)`. That same
|
|
347
|
+
direction is where the class sits on the rim.
|
|
348
|
+
- **Ascendancies are relocatable blocks.** GGG bakes each ascendancy's nodes at a
|
|
349
|
+
far-flung cluster. The engine exposes the disc's `worldAnchor` so the renderer
|
|
350
|
+
can translate it into the hub (how the game shows it) or leave it where the
|
|
351
|
+
export put it.
|
|
352
|
+
|
|
353
|
+
## What gets filtered out
|
|
354
|
+
|
|
355
|
+
`buildScene` drops a few things that exist in the data but are not part of the
|
|
356
|
+
playable, drawn tree, to match what the official tree shows:
|
|
357
|
+
|
|
358
|
+
- **Class-start nodes** — invisible launch points; their edges would dangle.
|
|
359
|
+
- **Hidden special sockets** — the "Sinister Jewel Socket" decorations, which the
|
|
360
|
+
official tree never draws.
|
|
361
|
+
- **Conditional nodes** — entries gated by `unlockConstraint` (such as the
|
|
362
|
+
Oracle-only passives), which surface in-game only when their constraint is met
|
|
363
|
+
and are absent from the default tree.
|
|
364
|
+
- **Mastery edges** — masteries render as a background pattern, not a connectable
|
|
365
|
+
node, so edges to and from them are dropped.
|
|
366
|
+
- **Main-tree ↔ ascendancy edges** — the ascendancy is a separate, relocated
|
|
367
|
+
panel, so the link crossing that boundary is not drawn.
|
|
368
|
+
|
|
369
|
+
These are skipped in the scene; the data still contains them.
|
|
370
|
+
|
|
371
|
+
## API reference
|
|
372
|
+
|
|
373
|
+
**Pipeline** (from `@poe2-toolkit/tree-core`)
|
|
374
|
+
|
|
375
|
+
| Export | Signature | Purpose |
|
|
376
|
+
| --- | --- | --- |
|
|
377
|
+
| `buildScene` | `(data: TreeData, opts?: SceneOptions) => Scene` | World-space, render-ready scene. |
|
|
378
|
+
| `project` | `(scene: Scene, viewport: Viewport, size: Size) => ScreenScene` | Project + cull to pixels. |
|
|
379
|
+
|
|
380
|
+
**Source adapter** (from `@poe2-toolkit/tree-core/ggg`)
|
|
381
|
+
|
|
382
|
+
| Export | Signature | Purpose |
|
|
383
|
+
| --- | --- | --- |
|
|
384
|
+
| `normalizeGggTree` | `(raw: GggTreeJson, version: string) => TreeData` | Normalize GGG's official export into `TreeData`. |
|
|
385
|
+
|
|
386
|
+
**Projection and hit-testing**
|
|
387
|
+
|
|
388
|
+
| Export | Purpose |
|
|
389
|
+
| --- | --- |
|
|
390
|
+
| `projectPoint` | World point to pixels. |
|
|
391
|
+
| `screenToWorld` | Pixels to a world point. |
|
|
392
|
+
| `nodeAt` | Skill id under a pixel, or `null`. |
|
|
393
|
+
| `nodePosition` | World position of a node by skill id. |
|
|
394
|
+
|
|
395
|
+
**Layout helpers**
|
|
396
|
+
|
|
397
|
+
| Export | Purpose |
|
|
398
|
+
| --- | --- |
|
|
399
|
+
| `computeCentreLayout` | Hub geometry + per-class ring rotation. |
|
|
400
|
+
| `placeConnection` | Resolve one edge to a line or an arc. |
|
|
401
|
+
| `classifyNode` | Node's render `kind`. |
|
|
402
|
+
| `nodeTargetSize` | Derived icon/overlay/effect sizes. |
|
|
403
|
+
| `chosenAttributeOption` | The Str/Dex/Int option a build picked for a node. |
|
|
404
|
+
| `allocatedBounds` | World bounds of the allocated nodes. |
|
|
405
|
+
| `classBounds` | World bounds of a class's tree sector. |
|
|
406
|
+
|
|
407
|
+
**Interactive editing**
|
|
408
|
+
|
|
409
|
+
| Export | Purpose |
|
|
410
|
+
| --- | --- |
|
|
411
|
+
| `buildTreeGraph` | Walkable adjacency graph of the main tree. |
|
|
412
|
+
| `toggleAllocation` | Allocate the path to a node, or remove from it. |
|
|
413
|
+
| `pathToNode` | Shortest path (BFS) between a node set and a target. |
|
|
414
|
+
| `reachable` | Nodes still connected to a set of roots. |
|
|
415
|
+
| `removalSet` | Exactly which nodes a removal click drops. |
|
|
416
|
+
| `buildAscendancyGraph` | Adjacency graph of one ascendancy. |
|
|
417
|
+
| `ascendancyStartNode` | An ascendancy's pathing root. |
|
|
418
|
+
| `toggleAscendancyAllocation` | Edit within one ascendancy's subgraph. |
|
|
419
|
+
|
|
420
|
+
All types are exported from the main entry as well (`TreeData`, `TreeNode`,
|
|
421
|
+
`Scene`, `PlacedNode`, `BuildAllocation`, `Viewport`, `ScreenScene`,
|
|
422
|
+
`SpriteManifest`, and the rest). The GGG raw shape `GggTreeJson` is exported from
|
|
423
|
+
the `@poe2-toolkit/tree-core/ggg` subpath.
|
|
424
|
+
|
|
425
|
+
## Design principles
|
|
426
|
+
|
|
427
|
+
- **Data in, scene out.** Fidelity is a property of the data. No magic-constant
|
|
428
|
+
positioning or sizing leaks into application code.
|
|
429
|
+
- **Pure and headless.** Zero runtime dependencies. No DOM, no canvas, no
|
|
430
|
+
framework. Every function is a deterministic transform over plain data.
|
|
431
|
+
- **Source-agnostic core.** The engine entry point knows only the `TreeData`
|
|
432
|
+
contract; data-source adapters live in their own subpaths (`@poe2-toolkit/tree-core/ggg`
|
|
433
|
+
today). Swapping or adding a source touches nothing downstream.
|
|
434
|
+
- **One boundary, many producers.** The engine takes an allocation and nothing
|
|
435
|
+
more. Import/export formats, OAuth, and manual editors all just produce that
|
|
436
|
+
allocation.
|
|
437
|
+
- **Tolerant normalization.** An adapter is the only code that knows a raw export
|
|
438
|
+
shape, and it never throws on missing or shifting fields.
|
|
439
|
+
|
|
440
|
+
## License and credits
|
|
441
|
+
|
|
442
|
+
MIT.
|
|
443
|
+
|
|
444
|
+
Path of Exile 2 and its passive-tree data are © Grinding Gear Games. This package
|
|
445
|
+
ships **no game art or data** — only code. Consumers supply the tree data and
|
|
446
|
+
graphics themselves and are responsible for their own use of GGG's assets. This
|
|
447
|
+
project is not affiliated with or endorsed by Grinding Gear Games.
|
|
448
|
+
|
|
449
|
+
The derived node sizes and the central-hub rendering rules follow the conventions
|
|
450
|
+
established by [Path of Building Community](https://github.com/PathOfBuildingCommunity),
|
|
451
|
+
the reference open-source Path of Exile tool.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central hub geometry: the size and orientation of the middle circle, and how
|
|
3
|
+
* its gold ring rotates to point at the active class.
|
|
4
|
+
*
|
|
5
|
+
* Three concentric layers sit at the hub (the origin): the static class
|
|
6
|
+
* portrait, a rotating gold ring, and a static ornate frame. The ring's
|
|
7
|
+
* rotation is the only moving part, and it is derived — not eyeballed — from the
|
|
8
|
+
* direction of the class's start node:
|
|
9
|
+
*
|
|
10
|
+
* ringRotation = π/2 + atan2(startNode.y − centre.y, startNode.x − centre.x)
|
|
11
|
+
*
|
|
12
|
+
* That same atan2 direction is where the class sits on the rim (its start
|
|
13
|
+
* "leaf").
|
|
14
|
+
*/
|
|
15
|
+
import type { CentreLayout, TreeData } from '../types.js';
|
|
16
|
+
export declare function computeCentreLayout(data: TreeData): CentreLayout;
|
|
17
|
+
//# sourceMappingURL=centre.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"centre.d.ts","sourceRoot":"","sources":["../../src/geometry/centre.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAe,QAAQ,EAAE,MAAM,aAAa,CAAC;AAKvE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,QAAQ,GAAG,YAAY,CA+ChE"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central hub geometry: the size and orientation of the middle circle, and how
|
|
3
|
+
* its gold ring rotates to point at the active class.
|
|
4
|
+
*
|
|
5
|
+
* Three concentric layers sit at the hub (the origin): the static class
|
|
6
|
+
* portrait, a rotating gold ring, and a static ornate frame. The ring's
|
|
7
|
+
* rotation is the only moving part, and it is derived — not eyeballed — from the
|
|
8
|
+
* direction of the class's start node:
|
|
9
|
+
*
|
|
10
|
+
* ringRotation = π/2 + atan2(startNode.y − centre.y, startNode.x − centre.x)
|
|
11
|
+
*
|
|
12
|
+
* That same atan2 direction is where the class sits on the rim (its start
|
|
13
|
+
* "leaf").
|
|
14
|
+
*/
|
|
15
|
+
import { nodePosition } from './orbit.js';
|
|
16
|
+
const HALF_PI = Math.PI / 2;
|
|
17
|
+
export function computeCentreLayout(data) {
|
|
18
|
+
const first = data.classes[0];
|
|
19
|
+
// The hub is shared by every class; fall back to the origin if there are no
|
|
20
|
+
// classes (a degenerate tree) so the layout is always well-formed.
|
|
21
|
+
const centre = first ? { x: first.centre.x, y: first.centre.y } : { x: 0, y: 0 };
|
|
22
|
+
// World radius = native layer width (each layer is drawn at 2*width centred).
|
|
23
|
+
const ring = first
|
|
24
|
+
? {
|
|
25
|
+
artRadius: first.centre.art.width,
|
|
26
|
+
activeRadius: first.centre.active.width,
|
|
27
|
+
frameRadius: first.centre.frame.width,
|
|
28
|
+
}
|
|
29
|
+
: { artRadius: 0, activeRadius: 0, frameRadius: 0 };
|
|
30
|
+
const classes = [];
|
|
31
|
+
for (const cls of data.classes) {
|
|
32
|
+
if (cls.startNode < 0 || !data.nodes[cls.startNode]) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Skip PoE1 placeholder classes (Marauder/Duelist/Shadow/Templar): GGG keeps
|
|
36
|
+
// their array slots and pairs each with a real class on a shared start node,
|
|
37
|
+
// but they have no ascendancies and are not playable in PoE2.
|
|
38
|
+
if (cls.ascendancies.length === 0) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const start = nodePosition(data, cls.startNode);
|
|
42
|
+
const startAngle = Math.atan2(start.y - centre.y, start.x - centre.x);
|
|
43
|
+
classes.push({
|
|
44
|
+
classId: cls.id,
|
|
45
|
+
name: cls.name,
|
|
46
|
+
startNode: cls.startNode,
|
|
47
|
+
startAngle,
|
|
48
|
+
ringRotation: HALF_PI + startAngle,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
centre,
|
|
53
|
+
innerRadius: data.constants.centreInnerRadius,
|
|
54
|
+
ring,
|
|
55
|
+
classes,
|
|
56
|
+
ascendancies: data.classes.flatMap((cls) => cls.ascendancies),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=centre.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"centre.js","sourceRoot":"","sources":["../../src/geometry/centre.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1C,MAAM,OAAO,GAAG,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;AAE5B,MAAM,UAAU,mBAAmB,CAAC,IAAc;IAChD,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC9B,4EAA4E;IAC5E,mEAAmE;IACnE,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;IACjF,8EAA8E;IAC9E,MAAM,IAAI,GAAG,KAAK;QAChB,CAAC,CAAC;YACE,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK;YACjC,YAAY,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK;YACvC,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK;SACtC;QACH,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC;IAEtD,MAAM,OAAO,GAAkB,EAAE,CAAC;IAElC,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QAC/B,IAAI,GAAG,CAAC,SAAS,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;YACpD,SAAS;QACX,CAAC;QAED,6EAA6E;QAC7E,6EAA6E;QAC7E,8DAA8D;QAC9D,IAAI,GAAG,CAAC,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAClC,SAAS;QACX,CAAC;QAED,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC;QAChD,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QAEtE,OAAO,CAAC,IAAI,CAAC;YACX,OAAO,EAAE,GAAG,CAAC,EAAE;YACf,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,SAAS,EAAE,GAAG,CAAC,SAAS;YACxB,UAAU;YACV,YAAY,EAAE,OAAO,GAAG,UAAU;SACnC,CAAC,CAAC;IACL,CAAC;IAED,OAAO;QACL,MAAM;QACN,WAAW,EAAE,IAAI,CAAC,SAAS,CAAC,iBAAiB;QAC7C,IAAI;QACJ,OAAO;QACP,YAAY,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,YAAY,CAAC;KAC9D,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* World-space framing helpers: compute the bounding box the view should focus
|
|
3
|
+
* on, so callers can pan/zoom to a build's allocation or a single class's
|
|
4
|
+
* region instead of the whole tree.
|
|
5
|
+
*/
|
|
6
|
+
import type { Scene, WorldRect } from '../types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Bounds of the allocated main-tree nodes (ascendancy nodes excluded — they live
|
|
9
|
+
* in the relocated hub panel). `null` when nothing is allocated.
|
|
10
|
+
*/
|
|
11
|
+
export declare function allocatedBounds(scene: Scene): WorldRect | null;
|
|
12
|
+
/**
|
|
13
|
+
* Allocated bounds grown to include the centre hub, so a view framed on a fresh
|
|
14
|
+
* import keeps the class portrait at the tree's centre in shot. `allocatedBounds`
|
|
15
|
+
* alone hugs only the outlying nodes and pushes the portrait off-screen — the
|
|
16
|
+
* very art the import is meant to reveal. `null` when nothing main-tree is
|
|
17
|
+
* allocated (callers fall back to the renderer's default hub-centred view).
|
|
18
|
+
*/
|
|
19
|
+
export declare function allocatedBoundsWithCentre(scene: Scene): WorldRect | null;
|
|
20
|
+
/**
|
|
21
|
+
* Bounds of the tree sector belonging to one class. The tree radiates from the
|
|
22
|
+
* hub and each class owns a rim direction (`ClassAnchor.startAngle`); a main
|
|
23
|
+
* node belongs to the class whose direction its bearing from the centre is
|
|
24
|
+
* closest to. `null` for an unknown class or an empty sector.
|
|
25
|
+
*/
|
|
26
|
+
export declare function classBounds(scene: Scene, classId: number): WorldRect | null;
|
|
27
|
+
//# sourceMappingURL=framing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"framing.d.ts","sourceRoot":"","sources":["../../src/geometry/framing.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAc,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AA8BhE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,IAAI,CAU9D;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,IAAI,CAgBxE;AAiBD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAkC3E"}
|