@opsydyn/effect-three-foldkit 0.3.0 → 0.6.4
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/CHANGELOG.md +51 -0
- package/README.md +4 -2
- package/dist/assetRegistry.d.ts +49 -0
- package/dist/assetRegistry.d.ts.map +1 -0
- package/dist/assets.d.ts +18 -5
- package/dist/assets.d.ts.map +1 -1
- package/dist/boxSelect.d.ts +150 -0
- package/dist/boxSelect.d.ts.map +1 -0
- package/dist/dom/canvas.d.ts +7 -0
- package/dist/dom/canvas.d.ts.map +1 -1
- package/dist/dom/pointer.d.ts.map +1 -1
- package/dist/dom/webgl.d.ts.map +1 -1
- package/dist/dom/webgpu.d.ts.map +1 -1
- package/dist/element.d.ts +60 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/environment.d.ts +6 -0
- package/dist/environment.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +745 -60
- package/dist/index.js.map +19 -12
- package/dist/inspector.d.ts +24 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/measuringTape.d.ts +76 -0
- package/dist/measuringTape.d.ts.map +1 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/pivotControls.d.ts +106 -0
- package/dist/pivotControls.d.ts.map +1 -0
- package/dist/transformGizmo.d.ts +158 -0
- package/dist/transformGizmo.d.ts.map +1 -0
- package/dist/view.d.ts +23 -0
- package/dist/view.d.ts.map +1 -1
- package/dist/viewportGizmo.d.ts +146 -0
- package/dist/viewportGizmo.d.ts.map +1 -0
- package/package.json +8 -8
- package/src/assetRegistry.ts +218 -0
- package/src/assets.ts +96 -39
- package/src/boxSelect.ts +158 -0
- package/src/dom/canvas.ts +12 -0
- package/src/dom/pointer.ts +14 -1
- package/src/dom/webgl.ts +15 -0
- package/src/dom/webgpu.ts +15 -0
- package/src/element.ts +6 -0
- package/src/environment.ts +6 -1
- package/src/index.ts +10 -0
- package/src/inspector.ts +112 -0
- package/src/measuringTape.ts +124 -0
- package/src/mount.ts +24 -1
- package/src/pivotControls.ts +118 -0
- package/src/transformGizmo.ts +131 -0
- package/src/view.ts +28 -0
- package/src/viewportGizmo.ts +119 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsydyn/effect-three-foldkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Thin Foldkit adapter for @opsydyn/effect-three.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,17 +44,17 @@
|
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@arethetypeswrong/cli": "0.18.5",
|
|
47
|
-
"@effect/platform-browser": "4.
|
|
47
|
+
"@effect/platform-browser": "4.1.0",
|
|
48
48
|
"@fontsource/inter": "5.3.0",
|
|
49
49
|
"@microsoft/api-extractor": "7.59.4",
|
|
50
|
-
"@opsydyn/effect-three": "0.
|
|
51
|
-
"@opsydyn/effect-three-troika": "0.
|
|
50
|
+
"@opsydyn/effect-three": "0.6.3",
|
|
51
|
+
"@opsydyn/effect-three-troika": "0.6.3",
|
|
52
52
|
"@opsydyn/oxlint-effect": "2.0.1",
|
|
53
53
|
"@types/bun": "1.4.3",
|
|
54
54
|
"@types/pngjs": "6.0.5",
|
|
55
55
|
"@types/three": "0.186.0",
|
|
56
|
-
"effect": "4.
|
|
57
|
-
"foldkit": "0.
|
|
56
|
+
"effect": "4.1.0",
|
|
57
|
+
"foldkit": "0.170.0",
|
|
58
58
|
"iwer": "2.5.0",
|
|
59
59
|
"oxlint": "1.88.0",
|
|
60
60
|
"oxlint-tsgolint": "7.0.2004",
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"typescript": "7.0.2"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
|
-
"@opsydyn/effect-three": "0.
|
|
69
|
+
"@opsydyn/effect-three": "0.6.4",
|
|
70
70
|
"effect": "^4.0.0",
|
|
71
|
-
"foldkit": "0.167.0",
|
|
71
|
+
"foldkit": ">=0.167.0 <0.171.0",
|
|
72
72
|
"three": ">=0.186.0 <0.187.0"
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assets scenes ask for with `scene({ assets })` (ADR-015), held for the environment:
|
|
3
|
+
* each loaded once however many scenes ask, lent to the store under its id, its load
|
|
4
|
+
* cancelled when no scene asks for it any more, and withdrawn and disposed when the last
|
|
5
|
+
* scene stops asking. Each scene hears whether what it asked for loaded or failed.
|
|
6
|
+
*
|
|
7
|
+
* An asset with coarser `levels` streams in: every level loads at once, each is lent as it
|
|
8
|
+
* arrives if it is finer than what is shown (replacing it), and once the finest is shown
|
|
9
|
+
* the coarser loads still under way are cancelled. Only the finest failing fails it.
|
|
10
|
+
*
|
|
11
|
+
* An id names one asset at a time: a scene asking for an id another scene holds as
|
|
12
|
+
* something else (another kind or URL) is told it failed, and gets it once the other lets
|
|
13
|
+
* go.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import { type Asset, assetsEqual } from '@opsydyn/effect-three';
|
|
18
|
+
|
|
19
|
+
/** An asset as loaded and lent: what a model offers, and how to withdraw and free it. */
|
|
20
|
+
export type Lent = {
|
|
21
|
+
readonly parts: ReadonlyArray<string>;
|
|
22
|
+
readonly clips: ReadonlyArray<string>;
|
|
23
|
+
readonly release: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Loads one asset and lends it under its id; gives up (rejecting) when `signal` aborts. */
|
|
27
|
+
export type AssetLoader = (asset: Asset, signal: AbortSignal) => Promise<Lent>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a scene hears about an asset it asked for: a level of detail shown (0 for the
|
|
31
|
+
* coarsest of its levels, `levels.length` for its URL, the finest), or its finest failed.
|
|
32
|
+
*/
|
|
33
|
+
export type AssetNotice =
|
|
34
|
+
| ({
|
|
35
|
+
readonly _tag: 'Loaded';
|
|
36
|
+
readonly level: number;
|
|
37
|
+
readonly final: boolean;
|
|
38
|
+
} & Asset &
|
|
39
|
+
Pick<Lent, 'parts' | 'clips'>)
|
|
40
|
+
| ({ readonly _tag: 'Failed'; readonly reason: string } & Asset);
|
|
41
|
+
|
|
42
|
+
/** Who asks: a mounted scene, by any object that stands for it. */
|
|
43
|
+
type Holder = object;
|
|
44
|
+
|
|
45
|
+
type Notify = (notice: AssetNotice) => void;
|
|
46
|
+
|
|
47
|
+
type Entry = {
|
|
48
|
+
readonly asset: Asset;
|
|
49
|
+
readonly holders: Set<Holder>;
|
|
50
|
+
/** One a level, coarsest first: aborted once a finer level is shown, or the entry ends. */
|
|
51
|
+
readonly controllers: ReadonlyArray<AbortController>;
|
|
52
|
+
/** The finest level lent so far. */
|
|
53
|
+
shown: { readonly level: number; readonly lent: Lent } | undefined;
|
|
54
|
+
/** Why the finest level could not be loaded, once it could not. */
|
|
55
|
+
failure: string | undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const sameAsset = (a: Asset, b: Asset): boolean =>
|
|
59
|
+
a.kind === b.kind &&
|
|
60
|
+
a.url === b.url &&
|
|
61
|
+
a.levels.length === b.levels.length &&
|
|
62
|
+
a.levels.every((level, i) => level === b.levels[i]);
|
|
63
|
+
|
|
64
|
+
/** Every level's URL, coarsest first: the asset's own URL is the last, and finest. */
|
|
65
|
+
const urlsOf = (asset: Asset): ReadonlyArray<string> => [...asset.levels, asset.url];
|
|
66
|
+
|
|
67
|
+
const reasonOf = (asset: Asset, cause: unknown): string =>
|
|
68
|
+
cause instanceof Error && cause.message !== ''
|
|
69
|
+
? `Could not load ${asset.url}: ${cause.message}`
|
|
70
|
+
: `Could not load ${asset.url}`;
|
|
71
|
+
|
|
72
|
+
const taken = (asset: Asset, holding: Asset): AssetNotice => ({
|
|
73
|
+
_tag: 'Failed',
|
|
74
|
+
...asset,
|
|
75
|
+
reason: `"${asset.id}" is already ${holding.url} for another scene`,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Creates the registry for one environment, loading with `load`. */
|
|
79
|
+
export const makeAssetRegistry = (load: AssetLoader) => {
|
|
80
|
+
const entries = new Map<string, Entry>();
|
|
81
|
+
/** What each scene asks for now, and how to tell it. */
|
|
82
|
+
const asking = new Map<
|
|
83
|
+
Holder,
|
|
84
|
+
{ readonly assets: ReadonlyArray<Asset>; readonly notify: Notify }
|
|
85
|
+
>();
|
|
86
|
+
|
|
87
|
+
const tellAll = (entry: Entry, notice: AssetNotice): void => {
|
|
88
|
+
for (const holder of entry.holders) asking.get(holder)?.notify(notice);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const loadedNotice = (entry: Entry, level: number, lent: Lent): AssetNotice => ({
|
|
92
|
+
_tag: 'Loaded',
|
|
93
|
+
...entry.asset,
|
|
94
|
+
level,
|
|
95
|
+
final: level === entry.asset.levels.length,
|
|
96
|
+
parts: lent.parts,
|
|
97
|
+
clips: lent.clips,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** What a scene that starts holding an entry hears at once: what is shown, and a failure. */
|
|
101
|
+
const noticesOf = (entry: Entry): ReadonlyArray<AssetNotice> => [
|
|
102
|
+
...(entry.shown === undefined
|
|
103
|
+
? []
|
|
104
|
+
: [loadedNotice(entry, entry.shown.level, entry.shown.lent)]),
|
|
105
|
+
...(entry.failure === undefined
|
|
106
|
+
? []
|
|
107
|
+
: [{ _tag: 'Failed' as const, ...entry.asset, reason: entry.failure }]),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/** Starts loading every level of `asset`; what comes back too late is freed unseen. */
|
|
111
|
+
const start = (asset: Asset): Entry => {
|
|
112
|
+
const urls = urlsOf(asset);
|
|
113
|
+
const finest = urls.length - 1;
|
|
114
|
+
const entry: Entry = {
|
|
115
|
+
asset,
|
|
116
|
+
holders: new Set(),
|
|
117
|
+
controllers: urls.map(() => new AbortController()),
|
|
118
|
+
shown: undefined,
|
|
119
|
+
failure: undefined,
|
|
120
|
+
};
|
|
121
|
+
entries.set(asset.id, entry);
|
|
122
|
+
function current(level: number): boolean {
|
|
123
|
+
return entries.get(asset.id) === entry && entry.controllers[level]?.signal.aborted === false;
|
|
124
|
+
}
|
|
125
|
+
function loaded(level: number) {
|
|
126
|
+
return (lent: Lent): void => {
|
|
127
|
+
if (!current(level) || (entry.shown !== undefined && entry.shown.level >= level)) {
|
|
128
|
+
lent.release();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Lent first, then the coarser one freed: its withdrawal leaves the new loan.
|
|
132
|
+
const before = entry.shown;
|
|
133
|
+
entry.shown = { level, lent };
|
|
134
|
+
before?.lent.release();
|
|
135
|
+
if (level === finest)
|
|
136
|
+
for (const coarser of entry.controllers.slice(0, level)) coarser.abort();
|
|
137
|
+
tellAll(entry, loadedNotice(entry, level, lent));
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function failed(level: number) {
|
|
141
|
+
return (cause: unknown): void => {
|
|
142
|
+
// A coarser level that fails is skipped; only the finest failing fails the asset.
|
|
143
|
+
if (!current(level) || level !== finest) return;
|
|
144
|
+
const reason = reasonOf({ ...asset, url: urls[level] ?? asset.url }, cause);
|
|
145
|
+
entry.failure = reason;
|
|
146
|
+
tellAll(entry, { _tag: 'Failed', ...asset, reason });
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
urls.forEach((url, level) => {
|
|
150
|
+
const signal = entry.controllers[level]?.signal ?? AbortSignal.abort();
|
|
151
|
+
load({ ...asset, url }, signal).then(loaded(level), failed(level));
|
|
152
|
+
});
|
|
153
|
+
return entry;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Ends an entry: its loads cancelled, and what it lent withdrawn and freed. */
|
|
157
|
+
const end = (entry: Entry): void => {
|
|
158
|
+
entries.delete(entry.asset.id);
|
|
159
|
+
for (const controller of entry.controllers) controller.abort();
|
|
160
|
+
entry.shown?.lent.release();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** `holder` lets go of `id`; the last to let go ends it, and whoever waited on it asks again. */
|
|
164
|
+
const letGo = (holder: Holder, id: string): void => {
|
|
165
|
+
const entry = entries.get(id);
|
|
166
|
+
if (entry === undefined || !entry.holders.delete(holder) || entry.holders.size > 0) return;
|
|
167
|
+
end(entry);
|
|
168
|
+
for (const [waiting, { assets }] of asking) {
|
|
169
|
+
if (assets.some((asset) => asset.id === id)) hold(waiting, assets);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** `holder` takes each of `assets` it does not hold yet. */
|
|
174
|
+
const hold = (holder: Holder, assets: ReadonlyArray<Asset>): void => {
|
|
175
|
+
for (const asset of assets) {
|
|
176
|
+
const entry = entries.get(asset.id) ?? start(asset);
|
|
177
|
+
if (!sameAsset(entry.asset, asset)) {
|
|
178
|
+
asking.get(holder)?.notify(taken(asset, entry.asset));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (entry.holders.has(holder)) continue;
|
|
182
|
+
entry.holders.add(holder);
|
|
183
|
+
for (const notice of noticesOf(entry)) asking.get(holder)?.notify(notice);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What `holder` asks for now: loads what is new, lets go of what it no longer asks for
|
|
189
|
+
* (or asks for as something else), and tells it, through `notify`, when each asset it
|
|
190
|
+
* holds has loaded or failed (at once, for one loaded already).
|
|
191
|
+
*/
|
|
192
|
+
const want = (holder: Holder, assets: ReadonlyArray<Asset>, notify: Notify): void => {
|
|
193
|
+
const before = asking.get(holder);
|
|
194
|
+
asking.set(holder, { assets, notify });
|
|
195
|
+
if (before !== undefined && assetsEqual(before.assets, assets)) return;
|
|
196
|
+
for (const old of before?.assets ?? []) {
|
|
197
|
+
const still = assets.find((asset) => asset.id === old.id);
|
|
198
|
+
if (still === undefined || !sameAsset(still, old)) letGo(holder, old.id);
|
|
199
|
+
}
|
|
200
|
+
hold(holder, assets);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** `holder` (a scene going away) asks for nothing any more. */
|
|
204
|
+
const release = (holder: Holder): void => {
|
|
205
|
+
want(holder, [], () => undefined);
|
|
206
|
+
asking.delete(holder);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Ends every load and frees every asset (the environment going away). */
|
|
210
|
+
const dispose = (): void => {
|
|
211
|
+
asking.clear();
|
|
212
|
+
for (const entry of [...entries.values()]) end(entry);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return { want, release, dispose };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export type AssetRegistry = ReturnType<typeof makeAssetRegistry>;
|
package/src/assets.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loading textures, glTF models and typeface fonts by URL
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Loading textures, glTF models, HDR panoramas and typeface fonts by URL. Each asset is
|
|
3
|
+
* lent to the shared resource store under an id: materials use a texture with `map: id`,
|
|
4
|
+
* a `borrowed({ id })` node shows a model, `environment({ map: id })` a panorama, and
|
|
5
|
+
* `text({ font: id })` geometry draws with a font.
|
|
6
|
+
*
|
|
7
|
+
* Two ways in. A scene can ask for assets in its description (`scene({ assets })`), and
|
|
8
|
+
* the environment's registry loads, shares, cancels and frees them as scenes ask
|
|
9
|
+
* (`assetLoader` is what it loads with; ADR-015). Or an application loads one with an
|
|
10
|
+
* Effect from a Command and settles it into `AsyncData`; the environment owns what it
|
|
11
|
+
* loads that way, loading an id again replaces and disposes the earlier asset, and
|
|
12
|
+
* `unload` or the environment's `dispose` frees it. Use one way for any one id.
|
|
7
13
|
*
|
|
8
14
|
* @module
|
|
9
15
|
*/
|
|
10
|
-
import type { ResourceStore } from '@opsydyn/effect-three';
|
|
16
|
+
import type { Asset, AssetKind, ResourceStore } from '@opsydyn/effect-three';
|
|
11
17
|
import { Data, Effect } from 'effect';
|
|
12
18
|
import {
|
|
13
19
|
EquirectangularReflectionMapping,
|
|
20
|
+
LoadingManager,
|
|
14
21
|
type Object3D,
|
|
15
22
|
SRGBColorSpace,
|
|
16
23
|
type Texture,
|
|
@@ -20,6 +27,7 @@ import { type Font, FontLoader } from 'three/addons/loaders/FontLoader.js';
|
|
|
20
27
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
21
28
|
import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
|
|
22
29
|
|
|
30
|
+
import type { AssetLoader, Lent } from './assetRegistry.ts';
|
|
23
31
|
import { disposeModel, namesIn } from './dom/models.ts';
|
|
24
32
|
|
|
25
33
|
/** An asset could not be fetched or decoded. */
|
|
@@ -37,6 +45,78 @@ export type ModelInfo = {
|
|
|
37
45
|
readonly clips: ReadonlyArray<string>;
|
|
38
46
|
};
|
|
39
47
|
|
|
48
|
+
type Gltf = { readonly scene: Object3D; readonly animations: Object3D['animations'] };
|
|
49
|
+
|
|
50
|
+
const nothing: ReadonlyArray<string> = [];
|
|
51
|
+
|
|
52
|
+
/** What lending an asset gives back: no parts or clips, and how to withdraw and free it. */
|
|
53
|
+
const plain = (release: () => void): Lent => ({ parts: nothing, clips: nothing, release });
|
|
54
|
+
|
|
55
|
+
/** Lends a colour texture (sRGB). */
|
|
56
|
+
const lentTexture = (store: ResourceStore, id: string, texture: Texture): Lent => {
|
|
57
|
+
texture.colorSpace = SRGBColorSpace;
|
|
58
|
+
const withdraw = store.lendTexture(id, texture);
|
|
59
|
+
return plain(() => {
|
|
60
|
+
withdraw();
|
|
61
|
+
texture.dispose();
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Lends a model's scene, keeping its clips with it so a borrowed node can play them. */
|
|
66
|
+
const lentModel = (store: ResourceStore, id: string, gltf: Gltf): Lent => {
|
|
67
|
+
gltf.scene.animations = gltf.animations;
|
|
68
|
+
const withdraw = store.lend(id, gltf.scene);
|
|
69
|
+
return {
|
|
70
|
+
parts: namesIn(gltf.scene),
|
|
71
|
+
clips: gltf.animations.map((clip) => clip.name),
|
|
72
|
+
release: () => {
|
|
73
|
+
withdraw();
|
|
74
|
+
disposeModel(gltf.scene);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Lends an HDR panorama for `environment({ map })`: linear, mapped for reflection. */
|
|
80
|
+
const lentEnvironment = (store: ResourceStore, id: string, texture: Texture): Lent => {
|
|
81
|
+
texture.mapping = EquirectangularReflectionMapping;
|
|
82
|
+
const withdraw = store.lendTexture(id, texture);
|
|
83
|
+
return plain(() => {
|
|
84
|
+
withdraw();
|
|
85
|
+
texture.dispose();
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Lends a font; fonts hold no GPU resources, so withdrawing the loan frees them. */
|
|
90
|
+
const lentFont = (store: ResourceStore, id: string, font: Font): Lent =>
|
|
91
|
+
plain(store.lendFont(id, font));
|
|
92
|
+
|
|
93
|
+
type LoadAndLend = (store: ResourceStore, asset: Asset, manager: LoadingManager) => Promise<Lent>;
|
|
94
|
+
|
|
95
|
+
/** How each kind of asset loads, with a loading manager of its own, and is lent. */
|
|
96
|
+
const loadAndLend = {
|
|
97
|
+
model: async (store, { id, url }, manager) =>
|
|
98
|
+
lentModel(store, id, await new GLTFLoader(manager).loadAsync(url)),
|
|
99
|
+
texture: async (store, { id, url }, manager) =>
|
|
100
|
+
lentTexture(store, id, await new TextureLoader(manager).loadAsync(url)),
|
|
101
|
+
environment: async (store, { id, url }, manager) =>
|
|
102
|
+
lentEnvironment(store, id, await new HDRLoader(manager).loadAsync(url)),
|
|
103
|
+
font: async (store, { id, url }, manager) =>
|
|
104
|
+
lentFont(store, id, await new FontLoader(manager).loadAsync(url)),
|
|
105
|
+
} satisfies Record<AssetKind, LoadAndLend>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What the environment's registry loads scenes' assets with: three's loaders, each load
|
|
109
|
+
* with a loading manager of its own, so aborting it cancels its requests (three's file
|
|
110
|
+
* and image-bitmap loaders honour the manager's abort).
|
|
111
|
+
*/
|
|
112
|
+
export const assetLoader =
|
|
113
|
+
(store: ResourceStore): AssetLoader =>
|
|
114
|
+
(asset, signal) => {
|
|
115
|
+
const manager = new LoadingManager();
|
|
116
|
+
signal.addEventListener('abort', () => manager.abort(), { once: true });
|
|
117
|
+
return loadAndLend[asset.kind](store, asset, manager);
|
|
118
|
+
};
|
|
119
|
+
|
|
40
120
|
/** Creates the asset loader for a store. One per environment. */
|
|
41
121
|
export const makeAssets = (store: ResourceStore) => {
|
|
42
122
|
const releases = new Map<string, () => void>();
|
|
@@ -48,47 +128,24 @@ export const makeAssets = (store: ResourceStore) => {
|
|
|
48
128
|
};
|
|
49
129
|
|
|
50
130
|
const lendTexture = (id: string) => (texture: Texture) => {
|
|
51
|
-
texture.
|
|
52
|
-
const withdraw = store.lendTexture(id, texture);
|
|
53
|
-
own(id, () => {
|
|
54
|
-
withdraw();
|
|
55
|
-
texture.dispose();
|
|
56
|
-
});
|
|
131
|
+
own(id, lentTexture(store, id, texture).release);
|
|
57
132
|
return id;
|
|
58
133
|
};
|
|
59
134
|
|
|
60
|
-
const lendModel =
|
|
61
|
-
(id
|
|
62
|
-
(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
withdraw();
|
|
68
|
-
disposeModel(gltf.scene);
|
|
69
|
-
});
|
|
70
|
-
const info: ModelInfo = {
|
|
71
|
-
id,
|
|
72
|
-
parts: namesIn(gltf.scene),
|
|
73
|
-
clips: gltf.animations.map((clip) => clip.name),
|
|
74
|
-
};
|
|
75
|
-
return info;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/** Lends an HDR panorama for `environment({ map })`: linear, mapped for reflection. */
|
|
135
|
+
const lendModel = (id: string) => (gltf: Gltf) => {
|
|
136
|
+
const lent = lentModel(store, id, gltf);
|
|
137
|
+
own(id, lent.release);
|
|
138
|
+
const info: ModelInfo = { id, parts: lent.parts, clips: lent.clips };
|
|
139
|
+
return info;
|
|
140
|
+
};
|
|
141
|
+
|
|
79
142
|
const lendEnvironment = (id: string) => (texture: Texture) => {
|
|
80
|
-
texture.
|
|
81
|
-
const withdraw = store.lendTexture(id, texture);
|
|
82
|
-
own(id, () => {
|
|
83
|
-
withdraw();
|
|
84
|
-
texture.dispose();
|
|
85
|
-
});
|
|
143
|
+
own(id, lentEnvironment(store, id, texture).release);
|
|
86
144
|
return id;
|
|
87
145
|
};
|
|
88
146
|
|
|
89
147
|
const lendFont = (id: string) => (font: Font) => {
|
|
90
|
-
|
|
91
|
-
own(id, store.lendFont(id, font));
|
|
148
|
+
own(id, lentFont(store, id, font).release);
|
|
92
149
|
return id;
|
|
93
150
|
};
|
|
94
151
|
|
package/src/boxSelect.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box selection as a Foldkit submodel (roadmap, "Beyond parity" 5), after Drei's `Select`
|
|
3
|
+
* over three.js's `SelectionBox`: a shift-drag that begins on nothing draws a box, and
|
|
4
|
+
* its release selects what the box holds, through the core's pure `boxSelect`. The parent
|
|
5
|
+
* owns what is selected; the submodel owns only the box:
|
|
6
|
+
*
|
|
7
|
+
* - `Dragged` (each scene `Drag` gesture the box `claims`, made with `dragOf`) starts the box
|
|
8
|
+
* where the press was and moves its far corner by the gesture's deltas;
|
|
9
|
+
* - `Ended` (the scene's `GestureEnd`, with the camera and what can be selected) reports
|
|
10
|
+
* `Selected { keys }`, which replaces the selection as Drei's box does, and clears the
|
|
11
|
+
* box;
|
|
12
|
+
* - `Cancelled` clears the box and selects nothing.
|
|
13
|
+
*
|
|
14
|
+
* `rectangle(model)` places the box on the canvas in CSS pixels, for an HTML outline, and
|
|
15
|
+
* `inside(model, camera, selectable)` says what the box holds so far, for highlighting
|
|
16
|
+
* while it is drawn (Drei selects as the box moves).
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import { boxSelect, type Camera, type Gesture, SceneSchema } from '@opsydyn/effect-three';
|
|
21
|
+
import { Option, Schema } from 'effect';
|
|
22
|
+
import { defineMessageUnion } from 'foldkit/message';
|
|
23
|
+
import type { ReturnWithOutMessage } from 'foldkit/update';
|
|
24
|
+
|
|
25
|
+
const Corner = Schema.Struct({ x: Schema.Finite, y: Schema.Finite });
|
|
26
|
+
|
|
27
|
+
/** Something the box can select: its key, and the point that must fall inside. */
|
|
28
|
+
const Selectable = Schema.Struct({ key: Schema.String, at: SceneSchema.Vec3 });
|
|
29
|
+
|
|
30
|
+
/** A box being drawn: its corners in the canvas's normalised coordinates, −1 to 1, y up. */
|
|
31
|
+
export const Box = Schema.Struct({
|
|
32
|
+
from: Corner,
|
|
33
|
+
to: Corner,
|
|
34
|
+
width: Schema.Finite,
|
|
35
|
+
height: Schema.Finite,
|
|
36
|
+
});
|
|
37
|
+
export type Box = typeof Box.Type;
|
|
38
|
+
|
|
39
|
+
export const Model = Schema.Struct({ box: Schema.Option(Box) });
|
|
40
|
+
export type Model = typeof Model.Type;
|
|
41
|
+
|
|
42
|
+
export const Message = defineMessageUnion({
|
|
43
|
+
Dragged: {
|
|
44
|
+
x: Schema.Finite,
|
|
45
|
+
y: Schema.Finite,
|
|
46
|
+
deltaX: Schema.Finite,
|
|
47
|
+
deltaY: Schema.Finite,
|
|
48
|
+
width: Schema.Finite,
|
|
49
|
+
height: Schema.Finite,
|
|
50
|
+
},
|
|
51
|
+
Ended: { camera: SceneSchema.Camera, selectable: Schema.Array(Selectable) },
|
|
52
|
+
Cancelled: {},
|
|
53
|
+
});
|
|
54
|
+
export type Message = typeof Message.Type;
|
|
55
|
+
|
|
56
|
+
export const OutMessage = defineMessageUnion({
|
|
57
|
+
/** The box was let go: the keys inside it, for the new selection. */
|
|
58
|
+
Selected: { keys: Schema.Array(Schema.String) },
|
|
59
|
+
});
|
|
60
|
+
export type OutMessage = typeof OutMessage.Type;
|
|
61
|
+
|
|
62
|
+
/** No box drawn. */
|
|
63
|
+
export const init = (): Model => ({ box: Option.none() });
|
|
64
|
+
|
|
65
|
+
type Return = ReturnWithOutMessage<Model, Message, OutMessage>;
|
|
66
|
+
|
|
67
|
+
/** The box with its far corner moved by a drag's CSS-pixel deltas (down positive). */
|
|
68
|
+
const moved = (box: Box, deltaX: number, deltaY: number): Box => ({
|
|
69
|
+
...box,
|
|
70
|
+
to: { x: box.to.x + (deltaX * 2) / box.width, y: box.to.y - (deltaY * 2) / box.height },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const dragged = (model: Model, message: Extract<Message, { _tag: 'Dragged' }>): Model => {
|
|
74
|
+
const { x, y, deltaX, deltaY, width, height } = message;
|
|
75
|
+
const start: Box = { from: { x, y }, to: { x, y }, width, height };
|
|
76
|
+
const box = Option.getOrElse(model.box, () => start);
|
|
77
|
+
return { box: Option.some(moved(box, deltaX, deltaY)) };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type Selectables = ReadonlyArray<typeof Selectable.Type>;
|
|
81
|
+
|
|
82
|
+
const keysIn = (box: Box, camera: Camera, selectable: Selectables): ReadonlyArray<string> =>
|
|
83
|
+
boxSelect(camera, box, box.from, box.to, selectable);
|
|
84
|
+
|
|
85
|
+
const ended = (model: Model, camera: Camera, selectable: Selectables): Return =>
|
|
86
|
+
Option.match(model.box, {
|
|
87
|
+
onNone: () => ({ model }),
|
|
88
|
+
onSome: (box) => ({
|
|
89
|
+
model: init(),
|
|
90
|
+
outMessage: OutMessage.Selected({ keys: keysIn(box, camera, selectable) }),
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const update = (model: Model, message: Message): Return =>
|
|
95
|
+
Message.match<Return>(message, {
|
|
96
|
+
Dragged: (drag) => ({ model: dragged(model, drag) }),
|
|
97
|
+
Ended: ({ camera, selectable }) => ended(model, camera, selectable),
|
|
98
|
+
Cancelled: () => ({ model: init() }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** Whether a box is being drawn: the parent leaves the camera alone while it is. */
|
|
102
|
+
export const drawing = (model: Model): boolean => Option.isSome(model.box);
|
|
103
|
+
|
|
104
|
+
/** A scene `Drag` gesture: the only kind a box takes. */
|
|
105
|
+
export type DragGesture = Extract<Gesture, { readonly _tag: 'Drag' }>;
|
|
106
|
+
|
|
107
|
+
/** The primary button: a box starts only with it. */
|
|
108
|
+
const primaryButton = 0;
|
|
109
|
+
|
|
110
|
+
/** A primary-button shift-drag that began on nothing starts a box. */
|
|
111
|
+
const startsBox = (drag: DragGesture): boolean =>
|
|
112
|
+
drag.button === primaryButton && drag.modifiers.shift && drag.key === undefined;
|
|
113
|
+
|
|
114
|
+
/** Whether the box takes `drag`: every drag while one is drawn, and one that starts it. */
|
|
115
|
+
export const claims = (model: Model, drag: DragGesture): boolean =>
|
|
116
|
+
drawing(model) || startsBox(drag);
|
|
117
|
+
|
|
118
|
+
/** The `Dragged` Message for a drag the box claims. */
|
|
119
|
+
export const dragOf = (gesture: DragGesture): Message =>
|
|
120
|
+
Message.Dragged({
|
|
121
|
+
x: gesture.x,
|
|
122
|
+
y: gesture.y,
|
|
123
|
+
deltaX: gesture.deltaX,
|
|
124
|
+
deltaY: gesture.deltaY,
|
|
125
|
+
width: gesture.width,
|
|
126
|
+
height: gesture.height,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/** What the box holds so far, seen through `camera`: none while no box is drawn. */
|
|
130
|
+
export const inside = (
|
|
131
|
+
model: Model,
|
|
132
|
+
camera: Camera,
|
|
133
|
+
selectable: Selectables,
|
|
134
|
+
): ReadonlyArray<string> =>
|
|
135
|
+
Option.match(model.box, {
|
|
136
|
+
onNone: () => [],
|
|
137
|
+
onSome: (box) => keysIn(box, camera, selectable),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** A box on the canvas, in CSS pixels from its top left. */
|
|
141
|
+
export type Rectangle = {
|
|
142
|
+
readonly left: number;
|
|
143
|
+
readonly top: number;
|
|
144
|
+
readonly width: number;
|
|
145
|
+
readonly height: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onCanvas = (box: Box): Rectangle => {
|
|
149
|
+
const left = ((Math.min(box.from.x, box.to.x) + 1) / 2) * box.width;
|
|
150
|
+
const right = ((Math.max(box.from.x, box.to.x) + 1) / 2) * box.width;
|
|
151
|
+
const top = ((1 - Math.max(box.from.y, box.to.y)) / 2) * box.height;
|
|
152
|
+
const bottom = ((1 - Math.min(box.from.y, box.to.y)) / 2) * box.height;
|
|
153
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Where the box sits on the canvas, while one is drawn. */
|
|
157
|
+
export const rectangle = (model: Model): Option.Option<Rectangle> =>
|
|
158
|
+
Option.map(model.box, onCanvas);
|
package/src/dom/canvas.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Op, ResourceStore, SceneEvent } from '@opsydyn/effect-three';
|
|
7
7
|
|
|
8
|
+
import type { AssetNotice } from '../assetRegistry.ts';
|
|
8
9
|
import { domEventName, type SceneError } from '../element.ts';
|
|
9
10
|
import type { Latch } from './latch.ts';
|
|
10
11
|
import { summarise } from './reconciled.ts';
|
|
@@ -31,6 +32,17 @@ export const dispatchSceneError = (element: Element, error: SceneError): void =>
|
|
|
31
32
|
element.dispatchEvent(new CustomEvent('scene-error', { detail: { error } }));
|
|
32
33
|
};
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Dispatches what the environment says about an asset the scene asked for, as a
|
|
37
|
+
* `scene-asset-loaded` or `scene-asset-failed` event. Not held back while time travel is
|
|
38
|
+
* paused: whether an asset loaded is a fact about the page, not a frame.
|
|
39
|
+
*/
|
|
40
|
+
export const dispatchAssetNotice = (element: Element, notice: AssetNotice): void => {
|
|
41
|
+
const { _tag, ...detail } = notice;
|
|
42
|
+
const name = _tag === 'Loaded' ? 'scene-asset-loaded' : 'scene-asset-failed';
|
|
43
|
+
element.dispatchEvent(new CustomEvent(name, { detail }));
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
/** Thunk builders, so Effect code can run DOM work with `Effect.sync(thunk)`. */
|
|
35
47
|
export const canvasIn = (host: Element) => () => appendCanvas(host);
|
|
36
48
|
export const removal = (node: ChildNode) => () => node.remove();
|
package/src/dom/pointer.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
Gestures,
|
|
8
|
+
Modifiers,
|
|
9
|
+
PointerDevice,
|
|
10
|
+
PointerInput,
|
|
11
|
+
SceneEvent,
|
|
12
|
+
} from '@opsydyn/effect-three';
|
|
7
13
|
|
|
8
14
|
/** The part of `DOMRect` the conversion needs. */
|
|
9
15
|
export type Rect = {
|
|
@@ -33,6 +39,12 @@ const modifiersOf = (event: MouseEvent): Modifiers => ({
|
|
|
33
39
|
alt: event.altKey,
|
|
34
40
|
});
|
|
35
41
|
|
|
42
|
+
/** What pressed: a finger, a pen, or (for anything else, and plain mouse events) a mouse. */
|
|
43
|
+
const deviceOf = (event: MouseEvent): PointerDevice =>
|
|
44
|
+
event instanceof PointerEvent && (event.pointerType === 'touch' || event.pointerType === 'pen')
|
|
45
|
+
? event.pointerType
|
|
46
|
+
: 'mouse';
|
|
47
|
+
|
|
36
48
|
/** The pointer's id, or 0 for mouse events that are not pointer events. */
|
|
37
49
|
const pointerIdOf = (event: MouseEvent): number =>
|
|
38
50
|
event instanceof PointerEvent ? event.pointerId : 0;
|
|
@@ -64,6 +76,7 @@ export const toPointerInput = (event: MouseEvent, rect: Rect): PointerInput | un
|
|
|
64
76
|
button: event.button,
|
|
65
77
|
pointer,
|
|
66
78
|
modifiers: modifiersOf(event),
|
|
79
|
+
device: deviceOf(event),
|
|
67
80
|
};
|
|
68
81
|
case 'pointerup':
|
|
69
82
|
return { _tag: 'PointerUp', x, y, button: event.button, pointer };
|
package/src/dom/webgl.ts
CHANGED
|
@@ -184,6 +184,21 @@ export const webglRendererFactory = (
|
|
|
184
184
|
// Effects apply to the single full view only; views draw straight to the screen.
|
|
185
185
|
renderer.render(scene, camera);
|
|
186
186
|
},
|
|
187
|
+
renderOver: (scene, camera) => {
|
|
188
|
+
if (renderer.xr.isPresenting) return;
|
|
189
|
+
if (scissored) {
|
|
190
|
+
const { width, height } = renderer.getSize(drawingSize);
|
|
191
|
+
renderer.setScissorTest(false);
|
|
192
|
+
renderer.setViewport(0, 0, width, height);
|
|
193
|
+
scissored = false;
|
|
194
|
+
}
|
|
195
|
+
// As Drei's Hud: keep what is drawn, clear depth so the HUD is never hidden by it.
|
|
196
|
+
const autoClear = renderer.autoClear;
|
|
197
|
+
renderer.autoClear = false;
|
|
198
|
+
renderer.clearDepth();
|
|
199
|
+
renderer.render(scene, camera);
|
|
200
|
+
renderer.autoClear = autoClear;
|
|
201
|
+
},
|
|
187
202
|
createTarget: (width, height) => createTarget(targets, width, height),
|
|
188
203
|
createEnvironment: () => createRoom(renderer),
|
|
189
204
|
configure: (settings) => {
|