@scenography/engine 0.3.1 → 0.4.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 +21 -0
- package/dist/index.cjs +159 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -1
- package/dist/index.d.mts +11 -1
- package/dist/index.mjs +159 -64
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier López Úbeda
|
|
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/dist/index.cjs
CHANGED
|
@@ -65,6 +65,7 @@ const HIGHLIGHT_COLOR = "#ef4444";
|
|
|
65
65
|
const HIGHLIGHT_THICKNESS = 1.5;
|
|
66
66
|
const HIGHLIGHT_MARGIN = 1.5;
|
|
67
67
|
const CLICK_SLOP = 4;
|
|
68
|
+
const PICK_INTERVAL = 60;
|
|
68
69
|
/**
|
|
69
70
|
* Pointer picking for artworks: hovering a piece's click target (its label, or
|
|
70
71
|
* the piece itself when it has none) draws a pulsing red frame and shows a
|
|
@@ -86,6 +87,8 @@ var InteractionController = class {
|
|
|
86
87
|
radii = /* @__PURE__ */ new Map();
|
|
87
88
|
hovered = null;
|
|
88
89
|
time = 0;
|
|
90
|
+
pickElapsed = 0;
|
|
91
|
+
pointerMoved = false;
|
|
89
92
|
highlight = new three.Group();
|
|
90
93
|
highlightMat = null;
|
|
91
94
|
handlers = [];
|
|
@@ -128,6 +131,7 @@ var InteractionController = class {
|
|
|
128
131
|
const rect = this.canvas.getBoundingClientRect();
|
|
129
132
|
this.pointer.set((e.clientX - rect.left) / rect.width * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
|
130
133
|
this.over = true;
|
|
134
|
+
this.pointerMoved = true;
|
|
131
135
|
}
|
|
132
136
|
onMove(e) {
|
|
133
137
|
this.setPointer(e);
|
|
@@ -169,18 +173,38 @@ var InteractionController = class {
|
|
|
169
173
|
}
|
|
170
174
|
return null;
|
|
171
175
|
}
|
|
172
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Recompute hover state and animate the highlight. Call once per frame.
|
|
178
|
+
* Hover picking is throttled: a full-scene raycast runs at most once every
|
|
179
|
+
* `PICK_INTERVAL` ms, or immediately after the pointer moves. The camera can
|
|
180
|
+
* move without the pointer, so the interval keeps hovering correct while
|
|
181
|
+
* walking.
|
|
182
|
+
*/
|
|
173
183
|
update(deltaMs) {
|
|
174
184
|
this.time += deltaMs;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
this.
|
|
178
|
-
this.
|
|
179
|
-
|
|
180
|
-
|
|
185
|
+
if (!this.over || !this.canInteract()) {
|
|
186
|
+
this.pickElapsed = PICK_INTERVAL;
|
|
187
|
+
this.pointerMoved = false;
|
|
188
|
+
this.applyPick(null);
|
|
189
|
+
} else {
|
|
190
|
+
this.pickElapsed += deltaMs;
|
|
191
|
+
if (this.pointerMoved) this.pickElapsed = PICK_INTERVAL;
|
|
192
|
+
this.pointerMoved = false;
|
|
193
|
+
if (this.pickElapsed >= PICK_INTERVAL) {
|
|
194
|
+
this.pickElapsed = 0;
|
|
195
|
+
this.applyPick(this.raycast());
|
|
196
|
+
}
|
|
181
197
|
}
|
|
182
198
|
if (this.highlight.visible && this.highlightMat) this.highlightMat.opacity = .45 + .45 * (.5 + .5 * Math.sin(this.time / 320));
|
|
183
199
|
}
|
|
200
|
+
/** Swap the hovered piece, touching the canvas and the highlight only on change. */
|
|
201
|
+
applyPick(pick) {
|
|
202
|
+
if ((pick?.id ?? null) === this.hovered) return;
|
|
203
|
+
this.hovered = pick?.id ?? null;
|
|
204
|
+
this.canvas.style.cursor = pick ? "pointer" : "";
|
|
205
|
+
if (pick) this.buildHighlight(pick);
|
|
206
|
+
else this.highlight.visible = false;
|
|
207
|
+
}
|
|
184
208
|
clearHighlight() {
|
|
185
209
|
for (const child of [...this.highlight.children]) {
|
|
186
210
|
this.highlight.remove(child);
|
|
@@ -479,8 +503,126 @@ const EDGES = "edges";
|
|
|
479
503
|
*/
|
|
480
504
|
const EYE_HEIGHT = "eyeHeight";
|
|
481
505
|
//#endregion
|
|
506
|
+
//#region src/builders/textureCache.ts
|
|
507
|
+
/** Two surfaces can share a texture only at the same tiling and color space. */
|
|
508
|
+
const textureKey = (src, repeat, srgb) => `${src}|${repeat[0]}x${repeat[1]}|${srgb ? "srgb" : "linear"}`;
|
|
509
|
+
/** Tiling and color space of a texture; the image arrives later from the loader. */
|
|
510
|
+
const configureTexture = (texture, repeat, srgb) => {
|
|
511
|
+
texture.wrapS = three.RepeatWrapping;
|
|
512
|
+
texture.wrapT = three.RepeatWrapping;
|
|
513
|
+
texture.repeat.set(repeat[0], repeat[1]);
|
|
514
|
+
texture.colorSpace = srgb ? three.SRGBColorSpace : three.NoColorSpace;
|
|
515
|
+
};
|
|
516
|
+
/**
|
|
517
|
+
* Shares textures between materials: the same source at the same tiling and
|
|
518
|
+
* color space becomes one texture, loaded once and uploaded to the GPU once.
|
|
519
|
+
* Reference counted, so releasing a material keeps the texture alive while
|
|
520
|
+
* another one still samples it.
|
|
521
|
+
*/
|
|
522
|
+
var TextureCache = class {
|
|
523
|
+
source;
|
|
524
|
+
entries = /* @__PURE__ */ new Map();
|
|
525
|
+
owners = /* @__PURE__ */ new Map();
|
|
526
|
+
constructor(source) {
|
|
527
|
+
this.source = source;
|
|
528
|
+
}
|
|
529
|
+
/** The shared texture for a source, created on first use. */
|
|
530
|
+
acquire(src, repeat, srgb) {
|
|
531
|
+
const key = textureKey(src, repeat, srgb);
|
|
532
|
+
const existing = this.entries.get(key);
|
|
533
|
+
if (existing) {
|
|
534
|
+
existing.refs += 1;
|
|
535
|
+
return existing.texture;
|
|
536
|
+
}
|
|
537
|
+
const texture = this.source(src);
|
|
538
|
+
configureTexture(texture, repeat, srgb);
|
|
539
|
+
const entry = {
|
|
540
|
+
key,
|
|
541
|
+
texture,
|
|
542
|
+
refs: 1
|
|
543
|
+
};
|
|
544
|
+
this.entries.set(key, entry);
|
|
545
|
+
this.owners.set(texture, entry);
|
|
546
|
+
return texture;
|
|
547
|
+
}
|
|
548
|
+
/** Drop one reference; the texture is disposed when the last one goes. */
|
|
549
|
+
release(texture) {
|
|
550
|
+
const entry = this.owners.get(texture);
|
|
551
|
+
if (!entry) {
|
|
552
|
+
texture.dispose();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
entry.refs -= 1;
|
|
556
|
+
if (entry.refs > 0) return;
|
|
557
|
+
this.entries.delete(entry.key);
|
|
558
|
+
this.owners.delete(texture);
|
|
559
|
+
texture.dispose();
|
|
560
|
+
}
|
|
561
|
+
/** Textures currently shared. */
|
|
562
|
+
get size() {
|
|
563
|
+
return this.entries.size;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region src/builders/texture.ts
|
|
568
|
+
const textureLoader = new three.TextureLoader();
|
|
569
|
+
const cache = new TextureCache((src) => textureLoader.load(src));
|
|
570
|
+
/**
|
|
571
|
+
* Load one map into `material[slot]`. Textures are shared: every material that
|
|
572
|
+
* samples the same source at the same tiling reuses one texture object. `.exr`
|
|
573
|
+
* maps go through EXRLoader, imported on demand, and are not shared because
|
|
574
|
+
* they resolve asynchronously.
|
|
575
|
+
*/
|
|
576
|
+
const loadMap = (material, slot, src, repeat, srgb) => {
|
|
577
|
+
if (src.toLowerCase().endsWith(".exr")) {
|
|
578
|
+
import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
|
|
579
|
+
new EXRLoader().load(src, (texture) => {
|
|
580
|
+
configureTexture(texture, repeat, srgb);
|
|
581
|
+
texture.needsUpdate = true;
|
|
582
|
+
material[slot] = texture;
|
|
583
|
+
material.needsUpdate = true;
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
material[slot] = cache.acquire(src, repeat, srgb);
|
|
589
|
+
};
|
|
590
|
+
/**
|
|
591
|
+
* Apply a `SurfaceTexture` to a standard material. Returns whether a second UV
|
|
592
|
+
* set (`uv1`) is needed — three samples `aoMap` from it, so the geometry must
|
|
593
|
+
* provide one.
|
|
594
|
+
*/
|
|
595
|
+
const applyTexture = (material, texture) => {
|
|
596
|
+
const repeat = texture.repeat ?? [1, 1];
|
|
597
|
+
loadMap(material, "map", texture.src, repeat, true);
|
|
598
|
+
if (texture.normal) {
|
|
599
|
+
loadMap(material, "normalMap", texture.normal, repeat, false);
|
|
600
|
+
const scale = texture.normalScale ?? 1;
|
|
601
|
+
material.normalScale = new three.Vector2(scale, scale);
|
|
602
|
+
}
|
|
603
|
+
if (texture.roughness) loadMap(material, "roughnessMap", texture.roughness, repeat, false);
|
|
604
|
+
if (texture.ao) loadMap(material, "aoMap", texture.ao, repeat, false);
|
|
605
|
+
material.needsUpdate = true;
|
|
606
|
+
return texture.ao !== void 0;
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* Release one reference to a texture assigned by `applyTexture`. Shared
|
|
610
|
+
* textures stay alive while another material still samples them.
|
|
611
|
+
*/
|
|
612
|
+
const releaseTexture = (texture) => {
|
|
613
|
+
cache.release(texture);
|
|
614
|
+
};
|
|
615
|
+
//#endregion
|
|
482
616
|
//#region src/SceneManager.ts
|
|
617
|
+
const TEXTURE_SLOTS = [
|
|
618
|
+
"map",
|
|
619
|
+
"normalMap",
|
|
620
|
+
"roughnessMap",
|
|
621
|
+
"aoMap",
|
|
622
|
+
"metalnessMap"
|
|
623
|
+
];
|
|
483
624
|
const disposeObject = (object) => {
|
|
625
|
+
const materials = /* @__PURE__ */ new Set();
|
|
484
626
|
object.traverse((child) => {
|
|
485
627
|
const video = child.userData?.video;
|
|
486
628
|
if (video) {
|
|
@@ -490,21 +632,18 @@ const disposeObject = (object) => {
|
|
|
490
632
|
}
|
|
491
633
|
const mesh = child;
|
|
492
634
|
mesh.geometry?.dispose?.();
|
|
493
|
-
const dispose = (m) => {
|
|
494
|
-
const material = m;
|
|
495
|
-
for (const slot of [
|
|
496
|
-
"map",
|
|
497
|
-
"normalMap",
|
|
498
|
-
"roughnessMap",
|
|
499
|
-
"aoMap",
|
|
500
|
-
"metalnessMap"
|
|
501
|
-
]) (material?.[slot])?.dispose?.();
|
|
502
|
-
material?.dispose?.();
|
|
503
|
-
};
|
|
504
635
|
const material = mesh.material;
|
|
505
|
-
if (Array.isArray(material)) material.
|
|
506
|
-
else
|
|
636
|
+
if (Array.isArray(material)) for (const entry of material) materials.add(entry);
|
|
637
|
+
else if (material) materials.add(material);
|
|
507
638
|
});
|
|
639
|
+
for (const entry of materials) {
|
|
640
|
+
const material = entry;
|
|
641
|
+
for (const slot of TEXTURE_SLOTS) {
|
|
642
|
+
const texture = material[slot];
|
|
643
|
+
if (texture) releaseTexture(texture);
|
|
644
|
+
}
|
|
645
|
+
material.dispose?.();
|
|
646
|
+
}
|
|
508
647
|
};
|
|
509
648
|
/**
|
|
510
649
|
* Owns the live three.js scene graph and keeps it in sync with a `Scene`
|
|
@@ -954,50 +1093,6 @@ function createGallery(scene, options) {
|
|
|
954
1093
|
};
|
|
955
1094
|
}
|
|
956
1095
|
//#endregion
|
|
957
|
-
//#region src/builders/texture.ts
|
|
958
|
-
const textureLoader = new three.TextureLoader();
|
|
959
|
-
/** Configure a loaded texture: tiling + color space (sRGB only for color maps). */
|
|
960
|
-
const configure = (tex, repeat, srgb) => {
|
|
961
|
-
tex.wrapS = three.RepeatWrapping;
|
|
962
|
-
tex.wrapT = three.RepeatWrapping;
|
|
963
|
-
tex.repeat.set(repeat[0], repeat[1]);
|
|
964
|
-
tex.colorSpace = srgb ? three.SRGBColorSpace : three.NoColorSpace;
|
|
965
|
-
tex.needsUpdate = true;
|
|
966
|
-
};
|
|
967
|
-
/**
|
|
968
|
-
* Load one map and assign it to `material[slot]` once ready. `.exr` maps go
|
|
969
|
-
* through EXRLoader, imported on demand so it only ships in the bundle when a
|
|
970
|
-
* scene actually uses an EXR map; everything else uses the image loader.
|
|
971
|
-
*/
|
|
972
|
-
const loadMap = (material, slot, src, repeat, srgb) => {
|
|
973
|
-
const assign = (tex) => {
|
|
974
|
-
configure(tex, repeat, srgb);
|
|
975
|
-
material[slot] = tex;
|
|
976
|
-
material.needsUpdate = true;
|
|
977
|
-
};
|
|
978
|
-
if (src.toLowerCase().endsWith(".exr")) import("three/examples/jsm/loaders/EXRLoader.js").then(({ EXRLoader }) => {
|
|
979
|
-
new EXRLoader().load(src, assign);
|
|
980
|
-
});
|
|
981
|
-
else textureLoader.load(src, assign);
|
|
982
|
-
};
|
|
983
|
-
/**
|
|
984
|
-
* Apply a `SurfaceTexture` to a standard material. Returns whether a second UV
|
|
985
|
-
* set (`uv1`) is needed — three samples `aoMap` from it, so the geometry must
|
|
986
|
-
* provide one.
|
|
987
|
-
*/
|
|
988
|
-
const applyTexture = (material, texture) => {
|
|
989
|
-
const repeat = texture.repeat ?? [1, 1];
|
|
990
|
-
loadMap(material, "map", texture.src, repeat, true);
|
|
991
|
-
if (texture.normal) {
|
|
992
|
-
loadMap(material, "normalMap", texture.normal, repeat, false);
|
|
993
|
-
const s = texture.normalScale ?? 1;
|
|
994
|
-
material.normalScale = new three.Vector2(s, s);
|
|
995
|
-
}
|
|
996
|
-
if (texture.roughness) loadMap(material, "roughnessMap", texture.roughness, repeat, false);
|
|
997
|
-
if (texture.ao) loadMap(material, "aoMap", texture.ao, repeat, false);
|
|
998
|
-
return texture.ao !== void 0;
|
|
999
|
-
};
|
|
1000
|
-
//#endregion
|
|
1001
1096
|
exports.COLLIDERS = COLLIDERS;
|
|
1002
1097
|
exports.CapabilityRegistry = CapabilityRegistry;
|
|
1003
1098
|
exports.ControlSettings = ControlSettings;
|