From bc2821adb11a30244fc4663f4fafb756877d9508 Mon Sep 17 00:00:00 2001
From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>
Date: Wed, 7 Oct 2026 23:09:40 +0900
Subject: bluebey-studio: public/ の外へ移動し非公開化
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
bluebey-studio/assets/backgrounds/CREDITS.json | 187 +
.../backgrounds/abandoned_factory_canteen_01.webp | Bin 0 -> 50074 bytes
bluebey-studio/assets/backgrounds/autoshop_01.webp | Bin 0 -> 36042 bytes
bluebey-studio/assets/backgrounds/autumn_park.webp | Bin 0 -> 160340 bytes
bluebey-studio/assets/backgrounds/ballroom.webp | Bin 0 -> 70838 bytes
.../assets/backgrounds/basement_boxing_ring.webp | Bin 0 -> 38548 bytes
bluebey-studio/assets/backgrounds/dokan.webp | Bin 0 -> 33248 bytes
.../assets/backgrounds/empty_warehouse_01.webp | Bin 0 -> 49154 bytes
.../assets/backgrounds/kloppenheim_06_puresky.webp | Bin 0 -> 14722 bytes
.../assets/backgrounds/minedump_flats.webp | Bin 0 -> 88496 bytes
bluebey-studio/assets/backgrounds/mutoujima.webp | Bin 0 -> 13282 bytes
.../assets/backgrounds/newman_cafeteria.webp | Bin 0 -> 62478 bytes
.../assets/backgrounds/spiaggia_di_mondello.webp | Bin 0 -> 103002 bytes
bluebey-studio/assets/backgrounds/uchu.webp | Bin 0 -> 33900 bytes
.../assets/backgrounds/urban_alley_01.webp | Bin 0 -> 79528 bytes
.../assets/backgrounds/wooden_lounge.webp | Bin 0 -> 40662 bytes
bluebey-studio/assets/beards/kaiser-right.png | Bin 0 -> 1603 bytes
bluebey-studio/assets/beards/scotch.png | Bin 0 -> 1332 bytes
bluebey-studio/assets/bluebey.glb | Bin 0 -> 3109288 bytes
bluebey-studio/assets/brows/gol-right.png | Bin 0 -> 2537 bytes
bluebey-studio/assets/fonts/METADATA.pb | 84 +
bluebey-studio/assets/fonts/OFL.txt | 93 +
bluebey-studio/assets/fonts/bluebey-caption.ttf | Bin 0 -> 1224372 bytes
bluebey-studio/assets/fonts/bluebey-caption.woff2 | Bin 0 -> 430196 bytes
bluebey-studio/index.html | 241 +
bluebey-studio/src/animation.js | 204 +
bluebey-studio/src/background.js | 666 +
bluebey-studio/src/caption.js | 1321 +
bluebey-studio/src/clip.js | 202 +
bluebey-studio/src/exporter.js | 370 +
bluebey-studio/src/face.js | 453 +
bluebey-studio/src/faceArt.js | 1855 +
bluebey-studio/src/gacha.js | 481 +
bluebey-studio/src/gion.js | 226 +
bluebey-studio/src/glbExport.js | 148 +
bluebey-studio/src/handDrawn.js | 391 +
bluebey-studio/src/hats.js | 135 +
bluebey-studio/src/history.js | 105 +
bluebey-studio/src/look.js | 184 +
bluebey-studio/src/main.js | 3056 +
bluebey-studio/src/model.js | 283 +
bluebey-studio/src/mouthFlap.js | 267 +
bluebey-studio/src/outline.js | 368 +
bluebey-studio/src/panel.js | 3144 +
bluebey-studio/src/presets.js | 892 +
bluebey-studio/src/props.js | 646 +
bluebey-studio/src/rig.js | 212 +
bluebey-studio/src/style.css | 880 +
bluebey-studio/src/styles.js | 457 +
bluebey-studio/src/textOutlines.js | 432 +
bluebey-studio/src/trace.js | 410 +
bluebey-studio/src/ui.js | 367 +
bluebey-studio/src/urlState.js | 237 +
bluebey-studio/src/zip.js | 218 +
bluebey-studio/vendor/lib/opentype.module.js | 14460 +++++
.../vendor/lib/opentype.module.js.LICENSE | 20 +
.../vendor/lib/opentype.module.js.VERSION | 1 +
bluebey-studio/vendor/three/LICENSE | 21 +
bluebey-studio/vendor/three/VERSION | 1 +
bluebey-studio/vendor/three/build/three.core.js | 60586 +++++++++++++++++++
bluebey-studio/vendor/three/build/three.module.js | 19719 ++++++
.../three/examples/jsm/controls/OrbitControls.js | 1972 +
.../examples/jsm/controls/TransformControls.js | 2003 +
.../examples/jsm/environments/RoomEnvironment.js | 185 +
.../three/examples/jsm/exporters/GLTFExporter.js | 3856 ++
.../three/examples/jsm/loaders/GLTFLoader.js | 4925 ++
.../examples/jsm/utils/BufferGeometryUtils.js | 1501 +
.../three/examples/jsm/utils/SkeletonUtils.js | 496 +
.../bluebey-studio/assets/backgrounds/CREDITS.json | 187 -
.../backgrounds/abandoned_factory_canteen_01.webp | Bin 50074 -> 0 bytes
.../assets/backgrounds/autoshop_01.webp | Bin 36042 -> 0 bytes
.../assets/backgrounds/autumn_park.webp | Bin 160340 -> 0 bytes
.../assets/backgrounds/ballroom.webp | Bin 70838 -> 0 bytes
.../assets/backgrounds/basement_boxing_ring.webp | Bin 38548 -> 0 bytes
.../bluebey-studio/assets/backgrounds/dokan.webp | Bin 33248 -> 0 bytes
.../assets/backgrounds/empty_warehouse_01.webp | Bin 49154 -> 0 bytes
.../assets/backgrounds/kloppenheim_06_puresky.webp | Bin 14722 -> 0 bytes
.../assets/backgrounds/minedump_flats.webp | Bin 88496 -> 0 bytes
.../assets/backgrounds/mutoujima.webp | Bin 13282 -> 0 bytes
.../assets/backgrounds/newman_cafeteria.webp | Bin 62478 -> 0 bytes
.../assets/backgrounds/spiaggia_di_mondello.webp | Bin 103002 -> 0 bytes
public/bluebey-studio/assets/backgrounds/uchu.webp | Bin 33900 -> 0 bytes
.../assets/backgrounds/urban_alley_01.webp | Bin 79528 -> 0 bytes
.../assets/backgrounds/wooden_lounge.webp | Bin 40662 -> 0 bytes
.../bluebey-studio/assets/beards/kaiser-right.png | Bin 1603 -> 0 bytes
public/bluebey-studio/assets/beards/scotch.png | Bin 1332 -> 0 bytes
public/bluebey-studio/assets/bluebey.glb | Bin 3109288 -> 0 bytes
public/bluebey-studio/assets/brows/gol-right.png | Bin 2537 -> 0 bytes
public/bluebey-studio/assets/fonts/METADATA.pb | 84 -
public/bluebey-studio/assets/fonts/OFL.txt | 93 -
.../assets/fonts/bluebey-caption.ttf | Bin 1224372 -> 0 bytes
.../assets/fonts/bluebey-caption.woff2 | Bin 430196 -> 0 bytes
public/bluebey-studio/index.html | 241 -
public/bluebey-studio/src/animation.js | 204 -
public/bluebey-studio/src/background.js | 666 -
public/bluebey-studio/src/caption.js | 1321 -
public/bluebey-studio/src/clip.js | 202 -
public/bluebey-studio/src/exporter.js | 370 -
public/bluebey-studio/src/face.js | 453 -
public/bluebey-studio/src/faceArt.js | 1855 -
public/bluebey-studio/src/gacha.js | 481 -
public/bluebey-studio/src/gion.js | 226 -
public/bluebey-studio/src/glbExport.js | 148 -
public/bluebey-studio/src/handDrawn.js | 391 -
public/bluebey-studio/src/hats.js | 135 -
public/bluebey-studio/src/history.js | 105 -
public/bluebey-studio/src/look.js | 184 -
public/bluebey-studio/src/main.js | 3056 -
public/bluebey-studio/src/model.js | 283 -
public/bluebey-studio/src/mouthFlap.js | 267 -
public/bluebey-studio/src/outline.js | 368 -
public/bluebey-studio/src/panel.js | 3144 -
public/bluebey-studio/src/presets.js | 892 -
public/bluebey-studio/src/props.js | 646 -
public/bluebey-studio/src/rig.js | 212 -
public/bluebey-studio/src/style.css | 880 -
public/bluebey-studio/src/styles.js | 457 -
public/bluebey-studio/src/textOutlines.js | 432 -
public/bluebey-studio/src/trace.js | 410 -
public/bluebey-studio/src/ui.js | 367 -
public/bluebey-studio/src/urlState.js | 237 -
public/bluebey-studio/src/zip.js | 218 -
.../bluebey-studio/vendor/lib/opentype.module.js | 14460 -----
.../vendor/lib/opentype.module.js.LICENSE | 20 -
.../vendor/lib/opentype.module.js.VERSION | 1 -
public/bluebey-studio/vendor/three/LICENSE | 21 -
public/bluebey-studio/vendor/three/VERSION | 1 -
.../vendor/three/build/three.core.js | 60586 -------------------
.../vendor/three/build/three.module.js | 19719 ------
.../three/examples/jsm/controls/OrbitControls.js | 1972 -
.../examples/jsm/controls/TransformControls.js | 2003 -
.../examples/jsm/environments/RoomEnvironment.js | 185 -
.../three/examples/jsm/exporters/GLTFExporter.js | 3856 --
.../three/examples/jsm/loaders/GLTFLoader.js | 4925 --
.../examples/jsm/utils/BufferGeometryUtils.js | 1501 -
.../three/examples/jsm/utils/SkeletonUtils.js | 496 -
136 files changed, 128961 insertions(+), 128961 deletions(-)
create mode 100644 bluebey-studio/assets/backgrounds/CREDITS.json
create mode 100644 bluebey-studio/assets/backgrounds/abandoned_factory_canteen_01.webp
create mode 100644 bluebey-studio/assets/backgrounds/autoshop_01.webp
create mode 100644 bluebey-studio/assets/backgrounds/autumn_park.webp
create mode 100644 bluebey-studio/assets/backgrounds/ballroom.webp
create mode 100644 bluebey-studio/assets/backgrounds/basement_boxing_ring.webp
create mode 100644 bluebey-studio/assets/backgrounds/dokan.webp
create mode 100644 bluebey-studio/assets/backgrounds/empty_warehouse_01.webp
create mode 100644 bluebey-studio/assets/backgrounds/kloppenheim_06_puresky.webp
create mode 100644 bluebey-studio/assets/backgrounds/minedump_flats.webp
create mode 100644 bluebey-studio/assets/backgrounds/mutoujima.webp
create mode 100644 bluebey-studio/assets/backgrounds/newman_cafeteria.webp
create mode 100644 bluebey-studio/assets/backgrounds/spiaggia_di_mondello.webp
create mode 100644 bluebey-studio/assets/backgrounds/uchu.webp
create mode 100644 bluebey-studio/assets/backgrounds/urban_alley_01.webp
create mode 100644 bluebey-studio/assets/backgrounds/wooden_lounge.webp
create mode 100644 bluebey-studio/assets/beards/kaiser-right.png
create mode 100644 bluebey-studio/assets/beards/scotch.png
create mode 100644 bluebey-studio/assets/bluebey.glb
create mode 100644 bluebey-studio/assets/brows/gol-right.png
create mode 100644 bluebey-studio/assets/fonts/METADATA.pb
create mode 100644 bluebey-studio/assets/fonts/OFL.txt
create mode 100644 bluebey-studio/assets/fonts/bluebey-caption.ttf
create mode 100644 bluebey-studio/assets/fonts/bluebey-caption.woff2
create mode 100644 bluebey-studio/index.html
create mode 100644 bluebey-studio/src/animation.js
create mode 100644 bluebey-studio/src/background.js
create mode 100644 bluebey-studio/src/caption.js
create mode 100644 bluebey-studio/src/clip.js
create mode 100644 bluebey-studio/src/exporter.js
create mode 100644 bluebey-studio/src/face.js
create mode 100644 bluebey-studio/src/faceArt.js
create mode 100644 bluebey-studio/src/gacha.js
create mode 100644 bluebey-studio/src/gion.js
create mode 100644 bluebey-studio/src/glbExport.js
create mode 100644 bluebey-studio/src/handDrawn.js
create mode 100644 bluebey-studio/src/hats.js
create mode 100644 bluebey-studio/src/history.js
create mode 100644 bluebey-studio/src/look.js
create mode 100644 bluebey-studio/src/main.js
create mode 100644 bluebey-studio/src/model.js
create mode 100644 bluebey-studio/src/mouthFlap.js
create mode 100644 bluebey-studio/src/outline.js
create mode 100644 bluebey-studio/src/panel.js
create mode 100644 bluebey-studio/src/presets.js
create mode 100644 bluebey-studio/src/props.js
create mode 100644 bluebey-studio/src/rig.js
create mode 100644 bluebey-studio/src/style.css
create mode 100644 bluebey-studio/src/styles.js
create mode 100644 bluebey-studio/src/textOutlines.js
create mode 100644 bluebey-studio/src/trace.js
create mode 100644 bluebey-studio/src/ui.js
create mode 100644 bluebey-studio/src/urlState.js
create mode 100644 bluebey-studio/src/zip.js
create mode 100644 bluebey-studio/vendor/lib/opentype.module.js
create mode 100644 bluebey-studio/vendor/lib/opentype.module.js.LICENSE
create mode 100644 bluebey-studio/vendor/lib/opentype.module.js.VERSION
create mode 100644 bluebey-studio/vendor/three/LICENSE
create mode 100644 bluebey-studio/vendor/three/VERSION
create mode 100644 bluebey-studio/vendor/three/build/three.core.js
create mode 100644 bluebey-studio/vendor/three/build/three.module.js
create mode 100644 bluebey-studio/vendor/three/examples/jsm/controls/OrbitControls.js
create mode 100644 bluebey-studio/vendor/three/examples/jsm/controls/TransformControls.js
create mode 100644 bluebey-studio/vendor/three/examples/jsm/environments/RoomEnvironment.js
create mode 100644 bluebey-studio/vendor/three/examples/jsm/exporters/GLTFExporter.js
create mode 100644 bluebey-studio/vendor/three/examples/jsm/loaders/GLTFLoader.js
create mode 100644 bluebey-studio/vendor/three/examples/jsm/utils/BufferGeometryUtils.js
create mode 100644 bluebey-studio/vendor/three/examples/jsm/utils/SkeletonUtils.js
delete mode 100644 public/bluebey-studio/assets/backgrounds/CREDITS.json
delete mode 100644 public/bluebey-studio/assets/backgrounds/abandoned_factory_canteen_01.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/autoshop_01.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/autumn_park.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/ballroom.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/basement_boxing_ring.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/dokan.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/empty_warehouse_01.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/kloppenheim_06_puresky.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/minedump_flats.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/mutoujima.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/newman_cafeteria.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/spiaggia_di_mondello.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/uchu.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/urban_alley_01.webp
delete mode 100644 public/bluebey-studio/assets/backgrounds/wooden_lounge.webp
delete mode 100644 public/bluebey-studio/assets/beards/kaiser-right.png
delete mode 100644 public/bluebey-studio/assets/beards/scotch.png
delete mode 100644 public/bluebey-studio/assets/bluebey.glb
delete mode 100644 public/bluebey-studio/assets/brows/gol-right.png
delete mode 100644 public/bluebey-studio/assets/fonts/METADATA.pb
delete mode 100644 public/bluebey-studio/assets/fonts/OFL.txt
delete mode 100644 public/bluebey-studio/assets/fonts/bluebey-caption.ttf
delete mode 100644 public/bluebey-studio/assets/fonts/bluebey-caption.woff2
delete mode 100644 public/bluebey-studio/index.html
delete mode 100644 public/bluebey-studio/src/animation.js
delete mode 100644 public/bluebey-studio/src/background.js
delete mode 100644 public/bluebey-studio/src/caption.js
delete mode 100644 public/bluebey-studio/src/clip.js
delete mode 100644 public/bluebey-studio/src/exporter.js
delete mode 100644 public/bluebey-studio/src/face.js
delete mode 100644 public/bluebey-studio/src/faceArt.js
delete mode 100644 public/bluebey-studio/src/gacha.js
delete mode 100644 public/bluebey-studio/src/gion.js
delete mode 100644 public/bluebey-studio/src/glbExport.js
delete mode 100644 public/bluebey-studio/src/handDrawn.js
delete mode 100644 public/bluebey-studio/src/hats.js
delete mode 100644 public/bluebey-studio/src/history.js
delete mode 100644 public/bluebey-studio/src/look.js
delete mode 100644 public/bluebey-studio/src/main.js
delete mode 100644 public/bluebey-studio/src/model.js
delete mode 100644 public/bluebey-studio/src/mouthFlap.js
delete mode 100644 public/bluebey-studio/src/outline.js
delete mode 100644 public/bluebey-studio/src/panel.js
delete mode 100644 public/bluebey-studio/src/presets.js
delete mode 100644 public/bluebey-studio/src/props.js
delete mode 100644 public/bluebey-studio/src/rig.js
delete mode 100644 public/bluebey-studio/src/style.css
delete mode 100644 public/bluebey-studio/src/styles.js
delete mode 100644 public/bluebey-studio/src/textOutlines.js
delete mode 100644 public/bluebey-studio/src/trace.js
delete mode 100644 public/bluebey-studio/src/ui.js
delete mode 100644 public/bluebey-studio/src/urlState.js
delete mode 100644 public/bluebey-studio/src/zip.js
delete mode 100644 public/bluebey-studio/vendor/lib/opentype.module.js
delete mode 100644 public/bluebey-studio/vendor/lib/opentype.module.js.LICENSE
delete mode 100644 public/bluebey-studio/vendor/lib/opentype.module.js.VERSION
delete mode 100644 public/bluebey-studio/vendor/three/LICENSE
delete mode 100644 public/bluebey-studio/vendor/three/VERSION
delete mode 100644 public/bluebey-studio/vendor/three/build/three.core.js
delete mode 100644 public/bluebey-studio/vendor/three/build/three.module.js
delete mode 100644 public/bluebey-studio/vendor/three/examples/jsm/controls/OrbitControls.js
delete mode 100644 public/bluebey-studio/vendor/three/examples/jsm/controls/TransformControls.js
delete mode 100644 public/bluebey-studio/vendor/three/examples/jsm/environments/RoomEnvironment.js
delete mode 100644 public/bluebey-studio/vendor/three/examples/jsm/exporters/GLTFExporter.js
delete mode 100644 public/bluebey-studio/vendor/three/examples/jsm/loaders/GLTFLoader.js
delete mode 100644 public/bluebey-studio/vendor/three/examples/jsm/utils/BufferGeometryUtils.js
delete mode 100644 public/bluebey-studio/vendor/three/examples/jsm/utils/SkeletonUtils.js
diff --git a/bluebey-studio/assets/backgrounds/CREDITS.json b/bluebey-studio/assets/backgrounds/CREDITS.json
new file mode 100644
index 0000000..d4114c7
--- /dev/null
+++ b/bluebey-studio/assets/backgrounds/CREDITS.json
@@ -0,0 +1,187 @@
+{
+ "note": "背景素材は Poly Haven (CC0-1.0)。tools/fetch-backgrounds.py で取得。ただし mutoujima.webp は作者(安竹洋平)によるオリジナルの描き起こし。dokan.webp と uchu.webp も作者のオリジナル(blender-scenes でレンダリング)。",
+ "items": [
+ {
+ "file": "empty_warehouse_01.webp",
+ "label": "倉庫",
+ "yaw": 0,
+ "name": "Empty Warehouse 01",
+ "authors": {
+ "Sergej Majboroda": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/empty_warehouse_01"
+ },
+ {
+ "file": "ballroom.webp",
+ "label": "広間",
+ "yaw": 0,
+ "name": "Ballroom",
+ "authors": {
+ "Sergej Majboroda": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/ballroom"
+ },
+ {
+ "file": "kloppenheim_06_puresky.webp",
+ "label": "空と雲",
+ "yaw": 0,
+ "name": "Kloppenheim 06 (Pure Sky)",
+ "authors": {
+ "Greg Zaal": "Original",
+ "Jarod Guest": "Sky edits"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/kloppenheim_06_puresky"
+ },
+ {
+ "file": "autumn_park.webp",
+ "label": "秋の公園",
+ "yaw": 0,
+ "name": "Autumn Park",
+ "authors": {
+ "Sergej Majboroda": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/autumn_park"
+ },
+ {
+ "file": "newman_cafeteria.webp",
+ "label": "学校",
+ "yaw": 0,
+ "name": "Newman Cafeteria",
+ "authors": {
+ "Savva Zakharov": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/newman_cafeteria"
+ },
+ {
+ "file": "minedump_flats.webp",
+ "label": "砂漠",
+ "yaw": 0,
+ "name": "Minedump Flats",
+ "authors": {
+ "Dimitrios Savva": "Photography",
+ "Jarod Guest": "Processing"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/minedump_flats"
+ },
+ {
+ "file": "spiaggia_di_mondello.webp",
+ "label": "海岸",
+ "yaw": 0,
+ "name": "Spiaggia di Mondello",
+ "authors": {
+ "Andreas Mischok": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/spiaggia_di_mondello"
+ },
+ {
+ "file": "wooden_lounge.webp",
+ "label": "社長室(木の部屋)",
+ "yaw": 0,
+ "name": "Wooden Lounge",
+ "authors": {
+ "Greg Zaal": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/wooden_lounge"
+ },
+ {
+ "file": "abandoned_factory_canteen_01.webp",
+ "label": "廃工場の食堂",
+ "yaw": 0,
+ "name": "Abandoned Factory Canteen 01",
+ "authors": {
+ "Sergej Majboroda": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/abandoned_factory_canteen_01"
+ },
+ {
+ "file": "basement_boxing_ring.webp",
+ "label": "地下室のリング",
+ "yaw": 180,
+ "name": "Basement Boxing Ring",
+ "authors": {
+ "Sergej Majboroda": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/basement_boxing_ring"
+ },
+ {
+ "file": "autoshop_01.webp",
+ "label": "自動車工場",
+ "yaw": 0,
+ "name": "Autoshop 01",
+ "authors": {
+ "Oliksiy Yakovlyev": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/autoshop_01"
+ },
+ {
+ "file": "urban_alley_01.webp",
+ "label": "路地",
+ "yaw": 0,
+ "name": "Urban Alley 01",
+ "authors": {
+ "Andreas Mischok": "All"
+ },
+ "license": "CC0-1.0",
+ "kind": "hdri",
+ "source": "https://polyhaven.com/a/urban_alley_01"
+ },
+ {
+ "file": "mutoujima.webp",
+ "label": "無人島",
+ "yaw": 0,
+ "name": "Mutoujima",
+ "authors": {
+ "安竹洋平": "All"
+ },
+ "license": "original (not CC0)",
+ "kind": "illustration",
+ "source": "作者(安竹洋平)が bluebey-studio のために描き起こしたオリジナル作品"
+ },
+ {
+ "file": "dokan.webp",
+ "label": "土管のある空き地",
+ "yaw": 0,
+ "name": "Dokan",
+ "authors": {
+ "安竹洋平": "All"
+ },
+ "license": "original (not CC0)",
+ "kind": "illustration",
+ "source": "作者(安竹洋平)が blender-scenes でレンダリングした bluebey-studio 用のオリジナル作品"
+ },
+ {
+ "file": "uchu.webp",
+ "label": "宇宙船の中",
+ "yaw": 0,
+ "name": "Uchu",
+ "authors": {
+ "安竹洋平": "All"
+ },
+ "license": "original (not CC0)",
+ "kind": "illustration",
+ "source": "作者(安竹洋平)が blender-scenes でレンダリングした bluebey-studio 用のオリジナル作品"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bluebey-studio/assets/backgrounds/abandoned_factory_canteen_01.webp b/bluebey-studio/assets/backgrounds/abandoned_factory_canteen_01.webp
new file mode 100644
index 0000000..20650a1
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/abandoned_factory_canteen_01.webp differ
diff --git a/bluebey-studio/assets/backgrounds/autoshop_01.webp b/bluebey-studio/assets/backgrounds/autoshop_01.webp
new file mode 100644
index 0000000..e8275f9
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/autoshop_01.webp differ
diff --git a/bluebey-studio/assets/backgrounds/autumn_park.webp b/bluebey-studio/assets/backgrounds/autumn_park.webp
new file mode 100644
index 0000000..d96f889
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/autumn_park.webp differ
diff --git a/bluebey-studio/assets/backgrounds/ballroom.webp b/bluebey-studio/assets/backgrounds/ballroom.webp
new file mode 100644
index 0000000..43214cc
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/ballroom.webp differ
diff --git a/bluebey-studio/assets/backgrounds/basement_boxing_ring.webp b/bluebey-studio/assets/backgrounds/basement_boxing_ring.webp
new file mode 100644
index 0000000..3e04ce2
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/basement_boxing_ring.webp differ
diff --git a/bluebey-studio/assets/backgrounds/dokan.webp b/bluebey-studio/assets/backgrounds/dokan.webp
new file mode 100644
index 0000000..7d408d3
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/dokan.webp differ
diff --git a/bluebey-studio/assets/backgrounds/empty_warehouse_01.webp b/bluebey-studio/assets/backgrounds/empty_warehouse_01.webp
new file mode 100644
index 0000000..68890fe
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/empty_warehouse_01.webp differ
diff --git a/bluebey-studio/assets/backgrounds/kloppenheim_06_puresky.webp b/bluebey-studio/assets/backgrounds/kloppenheim_06_puresky.webp
new file mode 100644
index 0000000..45764b1
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/kloppenheim_06_puresky.webp differ
diff --git a/bluebey-studio/assets/backgrounds/minedump_flats.webp b/bluebey-studio/assets/backgrounds/minedump_flats.webp
new file mode 100644
index 0000000..7225a03
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/minedump_flats.webp differ
diff --git a/bluebey-studio/assets/backgrounds/mutoujima.webp b/bluebey-studio/assets/backgrounds/mutoujima.webp
new file mode 100644
index 0000000..3d79b6b
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/mutoujima.webp differ
diff --git a/bluebey-studio/assets/backgrounds/newman_cafeteria.webp b/bluebey-studio/assets/backgrounds/newman_cafeteria.webp
new file mode 100644
index 0000000..fc5cdfe
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/newman_cafeteria.webp differ
diff --git a/bluebey-studio/assets/backgrounds/spiaggia_di_mondello.webp b/bluebey-studio/assets/backgrounds/spiaggia_di_mondello.webp
new file mode 100644
index 0000000..356793d
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/spiaggia_di_mondello.webp differ
diff --git a/bluebey-studio/assets/backgrounds/uchu.webp b/bluebey-studio/assets/backgrounds/uchu.webp
new file mode 100644
index 0000000..3ab794d
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/uchu.webp differ
diff --git a/bluebey-studio/assets/backgrounds/urban_alley_01.webp b/bluebey-studio/assets/backgrounds/urban_alley_01.webp
new file mode 100644
index 0000000..91df0a1
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/urban_alley_01.webp differ
diff --git a/bluebey-studio/assets/backgrounds/wooden_lounge.webp b/bluebey-studio/assets/backgrounds/wooden_lounge.webp
new file mode 100644
index 0000000..ff3fbda
Binary files /dev/null and b/bluebey-studio/assets/backgrounds/wooden_lounge.webp differ
diff --git a/bluebey-studio/assets/beards/kaiser-right.png b/bluebey-studio/assets/beards/kaiser-right.png
new file mode 100644
index 0000000..31edd28
Binary files /dev/null and b/bluebey-studio/assets/beards/kaiser-right.png differ
diff --git a/bluebey-studio/assets/beards/scotch.png b/bluebey-studio/assets/beards/scotch.png
new file mode 100644
index 0000000..3978993
Binary files /dev/null and b/bluebey-studio/assets/beards/scotch.png differ
diff --git a/bluebey-studio/assets/bluebey.glb b/bluebey-studio/assets/bluebey.glb
new file mode 100644
index 0000000..0072864
Binary files /dev/null and b/bluebey-studio/assets/bluebey.glb differ
diff --git a/bluebey-studio/assets/brows/gol-right.png b/bluebey-studio/assets/brows/gol-right.png
new file mode 100644
index 0000000..73086a8
Binary files /dev/null and b/bluebey-studio/assets/brows/gol-right.png differ
diff --git a/bluebey-studio/assets/fonts/METADATA.pb b/bluebey-studio/assets/fonts/METADATA.pb
new file mode 100644
index 0000000..55ae891
--- /dev/null
+++ b/bluebey-studio/assets/fonts/METADATA.pb
@@ -0,0 +1,84 @@
+name: "M PLUS Rounded 1c"
+designer: "Coji Morishita, M+ Fonts Project"
+license: "OFL"
+category: "SANS_SERIF"
+date_added: "2018-05-17"
+fonts {
+ name: "M PLUS Rounded 1c"
+ style: "normal"
+ weight: 100
+ filename: "MPLUSRounded1c-Thin.ttf"
+ post_script_name: "MPLUSRounded1c-Thin"
+ full_name: "M PLUS Rounded 1c Thin"
+ copyright: "Copyright 2016 The Rounded M+ Project Authors."
+}
+fonts {
+ name: "M PLUS Rounded 1c"
+ style: "normal"
+ weight: 300
+ filename: "MPLUSRounded1c-Light.ttf"
+ post_script_name: "MPLUSRounded1c-Light"
+ full_name: "M PLUS Rounded 1c Light"
+ copyright: "Copyright 2016 The Rounded M+ Project Authors."
+}
+fonts {
+ name: "M PLUS Rounded 1c"
+ style: "normal"
+ weight: 400
+ filename: "MPLUSRounded1c-Regular.ttf"
+ post_script_name: "MPLUSRounded1c-Regular"
+ full_name: "M PLUS Rounded 1c"
+ copyright: "Copyright 2016 The Rounded M+ Project Authors."
+}
+fonts {
+ name: "M PLUS Rounded 1c"
+ style: "normal"
+ weight: 500
+ filename: "MPLUSRounded1c-Medium.ttf"
+ post_script_name: "MPLUSRounded1c-Medium"
+ full_name: "M PLUS Rounded 1c Medium"
+ copyright: "Copyright 2016 The Rounded M+ Project Authors."
+}
+fonts {
+ name: "M PLUS Rounded 1c"
+ style: "normal"
+ weight: 700
+ filename: "MPLUSRounded1c-Bold.ttf"
+ post_script_name: "MPLUSRounded1c-Bold"
+ full_name: "M PLUS Rounded 1c Bold"
+ copyright: "Copyright 2016 The Rounded M+ Project Authors."
+}
+fonts {
+ name: "M PLUS Rounded 1c"
+ style: "normal"
+ weight: 800
+ filename: "MPLUSRounded1c-ExtraBold.ttf"
+ post_script_name: "MPLUSRounded1c-ExtraBold"
+ full_name: "M PLUS Rounded 1c ExtraBold"
+ copyright: "Copyright 2016 The Rounded M+ Project Authors."
+}
+fonts {
+ name: "M PLUS Rounded 1c"
+ style: "normal"
+ weight: 900
+ filename: "MPLUSRounded1c-Black.ttf"
+ post_script_name: "MPLUSRounded1c-Black"
+ full_name: "M PLUS Rounded 1c Black"
+ copyright: "Copyright 2016 The Rounded M+ Project Authors."
+}
+subsets: "cyrillic"
+subsets: "cyrillic-ext"
+subsets: "greek"
+subsets: "greek-ext"
+subsets: "hebrew"
+subsets: "japanese"
+subsets: "latin"
+subsets: "latin-ext"
+subsets: "menu"
+subsets: "vietnamese"
+
+source {
+ repository_url: "https://github.com/coz-m/MPLUS_FONTS"
+ commit: "eb604901d6f04b6f7f2a84b0378c58df84a9dba6"
+}
+primary_script: "Jpan"
diff --git a/bluebey-studio/assets/fonts/OFL.txt b/bluebey-studio/assets/fonts/OFL.txt
new file mode 100644
index 0000000..b038fd1
--- /dev/null
+++ b/bluebey-studio/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2021 The M+ FONTS Project Authors (https://github.com/coz-m/MPLUS_FONTS)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/bluebey-studio/assets/fonts/bluebey-caption.ttf b/bluebey-studio/assets/fonts/bluebey-caption.ttf
new file mode 100644
index 0000000..9ae8eae
Binary files /dev/null and b/bluebey-studio/assets/fonts/bluebey-caption.ttf differ
diff --git a/bluebey-studio/assets/fonts/bluebey-caption.woff2 b/bluebey-studio/assets/fonts/bluebey-caption.woff2
new file mode 100644
index 0000000..69410e1
Binary files /dev/null and b/bluebey-studio/assets/fonts/bluebey-caption.woff2 differ
diff --git a/bluebey-studio/index.html b/bluebey-studio/index.html
new file mode 100644
index 0000000..7111b9b
--- /dev/null
+++ b/bluebey-studio/index.html
@@ -0,0 +1,241 @@
+
+
+
+
+
+ぶるべー スタジオ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ぶるべー スタジオ
+
+ PNG保存
+ コピー
+ ?
+ ≡
+
+
+
+
+
+
+ ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:移動 / ぶるべーをドラッグ:移動
+
+
+ 2本指でドラッグ:カメラ移動(ぶるべーは動きません)/ 2本指をつまむ:ズーム
+ ぶるべーの上で1本指:ぶるべーを移動(上下で高さも)/ 何もない所で1本指:回転
+
+
+
+
+
+
+
+
+
×
+
ぶるべー スタジオの使い方
+
+
これは何?
+
+ bluebey.glb(ぶるべーの3Dモデル)をブラウザで読み込み、自由にポーズをとらせ、
+ 顔の表情を自由に作って、2Dの画像(PNG)として書き出せるスタジオです。
+ 元の手描きパーツ(GIMPで作った目と口の画像)もそのまま使えます。
+
+
+
ポーズ
+
+ 右パネルの「ポーズ」でボーンを選び、X/Y/Zのスライダーを動かします。
+ モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。
+ ギズモ(回転の輪)を直接ドラッグしても回せます。
+ スライダーの値は「休めの姿勢(レストポーズ)」からの差分です。0,0,0 で元の姿勢。
+ 気に入ったポーズは「ポーズを保存」でファイルに残せます。
+
+
+
スマホでの操作
+
+ 2本指でぐりぐり :カメラ(見ている基準点)を動かします。ぶるべーは基準点から動きません。
+ 2本指をつまむ(ピンチ) :ズーム(寄る/引く)。
+ ぶるべーの上で1本指でぐりぐり :ぶるべーを基準点から動かします。上下に動かすと高さも変わります。
+ 何もない所で1本指でぐりぐり :カメラが回ります。
+ パネル(下のシート)は、上の横線(ハンドル)をドラッグすると上下に動かせます。
+
+
+
表情
+
+ 目:開き具合(まばたき・ウインク)、目線(上下左右)、瞳の大きさ、ハイライト、閉じ方の種類。
+ 口:笑いの深さ、口の開き、舌、口角の点、太さ、色。
+ 「目線パッド」をドラッグすると、選んだ側の目線が動きます(「左右を連動させる」中は両目)。
+ プリセット(にっこり・ウインク・びっくり…)から始めると早いです。
+ 「手描きパーツを使う」を選ぶと、元のGIMP製パーツに切り替わります。
+
+
+
3Dのまま/2Dにする
+
+ 「表示」で リアル/フラット(トゥーン)/線画/線画(透明) を切り替えます。
+ 線画は輪郭線+白ぬり。線の太さと色を変えられます。
+ 「正投影」にすると遠近のゆがみがなくなり、図面のような絵になります。
+ 背景は透明・白・好きな色から選べます。PNG保存は透明のまま保存できます。
+
+
+
キーボード
+
+ S PNG保存
+ C 画像をコピー
+ Tab パネルの表示/非表示
+ ? このヘルプ
+ 1 –5 カメラの向き(正面・斜め・横・後ろ・俯瞰)
+ R ポーズをリセット
+ G ギズモの切り替え
+ Space 生きている演出(ゆれ・まばたき)の開始/停止
+
+
+
書き出しのヒント
+
+ PNGは「書き出しサイズ」を2倍・3倍にすると高解像度になります(印刷やスライド用)。
+ ポーズと表情の全設定は「設定を保存」でJSONに残せます。READMEも参照してください。
+
+
+
+
+
+
+
ご利用の前に
+
このスタジオは、市政・政治活動とは関係のない個人的な制作物です。
+
このスタジオで作成した画像などは、自由に使っていただけます。ただし、商用利用やそのほか特殊な使い方をされる場合は、小平市の確認が必要ですので、このサイトのお問い合わせ からご連絡ください。
+
次のような使い方はご遠慮ください。
+
+ 公序良俗に反する目的での利用
+ ぶるべーのイメージを損なうような利用(たとえば、攻撃的・差別的・性的・過激な利用、誹謗中傷など)
+ 反社会的勢力や違法行為に関わる利用
+ 政治的意図があるように見える利用(例:特定の政治家や政党をぶるべーが応援しているような表現)
+ 特定の宗教・宗派への勧誘や布教と誤解される利用
+ 小平市が公認・推薦・後援しているように見える利用(市の公式発表や公文書と誤認させる利用を含む)
+ 特定の企業・商品・思想・団体を、ぶるべーや小平市が推薦・保証しているように見える利用
+
+
ぶるべーの著作権は小平市にあります。このスタジオと3Dモデルは作者(安竹洋平)が自作したものです。
+
+ 次回から表示しない
+ 同意して使う
+
+
+
+
+
+
+
+
+
diff --git a/bluebey-studio/src/animation.js b/bluebey-studio/src/animation.js
new file mode 100644
index 0000000..e569d12
--- /dev/null
+++ b/bluebey-studio/src/animation.js
@@ -0,0 +1,204 @@
+/**
+ * The "living" layer: idle body motion, blinking and idle eye drift.
+ *
+ * These never touch the saved state. `pose()` returns the base pose with the
+ * idle offsets added, and `eyeOpen`/`look` are multipliers the caller merges
+ * into the face parameters, so turning the animation off restores exactly the
+ * pose and expression the user had before.
+ */
+
+export class Animator {
+ constructor({ state }) {
+ this.state = state;
+ this.time = 0;
+ this.blinkPhase = -1;
+ this.blinkDuration = 0.17;
+ this.blinkTimer = 2 + Math.random() * 1.5;
+ this.eyeOpen = 1;
+ this.look = { x: 0, y: 0 };
+ this.idleWeight = 0;
+ this.snotScale = 1;
+ }
+
+ reset() {
+ this.eyeOpen = 1;
+ this.look = { x: 0, y: 0 };
+ this.blinkPhase = -1;
+ this.blinkTimer = 2;
+ this.time = 0;
+ this.snotScale = 1;
+ }
+
+ update(dt) {
+ const speed = clamp(this.state.anim.speed ?? 1, 0.1, 3);
+ const step = Math.min(dt, 0.1) * speed;
+ this.time += step;
+
+ const moving = (this.state.anim.mode ?? 'off') !== 'off';
+ this.idleWeight += ((moving ? 1 : 0) - this.idleWeight) * Math.min(1, step * 4);
+
+ // 鼻ちょうちん: while sleeping, the bubble swells on the out-breath and shrinks
+ // again. It is a real object (see src/main.js), so the motion is published here
+ // as a plain multiplier the render loop applies to the bubble's scale.
+ this.snotScale = (this.state.anim.mode ?? 'off') === 'sleep'
+ ? 0.45 + 0.55 * (0.5 - 0.5 * Math.cos(this.time * 1.3))
+ : 1;
+
+ this.updateBlink(step);
+ this.updateLook(step);
+ }
+
+ updateBlink(dt) {
+ if ((this.state.anim.mode ?? 'off') === 'sleep') {
+ // Asleep: the eyes stay shut, whatever the blink setting says.
+ this.eyeOpen = 0;
+ this.blinkPhase = -1;
+ return;
+ }
+ if (!this.state.anim.blink) {
+ this.eyeOpen = 1;
+ this.blinkPhase = -1;
+ return;
+ }
+ if (this.blinkPhase >= 0) {
+ this.blinkPhase += dt / this.blinkDuration;
+ if (this.blinkPhase >= 1) {
+ this.blinkPhase = -1;
+ this.eyeOpen = 1;
+ const base = Math.max(0.5, this.state.anim.blinkInterval ?? 3.4);
+ // Blink again sooner sometimes, so it does not look metronomic.
+ this.blinkTimer = base * (0.55 + Math.random());
+ if (Math.random() < 0.22) this.blinkTimer *= 0.35; // occasional double blink
+ } else {
+ this.eyeOpen = 1 - Math.pow(Math.sin(Math.PI * this.blinkPhase), 0.8);
+ }
+ return;
+ }
+ this.blinkTimer -= dt;
+ if (this.blinkTimer <= 0) this.blinkPhase = 0;
+ }
+
+ updateLook(dt) {
+ if (!this.state.anim.lookAround) {
+ this.look.x = 0;
+ this.look.y = 0;
+ return;
+ }
+ const t = this.time;
+ this.look.x = Math.sin(t * 0.37) * 0.3 + Math.sin(t * 0.13 + 1.7) * 0.14;
+ this.look.y = Math.sin(t * 0.29 + 0.6) * 0.2;
+ }
+
+ /** Base pose plus the current movement offsets; `pose` is `{ bones, root }`. */
+ pose(basePose = {}) {
+ const bones = { ...(basePose.bones ?? {}) };
+ const root = [...(basePose.root ?? [0, 0, 0])];
+ const mode = this.state.anim.mode ?? 'off';
+ if (mode === 'off' || this.idleWeight <= 0.001) return { bones, root };
+
+ const w = this.idleWeight;
+ const t = this.time;
+ const add = (name, dx, dy, dz) => {
+ const current = bones[name] ?? [0, 0, 0];
+ bones[name] = [current[0] + dx * w, current[1] + dy * w, current[2] + dz * w];
+ };
+
+ if (mode === 'walk') {
+ // A waddle for a character with no legs: a step bob, a side-to-side rock
+ // and alternating arms and feet.
+ //
+ // Z is the forward/back swing, and it is mirrored between the sides - so the
+ // SAME value on both arms swings them opposite ways, which is what a walk
+ // wants. X (which the first version used) is the *lift*, so opposite signs
+ // there just raised one flipper and dropped the other one.
+ // `legsupport` pivots at the middle of the body, so the legs take a much
+ // smaller angle than the arms. The legs swing on the OPPOSITE phase to the
+ // arms, so when a hand comes forward it is the opposite foot that steps out
+ // (the same value as the arms put them in step, i.e. a "same-side" waddle).
+ const phase = Math.sin(t * 2.4);
+ const bob = Math.abs(Math.sin(t * 2.4));
+ add('master', -2, 0, phase * 4);
+ add('arm.l', 6, 0, phase * 26);
+ add('arm.r', 6, 0, phase * 26);
+ add('hand.l', 0, 0, phase * 10);
+ add('hand.r', 0, 0, phase * 10);
+ add('legsupport.l', 0, 0, -phase * 9);
+ add('legsupport.r', 0, 0, -phase * 9);
+ root[1] += bob * 0.05;
+ return { bones, root };
+ }
+
+ if (mode === 'wave') {
+ // One flipper is held up and sweeps back and forth, with the hand lagging a
+ // little behind it; the body and the other flipper rock along gently. The
+ // lift is kept modest: raising the arm much further folds the flipper over
+ // the top of the head, where its inner edge cuts into the face, so the X
+ // here (and its swing) stop well short of that. The Z swing is what reads
+ // as the wave, and it is biased slightly *back* for the same reason.
+ const wave = Math.sin(t * 3.2);
+ add('arm.l', 66 + wave * 8, 0, 6 + wave * 10);
+ add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 14);
+ add('arm.r', 0, 0, Math.sin(t * 1.6 + 1) * 4);
+ add('master', Math.sin(t * 1.6) * 1.2, 0, Math.sin(t * 1.6 + 0.4) * 3);
+ return { bones, root };
+ }
+
+ if (mode === 'sleep') {
+ // Slow breathing: the body rises and settles, the flippers drift, and the
+ // bubble swells and shrinks (that swell is `snotScale`, read by the loop).
+ const breath = 0.5 - 0.5 * Math.cos(t * 1.3);
+ add('master', Math.sin(t * 0.65) * 1.1, 0, 0);
+ add('arm.l', 0, 0, Math.sin(t * 0.9) * 2.5);
+ add('arm.r', 0, 0, -Math.sin(t * 0.9) * 2.5);
+ root[1] += breath * 0.03;
+ return { bones, root };
+ }
+
+ // Default: gentle breathing, as if standing there alive.
+ add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6);
+ add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5);
+ add('arm.r', 0, 0, -Math.sin(t * 1.28 + 0.5) * 4.5);
+ add('hand.l', 0, 0, Math.sin(t * 1.05 + 1) * 3);
+ add('hand.r', 0, 0, -Math.sin(t * 1.05 + 1) * 3);
+ root[1] += Math.sin(t * 1.5) * 0.022;
+ return { bones, root };
+ }
+}
+
+/**
+ * Records the viewport canvas to a WebM blob while the animation plays.
+ * Chrome and Edge support `MediaRecorder` on `canvas.captureStream()`.
+ */
+export function createRecorder(canvas) {
+ if (typeof MediaRecorder === 'undefined' || !canvas.captureStream) return null;
+ const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
+ .find((type) => MediaRecorder.isTypeSupported?.(type));
+ return { canvas, mimeType: mimeType ?? '', recorder: null, chunks: [] };
+}
+
+export function startRecording(session) {
+ if (!session) return false;
+ const stream = session.canvas.captureStream(30);
+ const recorder = session.mimeType
+ ? new MediaRecorder(stream, { mimeType: session.mimeType, videoBitsPerSecond: 8000000 })
+ : new MediaRecorder(stream);
+ session.chunks = [];
+ session.recorder = recorder;
+ recorder.ondataavailable = (event) => { if (event.data?.size) session.chunks.push(event.data); };
+ recorder.start(100);
+ return true;
+}
+
+export function stopRecording(session) {
+ return new Promise((resolve) => {
+ if (!session?.recorder || session.recorder.state === 'inactive') {
+ resolve(null);
+ return;
+ }
+ const { recorder, chunks } = session;
+ recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
+ recorder.stop();
+ });
+}
+
+const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
diff --git a/bluebey-studio/src/background.js b/bluebey-studio/src/background.js
new file mode 100644
index 0000000..adc7f94
--- /dev/null
+++ b/bluebey-studio/src/background.js
@@ -0,0 +1,666 @@
+/**
+ * The studio backdrop, as a DOM layer that sits behind the WebGL canvas.
+ *
+ * The renderer is created with `alpha: true`, so whenever it is cleared to
+ * alpha 0 the page shows through. Painting the backdrop into a sibling layer
+ * placed *under* `#view` therefore gives the character a scene without touching
+ * the scene graph: the CC0 photo, the user's own picture and the phone's camera
+ * feed are all just CSS on one element, and no texture has to be uploaded per
+ * frame.
+ *
+ * `backdropStyle()` is deliberately pure. It is the only place that turns the
+ * `view.background*` slice into a CSS descriptor, so the behaviour `apply()`
+ * ships with is exactly the behaviour the unit tests cover.
+ */
+
+/** The library already downloaded into `assets/backgrounds/` (see CREDITS.json).
+ * Most are Poly Haven *HDRIs* - CC0 photographs of real places - so each one comes
+ * with a floor, a horizon and perspective, which is what gives a picture depth
+ * that a flat wall texture cannot. `mutoujima`, `dokan` and `uchu` are the
+ * exceptions: original illustrations drawn for this studio by the author, not
+ * CC0 photos. */
+export const BACKGROUND_PRESETS = [
+ { name: 'empty_warehouse_01', label: '倉庫' },
+ { name: 'ballroom', label: '広間' },
+ { name: 'kloppenheim_06_puresky', label: '空と雲' },
+ { name: 'autumn_park', label: '秋の公園' },
+ // The places a councillor explains things in, or stands in to make a point.
+ // Poly Haven has no true classroom, desert or 社長室, so these are the closest
+ // real places it does have.
+ { name: 'newman_cafeteria', label: '学校' },
+ { name: 'wooden_lounge', label: '社長室(木の部屋)' },
+ { name: 'minedump_flats', label: '砂漠' },
+ { name: 'spiaggia_di_mondello', label: '海岸' },
+ // ポリヘイブンの写真ではなく、作者(安竹洋平)が描き起こしたオリジナルの一枚絵。
+ { name: 'mutoujima', label: '無人島' },
+ { name: 'dokan', label: '土管のある空き地' },
+ { name: 'uchu', label: '宇宙船の中' },
+ // 「ぶるべーを探せ!」用。物がたくさん詰まっていて、同じ形が何度も出てくる場所。
+ { name: 'abandoned_factory_canteen_01', label: '廃工場の食堂' },
+ { name: 'basement_boxing_ring', label: '地下室のリング' },
+ { name: 'autoshop_01', label: '自動車工場' },
+ { name: 'urban_alley_01', label: '路地' },
+];
+
+const DEFAULT_PRESET = 'autumn_park';
+
+/**
+ * Manga effect-line backdrops, drawn *procedurally* (no file, no network).
+ *
+ * They are the classic stress marks: a burst of lines from behind the character
+ * (`focus`), lines raining down (`fall`), lines streaming sideways (`speed`) and
+ * short lines ringing a wide ellipse so the empty middle reads as isolation
+ * (`ellipse`).
+ * Each is a canvas painted black on white; the same generator runs for the
+ * preview, the PNG and the standalone build.
+ */
+export const EFFECT_PRESETS = [
+ { name: 'focus', label: '集中線' },
+ { name: 'fall', label: '落ち込み線' },
+ { name: 'speed', label: '疾走線' },
+ { name: 'ellipse', label: '楕円集中線' },
+];
+
+const DEFAULT_EFFECT = 'focus';
+
+/** The canvas every effect is painted on, so preview and export agree. */
+export const EFFECT_SIZE = { width: 1200, height: 800 };
+
+/**
+ * A small deterministic PRNG (FNV-1a seeding + mulberry32), so an effect looks
+ * the same on every run, in every build, and in the tests.
+ */
+function makeRandom(seedText) {
+ let seed = 2166136261;
+ for (let i = 0; i < seedText.length; i++) {
+ seed ^= seedText.charCodeAt(i);
+ seed = Math.imul(seed, 16777619);
+ }
+ return () => {
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Paint one effect-line backdrop onto `ctx`. Pure and deterministic: given the
+ * same name and size it always issues the same black-on-white strokes, so the
+ * unit tests can pin the look down and the cache below stays sound.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {'focus'|'fall'|'speed'|'ellipse'} name
+ * @param {{width:number,height:number}} [size]
+ */
+export function drawEffectLines(ctx, name, size = EFFECT_SIZE) {
+ const width = positiveOrOne(size?.width);
+ const height = positiveOrOne(size?.height);
+ const kind = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT;
+ const random = makeRandom(kind);
+
+ ctx.save();
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, width, height);
+ ctx.strokeStyle = '#111111';
+ ctx.lineCap = 'butt';
+
+ if (kind === 'focus') {
+ // A burst: lines radiate from a point behind the head, and stop short of it
+ // so the character's face is not crossed by ink.
+ const cx = width * 0.5;
+ const cy = height * 0.42;
+ const reach = Math.hypot(width, height) * 1.1;
+ const near = Math.min(width, height);
+ for (let i = 0; i < 150; i++) {
+ const angle = (i / 150) * Math.PI * 2 + (random() - 0.5) * 0.03;
+ const inner = near * (0.12 + random() * 0.5);
+ ctx.lineWidth = 1 + random() * 4;
+ ctx.beginPath();
+ ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
+ ctx.lineTo(cx + Math.cos(angle) * reach, cy + Math.sin(angle) * reach);
+ ctx.stroke();
+ }
+ } else if (kind === 'fall') {
+ // Lines raining down from the top edge, of uneven length.
+ for (let i = 0; i < 90; i++) {
+ const x = Math.min(width, Math.max(0, ((i + 0.5) / 90) * width + (random() - 0.5) * 8));
+ const length = height * (0.25 + random() * 0.7);
+ ctx.lineWidth = 1 + random() * 3;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, length);
+ ctx.stroke();
+ }
+ } else if (kind === 'speed') {
+ // Lines streaming in from one side or the other.
+ for (let i = 0; i < 70; i++) {
+ const y = Math.min(height, Math.max(0, ((i + 0.5) / 70) * height + (random() - 0.5) * 6));
+ const length = width * (0.35 + random() * 0.65);
+ const x = random() < 0.5 ? 0 : width - length;
+ ctx.lineWidth = 1 + random() * 3.5;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + length, y);
+ ctx.stroke();
+ }
+ } else if (kind === 'ellipse') {
+ // A dense ring of short lines *outside* a wide ellipse: the blank inside is
+ // the point (the 「ひとり」 panel), so nothing is drawn there. The start
+ // points are spaced by arc length, not by angle - an equal angular step
+ // bunches the lines at the two ends of a wide ellipse and leaves gaps along
+ // the flat top and bottom, which is what made the old ring look uneven. Each
+ // line then leaves along the ellipse's outward normal, so the inner edge
+ // stays a clean ellipse all the way round. Lengths vary (0.5-1.7x reach) and
+ // every line is anchored to the ring, so none reads as a stray scratch.
+ const cx = width * 0.5;
+ const cy = height * 0.5;
+ const rx = Math.min(width * 0.42, height * 0.9);
+ const ry = rx * 0.55;
+ const lines = 200;
+ const reach = Math.min(width, height) * 0.24;
+
+ // Cumulative arc length around the ring, so the lines can be spread evenly
+ // around it rather than evenly by angle.
+ const samples = 512;
+ const arc = [0];
+ for (let s = 1; s <= samples; s++) {
+ const prev = ((s - 1) / samples) * Math.PI * 2;
+ const next = (s / samples) * Math.PI * 2;
+ const dx = (Math.cos(next) - Math.cos(prev)) * rx;
+ const dy = (Math.sin(next) - Math.sin(prev)) * ry;
+ arc.push(arc[s - 1] + Math.hypot(dx, dy));
+ }
+ const total = arc[samples];
+
+ let s = 1;
+ for (let i = 0; i < lines; i++) {
+ const target = ((i + 0.5) / lines) * total;
+ while (s < samples && arc[s] < target) s++;
+ const span = arc[s] - arc[s - 1] || 1;
+ const t = (((s - 1) + (target - arc[s - 1]) / span) / samples) * Math.PI * 2;
+ const cos = Math.cos(t);
+ const sin = Math.sin(t);
+ // The outward normal of an ellipse points along (cos/rx, sin/ry), not along
+ // the radius from the centre, so the lines meet the ring at a right angle.
+ const nx = cos / rx;
+ const ny = sin / ry;
+ const length = (reach * (0.5 + random() * 1.1)) / (Math.hypot(nx, ny) || 1);
+ ctx.lineWidth = 1 + random() * 3.5;
+ const startX = cx + cos * rx;
+ const startY = cy + sin * ry;
+ ctx.beginPath();
+ ctx.moveTo(startX, startY);
+ ctx.lineTo(startX + nx * length, startY + ny * length);
+ ctx.stroke();
+ }
+ }
+ ctx.restore();
+}
+
+/** How each `backgroundFit` paints: a CSS size, plus whether the image tiles. */
+const FIT_STYLES = {
+ cover: { backgroundSize: 'cover', backgroundRepeat: 'no-repeat' },
+ contain: { backgroundSize: 'contain', backgroundRepeat: 'no-repeat' },
+ stretch: { backgroundSize: '100% 100%', backgroundRepeat: 'no-repeat' },
+ tile: { backgroundSize: 'auto', backgroundRepeat: 'repeat' },
+};
+const DEFAULT_FIT = 'cover';
+
+/** A live frame is a replaced element, so the same fit maps to `object-fit`. */
+const OBJECT_FIT = { cover: 'cover', contain: 'contain', '100% 100%': 'fill', auto: 'cover' };
+
+const MODES = new Set(['solid', 'transparent', 'preset', 'image', 'effect', 'camera']);
+
+/** Device orientation fires around 60 Hz; 30 Hz is plenty for swinging a camera. */
+const GYRO_INTERVAL_MS = 33;
+
+/** Coerce to a finite number, or `null` when the value is not one. */
+function finiteOrNull(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+function normalizeMode(mode) {
+ return MODES.has(mode) ? mode : 'solid';
+}
+
+function clamp01(value) {
+ const n = finiteOrNull(value);
+ if (n == null) return 0;
+ return Math.min(1, Math.max(0, n));
+}
+
+function nonNegative(value) {
+ const n = finiteOrNull(value);
+ return n == null ? 0 : Math.max(0, n);
+}
+
+function positiveOrOne(value) {
+ const n = finiteOrNull(value);
+ return n == null || n <= 0 ? 1 : n;
+}
+
+/** The shortest signed distance between two angles, in degrees. */
+function wrapDeg(degrees) {
+ return ((degrees + 540) % 360) - 180;
+}
+
+/**
+ * Turn the `view` slice into the plain descriptor `apply()` renders from.
+ * Every field is optional, so a half-written state still yields a usable look;
+ * an unknown `background` falls back to `solid` and an unknown `backgroundFit`
+ * to `cover`.
+ *
+ * `offset` is in fractions of the layer size, and `visible` is false only for
+ * `transparent` (where the page background is meant to show through).
+ */
+export function backdropStyle(viewState) {
+ const state = viewState && typeof viewState === 'object' ? viewState : {};
+ const fit = FIT_STYLES[state.backgroundFit] ?? FIT_STYLES[DEFAULT_FIT];
+ const offset = state.backgroundOffset && typeof state.backgroundOffset === 'object'
+ ? state.backgroundOffset
+ : {};
+
+ return {
+ backgroundSize: fit.backgroundSize,
+ backgroundRepeat: fit.backgroundRepeat,
+ mirror: state.cameraMirror === true,
+ darken: clamp01(state.backgroundDarken),
+ blur: nonNegative(state.backgroundBlur),
+ offset: { x: finiteOrNull(offset.x) ?? 0, y: finiteOrNull(offset.y) ?? 0 },
+ scale: positiveOrOne(state.backgroundScale),
+ visible: normalizeMode(state.background) !== 'transparent',
+ };
+}
+
+/** `assets/backgrounds/.webp` - what the multi-file build serves. */
+function defaultResolveUrl(name) {
+ return `assets/backgrounds/${name}.webp`;
+}
+
+/**
+ * Build the backdrop layer for a `#stage` element.
+ *
+ * `resolveUrl(name)` maps a preset name to an image URL; the single-file build
+ * passes one that reads an inlined map, while the default points at
+ * `assets/backgrounds/`. `onNeedsRender` is called whenever the layer changes so
+ * the host can repaint.
+ *
+ * The returned object is the only way in: the layer never reads or writes the
+ * studio state itself, `apply(viewState)` is the single entry point.
+ */
+export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) {
+ if (!stage || typeof stage.prepend !== 'function') {
+ throw new Error('createBackdrop: ステージ要素(#stage)が必要です');
+ }
+
+ const doc = stage.ownerDocument ?? globalThis.document;
+ const resolve = typeof resolveUrl === 'function' ? resolveUrl : defaultResolveUrl;
+ const needsRender = typeof onNeedsRender === 'function' ? onNeedsRender : () => {};
+
+ const el = doc.createElement('div');
+ el.className = 'backdrop';
+ el.setAttribute('aria-hidden', 'true');
+ Object.assign(el.style, {
+ position: 'absolute',
+ inset: '0',
+ zIndex: '0',
+ overflow: 'hidden',
+ pointerEvents: 'none',
+ transformOrigin: 'center',
+ });
+
+ // The image surface is a plain div so blur/scale/tint stay pure CSS.
+ const media = doc.createElement('div');
+ Object.assign(media.style, { position: 'absolute', inset: '0', backgroundPosition: 'center' });
+
+ // The camera feed is a muted, inline, autoplaying video for the AR mode.
+ const video = doc.createElement('video');
+ video.muted = true;
+ video.playsInline = true;
+ video.autoplay = true;
+ video.setAttribute('muted', '');
+ video.setAttribute('playsinline', '');
+ video.setAttribute('aria-hidden', 'true');
+ Object.assign(video.style, { position: 'absolute', inset: '0', display: 'none' });
+
+ // The dark veil sits on top of whichever surface is showing, so the character
+ // keeps its contrast against a busy photo.
+ const veil = doc.createElement('div');
+ Object.assign(veil.style, { position: 'absolute', inset: '0' });
+
+ el.append(media, video, veil);
+ // Prepended, i.e. before the canvas; the canvas is lifted back above it.
+ stage.prepend(el);
+
+ const canvas = stage.querySelector('canvas');
+ if (canvas) {
+ canvas.style.position = 'relative';
+ canvas.style.zIndex = '1';
+ }
+
+ /** The most recent state slice, so the imperative helpers keep its styling. */
+ let last = {};
+ let stream = null;
+
+ let gyroEnabled = false;
+ let gyroAttached = false;
+ let gyroBase = null;
+ let gyroCallback = null;
+ let lastGyroAt = 0;
+
+ const effectUrls = new Map();
+
+ /** The data URL for an effect-line backdrop, generated once and cached. */
+ function effectUrl(name) {
+ const key = EFFECT_PRESETS.some((preset) => preset.name === name) ? name : DEFAULT_EFFECT;
+ if (effectUrls.has(key)) return effectUrls.get(key);
+ const surface = doc.createElement('canvas');
+ surface.width = EFFECT_SIZE.width;
+ surface.height = EFFECT_SIZE.height;
+ const surfaceCtx = surface.getContext('2d');
+ if (!surfaceCtx) return null;
+ drawEffectLines(surfaceCtx, key, EFFECT_SIZE);
+ const url = surface.toDataURL('image/png');
+ effectUrls.set(key, url);
+ return url;
+ }
+
+ function imageUrlOf(mode, viewState) {
+ if (mode === 'image') {
+ return typeof viewState.backgroundImage === 'string' && viewState.backgroundImage
+ ? viewState.backgroundImage
+ : null;
+ }
+ if (mode === 'effect') {
+ const name = typeof viewState.backgroundEffect === 'string' && viewState.backgroundEffect
+ ? viewState.backgroundEffect
+ : DEFAULT_EFFECT;
+ return effectUrl(name);
+ }
+ if (mode !== 'preset') return null;
+
+ const name = typeof viewState.backgroundPreset === 'string' && viewState.backgroundPreset
+ ? viewState.backgroundPreset
+ : DEFAULT_PRESET;
+ try {
+ const url = resolve(name);
+ if (typeof url === 'string' && url) return url;
+ } catch {
+ // Fall through to the file path: an unknown preset is not worth throwing.
+ }
+ return defaultResolveUrl(name);
+ }
+
+ function colorOr(value) {
+ return typeof value === 'string' && value ? value : '#ffffff';
+ }
+
+ /**
+ * Fit, mirror, blur and the blur bleeding past the edges (the negative inset
+ * keeps `filter: blur()` from fading the border to transparent).
+ */
+ function styleSurface(node, style) {
+ node.style.inset = style.blur > 0 ? `-${style.blur * 2}px` : '0';
+ node.style.filter = style.blur > 0 ? `blur(${style.blur}px)` : 'none';
+ node.style.transform = style.mirror ? 'scaleX(-1)' : 'none';
+
+ if (node === media) {
+ node.style.backgroundSize = style.backgroundSize;
+ node.style.backgroundRepeat = style.backgroundRepeat;
+ } else {
+ node.style.objectFit = OBJECT_FIT[style.backgroundSize] ?? 'cover';
+ }
+ }
+
+ function apply(viewState) {
+ last = viewState && typeof viewState === 'object' ? viewState : {};
+ const style = backdropStyle(last);
+
+ const wanted = normalizeMode(last.background);
+ const url = imageUrlOf(wanted, last);
+ // A preset that cannot be resolved is shown as a plain colour, never blank.
+ const drawable = wanted === 'preset' || wanted === 'image' || wanted === 'effect';
+ const mode = drawable && !url ? 'solid' : wanted;
+
+ el.style.display = style.visible ? 'block' : 'none';
+ el.style.backgroundColor = mode === 'solid' ? colorOr(last.backgroundColor) : 'transparent';
+ el.style.transform = style.scale === 1 && style.offset.x === 0 && style.offset.y === 0
+ ? 'none'
+ : `scale(${style.scale}) translate(${style.offset.x * 100}%, ${style.offset.y * 100}%)`;
+
+ veil.style.background = `rgba(0, 0, 0, ${style.darken})`;
+ veil.style.display = style.darken > 0 ? 'block' : 'none';
+
+ const showCamera = style.visible && mode === 'camera';
+ const showImage = style.visible && (mode === 'preset' || mode === 'image' || mode === 'effect');
+ // Leaving the AR mode must release the camera, not just hide the video.
+ if (!showCamera && stream) stopCamera();
+
+ styleSurface(media, style);
+ styleSurface(video, style);
+ media.style.display = showImage ? 'block' : 'none';
+ video.style.display = showCamera ? 'block' : 'none';
+ media.style.backgroundImage = showImage ? `url("${url}")` : 'none';
+
+ needsRender();
+ }
+
+ /** Show a built-in backdrop, keeping the fit/darken/blur already in play. */
+ function loadPreset(name) {
+ const preset = typeof name === 'string' && name ? name : DEFAULT_PRESET;
+ apply({ ...last, background: 'preset', backgroundPreset: preset });
+ }
+
+ /** Apply a stored data URL (as kept in `view.backgroundImage`). */
+ function applyImage(dataUrl) {
+ if (typeof dataUrl !== 'string' || !dataUrl) {
+ apply({ ...last, background: 'solid', backgroundImage: null });
+ return;
+ }
+ apply({ ...last, background: 'image', backgroundImage: dataUrl });
+ }
+
+ /** Read a user File into a data URL, show it, and hand the URL back to save. */
+ async function loadImageFile(file) {
+ const dataUrl = await new Promise((resolveUrl, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolveUrl(String(reader.result));
+ reader.onerror = () => reject(reader.error ?? new Error('画像を読み込めませんでした'));
+ reader.readAsDataURL(file);
+ });
+ applyImage(dataUrl);
+ return dataUrl;
+ }
+
+ /* Shown whenever the page is not a secure context. This is a fact about the
+ * web platform, not about any one browser, so the wording must not point a
+ * finger at the browser the user happens to be holding. */
+ const INSECURE_CAMERA_REASON =
+ 'この開き方(http のアドレス)では、どのブラウザでもカメラを使えません。https のサイトか localhost で開いてください。';
+
+ /**
+ * Japanese, actionable text for the ways `getUserMedia` usually fails. On a
+ * phone this toast is all the user has to go on, so each case names the exact
+ * thing to tap instead of just stating that something went wrong.
+ */
+ function cameraFailureReason(error) {
+ switch (error?.name) {
+ case 'NotAllowedError':
+ case 'PermissionDeniedError':
+ return 'カメラが許可されていません。URLバーのアイコン→カメラ→「許可」に変え(一度「ブロック」するとブラウザは覚えています)、端末の設定でもアプリに許可してください(Android: 設定→アプリ→Brave→権限→カメラ/iOS: 設定→Brave→カメラ)。';
+ case 'NotFoundError':
+ case 'DevicesNotFoundError':
+ return '使えるカメラが見つかりませんでした';
+ case 'NotReadableError':
+ case 'TrackStartError':
+ return 'カメラを起動できませんでした(他のアプリが使用中の可能性があります)';
+ case 'OverconstrainedError':
+ case 'ConstraintNotSatisfiedError':
+ return '指定したカメラを使用できません';
+ case 'SecurityError':
+ // No `mediaDevices` at all is the usual symptom, but this covers the
+ // same cause when a sandboxed frame raises the error instead.
+ return INSECURE_CAMERA_REASON;
+ default:
+ return 'カメラを起動できませんでした';
+ }
+ }
+
+ async function startCamera(facing = 'environment') {
+ const mediaDevices = globalThis.navigator?.mediaDevices;
+ if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') {
+ // An insecure page has no `mediaDevices` at all. Say so plainly rather
+ // than leaving the user to think their camera or browser is broken.
+ return {
+ ok: false,
+ reason: globalThis.isSecureContext === false
+ ? INSECURE_CAMERA_REASON
+ : 'カメラを使うには https か localhost が必要です',
+ };
+ }
+
+ stopCamera();
+ try {
+ const next = await mediaDevices.getUserMedia({ video: { facingMode: facing } });
+ stream = next;
+ video.srcObject = next;
+ try {
+ await video.play();
+ } catch {
+ // A blocked autoplay still resolves to a painting stream in practice.
+ }
+ apply({ ...last, background: 'camera', cameraFacing: facing });
+ return { ok: true };
+ } catch (error) {
+ stopCamera();
+ return { ok: false, reason: cameraFailureReason(error) };
+ }
+ }
+
+ function stopCamera() {
+ const active = stream;
+ stream = null;
+ if (active) {
+ try {
+ for (const track of active.getTracks()) track.stop();
+ } catch {
+ // A track that cannot be stopped is already gone as far as we care.
+ }
+ }
+ if (video) {
+ try {
+ video.pause();
+ } catch {
+ // Pausing a video without a source is expected to throw.
+ }
+ video.srcObject = null;
+ video.style.display = 'none';
+ }
+ }
+
+ function emitGyro(yaw, pitch, roll) {
+ gyroCallback?.({ yaw, pitch, roll });
+ }
+
+ /**
+ * Attach the orientation listener and take the current pose as zero, so the
+ * first reading a callback sees is `{ yaw: 0, pitch: 0, roll: 0 }`.
+ */
+ function enableGyro() {
+ gyroEnabled = true;
+ gyroBase = null;
+ if (!gyroAttached && typeof globalThis.addEventListener === 'function') {
+ globalThis.addEventListener('deviceorientation', handleOrientation);
+ gyroAttached = true;
+ }
+ emitGyro(0, 0, 0);
+ }
+
+ /**
+ * Degrees of turn from the pose gyro started at. `yaw` grows clockwise
+ * (turning the device to the right); `pitch` and `roll` follow the raw
+ * `beta` and `gamma` axes. Readings are throttled to roughly 30 Hz.
+ */
+ function handleOrientation(event) {
+ if (!gyroEnabled) return;
+
+ const alpha = typeof event?.alpha === 'number' ? event.alpha : null;
+ const beta = typeof event?.beta === 'number' ? event.beta : null;
+ const gamma = typeof event?.gamma === 'number' ? event.gamma : null;
+ if (alpha == null && beta == null && gamma == null) return;
+
+ if (!gyroBase) gyroBase = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 };
+
+ const now = Date.now();
+ if (now - lastGyroAt < GYRO_INTERVAL_MS) return;
+ lastGyroAt = now;
+
+ emitGyro(
+ alpha == null ? 0 : wrapDeg(gyroBase.alpha - alpha),
+ beta == null ? 0 : wrapDeg(beta - gyroBase.beta),
+ gamma == null ? 0 : wrapDeg(gamma - gyroBase.gamma),
+ );
+ }
+
+ /** Ask for orientation permission (iOS) and start listening. */
+ async function requestGyro() {
+ const OrientationEvent = globalThis.DeviceOrientationEvent;
+ if (!OrientationEvent || typeof globalThis.addEventListener !== 'function') return false;
+
+ if (typeof OrientationEvent.requestPermission === 'function') {
+ let granted = false;
+ try {
+ granted = (await OrientationEvent.requestPermission()) === 'granted';
+ } catch {
+ granted = false;
+ }
+ if (!granted) return false;
+ }
+
+ enableGyro();
+ return true;
+ }
+
+ /** Subscribe to `{ yaw, pitch, roll }`; returns an unsubscribe function. */
+ function onGyro(callback) {
+ gyroCallback = typeof callback === 'function' ? callback : null;
+ if (!gyroEnabled) enableGyro();
+ return () => {
+ if (gyroCallback === callback) gyroCallback = null;
+ };
+ }
+
+ function dispose() {
+ stopCamera();
+ if (gyroAttached) {
+ globalThis.removeEventListener?.('deviceorientation', handleOrientation);
+ gyroAttached = false;
+ }
+ gyroEnabled = false;
+ gyroCallback = null;
+ gyroBase = null;
+ el.remove();
+ }
+
+ return {
+ el,
+ apply,
+ loadPreset,
+ loadImageFile,
+ applyImage,
+ startCamera,
+ stopCamera,
+ /** The data URL of a drawn effect line, so exports can composite the same
+ * bitmap the preview shows. */
+ effectUrl,
+ get cameraActive() {
+ return stream !== null;
+ },
+ requestGyro,
+ onGyro,
+ snapshot() {},
+ dispose,
+ };
+}
diff --git a/bluebey-studio/src/caption.js b/bluebey-studio/src/caption.js
new file mode 100644
index 0000000..71759ea
--- /dev/null
+++ b/bluebey-studio/src/caption.js
@@ -0,0 +1,1321 @@
+import {
+ captionFontStack,
+ hasGlyphs,
+ layoutText,
+ textToPathData,
+} from './textOutlines.js';
+
+/**
+ * The speech bubble that sits next to the character.
+ *
+ * The same caption is drawn three times: into the 2D canvas that is overlaid on
+ * the 3D view, into the canvas that gets composited into an exported PNG, and
+ * into the vector SVG export. Drawing a shape three times by hand is how a
+ * preview starts to disagree with the file it produced, so the outline is built
+ * exactly once, here, as a short list of path commands. `drawCaption` replays
+ * that list through a 2D context and `captionToSvg` prints the same list as SVG
+ * path data, which is what keeps the two renderers together.
+ *
+ * The module never measures or wraps text itself: `textOutlines.js` owns line
+ * breaking and glyph outlines. This file only decides how large the bubble has
+ * to be for the block it is handed, padding included, and guarantees that block
+ * fits inside.
+ *
+ * It is pure in the same sense as `trace.js`: no DOM, no globals and no
+ * asynchronous work, so the tests can drive it in Node with a stub context.
+ */
+
+/** Defaults mirroring the `caption` slice of `presets.js`. */
+const DEFAULT_FONT_SIZE = 34;
+const DEFAULT_LINE_HEIGHT = 1.42;
+const DEFAULT_PADDING = 18;
+const DEFAULT_RADIUS = 24;
+const DEFAULT_BORDER_WIDTH = 4;
+const DEFAULT_MAX_WIDTH = 0.36;
+const DEFAULT_TEXT_COLOR = '#3f2b52';
+const DEFAULT_BUBBLE_COLOR = '#ffffff';
+const DEFAULT_BORDER_COLOR = '#55386e';
+
+/** Advance widths used before the vendored font has loaded, as a fraction of em. */
+const FALLBACK_WIDE_EM = 1;
+const FALLBACK_NARROW_EM = 0.55;
+
+/**
+ * Vertical metrics of the fallback stack, as a fraction of the font size.
+ *
+ * A typical Japanese family sits close to these numbers, so the baseline of the
+ * pre-load preview barely moves when the real font arrives.
+ */
+const FALLBACK_ASCENT = 0.88;
+const FALLBACK_DESCENT = 0.12;
+
+/** Control-point distance that turns a corner into a quarter circle. */
+const KAPPA = 0.5522847498307936;
+
+/** Code-point ranges that deserve a full em in the fallback measurement. */
+const WIDE_RANGES = [
+ [0x1100, 0x11ff], [0x2e80, 0x30ff], [0x3130, 0x318f], [0x3400, 0x4dbf],
+ [0x4e00, 0x9fff], [0xac00, 0xd7ff], [0xf900, 0xfaff], [0xff00, 0xffef],
+];
+
+/**
+ * Punctuation that may hang past the bottom of a vertical column.
+ *
+ * Japanese line breaking (禁則処理) forbids starting a line with a closing
+ * mark, so when one of these would land at the top of a new column it is kept
+ * on the previous column and drawn past that column's last cell instead.
+ */
+const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']);
+
+/** Small kana, which sit toward the upper-right of their cell when stacked. */
+const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ';
+
+/**
+ * Characters that may not open a column (行頭禁則).
+ *
+ * A closing mark such as `、` simply hangs off the previous column; `ー`, small
+ * kana and closing brackets are kept company by handing the preceding
+ * character over to the next column as well, so a column never starts with
+ * something that has nothing to attach to.
+ */
+const COLUMN_START_FORBIDDEN = new Set([
+ '」', '』', ')', '〕', '】', '〉', '》', '”', '’', '・', 'ー',
+ ...SMALL_KANA,
+ ...HANGING_PUNCTUATION,
+]);
+
+/** Characters that may not close a column (行末禁則). */
+const COLUMN_END_FORBIDDEN = new Set(['「', '『', '(', '〔', '【', '〈', '《', '“', '‘']);
+
+/**
+ * Per-character offset for vertical (縦書き) text, in em.
+ *
+ * A column draws one glyph per cell, so a few characters need a nudge to read
+ * the way a Japanese typesetter would place them: the prolonged sound mark
+ * becomes an upright tick, small kana tuck into the upper-right, and the
+ * brackets lean toward the ends of the span they enclose. Everything else
+ * stays dead-centre. Sharing this table is what keeps the canvas and the SVG
+ * export in step.
+ */
+const VERTICAL_OFFSETS = new Map();
+for (const ch of SMALL_KANA) VERTICAL_OFFSETS.set(ch, { dx: 0.12, dy: -0.12 });
+// 縦書きの句読点は、横書きの左下ではなく、字枠の右上に置く。
+for (const ch of HANGING_PUNCTUATION) VERTICAL_OFFSETS.set(ch, { dx: 0.68, dy: -0.55 });
+
+/**
+ * Characters drawn turned a quarter turn when stacked, so they read the way
+ * 縦書き sets them: the prolonged sound mark `ー` becomes an upright bar, and the
+ * brackets `「」『』[]()` turn so they open down the column.
+ */
+const VERTICAL_ROTATED = new Set(['ー', '~', '〜', '―']);
+const VERTICAL_BRACKETS = new Set([
+ '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '〔', '〕', '〈', '〉', '《', '》',
+]);
+
+/**
+ * How far a rotated glyph is nudged down inside its cell, in em. The rotated `ー`
+ * is centred otherwise, which leaves it hugging the character above it; the
+ * brackets stay centred.
+ */
+const VERTICAL_ROTATED_DROP = 0.14;
+
+/**
+ * The downward nudge (em) for a glyph that is turned a quarter turn in 縦書き, or
+ * `null` when the glyph is drawn the ordinary way.
+ */
+function verticalRotation(ch) {
+ if (VERTICAL_ROTATED.has(ch)) return VERTICAL_ROTATED_DROP;
+ if (VERTICAL_BRACKETS.has(ch)) return 0;
+ return null;
+}
+
+/** Offset used for the characters that need no special placement. */
+const NO_OFFSET = Object.freeze({ dx: 0, dy: 0 });
+
+/**
+ * The `ctx.font` string for a caption.
+ *
+ * Canvas wants size, weight and family in one string, and the size has to carry
+ * the export scale because the same caption is drawn at 1x for the preview and
+ * at 2x or more into a PNG. `loaded` is the app's answer to "is the vendored
+ * subset ready?": until it is, the vendored family is dropped from the stack so
+ * the preview does not ask for a font that is still downloading.
+ *
+ * @param {object} caption the `caption` slice of the state
+ * @param {number} [scale=1]
+ * @param {boolean} [loaded=false]
+ * @returns {string}
+ */
+export function fontSpec(caption, scale = 1, loaded = false) {
+ const state = caption ?? {};
+ const size = resolveFontSize(state, positiveScale(scale));
+ const weight = state.bold ? 'bold ' : '';
+ return `${weight}${formatNumber(size, 3)}px ${fontFamilyStack(state, loaded)}`;
+}
+
+/**
+ * Resolve a caption to pixel geometry at the output size.
+ *
+ * `x`/`y` are fractions of the image and land on the bubble's top-left corner;
+ * `maxWidth` is a fraction of the image width and caps the text column. The box
+ * is always the bubble itself, padding included and the tail excluded, and it
+ * always fits inside the image: padding gives way first, then the position is
+ * clamped (leaving room for the tail), and only a block larger than the image
+ * is clipped. Every returned number is finite, even for an empty caption or a
+ * zero-sized image.
+ *
+ * `font` is an opentype font from `textOutlines.js`. Without it the text is
+ * wrapped by a rough character count, so the preview still shows a bubble while
+ * the font is loading.
+ *
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @returns {{
+ * box: {x: number, y: number, w: number, h: number},
+ * lines: Array<{text: string, width: number}>,
+ * fontSize: number, lineHeight: number, padding: number, scale: number,
+ * usedOutlines: boolean,
+ * }}
+ */
+export function layoutCaption(caption, { width, height, scale = 1, font = null } = {}) {
+ const state = caption ?? {};
+ const resolvedScale = positiveScale(scale);
+ const outWidth = toNonNegative(width, 0);
+ const outHeight = toNonNegative(height, 0);
+
+ const fontSize = resolveFontSize(state, resolvedScale);
+ const lineHeight = toPositive(state.lineHeight, DEFAULT_LINE_HEIGHT);
+ const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale;
+ const text = typeof state.text === 'string' ? state.text : '';
+
+ const vertical = state.vertical === true;
+ const block = vertical
+ ? layoutVertical(text, {
+ fontSize,
+ lineHeight,
+ maxHeight: resolveColumn(state, outHeight, requested),
+ })
+ : layoutBlock(font, text, {
+ fontSize,
+ maxWidth: resolveColumn(state, outWidth, requested),
+ lineHeight,
+ align: alignOf(state),
+ });
+
+ // Padding gives way before the bubble does, so a caption that cannot fit with
+ // its usual breathing room still fits inside the image.
+ const padding = Math.max(
+ 0,
+ Math.min(requested, (outWidth - block.width) / 2, (outHeight - block.height) / 2),
+ );
+ const w = Math.min(block.width + 2 * padding, outWidth);
+ const h = Math.min(block.height + 2 * padding, outHeight);
+
+ // The tail sticks out of the box, so the box has to keep that much clear of
+ // the edge it points at.
+ const tail = tailOf(state);
+ const tailLength = tail === 'none' ? 0 : Math.max(0, Math.min(fontSize * 0.9, h * 0.5));
+ const roomX = Math.max(0, outWidth - w);
+ const roomY = Math.max(0, outHeight - h);
+
+ // Which way the tail leaves the box, as a direction where each axis is -1,
+ // 0 or 1. A diagonal tail needs room on *both* axes, so the clearance is
+ // driven by the direction rather than by which name the tail has.
+ const dir = tailDirection(tail);
+ let lowX = 0;
+ let highX = roomX;
+ if (dir.x < 0) lowX = Math.min(tailLength, roomX);
+ else if (dir.x > 0) highX = Math.max(0, roomX - tailLength);
+
+ let lowY = 0;
+ let highY = roomY;
+ if (dir.y < 0) lowY = Math.min(tailLength, roomY);
+ else if (dir.y > 0) highY = Math.max(0, roomY - tailLength);
+
+ const box = {
+ x: clamp(toFinite(state.x, 0) * outWidth, lowX, highX),
+ y: clamp(toFinite(state.y, 0) * outHeight, lowY, highY),
+ w,
+ h,
+ };
+
+ return {
+ box,
+ lines: block.lines,
+ fontSize,
+ lineHeight,
+ padding,
+ scale: resolvedScale,
+ vertical,
+ usedOutlines: Boolean(font),
+ };
+}
+
+/**
+ * Lay the bubble down in a 2D context, ready to be filled and stroked.
+ *
+ * The tail is part of the same path as the bubble, so the border runs along it
+ * and joins the outline cleanly instead of meeting it at a seam. The caller
+ * owns the colours; this only traces the shape.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {{x: number, y: number, w: number, h: number}} box bubble box, tail excluded
+ * @param {object} caption
+ * @param {number} [scale=1]
+ * @returns {boolean} `true` when there was a shape to trace
+ */
+export function bubblePath(ctx, box, caption, scale = 1) {
+ const commands = bubbleCommands(box, caption ?? {}, positiveScale(scale));
+ applyCommands(ctx, commands);
+ return commands.length > 0;
+}
+
+/**
+ * Draw a caption: bubble first, then the text line by line.
+ *
+ * The 2D canvas is overlaid exactly on the 3D view for the preview and is
+ * composited into the PNG on export, which is why the very same function runs
+ * in both places: what the user sees is what the file contains.
+ *
+ * The bubble box comes back even when the caption is switched off, so the app
+ * can show a placeholder and hit-test a drag without a second layout.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @returns {{x: number, y: number, w: number, h: number}}
+ */
+export function drawCaption(ctx, caption, { width, height, scale = 1, font = null } = {}) {
+ const state = caption ?? {};
+ const layout = layoutCaption(state, { width, height, scale, font });
+ const { box } = layout;
+ if (!state.enabled || !ctx) return box;
+
+ ctx.save();
+
+ const commands = bubbleCommands(box, state, layout.scale);
+ if (commands.length > 0) {
+ applyCommands(ctx, commands);
+ ctx.fillStyle = colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR);
+ ctx.fill();
+
+ const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
+ if (borderWidth > 0) {
+ ctx.strokeStyle = colorOf(state.borderColor, DEFAULT_BORDER_COLOR);
+ ctx.lineWidth = borderWidth;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ // A whisper is the rounded bubble with a broken outline.
+ if (bubbleOf(state) === 'whisper' && typeof ctx.setLineDash === 'function') {
+ ctx.setLineDash([borderWidth * 3, borderWidth * 2]);
+ }
+ ctx.stroke();
+ }
+ }
+
+ if (layout.lines.length > 0) drawText(ctx, layout, state, font);
+
+ ctx.restore();
+ return box;
+}
+
+/**
+ * The caption as an SVG fragment, plus whether the text is outlined.
+ *
+ * Text becomes glyph outlines whenever the font covers it, because a ``
+ * element is redrawn with whatever font the viewer happens to have and a
+ * caption that reflows is worse than one that cannot be selected. When a glyph
+ * is missing the whole caption falls back to `` and `usedOutlines` is
+ * `false`, which is the caller's signal to warn that the file now depends on
+ * the viewer's fonts.
+ *
+ * The bubble and its tail are one ``, traced from the same command list
+ * the canvas uses. `enabled` is not consulted: the export path only runs for an
+ * enabled caption, and drawing whatever is handed over keeps this usable for a
+ * thumbnail.
+ *
+ * @param {object} caption
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ * @param {import('opentype.js').Font|null} [options.font=null]
+ * @param {number} [options.round=2] decimal places in the output
+ * @returns {{svg: string, usedOutlines: boolean}}
+ */
+export function captionToSvg(caption, { width, height, scale = 1, font = null, round = 2 } = {}) {
+ const state = caption ?? {};
+ const places = Math.max(0, Math.min(8, Math.floor(Number.isFinite(round) ? round : 2)));
+ const layout = layoutCaption(state, { width, height, scale, font });
+ const { box, lines, fontSize, lineHeight, padding } = layout;
+ const outWidth = toNonNegative(width, 0);
+ const outHeight = toNonNegative(height, 0);
+ const text = typeof state.text === 'string' ? state.text : '';
+
+ let usedOutlines = Boolean(font) && text.trim() !== '' && hasGlyphs(font, text);
+ // Vertical text falls back to ``: the outline path lays glyphs out
+ // horizontally, and a column of glyphs reads fine as live text.
+ if (layout.vertical) usedOutlines = false;
+
+ const parts = [
+ '`,
+ ];
+
+ const bubble = commandsToPathData(bubbleCommands(box, state, layout.scale), places);
+ if (bubble) {
+ const fill = escapeAttribute(colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR));
+ const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale;
+ const stroke = borderWidth > 0
+ ? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"`
+ + ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"`
+ + (bubbleOf(state) === 'whisper'
+ ? ` stroke-dasharray="${formatNumber(borderWidth * 3, places)} ${formatNumber(borderWidth * 2, places)}"`
+ : '')
+ : '';
+ parts.push(` `);
+ }
+
+ if (lines.length > 0) {
+ const textColor = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
+ let outlined = '';
+ if (usedOutlines) {
+ // `textToPathData` re-applies the alignment against the widest line, so
+ // the block it needs is the one `layoutText` measured.
+ outlined = textToPathData(font, {
+ lines,
+ width: blockWidthOf(lines),
+ height: lines.length * fontSize * lineHeight,
+ lineHeight,
+ fontSize,
+ align: alignOf(state),
+ }, { x: box.x + padding, y: box.y + padding, round: places });
+ usedOutlines = outlined !== '';
+ }
+ if (usedOutlines) {
+ parts.push(` `);
+ } else {
+ parts.push(liveText(state, layout, font, textColor, places));
+ }
+ }
+
+ parts.push(' ');
+ return { svg: parts.join('\n') + '\n', usedOutlines };
+}
+
+// --- text --------------------------------------------------------------------
+
+/** Draw every line at its own baseline, shifted sideways by the alignment. */
+function drawText(ctx, layout, state, font) {
+ const { box, lines, fontSize, lineHeight, padding, scale } = layout;
+ const metrics = baselineMetrics(font, fontSize);
+ const step = fontSize * lineHeight;
+ const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
+ const blockWidth = blockWidthOf(lines);
+ const left = box.x + padding;
+ const top = box.y + padding;
+ const align = alignOf(state);
+
+ ctx.font = fontSpec(state, scale, Boolean(font));
+ ctx.fillStyle = colorOf(state.textColor, DEFAULT_TEXT_COLOR);
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'alphabetic';
+
+ if (layout.vertical) {
+ // Columns run right to left; each glyph sits in its own cell. `ー` and its
+ // like are turned a quarter turn so they read as vertical strokes.
+ for (let i = 0; i < lines.length; i++) {
+ const chars = [...lines[i].text];
+ const x = left + (lines.length - 1 - i) * step;
+ for (let j = 0; j < chars.length; j++) {
+ const ch = chars[j];
+ const drop = verticalRotation(ch);
+ if (drop !== null) {
+ ctx.save();
+ ctx.translate(x + fontSize / 2, top + j * fontSize + fontSize / 2 + drop * fontSize);
+ ctx.rotate(Math.PI / 2);
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(ch, 0, 0);
+ ctx.restore();
+ continue;
+ }
+ const offset = verticalOffsetOf(ch);
+ const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
+ ctx.fillText(ch, x + offset.dx * fontSize, y);
+ }
+ }
+ return;
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const x = left + alignOffset(align, blockWidth, line.width);
+ const y = top + i * step + halfLeading + metrics.ascent;
+ ctx.fillText(line.text, x, y);
+ }
+}
+
+/**
+ * The `` fallback.
+ *
+ * The family comes from the font stack alone, not from `fontSpec`: size and
+ * weight have attributes of their own, and a `font-family` that carried them
+ * would be ignored by every viewer.
+ */
+function liveText(state, layout, font, fill, places) {
+ const { box, lines, fontSize, lineHeight, padding } = layout;
+ const blockWidth = blockWidthOf(lines);
+ const align = alignOf(state);
+ const left = box.x + padding;
+ const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
+ const x = align === 'center'
+ ? left + blockWidth / 2
+ : align === 'right'
+ ? left + blockWidth
+ : left;
+
+ const metrics = baselineMetrics(font, fontSize);
+ const step = fontSize * lineHeight;
+ const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
+ const top = box.y + padding;
+
+ if (layout.vertical) {
+ // The stacked glyphs share one left-anchored . The rotated ones (`ー`)
+ // get a of their own, centred on the cell and turned a quarter turn.
+ const base = [
+ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
+ `font-size="${formatNumber(fontSize, places)}"`,
+ state.bold ? 'font-weight="bold"' : '',
+ `fill="${escapeAttribute(fill)}"`,
+ ].filter(Boolean).join(' ');
+ const tspans = [];
+ const rotated = [];
+ for (let i = 0; i < lines.length; i++) {
+ const chars = [...lines[i].text];
+ const x = left + (lines.length - 1 - i) * step;
+ for (let j = 0; j < chars.length; j++) {
+ const ch = chars[j];
+ const drop = verticalRotation(ch);
+ if (drop !== null) {
+ const cx = x + fontSize / 2;
+ const cy = top + j * fontSize + fontSize / 2 + drop * fontSize;
+ rotated.push(`${escapeText(ch)} `);
+ continue;
+ }
+ const offset = verticalOffsetOf(ch);
+ const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize;
+ tspans.push(`${escapeText(ch)} `);
+ }
+ }
+ const main = tspans.length ? `${tspans.join('')} ` : '';
+ return `${main}${rotated.join('')}`;
+ }
+
+ const attributes = [
+ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
+ `font-size="${formatNumber(fontSize, places)}"`,
+ state.bold ? 'font-weight="bold"' : '',
+ `fill="${escapeAttribute(fill)}"`,
+ `text-anchor="${anchor}"`,
+ ].filter(Boolean).join(' ');
+
+ const tspans = lines.map((line, i) => {
+ const y = top + i * step + halfLeading + metrics.ascent;
+ return ``
+ + `${escapeText(line.text)} `;
+ }).join('');
+
+ return `${tspans} `;
+}
+
+/**
+ * Wrap text without a font, from a character count.
+ *
+ * The preview has to show something while the vendored font downloads, and this
+ * is a deliberately crude stand-in: wide (CJK) characters count as one em and
+ * everything else as half an em. Once `layoutText` can run its metrics replace
+ * these numbers, so the rough version only ever decides a preview, never a file.
+ */
+function wrapByCount(text, { fontSize, maxWidth, lineHeight }) {
+ const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity;
+ const lines = [];
+
+ for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
+ let current = '';
+ for (const ch of paragraph) {
+ const candidate = current + ch;
+ if (current.trimEnd() !== '' && measureFallback(candidate, fontSize) > limit) {
+ lines.push(current.trimEnd());
+ current = /\s/.test(ch) ? '' : ch; // the break swallows the space
+ } else {
+ current = candidate;
+ }
+ }
+ lines.push(current.trimEnd());
+ }
+
+ const measured = lines.map((line) => ({ text: line, width: measureFallback(line, fontSize) }));
+ let width = 0;
+ for (const line of measured) width = Math.max(width, line.width);
+ return { lines: measured, width, height: measured.length * fontSize * lineHeight };
+}
+
+/** Wrap with `layoutText`, or with the character count when there is no font. */
+function layoutBlock(font, text, options) {
+ if (text === '') return { lines: [], width: 0, height: 0 };
+ if (font) {
+ const layout = layoutText(font, text, options);
+ return { lines: layout.lines, width: layout.width, height: layout.height };
+ }
+ return wrapByCount(text, options);
+}
+
+/**
+ * Offset of one character inside its vertical cell, in em.
+ *
+ * Shared by `drawText` and `liveText` so a glyph that needs a nudge lands in
+ * the same place on screen and in the export.
+ */
+function verticalOffsetOf(ch) {
+ return VERTICAL_OFFSETS.get(ch) ?? NO_OFFSET;
+}
+
+/**
+ * Vertical (縦書き) layout: each paragraph becomes one column, characters stacked
+ * top to bottom, columns running right to left. A paragraph longer than the
+ * available height is split into more columns.
+ *
+ * Closing punctuation hangs: when `、` or `。` (or `,`/`.`) would fall at the top
+ * of a new column, it stays on the previous one and is drawn just below that
+ * column's last cell instead. Such a hanging character does not count toward
+ * the column's length, so it never makes the block a cell taller or adds a
+ * column of its own. `lines[i].hang` reports how many characters on that line
+ * are hanging.
+ *
+ * The other 禁則 cases are repaired too: ー, small kana and closing brackets are
+ * kept off the top of a column by handing the preceding character down with
+ * them (追い出し), and an opening bracket is never left at the bottom of one.
+ */
+function layoutVertical(text, { fontSize, lineHeight, maxHeight }) {
+ const limit = Number.isFinite(maxHeight) && maxHeight > 0
+ ? Math.max(1, Math.floor(maxHeight / fontSize))
+ : Infinity;
+ const columns = [];
+ for (const paragraph of String(text).split(/\r\n|\r|\n/)) {
+ const chars = [...paragraph];
+ if (chars.length === 0) {
+ columns.push({ text: '', count: 0 });
+ continue;
+ }
+ let i = 0;
+ while (i < chars.length) {
+ // Fill the column cell by cell, then repair its two ends for 禁則処理.
+ const cells = [];
+ const hanging = [];
+ while (i < chars.length && cells.length < limit) {
+ cells.push(chars[i]);
+ i += 1;
+ }
+ // 行末禁則: an opening bracket may not close a column, so hand it over to
+ // the next one (it will open that column instead).
+ while (cells.length > 1 && COLUMN_END_FORBIDDEN.has(cells[cells.length - 1])) {
+ cells.pop();
+ i -= 1;
+ }
+ // 行頭禁則 (追い出し): ー, small kana and closing brackets may not open a
+ // column; move the preceding character down to keep them company.
+ if (i < chars.length && cells.length > 1
+ && COLUMN_START_FORBIDDEN.has(chars[i]) && !HANGING_PUNCTUATION.has(chars[i])) {
+ cells.pop();
+ i -= 1;
+ }
+ // 行頭禁則 (ぶら下げ): a closing mark that would open the next column
+ // stays on this one, drawn just below its last cell.
+ while (i < chars.length && HANGING_PUNCTUATION.has(chars[i])) {
+ hanging.push(chars[i]);
+ i += 1;
+ }
+ columns.push({ text: cells.join('') + hanging.join(''), count: cells.length });
+ }
+ }
+ const longest = columns.reduce((max, column) => Math.max(max, column.count), 0);
+ return {
+ lines: columns.map((column) => ({
+ text: column.text,
+ width: fontSize,
+ hang: [...column.text].length - column.count,
+ })),
+ // The block is exactly as wide as the columns that are drawn: `step` apart,
+ // each cell one em wide (not one whole `step`, which would leave a leading
+ // of dead space on the right and push the text off-centre).
+ width: columns.length === 0
+ ? 0
+ : (columns.length - 1) * fontSize * lineHeight + fontSize,
+ height: longest * fontSize,
+ vertical: true,
+ };
+}
+
+/** Text column in pixels: a fraction of the image, never wider than the image. */
+function resolveColumn(state, outWidth, padding) {
+ const fraction = toNonNegative(state.maxWidth, DEFAULT_MAX_WIDTH);
+ if (!(fraction > 0)) return 0; // 0 means "do not wrap", as in `layoutText`
+ return Math.min(fraction * outWidth, Math.max(0, outWidth - 2 * padding));
+}
+
+/** Ascent above and descent below the baseline, in pixels. */
+function baselineMetrics(font, fontSize) {
+ const unitsPerEm = font?.unitsPerEm;
+ if (Number.isFinite(font?.ascender) && Number.isFinite(font?.descender) && unitsPerEm > 0) {
+ return {
+ ascent: (font.ascender * fontSize) / unitsPerEm,
+ descent: (-font.descender * fontSize) / unitsPerEm,
+ };
+ }
+ return { ascent: fontSize * FALLBACK_ASCENT, descent: fontSize * FALLBACK_DESCENT };
+}
+
+/** Widest line of the block, which is the width the alignment works against. */
+function blockWidthOf(lines) {
+ let width = 0;
+ for (const line of lines) width = Math.max(width, toFinite(line?.width, 0));
+ return width;
+}
+
+/** Sideways shift of one line inside the block. */
+function alignOffset(align, blockWidth, lineWidth) {
+ const slack = Math.max(0, blockWidth - lineWidth);
+ if (align === 'center') return slack / 2;
+ if (align === 'right') return slack;
+ return 0;
+}
+
+/** Width of a string by character count, in pixels. */
+function measureFallback(text, fontSize) {
+ let width = 0;
+ for (const ch of String(text ?? '')) {
+ width += isWideChar(ch) ? fontSize * FALLBACK_WIDE_EM : fontSize * FALLBACK_NARROW_EM;
+ }
+ return width;
+}
+
+/** Characters that usually take a full em in a Japanese font. */
+function isWideChar(ch) {
+ const code = ch.codePointAt(0);
+ for (const [start, end] of WIDE_RANGES) {
+ if (code >= start && code <= end) return true;
+ }
+ return false;
+}
+
+// --- geometry ----------------------------------------------------------------
+
+/**
+ * The bubble outline as path commands.
+ *
+ * Both renderers go through here, which is the point: the canvas preview, the
+ * PNG and the SVG are three views of one geometry rather than three
+ * implementations of it.
+ *
+ * @returns {Array} empty when there is nothing to draw
+ */
+function bubbleCommands(box, state, scale) {
+ const shape = bubbleOf(state);
+ const rect = {
+ x: toFinite(box?.x, 0),
+ y: toFinite(box?.y, 0),
+ w: toNonNegative(box?.w, 0),
+ h: toNonNegative(box?.h, 0),
+ };
+ if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return [];
+
+ if (shape === 'think') return thinkCommands(rect, state, scale);
+
+ const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale;
+ const edges = shape === 'shout'
+ ? shoutEdges(rect, scale)
+ : shape === 'rect'
+ ? rectEdges(rect)
+ : roundEdges(rect, radius);
+
+ return edgesToCommands(withTail(edges, rect, state, scale));
+}
+
+/** Clockwise rectangle, starting at the top-left corner. */
+function rectEdges(box) {
+ const { x, y, w, h } = box;
+ const tl = { x, y };
+ const tr = { x: x + w, y };
+ const br = { x: x + w, y: y + h };
+ const bl = { x, y: y + h };
+ return [
+ lineEdge(tl, tr),
+ lineEdge(tr, br),
+ lineEdge(br, bl),
+ lineEdge(bl, tl),
+ ];
+}
+
+/**
+ * Rounded rectangle as straight sides plus quarter-circle corners.
+ *
+ * Keeping the sides straight (rather than approximating the whole outline with
+ * a polyline) is what lets the tail attach to a real straight edge; the radius
+ * is clamped so the corners can never cross each other.
+ */
+function roundEdges(box, radius) {
+ const { x, y, w, h } = box;
+ const r = clamp(radius, 0, Math.min(w, h) / 2);
+ if (!(r > 0.5)) return rectEdges(box);
+
+ const k = r * KAPPA;
+ const p = (px, py) => ({ x: px, y: py });
+ const corner = (from, c1, c2, to) => cubicEdge(p(...from), p(...c1), p(...c2), p(...to));
+ return [
+ lineEdge(p(x + r, y), p(x + w - r, y)),
+ corner([x + w - r, y], [x + w - r + k, y], [x + w, y + r - k], [x + w, y + r]),
+ lineEdge(p(x + w, y + r), p(x + w, y + h - r)),
+ corner([x + w, y + h - r], [x + w, y + h - r + k], [x + w - r + k, y + h], [x + w - r, y + h]),
+ lineEdge(p(x + w - r, y + h), p(x + r, y + h)),
+ corner([x + r, y + h], [x + r - k, y + h], [x, y + h - r + k], [x, y + h - r]),
+ lineEdge(p(x, y + h - r), p(x, y + r)),
+ corner([x, y + r], [x, y + r - k], [x + r - k, y], [x + r, y]),
+ ];
+}
+
+/**
+ * Star-burst: a rectangle whose four sides zig-zag outwards.
+ *
+ * The tooth depth and pitch are fractions of the bubble, so a big "shout" and a
+ * small one have the same number of spikes instead of the big one looking like
+ * a saw.
+ */
+function shoutEdges(box, scale) {
+ const { x, y, w, h } = box;
+ const amp = Math.min(w, h) * 0.07;
+ const pitch = Math.max(scale, Math.min(w, h) * 0.22);
+ const corners = [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }];
+ const normals = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }];
+
+ const points = [];
+ for (let side = 0; side < 4; side++) {
+ const a = corners[side];
+ const b = corners[(side + 1) % 4];
+ const normal = normals[side];
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
+ const teeth = Math.max(2, Math.round(length / pitch));
+ points.push(a);
+ for (let i = 0; i < teeth; i++) {
+ if (i > 0) points.push(lerp(a, b, i / teeth));
+ const out = lerp(a, b, (i + 0.5) / teeth);
+ points.push({ x: out.x + normal.x * amp, y: out.y + normal.y * amp });
+ }
+ }
+
+ return points.map((from, i) => lineEdge(from, points[(i + 1) % points.length]));
+}
+
+/**
+ * Cloud outline for a thought bubble, plus how far its scallops bulge out.
+ *
+ * The four sides of the box are replaced by a ring of outward scallops: a
+ * rounded rectangle is sampled evenly by arc length and each pair of samples is
+ * joined by a half-ellipse bulging away from the centre.
+ */
+function thinkEdges(box, scale) {
+ const { x, y, w, h } = box;
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const ring = roundedRectRing(box, Math.min(w, h) * 0.28);
+ const perimeter = ringLength(ring);
+ // Fluffier: more scallops, each bulging further, so it reads as a soft cloud.
+ const pitch = Math.max(scale * 6, Math.min(w, h) * 0.30);
+ const count = Math.max(6, Math.round(perimeter / pitch));
+ const points = sampleRing(ring, count);
+ const amp = Math.min((perimeter / count) * 0.55, Math.min(w, h) * 0.26);
+
+ const edges = [];
+ let outer = amp;
+ for (let i = 0; i < points.length; i++) {
+ const a = points[i];
+ const b = points[(i + 1) % points.length];
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ // Bulge perpendicular to the edge, not away from the centre: on a wide box
+ // the centre direction points sideways along the top and bottom edges, which
+ // is what tilted those scallops. The sign is flipped so it always faces out,
+ // which makes every edge scallop the same way the left and right ones do.
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const length = Math.hypot(dx, dy) || 1;
+ let nx = -dy / length;
+ let ny = dx / length;
+ if (nx * (mid.x - cx) + ny * (mid.y - cy) < 0) {
+ nx = -nx;
+ ny = -ny;
+ }
+ // A small, *deterministic* wobble so the scallops are not all identical - the
+ // even size read as too regular. Same index, same bubble, every render.
+ const scallopAmp = amp * (0.72 + 0.56 * scallopJitter(i));
+ if (scallopAmp > outer) outer = scallopAmp;
+ edges.push(scallopEdge(a, b, { x: nx, y: ny }, scallopAmp));
+ }
+ return { edges, amp: outer };
+}
+
+/** A closed cloud, followed by the tail: two or three shrinking puffs. */
+function thinkCommands(box, state, scale) {
+ const cloud = thinkEdges(box, scale);
+ const commands = edgesToCommands(cloud.edges);
+ for (const puff of thoughtTrail(box, state, scale, cloud.amp)) {
+ commands.push(...circleCommands(puff.x, puff.y, puff.r));
+ }
+ return commands;
+}
+
+/**
+ * The circles that trail away from a thought bubble instead of a pointed tail.
+ *
+ * They start just outside the cloud (`outer`) and shrink as they go, ending
+ * inside the room `layoutCaption` reserves for the tail.
+ */
+function thoughtTrail(box, state, scale, outer) {
+ const tail = tailOf(state);
+ if (tail === 'none') return [];
+ const dir = tailDirection(tail);
+ const diagonal = dir.x !== 0 && dir.y !== 0;
+ const nx = dir.x * (diagonal ? Math.SQRT1_2 : 1);
+ const ny = dir.y * (diagonal ? Math.SQRT1_2 : 1);
+ const anchor = {
+ x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
+ y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
+ };
+ // Sized from the font, not the leftover room, so the puffs stay visible at any
+ // bubble size; they start just outside the cloud (`outer`) and shrink away.
+ const unit = resolveFontSize(state, scale);
+ // 80% of the first cut, which read a touch large.
+ const r1 = unit * 0.48;
+ const r2 = unit * 0.34;
+ const r3 = unit * 0.21;
+ const gap = unit * 0.5;
+ const d1 = outer + r1;
+ const d2 = d1 + r1 + gap;
+ const d3 = d2 + r2 + gap;
+ return [
+ { x: anchor.x + nx * d1, y: anchor.y + ny * d1, r: r1 },
+ { x: anchor.x + nx * d2, y: anchor.y + ny * d2, r: r2 },
+ { x: anchor.x + nx * d3, y: anchor.y + ny * d3, r: r3 },
+ ];
+}
+
+/** One outward scallop: a half-ellipse from `from` to `to` bulging along `normal`. */
+function scallopEdge(from, to, normal, amp) {
+ const k = (4 / 3) * amp;
+ return cubicEdge(
+ from,
+ { x: from.x + normal.x * k, y: from.y + normal.y * k },
+ { x: to.x + normal.x * k, y: to.y + normal.y * k },
+ to,
+ );
+}
+
+/**
+ * A small deterministic wobble in 0..1 for the i-th scallop of a cloud.
+ *
+ * It is derived from the index alone, so a bubble draws its scallops exactly
+ * the same in the live preview, a screenshot, and the SVG export.
+ */
+function scallopJitter(i) {
+ const x = Math.sin((i + 1) * 12.9898) * 43758.5453;
+ return x - Math.floor(x);
+}
+
+/** A circle as `M`/`C`/`Z` commands, so it can join the bubble's one path. */
+function circleCommands(cx, cy, r) {
+ const k = r * KAPPA;
+ return [
+ { type: 'M', x: cx + r, y: cy },
+ { type: 'C', x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r },
+ { type: 'C', x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy },
+ { type: 'C', x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r },
+ { type: 'C', x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy },
+ { type: 'Z' },
+ ];
+}
+
+/** Points around a rounded rectangle, clockwise from the top-left arc. */
+function roundedRectRing(box, radius) {
+ const { x, y, w, h } = box;
+ const r = clamp(radius, 0, Math.min(w, h) / 2);
+ const points = [];
+ const arc = (cx, cy, from, to) => {
+ const steps = 6;
+ for (let i = 1; i <= steps; i++) {
+ const t = from + (to - from) * (i / steps);
+ points.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r });
+ }
+ };
+
+ points.push({ x: x + r, y });
+ points.push({ x: x + w - r, y });
+ arc(x + w - r, y + r, -Math.PI / 2, 0);
+ points.push({ x: x + w, y: y + h - r });
+ arc(x + w - r, y + h - r, 0, Math.PI / 2);
+ points.push({ x: x + r, y: y + h });
+ arc(x + r, y + h - r, Math.PI / 2, Math.PI);
+ points.push({ x, y: y + r });
+ arc(x + r, y + r, Math.PI, Math.PI * 1.5);
+ return points;
+}
+
+/** Total length of a closed polyline's perimeter. */
+function ringLength(points) {
+ let total = 0;
+ for (let i = 0; i < points.length; i++) {
+ total += distance(points[i], points[(i + 1) % points.length]);
+ }
+ return total;
+}
+
+/** `count` points spaced evenly by arc length around a closed polyline. */
+function sampleRing(points, count) {
+ const n = points.length;
+ const total = ringLength(points);
+ if (!(total > 0) || !(count > 0)) return [];
+ const out = [];
+ let seg = 0;
+ let start = 0;
+ let length = n > 1 ? distance(points[0], points[1 % n]) : 0;
+ for (let i = 0; i < count; i++) {
+ const target = (total * i) / count;
+ while (seg < n - 1 && start + length < target) {
+ start += length;
+ seg += 1;
+ length = distance(points[seg], points[(seg + 1) % n]);
+ }
+ const t = length > 0 ? clamp((target - start) / length, 0, 1) : 0;
+ out.push(lerp(points[seg], points[(seg + 1) % n], t));
+ }
+ return out;
+}
+
+/**
+ * Replace part of the edge the tail points at with a spike.
+ *
+ * The spike is inserted *into* the outline, not appended to it: the two sides
+ * of the tail and the bubble become one continuous border. The straight edge
+ * nearest the tail's anchor is used, which for every shape except the shout is
+ * the obvious side and for the shout is the nearest zig-zag segment.
+ */
+function withTail(edges, box, state, scale) {
+ const tail = tailOf(state);
+ if (tail === 'none' || edges.length === 0) return edges;
+
+ const tailLength = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5);
+ if (!(tailLength > 0)) return edges;
+
+ const dir = tailDirection(tail);
+ // A diagonal tail leaves from a corner; an axis-aligned one leaves from the
+ // middle of the side it points at.
+ const anchor = {
+ x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2,
+ y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2,
+ };
+ const diagonal = dir.x !== 0 && dir.y !== 0;
+ const normal = {
+ x: dir.x * (diagonal ? Math.SQRT1_2 : 1),
+ y: dir.y * (diagonal ? Math.SQRT1_2 : 1),
+ };
+
+ const index = nearestEdge(edges, anchor, box, tail);
+ if (index < 0) return edges;
+
+ const edge = edges[index];
+ const length = distance(edge.from, edge.to);
+ const base = Math.min(tailLength * 0.7, length * 0.45);
+ if (!(base > 0) || !(length > 0)) return edges;
+
+ // The spike sits at the point of the edge nearest the anchor - the middle for
+ // a side, the corner for a diagonal - kept far enough in that both feet land
+ // on the edge itself.
+ const t = clamp(projectionT(edge, anchor), base / length, 1 - base / length);
+ const middle = lerp(edge.from, edge.to, t);
+ // A diagonal spike advances `tailLength` on *each* axis, so it reaches as far
+ // as a side tail does and reads as a proper corner.
+ const reach = tailLength * (diagonal ? Math.SQRT2 : 1);
+ const tip = { x: middle.x + normal.x * reach, y: middle.y + normal.y * reach };
+ const leftFoot = lerp(edge.from, edge.to, t - base / length);
+ const rightFoot = lerp(edge.from, edge.to, t + base / length);
+ const replacement = [
+ lineEdge(edge.from, leftFoot),
+ lineEdge(leftFoot, tip),
+ lineEdge(tip, rightFoot),
+ lineEdge(rightFoot, edge.to),
+ ];
+ return edges.slice(0, index).concat(replacement, edges.slice(index + 1));
+}
+
+/** Index of the straight edge closest to the tail's anchor, or `-1`. */
+function nearestEdge(edges, anchor, box, tail) {
+ let best = -1;
+ let bestDistance = Infinity;
+ for (let i = 0; i < edges.length; i++) {
+ const edge = edges[i];
+ if (edge.kind !== 'line') continue;
+ const middle = { x: (edge.from.x + edge.to.x) / 2, y: (edge.from.y + edge.to.y) / 2 };
+ if (!onSide(middle, box, tail)) continue;
+ const d = distance(middle, anchor);
+ if (d < bestDistance) {
+ bestDistance = d;
+ best = i;
+ }
+ }
+ return best;
+}
+
+/** Is `point` on the side of the box the tail points at? */
+function onSide(point, box, tail) {
+ const dir = tailDirection(tail);
+ const midX = box.x + box.w * 0.5;
+ const midY = box.y + box.h * 0.5;
+ if (dir.x < 0 && point.x > midX) return false;
+ if (dir.x > 0 && point.x < midX) return false;
+ if (dir.y < 0 && point.y > midY) return false;
+ if (dir.y > 0 && point.y < midY) return false;
+ return true;
+}
+
+/** How far along `edge` (0..1) the point nearest `point` falls. */
+function projectionT(edge, point) {
+ const dx = edge.to.x - edge.from.x;
+ const dy = edge.to.y - edge.from.y;
+ const lengthSq = dx * dx + dy * dy;
+ if (!(lengthSq > 0)) return 0.5;
+ return ((point.x - edge.from.x) * dx + (point.y - edge.from.y) * dy) / lengthSq;
+}
+
+/** One straight edge of a closed outline. */
+function lineEdge(from, to) {
+ return { kind: 'line', from, to };
+}
+
+/** One cubic edge of a closed outline. */
+function cubicEdge(from, c1, c2, to) {
+ return { kind: 'cubic', from, c1, c2, to };
+}
+
+/** Walk the edges into `M`/`L`/`C`/`Z` commands. */
+function edgesToCommands(edges) {
+ if (edges.length === 0) return [];
+ const commands = [{ type: 'M', x: edges[0].from.x, y: edges[0].from.y }];
+ for (const edge of edges) {
+ if (edge.kind === 'line') {
+ commands.push({ type: 'L', x: edge.to.x, y: edge.to.y });
+ } else {
+ commands.push({
+ type: 'C',
+ x1: edge.c1.x, y1: edge.c1.y,
+ x2: edge.c2.x, y2: edge.c2.y,
+ x: edge.to.x, y: edge.to.y,
+ });
+ }
+ }
+ commands.push({ type: 'Z' });
+ return commands;
+}
+
+/** Replay commands into a 2D context. */
+function applyCommands(ctx, commands) {
+ ctx.beginPath();
+ for (const command of commands) {
+ if (command.type === 'M') ctx.moveTo(command.x, command.y);
+ else if (command.type === 'L') ctx.lineTo(command.x, command.y);
+ else if (command.type === 'C') {
+ ctx.bezierCurveTo(command.x1, command.y1, command.x2, command.y2, command.x, command.y);
+ } else if (command.type === 'Z') {
+ ctx.closePath();
+ }
+ }
+}
+
+/** Print commands as SVG path data. */
+function commandsToPathData(commands, places) {
+ const parts = [];
+ for (const command of commands) {
+ if (command.type === 'M') {
+ parts.push(`M${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
+ } else if (command.type === 'L') {
+ parts.push(`L${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`);
+ } else if (command.type === 'C') {
+ parts.push(
+ `C${formatNumber(command.x1, places)} ${formatNumber(command.y1, places)}`
+ + ` ${formatNumber(command.x2, places)} ${formatNumber(command.y2, places)}`
+ + ` ${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`,
+ );
+ } else if (command.type === 'Z') {
+ parts.push('Z');
+ }
+ }
+ return parts.join(' ');
+}
+
+// --- shared helpers ----------------------------------------------------------
+
+/** The family list for a caption, or the system-only list before the font loads. */
+function fontFamilyStack(caption, loaded) {
+ const stack = captionFontStack();
+ if (loaded && caption?.font !== 'system') return stack;
+ return dropLeadingFamily(stack);
+}
+
+/** `captionFontStack()` minus its first family, for a plain system fallback. */
+function dropLeadingFamily(stack) {
+ let quote = null;
+ for (let i = 0; i < stack.length; i++) {
+ const ch = stack[i];
+ if (quote) {
+ if (ch === '\\') i++;
+ else if (ch === quote) quote = null;
+ } else if (ch === "'" || ch === '"') {
+ quote = ch;
+ } else if (ch === ',') {
+ const rest = stack.slice(i + 1).trim();
+ return rest === '' ? 'sans-serif' : rest;
+ }
+ }
+ return 'sans-serif';
+}
+
+/** Font size in output pixels, always positive and finite. */
+function resolveFontSize(caption, scale) {
+ const base = toPositive(caption?.fontSize, DEFAULT_FONT_SIZE);
+ return toPositive(base * positiveScale(scale), DEFAULT_FONT_SIZE);
+}
+
+function bubbleOf(caption) {
+ const value = caption?.bubble;
+ if (value === 'round' || value === 'rect' || value === 'shout'
+ || value === 'whisper' || value === 'think' || value === 'none') return value;
+ return 'round';
+}
+
+function tailOf(caption) {
+ const value = caption?.tail;
+ return TAIL_VALUES.has(value) ? value : 'left';
+}
+
+const TAIL_VALUES = new Set([
+ 'left', 'right', 'top', 'bottom',
+ 'topLeft', 'topRight', 'bottomLeft', 'bottomRight',
+ 'none',
+]);
+
+/**
+ * Which way a tail leaves the box, as a direction where each axis is -1, 0 or
+ * 1 (0 for `none` or anything unknown). A diagonal tail carries a sign on both
+ * axes, so `bottomLeft` is `{ x: -1, y: 1 }`.
+ */
+function tailDirection(tail) {
+ switch (tail) {
+ case 'left': return { x: -1, y: 0 };
+ case 'right': return { x: 1, y: 0 };
+ case 'top': return { x: 0, y: -1 };
+ case 'bottom': return { x: 0, y: 1 };
+ case 'topLeft': return { x: -1, y: -1 };
+ case 'topRight': return { x: 1, y: -1 };
+ case 'bottomLeft': return { x: -1, y: 1 };
+ case 'bottomRight': return { x: 1, y: 1 };
+ default: return { x: 0, y: 0 };
+ }
+}
+
+function alignOf(caption) {
+ const value = caption?.align;
+ return value === 'center' || value === 'right' ? value : 'left';
+}
+
+function colorOf(value, fallback) {
+ return typeof value === 'string' && value !== '' ? value : fallback;
+}
+
+/** Scale is a multiplier: anything that is not a positive number means 1. */
+function positiveScale(value) {
+ return Number.isFinite(value) && value > 0 ? value : 1;
+}
+
+function toFinite(value, fallback) {
+ return Number.isFinite(value) ? value : fallback;
+}
+
+function toPositive(value, fallback) {
+ return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+function toNonNegative(value, fallback) {
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
+}
+
+function clamp(value, low, high) {
+ if (high < low) return low;
+ return Math.min(Math.max(value, low), high);
+}
+
+/** Decimal string for both `ctx.font` and SVG, never `NaN` and never `-0`. */
+function formatNumber(value, places = 2) {
+ if (!Number.isFinite(value)) return '0';
+ const decimals = Number.isFinite(places) ? Math.max(0, Math.min(8, Math.floor(places))) : 2;
+ const factor = Math.pow(10, decimals);
+ const rounded = Math.round(value * factor) / factor;
+ return Object.is(rounded, -0) ? '0' : String(rounded);
+}
+
+/**
+ * Escape character data.
+ *
+ * Quotes are escaped too even though element content allows them: the caption
+ * is user text, and the extra entities cost nothing next to never emitting a
+ * document a stricter parser could object to.
+ */
+function escapeText(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Escape an attribute value.
+ *
+ * Only the double quote matters inside a double-quoted attribute, so single
+ * quotes survive and a family stack such as `'Hiragino Maru Gothic ProN',
+ * sans-serif` stays readable in the file.
+ */
+function escapeAttribute(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function distance(a, b) {
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+function lerp(a, b, t) {
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
+}
diff --git a/bluebey-studio/src/clip.js b/bluebey-studio/src/clip.js
new file mode 100644
index 0000000..4fdcfd9
--- /dev/null
+++ b/bluebey-studio/src/clip.js
@@ -0,0 +1,202 @@
+import * as THREE from 'three';
+
+/**
+ * 見えない壁 (the invisible wall): a clipping plane that hides whatever falls
+ * behind it, so the character can be buried in the wall and only the rest of the
+ * body shows.
+ *
+ * The wall is a *finite* rectangle: an invisible quad that writes depth and no
+ * colour, drawn before everything else in the opaque pass, so the character
+ * behind it is culled by the depth test. That is what a real wall does, and it
+ * is the only way to bound the effect - a `renderer.clippingPlanes` entry is an
+ * infinite half-space, so it can never be limited to a rectangle.
+ *
+ * Nothing needs to agree per-material: the depth buffer does the work, so the
+ * body, the outline hulls, the face plates, the ink pass and the offscreen
+ * renders an export uses are all hidden by the same wall.
+ *
+ * `wallPlane` is pure (no three.js maths), so the geometry can be unit-tested
+ * without a renderer.
+ */
+
+const DEG = Math.PI / 180;
+
+/**
+ * A hair toward the kept side. The guide sits exactly on the cut, and a plane
+ * on its own boundary is half inside the discarded half - nudging it along the
+ * normal keeps all of it on the visible side.
+ */
+const GUIDE_EPSILON = 0.002;
+
+/**
+ * The plane a wall setting lies in.
+ *
+ * The wall is a *finite* quad, so this no longer decides which half of space is
+ * hidden - the quad's own depth does that. What `apply` needs from here is the
+ * normal, which is the direction the quad faces (and so the direction the wall
+ * lies along), plus the constant of the plane through the point it sits on.
+ *
+ * By default the wall lies in the XY plane through `(x, y, z)`; `yaw` turns it
+ * about Y and `tilt` leans it about X afterwards.
+ *
+ * @param {{x?:number,y?:number,z?:number,yaw?:number,tilt?:number}} [wall]
+ * @returns {{normal:[number,number,number], constant:number}}
+ */
+export function wallPlane(wall = {}) {
+ const yaw = (wall.yaw ?? 0) * DEG;
+ const tilt = (wall.tilt ?? 0) * DEG;
+ const cosYaw = Math.cos(yaw);
+ const sinYaw = Math.sin(yaw);
+ const cosTilt = Math.cos(tilt);
+ const sinTilt = Math.sin(tilt);
+
+ // Start from +Z, turn about Y, then lean about the (already turned) X axis.
+ let nx = sinYaw;
+ let ny = -cosYaw * sinTilt;
+ let nz = cosYaw * cosTilt;
+
+ const length = Math.hypot(nx, ny, nz) || 1;
+ nx /= length;
+ ny /= length;
+ nz /= length;
+
+ // Constant so the plane passes through (x, y, z): dot(n, p) + c = 0.
+ const constant = -(nx * (wall.x ?? 0) + ny * (wall.y ?? 0) + nz * (wall.z ?? 0));
+ return { normal: [nx, ny, nz], constant };
+}
+
+/** The faint plane shown while placing the wall. Hidden from every export. */
+/**
+ * The wall itself: an invisible, *finite* quad that writes depth but no colour.
+ *
+ * It is drawn before everything else in the opaque pass (`renderOrder`), so the
+ * character behind it is culled by the depth test. That is what a real wall does,
+ * and - unlike a clip plane, which is an infinite half-space - it only hides what
+ * the rectangle actually covers. So the size sliders are the wall's real size.
+ */
+function makeWall() {
+ const material = new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ side: THREE.DoubleSide,
+ toneMapped: false,
+ });
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
+ mesh.name = 'wall';
+ // Before everything, including the silhouette-only hulls (renderOrder -1), so
+ // its depth hides a nose buried in the wall (see styles.js hullMaterialFor).
+ mesh.renderOrder = -2;
+ mesh.visible = false;
+ mesh.castShadow = false;
+ mesh.receiveShadow = false;
+ return mesh;
+}
+
+function makeGuide() {
+ const geometry = new THREE.PlaneGeometry(1, 1);
+ const material = new THREE.MeshBasicMaterial({
+ color: 0x8a4fe0,
+ transparent: true,
+ opacity: 0.16,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ toneMapped: false,
+ });
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.name = 'wall-guide';
+ mesh.userData.isHelper = true;
+ mesh.visible = false;
+
+ // An edge, so the plane's extent is legible even where the fill is faint.
+ const edge = new THREE.LineSegments(
+ new THREE.EdgesGeometry(geometry),
+ new THREE.LineBasicMaterial({ color: 0x8a4fe0, transparent: true, opacity: 0.55, toneMapped: false }),
+ );
+ edge.userData.isHelper = true;
+ mesh.add(edge);
+ return mesh;
+}
+
+export function createClipper({ scene, renderer, model }) {
+ // A little larger than the character: big enough to read as a wall, small
+ // enough not to cover the whole viewport.
+ const guideSpan = Math.max(model.size.x, model.size.y, model.size.z) * 1.5;
+ const FROM = new THREE.Vector3(0, 0, 1); // the plane the quad starts in
+
+ function makeSlot() {
+ const occluder = makeWall();
+ const guide = makeGuide();
+ scene.add(guide);
+ scene.add(occluder);
+ return { occluder, guide, normal: new THREE.Vector3(0, 0, 1) };
+ }
+ // Two walls. Each slot keeps its own rectangle, so the two are independent.
+ const slots = [makeSlot(), makeSlot()];
+
+ /**
+ * @param {Array} walls up to two `state.render.wall` settings, in slot
+ * order.
+ * @param {{x?:number,y?:number,z?:number}} [offset] the character's own
+ * translation. A wall's position is stored *relative to the character*, so
+ * adding this makes the walls travel with the body when it is moved.
+ * @param {number} [yaw] the character's own turn about Y, in radians. The stored
+ * position is rotated by it (and added to the wall's own `yaw`), so a wall
+ * stays glued to the body when the character is turned.
+ *
+ * The guide is drawn only when `wall.guide` is on, which is also when the wall
+ * can be grabbed in the viewport. Off, the wall is invisible: the cut still
+ * applies, so you can see the character half-hidden with nothing in the way.
+ */
+ function apply(walls, offset, yaw = 0) {
+ const list = Array.isArray(walls) ? walls : [walls];
+ const ox = offset?.x ?? 0;
+ const oy = offset?.y ?? 0;
+ const oz = offset?.z ?? 0;
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const yawDeg = (yaw * 180) / Math.PI;
+ slots.forEach((slot, index) => {
+ const wall = list[index];
+ if (!wall || wall.on !== true) {
+ slot.occluder.visible = false;
+ slot.guide.visible = false;
+ return;
+ }
+ // The stored position is relative to the character, so it turns with the
+ // body: rotate (x, z) about Y by the character's yaw, then add the offset.
+ const lx = wall.x ?? 0;
+ const lz = wall.z ?? 0;
+ const x = cos * lx + sin * lz + ox;
+ const y = (wall.y ?? 0) + oy;
+ const z = -sin * lx + cos * lz + oz;
+ const { normal } = wallPlane({ yaw: (wall.yaw ?? 0) + yawDeg, tilt: wall.tilt, x, y, z });
+ slot.normal.set(normal[0], normal[1], normal[2]);
+ const span = guideSpan * Math.max(0.05, wall.size ?? 1);
+ // The wall and its guide are the same rectangle, turned to face along the
+ // plane's normal: the wall is the occluder, the guide is the tinted copy
+ // that is shown only while placing.
+ for (const mesh of [slot.occluder, slot.guide]) {
+ mesh.quaternion.setFromUnitVectors(FROM, slot.normal);
+ mesh.scale.setScalar(span);
+ }
+ slot.occluder.position.set(x, y, z);
+ slot.occluder.visible = true;
+ slot.guide.position.set(x, y, z).addScaledVector(slot.normal, GUIDE_EPSILON);
+ slot.guide.visible = wall.guide === true;
+ });
+ }
+
+ return {
+ guides: slots.map((slot) => slot.guide),
+ occluders: slots.map((slot) => slot.occluder),
+ apply,
+ dispose() {
+ for (const slot of slots) {
+ for (const mesh of [slot.guide, slot.occluder]) {
+ mesh.geometry.dispose();
+ mesh.material.dispose();
+ mesh.removeFromParent();
+ }
+ }
+ },
+ };
+}
diff --git a/bluebey-studio/src/exporter.js b/bluebey-studio/src/exporter.js
new file mode 100644
index 0000000..b34b6f6
--- /dev/null
+++ b/bluebey-studio/src/exporter.js
@@ -0,0 +1,370 @@
+import { traceAlphaContours, contoursToPathData } from './trace.js';
+import { roughenContours } from './handDrawn.js';
+import { captionToSvg } from './caption.js';
+import { EYE_LAYOUT, MOUTH_LAYOUT, HAIR_WRAP_LAYOUT } from './faceArt.js';
+
+/**
+ * Export helpers.
+ *
+ * Everything renders through the live renderer at a temporary resolution, so an
+ * export always matches what is on screen (same camera, same framing) and the
+ * only difference is the pixel size.
+ */
+
+/** Render the current view into an offscreen 2D canvas at an arbitrary size. */
+export function renderStill(view, { width, height, background }) {
+ const { renderer, scene, camera } = view;
+ const previousAspect = camera.aspect;
+
+ renderer.setPixelRatio(1);
+ renderer.setSize(width, height, false);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+
+ applyBackground(renderer, background);
+ // The screen-space outline renders the scene itself, so it replaces the plain
+ // render rather than following it. (buildSVG passes outlineOptions of its own:
+ // it wants the ink and nothing else, because the ink is what it traces.)
+ if (view.outline && view.outlineOptions?.enabled) {
+ view.outline.setSize(width, height);
+ view.outline.render(() => renderer.render(scene, camera), { camera, ...view.outlineOptions });
+ } else {
+ renderer.render(scene, camera);
+ }
+
+ // Copy before restoring: resizing the renderer throws the frame away.
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ canvas.getContext('2d').drawImage(renderer.domElement, 0, 0);
+
+ camera.aspect = previousAspect;
+ camera.updateProjectionMatrix();
+ view.restore?.();
+
+ return canvas;
+}
+
+/** `background` is `{ mode: 'transparent' }` or `{ mode: 'solid', color }`. */
+export function applyBackground(renderer, background) {
+ if (!background || background.mode === 'transparent') {
+ renderer.setClearColor(0x000000, 0);
+ } else {
+ renderer.setClearColor(background.color ?? '#ffffff', 1);
+ }
+}
+
+/** A PNG data URL of the current view, scaled up by `scale`. */
+export async function capturePNG(view, { scale = 2, background, width, height }) {
+ const baseWidth = view.width || view.renderer.domElement.clientWidth || 1280;
+ const baseHeight = view.height || view.renderer.domElement.clientHeight || 800;
+ const outWidth = Math.min(8192, Math.max(64, Math.round((width ?? baseWidth) * scale)));
+ const outHeight = Math.min(8192, Math.max(64, Math.round((height ?? baseHeight) * scale)));
+ const canvas = renderStill(view, { width: outWidth, height: outHeight, background });
+ const blob = await canvasToBlob(canvas);
+ return { blob, canvas, width: outWidth, height: outHeight };
+}
+
+/**
+ * Encode a canvas as a PNG blob. Exported because the caller composites the
+ * backdrop and the caption onto the rendered canvas afterwards, and then has to
+ * re-encode it.
+ */
+export function canvasToBlob(canvas) {
+ return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
+}
+
+export async function copyCanvasToClipboard(canvas) {
+ if (!navigator.clipboard || typeof ClipboardItem === 'undefined') {
+ throw new Error('この環境ではクリップボードにコピーできません');
+ }
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+}
+
+/**
+ * Vector line art: render the outline-only style, trace the coverage mask of
+ * the result and emit one even-odd path. Rendering at a high resolution keeps
+ * the traced curves smooth.
+ */
+export function buildSVG(view, {
+ styles,
+ face,
+ faceParams,
+ width = 2048,
+ threshold = 0.4,
+ lineColor = '#111111',
+ lineWidth = null,
+ background = null,
+ handDrawn = null,
+ captions = [],
+ captionFont = null,
+ outlinePixels = 2,
+} = {}) {
+ const previousStyle = styles.style;
+ const previousMode = face.styleMode;
+
+ // The `outline` style keeps every body mesh invisible (colorWrite off) and lets
+ // the outline passes draw the lines, exactly as on screen: the hulls ink the
+ // body, the screen-space pass inks the leaves. What reaches the trace is
+ // therefore the ink alone - and a traced stroke comes out as its own outline,
+ // i.e. two nested contours that `fill-rule="evenodd"` fills as a line of the
+ // same width. That is what finally gives the leaves, which no inverted hull can
+ // outline, clean even lines in the vector file too.
+ //
+ // `withOutlineFor` in main.js sets that split up for this call, and guards it so
+ // a renderer without the outline pass still falls back to a hull-only trace.
+ const screenSpace = Boolean(view.outline);
+
+ styles.setStyle('outline');
+ face.setParams(faceParams, 'line');
+ face.flush(performance.now(), 0);
+
+ const aspect = (view.height || 800) / (view.width || 1280);
+ const height = Math.max(64, Math.round(width * aspect));
+ // `radius` is in screen pixels, so it has to grow with the render: the SVG is
+ // drawn far larger than the viewport, and a 2px line would become a hairline.
+ const scale = width / Math.max(1, view.width || 1280);
+ const canvas = renderStill(
+ {
+ ...view,
+ outlineOptions: {
+ enabled: screenSpace,
+ color: lineColor,
+ radius: outlinePixels * scale,
+ },
+ },
+ { width, height, background: { mode: 'transparent' } },
+ );
+
+ styles.setStyle(previousStyle);
+ face.setParams(faceParams, previousMode);
+ face.flush(performance.now(), 0);
+
+ const pixels = canvas.getContext('2d').getImageData(0, 0, width, height).data;
+ const alpha = new Uint8Array(width * height);
+ for (let i = 0; i < alpha.length; i++) alpha[i] = pixels[i * 4 + 3];
+
+ let contours = traceAlphaContours(alpha, width, height, {
+ threshold,
+ simplifyTolerance: 0.6,
+ minArea: 5,
+ });
+ // 手描き風: nudge the traced outlines so they read as pen strokes instead of
+ // the mathematically smooth curves a mask trace produces.
+ if (handDrawn && handDrawn.amount > 0) {
+ contours = roughenContours(contours, {
+ amount: handDrawn.amount,
+ seed: handDrawn.seed ?? 1,
+ scale: handDrawn.scale ?? 40,
+ passes: handDrawn.passes ?? 1,
+ });
+ }
+ const d = contoursToPathData(contours, (x, y) => [x, y], 2);
+
+ const rect = background
+ ? ` \n`
+ : '';
+ const widthAttr = lineWidth ? ` stroke="${lineColor}" stroke-width="${lineWidth}"` : '';
+
+ // The caption is authored against the viewport size, so it scales with the
+ // requested SVG width. Its bubble and text are emitted as real vector shapes.
+ // Every bubble is emitted as its own real vector shapes, one after the other.
+ let captionSvg = '';
+ let captionUsedOutlines = captions.some(Boolean);
+ for (const caption of captions) {
+ if (!caption) continue;
+ const scale = width / Math.max(1, view.width || 1280);
+ const result = captionToSvg(caption, { width, height, scale, font: captionFont });
+ captionSvg += result.svg;
+ if (result.usedOutlines === false) captionUsedOutlines = false;
+ }
+
+ return {
+ contours: contours.length,
+ captionUsedOutlines,
+ svg: `
+
+ぶるべー 線画
+${rect}
+${captionSvg}
+`,
+ };
+}
+
+/** Alignment guides: blue on the white paper, light enough to paint over. */
+const FACE_MAP_GUIDE = 'rgba(90, 140, 220, 0.55)';
+/** Half-length of the centre ticks, in artwork-window pixels. */
+const FACE_MAP_TICK = 16;
+
+/**
+ * Build the「下地」image an author paints a custom face texture on.
+ *
+ * The studio loads a hand-drawn image by drawing it at the artwork window's own
+ * offset and size (see `Face.loadImage` / `placeInWindow`), so an image the size
+ * of the *plate canvas*, with its artwork aligned to `offsetX/offsetY`, imports
+ * 1:1. That is exactly what this hands out: the live drawing as a reference, an
+ * opaque white window to paint on, and faint guides for the eye centres, the lid
+ * line or the mouth chord.
+ *
+ * The white and the guides go on a **copy**: the plate canvas *is* the live
+ * texture, and painting it would show up in the view.
+ *
+ * @param {import('./face.js').Face} face
+ * @param {'eyes'|'mouth'} kind
+ * @returns {HTMLCanvasElement}
+ */
+export function buildFaceMap(face, kind) {
+ const eyes = kind === 'eyes';
+ const layout = eyes ? face.eyeLayout : face.mouthLayout;
+ const plate = eyes ? face.eyeCanvas : face.mouthCanvas;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = layout.width;
+ canvas.height = layout.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(plate, 0, 0);
+
+ const { offsetX, offsetY, window: win } = layout;
+ // `destination-over` so the white paper lands *under* the copy: filling it
+ // normally would erase the very drawing the author lines the new art up against.
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(offsetX, offsetY, win.width, win.height);
+ ctx.globalCompositeOperation = 'source-over';
+
+ // The plate canvas keeps the artwork at its original pixel size (only the canvas
+ // grows around it), so a fixed-width stroke reads the same on both parts.
+ ctx.strokeStyle = FACE_MAP_GUIDE;
+ ctx.fillStyle = FACE_MAP_GUIDE;
+ ctx.lineWidth = 2;
+
+ // Where the drawing has to fit, and where its middle is. The cross uses short
+ // ticks, not full-width lines, so it cannot be mistaken for artwork.
+ ctx.strokeRect(offsetX, offsetY, win.width, win.height);
+ const cx = offsetX + win.width / 2;
+ const cy = offsetY + win.height / 2;
+ guideLine(ctx, cx - FACE_MAP_TICK, cy, cx + FACE_MAP_TICK, cy);
+ guideLine(ctx, cx, cy - FACE_MAP_TICK, cx, cy + FACE_MAP_TICK);
+
+ if (eyes) {
+ // Both eyeballs (`radius`) and the lid line through their centres, so a
+ // hand-drawn brow or eye can be placed against the parametric ones.
+ const [a, b] = EYE_LAYOUT.eyes;
+ const y = offsetY + a.cy;
+ guideLine(ctx, offsetX + a.cx, y, offsetX + b.cx, y);
+ for (const eye of EYE_LAYOUT.eyes) {
+ const ex = offsetX + eye.cx;
+ const ey = offsetY + eye.cy;
+ ctx.beginPath();
+ ctx.arc(ex, ey, EYE_LAYOUT.radius, 0, Math.PI * 2);
+ ctx.stroke();
+ // A small dot marks the exact centre, where the eyeball pivots.
+ ctx.beginPath();
+ ctx.arc(ex, ey, ctx.lineWidth * 1.5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ } else {
+ // The lip line the mouth is drawn around: end to end, plus a tick at the centre.
+ const y = offsetY + MOUTH_LAYOUT.chordY;
+ const { centreX, halfChord } = MOUTH_LAYOUT;
+ guideLine(ctx, offsetX + centreX - halfChord, y, offsetX + centreX + halfChord, y);
+ guideLine(ctx, offsetX + centreX, y - FACE_MAP_TICK, offsetX + centreX, y + FACE_MAP_TICK);
+ }
+
+ return canvas;
+}
+
+/**
+ * The 髪の下地 (a base to draw hair on).
+ *
+ * The hair plate is a cylinder unrolled: `x` is the angle round the head, with
+ * the face at the middle of the artwork window and the seam at the back at the
+ * two edges, and `y` is the height, top of the head first. The current hair is
+ * copied in as a faint reference, and guides mark the window, the front centre
+ * and the height the default まえがみ hairline reaches.
+ *
+ * @param {import('./face.js').Face} face
+ * @returns {HTMLCanvasElement|null}
+ */
+export function buildHairMap(face) {
+ const layout = face.hairLayout;
+ const plate = face.hairCanvas;
+ if (!layout || !plate) return null;
+ // The plate's UVs were rewritten to fill 0..1, so the whole canvas is the
+ // texture (unlike the eye/mouth maps, which keep a window inside a wider
+ // canvas). Every guide is therefore measured straight against the canvas.
+ const canvas = document.createElement('canvas');
+ canvas.width = plate.width;
+ canvas.height = plate.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(plate, 0, 0);
+
+ // White paper under the copy, so a redraw starts from an opaque base.
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.globalCompositeOperation = 'source-over';
+
+ ctx.strokeStyle = FACE_MAP_GUIDE;
+ ctx.fillStyle = FACE_MAP_GUIDE;
+ ctx.lineWidth = 3;
+
+ // The front of the head sits at the middle of the artwork window, which is
+ // where `drawHeadCoverWrap` puts the face, so this vertical line is the
+ // nose-to-backbone plane. The canvas's own left/right edges are the seam at
+ // the back of the head.
+ const fx = (layout.offsetX + layout.window.width * 0.5) * (canvas.width / layout.width);
+ guideLine(ctx, fx, 0, fx, canvas.height);
+ // The default hairline (大きさ 1) runs across the whole wrap at this height.
+ guideLine(ctx, 0, HAIR_WRAP_LAYOUT.base * canvas.height, canvas.width, HAIR_WRAP_LAYOUT.base * canvas.height);
+
+ return canvas;
+}
+
+/** One guide segment, kept out of the map builders so the placements stay readable. */
+function guideLine(ctx, x0, y0, x1, y1) {
+ ctx.beginPath();
+ ctx.moveTo(x0, y0);
+ ctx.lineTo(x1, y1);
+ ctx.stroke();
+}
+
+export function downloadBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.append(link);
+ link.click();
+ link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
+}
+
+export function downloadText(text, filename, type = 'application/json') {
+ downloadBlob(new Blob([text], { type }), filename);
+}
+
+export function readFileAsText(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(file);
+ });
+}
+
+export function readFileAsArrayBuffer(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+export function timestamp() {
+ const now = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
+}
diff --git a/bluebey-studio/src/face.js b/bluebey-studio/src/face.js
new file mode 100644
index 0000000..cec7619
--- /dev/null
+++ b/bluebey-studio/src/face.js
@@ -0,0 +1,453 @@
+import * as THREE from 'three';
+import { drawEyes, drawMouth, drawHeadCoverWrap, canvasLayout } from './faceArt.js';
+
+// The hair plate's texture is drawn at this multiple of the plate's natural UV
+// size. The hairline crosses the head over only a fraction of that height, so at
+// 1x its edge comes out visibly jagged; doubling it (with the finer curve
+// subdivision in `drawHeadCoverWrap`) keeps the edge smooth.
+const HAIR_SCALE = 2;
+
+/**
+ * Owns the two procedural textures (eyes and mouth) and keeps them in sync with
+ * the face parameters.
+ *
+ * The artwork is carried by the model's face plates, but those plates are shells
+ * of the *whole* front half of the body, so their UVs cover far more than the
+ * 0..1 window the drawing lives in. `remapPlate` rewrites each plate's UVs onto
+ * 0..1 and the canvas is enlarged to match, which is what removes the
+ * clamp-to-edge smear at the border and what gives a moved tear, brow or mouth
+ * room to move (see `canvasLayout` in src/faceArt.js).
+ *
+ * Both parts can independently fall back to the original hand-drawn texture
+ * that ships inside the GLB.
+ */
+export class Face {
+ constructor({ eyeMesh, mouthMesh, hairMesh = null, originals }) {
+ this.eyeMesh = eyeMesh;
+ this.mouthMesh = mouthMesh;
+ // 頭の模様 can be carried by a plate that wraps right round the head (the
+ // `hair-plate`), so hair is visible from behind as well. Older models have no
+ // such plate and draw the covering on the front one instead.
+ this.hairMesh = hairMesh;
+
+ // Rewrite each plate's UVs so its own range fills 0..1, and work out the
+ // canvas that keeps the artwork exactly where the 2022 textures put it.
+ this.eyeLayout = remapPlate(eyeMesh);
+ this.mouthLayout = remapPlate(mouthMesh);
+ this.hairLayout = hairMesh ? remapPlate(hairMesh) : null;
+
+ // The hand-drawn eye textures bake the face colour into their background,
+ // which is invisible in the lit "real" style but shows up as a flat purple
+ // patch in the flat and line-art styles. Key it out once, here. Both kinds
+ // of original are window-sized artwork, so they belong *in* the window, not
+ // stretched across the enlarged canvas.
+ this.originals = {
+ eyes: Object.fromEntries(
+ Object.entries(originals.eyes).map(([key, texture]) => [key, keyOutBackground(texture, this.eyeLayout)]),
+ ),
+ mouth: placeInWindowTexture(originals.mouth, this.mouthLayout),
+ };
+
+ this.customNames = new Map();
+ // Drawings supplied for a beard kind (assets/beards/.png). Loaded by the
+ // app; a kind without one falls back to the built-in drawing (see drawBeard).
+ this.beardImages = {};
+ // Drawings for the brows (assets/brows/.png), loaded the same way.
+ this.browImages = {};
+ this.eyeCanvas = makeCanvas(this.eyeLayout);
+ this.mouthCanvas = makeCanvas(this.mouthLayout);
+ this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout, HAIR_SCALE) : null;
+ this.eyeCtx = this.eyeCanvas.getContext('2d');
+ this.mouthCtx = this.mouthCanvas.getContext('2d');
+ this.hairCtx = this.hairCanvas ? this.hairCanvas.getContext('2d') : null;
+
+ this.eyeTexture = makeTexture(this.eyeCanvas);
+ this.mouthTexture = makeTexture(this.mouthCanvas);
+ this.hairTexture = this.hairCanvas ? makeTexture(this.hairCanvas) : null;
+
+ this.params = null;
+ this.styleMode = 'paint';
+ this.eyeSource = 'parametric';
+ this.mouthSource = 'parametric';
+ this.dirty = true;
+ this.lastDraw = -Infinity;
+
+ // The face plates are shells of the body surface, pushed a hair outwards by
+ // the loader, so they win the depth test against the body by themselves.
+ // Nudging them with a polygon offset as well keeps the two from z-fighting
+ // along grazing angles. Depth *testing* stays on: that is what lets the nose
+ // (which pokes further out) and an arm waved in front of the face hide the
+ // artwork the way they should - it only ever hid the old planes because
+ // those did not reach far enough down the head.
+ const orders = new Map([[eyeMesh, 2], [mouthMesh, 1]]);
+ if (hairMesh) orders.set(hairMesh, 3);
+ for (const mesh of [eyeMesh, mouthMesh, ...(hairMesh ? [hairMesh] : [])]) {
+ const material = mesh.material;
+ material.polygonOffset = true;
+ material.polygonOffsetFactor = -1;
+ material.polygonOffsetUnits = -2;
+ material.depthTest = true;
+ material.depthWrite = false;
+ // The GLB may describe the plate material as opaque; the artwork is a
+ // texture with alpha, so blend instead of replacing what is behind it.
+ material.transparent = true;
+ material.side = THREE.DoubleSide;
+ material.needsUpdate = true;
+ // Tears are drawn on the eye plate and the mouth on the other one, and
+ // both sit on the same shell: draw the mouth first so a teardrop can fall
+ // across it instead of being painted over.
+ mesh.renderOrder = orders.get(mesh) ?? 0;
+ }
+
+ this.applyTextures();
+ }
+
+ /** Show or hide the mouth without touching the artwork. */
+ setMouthVisible(visible) {
+ if (this.mouthMesh.visible !== visible) this.mouthMesh.visible = visible;
+ }
+
+ applyTextures() {
+ const eyeMap = this.eyeSource === 'parametric'
+ ? this.eyeTexture
+ : (this.originals.eyes[this.eyeSource] ?? this.eyeTexture);
+ const mouthMap = this.mouthSource === 'parametric'
+ ? this.mouthTexture
+ : (this.originals.mouth ?? this.mouthTexture);
+
+ if (this.eyeMesh.material.map !== eyeMap) {
+ this.eyeMesh.material.map = eyeMap;
+ this.eyeMesh.material.needsUpdate = true;
+ }
+ if (this.mouthMesh.material.map !== mouthMap) {
+ this.mouthMesh.material.map = mouthMap;
+ this.mouthMesh.material.needsUpdate = true;
+ }
+ if (this.hairMesh && this.hairMesh.material.map !== this.hairTexture) {
+ this.hairMesh.material.map = this.hairTexture;
+ this.hairMesh.material.needsUpdate = true;
+ }
+ }
+
+ /**
+ * Load a hand-drawn image and use it for the eyes or the mouth. Any size is
+ * accepted; it is scaled to the layout the model expects (1024 x 380).
+ * Returns the key the new artwork is registered under.
+ */
+ async loadImage(kind, file) {
+ const layout = kind === 'eyes' ? this.eyeLayout : this.mouthLayout;
+ const bitmap = await createImageBitmap(file);
+ // Any size is accepted; it is scaled into the artwork window, which is where
+ // the plate's rewritten UVs expect it.
+ const canvas = placeInWindow(bitmap, layout);
+ if (typeof bitmap.close === 'function') bitmap.close();
+ const texture = makeTexture(canvas);
+
+ if (kind === 'eyes') {
+ const key = `custom-${this.customNames.size + 1}`;
+ this.customNames.set(key, file.name);
+ this.originals.eyes[key] = texture;
+ this.setSource('eyes', key);
+ return key;
+ }
+ this.originals.mouth = texture;
+ this.setSource('mouth', 'original');
+ return 'original';
+ }
+
+ /**
+ * Set the drawings used for the beards, keyed by beard kind (`scotch`, `kaiser`,
+ * `apron`, `cat`). Called by the app once the files are loaded; the next redraw
+ * picks them up.
+ */
+ setBeardImages(map) {
+ this.beardImages = map ?? {};
+ this.dirty = true;
+ }
+
+ /**
+ * Set the drawings used for the brows, keyed by brow kind (`gol`). Called by the
+ * app once the files are loaded; the next redraw picks them up.
+ */
+ setBrowImages(map) {
+ this.browImages = map ?? {};
+ this.dirty = true;
+ }
+
+ /** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */
+ setSource(kind, source) {
+ if (kind === 'eyes') {
+ if (this.eyeSource === source) return;
+ this.eyeSource = source;
+ } else {
+ if (this.mouthSource === source) return;
+ this.mouthSource = source;
+ }
+ this.applyTextures();
+ this.dirty = true;
+ }
+
+ setParams(params, styleMode = this.styleMode) {
+ this.params = params;
+ this.styleMode = styleMode;
+ this.dirty = true;
+ }
+
+ /**
+ * Redraw the procedural textures when they are stale. `minInterval` throttles
+ * redraws while animating; pass 0 before an export so nothing is left pending.
+ */
+ flush(now = performance.now(), minInterval = 0) {
+ if (!this.dirty) return false;
+ if (now - this.lastDraw < minInterval) return false;
+ this.redraw();
+ this.lastDraw = now;
+ return true;
+ }
+
+ redraw() {
+ const p = this.params;
+ if (!p) {
+ this.dirty = false;
+ return;
+ }
+ // Hiding the mouth has to hide the plane itself, not just stop drawing on
+ // it - otherwise the last drawing stays on screen.
+ this.setMouthVisible(p.mouth.visible !== false);
+ // A tongue poking over a *closed* lip can reach up behind the 3D nose, which
+ // would hide it. In that one case let the mouth artwork win the depth test,
+ // so the tongue reads; otherwise the nose (and an arm) still hide the mouth
+ // the way they should. The lip line and the rest of the mouth sit clear of
+ // the nose, so only the tongue can overlap it.
+ const mouth = p.mouth ?? {};
+ const tongueOverLip = (mouth.round ?? 0) <= 0.004
+ && (mouth.open ?? 0) <= 0.004
+ && (mouth.tongue ?? 1) > 0.01;
+ this.mouthMesh.material.depthTest = !tongueOverLip;
+ const mode = this.styleMode === 'line' ? 'line' : 'paint';
+
+ if (this.eyeSource === 'parametric') {
+ const layout = this.eyeLayout;
+ const ctx = this.eyeCtx;
+ ctx.clearRect(0, 0, layout.width, layout.height);
+ ctx.save();
+ // The artwork window sits at an offset inside the bigger canvas.
+ ctx.translate(layout.offsetX, layout.offsetY);
+ drawEyes(ctx, {
+ mode,
+ eyes: { left: p.eyes.left, right: p.eyes.right },
+ style: {
+ white: p.eyes.white,
+ iris: p.eyes.iris,
+ line: p.eyes.line,
+ irisScale: p.eyes.irisScale,
+ lookMax: p.eyes.lookMax,
+ highlight: p.eyes.highlight,
+ lidWidth: p.eyes.lidWidth,
+ lowerLid: p.eyes.lowerLid,
+ lidShape: p.eyes.lidShape,
+ lidTilt: p.eyes.lidTilt,
+ lashes: p.eyes.lashes,
+ brow: p.eyes.brow,
+ // ほっぺ and 頭の模様 are shared by the whole face, so they travel with
+ // the other shared eye fields (see `drawEyes` in src/faceArt.js).
+ cheeks: p.eyes.cheeks,
+ // When the model has a wrapping hair plate the covering is drawn there
+ // instead (below), so it is switched off on the front plate to keep it
+ // from being drawn twice.
+ headMark: this.hairMesh ? { shape: 'off' } : p.eyes.headMark,
+ // ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D
+ // object hung off the nose - see src/main.js - so it is not drawn here.)
+ beard: p.eyes.beard,
+ beards: this.beardImages,
+ brows: this.browImages,
+ // 眼鏡 / サングラス are shared by both eyes and drawn into this same
+ // texture, so they travel with the other shared eye fields.
+ glasses: p.eyes.glasses,
+ heartScale: p.eyes.heartScale,
+ heartColor: p.eyes.heartColor,
+ tearColor: p.eyes.tearColor,
+ // Where the artwork's real edges are, so a brow lifted too far or a
+ // tear dropped too low can stop inside the artwork (see `canvasLayout`).
+ limits: layout.limits,
+ },
+ });
+ ctx.restore();
+ this.eyeTexture.needsUpdate = true;
+ }
+
+ if (this.mouthSource === 'parametric' && p.mouth.visible) {
+ const layout = this.mouthLayout;
+ const ctx = this.mouthCtx;
+ ctx.clearRect(0, 0, layout.width, layout.height);
+ ctx.save();
+ ctx.translate(layout.offsetX, layout.offsetY);
+ drawMouth(ctx, { mode, ...p.mouth, limits: layout.limits });
+ ctx.restore();
+ this.mouthTexture.needsUpdate = true;
+ }
+
+ if (this.hairMesh) {
+ const hairShape = p.eyes.headMark?.shape ?? 'off';
+ // Shapes that draw nothing (the retired front-plate ones, or なし). A fully
+ // transparent texture still tinted the head on iOS, so the plate is hidden
+ // outright when there is no hair rather than left showing an empty texture.
+ const hairShown = !(hairShape === 'off' || hairShape === 'none'
+ || hairShape === 'split' || hairShape === 'wave' || hairShape === 'side');
+ this.hairMesh.visible = hairShown;
+ if (hairShown) {
+ // `overEyes` decides the draw order: the hair plate sits in front of the
+ // eyes (bangs over the face) or behind them.
+ this.hairMesh.renderOrder = p.eyes.headMark?.overEyes === false ? 0 : 3;
+ // The colour lives on the material (the texture is a white mask), so it
+ // follows the same colour management as the body on every platform.
+ this.hairMesh.material.color.set(p.eyes.headMark?.color ?? '#8a4fe0');
+ const layout = this.hairLayout;
+ const ctx = this.hairCtx;
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, layout.width * HAIR_SCALE, layout.height * HAIR_SCALE);
+ ctx.scale(HAIR_SCALE, HAIR_SCALE);
+ // `drawHeadCoverWrap` works in canvas coordinates (its x axis is the UV
+ // angle already normalised), so it is not shifted by the window offset.
+ drawHeadCoverWrap(ctx, p.eyes.headMark ?? {}, layout);
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.hairTexture.needsUpdate = true;
+ }
+ }
+
+ this.dirty = false;
+ }
+}
+
+function makeCanvas({ width, height }, scale = 1) {
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.round(width * scale);
+ canvas.height = Math.round(height * scale);
+ return canvas;
+}
+
+/**
+ * Rewrite a plate's UVs so its own range maps onto 0..1, and return the canvas
+ * that keeps the artwork at its original pixel size (`canvasLayout`).
+ *
+ * The plate covers the whole front half of the body, so its UVs used to run well
+ * outside the artwork (v -1.02..1.93 on the eye plate). Everything past the edge
+ * clamped to the canvas border, which is what stretched a tear or a brow that
+ * reached it. After this the whole plate samples real canvas, so nothing clamps.
+ */
+function remapPlate(mesh) {
+ const uv = mesh?.geometry?.attributes?.uv;
+ if (!uv) return canvasLayout(null);
+ let u0 = Infinity;
+ let u1 = -Infinity;
+ let v0 = Infinity;
+ let v1 = -Infinity;
+ for (let i = 0; i < uv.count; i += 1) {
+ const u = uv.getX(i);
+ const v = uv.getY(i);
+ if (u < u0) u0 = u;
+ if (u > u1) u1 = u;
+ if (v < v0) v0 = v;
+ if (v > v1) v1 = v;
+ }
+ const du = Math.max(1e-6, u1 - u0);
+ const dv = Math.max(1e-6, v1 - v0);
+ for (let i = 0; i < uv.count; i += 1) {
+ uv.setXY(i, (uv.getX(i) - u0) / du, (uv.getY(i) - v0) / dv);
+ }
+ uv.needsUpdate = true;
+ return canvasLayout({ u0, u1, v0, v1 });
+}
+
+/**
+ * Draw a hand-drawn 1024 x 380 image where the artwork window now sits, on a
+ * canvas as big as the plate's own UV range. The plates cover far more than the
+ * window (see `canvasLayout`) and the UVs were rewritten to match, so the window
+ * is exactly where that image belongs.
+ */
+function placeInWindow(source, layout) {
+ const canvas = document.createElement('canvas');
+ canvas.width = layout.width;
+ canvas.height = layout.height;
+ if (source) {
+ canvas.getContext('2d').drawImage(
+ source, layout.offsetX, layout.offsetY, layout.window.width, layout.window.height,
+ );
+ }
+ return canvas;
+}
+
+/** `placeInWindow` for an existing texture (keeps `null` as `null`). */
+function placeInWindowTexture(texture, layout) {
+ const image = texture?.image;
+ if (!image || !image.width || !image.height) return texture ?? null;
+ return makeTexture(placeInWindow(image, layout));
+}
+
+function makeTexture(canvas) {
+ const texture = new THREE.CanvasTexture(canvas);
+ // glTF puts v = 0 at the top of the image and the loader uploads the model's
+ // own textures with flipY = false; matching that keeps the artwork aligned
+ // with the mesh UVs.
+ texture.flipY = false;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.premultiplyAlpha = false;
+ texture.needsUpdate = true;
+ return clampToEdge(texture);
+}
+
+/**
+ * The face plates are shells of the whole body, so their UVs run well past the
+ * 0..1 of the artwork (a plate corner can sit at v = -1.26). glTF's default
+ * sampler repeats, which would stamp the drawing back onto the model several
+ * times - a frown flicked up onto the forehead, a smile's end onto its side.
+ * Clamping sends everything outside the artwork to the blank edge of the canvas
+ * instead, where nothing is drawn.
+ */
+function clampToEdge(texture) {
+ if (!texture) return texture;
+ texture.wrapS = THREE.ClampToEdgeWrapping;
+ texture.wrapT = THREE.ClampToEdgeWrapping;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+/**
+ * Make the flat colour that fills the background of a hand-drawn texture
+ * transparent. The edge is feathered rather than hard, so the anti-aliased
+ * pixels along the artwork do not leave a pale fringe.
+ */
+function keyOutBackground(texture, layout) {
+ const image = texture?.image;
+ if (!image || !image.width || !image.height) return texture;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ ctx.drawImage(image, 0, 0);
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const pixels = imageData.data;
+
+ // The background is uniform, so the top-left pixel is a reliable sample.
+ const baseR = pixels[0];
+ const baseG = pixels[1];
+ const baseB = pixels[2];
+ const fullyClear = 30; // at or below this distance: transparent
+ const fullySolid = 96; // at or above this distance: untouched
+
+ for (let i = 0; i < pixels.length; i += 4) {
+ const distance = Math.abs(pixels[i] - baseR)
+ + Math.abs(pixels[i + 1] - baseG)
+ + Math.abs(pixels[i + 2] - baseB);
+ if (distance <= fullyClear) {
+ pixels[i + 3] = 0;
+ } else if (distance < fullySolid) {
+ const ratio = (distance - fullyClear) / (fullySolid - fullyClear);
+ pixels[i + 3] = Math.round(pixels[i + 3] * ratio);
+ }
+ }
+ ctx.putImageData(imageData, 0, 0);
+
+ return makeTexture(placeInWindow(canvas, layout));
+}
diff --git a/bluebey-studio/src/faceArt.js b/bluebey-studio/src/faceArt.js
new file mode 100644
index 0000000..8537fd8
--- /dev/null
+++ b/bluebey-studio/src/faceArt.js
@@ -0,0 +1,1855 @@
+/**
+ * The face artwork: everything that used to be a hand-drawn PNG in GIMP is now
+ * drawn from parameters onto a canvas, which is then used as the texture of the
+ * two overlay planes that already exist on the model.
+ *
+ * The plates have a linear UV mapping fitted to the original artwork, so drawing
+ * in "texture pixels" lands exactly where the original artwork did. Every constant
+ * below was measured from the original textures (see README for the numbers), so
+ * the defaults reproduce the 2022 artwork while every part of it stays editable.
+ *
+ * Texture space: the artwork WINDOW, 1024 x 380, origin at the top-left, y grows
+ * downwards. `canvasLayout` then says where that window sits on the bigger canvas
+ * a face plate actually needs (see below), and `Face` hands the bounds back as
+ * `limits`, so the drawing can clamp against the real edge instead of assuming
+ * the window is all there is.
+ *
+ * glTF stores v downwards too, and the textures are uploaded with flipY = false,
+ * so no flipping is needed anywhere.
+ */
+
+const TAU = Math.PI * 2;
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+const HUGE = 4000;
+
+/**
+ * The artwork window: the rectangle the 2022 hand-drawn textures occupied. Every
+ * constant below is written in this space.
+ */
+export const ART_WINDOW = { width: 1024, height: 380 };
+
+/** Keep this much clear at the canvas border, so filtering has room to breathe. */
+const EDGE_MARGIN = 8;
+
+/** What a drawing falls back to when no plate bounds are supplied. */
+const WINDOW_LIMITS = {
+ top: 0, bottom: ART_WINDOW.height, left: 0, right: ART_WINDOW.width,
+};
+
+/**
+ * The canvas a face plate's drawing needs, and where the artwork window sits on it.
+ *
+ * WHY: the plates are the *whole* front half of the body, so their UVs run far
+ * outside the 0..1 the artwork was drawn in (measured on the shipped model: the
+ * eye plate covers v -1.02..1.93, and 9 of 10 of its vertices sit within a
+ * twentieth of the border). Everything outside 0..1 used to clamp to the canvas
+ * edge row, so the moment a drawing reached the border - a tear dropped low, a
+ * brow lifted, a thick mouth pushed down - that edge row was copied across the
+ * rest of the plate and the mark stretched into a long smear.
+ *
+ * `Face` rewrites each plate's UVs so this range maps onto 0..1 instead, which
+ * removes the clamp altogether. The map is affine, so the artwork keeps its exact
+ * pixel size and position and merely shifts by `offset`; the canvas grows by the
+ * same factor, and those extra rows are the room a moved tear, brow or mouth
+ * needs (`limits` says where the real edge now is, in window coordinates).
+ *
+ * @param {{u0:number,u1:number,v0:number,v1:number}} range the plate's UV range
+ * @param {{width:number,height:number}} [window] the artwork window
+ */
+export function canvasLayout(range, window = ART_WINDOW) {
+ const u0 = Number.isFinite(range?.u0) ? range.u0 : 0;
+ const u1 = Number.isFinite(range?.u1) ? range.u1 : 1;
+ const v0 = Number.isFinite(range?.v0) ? range.v0 : 0;
+ const v1 = Number.isFinite(range?.v1) ? range.v1 : 1;
+ const du = Math.max(1e-6, u1 - u0);
+ const dv = Math.max(1e-6, v1 - v0);
+ const width = Math.max(1, Math.round(window.width * du));
+ const height = Math.max(1, Math.round(window.height * dv));
+ // `+ 0` turns a `-0` into a plain `0`, so the offsets compare cleanly.
+ const offsetX = Math.round(-u0 * window.width) + 0;
+ const offsetY = Math.round(-v0 * window.height) + 0;
+ return {
+ width,
+ height,
+ offsetX,
+ offsetY,
+ window,
+ // The canvas edges in the artwork's own coordinates. `top` is negative when
+ // the plate reaches above the window: that is the extra room.
+ limits: {
+ top: -offsetY + 0,
+ bottom: height - offsetY,
+ left: -offsetX + 0,
+ right: width - offsetX,
+ },
+ };
+}
+
+/** Fixed layout of the eye plane, measured from the original `eyes-open.png`. */
+export const EYE_LAYOUT = {
+ radius: 84,
+ irisRadius: 62.5,
+ highlightRadius: 28,
+ highlightOffset: { x: 29.5, y: -18 },
+ // The model's left eye (its own left, +x) sits in the u > 0.5 half.
+ eyes: [
+ { key: 'right', cx: 267.5, cy: 240, towardNose: 1 },
+ { key: 'left', cx: 755.5, cy: 240, towardNose: -1 },
+ ],
+ lidRadiusFactor: 1.35,
+ lidStroke: 14,
+ // Eyebrows are painted into the same texture as the eyes. The eye plane is a
+ // flat-ish patch on a round head, so only its lower part is really outside
+ // the head: a brow high on the forehead would be swallowed by it. The brow
+ // therefore sits just above the eyeball (row 156) and the eyeball is drawn
+ // over its lower edge afterwards.
+ // The brow sits just above the eyeball, and the eyeball is drawn over its
+ // lower edge. `lift` is that resting height, so `height: 0` is the brow's
+ // natural place and a POSITIVE height raises it (a negative one sinks it
+ // towards the eye, where the eyeball will cover it).
+ brow: { lift: 114, length: 134, thickness: 15, curve: 0.14, offsetX: 6 },
+ // A teardrop hangs well below the eye. The plate keeps the whole canvas, so
+ // the drop can sit low without being clipped (measured with a ruler grid).
+ tear: { size: 34, offsetX: 66, offsetY: 88 },
+ closedLine: {
+ // The single shut line is the *long* form of the eyelid, the same reach as
+ // the arms of the "ぎゅっ" below, so switching between 1 and 3 lines does not
+ // change how wide the eye reads.
+ length: 205,
+ offsetX: 6,
+ offsetY: -10,
+ slantDeg: 3.9,
+ bow: 3,
+ // 2- and 3-line shut eyes, copied from the original `eyes-close-tight`
+ // artwork: three strokes sharing one vertex that points at the nose, opening
+ // to a wide bird's foot / arrow shape. `spread` is half the height the arms
+ // open to at the far end.
+ armSpan: 205,
+ spread: 62,
+ vertex: 87.5,
+ armX: 104,
+ },
+ arch: { edgesUp: 8, apexUp: 44 },
+ // まつげ: a few strokes flicking out from the upper-outer lid (see drawEyes).
+ // `raise` lifts them a touch above the lid edge (2% of the lid slider).
+ lash: { length: 30, width: 8, angles: [46, 66, 86], raise: 0.04 },
+ // "ふつう"の目の瞳を、両目とも少し鼻側に寄せてかわいく見せる(px)。
+ irisInward: 7,
+};
+
+/**
+ * ほっぺ (a manga blush) and 頭の模様 (a mark on the head), both drawn into the eye
+ * plate next to the eyes and brows.
+ *
+ * They are deliberately *generic* manga devices, not a copy of any one character:
+ * a pink patch crossed by short diagonal strokes is a stock convention, and the
+ * head mark is one of a few simple shapes (a hook, a spiral, three strokes, a dot
+ * row) that starts off. Both stay editable through the panel. The cheek positions
+ * are tied to `EYE_LAYOUT` so they sit beside the eyes; the head mark sits up in
+ * the plate's upper area, i.e. on the forehead above the brows.
+ */
+export const CHEEK_LAYOUT = {
+ drop: 64, // how far below the eye centre the patch sits (artwork px)
+ outward: 66, // how far outwards, away from the midline, it sits
+ radius: 50, // half-width of the patch
+ squash: 0.55, // half-height, as a fraction of the half-width (a flat ellipse)
+ // The blush is *always* four strokes - the reference artwork has four - so
+ // there is no line-count control any more. The strokes are horizontal and
+ // hand-drawn: each wobbles a little and tapers to a point at both ends.
+ lines: 4,
+ lineWidth: 10, // the stroke thickness at its widest (artwork px)
+ spread: 0.62, // how far the outermost stroke sits from the centre (x half-height)
+ margin: 0.18, // clear gap left between a stroke's end and the patch outline
+ wobble: 0.32, // sideways wander, as a fraction of the stroke thickness
+};
+
+/**
+ * 頭の模様: a *hair-like covering* over the whole head, not a small forehead symbol.
+ *
+ * The face plate is a shell of the *whole* front half of the body, so the plate's
+ * own edge is the head's front silhouette. Filling the plate from its top edge
+ * down to an adjustable boundary therefore makes the covering's outline follow
+ * the silhouette for free - there is no separate ellipse to keep in step.
+ *
+ * It is a generic device: a colour cap, a fringe, a side part, or a left/right
+ * two-tone split. It is deliberately *not* a copy of any one character's hair.
+ * It starts off.
+ */
+export const HEAD_MARK_LAYOUT = {
+ // Where the boundary sits by default: on the forehead, just above the brows
+ // (EYE_LAYOUT.eyes[0].cy is the eye row and the brows reach ~114px above it).
+ cy: 120,
+ reach: 190, // artwork px the boundary moves per unit of `size`
+};
+
+/**
+ * 頭の模様 on the *hair plate*: the head's cover when the model carries a plate
+ * that wraps all the way round (material `hair-plate`, cylindrical UV).
+ *
+ * The front plate only reaches the head's silhouette, so hair drawn on it stops
+ * at the front half. The hair plate's UV is `u` = angle round the head (front at
+ * 0.5, back at the seam 0/1) and `v` = height (0 at the model's feet, 1 at the
+ * top of the head), so the covering is everything *above* a boundary line: a cap
+ * that is visible from behind as well.
+ */
+export const HAIR_WRAP_LAYOUT = {
+ // The wrap's v runs top-of-head (0) to the model's feet (1) after the GLB's
+ // V-flip, so `base` is how far down from the top the hairline sits at size 1.
+ // `reach` is how much a unit of `size` moves it; `offsetScale` converts the
+ // `offsetY` slider (artwork px) to the same units, so one slider drives both.
+ base: 0.30,
+ reach: 0.30,
+ offsetScale: 0.30 / 190,
+};
+
+/**
+ * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate
+ * at the nose, the same spot the snot bubble uses. `shape` picks a well-known
+ * kind; `size` scales it about the nose, and the colours are adjustable.
+ */
+export const BEARD_LAYOUT = {
+ cx: 511.5,
+ cy: 352, // just under the nose
+ width: 240, // base width of the widest beard at size 1
+};
+
+/**
+ * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows.
+ *
+ * Not measured off any artwork - the character has no glasses - but the numbers
+ * are tied to the eyeball so a lens is always a little wider than the eye it
+ * covers. The lens centre sits *below* the eye centre because the brow is drawn
+ * first, just above the eyeball (see `EYE_LAYOUT.brow`): a lens wide enough to
+ * read as glasses would otherwise cut straight through it.
+ *
+ * The two kinds get separate shapes on purpose. They used to share one ellipse
+ * and differ only in how dark the lens was filled, which made them hard to tell
+ * apart; the round 眼鏡 below and the wide, angular サングラス in
+ * `SUNGLASSES_LAYOUT` now read as different objects at a glance.
+ */
+const GLASSES_LAYOUT = {
+ widthFactor: 1.08, // lens half-width, as a multiple of the eyeball radius
+ heightFactor: 1.0, // a circle, so the round 眼鏡 stays round (a flatter
+ // ellipse used to drift towards the shades below)
+ drop: 20, // the lens centre hangs this far below the eye centre (artwork px)
+ bridgeRise: 8, // how much the bridge arcs up over the nose
+ bridgeWidth: 0.75, // bridge thickness, as a fraction of the frame stroke
+ templeLength: 1.5, // temple stub length, as a multiple of the eyeball radius
+ templeRise: 30, // how far the temple climbs towards the side of the head
+};
+
+/**
+ * サングラス: a long, pointed cat-eye rather than the round 眼鏡 lens. Each side is a
+ * slim wedge whose *outer end starts low*, rises to a *high, pointed outer corner*
+ * set further out (`tipX`), and whose top edge then sweeps back down towards the
+ * nose - the "外側が長くとがって上に上がる" shape the reference shows. The inner end is
+ * short, so the lens tapers inwards. The eye is allowed to poke out above and
+ * below it, so the lens no longer has to cover the whole eyeball. A level bar
+ * joins the two inner ends, so the pair still reads as one dark visor. The ratios
+ * are of the eyeball radius, exactly like `GLASSES_LAYOUT`, so `scale` means the
+ * same thing for both kinds.
+ */
+const SUNGLASSES_LAYOUT = {
+ widthFactor: 1.6, // a long lens: the outer end reaches well past the eyeball
+ heightFactor: 0.58, // slim, so the eye is free to show above and below it
+ drop: 8, // sits a little lower than the round pair
+ corner: 0.05, // corner rounding, as a fraction of the half-height; kept
+ // tiny so the outer corner stays pointed
+ topSkew: 1.0, // the pointed corner rises this far above the lens top (x half-height)
+ innerTop: 0.42, // the inner top is low, which is what makes the top edge climb
+ innerBottom: 0.34, // the inner bottom is pinched up towards the nose (x half-height)
+ outerEndX: 0.75, // the low outer end sits this far out (x half-width)
+ outerBottom: 0.82, // ...and this deep (x half-height)
+ tipX: 1.22, // the pointed corner juts this far out (x half-width)
+ bridgeWidth: 1.15, // a short, thick bar - thicker than the frame stroke
+ bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height)
+ templeLength: 1.15, // a short stub, like the round pair's but a little shorter
+ templeRise: 24,
+ // A heavy rim. It used to be slimmer than the round pair's stroke, which read
+ // as a thin pair of shades; the reference look is a chunky frame.
+ frameFactor: 1.6,
+};
+
+/** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole
+ * 1024 x 380 canvas (see tools/build-face-plates.py), so the drawing no longer
+ * has to be squeezed into a band. The only limit left is the canvas itself. */
+export const MOUTH_LAYOUT = {
+ centreX: 511.5,
+ chordY: 88,
+ halfChord: 420.5,
+ sag: 177,
+ // With the ends and the depth both fixed - which is what the slider promises -
+ // the only thing left to choose about the curve is *where* it bends. 1/3 is a
+ // quadratic Bézier (a parabola): it is flattest at the apex and falls away
+ // fastest near the ends. Pulling the cubic's control points in towards the ends
+ // makes the middle of the smile straighter still and lets the fall happen near
+ // the corners, which is what reads as a *gentler* curve at the same depth.
+ sagBend: 0.2,
+ thickness: 21,
+ // How big the round "O" oval gets at `round: 1`, as a multiple of the smile's
+ // own sag. The oval's size comes from this and `round` alone: `thickness` is
+ // only the stroke weight, so 口の太さ and 丸く開く no longer move together.
+ roundScale: 1.3,
+ // Kept for reference: the 2022 plane only showed these rows.
+ bandTop: 46,
+ bandBottom: 332,
+ // Only used to cap the *size* of the round "O" mouth, so the surprise face
+ // stays a mouth and not a hole. The mouth's travel no longer stops here: the
+ // drawing is given the plate's real edges through `limits`.
+ safeBottom: 372,
+ // The nose is a separate mesh that pokes out in front of the plate. The plate
+ // is depth tested, so the nose hides whatever is drawn behind it - but a frown
+ // arcs *up* into that hiding place, so slide the mouth down until the middle
+ // of the arc clears the nose. Only the middle is checked: a smile curves away
+ // from the nose, and checking its ends instead is what used to pin the whole
+ // mouth in place and make the height slider do nothing.
+ noseClear: 132,
+ // The original hand-drawn mouth is a *shallow* arc with tall corner strokes
+ // flicking up at the ends (measured from the artwork: the arc's own sag is
+ // ~0.13 of its chord, while the corners reach ~90px above it). Keeping that
+ // split is what makes `smile: 1` read as the original; making the arc itself
+ // deep instead looked too steep.
+ //
+ // The tongue rises from the lip line to a rounded top just above the corner
+ // strokes. Measured off the original: its crown is a *super-ellipse* -
+ // `rise = height * (1 - |dx/half|^2.5)` - so it is steep-sided with a smooth,
+ // almost flat top. It is convex upwards, but it is **not** a point.
+ tongue: { pos: 0.84, width: 126, height: 115, crown: 2.5 },
+ corner: { fromX: 26, fromY: 3, toX: 32, toY: 42, width: 11, curve: 26 },
+ openRise: 64,
+ // Was 0.25: opening the mouth used to flatten the smile to stay inside the
+ // texture band. There is no band to stay inside now.
+ openFlatten: 0,
+};
+
+/* ------------------------------------------------------------------ helpers */
+
+function circlePath(ctx, cx, cy, r) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, TAU);
+ ctx.closePath();
+}
+
+function fillCircle(ctx, cx, cy, r, color) {
+ circlePath(ctx, cx, cy, r);
+ ctx.fillStyle = color;
+ ctx.fill();
+}
+
+/**
+ * A heart, used for the "love" eyes. The path is wider than it is tall, which
+ * is what makes it read as a heart rather than a blob at small sizes.
+ */
+function heartPath(ctx, cx, cy, r) {
+ ctx.beginPath();
+ ctx.moveTo(cx, cy + r * 0.80);
+ ctx.bezierCurveTo(cx - r * 1.24, cy - r * 0.34, cx - r * 0.50, cy - r * 1.18, cx, cy - r * 0.40);
+ ctx.bezierCurveTo(cx + r * 0.50, cy - r * 1.18, cx + r * 1.24, cy - r * 0.34, cx, cy + r * 0.80);
+ ctx.closePath();
+}
+
+function fillHeart(ctx, cx, cy, r, color) {
+ heartPath(ctx, cx, cy, r);
+ ctx.fillStyle = color;
+ ctx.fill();
+}
+
+/** A teardrop, used by the crying expression. */
+function dropPath(ctx, cx, cy, size) {
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - size * 1.32);
+ ctx.bezierCurveTo(cx + size * 0.95, cy - size * 0.34, cx + size * 0.95, cy + size * 0.78, cx, cy + size * 0.78);
+ ctx.bezierCurveTo(cx - size * 0.95, cy + size * 0.78, cx - size * 0.95, cy - size * 0.34, cx, cy - size * 1.32);
+ ctx.closePath();
+}
+
+function strokeCircle(ctx, cx, cy, r, color, width) {
+ circlePath(ctx, cx, cy, r);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.stroke();
+}
+
+function tracePolyline(ctx, points, closed) {
+ if (!points.length) return;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
+ if (closed) ctx.closePath();
+}
+
+function strokePolyline(ctx, points, { color, width, closed = false }) {
+ if (points.length < 2) return;
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ tracePolyline(ctx, points, closed);
+ ctx.stroke();
+ ctx.restore();
+}
+
+function fillPolygon(ctx, points, color) {
+ if (points.length < 3) return;
+ ctx.save();
+ ctx.fillStyle = color;
+ tracePolyline(ctx, points, true);
+ ctx.fill();
+ ctx.restore();
+}
+
+/**
+ * Trace a closed polygon with rounded corners. Used for the squarish
+ * サングラス lens: an ellipse cannot be angular, and a plain polygon has cusps.
+ * Each corner is cut back by `radius` (clamped so short edges cannot overlap)
+ * and joined with a quadratic through the original vertex.
+ */
+function traceRoundedPolygon(ctx, points, radius) {
+ const n = points.length;
+ ctx.beginPath();
+ for (let i = 0; i < n; i++) {
+ const prev = points[(i + n - 1) % n];
+ const cur = points[i];
+ const next = points[(i + 1) % n];
+ const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1;
+ const outLen = Math.hypot(next.x - cur.x, next.y - cur.y) || 1;
+ const cut = Math.min(radius, inLen / 2, outLen / 2);
+ const from = { x: cur.x + ((prev.x - cur.x) / inLen) * cut, y: cur.y + ((prev.y - cur.y) / inLen) * cut };
+ const to = { x: cur.x + ((next.x - cur.x) / outLen) * cut, y: cur.y + ((next.y - cur.y) / outLen) * cut };
+ if (i === 0) ctx.moveTo(from.x, from.y);
+ else ctx.lineTo(from.x, from.y);
+ ctx.quadraticCurveTo(cur.x, cur.y, to.x, to.y);
+ }
+ ctx.closePath();
+}
+
+/** Points along a quadratic Bézier, `steps` segments (steps + 1 points). */
+function quadraticPoints(p0, p1, p2, steps) {
+ const out = [];
+ for (let i = 0; i <= steps; i++) {
+ const t = i / steps;
+ const u = 1 - t;
+ out.push({
+ x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
+ y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y,
+ });
+ }
+ return out;
+}
+
+/** A point on a cubic Bézier. */
+function cubicPoint(p0, p1, p2, p3, t) {
+ const u = 1 - t;
+ const a = u * u * u;
+ const b = 3 * u * u * t;
+ const c = 3 * u * t * t;
+ const d = t * t * t;
+ return {
+ x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
+ y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
+ };
+}
+
+/** Points along a cubic Bézier, `steps` segments (steps + 1 points). */
+function cubicPoints(p0, p1, p2, p3, steps) {
+ const out = [];
+ for (let i = 0; i <= steps; i++) out.push(cubicPoint(p0, p1, p2, p3, i / steps));
+ return out;
+}
+
+/**
+ * The four control points of the mouth's centreline: the same two ends, the same
+ * depth, but `bend` decides where the curve actually bends (see `sagBend`).
+ */
+function mouthControls(left, right, sag, bend) {
+ const width = right.x - left.x;
+ const y = left.y + (4 / 3) * sag;
+ return {
+ p0: left,
+ p1: { x: left.x + width * bend, y },
+ p2: { x: right.x - width * bend, y },
+ p3: right,
+ };
+}
+
+function rotate(points, pivot, degrees) {
+ if (!degrees) return points;
+ const a = (degrees * Math.PI) / 180;
+ const cos = Math.cos(a);
+ const sin = Math.sin(a);
+ return points.map((p) => {
+ const dx = p.x - pivot.x;
+ const dy = p.y - pivot.y;
+ return { x: pivot.x + dx * cos - dy * sin, y: pivot.y + dx * sin + dy * cos };
+ });
+}
+
+/* --------------------------------------------------------------------- eyes */
+
+/**
+ * Intersect the canvas clip with the eyeball disc and the (possibly closed)
+ * lids. Because the overlay plane is transparent and sits in front of the face,
+ * clipping is all that is needed: whatever is clipped away shows the real
+ * shaded face behind it. That is what makes the eyelid blend perfectly without
+ * baking a face-coloured background into the texture.
+ */
+/** Clip to the upper and lower lids only, without the eyeball circle. */
+function clipLids(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) {
+ const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180;
+ const rotated = Math.abs(tilt) > 1e-4;
+ const spin = () => {
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate(tilt);
+ ctx.translate(-eye.cx, -eye.cy);
+ };
+ if (rotated) spin();
+
+ const lower = clamp(lowerLid, 0, 1);
+ // The upper and lower lids are independent: 上まぶたの高さ sets where the upper
+ // lid sits on its own, so raising 下まぶたの高さ does not drag the upper lid
+ // down with it. A blink still shuts the eye because `open` reaching 0 (or the
+ // upper lid dropping to the lower one) hands over to the shut-eye drawing
+ // before this clip is used.
+ const upper = 1 - clamp(open, 0, 1);
+ if (upper > 0.0005) {
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ const lowest = eye.cy - eye.r + 2 * eye.r * upper;
+ ctx.beginPath();
+ if (flat) {
+ // A flat lid: a straight edge straight across the eye.
+ ctx.rect(eye.cx - HUGE, lowest, HUGE * 2, HUGE);
+ } else {
+ const cy = lowest - rl;
+ ctx.moveTo(eye.cx - HUGE, cy);
+ ctx.lineTo(eye.cx - rl, cy);
+ ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down
+ ctx.lineTo(eye.cx + HUGE, cy);
+ ctx.lineTo(eye.cx + HUGE, cy + HUGE);
+ ctx.lineTo(eye.cx - HUGE, cy + HUGE);
+ }
+ ctx.closePath();
+ ctx.clip();
+ }
+
+ const bottom = lower;
+ if (bottom > 0.0005) {
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ const highest = eye.cy + eye.r - 2 * eye.r * bottom;
+ ctx.beginPath();
+ if (flat) {
+ ctx.rect(eye.cx - HUGE, highest - HUGE, HUGE * 2, HUGE);
+ } else {
+ const cy = highest + rl;
+ ctx.moveTo(eye.cx - HUGE, cy);
+ ctx.lineTo(eye.cx - rl, cy);
+ ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up
+ ctx.lineTo(eye.cx + HUGE, cy);
+ ctx.lineTo(eye.cx + HUGE, cy - HUGE);
+ ctx.lineTo(eye.cx - HUGE, cy - HUGE);
+ }
+ ctx.closePath();
+ ctx.clip();
+ }
+
+ if (rotated) {
+ // Undo the rotation for the caller, but keep the clip we just set.
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate(-tilt);
+ ctx.translate(-eye.cx, -eye.cy);
+ }
+}
+
+/** Clip to the eyeball circle as well (the iris and heart are eyeball content). */
+function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) {
+ circlePath(ctx, eye.cx, eye.cy, eye.r);
+ ctx.clip();
+ clipLids(ctx, eye, open, lowerLid, flat, tiltDeg);
+}
+
+function lidPath(ctx, eye, amount, lower, flat = false) {
+ ctx.beginPath();
+ if (flat) {
+ // A straight lid edge; the caller clips it to the eyeball.
+ const y = lower
+ ? eye.cy + eye.r - 2 * eye.r * amount
+ : eye.cy - eye.r + 2 * eye.r * (1 - amount);
+ ctx.moveTo(eye.cx - eye.r * 2, y);
+ ctx.lineTo(eye.cx + eye.r * 2, y);
+ return;
+ }
+ const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
+ if (!lower) {
+ const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount);
+ ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true);
+ } else {
+ const highest = eye.cy + eye.r - 2 * eye.r * amount;
+ ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false);
+ }
+}
+
+/** The "eyes shut" artwork: a line, a chevron or a happy arch. */
+function drawShutEye(ctx, eye, spec, style, shapeScale = 1, maxHalf = Infinity) {
+ const layout = EYE_LAYOUT.closedLine;
+ const { line, width } = style;
+ // 形の大きさ is a multiplier on the shape's *fitting* size. With the white shown
+ // that fitting size is what just fits inside the eyeball, so the default (1)
+ // sits in the white; raising it may push the shape out past the white, which is
+ // allowed. `maxHalf` is Infinity when the white is hidden, so nothing is scaled
+ // back there.
+ const k = (unitHalf) => shapeScale * (maxHalf === Infinity ? 1 : Math.min(1, maxHalf / unitHalf));
+
+ if (spec.closed === 'chevron') {
+ // The chevron points *at* the nose, matching the original artwork.
+ const scale = k(layout.armX);
+ const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale };
+ const armX = eye.cx - eye.towardNose * layout.armX * scale;
+ const spread = layout.spread * (spec.spread ?? 1) * scale;
+ strokePolyline(ctx, [vertex, { x: armX, y: vertex.y - spread }], { color: line, width });
+ strokePolyline(ctx, [vertex, { x: armX, y: vertex.y + spread }], { color: line, width });
+ return;
+ }
+
+ if (spec.closed === 'three') {
+ // A real "3": an upper bowl and a lower bowl that meet at a single pinch on
+ // the nose side. Stacking two C's instead made the two lobes sit on top of
+ // each other; joining them at a point is what makes it read as the digit.
+ const r = 34 * k(1.7 * 34);
+ // Which way the digit faces. It used to follow the nose side, which made the
+ // pair a mirror image; each eye can now be set either way, because a pair of
+ // mirrored 3s does not always read the way you want.
+ const dir = (spec.threeFlip ? -1 : 1) * eye.towardNose;
+ const sx = eye.cx;
+ const cy = eye.cy + layout.offsetY;
+ const at = (x, y) => ({ x: sx + dir * r * x, y: cy + r * y });
+ const pinch = at(0.72, 0);
+ const upper = quadraticPoints(at(0.02, -1.62), at(2.05, -1.40), pinch, 26);
+ const lower = quadraticPoints(pinch, at(2.05, 1.46), at(0.02, 1.78), 26);
+ strokePolyline(ctx, upper, { color: line, width });
+ strokePolyline(ctx, lower, { color: line, width });
+ return;
+ }
+
+ if (spec.closed === 'arch') {
+ const scale = k(eye.r);
+ const edges = { x: eye.r * scale, y: EYE_LAYOUT.arch.edgesUp * scale };
+ const points = quadraticPoints(
+ { x: eye.cx - edges.x, y: eye.cy + edges.y },
+ { x: eye.cx, y: eye.cy + edges.y - EYE_LAYOUT.arch.apexUp * 2 * scale },
+ { x: eye.cx + edges.x, y: eye.cy + edges.y },
+ 24,
+ );
+ strokePolyline(ctx, points, { color: line, width });
+ return;
+ }
+
+ // Default: the gently slanted line of the original artwork, mirrored so it
+ // always slopes down towards the nose. With `closedLines` set to 2 or 3 the
+ // strokes share one endpoint instead - a ">" or bird's-foot shape, which is
+ // how manga draws a happily squeezed-shut eye.
+ const count = clamp(Math.round(spec.closedLines ?? 1), 1, 3);
+ const scale = k(layout.length / 2);
+ if (count >= 2) {
+ const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale };
+ // Reach as far as the single line does, not just a short stub: the arms are
+ // the *long* form of the same eyelid the one-line version covers.
+ const outX = -eye.towardNose * layout.armSpan * scale;
+ const spread = layout.spread * scale;
+ const arms = count === 2
+ ? [{ x: outX, y: -spread }, { x: outX, y: spread }]
+ : [
+ { x: outX, y: -spread },
+ { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread) * scale, y: 0 },
+ { x: outX, y: spread },
+ ];
+ for (const arm of arms) {
+ strokePolyline(ctx, [vertex, { x: vertex.x + arm.x, y: vertex.y + arm.y }], { color: line, width });
+ }
+ return;
+ }
+
+ const half = (layout.length / 2) * (spec.length ?? 1) * scale;
+ const midX = eye.cx - eye.towardNose * layout.offsetX * scale;
+ const midY = eye.cy + layout.offsetY * scale;
+ const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180;
+ const bow = (layout.bow ?? 0) * (spec.slant ?? 1) * scale;
+ const from = { x: midX - Math.cos(angle) * half, y: midY - Math.sin(angle) * half };
+ const to = { x: midX + Math.cos(angle) * half, y: midY + Math.sin(angle) * half };
+ const points = quadraticPoints(from, { x: midX, y: midY + bow * 2 }, to, 20);
+ strokePolyline(ctx, points, { color: line, width });
+}
+
+/**
+ * A stroke whose width runs from `w0` at the first point to `w1` at the last.
+ *
+ * Used for a ゴルゴ13-style brow: a filled wedge that comes to a point at one end
+ * reads as a heavy eyebrow, where a constant-width stroke reads as a soft arc.
+ */
+function taperedStroke(ctx, points, w0, w1, color) {
+ const count = points.length;
+ if (count < 2) return;
+ const side = [[], []];
+ for (let i = 0; i < count; i++) {
+ const t = i / (count - 1);
+ const w = (w0 + (w1 - w0) * t) / 2;
+ const before = points[Math.max(0, i - 1)];
+ const after = points[Math.min(count - 1, i + 1)];
+ const dx = after.x - before.x;
+ const dy = after.y - before.y;
+ const len = Math.hypot(dx, dy) || 1;
+ const nx = -dy / len;
+ const ny = dx / len;
+ side[0].push({ x: points[i].x + nx * w, y: points[i].y + ny * w });
+ side[1].push({ x: points[i].x - nx * w, y: points[i].y - ny * w });
+ }
+ ctx.save();
+ tracePolyline(ctx, [...side[0], ...side[1].reverse()], true);
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.restore();
+}
+
+/** An eyebrow: a short arc above the eye, mirrored between the two eyes so a
+ * positive `angle` always means "inner end down" (an angry brow). */
+function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) {
+ const brow = style.brow;
+ if (!brow?.enabled) return;
+ const layout = EYE_LAYOUT.brow;
+ // 0 is allowed: a zero-length stroke with round caps is a dot, which is what a
+ // length of 0 is asking for.
+ const length = Math.max(0, (brow.length ?? 1) * layout.length);
+ const thickness = Math.max(1, (brow.thickness ?? 1) * layout.thickness);
+ const cy = eye.cy - layout.lift - (brow.height ?? 0) - (spec.browHeight ?? 0);
+ // `spacing` widens the gap between the two brows: a positive value moves each
+ // one away from the nose. `layout.offsetX` is the resting inset from the
+ // artwork (which the old code read off the wrong object, so it never applied).
+ const cx = eye.cx + eye.towardNose * (layout.offsetX - (brow.spacing ?? 0));
+
+ // A supplied drawing (assets/brows/gol-right.png, the model's right brow only)
+ // replaces the strokes: like the textured ひげ it is drawn twice, mirrored, at
+ // `cx`, and tinted with the brow colour.
+ const entry = style.brows?.gol;
+ if (brow.image && entry?.image) {
+ const sprite = tintedSprite(entry.image, brow.color ?? '#2a1e33');
+ const sw = sprite.width || 1;
+ const sh = sprite.height || 1;
+ const drawW = Math.max(4, length);
+ const drawH = drawW * (sh / sw);
+ ctx.save();
+ ctx.translate(cx, cy);
+ // The drawing is the model's right brow, so the other eye gets it mirrored.
+ ctx.scale(eye.towardNose >= 0 ? 1 : -1, 1);
+ ctx.drawImage(sprite, -drawW / 2, -drawH / 2, drawW, drawH);
+ ctx.restore();
+ return;
+ }
+ const angle = (((brow.angle ?? 0) + (spec.browAngle ?? 0)) * eye.towardNose * Math.PI) / 180;
+ const half = length / 2;
+ const from = { x: cx - Math.cos(angle) * half, y: cy - Math.sin(angle) * half };
+ const to = { x: cx + Math.cos(angle) * half, y: cy + Math.sin(angle) * half };
+ const bow = (brow.curve ?? layout.curve) * length;
+ const points = quadraticPoints(from, { x: cx, y: cy - bow }, to, 16);
+ // A brow raised with the height slider used to run off the top of the canvas,
+ // where the clamped edge row was copied across the whole plate and the brow
+ // smeared upwards. Keep the whole stroke inside the artwork instead.
+ const top = limits.top + EDGE_MARGIN;
+ const highest = Math.min(...points.map((q) => q.y)) - thickness / 2;
+ if (highest < top) for (const q of points) q.y += top - highest;
+ // `taper` is how wide the *inner* (nose-side) end is: 1 = a plain stroke, and
+ // lower values turn the brow into a wedge that comes to a point at the nose.
+ const taper = clamp(brow.taper ?? 1, 0, 1);
+ const color = brow.color ?? style.line ?? '#55386e';
+ // A tapered wedge has no shape at zero length, so a dot always takes the plain
+ // round-capped stroke.
+ if (taper >= 0.999 || length < 2) {
+ strokePolyline(ctx, points, { color, width: thickness });
+ return;
+ }
+ const innerIsTo = eye.towardNose > 0;
+ taperedStroke(
+ ctx,
+ points,
+ thickness * (innerIsTo ? 1 : taper),
+ thickness * (innerIsTo ? taper : 1),
+ color,
+ );
+}
+
+/* ------------------------------------------------------- ほっぺ / 頭の模様 */
+
+/**
+ * ほっぺ: a soft pink patch crossed by short diagonal hatch strokes, the classic
+ * manga blush. Drawn once per cheek; the caller draws these before the eyes.
+ */
+function drawCheek(ctx, cx, cy, spec) {
+ const size = clamp(spec.size ?? 1, 0.2, 3);
+ const r = CHEEK_LAYOUT.radius * size;
+ const ry = r * CHEEK_LAYOUT.squash;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.ellipse(cx, cy, r, ry, 0, 0, TAU);
+ ctx.fillStyle = spec.color ?? '#f6a6b8';
+ ctx.fill();
+ // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge.
+ ctx.clip();
+
+ // Four horizontal strokes, hand-drawn rather than ruled: each tapers to a
+ // point at both ends and wanders a little. Each one stops short of the patch
+ // outline - the margin is measured against the patch's own width at that
+ // height, so a stroke on the narrow top or bottom cannot reach the edge.
+ const thickness = Math.max(1.5, CHEEK_LAYOUT.lineWidth * size);
+ ctx.fillStyle = spec.hatchColor ?? '#e0708f';
+ for (let i = 0; i < CHEEK_LAYOUT.lines; i += 1) {
+ const t = CHEEK_LAYOUT.lines === 1 ? 0 : (i / (CHEEK_LAYOUT.lines - 1)) * 2 - 1; // -1..1
+ const dy = t * ry * CHEEK_LAYOUT.spread;
+ const chord = r * Math.sqrt(Math.max(0, 1 - (dy / ry) ** 2));
+ const half = chord - r * CHEEK_LAYOUT.margin;
+ if (half < 4) continue;
+ traceHandHatch(ctx, cx, cy + dy, half, thickness, i * 1.9);
+ }
+ ctx.restore();
+}
+
+/**
+ * One hand-drawn hatch stroke: a lens-shaped mark that tapers to a point at both
+ * ends, with a gentle wobble so it reads as drawn rather than printed.
+ */
+function traceHandHatch(ctx, cx, cy, half, thickness, seed) {
+ const steps = 12;
+ const top = [];
+ const bottom = [];
+ for (let i = 0; i <= steps; i += 1) {
+ const u = i / steps;
+ const x = cx + (u - 0.5) * 2 * half;
+ const wobble = Math.sin(u * Math.PI * 1.6 + seed) * thickness * CHEEK_LAYOUT.wobble;
+ const w = (thickness / 2) * Math.sin(u * Math.PI); // 0 at both ends
+ top.push({ x, y: cy + wobble - w });
+ bottom.push({ x, y: cy + wobble + w });
+ }
+ ctx.beginPath();
+ ctx.moveTo(top[0].x, top[0].y);
+ for (const point of top.slice(1)) ctx.lineTo(point.x, point.y);
+ for (let i = bottom.length - 1; i >= 0; i -= 1) ctx.lineTo(bottom[i].x, bottom[i].y);
+ ctx.closePath();
+ ctx.fill();
+}
+
+/** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */
+function drawCheeks(ctx, spec) {
+ if (!spec?.enabled) return;
+ const drop = CHEEK_LAYOUT.drop + clamp(spec.offsetY ?? 0, -400, 400);
+ const outward = CHEEK_LAYOUT.outward + clamp(spec.spacing ?? 0, -400, 400);
+ for (const layout of EYE_LAYOUT.eyes) {
+ // Outwards is away from the midline, i.e. the opposite of `towardNose`.
+ const cx = layout.cx - layout.towardNose * outward;
+ drawCheek(ctx, cx, layout.cy + drop, spec);
+ }
+}
+
+/**
+ * 頭の模様: a hair-like covering over the whole head.
+ *
+ * The face plate is a shell of the front half of the body, so its own edge is
+ * the head's silhouette. This fills the plate from its top edge down to an
+ * adjustable boundary, which is what makes the covering follow the head outline
+ * without knowing the head's shape ahead of time. The boundary style, colour and
+ * size are all adjustable, and one style (`split`) divides the head into two
+ * colours along a vertical curve - a generic "two-tone head" device. It is not a
+ * copy of any particular character's hair.
+ */
+function drawHeadCover(ctx, spec, limits) {
+ const shape = spec?.shape ?? 'off';
+ if (shape === 'off' || shape === 'none' || shape === 'wave' || shape === 'side') return;
+
+ const size = clamp(spec.size ?? 1, 0.02, 3);
+ const left = limits.left;
+ const right = limits.right;
+ const top = limits.top;
+ const bottom = limits.bottom;
+ const width = right - left;
+ const cx = (left + right) / 2 + clamp(spec.offsetX ?? 0, -600, 600);
+ const color = spec.color ?? '#8a4fe0';
+ const color2 = spec.color2 ?? '#ffffff';
+
+ ctx.save();
+ // Stay on the plate: nothing may spill past the artwork's real edges.
+ ctx.beginPath();
+ ctx.rect(left, top, width, bottom - top);
+ ctx.clip();
+
+ if (shape === 'split') {
+ // Retired: the two-tone head was dropped, but an old saved state may still
+ // carry the shape, so treat it as off rather than drawing a two-tone head.
+ ctx.restore();
+ return;
+ }
+
+ // The boundary curve. `t` runs -1..1 across the plate, so each style is a
+ // small shape function; the covering is everything above it.
+ const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach
+ + clamp(spec.offsetY ?? 0, -600, 600);
+ const halfSpan = width / 2;
+ const boundary = (x) => {
+ const t = clamp((x - cx) / halfSpan, -1, 1);
+ // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front
+ // gives the teeth; `teeth` is how far they reach down (its own slider), so
+ // the 大きさ slider only moves the hairline up and down.
+ if (shape === 'fringe') {
+ const teeth = 8;
+ const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle
+ return base + (spec.teeth ?? 0.12) * 600 * saw;
+ }
+ // はちわれ: the same V upside down - a widow's peak.
+ if (shape === 'fringe-up') return base - 320 * Math.max(0, 1 - Math.abs(t) * 1.5);
+ // カーブ: a plain shallow curve, higher (more hair) round the sides.
+ if (shape === 'curve') return base + 160 * t * t;
+ // `cap`: a rounded helmet, lowest in the middle.
+ return base - 100 * (1 - t * t);
+ };
+
+ const steps = 48;
+ ctx.beginPath();
+ ctx.moveTo(left, top);
+ ctx.lineTo(right, top);
+ for (let i = steps; i >= 0; i -= 1) {
+ const x = left + (width * i) / steps;
+ ctx.lineTo(x, boundary(x));
+ }
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.fill();
+ ctx.restore();
+}
+
+/**
+ * 頭の模様 drawn onto the hair plate, which wraps right round the head.
+ *
+ * The plate's canvas is the cylindrical UV unrolled: `x` is the angle (front at
+ * the middle, the seam at the back at both ends) and `y` is the height. The GLB
+ * flips V on export, so the top of the head lands at the top of the canvas and
+ * the covering is everything above the boundary line - the same direction as
+ * `drawHeadCover`, which fills down from the top edge of the front plate.
+ *
+ * The same `shape` names are reused, with their front-to-back modulation tied to
+ * how far the column leans towards the front (`t`).
+ */
+export function drawHeadCoverWrap(ctx, spec, layout) {
+ const shape = spec?.shape ?? 'off';
+ // `split`, `wave` and `side` are retired front-plate shapes; they have no wrap
+ // equal. An old saved state may still carry one, so it simply draws no hair.
+ if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave' || shape === 'side') return;
+
+ const size = clamp(spec.size ?? 1, 0.02, 3);
+ const W = layout.width;
+ const H = layout.height;
+ // The horizontal axis is the angle. The *front* sits at u = 0.5, which is the
+ // middle of the artwork window (1024), not the middle of the canvas: after the
+ // seam heal the canvas is wider than the window, so using W/2 put the pattern
+ // a good way off centre.
+ const span = (layout.window ?? ART_WINDOW).width;
+ // After the GLB's V-flip the wrap's v is 0 at the top of the head and 1 at the
+ // model's feet, so a *larger* boundary means more hair (it reaches further
+ // down) - the same direction as `size` and `offsetY` on the front plate.
+ const base = HAIR_WRAP_LAYOUT.base
+ + (size - 1) * HAIR_WRAP_LAYOUT.reach
+ + clamp(spec.offsetY ?? 0, -600, 600) * HAIR_WRAP_LAYOUT.offsetScale;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(0, 0, W, H);
+ ctx.clip();
+
+ // `t` is -1..1 across the front (+-90 deg around the front); everything behind
+ // is pinned to +-1, so the hairline runs flat round the back of the head.
+ const half = span * 0.25;
+ const cx = layout.offsetX + span * 0.5 + clamp(spec.offsetX ?? 0, -600, 600);
+ const vBound = (x) => {
+ const t = clamp((x - cx) / half, -1, 1);
+ // まえがみ: 富士額 / M字 (a widow's peak). The hairline comes down at the
+ // centre (the peak) and at the temples, with the corners between receded -
+ // the three peaks and two valleys of an "M". `t` is -1..1 across the front.
+ if (shape === 'fringe') {
+ // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front
+ // gives the teeth; `teeth` is how far they reach down (its own slider), so
+ // the 大きさ slider only moves the hairline up and down.
+ const teeth = 8;
+ const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle
+ return base + (spec.teeth ?? 0.12) * saw;
+ }
+ // はちわれ: the same V upside down - a widow's peak.
+ if (shape === 'fringe-up') return base - 0.18 * Math.max(0, 1 - Math.abs(t) * 1.5);
+ // カーブ: a deep round cover - low at the sides, high in the middle, like the
+ // blue of a certain robot cat's head.
+ if (shape === 'curve') return base + 0.42 * t * t;
+ return base - 0.07 * (1 - t * t); // `cap`
+ };
+
+ const steps = Math.max(96, Math.ceil(W / 2));
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ for (let i = 0; i <= steps; i += 1) {
+ const x = (W * i) / steps;
+ ctx.lineTo(x, clamp(vBound(x), 0, 1) * H);
+ }
+ ctx.lineTo(W, 0);
+ ctx.closePath();
+ // A white mask: the colour is applied by the hair plate's material, so it goes
+ // through the same colour path as the body instead of being baked into the
+ // canvas (which shaded differently on iOS).
+ ctx.fillStyle = '#ffffff';
+ ctx.fill();
+ ctx.restore();
+}
+
+/**
+ * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes,
+ * so the eyes, brows and glasses win any overlap and the cheeks are not doubled.
+ */
+function drawFaceExtras(ctx, style, lineOnly) {
+ drawCheeks(ctx, style.cheeks);
+ // The head covering fills out to the plate's real edges, so it needs `limits`.
+ drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS);
+ drawBeard(ctx, style.beard, lineOnly, style.beards);
+}
+
+const BEARD_TINT_IDS = new WeakMap();
+const BEARD_TINT_CACHE = new Map();
+let beardTintSeq = 0;
+
+/**
+ * A copy of a beard drawing in the chosen colour.
+ *
+ * The supplied files are drawn in near-black, so repainting the ink with the
+ * beard colour (`source-in` keeps the anti-aliased alpha) is what lets the colour
+ * picker work on a drawing. Cached per (sprite, colour).
+ */
+function tintedSprite(image, color) {
+ let id = BEARD_TINT_IDS.get(image);
+ if (!id) {
+ beardTintSeq += 1;
+ id = beardTintSeq;
+ BEARD_TINT_IDS.set(image, id);
+ }
+ const key = `${id}|${color}`;
+ const cached = BEARD_TINT_CACHE.get(key);
+ if (cached) return cached;
+ const canvas = document.createElement('canvas');
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(image, 0, 0);
+ ctx.globalCompositeOperation = 'source-in';
+ ctx.fillStyle = color;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ BEARD_TINT_CACHE.set(key, canvas);
+ return canvas;
+}
+
+/**
+ * ひげ: a mustache or beard under the nose. Each kind is a small shape built on
+ * the spot; `scotch` and `cat` are drawn with round-capped strokes, and `kaiser`
+ * is a thick waving band with a curl at each tip. A kind that ships a drawing in
+ * `assets/beards/` is drawn from that instead (see `textures`), tinted with the
+ * beard colour. In a line drawing nothing is filled and only the outlines are inked.
+ */
+function drawBeard(ctx, spec, lineOnly = false, textures = null) {
+ const shape = spec?.shape ?? 'off';
+ if (shape === 'off' || shape === 'none') return;
+ const size = clamp(spec.size ?? 1, 0.2, 3);
+ const cx = BEARD_LAYOUT.cx;
+ const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600);
+ const w = BEARD_LAYOUT.width * size;
+ const color = spec.color ?? '#3a2a4a';
+
+ // A supplied drawing for this kind (assets/beards/.png) replaces the
+ // built-in strokes. The drawings are trimmed to their ink, so `w` is the
+ // beard's real width; it is centred on the nose and scaled by `size`, and it is
+ // tinted with the beard colour (the files are drawn in near-black).
+ const entry = textures?.[shape];
+ if (entry?.image) {
+ const sprite = tintedSprite(entry.image, color);
+ // Slightly wider than the built-in base: a moustache drawn to the same width
+ // as a beard's stroke sat mostly behind the nose.
+ const drawW = w * 1.4;
+ if (entry.half) {
+ // One side, mirrored to make the pair. The drawing is the model's own right
+ // side, which sits in the artwork's left half; `spacing` opens the middle
+ // (a negative value brings the two halves together).
+ const halfW = drawW / 2;
+ const dh = (halfW * (sprite.height || 1)) / (sprite.width || 1);
+ const gap = clamp(spec.spacing ?? 0, -400, 800);
+ const y = cy - dh / 2;
+ ctx.drawImage(sprite, cx - gap - halfW, y, halfW, dh);
+ ctx.save();
+ ctx.translate(cx + gap, cy);
+ ctx.scale(-1, 1);
+ // Draw into [-halfW, 0] so that, mirrored, it lands in [cx+gap, cx+gap+halfW].
+ ctx.drawImage(sprite, -halfW, -dh / 2, halfW, dh);
+ ctx.restore();
+ return;
+ }
+ const dh = (drawW * (sprite.height || 1)) / (sprite.width || 1);
+ ctx.drawImage(sprite, cx - drawW / 2, cy - dh / 2, drawW, dh);
+ return;
+ }
+
+ const line = spec.line ?? '#55386e';
+ const lw = Math.max(2, 9 * size);
+ const stroke = (points, width) => strokePolyline(ctx, points, {
+ color: lineOnly ? line : color, width,
+ });
+
+ if (shape === 'scotch') {
+ // ちょび髭: two short dashes under the nose, angled down and out so their tips
+ // clear the nose mesh (which hides anything drawn straight behind it).
+ stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
+ stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
+ return;
+ }
+ if (shape === 'kaiser') {
+ // カイゼル: a full, thick mustache whose ends turn up.
+ stroke([
+ { x: cx - w * 0.5, y: cy - w * 0.02 },
+ { x: cx - w * 0.28, y: cy + w * 0.07 },
+ { x: cx, y: cy + w * 0.09 },
+ { x: cx + w * 0.28, y: cy + w * 0.07 },
+ { x: cx + w * 0.5, y: cy - w * 0.02 },
+ ], lw * 2.6);
+ const curlAt = (dir) => {
+ // A little inward-turning spiral at the tip: the Dalí curl.
+ const ex = cx + dir * w * 0.5;
+ const ey = cy - w * 0.02;
+ const r0 = lw * 0.5;
+ const r1 = w * 0.17;
+ const steps = 22;
+ const points = [];
+ for (let i = 0; i <= steps; i += 1) {
+ const t = i / steps;
+ const a = -Math.PI / 2 + dir * Math.PI * 2 * 1.4 * t;
+ const r = r0 + (r1 - r0) * t;
+ points.push({ x: ex + dir * Math.cos(a) * r, y: ey + Math.sin(a) * r });
+ }
+ stroke(points, lw * 1.25);
+ };
+ curlAt(-1);
+ curlAt(1);
+ return;
+ }
+ if (shape === 'apron') {
+ // A filled bib was once a kind here; kept as a no-op so a saved state that
+ // still names it simply shows nothing rather than throwing.
+ return;
+ }
+ // ねこ: three whiskers a side, growing out of the cheeks with a gap left in the
+ // middle (like a cat's). The thickness is fixed, so `size` makes the whiskers
+ // longer and wider-spread without making them fatter, and `spacing` opens the
+ // gap between the left and right.
+ const whisker = 8;
+ const spacing = clamp(spec.spacing ?? 0, 0, 800);
+ const inner = w * 0.30 + spacing;
+ // `length` scales how far each whisker reaches out; `lineGap` opens the spacing
+ // between the three lines of one side. Neither changes the line's thickness.
+ const length = clamp(spec.length ?? 1, 0.2, 3);
+ const gap = w * 0.16 * clamp(spec.lineGap ?? 1, 0.2, 3);
+ const outer = inner + w * 0.38 * length;
+ for (const dir of [-1, 1]) {
+ for (let i = 0; i < 3; i += 1) {
+ stroke([
+ { x: cx + dir * inner, y: cy + (i - 1) * gap },
+ { x: cx + dir * outer, y: cy + (i - 1) * gap * 1.5 },
+ ], whisker);
+ }
+ }
+}
+
+/**
+ * One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its
+ * temple.
+ *
+ * Called once per eye from `drawEyes`, after the eyeball (or the shut-eye artwork)
+ * so the lens sits in front of the eye, and before that eye's tear so a teardrop
+ * falls past the lens. Nothing about a pair of glasses is per-eye, so the two
+ * halves are mirrored from `eye.towardNose` and meet at the face's midline. The
+ * kind picks the shape: a round 眼鏡 lens, or a wide angular サングラス one.
+ *
+ * `style.glasses` is the shared spec. In a line drawing the lens is left empty, so
+ * the paper behind shows through exactly as the eyeball's white does (see the note
+ * in `drawEyes`); the frames are then stroked in the line colour.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {{cx:number,cy:number,r:number,towardNose:number}} eye
+ * @param {boolean} line true in 線画 mode
+ * @param {object} style the bag `drawEyes` received
+ * @param {{top:number,bottom:number,left:number,right:number}} limits
+ */
+function drawGlasses(ctx, eye, line, style, limits) {
+ const spec = style.glasses;
+ if (!spec?.enabled) return;
+
+ const r = eye.r;
+ const scale = clamp(spec.scale ?? 1, 0.2, 3);
+ // The two kinds share everything about *where* they sit but not their shape:
+ // 眼鏡 is a round lens, サングラス a wide angular one (see the layouts above).
+ const shades = spec.kind === 'sunglasses';
+ const L = shades ? SUNGLASSES_LAYOUT : GLASSES_LAYOUT;
+ const rx = r * L.widthFactor * scale;
+ const ry = rx * L.heightFactor;
+ const frame = Math.max(
+ 1,
+ (spec.frameWidth ?? 1) * EYE_LAYOUT.lidStroke * (shades ? L.frameFactor : 1),
+ );
+ const colour = line ? (style.line ?? '#55386e') : (spec.frameColor ?? '#2a1e33');
+ // The outward direction (towards the temple) for this eye. The angular lens is
+ // built in a local frame whose u runs outwards, so the two sides mirror exactly.
+ const out = -eye.towardNose;
+ // The pointed outer corner makes the shades taller on that side; clamp against
+ // the full height so even that point cannot reach the canvas border.
+ const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry;
+
+ // The face's midline: the two eyes are symmetric about it, so it is where the
+ // two halves of the bridge meet and what `tilt` turns the pair about.
+ const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2;
+ // `lensGap` slides the two lenses apart (positive) or together (negative) along
+ // the face, symmetrically about the midline. It is clamped so the inner edges may
+ // meet at the midline but the two lenses can never cross each other.
+ const inwardRoom = Math.max(0, Math.abs(midX - eye.cx) - rx);
+ const gap = clamp(spec.lensGap ?? 0, -inwardRoom, HUGE);
+ // Keep the lens (and so everything that hangs off it) inside the artwork. The
+ // rest of the file clamps against `limits` for the same reason: a mark that
+ // reaches the canvas border gets the edge row copied across the whole plate.
+ const lensCx = clamp(
+ eye.cx + out * gap,
+ limits.left + EDGE_MARGIN + rx,
+ limits.right - EDGE_MARGIN - rx,
+ );
+ const lensCy = clamp(
+ eye.cy + L.drop + (spec.offsetY ?? 0),
+ limits.top + EDGE_MARGIN + halfV,
+ limits.bottom - EDGE_MARGIN - halfV,
+ );
+ // Local (u, v) -> canvas, with u outwards and v downwards.
+ const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v });
+
+ ctx.save();
+ // `tilt` leans the whole pair at once. Rotating about the midline keeps the
+ // bridge centred between the lenses instead of swinging it off to one side.
+ if (spec.tilt) {
+ ctx.translate(midX, lensCy);
+ ctx.rotate((clamp(spec.tilt, -90, 90) * Math.PI) / 180);
+ ctx.translate(-midX, -lensCy);
+ }
+
+ // --- lens ------------------------------------------------------------
+ // The one path is filled and then stroked, so the fill and the frame can never
+ // drift apart.
+ if (shades) {
+ // A long cat-eye, not an ellipse: the outer end starts low, rises to a
+ // pointed, lifted outer corner set further out, and the top edge sweeps back
+ // towards the nose. The inner end is short, so the lens tapers inwards.
+ const lens = [
+ at(-rx, -ry * L.innerTop), // inner top (near the nose)
+ at(rx * L.tipX, -ry * (L.innerTop + L.topSkew)), // pointed, lifted outer corner
+ at(rx * L.outerEndX, ry * L.outerBottom), // low outer end
+ at(-rx, ry * L.innerBottom), // inner bottom (pinched towards the nose)
+ ];
+ traceRoundedPolygon(ctx, lens, ry * L.corner);
+ if (!line) {
+ ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1);
+ ctx.fillStyle = spec.lensColor ?? '#2b2433';
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = colour;
+ ctx.lineWidth = frame;
+ ctx.stroke();
+ } else {
+ ctx.beginPath();
+ ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU);
+ ctx.closePath();
+ if (!line) {
+ ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1);
+ ctx.fillStyle = spec.lensColor ?? '#2b2433';
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = colour;
+ ctx.lineWidth = frame;
+ ctx.stroke();
+ }
+
+ // --- bridge ----------------------------------------------------------
+ // Each eye draws the half of the bridge from its own lens to the midline.
+ const innerX = eye.towardNose > 0
+ ? Math.min(lensCx + rx, midX)
+ : Math.max(lensCx - rx, midX);
+ if (shades) {
+ // A short, thick, level bar high on the lenses: with the two lenses it reads
+ // as one continuous visor across the eyes.
+ const barY = lensCy - ry * L.bridgeLift;
+ strokePolyline(ctx, [{ x: innerX, y: barY }, { x: midX, y: barY }], {
+ color: colour,
+ width: frame * L.bridgeWidth,
+ });
+ } else {
+ // The two halves meet at the midline with a horizontal tangent, so the join is smooth.
+ const noseX = lensCy - L.bridgeRise;
+ strokePolyline(ctx, quadraticPoints(
+ { x: innerX, y: lensCy },
+ { x: (innerX + midX) / 2, y: noseX },
+ { x: midX, y: noseX },
+ 12,
+ ), { color: colour, width: frame * L.bridgeWidth });
+ }
+
+ // --- temple ----------------------------------------------------------
+ // A stub outwards from the outer edge of the lens. It stays short on purpose:
+ // the plate only reaches so far, and a temple that ran off it would smear. The
+ // round pair's outer edge is a vertical line at the lens centre height. The
+ // shades' outer end drops low, so their arm leaves from the *top* edge instead
+ // - from the pointed outer corner - which is where a cat-eye's arm attaches.
+ const outerX = shades
+ ? lensCx - eye.towardNose * rx * L.tipX
+ : lensCx - eye.towardNose * rx;
+ const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy;
+ const reach = clamp(
+ outerX - eye.towardNose * r * L.templeLength,
+ limits.left + EDGE_MARGIN,
+ limits.right - EDGE_MARGIN,
+ );
+ strokePolyline(ctx, [
+ { x: outerX, y: outerY },
+ {
+ x: eye.towardNose > 0 ? Math.min(reach, outerX) : Math.max(reach, outerX),
+ y: clamp(outerY - L.templeRise, limits.top + EDGE_MARGIN, limits.bottom - EDGE_MARGIN),
+ },
+ ], { color: colour, width: frame });
+
+ ctx.restore();
+}
+
+/**
+ * Draw the pair of eyes into a 1024 x 380 canvas.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} p
+ * @param {'paint'|'line'} [p.mode] paint = filled cartoon eyes, line = outline only
+ * @param {object} p.eyes `{ left, right }`, each `{ open, lookX, lookY, closed }`
+ * @param {object} [p.style] colours and shared shaping parameters
+ */
+/**
+ * Which artwork an eye shows: 'open', 'heart', 'line1'..'line3', 'three' or
+ * 'arch'. `shape` is the explicit choice; older saved states and the おまかせ
+ * rolls only set `open`/`closed`/`irisShape`, so this falls back to those.
+ */
+function eyeShapeKey(spec) {
+ const shape = spec.shape;
+ const explicitShut = shape === 'three' || shape === 'arch'
+ || (typeof shape === 'string' && shape.startsWith('line'));
+ // An explicit shut artwork is used as-is; the lid height only trims it.
+ if (explicitShut) return shape;
+ // A lowered lid on an open or heart eye reads as a shut line - a blink. It must
+ // NOT borrow a 3 or an arch from a stale `closed`, which used to flash on screen
+ // for a frame when blinking after picking 3/わらう and then switching to heart.
+ if ((spec.open ?? 1) <= 0.02) {
+ if (!shape) {
+ // Older saved states have no `shape`; derive the shut artwork from `closed`.
+ if (spec.closed === 'three') return 'three';
+ if (spec.closed === 'arch') return 'arch';
+ return `line${clamp(Math.round(spec.closedLines ?? 1), 1, 3)}`;
+ }
+ return 'line1';
+ }
+ if (shape) return shape;
+ return (spec.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
+}
+
+export function drawEyes(ctx, p) {
+ const mode = p.mode ?? 'paint';
+ const line = mode === 'line';
+ const style = p.style ?? {};
+ const limits = style.limits ?? WINDOW_LIMITS;
+ const white = style.white ?? '#ffffff';
+ const irisColor = style.iris ?? '#150e1b';
+ const lineColor = style.line ?? '#55386e';
+ const lookMax = style.lookMax ?? 1;
+ const heartScale = 2;
+ const heartColor = style.heartColor ?? '#e0344f';
+ // A lens that is effectively opaque hides the eye completely, so a blink would
+ // show only as a flicker of the shut artwork through the lens. While such a
+ // lens is on, the eyes are drawn open and no blink is visible at all. This is a
+ // rendering decision, not an animation one - the blink still runs underneath.
+ const lensOpaque = style.glasses?.enabled === true
+ && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99;
+
+ // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here
+ // rather than inside the per-eye loop below. 鼻ちょうちん comes along too.
+ drawFaceExtras(ctx, style, line);
+
+ for (const layout of EYE_LAYOUT.eyes) {
+ const spec = (p.eyes ?? {})[layout.key] ?? {};
+ const eye = {
+ // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own.
+ cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400),
+ cy: layout.cy,
+ r: EYE_LAYOUT.radius,
+ towardNose: layout.towardNose,
+ };
+ // Each lid can be set per eye, falling back to the shared value.
+ const lidWidth = spec.lidWidth ?? style.lidWidth ?? EYE_LAYOUT.lidStroke;
+ const lidFlat = (spec.lidShape ?? style.lidShape ?? 'curve') === 'flat';
+ // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint).
+ const lidTilt = clamp(spec.lidTilt ?? style.lidTilt ?? 0, -45, 45);
+ const lineStyle = { line: lineColor, width: lidWidth };
+ // Which artwork this eye shows. `shape` is the explicit choice; older saved
+ // states and the おまかせ rolls only set `open`/`closed`, so fall back.
+ const shapeKey = eyeShapeKey(lensOpaque ? { ...spec, open: 1 } : spec);
+ const explicitShut = typeof spec.shape === 'string'
+ && (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line'));
+ const isHeart = shapeKey === 'heart';
+ const isShutShape = shapeKey === 'three' || shapeKey === 'arch' || shapeKey.startsWith('line');
+ // The shut artwork reads `closed`/`closedLines`; build them from `shape` so a
+ // line, the 3 and the arch share one path with the older fields.
+ const shapeSpec = isShutShape
+ ? {
+ ...spec,
+ closed: shapeKey === 'three' ? 'three' : (shapeKey === 'arch' ? 'arch' : 'line'),
+ closedLines: shapeKey.startsWith('line') ? Number(shapeKey.slice(4)) : (spec.closedLines ?? 1),
+ }
+ : spec;
+ // The upper lid (`open`) and the lower lid are independent, and both shape
+ // every eye now: the shut artwork is drawn as the eye's content and is
+ // trimmed by them. A shut eye saved the old way stored open = 0 meaning
+ // "shut"; lift it or the artwork would be clipped away. An explicit shut
+ // `shape` keeps its own lid height, so the lid can be lowered onto it.
+ const rawOpen = spec.open ?? 1;
+ const open = lensOpaque
+ ? 1
+ : clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1);
+ const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1);
+ // 大きさ: one knob for the iris, the heart and the shut artwork.
+ const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2);
+ const irisRadius = EYE_LAYOUT.irisRadius * shapeScale;
+ // `white` is tri-state: only the plain open eye shows it by default.
+ const showWhite = spec.white != null ? spec.white === true : shapeKey === 'open';
+ // While the white is shown, a shape that would poke out of the eyeball is
+ // scaled back inside it; `shapeHalfLimit` is the half-size it may reach.
+ const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity;
+
+ drawBrow(ctx, eye, spec, style, limits);
+
+ // --- tear ----------------------------------------------------------
+ // A teardrop hangs below the eye, so it is drawn on top of everything else
+ // (the opaque eyeball would otherwise cover it) and for a shut eye too. The
+ // amount, the height and the tilt are all *per eye*, because one eye crying
+ // while the other does not is a real expression.
+ const drawTear = () => {
+ if (spec.tearOn !== true) return;
+ const amount = clamp(spec.tear ?? 0.3, 0, 1.6);
+ if (amount <= 0.01) return;
+ const size = EYE_LAYOUT.tear.size * amount;
+ const tearX = eye.cx + clamp(spec.tearX ?? 0, -400, 400) - layout.towardNose * EYE_LAYOUT.tear.offsetX;
+ let tearY = eye.cy + EYE_LAYOUT.tear.offsetY + (spec.tearY ?? 0);
+ // The drop hangs from `cy - 1.32r` to `cy + 0.78r`. Keep its lower tip
+ // inside the artwork: a drop that reached the canvas border used to be
+ // smeared down the chin by the clamp.
+ const tearBottom = limits.bottom - EDGE_MARGIN;
+ if (tearY + size * 0.78 > tearBottom) tearY = tearBottom - size * 0.78;
+ ctx.save();
+ if (spec.tearTilt) {
+ ctx.translate(tearX, tearY);
+ ctx.rotate((spec.tearTilt * Math.PI) / 180);
+ ctx.translate(-tearX, -tearY);
+ }
+ dropPath(ctx, tearX, tearY, size);
+ if (!line) {
+ ctx.fillStyle = style.tearColor ?? '#8fd8ff';
+ ctx.fill();
+ }
+ ctx.strokeStyle = lineColor;
+ ctx.lineWidth = lidWidth * 0.55;
+ ctx.stroke();
+ ctx.restore();
+ };
+
+ // How far the gaze can push the content. A heart (or a shut line shown on the
+ // white) is much bigger than the iris, so it gets a smaller travel - and with
+ // the white shown the content is also clipped to the eyeball - so it never
+ // pokes outside the white.
+ const heartFit = isHeart && showWhite
+ ? Math.min(1, shapeHalfLimit / (EYE_LAYOUT.irisRadius * heartScale))
+ : 1;
+ const heartRadius = Math.min(
+ EYE_LAYOUT.irisRadius * heartScale * shapeScale * heartFit,
+ EYE_LAYOUT.radius * 2.1,
+ );
+ let travelBase = irisRadius;
+ if (isHeart) travelBase = heartRadius;
+ const eyeTravel = Math.max(0, EYE_LAYOUT.radius - travelBase) * lookMax;
+ let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel;
+ let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel;
+ const dist = Math.hypot(dx, dy);
+ if (dist > eyeTravel && dist > 0) {
+ dx = (dx / dist) * eyeTravel;
+ dy = (dy / dist) * eyeTravel;
+ }
+
+ // --- the eye's content, clipped by the lids -------------------------
+ // Every shape is drawn the same way now: a white backing, then the artwork
+ // (iris, heart or the shut line/3/arch), trimmed by the upper and lower lids.
+ // The shut artwork and a heart both reach past the eyeball, so they are only
+ // lid-clipped - but once the white is shown they are clipped to the eyeball
+ // too, so they stay inside it. Clipping the heart by its *lids* rather than by
+ // the eyeball is also what lets a raised lower lid cover it from below: the
+ // heart replaces the eyeball, but it must still sit behind both lids.
+ ctx.save();
+ if ((isShutShape || isHeart) && !showWhite) {
+ clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
+ } else if (!isHeart || open < 0.999 || showWhite) {
+ clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
+ }
+ if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
+ if (isShutShape) {
+ ctx.translate(dx, dy);
+ drawShutEye(ctx, eye, shapeSpec, lineStyle, shapeScale, shapeHalfLimit);
+ } else if (isHeart) {
+ fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor);
+ } else {
+ const irisX = eye.cx + dx + eye.towardNose * EYE_LAYOUT.irisInward;
+ const irisY = eye.cy + dy;
+ fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor);
+ // 光彩 (the white glint) is per eye, falling back to the shared switch.
+ const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false;
+ if (highlightOn) {
+ const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * shapeScale;
+ const hy = irisY + EYE_LAYOUT.highlightOffset.y * shapeScale;
+ const hr = EYE_LAYOUT.highlightRadius * shapeScale;
+ if (line) {
+ // Punch the sparkle out of the iris rather than painting it white. On
+ // screen the paper behind shows through, and in the SVG export (which
+ // only sees alpha) it stays a hole in the pupil.
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+ fillCircle(ctx, hx, hy, hr, '#000000');
+ ctx.restore();
+ } else {
+ fillCircle(ctx, hx, hy, hr, white);
+ }
+ }
+ }
+ ctx.restore();
+
+ // Between the eyeball and the tear: the lens covers the eye, and a tear still
+ // falls in front of it.
+ drawGlasses(ctx, eye, line, style, limits);
+
+ drawTear();
+
+ // --- lid strokes ---------------------------------------------------
+ // Faded in as the lid starts to cover the eye, so opening the eye all the
+ // way leaves the clean original artwork with no extra line.
+ const lidFade = clamp((0.95 - open) / 0.1, 0, 1);
+ // The tilt is mirrored between the eyes, so a positive value reads the same
+ // way on both (an inward, angry-looking squint).
+ const eyeTilt = lidTilt * eye.towardNose;
+ // The upper lid line has to sit where the clip put it - and that is set by
+ // `open` alone, so it stays put when the lower lid moves.
+ const upperAmt = clamp(open, 0, 1);
+ // A heart is a replacement for the eyeball, not an eye behind a lid: it keeps
+ // no lower-lid line, which used to cut across the heart.
+ const showLowerLid = lowerLid > 0.01;
+ if (lidFade > 0.01 || showLowerLid) {
+ ctx.save();
+ if (eyeTilt) {
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate((eyeTilt * Math.PI) / 180);
+ ctx.translate(-eye.cx, -eye.cy);
+ }
+ circlePath(ctx, eye.cx, eye.cy, eye.r);
+ ctx.clip();
+ ctx.globalAlpha = line ? 1 : lidFade;
+ ctx.strokeStyle = lineColor;
+ ctx.lineWidth = lidWidth;
+ if (lidFade > 0.01) {
+ lidPath(ctx, eye, upperAmt, false, lidFlat);
+ ctx.stroke();
+ }
+ if (showLowerLid) {
+ ctx.globalAlpha = line ? 1 : lowerLid;
+ lidPath(ctx, eye, lowerLid, true, lidFlat);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ // --- まつげ --------------------------------------------------------
+ // Short strokes flicking outwards from the upper-outer lid. They sit outside
+ // the eyeball, so they are drawn after the lid block and are not clipped.
+ if (spec.lashes ?? style.lashes) {
+ // The lashes ride the upper lid: they drop as the lid lowers (2r per unit of
+ // open) and tilt with まぶたの傾き, so they stay on the lid edge. まつげの位置
+ // slides them along the lid, まつげの角度 tilts the strokes themselves.
+ const outDir = -eye.towardNose;
+ const lidDrop = 2 * eye.r * (1 - open);
+ const lashPos = clamp(spec.lashPos ?? style.lashPos ?? 0, -80, 80);
+ const lashAngle = clamp(spec.lashAngle ?? style.lashAngle ?? 0, -80, 80) * outDir;
+ const a = (lashAngle * Math.PI) / 180;
+ const ca = Math.cos(a);
+ const sa = Math.sin(a);
+ ctx.save();
+ if (eyeTilt) {
+ ctx.translate(eye.cx, eye.cy);
+ ctx.rotate((eyeTilt * Math.PI) / 180);
+ ctx.translate(-eye.cx, -eye.cy);
+ }
+ for (const deg of EYE_LAYOUT.lash.angles) {
+ const t = ((deg + lashPos) * Math.PI) / 180;
+ const ux = outDir * Math.cos(t);
+ const uy = -Math.sin(t);
+ // The stroke points along the radial direction, tilted by まつげの角度.
+ const dx = ux * ca - uy * sa;
+ const dy = ux * sa + uy * ca;
+ const r0 = eye.r * 0.96;
+ const bx = eye.cx + ux * r0;
+ const by = eye.cy + uy * r0 + lidDrop - eye.r * EYE_LAYOUT.lash.raise;
+ strokePolyline(ctx, [
+ { x: bx, y: by },
+ { x: bx + dx * EYE_LAYOUT.lash.length, y: by + dy * EYE_LAYOUT.lash.length },
+ ], { color: lineColor, width: EYE_LAYOUT.lash.width });
+ }
+ ctx.restore();
+ }
+
+ // --- outline (line-art mode only) ---------------------------------
+ if (line) strokeCircle(ctx, eye.cx, eye.cy, eye.r, lineColor, lidWidth * 0.75);
+ }
+}
+
+/* -------------------------------------------------------------------- mouth */
+
+/**
+ * Draw the mouth into a 1024 x 380 canvas.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {object} p
+ * @param {'paint'|'line'} [p.mode]
+ */
+export function drawMouth(ctx, p) {
+ const mode = p.mode ?? 'paint';
+ const line = mode === 'line';
+ const L = MOUTH_LAYOUT;
+ const limits = p.limits ?? WINDOW_LIMITS;
+ // The artwork's real edges, in the artwork's own coordinates, with a margin
+ // kept clear. `top` is negative when the plate reaches above the window, which
+ // is the room a moved mouth has to work with.
+ const edgeLeft = limits.left + EDGE_MARGIN;
+ const edgeRight = limits.right - EDGE_MARGIN;
+ const edgeTop = limits.top + EDGE_MARGIN;
+ const edgeBottom = limits.bottom - EDGE_MARGIN;
+
+ const openAmount = clamp(p.open ?? 0, 0, 1);
+ const thickness = L.thickness * clamp(p.thickness ?? 1, 0.2, 3);
+ const cornerAmount = clamp(p.corners ?? 1, 0, 1.6);
+ // The corner strokes flick out past the ends of the chord, so the ends cannot
+ // use the whole canvas: a very wide mouth used to smear sideways off the plate.
+ const cornerReach = L.corner.toX * cornerAmount;
+ const halfChord = Math.min(
+ L.halfChord * clamp(p.width ?? 1, 0.2, 1.6),
+ Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX),
+ );
+
+ // A negative smile bulges the line upwards, which reads as a frown.
+ const sagRaw = L.sag * clamp(p.smile ?? 1, -0.55, 1.4);
+ // A frown's middle is the highest point of the mouth, and that is exactly
+ // where the nose is in the way, so slide the whole mouth down until the curve
+ // is in the open. The push is worked out from the *base* position and the
+ // height slider is added afterwards: adding it first made the push cancel the
+ // slider exactly, which is why dragging ‟口の高さ” did nothing at all on a
+ // frowning mouth like むっと.
+ const base = L.chordY + Math.max(0, L.noseClear - (L.chordY + sagRaw));
+ let y0 = base + (p.offsetY ?? 0);
+ // The smile flattens slightly as the mouth opens, so the whole mouth keeps
+ // fitting inside the band the mouth plane actually shows.
+ let sag = sagRaw * (1 - L.openFlatten * openAmount);
+ // Keep the whole mark on the canvas. The corner strokes sit *above* the ends of
+ // the chord, so the allowance is not symmetric - padding the bottom by the
+ // corner's depth used to cost most of the height slider's travel.
+ const cornerRise = L.corner.toY * cornerAmount + thickness;
+ const bottomEdge = y0 + Math.max(0, sag) + thickness;
+ if (bottomEdge > edgeBottom) y0 -= bottomEdge - edgeBottom;
+ const topEdge = y0 + Math.min(0, sag) - cornerRise;
+ if (topEdge < edgeTop) y0 += edgeTop - topEdge;
+ const pivot = { x: L.centreX, y: y0 + sag * 0.5 };
+ const tilt = p.tilt ?? 0;
+
+ const strokeColor = line ? (p.line ?? '#3a2a4a') : (p.color ?? '#ff1a44');
+ const innerColor = p.innerColor ?? '#4a0f1e';
+ const tongueColor = line ? (p.line ?? '#3a2a4a') : (p.tongueColor ?? '#ff2d2d');
+
+ const left = { x: L.centreX - halfChord, y: y0 };
+ const right = { x: L.centreX + halfChord, y: y0 };
+ const lip = mouthControls(left, right, sag, L.sagBend);
+ const arc = cubicPoints(lip.p0, lip.p1, lip.p2, lip.p3, 96);
+
+ const tongueAmount = clamp(p.tongue ?? 1, 0, 1.6);
+
+ // --- a round "O" mouth (surprised, singing) ---------------------------
+ const roundAmount = clamp(p.round ?? 0, 0, 1);
+ if (roundAmount > 0.004) {
+ let ry = L.sag * 1.05 * L.roundScale * roundAmount;
+ // Keep the oval below the nose and inside the plate, but allow it to grow to
+ // roughly half the head. The stroke's share is a fixed margin, not the live
+ // `thickness`: otherwise 口の太さ would quietly resize the oval too.
+ ry = Math.min(ry, (edgeBottom - L.thickness / 2 - L.noseClear) / 2.1);
+ const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 1.4);
+ const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - L.thickness / 2 - ry * 1.02);
+ if (ry > 5) {
+ ctx.save();
+ ctx.translate(pivot.x, pivot.y);
+ ctx.rotate((tilt * Math.PI) / 180);
+ ctx.translate(-pivot.x, -pivot.y);
+ ctx.beginPath();
+ ctx.ellipse(L.centreX, centreY, rx, ry, 0, 0, TAU);
+ if (!line) {
+ ctx.fillStyle = innerColor;
+ ctx.fill();
+ if (tongueAmount > 0.01) {
+ ctx.beginPath();
+ ctx.ellipse(L.centreX, centreY + ry * 0.46, rx * 0.52, ry * 0.4, 0, 0, TAU);
+ ctx.fillStyle = tongueColor;
+ ctx.fill();
+ }
+ }
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = line ? thickness * 0.8 : thickness;
+ ctx.stroke();
+ ctx.restore();
+ }
+ return;
+ }
+
+ // The smile line is the upper lip. Opening the mouth drops the lower jaw
+ // below it, and the room for that is limited by the texture band the mouth
+ // plane shows - otherwise the jaw is silently clipped away.
+ // Everything has to fit in the part of the plane that faces the camera.
+ let rise = L.openRise * openAmount;
+ const budget = edgeBottom - thickness / 2 - y0;
+ if (sag + rise > budget) {
+ rise = Math.max(0, budget - Math.min(sag, budget));
+ if (sag + rise > budget) sag = Math.max(-80, budget - rise);
+ }
+ const jawCubic = mouthControls(left, right, sag + rise, L.sagBend);
+ const jaw = cubicPoints(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, 96);
+ const jawAt = (t) => cubicPoint(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, t);
+ const lipAt = (t) => cubicPoint(lip.p0, lip.p1, lip.p2, lip.p3, t);
+ const openShape = rotate([...arc, ...jaw.slice(1, -1).reverse()], pivot, tilt);
+
+ const tonguePos = clamp(p.tonguePos ?? L.tongue.pos, 0.05, 0.95);
+ const tongueWidth = L.tongue.width * tongueAmount;
+ const tongueHeight = L.tongue.height * tongueAmount;
+
+ /**
+ * The tongue, rising from `curve` to `height` above its middle.
+ *
+ * `pointAt(t)` is that same curve as a function of t, so the tongue's foot can
+ * sit along it without this needing to know what kind of curve it is.
+ */
+ function drawTongue(pointAt, curve, height) {
+ const arcLength = Math.max(1, chordLength(curve));
+ const dt = clamp(tongueWidth / 2 / arcLength, 0.01, 0.45);
+ const t0 = clamp(tonguePos - dt, 0, 1);
+ const t1 = clamp(tonguePos + dt, 0, 1);
+ const base = [];
+ for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16));
+ const mid = pointAt(clamp(tonguePos, 0, 1));
+ const half = tongueWidth / 2;
+ // The foot runs left to right; the crown comes back right to left, so the two
+ // together are already a closed ring.
+ const footRight = base[base.length - 1];
+ const footLeft = base[0];
+ const crown = [];
+ const steps = 26;
+ for (let i = 0; i <= steps; i++) {
+ const f = i / steps;
+ const dx = 1 - 2 * f;
+ const footY = footRight.y + (footLeft.y - footRight.y) * f;
+ crown.push({
+ x: mid.x + dx * half,
+ y: footY - height * (1 - Math.pow(Math.abs(dx), L.tongue.crown)),
+ });
+ }
+ const shaped = rotate([...base, ...crown], pivot, tilt);
+ if (line) {
+ strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true });
+ return;
+ }
+ tracePolyline(ctx, shaped, true);
+ ctx.fillStyle = tongueColor;
+ ctx.fill();
+ }
+
+ if (openAmount > 0.004) {
+ if (!line) fillPolygon(ctx, openShape, innerColor);
+ // The tongue rises from the lower lip *into* the mouth, so it has to arrive
+ // with the opening: at a hair's width the jaw curve is a sliver, and the
+ // tongue used to burst straight out of it and sit on the chin.
+ const tongueOpen = clamp((openAmount - 0.06) / 0.24, 0, 1);
+ if (tongueAmount > 0.01 && tongueOpen > 0.01) {
+ ctx.save();
+ tracePolyline(ctx, openShape, true);
+ ctx.clip();
+ // Inside an open mouth the tongue sits on the lower jaw.
+ drawTongue(jawAt, jaw, tongueHeight * tongueOpen * (1 + openAmount * 0.3));
+ ctx.restore();
+ }
+ // One outline around the whole mouth reads as lips; the smile stroke alone
+ // would leave the lower edge as a bare colour change.
+ strokePolyline(ctx, openShape, { color: strokeColor, width: thickness, closed: true });
+ } else {
+ // Closed lips - but the tongue still pokes over the lip. That is what the
+ // original artwork does (the mouth reads as a smile with the tongue showing,
+ // like ペコちゃん), and it is why a plain line looked wrong there. The lip
+ // line goes on afterwards, so the tongue comes out from under it.
+ if (tongueAmount > 0.01) {
+ drawTongue(lipAt, arc, tongueHeight);
+ }
+ strokePolyline(ctx, rotate(arc, pivot, tilt), { color: strokeColor, width: thickness });
+ }
+
+ // --- corner marks ---------------------------------------------------
+ if (cornerAmount > 0.01) {
+ const C = L.corner;
+ const cornerAngle = p.cornerAngle ?? 0;
+ for (const [end, side] of [[left, -1], [right, 1]]) {
+ const from = { x: end.x + C.fromX * side * cornerAmount, y: end.y - C.fromY * cornerAmount };
+ const to = { x: end.x - C.toX * side * cornerAmount, y: end.y - C.toY * cornerAmount };
+ // A smooth curve, not a three-point kink. `mid` is the control point, so it
+ // is pulled twice as far as the bow should reach; both corners bow the same
+ // way (downwards on screen), which is what the original artwork does.
+ const control = {
+ x: (from.x + to.x) / 2,
+ y: (from.y + to.y) / 2 + (C.curve ?? 26) * cornerAmount,
+ };
+ const curve = quadraticPoints(from, control, to, 16);
+ // `cornerAngle` tilts the whole mark around the mouth corner, mirrored so
+ // both sides move together.
+ const shaped = cornerAngle ? rotate(curve, end, cornerAngle * side) : curve;
+ strokePolyline(ctx, rotate(shaped, pivot, tilt), {
+ color: line ? strokeColor : (p.cornerColor ?? '#725497'),
+ // The *length* of the corner mark follows `corners`, but its thickness
+ // does not: `corners: 0.71` is the length that matches the original, and
+ // the original's stroke is the full thickness.
+ width: C.width,
+ });
+ }
+ }
+}
+
+/** Approximate length of a polyline. */
+function chordLength(points) {
+ let total = 0;
+ for (let i = 1; i < points.length; i++) {
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
+ }
+ return total;
+}
diff --git a/bluebey-studio/src/gacha.js b/bluebey-studio/src/gacha.js
new file mode 100644
index 0000000..c44f80f
--- /dev/null
+++ b/bluebey-studio/src/gacha.js
@@ -0,0 +1,481 @@
+/**
+ * Seeded "おまかせ" rolls for the face and the pose.
+ *
+ * The studio is a hundred sliders, and that is exactly the problem: when you sit
+ * down to make an expression you reach for the same three settings every time.
+ * This module is the antidote - one press and a stranger picks the numbers for
+ * you, from ranges a person would actually dial in, so the result is plausible
+ * rather than a mash of the full slider span.
+ *
+ * Two ideas hold it together:
+ *
+ * - A roll picks an *emotion* first (ふつう, うれしい, びっくり, ...), then samples
+ * every part inside that emotion's window. Correlated parts read as one face;
+ * an independent draw per slider reads as noise.
+ *
+ * - The randomness is seeded, and the seed is a short base36 string that fits in
+ * a URL. `rollAll(seed)` is a pure function of that string, so a look the user
+ * liked can be reproduced and shared (`?seed=...`) instead of lost. A "seed"
+ * is either a number or text; text that is already a base36 number is read as
+ * one, so `decodeSeed(seed)` is always accepted back by `makeRandom`.
+ *
+ * The face and pose vocabulary, and every bound quoted below, follows
+ * `src/presets.js` and the slider ranges in `src/panel.js`. Nothing here touches
+ * the DOM or the app state: the functions return patches for `applyPatch`.
+ */
+
+/**
+ * FNV-1a over UTF-16 code units, for text that is not already a seed.
+ */
+function fnv1a(text) {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < text.length; i += 1) {
+ hash ^= text.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return hash >>> 0;
+}
+
+/**
+ * Normalise any seed to a 32-bit integer. Text that is already a base36 number
+ * is read as one - that is what `decodeSeed` emits, so it has to come back in
+ * unchanged - and anything else is hashed. `hashSeed`, `makeRandom` and
+ * `decodeSeed` all go through this, so the four entry points never disagree.
+ */
+function toSeedInt(seed) {
+ if (typeof seed === 'number' && Number.isFinite(seed)) return Math.trunc(seed) >>> 0;
+ const text = String(seed ?? '').trim();
+ if (/^[0-9a-z]+$/.test(text)) {
+ const parsed = parseInt(text, 36);
+ if (Number.isFinite(parsed)) return parsed >>> 0;
+ }
+ return fnv1a(text);
+}
+
+/**
+ * Turn text into a stable 32-bit seed (for `?seed=` in a URL).
+ *
+ * A word becomes an FNV-1a hash; text that already looks like a base36 seed is
+ * passed through, so `decodeSeed`'s output round-trips.
+ */
+export function hashSeed(text) {
+ return toSeedInt(text);
+}
+
+/**
+ * A seeded PRNG (mulberry32). Returns `() => number` in `[0, 1)`.
+ * Accepts a number or a string; strings go through the same rule as `decodeSeed`.
+ */
+export function makeRandom(seed) {
+ let state = toSeedInt(seed);
+ return function random() {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * The short, readable form of a seed (base36), for the "現在のシード" field.
+ * Idempotent: `decodeSeed(decodeSeed(x)) === decodeSeed(x)`, and the result is
+ * accepted straight back by `makeRandom`.
+ */
+export function decodeSeed(seed) {
+ return (toSeedInt(seed) >>> 0).toString(36);
+}
+
+function rand(random, min, max) {
+ return min + random() * (max - min);
+}
+
+/** An angle in whole degrees, so the pose matches the step-1 sliders. */
+function deg(random, min, max) {
+ return Math.round(rand(random, min, max));
+}
+
+function round2(value) {
+ return Math.round(value * 100) / 100;
+}
+
+function pick(random, list) {
+ return list[Math.floor(random() * list.length)];
+}
+
+function chance(random, probability) {
+ return random() < probability;
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+/**
+ * The mood table. Each entry samples only inside a range that reads as that
+ * emotion, which is what keeps the parts agreeing with each other.
+ *
+ * Fields: `eyes` (open/irisScale/lookMax/lookX/lookY, all in slider units),
+ * `mouth` (smile/open/round/corners/cornerAngle/tilt/offsetY/tongue/width/
+ * thickness), plus optional `tear`, `lowerLid`, `heart`, `wink` and `shutBoth`.
+ */
+const MOODS = [
+ {
+ id: 'normal',
+ eyes: {
+ open: [0.92, 1], irisScale: [0.95, 1.06], lookMax: [0.55, 1],
+ lookX: [-0.28, 0.28], lookY: [-0.16, 0.16],
+ },
+ mouth: {
+ smile: [0.62, 0.95], open: [0, 0.05], width: [0.9, 1.1], thickness: [0.9, 1.15],
+ corners: [0.8, 1.2], cornerAngle: [-6, 6], tilt: [-4, 4], offsetY: [-5, 6],
+ tongue: [0, 1.1],
+ },
+ },
+ {
+ id: 'happy',
+ eyes: {
+ open: [0.88, 1], irisScale: [0.96, 1.08], lookMax: [0.5, 0.95],
+ lookX: [-0.3, 0.3], lookY: [-0.2, 0.05],
+ },
+ mouth: {
+ smile: [1.05, 1.3], open: [0.02, 0.22], width: [0.95, 1.2], thickness: [0.9, 1.15],
+ corners: [1.05, 1.4], cornerAngle: [0, 12], tilt: [-4, 6], offsetY: [-8, 2],
+ tongue: [0.9, 1.35],
+ },
+ },
+ {
+ id: 'surprised',
+ eyes: {
+ open: [0.95, 1], irisScale: [0.8, 0.9], lookMax: [0.3, 0.5],
+ lookX: [-0.15, 0.15], lookY: [-0.1, 0.1],
+ },
+ mouth: {
+ smile: [0, 0.2], open: [0.1, 0.32], round: [0.55, 0.8], width: [0.82, 0.98],
+ thickness: [1, 1.2], corners: [0, 0.15], cornerAngle: [-3, 3], tilt: [-3, 3],
+ offsetY: [-6, 6], tongue: [0, 0.4],
+ },
+ },
+ {
+ id: 'sad',
+ eyes: {
+ open: [0.55, 0.75], irisScale: [0.95, 1.05], lookMax: [0.45, 0.85],
+ lookX: [-0.2, 0.2], lookY: [-0.35, -0.12],
+ },
+ mouth: {
+ smile: [-0.7, -0.4], open: [0, 0.05], width: [0.68, 0.88], thickness: [1.05, 1.3],
+ corners: [0, 0.1], cornerAngle: [-4, 4], tilt: [-5, 5], offsetY: [6, 16],
+ tongue: [0, 0.1],
+ },
+ lowerLid: [0.16, 0.28],
+ tear: [0.6, 1.2],
+ tearChance: 1,
+ },
+ {
+ id: 'angry',
+ eyes: {
+ open: [0.55, 0.7], irisScale: [0.82, 0.92], lookMax: [0.5, 0.9],
+ lookX: [-0.15, 0.15], lookY: [-0.16, 0.02],
+ },
+ mouth: {
+ smile: [-0.85, -0.5], open: [0, 0.04], width: [0.78, 0.94], thickness: [1.05, 1.3],
+ corners: [0, 0.05], cornerAngle: [-6, 6], tilt: [-6, 6], offsetY: [8, 18],
+ tongue: [0, 0.05],
+ },
+ lowerLid: [0.08, 0.2],
+ },
+ {
+ id: 'love',
+ eyes: {
+ open: [1, 1], irisScale: [0.9, 1.02], lookMax: [0.4, 0.8],
+ lookX: [-0.16, 0.16], lookY: [-0.36, -0.2],
+ },
+ mouth: {
+ smile: [1.15, 1.3], open: [0.08, 0.3], width: [0.98, 1.18], thickness: [0.98, 1.2],
+ corners: [1.2, 1.4], cornerAngle: [4, 14], tilt: [2, 8], offsetY: [-2, 6],
+ tongue: [1.1, 1.4],
+ },
+ heart: true,
+ },
+ {
+ id: 'sleepy',
+ eyes: {
+ open: [0.32, 0.5], irisScale: [0.95, 1.05], lookMax: [0.4, 0.7],
+ lookX: [-0.18, 0.18], lookY: [-0.26, -0.08],
+ },
+ mouth: {
+ smile: [0.5, 0.82], open: [0, 0.08], width: [0.82, 1], thickness: [0.92, 1.1],
+ corners: [0.5, 0.9], cornerAngle: [-4, 4], tilt: [-7, -1], offsetY: [4, 13],
+ tongue: [0.3, 0.8],
+ },
+ lowerLid: [0.1, 0.24],
+ tear: [0.2, 0.5],
+ tearChance: 0.5,
+ shutBoth: { chance: 0.4, closed: 'line', closedLines: 1 },
+ },
+ {
+ id: 'wink',
+ eyes: {
+ open: [0.95, 1], irisScale: [0.95, 1.05], lookMax: [0.5, 0.9],
+ lookX: [-0.2, 0.2], lookY: [-0.15, 0.1],
+ },
+ mouth: {
+ smile: [1, 1.3], open: [0.02, 0.18], width: [0.95, 1.15], thickness: [0.92, 1.12],
+ corners: [0.9, 1.2], cornerAngle: [4, 12], tilt: [-5, 5], offsetY: [-6, 4],
+ tongue: [0.8, 1.25],
+ },
+ wink: true,
+ },
+];
+
+/**
+ * Sample the eyes. Both eyes normally agree (a tiny jitter keeps the face from
+ * looking printed); a wink shuts exactly one, and ねむい sometimes shuts both.
+ * A shut-eye style below 1 line is only ever picked when that eye is closed.
+ */
+function rollEyes(random, spec) {
+ const [openMin, openMax] = spec.open;
+ let leftOpen = round2(rand(random, openMin, openMax));
+ let rightOpen = round2(clamp(leftOpen + rand(random, -0.06, 0.06), 0, 1));
+ let leftShape = 'open';
+ let rightShape = 'open';
+ let leftClosed = 'line';
+ let rightClosed = 'line';
+ let leftLines = 1;
+ let rightLines = 1;
+
+ if (spec.wink) {
+ const side = chance(random, 0.5) ? 'left' : 'right';
+ const lines = pick(random, [2, 3]);
+ // A shut eye is the artwork with the lids open now; the lid height is its own
+ // slider, so `open` is 1 and the shape says which line it is.
+ if (side === 'left') {
+ leftOpen = 1;
+ leftLines = lines;
+ leftShape = `line${lines}`;
+ } else {
+ rightOpen = 1;
+ rightLines = lines;
+ rightShape = `line${lines}`;
+ }
+ } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) {
+ leftOpen = 1;
+ rightOpen = 1;
+ leftClosed = spec.shutBoth.closed;
+ rightClosed = spec.shutBoth.closed;
+ leftLines = spec.shutBoth.closedLines;
+ rightLines = spec.shutBoth.closedLines;
+ const shape = spec.shutBoth.closed === 'line'
+ ? `line${spec.shutBoth.closedLines}`
+ : spec.shutBoth.closed;
+ leftShape = shape;
+ rightShape = shape;
+ }
+
+ const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1]));
+ const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1]));
+
+ return {
+ left: { shape: leftShape, open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' },
+ right: { shape: rightShape, open: rightOpen, lookX, lookY, closed: rightClosed, closedLines: rightLines, irisShape: 'circle' },
+ irisScale: round2(rand(random, spec.irisScale[0], spec.irisScale[1])),
+ lookMax: round2(rand(random, spec.lookMax[0], spec.lookMax[1])),
+ highlight: true,
+ };
+}
+
+/** Sample the mouth. A round "O" and a smile are mutually exclusive by design. */
+function rollMouth(random, spec) {
+ return {
+ visible: true,
+ smile: round2(rand(random, spec.smile[0], spec.smile[1])),
+ open: round2(rand(random, spec.open[0], spec.open[1])),
+ round: spec.round ? round2(rand(random, spec.round[0], spec.round[1])) : 0,
+ width: round2(rand(random, spec.width[0], spec.width[1])),
+ thickness: round2(rand(random, spec.thickness[0], spec.thickness[1])),
+ tilt: Math.round(rand(random, spec.tilt[0], spec.tilt[1])),
+ offsetY: Math.round(rand(random, spec.offsetY[0], spec.offsetY[1])),
+ corners: round2(rand(random, spec.corners[0], spec.corners[1])),
+ cornerAngle: Math.round(rand(random, spec.cornerAngle[0], spec.cornerAngle[1])),
+ tongue: round2(rand(random, spec.tongue[0], spec.tongue[1])),
+ };
+}
+
+/**
+ * A patch for the `face` section: `{ eyes, mouth }`, ready for
+ * `applyPatch(state.face, patch)`.
+ *
+ * Bounds, in case you are reading this from the tests:
+ * eyes.open 0..1, irisScale 0.8..1.1, lookMax 0.3..1,
+ * lookX -0.35..0.35, lookY -0.45..0.2,
+ * mouth.smile -1..1.4 (negative = frown, only when the eyes agree),
+ * mouth.open 0..0.35, mouth.round 0 or 0.4..0.8,
+ * corners 0..1.4, cornerAngle -30..30, tilt -8..8, offsetY -20..30,
+ * width 0.6..1.3, thickness 0.7..1.4, tongue 0..1.6,
+ * eyes.left/right.tear 0..1.6.
+ */
+export function randomFace(random) {
+ const mood = pick(random, MOODS);
+ const eyes = rollEyes(random, mood.eyes);
+ const mouth = rollMouth(random, mood.mouth);
+
+ // Tears only read on a sad or sleepy face: a frown, or eyes that are half shut.
+ // A fully shut eye never cries.
+ const halfShut = eyes.left.open > 0 && eyes.left.open < 0.78;
+ if (mood.tear && eyes.left.open > 0 && (mouth.smile < 0 || halfShut)
+ && chance(random, mood.tearChance ?? 1)) {
+ // The tears are per eye, so both have to be set. (A crying face wants both;
+ // the shape still allows one eye to cry on its own.)
+ const amount = round2(rand(random, mood.tear[0], mood.tear[1]));
+ eyes.left.tear = amount;
+ eyes.right.tear = amount;
+ eyes.left.tearOn = true;
+ eyes.right.tearOn = true;
+ }
+
+ if (mood.lowerLid) {
+ eyes.lowerLid = round2(rand(random, mood.lowerLid[0], mood.lowerLid[1]));
+ }
+
+ // A heart pupil needs both eyes open, or it reads as a broken iris.
+ if (mood.heart && eyes.left.open >= 0.9 && eyes.right.open >= 0.9) {
+ eyes.left.irisShape = 'heart';
+ eyes.right.irisShape = 'heart';
+ eyes.left.shape = 'heart';
+ eyes.right.shape = 'heart';
+ eyes.heartScale = round2(rand(random, 0.9, 1.15));
+ eyes.heartColor = '#e0344f';
+ eyes.highlight = false;
+ }
+
+ return { eyes, mouth };
+}
+
+/**
+ * The pose moves. Each composes one to three bones (never the whole rig) inside
+ * safe limits: arms up to ~74°, master lean up to 24°, root offsets small.
+ *
+ * Bounds: master [-20..24, -28..28, -14..14], armsupport [-18..76, -12..12,
+ * -24..24], arm [-14..20, -8..8, -12..12], legsupport [-8..24, -8..8, -12..12],
+ * root x/z -0.15..0.15 and y 0..0.45.
+ */
+const POSE_MOVES = [
+ // A lean or a nod - one bone, the safest thing in the table.
+ (random) => ({
+ bones: { master: [deg(random, 6, 24), deg(random, -10, 10), deg(random, -6, 6)] },
+ }),
+ // A curious tilt to one side.
+ (random) => {
+ const sign = chance(random, 0.5) ? 1 : -1;
+ return { bones: { master: [deg(random, -6, 8), 0, sign * deg(random, 8, 14)] } };
+ },
+ // A wave from one arm.
+ (random) => {
+ const side = pick(random, ['l', 'r']);
+ return {
+ bones: {
+ [`armsupport.${side}`]: [deg(random, 42, 74), deg(random, -8, 8), deg(random, -14, 14)],
+ [`arm.${side}`]: [deg(random, 6, 18), 0, 0],
+ },
+ };
+ },
+ // A two-armed cheer, with a small hop.
+ (random) => ({
+ bones: {
+ master: [deg(random, -14, -2), 0, 0],
+ 'armsupport.l': [deg(random, 52, 74), 0, deg(random, 4, 18)],
+ 'armsupport.r': [deg(random, 52, 74), 0, -deg(random, 4, 18)],
+ },
+ root: [0, round2(rand(random, 0.05, 0.3)), 0],
+ }),
+ // Introducing something off to one side.
+ (random) => {
+ const side = pick(random, ['l', 'r']);
+ const sign = side === 'l' ? 1 : -1;
+ const other = side === 'l' ? 'r' : 'l';
+ return {
+ bones: {
+ master: [deg(random, 2, 8), sign * deg(random, 16, 26), 0],
+ [`armsupport.${side}`]: [deg(random, 24, 40), 0, sign * deg(random, 10, 20)],
+ [`armsupport.${other}`]: [deg(random, 2, 14), 0, 0],
+ },
+ };
+ },
+ // A little hop on the spot.
+ (random) => ({
+ bones: {
+ master: [deg(random, -12, -2), 0, 0],
+ 'legsupport.l': [deg(random, 8, 22), 0, deg(random, -8, 8)],
+ 'legsupport.r': [deg(random, 8, 22), 0, deg(random, -8, 8)],
+ },
+ root: [0, round2(rand(random, 0.18, 0.42)), 0],
+ }),
+ // Both arms up in a simple stretch.
+ (random) => ({
+ bones: {
+ 'armsupport.l': [deg(random, 46, 74), 0, deg(random, 4, 16)],
+ 'armsupport.r': [deg(random, 46, 74), 0, -deg(random, 4, 16)],
+ },
+ }),
+ // A dance sway: one arm leads, the other follows.
+ (random) => {
+ const sign = chance(random, 0.5) ? 1 : -1;
+ return {
+ bones: {
+ master: [deg(random, -4, 4), 0, sign * deg(random, 4, 12)],
+ 'armsupport.l': [deg(random, 30, 46), 0, sign * deg(random, 8, 18)],
+ 'armsupport.r': [deg(random, 12, 26), 0, -sign * deg(random, 8, 18)],
+ },
+ root: [round2(rand(random, -0.08, 0.08)), 0, 0],
+ };
+ },
+];
+
+/** A patch for the `pose` section: `{ bones, root }`. */
+export function randomPose(random) {
+ const move = pick(random, POSE_MOVES)(random);
+ return { bones: move.bones, root: move.root ?? [0, 0, 0] };
+}
+
+/**
+ * A patch for the `lookAt` section. About half the time it enables a target off
+ * to one side, so the character glances away instead of staring at the camera.
+ * Bounds: |x| 1.2..3.2 when enabled, y 1.6..3.4, z 2..4.2, amount 0.5..1.
+ */
+export function randomLook(random) {
+ if (chance(random, 0.45)) {
+ const sign = chance(random, 0.5) ? 1 : -1;
+ return {
+ lookAt: {
+ enabled: true,
+ x: round2(sign * rand(random, 1.2, 3.2)),
+ y: round2(rand(random, 1.6, 3.4)),
+ z: round2(rand(random, 2, 4.2)),
+ turnBody: chance(random, 0.35),
+ amount: round2(rand(random, 0.5, 1)),
+ },
+ };
+ }
+ return {
+ lookAt: { enabled: false, x: 0, y: 2.6, z: 3, turnBody: false, amount: round2(rand(random, 0.6, 1)) },
+ };
+}
+
+/**
+ * One press of おまかせ: face, pose and look, all from the same seed.
+ *
+ * @param {number | string} seed a number, or text that `decodeSeed` also accepts
+ * @returns {{ seed: string, patch: { face: object, pose: object, lookAt: object } }}
+ * `seed` is the readable base36 form, for the URL and the seed field.
+ */
+export function rollAll(seed) {
+ const random = makeRandom(seed);
+ return {
+ seed: decodeSeed(seed),
+ patch: {
+ face: randomFace(random),
+ pose: randomPose(random),
+ lookAt: randomLook(random).lookAt,
+ },
+ };
+}
diff --git a/bluebey-studio/src/gion.js b/bluebey-studio/src/gion.js
new file mode 100644
index 0000000..832313b
--- /dev/null
+++ b/bluebey-studio/src/gion.js
@@ -0,0 +1,226 @@
+/**
+ * 擬音 (マンガのオノマトペ) のスタンプ.
+ *
+ * The source images are dense sheets: several sounds, each in a white-outline and
+ * a solid version, packed so tightly that an automatic slice (outline tracing /
+ * connected components) tears a single character into fragments. So nothing here
+ * guesses at a sheet's layout. Instead the user draws a rectangle over the sheet
+ * in the picker and the app crops exactly that rectangle, which means the feature
+ * works for any sheet that arrives later.
+ *
+ * That is why the crops are stored as *source pixels* (`sx, sy, sw, sh`) taken
+ * from the bitmap's own `naturalWidth` / `naturalHeight`, never as fractions of
+ * some assumed grid: the sheet's pixel size is read at runtime.
+ *
+ * The module is pure and DOM-free in the same sense as `trace.js` and
+ * `caption.js`: no globals are touched except a read of the injected
+ * `__BLUEBEY_GIONS__` lookup, and `drawGion` takes the loaded bitmaps as an
+ * argument, so the caller owns loading and the tests can drive the geometry in
+ * Node with a stub context.
+ */
+
+/**
+ * The sheets the studio offers. It ships with none: the original otarunet sheet
+ * may not be redistributed with the app, and the images the author draws later are
+ * added here. So this list starts empty and the 擬音 section stays out of the panel
+ * until an entry appears.
+ *
+ * To add one: put the image in `assets/manga-gion/`, add `{ name, label }` here
+ * (`name` is the file name *with* its extension, e.g. `dokaan.png`) and add the
+ * same `name` to `GION_NAMES` in `tools/build-standalone.mjs` for the one-file
+ * build. Use an image with a transparent background - the crop is drawn as-is, so
+ * a white background would sit on the picture as a white rectangle.
+ */
+export const GION_SHEETS = [];
+
+/**
+ * The default display width of a stamp, in pixels at 1x. A crop that is tall and
+ * thin will be narrower than this after the aspect is applied; the user resizes
+ * it from the panel either way.
+ */
+export const DEFAULT_STAMP_WIDTH = 240;
+
+/**
+ * Where a sheet's image lives. The single-file build inlines every sheet as a
+ * data URL in `window.__BLUEBEY_GIONS__`; the normal build reads the file.
+ *
+ * @param {string} name the sheet file name, with its extension, e.g. `dokaan.png`
+ * @returns {string}
+ */
+export function gionSheetUrl(name) {
+ const inlined = globalThis.__BLUEBEY_GIONS__ ?? {};
+ return inlined[name] ?? `assets/manga-gion/${name}`;
+}
+
+/** Clamp `value` into `lo..hi`; a non-finite value becomes `lo`. */
+export function clamp(value, lo, hi) {
+ if (!Number.isFinite(value)) return lo;
+ return Math.min(hi, Math.max(lo, value));
+}
+
+/** True when `image` has pixels to draw (a not-yet-loaded Image has none). */
+export function imageReady(image) {
+ if (!image) return false;
+ const width = Number(image.naturalWidth ?? image.width ?? 0);
+ const height = Number(image.naturalHeight ?? image.height ?? 0);
+ // `complete` is the browser's own answer; a stub in a test omits it, so only an
+ // explicit `false` blocks the draw.
+ return width > 0 && height > 0 && image.complete !== false;
+}
+
+/** The bitmap's width / height. 1 when it is not loaded yet. */
+export function imageAspect(image) {
+ const width = toPositive(image?.naturalWidth ?? image?.width, 1);
+ const height = toPositive(image?.naturalHeight ?? image?.height, 1);
+ return width / height;
+}
+
+/**
+ * The rectangle a stamp occupies on screen, in pixels, with rotation left out
+ * (the caller rotates about the returned centre).
+ *
+ * `item.x` / `item.y` are the centre as a fraction of the viewport, `item.w` is
+ * the width in pixels at 1x, and the height follows the bitmap's aspect ratio,
+ * so a wide crop and a tall crop are both placed by their width alone.
+ *
+ * @param {object} item a stamp: `{ x, y, w, ... }`
+ * @param {object} [viewport] `{ width, height, scale }` of the target surface
+ * @param {number} [aspect] bitmap width / height
+ * @returns {{x: number, y: number, w: number, h: number, cx: number, cy: number}}
+ */
+export function stampRect(item, { width = 0, height = 0, scale = 1 } = {}, aspect = 1) {
+ const w = toPositive(item?.w, DEFAULT_STAMP_WIDTH) * positiveScale(scale);
+ const h = w / toPositive(aspect, 1);
+ const cx = toFinite(item?.x, 0.5) * toNonNegative(width, 0);
+ const cy = toFinite(item?.y, 0.5) * toNonNegative(height, 0);
+ return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy };
+}
+
+/** Turn two corner points into a rectangle with a positive width and height. */
+export function normalizeRect(a, b) {
+ const ax = toFinite(a?.x, 0);
+ const ay = toFinite(a?.y, 0);
+ const bx = toFinite(b?.x, 0);
+ const by = toFinite(b?.y, 0);
+ return { x: Math.min(ax, bx), y: Math.min(ay, by), w: Math.abs(bx - ax), h: Math.abs(by - ay) };
+}
+
+/**
+ * Map a marquee (drawn in screen pixels over the fitted sheet) to a pixel crop of
+ * the source bitmap.
+ *
+ * `sheetRect` is where the sheet is currently displayed, so the marquee becomes a
+ * fraction of the sheet and then a fraction of the bitmap - the sheet's own pixel
+ * size comes from `natural`, never from a hard-coded number. The crop is clamped
+ * to the bitmap and is at least 1x1, so it can always be drawn.
+ *
+ * @param {{x: number, y: number, w: number, h: number}} marquee screen pixels
+ * @param {{x: number, y: number, w: number, h: number}} sheetRect screen pixels
+ * @param {object} natural the sheet image (or any `{ naturalWidth, naturalHeight }`)
+ * @returns {{sx: number, sy: number, sw: number, sh: number}}
+ */
+export function cropFromMarquee(marquee, sheetRect, natural) {
+ const nw = Math.max(1, Math.round(toPositive(natural?.naturalWidth, 1)));
+ const nh = Math.max(1, Math.round(toPositive(natural?.naturalHeight, 1)));
+ const rect = {
+ x: toFinite(marquee?.x, 0),
+ y: toFinite(marquee?.y, 0),
+ w: toNonNegative(marquee?.w, 0),
+ h: toNonNegative(marquee?.h, 0),
+ };
+ const displayW = toNonNegative(sheetRect?.w, 0);
+ const displayH = toNonNegative(sheetRect?.h, 0);
+
+ const left = displayW > 0 ? clamp((rect.x - sheetRect.x) / displayW, 0, 1) : 0;
+ const right = displayW > 0 ? clamp((rect.x + rect.w - sheetRect.x) / displayW, 0, 1) : 0;
+ const top = displayH > 0 ? clamp((rect.y - sheetRect.y) / displayH, 0, 1) : 0;
+ const bottom = displayH > 0 ? clamp((rect.y + rect.h - sheetRect.y) / displayH, 0, 1) : 0;
+
+ const sx = clamp(Math.round(left * nw), 0, nw - 1);
+ const sy = clamp(Math.round(top * nh), 0, nh - 1);
+ return {
+ sx,
+ sy,
+ sw: clamp(Math.round((right - left) * nw), 1, nw - sx),
+ sh: clamp(Math.round((bottom - top) * nh), 1, nh - sy),
+ };
+}
+
+/**
+ * Fit a bitmap inside a box, keeping its aspect ratio and centring it. The picker
+ * uses this to show a whole sheet - however large - at a size the user can drag
+ * over.
+ *
+ * @param {object} natural the sheet image
+ * @param {{width: number, height: number}} box the available area, in pixels
+ * @param {object} [options]
+ * @param {number} [options.padding=0] a margin to keep inside the box
+ * @returns {{x: number, y: number, w: number, h: number}}
+ */
+export function fitSheet(natural, box, { padding = 0 } = {}) {
+ const nw = toPositive(natural?.naturalWidth ?? natural?.width, 1);
+ const nh = toPositive(natural?.naturalHeight ?? natural?.height, 1);
+ const pad = toNonNegative(padding, 0);
+ const availW = Math.max(0, toNonNegative(box?.width, 0) - pad * 2);
+ const availH = Math.max(0, toNonNegative(box?.height, 0) - pad * 2);
+ const scale = Math.min(availW / nw, availH / nh);
+ const w = nw * scale;
+ const h = nh * scale;
+ return { x: pad + (availW - w) / 2, y: pad + (availH - h) / 2, w, h };
+}
+
+/**
+ * Paint every stamp onto a 2D context.
+ *
+ * The same function runs over the live viewport overlay (scale 1) and into the
+ * exported PNG (scale = output pixels / CSS pixels), which is what keeps the
+ * preview and the file in step. A stamp whose sheet has not loaded yet is simply
+ * skipped, so the rest of the picture is never held up by one image.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {Array} items the stamps
+ * @param {Map} images loaded sheets, by name
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ */
+export function drawGion(ctx, items, images, { width = 0, height = 0, scale = 1 } = {}) {
+ if (!ctx || !Array.isArray(items)) return;
+ const viewport = { width, height, scale: positiveScale(scale) };
+ for (const item of items) {
+ const image = images?.get?.(item?.sheet);
+ if (!imageReady(image)) continue;
+ const rect = stampRect(item, viewport, imageAspect(image));
+ if (!(rect.w > 0) || !(rect.h > 0)) continue;
+ const rot = toFinite(item?.rot, 0);
+ ctx.save();
+ ctx.translate(rect.cx, rect.cy);
+ if (rot !== 0) ctx.rotate((rot * Math.PI) / 180);
+ // The flip is applied before the draw so the same source rect feeds both.
+ if (item?.flip) ctx.scale(-1, 1);
+ ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, -rect.w / 2, -rect.h / 2, rect.w, rect.h);
+ ctx.restore();
+ }
+}
+
+/* ----------------------------------------------------------------- numbers */
+
+function toFinite(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+function toPositive(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
+function toNonNegative(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
+}
+
+function positiveScale(value) {
+ return toPositive(value, 1);
+}
diff --git a/bluebey-studio/src/glbExport.js b/bluebey-studio/src/glbExport.js
new file mode 100644
index 0000000..6e3e997
--- /dev/null
+++ b/bluebey-studio/src/glbExport.js
@@ -0,0 +1,148 @@
+import * as THREE from 'three';
+import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
+
+/**
+ * Hand the posed character back as a 3D file.
+ *
+ * WHY: a PNG or a WebM only travels as pixels. Being able to take the pose, the
+ * expression and the props into Blender (or embed them elsewhere as a model)
+ * turns the studio into a front end for the character instead of a picture
+ * maker, and it costs nothing: what is on screen already *is* a glTF scene, so
+ * the only job here is to hand the live objects to three's exporter with the
+ * right options and a Blob around the result.
+ *
+ * The caller passes exactly what it wants in the file - the character root and
+ * any props - so no lights, helpers or ground ever enter it. The inverted-hull
+ * outlines are the one wrinkle: they are visible (they are part of the look)
+ * but they are duplicates of the body geometry, so exporting them would double
+ * the file and leave a black shell around the character. `needsTemporaryHide`
+ * decides what to hide for the duration, and `exportGLB` restores it after.
+ */
+
+const GLTF_BINARY_TYPE = 'model/gltf-binary';
+const GLTF_JSON_TYPE = 'model/gltf+json';
+/** The largest texture a GPU can be assumed to handle everywhere. */
+const MAX_TEXTURE_SIZE = 4096;
+
+/**
+ * True for objects the exporter must skip: the inverted-hull outlines (named
+ * `...:outline`), anything the app tagged as a helper or as excluded from the
+ * export, and anything already hidden. The name check is the important one: an
+ * outline is visible on screen, so `onlyVisible` alone would not drop it.
+ *
+ * @param {THREE.Object3D} object
+ * @returns {boolean}
+ */
+export function needsTemporaryHide(object) {
+ if (!object) return false;
+ if (typeof object.name === 'string' && object.name.endsWith(':outline')) return true;
+ if (object.userData?.isHelper === true) return true;
+ if (object.userData?.excludeFromExport === true) return true;
+ return object.visible === false;
+}
+
+/** Triangles in a geometry, ignoring draw ranges (the export does too). */
+function triangleCount(geometry) {
+ if (!geometry) return 0;
+ const index = geometry.index;
+ const count = index ? index.count : geometry.attributes?.position?.count ?? 0;
+ return Math.floor(count / 3);
+}
+
+/**
+ * Count what `exportGLB` will write, for the summary the app shows first.
+ *
+ * Walks the same pruned tree the exporter sees, so hiding an outline or a
+ * helper is reflected in the numbers. A `SkinnedMesh` is also a `Mesh`, so it
+ * counts in both `meshes` and `skinnedMeshes`; a material shared by several
+ * meshes counts once, and every texture a counted material refers to counts
+ * once.
+ *
+ * @param {THREE.Object3D[]} objects
+ * @returns {{ meshes: number, skinnedMeshes: number, materials: number, textures: number, triangles: number }}
+ */
+export function describeScene(objects) {
+ const materials = new Set();
+ const textures = new Set();
+ let meshes = 0;
+ let skinnedMeshes = 0;
+ let triangles = 0;
+
+ const visit = (object) => {
+ if (!object || needsTemporaryHide(object)) return;
+ if (object.isMesh) {
+ meshes += 1;
+ if (object.isSkinnedMesh) skinnedMeshes += 1;
+ const list = Array.isArray(object.material) ? object.material : [object.material];
+ for (const material of list) {
+ if (!material) continue;
+ materials.add(material);
+ for (const value of Object.values(material)) {
+ if (value && value.isTexture) textures.add(value);
+ }
+ }
+ triangles += triangleCount(object.geometry);
+ }
+ for (const child of object.children ?? []) visit(child);
+ };
+
+ for (const root of Array.isArray(objects) ? objects : []) visit(root);
+ return { meshes, skinnedMeshes, materials: materials.size, textures: textures.size, triangles };
+}
+
+/**
+ * Hide every object the file must not contain, remembering what to restore.
+ * Stops at the first excluded ancestor: the exporter skips a hidden subtree, so
+ * there is no need to walk inside one.
+ */
+function hideExcluded(object, hidden) {
+ if (needsTemporaryHide(object)) {
+ hidden.push({ object, visible: object.visible });
+ object.visible = false;
+ return;
+ }
+ for (const child of object.children ?? []) hideExcluded(child, hidden);
+}
+
+/**
+ * Export `objects` as a GLB (or a `.gltf` JSON) Blob.
+ *
+ * @param {THREE.Object3D[]} objects the character root and any props
+ * @param {{ binary?: boolean, name?: string }} [options]
+ * @returns {Promise} `model/gltf-binary`, or `model/gltf+json` when not binary
+ */
+export async function exportGLB(objects, { binary = true, name = 'bluebey' } = {}) {
+ const roots = (Array.isArray(objects) ? objects : []).filter(Boolean);
+ if (roots.length === 0) {
+ throw new Error('exportGLB: objects is empty, there is nothing to export');
+ }
+ if (describeScene(roots).meshes === 0) {
+ throw new Error('exportGLB: objects contain no visible meshes to export');
+ }
+
+ const hidden = [];
+ try {
+ for (const root of roots) hideExcluded(root, hidden);
+
+ // The exporter names a bare array of objects "AuxScene". Wrapping them in a
+ // named Scene keeps the caller's objects where they are (we push straight
+ // into `children`, exactly as the exporter does) and gives the file a real
+ // scene name.
+ const scene = new THREE.Scene();
+ scene.name = name;
+ for (const root of roots) scene.children.push(root);
+
+ const exporter = new GLTFExporter();
+ const result = await exporter.parseAsync(scene, {
+ binary,
+ onlyVisible: true,
+ truncateDrawRange: false,
+ maxTextureSize: MAX_TEXTURE_SIZE,
+ });
+
+ if (binary) return new Blob([result], { type: GLTF_BINARY_TYPE });
+ return new Blob([JSON.stringify(result)], { type: GLTF_JSON_TYPE });
+ } finally {
+ for (const { object, visible } of hidden) object.visible = visible;
+ }
+}
diff --git a/bluebey-studio/src/handDrawn.js b/bluebey-studio/src/handDrawn.js
new file mode 100644
index 0000000..e7fbce5
--- /dev/null
+++ b/bluebey-studio/src/handDrawn.js
@@ -0,0 +1,391 @@
+import { contoursToPathData } from './trace.js';
+
+/**
+ * Hand-drawn distortion for traced contours.
+ *
+ * WHY: `trace.js` recovers the silhouette of the mascot from a rendered alpha
+ * mask. The result is geometrically faithful but *mechanically* smooth: it reads
+ * as the outline of a printed sticker, not as a pen stroke. This module nudges
+ * the traced points along a smooth, seeded wobble so the very same silhouette
+ * looks inked by hand, without changing its point count or where it sits.
+ *
+ * The displacement is value noise interpolated along the contour, so neighbouring
+ * points move by nearly the same amount and the outline stays a wobbly *line*
+ * rather than pixel jitter. Everything is seeded (never `Math.random`), so a
+ * build is reproducible, and the module is pure: it never touches the DOM and
+ * its only import is the path-data helper in `trace.js`, which keeps the output
+ * format identical to the un-roughened export.
+ */
+
+/** Lattice cells in the non-wrapping noise table (the pattern repeats after this). */
+const NOISE_PERIOD = 4096;
+
+const EPSILON = 1e-9;
+
+/**
+ * mulberry32: a tiny, fast 32-bit generator. Good enough for visual noise and,
+ * crucially, fully reproducible; the seed is the only source of variation.
+ *
+ * @param {number} seed
+ * @returns {() => number} values in [0, 1)
+ */
+function mulberry32(seed) {
+ let a = seed >>> 0;
+ return function next() {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/** Hermite ramp: 0 at t=0, 1 at t=1, flat at both ends (C1 continuity). */
+function smoothstep(t) {
+ return t * t * (3 - 2 * t);
+}
+
+/**
+ * A circular table of random values in -1..1. `count` is the number of cells in
+ * one lap; sampling wraps at `count`, which is what lets a closed stroke's
+ * wobble meet itself exactly at the seam.
+ *
+ * @param {number} seed
+ * @param {number} count
+ * @returns {Float64Array}
+ */
+function buildNoiseTable(seed, count) {
+ const rng = mulberry32(seed);
+ const table = new Float64Array(count);
+ for (let i = 0; i < count; i++) table[i] = rng() * 2 - 1;
+ return table;
+}
+
+/**
+ * Smoothly interpolate the table at `t`, wrapping around its ends. `smoothstep`
+ * makes the value continuous and its slope continuous at every cell boundary, so
+ * the wobble has no visible kinks.
+ */
+function sampleTable(table, t) {
+ const len = table.length;
+ const base = Math.floor(t);
+ const f = t - base;
+ const i0 = ((base % len) + len) % len;
+ const i1 = (i0 + 1) % len;
+ const a = table[i0];
+ const b = table[i1];
+ return a + (b - a) * smoothstep(f);
+}
+
+/**
+ * One-dimensional value noise, exposed mainly so tests can pin its behaviour.
+ * The returned function is continuous, roughly in -1..1, deterministic for a
+ * given seed, and returns 0 for non-finite input.
+ *
+ * @param {number} [seed=1]
+ * @returns {(t: number) => number}
+ */
+export function makeNoise(seed = 1) {
+ const table = buildNoiseTable(seed, NOISE_PERIOD);
+ return function noise(t) {
+ if (!Number.isFinite(t)) return 0;
+ return sampleTable(table, t);
+ };
+}
+
+/** Euclidean distance between two points. */
+function distance(a, b) {
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+/** Cumulative arc length of every point, measured from the first. */
+function arcPositions(points) {
+ const pos = new Float64Array(points.length);
+ for (let i = 1; i < points.length; i++) {
+ pos[i] = pos[i - 1] + distance(points[i - 1], points[i]);
+ }
+ return pos;
+}
+
+/** True when a closed contour repeats its first point at the end. */
+function hasClosingDuplicate(points) {
+ const first = points[0];
+ const last = points[points.length - 1];
+ return Math.abs(first.x - last.x) <= EPSILON && Math.abs(first.y - last.y) <= EPSILON;
+}
+
+/**
+ * Neighbour index for each point, honouring the wrap of a closed contour. Open
+ * ends get -1, which makes the tangent fall back to a one-sided difference.
+ */
+function neighborIndices(count, closed, duplicate) {
+ const prev = new Int32Array(count);
+ const next = new Int32Array(count);
+ if (!closed) {
+ for (let i = 0; i < count; i++) {
+ prev[i] = i > 0 ? i - 1 : -1;
+ next[i] = i < count - 1 ? i + 1 : -1;
+ }
+ return { prev, next };
+ }
+ // A repeated closing point is a copy of point 0, so the ring has one fewer
+ // distinct vertex and the last index borrows point 0's two neighbours, which
+ // is what makes its wobble identical to the first point's.
+ const ring = duplicate ? count - 1 : count;
+ const firstPrev = (ring - 1) % ring;
+ const firstNext = ring > 1 ? 1 : 0;
+ for (let i = 0; i < count; i++) {
+ if (duplicate && i === count - 1) {
+ prev[i] = firstPrev;
+ next[i] = firstNext;
+ } else {
+ prev[i] = (i - 1 + ring) % ring;
+ next[i] = (i + 1) % ring;
+ }
+ }
+ return { prev, next };
+}
+
+/**
+ * Unit tangent at `i`, measured from the point before to the point after so the
+ * wobble follows the stroke instead of the sampling. Returns null for a
+ * degenerate point, where there is no direction to displace along.
+ */
+function tangentAt(points, i, prev, next) {
+ const before = prev[i] >= 0 ? points[prev[i]] : points[i];
+ const after = next[i] >= 0 ? points[next[i]] : points[i];
+ const dx = after.x - before.x;
+ const dy = after.y - before.y;
+ const len = Math.hypot(dx, dy);
+ if (!(len > EPSILON)) return null;
+ return { x: dx / len, y: dy / len };
+}
+
+/**
+ * Fade the wobble to zero at both ends of an open stroke, over `ramp` units.
+ * Without this the ends would fly off the traced geometry; a smooth ramp keeps
+ * the stroke anchored while still looking freehand.
+ */
+function edgeWindow(s, length, ramp) {
+ if (!(ramp > 0)) return 1;
+ const head = Math.min(1, s / ramp);
+ const tail = Math.min(1, (length - s) / ramp);
+ return smoothstep(head) * smoothstep(tail);
+}
+
+/** Derive a distinct, deterministic seed for each extra pass. */
+function mixSeed(seed, pass) {
+ return (seed + pass * 0x9e3779b1) >>> 0;
+}
+
+/** Apply one wobble pass. `roughenPolyline` owns the input copy and the passes. */
+function roughenOnce(points, { amount, seed, closed, scale }) {
+ const count = points.length;
+ const copy = () => points.map((p) => ({ x: p.x, y: p.y }));
+ if (count < 2 || !(amount > 0) || !(scale > 0)) return copy();
+
+ const duplicate = closed ? hasClosingDuplicate(points) : false;
+ const pos = arcPositions(points);
+ const length = pos[count - 1];
+ let perimeter = length;
+ if (closed) perimeter += distance(points[count - 1], points[0]);
+
+ const { prev, next } = neighborIndices(count, closed, duplicate);
+
+ // A closed stroke samples a circular table with a whole number of cells per
+ // lap, so the last point lands on the first cell and the seam closes. An open
+ // stroke samples plain (non-wrapping) noise and fades it out near the ends.
+ let table;
+ let cells = 0;
+ if (closed) {
+ if (!(perimeter > 0)) return copy();
+ cells = Math.max(2, Math.round(perimeter / scale));
+ table = buildNoiseTable(seed, cells);
+ } else {
+ table = buildNoiseTable(seed, NOISE_PERIOD);
+ }
+ const ramp = Math.min(scale, length * 0.25);
+
+ const out = new Array(count);
+ for (let i = 0; i < count; i++) {
+ const tangent = tangentAt(points, i, prev, next);
+ const p = points[i];
+ if (!tangent) {
+ out[i] = { x: p.x, y: p.y };
+ continue;
+ }
+ const t = closed ? (pos[i] / perimeter) * cells : pos[i] / scale;
+ let weight = sampleTable(table, t);
+ if (!closed) weight *= edgeWindow(pos[i], length, ramp);
+ const shift = amount * weight;
+ if (shift === 0) {
+ // Keep the original exactly (also avoids turning -0 into 0).
+ out[i] = { x: p.x, y: p.y };
+ continue;
+ }
+ // Displace perpendicular to the tangent, i.e. along the local pen normal.
+ out[i] = { x: p.x - tangent.y * shift, y: p.y + tangent.x * shift };
+ }
+ if (closed && duplicate) {
+ // Belt and braces: pin the explicit seam shut after any rounding.
+ out[count - 1] = { x: out[0].x, y: out[0].y };
+ }
+ return out;
+}
+
+/**
+ * Displace every point of a polyline perpendicular to its local direction by
+ * smooth noise. The input is never mutated and the point count never changes.
+ *
+ * Open polylines keep their first and last point exactly; closed ones wrap, so
+ * the wobble is continuous across the seam. `amount` is the peak displacement in
+ * the same units as the points, and `scale` is how much arc length one wobble
+ * spans (larger = lazier, longer wobble). With `passes > 1` the displacement is
+ * re-noised a few times at a share of `amount`, so the peak stays within
+ * `amount` however many passes are used.
+ *
+ * @param {Array<{x: number, y: number}>} points
+ * @param {object} [options]
+ * @param {number} [options.amount=2] peak displacement, in point units
+ * @param {number} [options.seed=1] deterministic seed
+ * @param {boolean} [options.closed=false] treat the polyline as a ring
+ * @param {number} [options.scale=40] arc length covered by one wobble
+ * @param {number} [options.passes=1] number of noise layers
+ * @returns {Array<{x: number, y: number}>} a new array of new points
+ */
+export function roughenPolyline(points, options = {}) {
+ const list = Array.isArray(points) ? points : [];
+ const amount = options.amount ?? 2;
+ const seed = options.seed ?? 1;
+ const closed = options.closed ?? false;
+ const scale = options.scale ?? 40;
+ const passes = Math.max(1, Math.floor(options.passes ?? 1));
+
+ let current = list.map((p) => ({ x: p.x, y: p.y }));
+ if (current.length < 2 || amount === 0) return current;
+ for (let pass = 0; pass < passes; pass++) {
+ current = roughenOnce(current, {
+ amount: amount / passes,
+ seed: mixSeed(seed, pass),
+ closed,
+ scale,
+ });
+ }
+ return current;
+}
+
+/**
+ * Roughen a list of contours, with the closed/open choice per contour. Following
+ * `trace.js`'s convention, a contour is assumed to be a closed ring unless the
+ * caller says otherwise: pass `options.closed` as a boolean for all of them or
+ * as an array of flags indexed like `contours`.
+ *
+ * @param {Array>} contours
+ * @param {object} [options] see `roughenPolyline`, plus `closed` as an array
+ * @returns {Array>}
+ */
+export function roughenContours(contours, options = {}) {
+ const list = Array.isArray(contours) ? contours : [];
+ const closedOption = options.closed;
+ const out = [];
+ for (let i = 0; i < list.length; i++) {
+ const closed = Array.isArray(closedOption) ? closedOption[i] ?? true : closedOption ?? true;
+ out.push(roughenPolyline(list[i], { ...options, closed }));
+ }
+ return out;
+}
+
+/**
+ * Roughen a list of contours and return their SVG `d` attribute, using exactly
+ * the format of `contoursToPathData`: one `M … L … Z` subpath per contour,
+ * coordinates rounded to `options.round` decimal places (default 2, the same
+ * meaning as that function's `decimals` argument).
+ *
+ * @param {Array>} contours
+ * @param {object} [options] roughening options, plus `round` and `mapPoint`
+ * @param {number} [options.round=2] decimal places in the output
+ * @param {(x: number, y: number) => [number, number]} [options.mapPoint]
+ * @returns {string}
+ */
+export function handDrawnPathData(contours, options = {}) {
+ const roughened = roughenContours(contours, options);
+ const mapPoint = options.mapPoint ?? ((x, y) => [x, y]);
+ return contoursToPathData(roughened, mapPoint, options.round ?? 2);
+}
+
+/**
+ * Offset a stroke to both sides by a width that breathes slightly along its
+ * length, giving the `[left, right]` polylines a pen stroke can be filled
+ * between. Both sides keep the point count and order of `points`.
+ *
+ * The width only varies (it never reaches zero), so the two sides stay well
+ * defined; `variation` is the fraction of `width` the wobble may add or remove.
+ *
+ * @param {Array<{x: number, y: number}>} points
+ * @param {object} [options]
+ * @param {number} [options.width=3] full stroke width
+ * @param {number} [options.seed=1]
+ * @param {boolean} [options.closed=false]
+ * @param {number} [options.variation=0.35] relative width wobble
+ * @param {number} [options.scale=40] arc length covered by one width wobble
+ * @returns {[Array<{x: number, y: number}>, Array<{x: number, y: number}>]}
+ */
+export function taperStroke(points, options = {}) {
+ const list = Array.isArray(points) ? points : [];
+ const width = options.width ?? 3;
+ const seed = options.seed ?? 1;
+ const closed = options.closed ?? false;
+ const variation = options.variation ?? 0.35;
+ const scale = options.scale ?? 40;
+
+ const count = list.length;
+ const left = new Array(count);
+ const right = new Array(count);
+ if (count === 0) return [left, right];
+ if (count === 1) {
+ left[0] = { x: list[0].x, y: list[0].y };
+ right[0] = { x: list[0].x, y: list[0].y };
+ return [left, right];
+ }
+
+ const duplicate = closed ? hasClosingDuplicate(list) : false;
+ const pos = arcPositions(list);
+ const length = pos[count - 1];
+ let perimeter = length;
+ if (closed) perimeter += distance(list[count - 1], list[0]);
+
+ const { prev, next } = neighborIndices(count, closed, duplicate);
+
+ let table;
+ let cells = 0;
+ if (closed && perimeter > 0 && scale > 0) {
+ cells = Math.max(2, Math.round(perimeter / scale));
+ table = buildNoiseTable(seed, cells);
+ } else {
+ table = buildNoiseTable(seed, NOISE_PERIOD);
+ }
+ const span = scale > 0 ? scale : 1;
+ const halfWidth = Math.abs(width) / 2;
+
+ for (let i = 0; i < count; i++) {
+ const tangent = tangentAt(list, i, prev, next);
+ const p = list[i];
+ if (!tangent) {
+ left[i] = { x: p.x, y: p.y };
+ right[i] = { x: p.x, y: p.y };
+ continue;
+ }
+ const t = closed && cells > 0 ? (pos[i] / perimeter) * cells : pos[i] / span;
+ // Clamped well above zero so both sides keep a usable offset even when the
+ // caller asks for a large `variation`.
+ const factor = Math.max(0.05, 1 + variation * sampleTable(table, t));
+ const w = halfWidth * factor;
+ left[i] = { x: p.x - tangent.y * w, y: p.y + tangent.x * w };
+ right[i] = { x: p.x + tangent.y * w, y: p.y - tangent.x * w };
+ }
+ if (closed && duplicate) {
+ left[count - 1] = { x: left[0].x, y: left[0].y };
+ right[count - 1] = { x: right[0].x, y: right[0].y };
+ }
+ return [left, right];
+}
diff --git a/bluebey-studio/src/hats.js b/bluebey-studio/src/hats.js
new file mode 100644
index 0000000..7a54c46
--- /dev/null
+++ b/bluebey-studio/src/hats.js
@@ -0,0 +1,135 @@
+import * as THREE from 'three';
+
+/**
+ * Hats for ぶるべー. Each hat is a small group of primitives whose base sits at
+ * y = 0, so the app can drop it straight onto the top of the head (see
+ * `hatMount` in src/main.js). The character's front is +z, so a brim or a badge
+ * faces +z.
+ *
+ * These are deliberately not textured: like the props, they are built from a few
+ * rounded primitives so they take a clean outline and read in every render style.
+ */
+
+const material = (color, opts = {}) => new THREE.MeshStandardMaterial({
+ color,
+ roughness: 0.72,
+ metalness: 0,
+ ...opts,
+});
+
+function add(parent, geometry, mat, x = 0, y = 0, z = 0) {
+ const mesh = new THREE.Mesh(geometry, mat);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = true;
+ mesh.receiveShadow = false;
+ parent.add(mesh);
+ return mesh;
+}
+
+const cylinder = (rt, rb, h, seg = 24) => new THREE.CylinderGeometry(rt, rb, h, seg);
+const sphere = (r, w = 24, h = 16, phiStart = 0, phiLength = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI) => (
+ new THREE.SphereGeometry(r, w, h, phiStart, phiLength, thetaStart, thetaLength)
+);
+const box = (w, h, d) => new THREE.BoxGeometry(w, h, d);
+
+/**
+ * The 線画 fill a part takes, as how far it steps from the paper towards the ink
+ * (see `addMesh` in src/styles.js). Every hat part already gets the light default
+ * tone, but two parts of the *same* hat then come out the same shade - so a part
+ * that sits flush on another (a band on a crown) loses its seam. Tagging it with
+ * a darker tone puts that seam back, without touching リアル / フラット, where the
+ * part's own colour already draws it.
+ */
+function tone(mesh, strength) {
+ mesh.userData.tone = strength;
+ return mesh;
+}
+
+/** シルクハット: a tall black crown, a narrow band and a flat brim. */
+function buildSilk() {
+ const group = new THREE.Group();
+ const black = material('#1b1722');
+ const band = material('#3a2f4a');
+ add(group, cylinder(1.52, 1.52, 0.12, 32), black, 0, 0.06, 0);
+ add(group, cylinder(1.00, 1.04, 1.62, 32), black, 0, 0.93, 0);
+ tone(add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0), 0.34);
+ return group;
+}
+
+/** キャップ: a baseball cap - a low dome, a rim and a bill at the front. */
+function buildCap() {
+ const group = new THREE.Group();
+ const blue = material('#3f6fd0');
+ const dark = material('#2f54a4');
+ // The crown: a hemisphere whose flat face sits on y = 0.
+ const crown = add(group, sphere(1.08, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), blue, 0, 0, 0);
+ crown.scale.set(1, 0.92, 1);
+ tone(add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0), 0.30);
+ // The bill, tipped down a little at the front.
+ const bill = tone(add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92), 0.30);
+ bill.scale.set(1, 1, 1.25);
+ bill.rotation.x = -0.12;
+ tone(add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0), 0.30);
+ return group;
+}
+
+/** コック帽: a tall pleated toque - a cylindrical band under a big puffy crown. */
+function buildChef() {
+ const group = new THREE.Group();
+ const white = material('#f7f6f2');
+ const shade = material('#e6e3da');
+ // The band: a tall cylinder that sits on the head.
+ add(group, cylinder(0.86, 0.92, 0.95, 28), white, 0, 0.475, 0);
+ tone(add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0), 0.26);
+ // The crown: a big squashed sphere ringed with lobes, so the top reads as the
+ // classic pleated toque rather than a plain dome.
+ const puff = add(group, sphere(1.24, 26, 20), white, 0, 1.30, 0);
+ puff.scale.set(1, 0.82, 1);
+ for (let i = 0; i < 8; i += 1) {
+ const a = (i / 8) * Math.PI * 2;
+ const lobe = add(group, sphere(0.5, 14, 12), white, Math.cos(a) * 0.92, 1.52, Math.sin(a) * 0.92);
+ lobe.scale.set(1, 0.9, 1);
+ }
+ const top = add(group, sphere(0.62, 16, 12), white, 0, 1.92, 0);
+ top.scale.set(1, 0.78, 1);
+ return group;
+}
+
+/** 消防士の帽子: a red helmet with a brim, a top ridge and a small front badge. */
+function buildFire() {
+ const group = new THREE.Group();
+ const red = material('#c5342f');
+ const dark = material('#8f241f');
+ const gold = material('#f0c24a', { metalness: 0.4, roughness: 0.4 });
+ const dome = add(group, sphere(1.06, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0);
+ dome.scale.set(1, 0.95, 1);
+ const brim = tone(add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18), 0.28);
+ brim.scale.set(1, 1, 1.1);
+ // The ridge along the top, front to back.
+ const crest = tone(add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0), 0.28);
+ crest.rotation.x = -0.05;
+ tone(add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02), 0.22);
+ return group;
+}
+
+const BUILDERS = {
+ silk: buildSilk,
+ cap: buildCap,
+ chef: buildChef,
+ fire: buildFire,
+};
+
+/** The hats offered in the panel: `none` clears it. */
+export const HAT_LIBRARY = [
+ { id: 'none', label: 'なし' },
+ { id: 'silk', label: 'シルクハット' },
+ { id: 'cap', label: 'キャップ' },
+ { id: 'chef', label: 'コック帽' },
+ { id: 'fire', label: '消防士' },
+];
+
+/** Builds the named hat, or `null` for `none` / an unknown name. */
+export function buildHat(id) {
+ const builder = BUILDERS[id];
+ return builder ? builder() : null;
+}
diff --git a/bluebey-studio/src/history.js b/bluebey-studio/src/history.js
new file mode 100644
index 0000000..d988b50
--- /dev/null
+++ b/bluebey-studio/src/history.js
@@ -0,0 +1,105 @@
+/**
+ * Undo / redo for the whole studio state.
+ *
+ * The studio is a big pile of sliders, and before this every experiment was
+ * one-way: nudging the wrong slider meant dialling the old value back by hand.
+ * A history of whole-state snapshots is the simplest thing that can possibly
+ * work here, because the state is already the single source of truth and every
+ * control funnels through `applyState`.
+ *
+ * Two details matter for it to feel right rather than merely correct:
+ *
+ * - Dragging a slider fires an event per pixel, which would bury the history in
+ * hundreds of near-identical entries. Entries therefore carry a label, and a
+ * new entry with the *same* label within `coalesceMs` REPLACES the previous
+ * one instead of stacking on top of it. So a whole drag becomes one step.
+ *
+ * - `view.backgroundImage` can be a multi-megabyte data URL, and `state` also
+ * holds the caption text. The snapshots copy objects by hand rather than via
+ * `JSON.parse(JSON.stringify(...))`, because assigning a string in JavaScript
+ * shares it instead of duplicating it - so a hundred snapshots of a heavy
+ * state stay cheap.
+ */
+
+const SHALLOW_TYPES = new Set(['string', 'number', 'boolean', 'undefined']);
+
+/** Deep copy that shares string data (and handles the odd null/array). */
+function copy(value) {
+ if (value === null || SHALLOW_TYPES.has(typeof value)) return value;
+ if (Array.isArray(value)) return value.map(copy);
+ if (typeof value === 'object') {
+ const out = {};
+ for (const [key, inner] of Object.entries(value)) out[key] = copy(inner);
+ return out;
+ }
+ return value; // functions, symbols: not part of the saved state
+}
+
+export class History {
+ constructor({ limit = 120, coalesceMs = 700, onChange = null } = {}) {
+ this.limit = Math.max(2, limit);
+ this.coalesceMs = coalesceMs;
+ this.onChange = onChange;
+ /** @type {{ state: object, label: string, at: number }[]} */
+ this.entries = [];
+ this.index = -1;
+ }
+
+ /** Forget everything and start from `state` (call after load/reset). */
+ reset(state, label = 'start') {
+ this.entries = [{ state: copy(state), label, at: Date.now() }];
+ this.index = 0;
+ this.onChange?.(this);
+ }
+
+ get canUndo() { return this.index > 0; }
+ get canRedo() { return this.index >= 0 && this.index < this.entries.length - 1; }
+
+ /** Label of the step undo would jump to, for the button tooltip. */
+ get undoLabel() { return this.canUndo ? this.entries[this.index].label : null; }
+ get redoLabel() { return this.canRedo ? this.entries[this.index + 1].label : null; }
+
+ /**
+ * Record the state *after* a change. Repeating the same label in quick
+ * succession (a slider drag) keeps a single entry that follows the value.
+ */
+ push(state, label = '変更') {
+ const now = Date.now();
+ const top = this.entries[this.index];
+ const sameDrag = top
+ && top.label === label
+ && now - top.at <= this.coalesceMs
+ && this.index === this.entries.length - 1;
+
+ if (sameDrag) {
+ this.entries[this.index] = { state: copy(state), label, at: now };
+ } else {
+ this.entries.length = this.index + 1;
+ this.entries.push({ state: copy(state), label, at: now });
+ if (this.entries.length > this.limit) this.entries.shift();
+ this.index = this.entries.length - 1;
+ }
+ this.onChange?.(this);
+ return this;
+ }
+
+ /** The previous snapshot, or `null` when there is nothing to go back to. */
+ undo() {
+ if (!this.canUndo) return null;
+ this.index -= 1;
+ this.onChange?.(this);
+ return copy(this.entries[this.index].state);
+ }
+
+ redo() {
+ if (!this.canRedo) return null;
+ this.index += 1;
+ this.onChange?.(this);
+ return copy(this.entries[this.index].state);
+ }
+
+ /** A plain description of where we are, for tests and debug output. */
+ describe() {
+ return this.entries.map((entry, i) => `${i === this.index ? '*' : ' '}${entry.label}`).join(' | ');
+ }
+}
diff --git a/bluebey-studio/src/look.js b/bluebey-studio/src/look.js
new file mode 100644
index 0000000..d755c3b
--- /dev/null
+++ b/bluebey-studio/src/look.js
@@ -0,0 +1,184 @@
+import * as THREE from 'three';
+import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
+
+/**
+ * The "look" of the scene, as opposed to the character's shape: body colours,
+ * mirroring, shadows and the lighting environment.
+ *
+ * These all live here rather than in `main.js` because they are a *policy* about
+ * how the model should be presented, and they need to be re-applied as a group
+ * whenever any of them changes. `main.js` only has to call `apply()`.
+ *
+ * Two things are worth knowing:
+ *
+ * - Colours are written onto the model's ORIGINAL materials. The flat/toon
+ * style caches its own materials derived from them, so `styles.refreshColors()`
+ * has to be called afterwards or the toon shading keeps the old colour.
+ *
+ * - Shadow softness is `LightShadow.radius` with a PCF soft shadow map, plus a
+ * hand-drawn contact blob for the "ground shadow only" mode. (VSM was tried
+ * for its wider blur and cut the shadow off in a straight line where it met
+ * the feet - see the note in `apply`.)
+ */
+
+export const ENVIRONMENTS = [
+ { value: 'gradient', label: 'スタジオ(明るい)' },
+ { value: 'room', label: '室内(自然な反射)' },
+ { value: 'none', label: 'なし(のっぺり)' },
+];
+
+/** Material name -> which entry of `render.colors` it takes. */
+const PART_COLORS = {
+ blb: 'body',
+ Hand: 'accent',
+ Foots: 'feet',
+ Nose: 'nose',
+ Leaf: 'leaf',
+ Vein: 'vein',
+};
+
+const clamp01 = (value) => Math.min(1, Math.max(0, Number(value) || 0));
+
+/**
+ * A soft blob that sits under the feet: the shadow a toy would cast on a table.
+ * It is a plain plane with a radial gradient, so it is cheap and it works in the
+ * line-art styles too, where the real shadow map is switched off.
+ */
+function makeContactShadow(size) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 128;
+ canvas.height = 128;
+ const ctx = canvas.getContext('2d');
+ const gradient = ctx.createRadialGradient(64, 64, 4, 64, 64, 62);
+ gradient.addColorStop(0, 'rgba(0,0,0,0.55)');
+ gradient.addColorStop(0.55, 'rgba(0,0,0,0.28)');
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 128, 128);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const material = new THREE.MeshBasicMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false,
+ toneMapped: false,
+ });
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
+ mesh.rotation.x = -Math.PI / 2;
+ mesh.renderOrder = -0.5;
+ mesh.userData.isHelper = true;
+ mesh.name = 'contact-shadow';
+ mesh.scale.setScalar(Math.max(1, size.x) * 1.15);
+ return mesh;
+}
+
+export function createLook({ renderer, scene, styles, model, ground, key, character, gradientEnvironment }) {
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ const roomEnvironment = pmrem.fromScene(new RoomEnvironment(), 0.06).texture;
+
+ const contact = makeContactShadow(model.size);
+ scene.add(contact);
+
+ const environmentFor = (name) => {
+ if (name === 'room') return roomEnvironment;
+ if (name === 'none') return null;
+ return gradientEnvironment ?? null;
+ };
+
+ function applyColors(colors) {
+ if (!colors) return;
+ let touched = false;
+ for (const mesh of model.parts.body) {
+ const material = styles.originals.get(mesh) ?? mesh.material;
+ const part = PART_COLORS[material?.name];
+ if (!part || !colors[part]) continue;
+ if (!material.color) material.color = new THREE.Color();
+ material.color.set(colors[part]);
+ touched = true;
+ }
+ // The toon materials are cached copies, so they need the same edit.
+ if (touched) styles.refreshColors?.();
+ return touched;
+ }
+
+ return {
+ contact,
+
+ /**
+ * @param {object} render `state.render`
+ * @param {{ line?: boolean }} [context] `line` is true in the line-art
+ * styles, where shadows and reflections are deliberately switched off.
+ */
+ apply(render, { line = false } = {}) {
+ if (!render) return;
+ applyColors(render.colors);
+
+ // Mirroring by negative scale is safe: the renderer flips the winding for
+ // a negative determinant, and the normals go through the inverse-transpose.
+ character.scale.x = render.mirror ? -1 : 1;
+
+ const wantsShadow = render.shadow !== false && !line;
+ const blobOnly = render.contactShadow === true;
+ ground.visible = wantsShadow && !blobOnly;
+ ground.material.opacity = clamp01(render.shadowOpacity ?? 0.22);
+ key.castShadow = wantsShadow && !blobOnly;
+ key.shadow.needsUpdate = true;
+
+ // The blob is drawn in BOTH modes. On its own it *is* the shadow (the
+ // "ground shadow only" setting); with the shadow map on it fills the gap
+ // where the body hides its own shadow right at the feet, which otherwise
+ // reads as "the shadow is cut off". It is the only thing keeping the
+ // character looking like it is standing on the floor rather than above it.
+ // The blob sits at the feet. It used to be drawn whenever the shadow was on,
+ // which left a faint ring behind at the origin once the character was moved;
+ // it is now opt-in (`render.contactBlob`, default off). It is always shown in
+ // the 「接地影だけ」 mode, where it is the only shadow there is.
+ contact.visible = wantsShadow && (blobOnly || render.contactBlob === true);
+ contact.material.opacity = clamp01((render.shadowOpacity ?? 0.22) * (blobOnly ? 2.6 : 1.6));
+ const spread = Math.max(0.2, (model.size.x * 1.15) / Math.max(0.05, render.shadowSoftness ?? 1.6));
+ contact.scale.setScalar(Math.max(0.5, model.size.x * 1.35 - spread * 0.25));
+ contact.position.y = 0.012;
+
+ const softness = Number(render.shadowSoftness) || 1.6;
+ // PCF, always. three 0.186 removed PCFSoftShadowMap (asking for it warns and
+ // falls back to this anyway), and VSM needs a depth-variance bias that cut
+ // the shadow away in a straight line right at the feet. `radius` is what
+ // softens PCF.
+ if (renderer.shadowMap.type !== THREE.PCFShadowMap) {
+ renderer.shadowMap.type = THREE.PCFShadowMap;
+ // Every shadow-receiving material has to be recompiled for the switch.
+ ground.material.needsUpdate = true;
+ for (const mesh of model.parts.body) {
+ const material = mesh.material;
+ if (Array.isArray(material)) material.forEach((m) => { m.needsUpdate = true; });
+ else if (material) material.needsUpdate = true;
+ }
+ }
+ // PCF's radius is in *shadow map texels*, and the map covers about 15 world
+ // units, so a radius of 1-2 is invisible. This scales it into something the
+ // eye can see without the sampling turning into noise.
+ if ('radius' in key.shadow) key.shadow.radius = Math.max(0.5, softness * 5);
+
+ const environment = line ? null : environmentFor(render.environment);
+ if (scene.environment !== environment) scene.environment = environment;
+ if ('environmentIntensity' in scene) {
+ scene.environmentIntensity = Number(render.envIntensity ?? 1);
+ }
+ },
+
+ /** The colours a theme would set, as a plain `{ part: '#rrggbb' }`. */
+ palette(theme) {
+ return { ...(theme?.colors ?? {}) };
+ },
+
+ dispose() {
+ contact.geometry.dispose();
+ contact.material.map?.dispose();
+ contact.material.dispose();
+ contact.removeFromParent();
+ roomEnvironment.dispose();
+ pmrem.dispose();
+ },
+ };
+}
diff --git a/bluebey-studio/src/main.js b/bluebey-studio/src/main.js
new file mode 100644
index 0000000..20c6c7d
--- /dev/null
+++ b/bluebey-studio/src/main.js
@@ -0,0 +1,3056 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+import { loadModel } from './model.js';
+import { Face } from './face.js';
+import { Styles, isLineStyle } from './styles.js';
+import { Rig } from './rig.js';
+import { Animator, createRecorder, startRecording, stopRecording } from './animation.js';
+import { defaultState, applyPatch, THEMES } from './presets.js';
+import { buildPanel } from './panel.js';
+import * as exporter from './exporter.js';
+import { toast } from './ui.js';
+import { History } from './history.js';
+import { createLook } from './look.js';
+import { createClipper } from './clip.js';
+import { buildHat } from './hats.js';
+import { createBackdrop, backdropStyle } from './background.js';
+import { drawCaption, layoutCaption } from './caption.js';
+import { loadFont, captionFontStack } from './textOutlines.js';
+import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js';
+import { rollAll, decodeSeed } from './gacha.js';
+import { encodeState, decodeState } from './urlState.js';
+import { createMouthFlap, levelToMouth } from './mouthFlap.js';
+import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js';
+import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js';
+
+const DEG = Math.PI / 180;
+/** The other direction: what three's Spherical reports is radians. */
+const RAD_TO_DEG = 180 / Math.PI;
+const MODEL_URL = 'assets/bluebey.glb';
+
+/** The caption font is only fetched when a bubble is first used. */
+const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf';
+const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2';
+/** How far the eyes can swing for a look-at target, in radians. */
+const MAX_LOOK_YAW = 0.5;
+const MAX_LOOK_PITCH = 0.34;
+
+/** Undo steps are labelled by the part of the state that changed. */
+const SCOPE_LABELS = {
+ all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', caption: 'セリフ', gion: '擬音',
+};
+
+/**
+ * The single-file build inlines the fonts and the backdrop library, so the same
+ * code reads a data URL there and a file URL in the normal build.
+ */
+function assetUrl(file, kind) {
+ const inlined = globalThis.__BLUEBEY_FONTS__ ?? {};
+ if (kind === 'background') {
+ const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {};
+ return backdrops[file] ?? `assets/backgrounds/${file}.webp`;
+ }
+ if (kind === 'beard') {
+ const beards = globalThis.__BLUEBEY_BEARDS__ ?? {};
+ return beards[file] ?? `assets/beards/${file}`;
+ }
+ if (kind === 'brow') {
+ const brows = globalThis.__BLUEBEY_BROWS__ ?? {};
+ return brows[file] ?? `assets/brows/${file}`;
+ }
+ return inlined[file] ?? `assets/fonts/${file}`;
+}
+
+/**
+ * Where the model comes from. The normal build fetches `assets/bluebey.glb`;
+ * the single-file build (tools/build-standalone.mjs) inlines the same bytes as
+ * base64 in `window.__BLUEBEY_MODEL__`, which also lets the page be opened
+ * straight from disk without any web server.
+ */
+function resolveModelSource() {
+ const injected = globalThis.__BLUEBEY_MODEL__;
+ if (!injected) return MODEL_URL;
+ if (typeof injected !== 'string') return injected;
+ try {
+ const binary = atob(injected);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+ } catch (error) {
+ console.error('inline model could not be decoded, falling back to the file', error);
+ return MODEL_URL;
+ }
+}
+
+const state = defaultState();
+
+const app = {
+ state,
+ model: null,
+ container: null,
+ ready: false,
+ needsRender: true,
+ /** The 擬音 stamp the panel is editing, so the viewport can outline it. */
+ gionSelected: null,
+ /** Set by init(), used by the panel. */
+ actions: {},
+};
+
+/* ------------------------------------------------------------------ startup */
+
+// The view-rig classes are declared below this point; deferring by a microtask
+// guarantees the whole module has finished evaluating before anything is built.
+queueMicrotask(() => {
+ init().catch((error) => {
+ console.error(error);
+ showLoadError(error);
+ });
+});
+
+async function init() {
+ const canvas = document.getElementById('view');
+ // #stage owns the layout; the canvas only fills it (see style.css).
+ const stage = document.getElementById('stage') ?? canvas;
+
+ const renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: true,
+ alpha: true,
+ preserveDrawingBuffer: true,
+ });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFShadowMap;
+
+ const scene = new THREE.Scene();
+ const environment = makeEnvironment(renderer);
+
+ const key = new THREE.DirectionalLight(0xffffff, 2.1);
+ key.castShadow = true;
+ key.shadow.mapSize.set(1536, 1536);
+ key.shadow.bias = -0.0008;
+ key.shadow.normalBias = 0.02;
+ const fill = new THREE.DirectionalLight(0xffffff, 0.5);
+ const ambient = new THREE.HemisphereLight(0xffffff, 0xd9d0f2, 0.9);
+ scene.add(key, key.target, fill, ambient);
+
+ // A shadow catcher, not a solid floor: `depthWrite: false` keeps the plane from
+ // ever hiding the character, so moving ぶるべー below it no longer makes the body
+ // (or its textures) vanish - the shadow still falls on it, but it never occludes.
+ const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(200, 200),
+ new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }),
+ );
+ ground.rotation.x = -Math.PI / 2;
+ ground.receiveShadow = true;
+ scene.add(ground);
+
+ // The camera lives in two places: `state.view` (what the panel, the saved file
+ // and a shared link use) and the orbit controls (what the mouse moves). Capture
+ // the controls' own position back into the state on every change, so the two
+ // never disagree - otherwise any refresh of the view yanked the camera back to
+ // wherever the panel had last put it.
+ const view = new ViewRig(canvas, () => {
+ view.captureInto(state.view);
+ app.needsRender = true;
+ });
+
+ /**
+ * The screen-space outline (see src/outline.js). The normal pass must not see
+ * the ground, the contact shadow or the gizmo, so they are excluded once those
+ * objects exist (see below).
+ */
+ const outline = createScreenOutline({
+ renderer,
+ scene,
+ camera: view.camera,
+ width: canvas.clientWidth || 1280,
+ height: canvas.clientHeight || 800,
+ });
+ outline.exclude([ground]);
+
+ /**
+ * The drawing buffer must follow the canvas' CSS size. Without this the
+ * browser stretches the default 300x150 buffer across the window and the
+ * whole picture looks coarse (and every exported trace is built from those
+ * few pixels).
+ *
+ * But it must not be *unlimited* either: on a large or high-density display
+ * the buffer can reach many millions of pixels, and with antialiasing, a
+ * shadow map and preserveDrawingBuffer on top, a weak GPU can take seconds
+ * per frame - which looks exactly like "nothing is showing". So the pixel
+ * count is capped and the ratio is lowered automatically on slow hardware.
+ */
+ const MAX_DRAWING_PIXELS = 2_600_000;
+ const QUALITY_FLOOR = 0.5;
+ let quality = Math.min(window.devicePixelRatio || 1, 2);
+ let autoReductions = 0;
+
+ function fitQuality(width, height, wanted) {
+ let ratio = wanted;
+ while (ratio > QUALITY_FLOOR && width * height * ratio * ratio > MAX_DRAWING_PIXELS) {
+ ratio = Math.round((ratio - 0.1) * 100) / 100;
+ }
+ return Math.max(QUALITY_FLOOR, ratio);
+ }
+
+ let lastResize = 0;
+ let resizeBurst = 0;
+
+ /** Declared early: the viewport can resize before the model has loaded. */
+ let captionCanvas = null;
+ /** Set while undo/redo replays a snapshot, so it is not recorded again. */
+ let suspendHistory = false;
+
+ // ------------------------------------------------------------------- 擬音
+ // `redrawCaption` runs from the very first `resizeViewport`, so the sheet cache
+ // and the drag handles have to exist before it does (a `const` cannot be read
+ // before it is evaluated). The drawing itself lives further down, next to the
+ // caption overlay it is layered under.
+ const gionImages = new Map(); // sheet name -> loaded Image
+ const gionLoading = new Map(); // sheet name -> in-flight Promise
+ const gionHandles = new Map(); // stamp id -> transparent drag div
+ let gionDrag = null;
+ /** Hands out stamp ids; a counter so removing one never reuses a live id. */
+ let gionSeq = 0;
+
+ function resizeViewport() {
+ const width = stage.clientWidth || window.innerWidth;
+ const height = stage.clientHeight || window.innerHeight;
+
+ // Safety net: if something ever feeds the viewport size back into itself,
+ // stop flipping the drawing buffer on and off and say so.
+ const now = performance.now();
+ if (now - lastResize < 60) {
+ resizeBurst += 1;
+ if (resizeBurst > 30 && resizeBurst % 30 === 0) {
+ console.warn('[bluebey] too many resizes in a row; ignoring them', resizeBurst);
+ }
+ if (resizeBurst > 30) return;
+ } else {
+ resizeBurst = 0;
+ }
+ lastResize = now;
+
+ const wanted = Math.min(window.devicePixelRatio || 1, 2);
+ quality = fitQuality(width, height, wanted);
+ renderer.setPixelRatio(quality);
+ renderer.setSize(width, height, false);
+ outline.setSize(width * quality, height * quality);
+ view.resize();
+ redrawCaption();
+ app.needsRender = true;
+ }
+ resizeViewport();
+ new ResizeObserver(() => resizeViewport()).observe(stage);
+
+ // Losing the WebGL context leaves a permanently blank canvas, so say so
+ // instead of leaving the user staring at nothing.
+ canvas.addEventListener('webglcontextlost', (event) => {
+ event.preventDefault();
+ showNotice('描画が止まりました(WebGLコンテキストを失いました)。\n'
+ + 'ブラウザのウィンドウを小さくするか、再読み込みしてください。');
+ });
+ canvas.addEventListener('webglcontextrestored', () => {
+ app.needsRender = true;
+ });
+
+ // -------------------------------------------------------------- the model
+ const model = await loadModel(resolveModelSource(), {
+ onProgress: (ratio) => setLoadingProgress(ratio),
+ });
+ app.model = model;
+
+ // The character hangs off a mirror group of its own, so "左右反転" flips the
+ // model without touching the pose offset (`container.position`) or the props.
+ const mirrorGroup = new THREE.Group();
+ mirrorGroup.add(model.root);
+ const container = new THREE.Group();
+ container.add(mirrorGroup);
+ scene.add(container);
+ app.container = container;
+
+ // 小物 live outside the mirror group: they are scenery, not part of the body.
+ const propRoot = new THREE.Group();
+ scene.add(propRoot);
+
+ // --- 鼻ちょうちん: a modelled bubble hung under the nose --------------------
+ // The nose is a *skinned* part of the body, so a child of the nose mesh would
+ // sit at the origin instead of on the face. The whole body (nose included)
+ // rides the `master` bone, so the bubble is parented there, placed at the nose's
+ // rest position in that bone's local frame, and read from the face state each
+ // frame in the render loop.
+ function applySnotBubble(group, snot) {
+ if (!group) return;
+ group.visible = snot?.enabled === true;
+ if (!group.visible) return;
+ // The offsets are applied in the *bone's* frame, along the world axes measured
+ // once at rest, so the bubble travels with the body. Recomputing them against
+ // the live world matrix made it snap back whenever the body had moved.
+ const k = 0.01; // artwork px -> world units, enough to nudge the bubble
+ const { localBase, axes } = group.userData;
+ group.position.copy(localBase)
+ .addScaledVector(axes.x, (snot.offsetX ?? 0) * k)
+ .addScaledVector(axes.y, (snot.offsetY ?? 0) * k)
+ .addScaledVector(axes.z, (snot.offsetZ ?? 0) * k);
+ group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1;
+ group.scale.setScalar(group.userData.baseScale);
+ group.userData.material.color.set(snot.color ?? '#dfe8ff');
+ }
+
+ const snotBubble = (() => {
+ const nose = model.parts.noseMesh;
+ const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master');
+ if (!nose || !master || !nose.isSkinnedMesh) return null;
+ model.root.updateMatrixWorld(true);
+ // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the
+ // back of the model, in fact) - sample the skinned positions instead, which is
+ // where the nose actually ends up.
+ const attr = nose.geometry.attributes.position;
+ const step = Math.max(1, Math.floor(attr.count / 400));
+ const point = new THREE.Vector3();
+ const centre = new THREE.Vector3();
+ let samples = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ centre.add(point);
+ samples += 1;
+ }
+ if (!samples) return null;
+ centre.multiplyScalar(1 / samples);
+ nose.localToWorld(centre);
+ let radius = 0;
+ for (let i = 0; i < attr.count; i += step) {
+ point.fromBufferAttribute(attr, i);
+ nose.applyBoneTransform(i, point);
+ nose.localToWorld(point);
+ radius = Math.max(radius, point.distanceTo(centre));
+ }
+ if (!Number.isFinite(radius) || radius <= 0) radius = 0.25;
+ const material = new THREE.MeshStandardMaterial({
+ color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92,
+ });
+ const group = new THREE.Group();
+ group.name = 'snot-bubble';
+ const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material);
+ group.add(bubble);
+ const bone = master.bone;
+ const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7));
+ const localBase = bone.worldToLocal(baseWorld.clone());
+ // World-axis directions expressed in the bone's frame, captured at rest: the
+ // sliders keep meaning "right / up / towards the viewer" while the bubble rides
+ // with the body.
+ const boneQuat = new THREE.Quaternion();
+ bone.getWorldQuaternion(boneQuat);
+ const invQuat = boneQuat.clone().invert();
+ const axes = {
+ x: new THREE.Vector3(1, 0, 0).applyQuaternion(invQuat),
+ y: new THREE.Vector3(0, 1, 0).applyQuaternion(invQuat),
+ z: new THREE.Vector3(0, 0, 1).applyQuaternion(invQuat),
+ };
+ group.position.copy(localBase);
+ group.userData = { bubble, material, bone, localBase, axes, baseScale: 1 };
+ group.visible = false;
+ bone.add(group);
+ return group;
+ })();
+ const snotMeshes = snotBubble ? [...snotBubble.children] : [];
+ applySnotBubble(snotBubble, state.face.eyes.snot);
+
+ // The hat rides the `master` bone like the snot bubble does: it is parented
+ // there, placed at the top of the head in that bone's local frame, and turned
+ // so it is world-aligned at rest (the bone's own rest rotation cancelled once).
+ // The character's front is +z, so the hats are modelled facing +z.
+ const hatMount = (() => {
+ const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master');
+ if (!master) return null;
+ model.root.updateMatrixWorld(true);
+ const bone = master.bone;
+ const group = new THREE.Group();
+ group.name = 'hat';
+ group.position.copy(bone.worldToLocal(new THREE.Vector3(0, model.bounds.max.y - 0.06, 0)));
+ group.quaternion.copy(bone.getWorldQuaternion(new THREE.Quaternion()).invert());
+ bone.add(group);
+ return group;
+ })();
+
+ let hatKindCurrent = null;
+ let hatObject = null;
+ /** A GLB hat the user loaded; kept so it can be re-chosen without re-importing. */
+ let customHatObject = null;
+ const hatMeshes = [];
+ /** Last transform applied, so the render loop only wakes when it changes. */
+ const hatXform = { x: null, z: null, height: null, tiltX: null, tiltZ: null };
+ /** Set once the outline pass exists, so a hat change can refresh the exclude set. */
+ let refreshOutlineExclusion = null;
+ /** Set once the style system exists, so a hat can register for styles + outline. */
+ let hatStyleRegister = null;
+ let hatStyleUnregister = null;
+
+ /**
+ * Swap the mounted hat for `built` (or clear it), keeping the style system and
+ * the outline exclusion in step.
+ *
+ * The old meshes are collected BEFORE they are unregistered: `removeMesh`
+ * deletes the outline hull (a child of the hat group) while we traverse, which
+ * used to shorten `children` mid-loop and call `.traverse` on `undefined`.
+ */
+ function mountHat(built) {
+ if (hatObject) {
+ const old = [];
+ hatObject.traverse((o) => { if (o.isMesh) old.push(o); });
+ hatMount.remove(hatObject);
+ for (const mesh of old) hatStyleUnregister?.(mesh);
+ // The custom model is kept so it can be re-chosen; a built hat is dropped.
+ if (hatObject !== customHatObject) {
+ for (const mesh of old) {
+ mesh.geometry?.dispose();
+ if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose());
+ else mesh.material?.dispose();
+ }
+ }
+ hatObject = null;
+ }
+ hatMeshes.length = 0;
+ if (built) {
+ hatMount.add(built);
+ hatObject = built;
+ const meshes = [];
+ built.traverse((o) => { if (o.isMesh) meshes.push(o); });
+ for (const mesh of meshes) {
+ hatMeshes.push(mesh);
+ hatStyleRegister?.(mesh);
+ }
+ }
+ refreshOutlineExclusion?.();
+ }
+
+ function applyHat(kind) {
+ if (!hatMount) return;
+ const custom = state.face.hat?.custom === true && customHatObject;
+ const key = custom ? 'custom' : kind;
+ if (key !== hatKindCurrent) {
+ hatKindCurrent = key;
+ mountHat(custom ? customHatObject : buildHat(kind));
+ }
+
+ // 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the
+ // character's front and +x its right: a plain translation and rotation read
+ // the way the sliders say.
+ const hat = state.face.hat ?? {};
+ const x = hat.x ?? 0;
+ const z = hat.z ?? 0;
+ const height = hat.height ?? 0;
+ const tiltX = hat.tiltX ?? 0;
+ const tiltZ = hat.tiltZ ?? 0;
+ if (hatObject) {
+ hatObject.position.set(x, height, z);
+ hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180);
+ }
+ if (x !== hatXform.x || z !== hatXform.z || height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) {
+ hatXform.x = x;
+ hatXform.z = z;
+ hatXform.height = height;
+ hatXform.tiltX = tiltX;
+ hatXform.tiltZ = tiltZ;
+ app.needsRender = true;
+ }
+ }
+ /**
+ * Import a GLB hat. It is centred sideways, dropped so its base sits at y = 0
+ * (the mount sits on the head), and scaled to about the head's width, then
+ * mounted like any built hat.
+ */
+ async function loadHatModel(file) {
+ if (!hatMount) return;
+ const buffer = await exporter.readFileAsArrayBuffer(file);
+ const loader = new GLTFLoader();
+ const gltf = await new Promise((resolve, reject) => loader.parse(buffer, '', resolve, reject));
+ const root = gltf.scene;
+ const box = new THREE.Box3().setFromObject(root);
+ const size = box.getSize(new THREE.Vector3());
+ const target = Math.max(0.2, model.size.x * 0.95);
+ const scale = target / Math.max(size.x, size.z, 0.001);
+ root.scale.setScalar(scale);
+ box.setFromObject(root);
+ const center = box.getCenter(new THREE.Vector3());
+ root.position.x -= center.x;
+ root.position.z -= center.z;
+ root.position.y -= box.min.y;
+ root.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = false; } });
+ customHatObject = root;
+ state.face.hat.custom = true;
+ state.face.hat.kind = 'none';
+ hatKindCurrent = null;
+ refresh('all');
+ app.panel?.sync();
+ }
+
+ applyHat(state.face.hat?.kind ?? 'none');
+
+ const halfHeight = model.size.y * 0.5;
+ view.frame(model.size, halfHeight);
+
+ // shadow camera big enough for the whole character
+ const radius = Math.max(model.size.x, model.size.y, model.size.z) * 1.6;
+ const shadowCamera = key.shadow.camera;
+ shadowCamera.left = -radius;
+ shadowCamera.right = radius;
+ shadowCamera.top = radius;
+ shadowCamera.bottom = -radius;
+ shadowCamera.near = 0.5;
+ shadowCamera.far = radius * 9;
+ shadowCamera.updateProjectionMatrix();
+ key.target.position.set(0, halfHeight, 0);
+
+ // The light's offset from the character, so the key light (and therefore its
+ // shadow camera) can follow the character around: moving far from the origin
+ // used to leave the shadow behind and cut it off at the shadow camera's edge.
+ const keyOffset = new THREE.Vector3(0, model.size.y * 3, model.size.y * 3);
+ function updateKeyLight() {
+ key.target.position.set(container.position.x, halfHeight + container.position.y, container.position.z);
+ key.position.copy(key.target.position).add(keyOffset);
+ }
+
+ // ------------------------------------------------------------- controllers
+ const styles = new Styles({ meshes: model.parts.body });
+ // Hats are built at runtime, so they are not in the GLB's mesh list. Register
+ // them with the style system (and the current hat) so they follow flat/line art
+ // and get an outline hull like every other part.
+ hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true, lineOnly: true });
+ hatStyleUnregister = (mesh) => styles.removeMesh(mesh);
+ for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true, lineOnly: true });
+ const face = new Face({
+ eyeMesh: model.parts.eyeMesh,
+ mouthMesh: model.parts.mouthMesh,
+ hairMesh: model.parts.hairMesh,
+ originals: model.originals,
+ });
+ const animator = new Animator({ state });
+
+ // Beard drawings supplied as files (`assets/beards/.png`): a kind that has
+ // one is drawn from the file instead of the built-in strokes. A missing file is
+ // skipped, so the app works before any are added.
+ void (async () => {
+ // Each entry: a beard kind and the drawing in `assets/beards/`. A file ending
+ // `-right.png` is one side of a moustache; it is drawn twice (mirrored) with a
+ // `spacing` gap between them. A kind with no entry uses the built-in strokes.
+ const files = [
+ { kind: 'scotch', file: 'scotch.png' },
+ { kind: 'kaiser', file: 'kaiser-right.png' },
+ ];
+ const loaded = {};
+ await Promise.all(files.map(async ({ kind, file }) => {
+ try {
+ const response = await fetch(assetUrl(file, 'beard'));
+ if (!response.ok) return;
+ loaded[kind] = {
+ image: await createImageBitmap(await response.blob()),
+ half: file.endsWith('-right.png'),
+ };
+ } catch {
+ /* a missing drawing falls back to the built-in strokes */
+ }
+ }));
+ if (Object.keys(loaded).length) {
+ face.setBeardImages(loaded);
+ app.needsRender = true;
+ }
+ })();
+
+ // Brow drawings supplied as files (`assets/brows/.png`). A file ending
+ // `-right.png` is one side; it is drawn twice (mirrored) at each eye. Missing
+ // files are skipped, so the built-in ゴル風 strokes are the fallback.
+ void (async () => {
+ const files = [{ kind: 'gol', file: 'gol-right.png' }];
+ const loaded = {};
+ await Promise.all(files.map(async ({ kind, file }) => {
+ try {
+ const response = await fetch(assetUrl(file, 'brow'));
+ if (!response.ok) return;
+ loaded[kind] = {
+ image: await createImageBitmap(await response.blob()),
+ half: file.endsWith('-right.png'),
+ };
+ } catch {
+ /* a missing drawing falls back to the built-in strokes */
+ }
+ }));
+ if (Object.keys(loaded).length) {
+ face.setBrowImages(loaded);
+ app.needsRender = true;
+ }
+ })();
+
+ // ------------------------------------------------------------------- look
+ const look = createLook({
+ renderer,
+ scene,
+ styles,
+ model,
+ ground,
+ key,
+ character: mirrorGroup,
+ gradientEnvironment: environment,
+ });
+
+ // --------------------------------------------------------------- 見えない壁
+ // A clip plane that hides part of the character, so it can look half buried
+ // in a wall (see src/clip.js). Applied in `refresh`, with the render settings.
+ const clip = createClipper({ scene, renderer, model });
+ // A wall's position is stored relative to the character, so every time the
+ // walls are applied they are shifted by the body's own offset - that is what
+ // makes them travel with ぶるべー when it is moved.
+ const characterOffset = () => {
+ const root = state.pose?.root;
+ return { x: root?.[0] ?? 0, y: root?.[1] ?? 0, z: root?.[2] ?? 0 };
+ };
+ const applyWalls = () => clip.apply(
+ [state.render.wall, state.render.wall2],
+ characterOffset(),
+ wallYaw(),
+ );
+ // The character's own turn about Y: the `master` bone's Y (Shift+drag) plus any
+ // body yaw the 見る先 mode added. The walls take this too, so turning the
+ // character with Shift+drag no longer slides the hidden region around.
+ const wallYaw = () => {
+ const masterY = state.pose?.bones?.master?.[1] ?? 0;
+ return (masterY + (container.rotation.y * 180) / Math.PI) * DEG;
+ };
+
+ // --------------------------------------------------------------- backdrop
+ const backdrop = createBackdrop({
+ stage,
+ resolveUrl: (name) => assetUrl(name, 'background'),
+ onNeedsRender: () => { app.needsRender = true; },
+ });
+ /** The bitmap the exports paint behind the model (an Image or the camera video). */
+ let backdropImage = null;
+
+ /**
+ * Story-panel previews (keyed by panel id) and the counter that hands out
+ * those ids.
+ *
+ * WHY the previews live outside the state: a thumbnail is an image, and the
+ * state is exactly what a shared link carries - a data URL per panel would
+ * push the link past what a URL can hold. They are keyed by id and rebuilt as
+ * panels are added, so a restored link simply shows panels without previews.
+ * (Declared up here because `buildPanel` runs - and syncs - before the panel
+ * functions below are reached.)
+ */
+ const storyThumbs = new Map();
+ let storySeq = 0;
+
+ // ------------------------------------------------------- caption overlay
+ // The bubble is drawn into its own 2D canvas laid over the WebGL one, with the
+ // very same routine the PNG export uses - so the preview cannot lie.
+ captionCanvas = document.createElement('canvas');
+ captionCanvas.id = 'caption-layer';
+ captionCanvas.style.cssText =
+ 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2';
+ stage.append(captionCanvas);
+
+ /** opentype font for outline export and text layout (lazily fetched). */
+ let captionFont = null;
+ let captionFontPromise = null;
+ /** Where each bubble was last drawn, in CSS pixels (in state order). */
+ const CAPTION_KEYS = ['caption', 'caption2', 'narration'];
+ const captionBoxes = new Map();
+ const captionHandles = new Map();
+ /** Set while a bubble is being dragged. */
+ let captionDrag = null;
+
+ // The bubbles are draggable, but the caption canvas has to stay
+ // `pointer-events: none`: it covers the whole viewport, and the orbit controls
+ // and the bone picking live underneath it. So each bubble gets a separate
+ // transparent drag box, parked exactly over it by `positionCaptionHandles`.
+ for (const key of CAPTION_KEYS) {
+ const handle = document.createElement('div');
+ handle.id = `caption-handle-${key}`;
+ handle.style.cssText = 'position:absolute;display:none;cursor:move;'
+ + 'touch-action:none;pointer-events:auto;z-index:3';
+ handle.addEventListener('pointerdown', (event) => startCaptionDrag(event, key));
+ handle.addEventListener('pointermove', moveCaptionDrag);
+ handle.addEventListener('pointerup', endCaptionDrag);
+ handle.addEventListener('pointercancel', endCaptionDrag);
+ handle.dataset.captionHandle = '1';
+ stage.append(handle);
+ captionHandles.set(key, handle);
+ }
+
+ /** Park each drag target over its bubble (or hide it when it is not shown). */
+ function positionCaptionHandles() {
+ for (const key of CAPTION_KEYS) {
+ const handle = captionHandles.get(key);
+ const box = captionBoxes.get(key);
+ if (!handle) continue;
+ if (!box) {
+ handle.style.display = 'none';
+ continue;
+ }
+ // A few pixels of slack, so the rounded corners are still easy to grab.
+ handle.style.display = 'block';
+ handle.style.left = `${box.x - 4}px`;
+ handle.style.top = `${box.y - 4}px`;
+ handle.style.width = `${box.w + 8}px`;
+ handle.style.height = `${box.h + 8}px`;
+ }
+ }
+
+ function startCaptionDrag(event, key) {
+ const caption = state[key];
+ const box = captionBoxes.get(key);
+ if (caption?.enabled !== true || !box) return;
+ // Touching a bubble also selects it in the panel, so the sliders edit the one
+ // you just grabbed.
+ app.panel?.selectCaption?.(key);
+ event.preventDefault();
+ event.stopPropagation();
+ // Not all pointers can be captured (and a synthetic one cannot), so a
+ // failure here must not stop the drag from starting.
+ try {
+ captionHandles.get(key)?.setPointerCapture(event.pointerId);
+ } catch {
+ /* capture is a nicety, not a requirement */
+ }
+ captionDrag = {
+ key,
+ pointerId: event.pointerId,
+ fromX: event.clientX,
+ fromY: event.clientY,
+ boxX: box.x,
+ boxY: box.y,
+ };
+ }
+
+ function moveCaptionDrag(event) {
+ if (!captionDrag || event.pointerId !== captionDrag.pointerId) return;
+ const caption = state[captionDrag.key];
+ if (!caption) return;
+ event.preventDefault();
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ caption.x = (captionDrag.boxX + (event.clientX - captionDrag.fromX)) / width;
+ caption.y = (captionDrag.boxY + (event.clientY - captionDrag.fromY)) / height;
+ // Store back what the layout will actually use, so pushing past an edge does
+ // not pile up an out-of-range value that has to be undone before the bubble
+ // moves again. `refresh` redraws the bubble and records one undo step (the
+ // history coalesces a run of the same label).
+ const layout = layoutCaption(caption, { width, height, scale: 1, font: captionFont });
+ if (width > 0) caption.x = layout.box.x / width;
+ if (height > 0) caption.y = layout.box.y / height;
+ refresh('caption');
+ }
+
+ function endCaptionDrag(event) {
+ if (!captionDrag || (event && event.pointerId !== captionDrag.pointerId)) return;
+ captionDrag = null;
+ // The 横位置 / 縦位置 sliders catch up when the drag ends, not every pixel.
+ app.actions.syncPanel?.();
+ }
+
+ async function ensureCaptionFont() {
+ if (captionFontPromise) return captionFontPromise;
+ captionFontPromise = (async () => {
+ try {
+ const face = new FontFace('M PLUS Rounded 1c', `url(${assetUrl('bluebey-caption.woff2')})`);
+ document.fonts.add(await face.load());
+ } catch (error) {
+ console.warn('[bluebey] caption font face failed', error);
+ }
+ try {
+ captionFont = await loadFont(assetUrl('bluebey-caption.ttf'));
+ } catch (error) {
+ console.warn('[bluebey] caption outlines unavailable', error);
+ }
+ redrawCaption();
+ return captionFont;
+ })();
+ return captionFontPromise;
+ }
+
+ function redrawCaption() {
+ if (!captionCanvas) return;
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ const ratio = Math.min(2, window.devicePixelRatio || 1);
+ const wantW = Math.round(width * ratio);
+ const wantH = Math.round(height * ratio);
+ if (captionCanvas.width !== wantW || captionCanvas.height !== wantH) {
+ captionCanvas.width = wantW;
+ captionCanvas.height = wantH;
+ }
+ const ctx = captionCanvas.getContext('2d');
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ // 擬音はセリフより背面に置く: the bubbles stay readable on top of them.
+ const gionItems = state.gion?.items ?? [];
+ for (const item of gionItems) ensureGionSheet(item.sheet);
+ drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 });
+ for (const key of CAPTION_KEYS) {
+ const caption = state[key];
+ captionBoxes.delete(key);
+ if (!caption?.enabled) continue;
+ // The vendored font is only fetched once a bubble is actually used; when it
+ // arrives `ensureCaptionFont` redraws this canvas.
+ if (!captionFontPromise) void ensureCaptionFont();
+ captionBoxes.set(key, drawCaption(ctx, caption, { width, height, scale: 1, font: captionFont }));
+ }
+ positionCaptionHandles();
+ ensureGionHandles(gionItems);
+ positionGionHandles(gionItems);
+ }
+
+ // ----------------------------------------------------------------- 擬音
+ // A stamp is a crop of a sheet, drawn on the caption canvas *under* the bubbles.
+ // The canvas is `pointer-events: none`, so - exactly like a caption - each stamp
+ // gets its own transparent drag box, parked over it by `positionGionHandles`.
+
+ /** Load a sheet once, on first use, and redraw when it arrives. */
+ function ensureGionSheet(name) {
+ if (gionImages.has(name)) return Promise.resolve(gionImages.get(name));
+ if (gionLoading.has(name)) return gionLoading.get(name);
+ const image = new Image();
+ const promise = new Promise((resolve) => {
+ image.onload = () => resolve(image);
+ image.onerror = () => resolve(null);
+ }).then((loaded) => {
+ gionLoading.delete(name);
+ if (loaded) {
+ gionImages.set(name, loaded);
+ redrawCaption();
+ }
+ return loaded;
+ });
+ gionLoading.set(name, promise);
+ image.src = gionSheetUrl(name);
+ return promise;
+ }
+
+ /** Create one drag target per stamp, and drop the ones whose stamp is gone. */
+ function ensureGionHandles(items) {
+ const live = new Set();
+ for (const item of items) {
+ live.add(item.id);
+ if (gionHandles.has(item.id)) continue;
+ const handle = document.createElement('div');
+ handle.className = 'gion-handle';
+ handle.addEventListener('pointerdown', (event) => startGionDrag(event, item.id));
+ handle.addEventListener('pointermove', moveGionDrag);
+ handle.addEventListener('pointerup', endGionDrag);
+ handle.addEventListener('pointercancel', endGionDrag);
+ handle.dataset.gionHandle = '1';
+ stage.append(handle);
+ gionHandles.set(item.id, handle);
+ }
+ for (const [id, handle] of gionHandles) {
+ if (live.has(id)) continue;
+ handle.remove();
+ gionHandles.delete(id);
+ }
+ }
+
+ /** Park each drag target over its stamp (or hide it while the sheet loads). */
+ function positionGionHandles(items) {
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ for (const item of items) {
+ const handle = gionHandles.get(item.id);
+ if (!handle) continue;
+ const image = gionImages.get(item.sheet);
+ if (!imageReady(image)) {
+ handle.style.display = 'none';
+ continue;
+ }
+ const rect = stampRect(item, { width, height, scale: 1 }, imageAspect(image));
+ handle.style.display = 'block';
+ handle.style.left = `${rect.x}px`;
+ handle.style.top = `${rect.y}px`;
+ handle.style.width = `${rect.w}px`;
+ handle.style.height = `${rect.h}px`;
+ // Match the drawn stamp, which is rotated about its centre.
+ handle.style.transformOrigin = 'center';
+ handle.style.transform = item.rot ? `rotate(${item.rot}deg)` : 'none';
+ handle.classList.toggle('selected', item.id === app.gionSelected);
+ }
+ }
+
+ function startGionDrag(event, id) {
+ const item = (state.gion?.items ?? []).find((entry) => entry.id === id);
+ if (!item) return;
+ // Touching a stamp also selects it in the panel, so the sliders edit the one
+ // you just grabbed.
+ app.panel?.selectGion?.(id);
+ event.preventDefault();
+ event.stopPropagation();
+ try {
+ gionHandles.get(id)?.setPointerCapture(event.pointerId);
+ } catch {
+ /* capture is a nicety, not a requirement */
+ }
+ gionDrag = {
+ id,
+ pointerId: event.pointerId,
+ fromX: event.clientX,
+ fromY: event.clientY,
+ x: item.x ?? 0.5,
+ y: item.y ?? 0.5,
+ };
+ }
+
+ function moveGionDrag(event) {
+ if (!gionDrag || event.pointerId !== gionDrag.pointerId) return;
+ const item = (state.gion?.items ?? []).find((entry) => entry.id === gionDrag.id);
+ if (!item) return;
+ event.preventDefault();
+ const width = stage.clientWidth || 1280;
+ const height = stage.clientHeight || 800;
+ item.x = clamp01(gionDrag.x + (event.clientX - gionDrag.fromX) / width);
+ item.y = clamp01(gionDrag.y + (event.clientY - gionDrag.fromY) / height);
+ refresh('gion');
+ }
+
+ function endGionDrag(event) {
+ if (!gionDrag || (event && event.pointerId !== gionDrag.pointerId)) return;
+ gionDrag = null;
+ app.actions.syncPanel?.();
+ }
+
+ /** Add a stamp from a picker crop, centred, and hand it to the panel. */
+ function addGionStamp({ sheet, sx, sy, sw, sh }) {
+ gionSeq += 1;
+ const item = {
+ id: `g${gionSeq}`,
+ sheet,
+ sx, sy, sw, sh,
+ x: 0.5,
+ y: 0.5,
+ w: DEFAULT_STAMP_WIDTH,
+ rot: 0,
+ flip: false,
+ };
+ state.gion = state.gion ?? { items: [] };
+ state.gion.items = [...(state.gion.items ?? []), item];
+ app.gionSelected = item.id;
+ void ensureGionSheet(sheet);
+ applyState({}, { scope: 'gion', sync: true });
+ toast('擬音を追加しました');
+ }
+
+ // ------------------------------------------------------------------ props
+ let propSignature = '';
+
+ function applyProps() {
+ const items = state.props?.items ?? [];
+ const signature = JSON.stringify([items, state.render.colors]);
+ if (signature === propSignature) return;
+ propSignature = signature;
+ for (const child of [...propRoot.children]) {
+ // Hand every mesh back to the style system before the prop is disposed.
+ // `removeMesh` restores each mesh's own material (a prop under 線画 is
+ // wearing the shared paper material, which must not be disposed). Collect
+ // the meshes first: `removeMesh` deletes the outline hull, a child of the
+ // prop group.
+ const meshes = [];
+ child.traverse((object) => {
+ if (styles.originals.has(object)) meshes.push(object);
+ });
+ for (const mesh of meshes) styles.removeMesh(mesh);
+ propRoot.remove(child);
+ disposeProp(child);
+ }
+ for (const item of items) {
+ const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
+ let group;
+ try {
+ group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 });
+ } catch (error) {
+ console.warn('[bluebey] unknown prop', item.kind, error);
+ continue;
+ }
+ group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0);
+ group.rotation.set(
+ (item.rotX ?? def.rotX ?? 0) * DEG,
+ (item.rotY ?? def.rotY ?? 0) * DEG,
+ (item.rotZ ?? def.rotZ ?? 0) * DEG,
+ );
+ propRoot.add(group);
+ // Register with the style system, so フラット and 線画 reach the props too
+ // and each part gets an outline hull like the body and the hats. `lineOnly`
+ // keeps that outline out of リアル/フラット, where the props read by shading.
+ group.traverse((object) => {
+ if (object.isMesh) styles.addMesh(object, { lineOnly: true });
+ });
+ // 看板 only: paint the saved text onto the freshly built face (no-op for
+ // every other prop, which has no writing surface).
+ applyPropText(group, item.text);
+ }
+ refreshOutlineExclusion?.();
+ app.needsRender = true;
+ }
+
+ // ------------------------------------------------------------ prop dragging
+ // A prop can be dragged with the mouse. The pointer picks one, then moves it in
+ // the plane that faces the camera, so it follows the cursor at whatever depth
+ // it already sits at. (The ground plane is the other obvious choice, but the
+ // camera sits almost level with the floor, so the floor is nearly edge-on and
+ // a prop dragged across it would fly off.)
+ //
+ // The listener is on `stage` in the CAPTURE phase: that runs before the canvas
+ // and `view`'s container, so stopping the event there keeps the orbit controls
+ // and the bone picking out of a prop drag.
+ const propRaycaster = new THREE.Raycaster();
+ const propPointer = new THREE.Vector2();
+ const propPlane = new THREE.Plane();
+ const propPoint = new THREE.Vector3();
+ const propNormal = new THREE.Vector3();
+ let propDrag = null;
+ // The 見えない壁 uses the same machinery: `wallDrag` keeps the grabbed point's
+ // offset from the wall's anchor so grabbing a corner does not make it jump.
+ const wallDrag = { pointerId: null, key: 'wall', offset: new THREE.Vector3() };
+
+ /** The pointer in normalised device coordinates, i.e. what a raycaster wants. */
+ function pointerNdc(event) {
+ const rect = canvas.getBoundingClientRect();
+ return propPointer.set(
+ ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1,
+ -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1,
+ );
+ }
+
+ /** The prop under the pointer as `{ index, group, point }`, or null. */
+ function propAt(event) {
+ if (!propRoot.children.length) return null;
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ const hits = propRaycaster.intersectObjects(propRoot.children, true);
+ if (!hits.length) return null;
+ // The meshes hang off the group `propRoot` holds, so walk up to that group.
+ let node = hits[0].object;
+ while (node.parent && node.parent !== propRoot) node = node.parent;
+ const items = state.props?.items ?? [];
+ const index = propRoot.children.indexOf(node);
+ if (index < 0 || index >= items.length) return null;
+ return { index, group: node, point: hits[0].point.clone() };
+ }
+
+ /**
+ * The point on the wall's guide under the pointer, or null.
+ *
+ * Only while the wall is on AND its guide is switched on: the guide is both
+ * what there is to grab and the switch that says "I am placing the wall now",
+ * so with it off a huge invisible plane cannot swallow every orbit drag.
+ */
+ function wallAt(event) {
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ let best = null;
+ for (let index = 0; index < clip.guides.length; index += 1) {
+ const key = index === 0 ? 'wall' : 'wall2';
+ const wall = state.render[key];
+ if (wall?.on !== true || wall?.guide !== true) continue;
+ const hits = propRaycaster.intersectObject(clip.guides[index], false);
+ if (!hits.length) continue;
+ if (!best || hits[0].distance < best.distance) {
+ best = { index, key, point: hits[0].point.clone() };
+ }
+ }
+ return best;
+ }
+
+ function onPropPointerDown(event) {
+ if (event.button !== 0) return;
+ if (event.target?.dataset?.captionHandle) return; // a bubble drags itself
+ if (rig?.controls?.dragging) return; // the rotation gizmo is in charge
+ const hit = propAt(event);
+ if (hit) {
+ // Claim the pointer, so nothing underneath sees it.
+ event.stopPropagation();
+ event.preventDefault();
+ view.camera.getWorldDirection(propNormal);
+ propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), hit.point);
+ propDrag = {
+ pointerId: event.pointerId,
+ index: hit.index,
+ group: hit.group,
+ // Grabbing a corner rather than the middle must not make it jump.
+ offset: hit.group.position.clone().sub(hit.point),
+ };
+ stage.style.cursor = 'grabbing';
+ app.needsRender = true;
+ return;
+ }
+
+ const onWall = wallAt(event);
+ if (!onWall) {
+ // Nothing to grab. Remember the press, and settle on pointer-up whether it
+ // was a tap (set the 見る先 target) or the start of an orbit.
+ if (state.lookAt?.enabled === true && !rig?.controls?.dragging) {
+ lookTap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
+ }
+ return;
+ }
+ // A prop wins if it is under the pointer; otherwise the wall takes it.
+ event.stopPropagation();
+ event.preventDefault();
+ view.camera.getWorldDirection(propNormal);
+ propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall.point);
+ const wall = state.render[onWall.key] ?? (state.render[onWall.key] = defaultState().render[onWall.key]);
+ const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = onWall.point.x - offset.x;
+ const pz = onWall.point.z - offset.z;
+ wallDrag.pointerId = event.pointerId;
+ wallDrag.key = onWall.key;
+ // Keep the grab point's offset *in the wall's own (character-relative) frame*,
+ // so grabbing a corner does not make the wall jump.
+ wallDrag.offset.set(
+ (wall.x ?? 0) - (cos * px - sin * pz),
+ (wall.y ?? 0) - (onWall.point.y - offset.y),
+ (wall.z ?? 0) - (sin * px + cos * pz),
+ );
+ stage.style.cursor = 'grabbing';
+ app.needsRender = true;
+ }
+
+ function onPropPointerMove(event) {
+ if (lookTap && event.pointerId === lookTap.pointerId
+ && Math.hypot(event.clientX - lookTap.x, event.clientY - lookTap.y) > LOOK_TAP_SLOP) {
+ lookTap = null; // that was an orbit drag, not a tap
+ }
+ if (wallDrag.pointerId != null && event.pointerId === wallDrag.pointerId) {
+ event.stopPropagation();
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
+ const offset = characterOffset();
+ const yaw = wallYaw();
+ const cos = Math.cos(yaw);
+ const sin = Math.sin(yaw);
+ const px = propPoint.x - offset.x;
+ const pz = propPoint.z - offset.z;
+ const wall = state.render[wallDrag.key] ?? (state.render[wallDrag.key] = defaultState().render[wallDrag.key]);
+ // Back into the wall's character-relative frame, then the same ranges the
+ // panel's sliders offer, so a drag and a number always describe the same
+ // place.
+ wall.x = round2(clampNumber(cos * px - sin * pz + wallDrag.offset.x, -4, 4));
+ wall.y = round2(clampNumber(propPoint.y - offset.y + wallDrag.offset.y, -2, 4));
+ wall.z = round2(clampNumber(sin * px + cos * pz + wallDrag.offset.z, -4, 4));
+ applyWalls();
+ app.needsRender = true;
+ return;
+ }
+ if (!propDrag || event.pointerId !== propDrag.pointerId) return;
+ event.stopPropagation();
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
+ const next = propPoint.add(propDrag.offset);
+ const item = state.props?.items?.[propDrag.index];
+ if (!item) return;
+ // Clamp to the ranges the panel's sliders offer, so a drag can never put a
+ // prop somewhere the numbers cannot describe (and never through the floor).
+ item.x = round2(clampNumber(next.x, -4, 4));
+ item.y = round2(clampNumber(next.y, 0, 2));
+ item.z = round2(clampNumber(next.z, -4, 4));
+ propDrag.group.position.set(item.x, item.y, item.z);
+ // Keep `applyProps`' signature in step with what is on screen, so a refresh
+ // during the drag does not tear the group down and rebuild it from the state
+ // (which would re-create every geometry on every pointer move).
+ propSignature = JSON.stringify([state.props?.items ?? [], state.render.colors]);
+ app.needsRender = true;
+ }
+
+ function onPropPointerUp(event) {
+ if (lookTap && (!event || event.pointerId === lookTap.pointerId)) {
+ lookTap = null;
+ if (event) applyLookTap(event);
+ return;
+ }
+ if (wallDrag.pointerId != null && (!event || event.pointerId === wallDrag.pointerId)) {
+ wallDrag.pointerId = null;
+ stage.style.cursor = '';
+ history.push(state, '見えない壁');
+ app.panel?.sync();
+ return;
+ }
+ if (!propDrag || (event && event.pointerId !== propDrag.pointerId)) return;
+ propDrag = null;
+ stage.style.cursor = '';
+ // One undo step for the whole drag, and the panel catches up with the value.
+ history.push(state, '小物');
+ app.panel?.sync();
+ }
+
+ // A tap (a press that does not turn into a drag) in the viewport sets the
+ // 見る先 target, when that mode is on. Cancelled as soon as the pointer moves,
+ // so an orbit drag never moves the target by accident.
+ let lookTap = null;
+ const LOOK_TAP_SLOP = 5;
+
+ stage.addEventListener('pointerdown', onPropPointerDown, { capture: true });
+ window.addEventListener('pointermove', onPropPointerMove);
+ window.addEventListener('pointerup', onPropPointerUp);
+ window.addEventListener('pointercancel', onPropPointerUp);
+
+ // ---------------------------------------------------------------- look-at
+ const lookScratch = new THREE.Vector3();
+ const headScratch = new THREE.Vector3();
+ const lookPlane = new THREE.Plane();
+ const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
+ const lookPlaneNormal = new THREE.Vector3();
+ const lookPivot = new THREE.Vector3();
+ const lookHit = new THREE.Vector3();
+ const lookFloor = new THREE.Vector3();
+ /** Where the eyes should aim this frame, from `state.lookAt` (a world point). */
+ let aimOffset = { x: 0, y: 0 };
+
+ /**
+ * The world point under a viewport tap, for the 見る先 target.
+ *
+ * The tap is projected onto the first thing it can sensibly mean: a vertical
+ * plane through the character that faces the camera (so a tap on the body or
+ * the backdrop keeps the character's own depth), or the floor, for a tap that
+ * lands on the ground in front. The nearer of the two wins.
+ */
+ function lookAtPointFrom(event) {
+ propRaycaster.setFromCamera(pointerNdc(event), view.camera);
+ const ray = propRaycaster.ray;
+ const headY = container.position.y + model.size.y * 0.72;
+ lookPlaneNormal.set(ray.direction.x, 0, ray.direction.z);
+ if (lookPlaneNormal.lengthSq() < 1e-8) lookPlaneNormal.set(0, 0, 1);
+ lookPlane.setFromNormalAndCoplanarPoint(
+ lookPlaneNormal.normalize(),
+ lookPivot.set(container.position.x, headY, container.position.z),
+ );
+ const onPlane = ray.intersectPlane(lookPlane, lookHit);
+ const onFloor = ray.direction.y < -1e-4 ? ray.intersectPlane(floorPlane, lookFloor) : null;
+ if (!onPlane) return onFloor;
+ if (!onFloor) return onPlane;
+ return onPlane.distanceToSquared(ray.origin) <= onFloor.distanceToSquared(ray.origin) ? onPlane : onFloor;
+ }
+
+ /** A tap in the viewport: aim the eyes at whatever was under the pointer. */
+ function applyLookTap(event) {
+ if (state.lookAt?.enabled !== true) return;
+ const point = lookAtPointFrom(event);
+ if (!point) return;
+ const cfg = state.lookAt;
+ // The same ranges the panel's sliders offer, so a tap and a number describe
+ // the same place.
+ cfg.x = round2(clampNumber(point.x, -6, 6));
+ cfg.y = round2(clampNumber(point.y, 0, 5));
+ cfg.z = round2(clampNumber(point.z, -6, 6));
+ history.push(state, '見る先');
+ refresh('lookAt');
+ app.panel?.sync();
+ }
+
+ function applyLookAt() {
+ const cfg = state.lookAt;
+ if (!cfg?.enabled) {
+ // Switching the mode off leaves the character where it was: the body turn
+ // and the gaze are remembered, not re-derived, so nothing snaps back.
+ container.rotation.y = (cfg?.bodyYawDeg ?? 0) * DEG;
+ const frozen = cfg?.freeze;
+ return frozen
+ ? { x: clamp(frozen.x ?? 0, -1, 1), y: clamp(frozen.y ?? 0, -1, 1) }
+ : { x: 0, y: 0 };
+ }
+
+ container.updateMatrixWorld(true);
+ lookScratch.set(cfg.x ?? 0, cfg.y ?? 0, cfg.z ?? 0);
+ mirrorGroup.worldToLocal(lookScratch);
+ headScratch.set(0, model.size.y * 0.72, 0);
+ lookScratch.sub(headScratch);
+
+ const flat = Math.hypot(lookScratch.x, lookScratch.z) || 1e-6;
+ const yaw = Math.atan2(lookScratch.x, lookScratch.z);
+ const pitch = Math.atan2(lookScratch.y, flat);
+ const amount = clamp(cfg.amount ?? 1, 0, 1);
+ const aim = {
+ x: clamp((yaw / MAX_LOOK_YAW) * amount, -1, 1),
+ y: clamp((pitch / MAX_LOOK_PITCH) * amount, -1, 1),
+ };
+
+ // Turning the whole body is what a person does to look behind themselves; a
+ // half-strength turn keeps the feet planted while the body leans round. The
+ // turn is *stored*, so switching 体も向ける off stops updating it and the body
+ // stays where it is instead of snapping back to the front.
+ if (cfg.turnBody) {
+ cfg.bodyYawDeg = round2(clamp(yaw, -MAX_LOOK_YAW * 1.6, MAX_LOOK_YAW * 1.6) * amount * 0.7 / DEG);
+ }
+ container.rotation.y = (cfg.bodyYawDeg ?? 0) * DEG;
+
+ // Keep the gaze too, for the moment the mode is switched off.
+ cfg.freeze = aim;
+ return aim;
+ }
+
+ // -------------------------------------------- history, 口パク, gacha
+ const history = new History({ limit: 120 });
+ /** How far the mouth is open right now because of the 口パク animation. */
+ let flapMouth = 0;
+ const mouthFlap = createMouthFlap({
+ onLevel: (level) => {
+ flapMouth = levelToMouth(level, state.mouthFlap?.mouthGain ?? 1);
+ app.needsRender = true;
+ },
+ onEnd: () => { flapMouth = 0; app.needsRender = true; },
+ });
+
+ let rig = null;
+ // The gizmo always belongs to whichever camera is currently active.
+ view.onCameraChange = (camera, controls) => {
+ if (!rig) return;
+ rig.controls.camera = camera;
+ rig.orbit = controls;
+ };
+
+ rig = new Rig({
+ bones: model.bones,
+ scene,
+ camera: view.camera,
+ domElement: canvas,
+ orbit: view.controls,
+ pickTargets: () => [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body],
+ onChange: () => {
+ capturePose();
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ },
+ });
+
+ // The ground, the contact shadow and the gizmo are scenery, not character, and
+ // the two face plates are the *front half* of the body: their open edge would be
+ // picked up as a silhouette and draw a line across the face, while contributing
+ // nothing, since they sit exactly on the body.
+ //
+ // The outline hulls must stay out too, and that one is easy to miss: they are
+ // extra copies of every mesh sitting in the scene graph, and this pass swaps the
+ // material of every visible mesh. A hull is an expanded *back-face* shell, so
+ // handing it a plain front-side label material puts an expanded copy of the
+ // whole character in front of itself - it then paints its own label over
+ // everything, and every leaf edge against the body is inked as if the body were
+ // empty paper. That is exactly the faint dotted line along the leaves' bases.
+ function outlineExclusion() {
+ return [
+ ground,
+ look.contact,
+ ...clip.guides,
+ rig.helper,
+ model.parts.eyeMesh,
+ model.parts.mouthMesh,
+ ...(model.parts.hairMesh ? [model.parts.hairMesh] : []),
+ ...snotMeshes,
+ ...hatMeshes,
+ ...styles.outlineMeshes,
+ ];
+ }
+ outline.exclude(outlineExclusion());
+ refreshOutlineExclusion = () => outline.exclude(outlineExclusion());
+ // The wall is *not* excluded: it is handed to the pass as an occluder, so the
+ // labels behind it are culled as well. Excluded, the leaves and the nose
+ // buried in the wall kept their outlines, because the labels are read before
+ // its depth is applied.
+ outline.occlude(clip.occluders);
+
+ /**
+ * The parts the screen-space pass draws, with the label each one writes.
+ *
+ * The leaves are the hard case: a hull cannot outline a shell that thin. They
+ * are drawn with the LEAF label, which means the pass inks them where they meet
+ * the paper and where they fold against each other, but *not* where they run
+ * into the body - a line there reads as the leaf sinking into the body, and the
+ * original artwork has none (the leaf simply passes behind the body).
+ *
+ * The nose joins them in the line-art styles, with the NOSE label, so its ring
+ * against the body *is* drawn: it is the only thing that shows the nose in a
+ * line drawing. In the shaded styles it is left out entirely, and its hull then
+ * keeps only the part that pokes out of the silhouette - which is what the
+ * original artwork does (the nose reads by its own colour there).
+ *
+ * @param {string} style
+ * @returns {Array<[import('three').Object3D, number]>}
+ */
+ function screenParts(style) {
+ const parts = (model.parts.leaves ?? []).map((mesh) => [mesh, LEAF_LABEL]);
+ if (isLineStyle(style) && model.parts.noseMesh) parts.push([model.parts.noseMesh, NOSE_LABEL]);
+ return parts;
+ }
+
+ /**
+ * The label every part writes (see src/outline.js).
+ *
+ * The leaves and the nose are the outlined ones. The body's label is the one
+ * real choice here, and it is exposed as `render.leafBodyLine`:
+ *
+ * - `BEHIND` (the default) makes the body count as paper, so each leaf is
+ * outlined where it emerges from the body. In a line drawing the two are
+ * paper on paper, so without this the leaves and the body merge into one
+ * white shape with no way to tell them apart - which is the greater evil.
+ * - `SOLID` makes the body block that line, so a leaf simply passes behind it.
+ * Cleaner where it works, but the leaves lose their outline there.
+ *
+ * Everything else - the feet, the hands, the props - is *merely behind*, so it
+ * stays BEHIND either way: it still hides what is behind it, but a leaf lying
+ * across it keeps its outline.
+ */
+ function outlineLabels(parts) {
+ const labels = new Map(parts);
+ const bodyLabel = state.render.leafBodyLine === false ? SOLID_LABEL : BEHIND_LABEL;
+ for (const mesh of model.parts.body ?? []) {
+ if (labels.has(mesh)) continue;
+ labels.set(mesh, model.parts.kinds?.get(mesh) === 'blb' ? bodyLabel : BEHIND_LABEL);
+ }
+ return [...labels];
+ }
+
+ /** Point both outline systems at the right parts for `method`. */
+ function applyOutlineMethod(style, method = state.render.outlineMethod) {
+ const hybrid = method === 'screen';
+ const parts = hybrid ? screenParts(style) : [];
+ styles.setHullHidden(parts.map(([mesh]) => mesh));
+ outline.only(parts.length ? outlineLabels(parts) : null);
+ }
+
+ /**
+ * Run `fn` with the outline pass set up for another style, then put the
+ * on-screen configuration back. Both the hull/screen split and which parts the
+ * screen pass may see are shared by the live view and every offscreen render,
+ * so a pass that draws another style has to say so - the SVG and the "lines
+ * only" PNG are ink through and through, so they always want the line-art
+ * arrangement (a hull cannot outline the leaves at all).
+ */
+ async function withOutlineFor({ style, method = 'screen' }, fn) {
+ applyOutlineMethod(style, method);
+ try {
+ return await fn();
+ } finally {
+ applyOutlineMethod(state.render.style);
+ }
+ }
+
+ Object.assign(app, {
+ renderer, scene, camera: view.camera, view, styles, face, rig, animator,
+ key, ambient, ground, environment, halfHeight, outline, clip,
+ look, backdrop, history, mouthFlap, propRoot, mirrorGroup, container,
+ get captionFont() { return captionFont; },
+ get backdropImage() { return backdropImage; },
+ });
+
+ const recorder = createRecorder(canvas);
+
+ // -------------------------------------------------------------- the panel
+ const panel = buildPanel(app, document.getElementById('panel'));
+ app.panel = panel;
+
+ // On a phone the panel is a bottom sheet. Its grab handle drags the sheet's top
+ // edge up and down, so the menu can be pulled out of the way of the character.
+ const panelEl = document.getElementById('panel');
+ const panelGrip = document.createElement('div');
+ panelGrip.className = 'panel-grip';
+ panelGrip.setAttribute('aria-hidden', 'true');
+ panelEl.prepend(panelGrip);
+ installSheetDrag(panelEl, panelGrip);
+
+ /** Drag on the phone sheet's handle; a no-op anywhere else. */
+ function installSheetDrag(sheet, grip) {
+ const phone = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)');
+ let drag = null;
+ const minHeight = () => Math.round(window.innerHeight * 0.16);
+ const maxHeight = () => Math.round(window.innerHeight * 0.94);
+
+ grip.addEventListener('pointerdown', (event) => {
+ if (!phone.matches) return;
+ const rect = sheet.getBoundingClientRect();
+ // Start from the height the sheet has now, so the first move does not jump.
+ drag = { id: event.pointerId, fromY: event.clientY, startH: rect.height };
+ sheet.style.maxHeight = 'none';
+ sheet.style.height = `${rect.height}px`;
+ try { grip.setPointerCapture(event.pointerId); } catch { /* capture is optional */ }
+ event.preventDefault();
+ });
+ grip.addEventListener('pointermove', (event) => {
+ if (!drag || event.pointerId !== drag.id) return;
+ // Dragging up grows the sheet (its top edge climbs); dragging down shrinks it.
+ const next = drag.startH + (drag.fromY - event.clientY);
+ sheet.style.height = `${Math.min(maxHeight(), Math.max(minHeight(), next))}px`;
+ event.preventDefault();
+ });
+ const end = (event) => {
+ if (!drag || (event && event.pointerId !== drag.id)) return;
+ drag = null;
+ };
+ grip.addEventListener('pointerup', end);
+ grip.addEventListener('pointercancel', end);
+
+ // A window grown to the desktop layout keeps the side column: drop the height
+ // the drag had fixed, so the panel goes back to filling the screen.
+ const clearOnDesktop = () => {
+ if (phone.matches) return;
+ sheet.style.height = '';
+ sheet.style.maxHeight = '';
+ };
+ window.addEventListener('resize', clearOnDesktop);
+ }
+
+ Object.assign(app.actions, {
+ capturePose,
+ applyState,
+ refresh,
+ loadHatModel,
+ patch: (scope, value) => applyState({ [scope]: value }, { scope }),
+ syncPanel: () => app.panel?.sync(),
+ imageCanvas: (options) => captureImage(options),
+ savePNG: (options = {}) => savePNG(options),
+ copyPNG: () => copyPNG(),
+ faceMapCanvas: (kind) => faceMapCanvas(kind),
+ saveFaceMap: (kind) => saveFaceMap(kind),
+ addGionStamp: (item) => addGionStamp(item),
+ saveSettings: () => exporter.downloadText(JSON.stringify(state, null, 2), `bluebey-settings-${exporter.timestamp()}.json`),
+ loadSettings: (text) => {
+ const data = JSON.parse(text);
+ applyState(data, { full: true, sync: true });
+ toast('設定を読み込みました');
+ },
+ toggleRecording: () => toggleRecording(recorder),
+ resetPose: () => {
+ rig.reset();
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ applyState({}, { scope: 'pose', sync: true });
+ toast('ポーズをリセットしました');
+ },
+ setCameraPreset: (id) => setCameraPreset(id),
+ undo: () => restoreFromHistory('undo'),
+ redo: () => restoreFromHistory('redo'),
+ applyTheme: (id) => applyTheme(id),
+ rollGacha: (seed) => rollGacha(seed),
+ copyShareLink: () => copyShareLink(),
+ shareImage: (network) => shareImage(network),
+ toggleMouthFlap: () => toggleMouthFlap(),
+ storyThumbs: () => storyThumbs,
+ toggleCamera: (on) => toggleCamera(on),
+ addProp: (kind) => addProp(kind),
+ addStoryPanel: () => addStoryPanel(),
+ saveStory: (options) => saveStory(options),
+ loadFromHash: () => loadFromHash(),
+ toggleAnimation: () => {
+ state.anim.mode = (state.anim.mode ?? 'off') === 'off' ? 'idle' : 'off';
+ app.animator.reset();
+ panel.sync();
+ app.needsRender = true;
+ },
+ });
+
+ installKeys();
+ installTopbar();
+ window.addEventListener('focus', () => { app.needsRender = true; });
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) app.needsRender = true;
+ });
+
+ {
+ const buffer = renderer.getDrawingBufferSize(new THREE.Vector2());
+ console.log(
+ '[bluebey] ready'
+ + ` canvas=${canvas.clientWidth}x${canvas.clientHeight}`
+ + ` buffer=${Math.round(buffer.x)}x${Math.round(buffer.y)}`
+ + ` quality=${quality}`
+ + ` dpr=${window.devicePixelRatio}`
+ + ` model=${model.size.toArray().map((v) => v.toFixed(2)).join(',')}`
+ + ` camera=${view.camera.position.toArray().map((v) => v.toFixed(2)).join(',')}`,
+ );
+ }
+ window.addEventListener('resize', () => resizeViewport());
+
+ applyState({}, {});
+ await loadFromHash();
+ history.reset(state);
+ rig.select('master', { silent: true });
+ panel.sync();
+
+ let last = performance.now();
+ let faceSignature = '';
+ let slowFrames = 0;
+ let framesSinceFps = 0;
+ let fpsAt = performance.now();
+ const warmupUntil = performance.now() + 2500;
+
+ let snotSignature = null;
+ const loop = (now) => {
+ const rawDelta = now - last;
+ const dt = Math.min(0.05, rawDelta / 1000);
+ last = now;
+
+ animator.update(dt);
+
+ const posed = animator.pose(state.pose);
+ if (!rig.controls.dragging) rig.applyPose(posed);
+ container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0);
+ updateKeyLight();
+ // The contact blob rides under the character, so it never lingers at the origin.
+ look.contact.position.x = container.position.x;
+ look.contact.position.z = container.position.z;
+
+ applyHat(state.face.hat?.kind ?? 'none');
+
+ if (snotBubble) {
+ const snot = state.face.eyes.snot;
+ const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`;
+ if (sig !== snotSignature) {
+ snotSignature = sig;
+ applySnotBubble(snotBubble, snot);
+ app.needsRender = true;
+ }
+ // The sleeping breath swells the bubble; `snotScale` is 1 otherwise.
+ if (snotBubble.visible) {
+ const scale = (snotBubble.userData.baseScale ?? 1) * (animator.snotScale ?? 1);
+ if (snotBubble.scale.x !== scale) {
+ snotBubble.scale.setScalar(scale);
+ app.needsRender = true;
+ }
+ }
+ }
+
+ const params = buildFaceParams();
+ const signature = JSON.stringify(params);
+ if (signature !== faceSignature) {
+ faceSignature = signature;
+ face.setParams(params, faceMode());
+ app.needsRender = true;
+ }
+ if (face.flush(now, 26)) app.needsRender = true;
+
+ view.controls.update(dt);
+
+ const animating = (state.anim.mode ?? 'off') !== 'off'
+ || state.anim.lookAround
+ || animator.blinkPhase >= 0
+ || recorder?.recorder?.state === 'recording';
+ // Always paint for the first couple of seconds, so a missed "needs render"
+ // can never leave an empty window staring back at the user.
+ if (app.needsRender || animating || view.controlsChanged || now < warmupUntil) {
+ app.needsRender = false;
+
+ // The screen-space outline needs one extra pass *before* the scene, so it
+ // wraps the normal render instead of following it. `outline.render` paints
+ // the scene itself (ink and all), so there must be no second render after
+ // it - that would clear the canvas and wipe the ink straight back off.
+ const wantsScreen = state.render.outlineMethod === 'screen' && state.render.outline !== false;
+ outline.render(() => renderer.render(scene, view.camera), {
+ enabled: wantsScreen,
+ camera: view.camera,
+ color: state.render.outlineColor,
+ // The label buffer is `quality`x the CSS size, so the radius has to carry
+ // that factor to keep the leaf/nose line a constant width in CSS pixels -
+ // otherwise it halves on a HiDPI display and jumps when the app lowers
+ // the quality on slow frames.
+ radius: outlineRadiusFor(view, state, quality),
+ });
+ view.controlsChanged = false;
+ framesSinceFps += 1;
+
+ // Hardware that cannot keep up gets a smaller drawing buffer instead of a
+ // slideshow (a very large buffer can look like "nothing ever appears").
+ if (rawDelta > 90) slowFrames += 1;
+ else slowFrames = 0;
+ if (slowFrames >= 8 && quality > QUALITY_FLOOR + 0.01 && autoReductions < 3) {
+ autoReductions += 1;
+ quality = Math.max(QUALITY_FLOOR, Math.round((quality - 0.25) * 100) / 100);
+ renderer.setPixelRatio(quality);
+ slowFrames = 0;
+ console.warn('[bluebey] frames are slow, lowering the drawing quality to', quality);
+ }
+ }
+
+ if (now - fpsAt > 1000) {
+ const fps = Math.round((framesSinceFps * 1000) / (now - fpsAt));
+ framesSinceFps = 0;
+ fpsAt = now;
+ const buffer = renderer.getDrawingBufferSize(new THREE.Vector2());
+ panel.setStats?.(
+ `描画 ${Math.round(buffer.x)}×${Math.round(buffer.y)}(画質 ${quality.toFixed(2)}×)/約 ${fps}fps`
+ + (autoReductions > 0 ? ' ※重いので自動で軽くしました' : ''),
+ );
+ }
+
+ requestAnimationFrame(loop);
+ };
+ requestAnimationFrame(loop);
+
+ hideLoading();
+ app.ready = true;
+ window.__bluebeyReady = true;
+ window.__bluebey = {
+ app,
+ state,
+ set: (patch) => applyState(patch),
+ face: (patch) => applyState({ face: patch }),
+ pose: (patch) => applyState({ pose: patch }),
+ view: (patch) => applyState({ view: patch }),
+ render: (patch) => applyState({ render: patch }),
+ selectBone: (name) => rig.select(name),
+ preset: (id) => app.actions.applyFacePreset?.(id),
+ posePreset: (id) => app.actions.applyPosePreset?.(id),
+ png: (options) => captureImage(options),
+ };
+
+ /* ------------------------------------------------------------- internals */
+
+ /** The camera the outline pass should use (kept in step with the view rig). */
+ function outlineCamera() {
+ return view.camera;
+ }
+
+ /** The gizmo/frame loop re-reads the bones, so store them back into state. */
+ function capturePose() {
+ const pose = rig.getPose();
+ state.pose.bones = pose.bones;
+ }
+
+ function buildFaceParams() {
+ const eyes = state.face.eyes;
+ const openScale = animator.eyeOpen;
+ const drift = animator.look;
+
+ const eyeFor = (key) => {
+ const own = eyes[key];
+ // A shut artwork (1〜3線 / 3の目 / わらう) does not blink: its lid stays where
+ // the user put it, so the blink animation must not scale its `open`.
+ const shutArt = own.shape === 'three' || own.shape === 'arch'
+ || (typeof own.shape === 'string' && own.shape.startsWith('line'));
+ return {
+ // Which artwork this eye shows (ふつう / 1〜3線 / 3の目 / わらう / ハート).
+ // The renderer chooses the shut artwork from this, so it must travel.
+ shape: own.shape,
+ open: clamp01(own.open * (shutArt ? 1 : openScale)),
+ lookX: clamp(own.lookX + drift.x + aimOffset.x, -1, 1),
+ lookY: clamp(own.lookY + drift.y + aimOffset.y, -1, 1),
+ closed: own.closed,
+ closedLines: own.closedLines,
+ irisShape: own.irisShape,
+ threeFlip: own.threeFlip === true,
+ // 白目 / 光彩 are per eye and tri-state: `null` means "the usual" (open
+ // eyes show their white; the shared 光彩 switch decides the glint).
+ white: own.white ?? null,
+ highlight: own.highlight ?? null,
+ // 形の大きさ scales the drawn eye shape (the shut lines, the 3, the arch)
+ // and the heart.
+ shapeScale: own.shapeScale ?? 1,
+ // The tears are per eye, and only drawn when their own switch is on.
+ tearOn: own.tearOn === true,
+ tear: own.tear ?? 0.3,
+ tearY: own.tearY ?? 0,
+ // Where this eye sits, and where its teardrop hangs, can be nudged one
+ // side at a time (an asymmetric face).
+ eyeX: own.eyeX ?? 0,
+ tearX: own.tearX ?? 0,
+ tearTilt: own.tearTilt ?? 0,
+ // The lower lid can differ per eye (falling back to the shared value).
+ lowerLid: own.lowerLid ?? eyes.lowerLid ?? 0,
+ // The rest of the lids are per eye too now (null = use the shared value).
+ lidShape: own.lidShape ?? null,
+ lidWidth: own.lidWidth ?? null,
+ lidTilt: own.lidTilt ?? null,
+ lashes: own.lashes ?? null,
+ lashAngle: own.lashAngle ?? null,
+ lashPos: own.lashPos ?? null,
+ };
+ };
+
+ // Lip-sync and a look-at target both drive the mouth and the eyes without
+ // being part of the saved expression.
+ const mouth = { ...state.face.mouth };
+ if (flapMouth > 0.001) mouth.open = Math.max(mouth.open ?? 0, flapMouth);
+ // A talking mouth has no tongue sticking out. This follows the whole flap
+ // session rather than the opening: the envelope dips through zero between
+ // syllables, and keying it off the opening made the tongue flicker back into
+ // view on every closed frame.
+ if (mouthFlap.running) mouth.tongue = 0;
+
+ return {
+ eyes: {
+ // Everything shared by both eyes (colours, iris scale, brows, glasses,
+ // ...) has to travel with the per-eye values, because the face renderer
+ // reads them from here. Spreading `...eyes` is also what makes the face
+ // redraw when one of them (e.g. `glasses`) changes: the loop compares the
+ // JSON of this object against the last one it drew.
+ ...eyes,
+ left: eyeFor('left'),
+ right: eyeFor('right'),
+ },
+ mouth,
+ };
+ }
+
+ function faceMode() {
+ return isLineStyle(state.render.style) ? 'line' : 'paint';
+ }
+
+ function applyState(patch, { silent = false, full = false, scope = 'all', sync = false } = {}) {
+ if (full) {
+ const fresh = defaultState();
+ applyPatch(fresh, patch);
+ for (const key of Object.keys(fresh)) state[key] = fresh[key];
+ } else {
+ applyPatch(state, patch);
+ }
+ refresh(scope);
+ app.needsRender = true;
+ if (sync && !silent) app.panel?.sync();
+ }
+
+ /** Re-apply the parts of the state named by `scope` ('all' by default). */
+ function refresh(scope = 'all') {
+ app.needsRender = true;
+ // Where the eyes aim depends on where the character is, so it is recomputed
+ // whenever anything that moves could change.
+ if (scope === 'all' || scope === 'view' || scope === 'pose' || scope === 'face' || scope === 'lookAt') {
+ aimOffset = applyLookAt();
+ }
+ if (scope === 'all' || scope === 'render') {
+ styles.setStyle(state.render.style);
+ styles.setPaper(state.render.paper);
+ styles.setOutlineWidth(state.render.outlineWidth);
+ styles.setOutlineColor(state.render.outlineColor);
+ styles.setOutlineEnabled(state.render.outline);
+ // Which parts each outline method draws, and which hulls stand down for it
+ // (the leaves, plus the nose in a line drawing). See `applyOutlineMethod`.
+ applyOutlineMethod(state.render.style);
+
+ const line = isLineStyle(state.render.style);
+ // Shadows, reflections, body colours and mirroring are the look module's
+ // job, so that they are applied together and stay consistent.
+ look.apply(state.render, { line });
+ applyProps();
+ // 見えない壁: the one setting that changes what is drawn rather than how.
+ applyWalls();
+
+ const azimuth = state.render.lightAzimuth * DEG;
+ const elevation = state.render.lightElevation * DEG;
+ const distance = model.size.y * 3;
+ keyOffset.set(
+ Math.cos(elevation) * Math.sin(azimuth) * distance,
+ Math.sin(elevation) * distance,
+ Math.cos(elevation) * Math.cos(azimuth) * distance,
+ );
+ updateKeyLight();
+ key.intensity = state.render.lightIntensity;
+ ambient.intensity = state.render.ambient;
+ key.color.set(state.render.lightColor ?? '#ffffff');
+ ambient.color.set(state.render.ambientColor ?? '#ffffff');
+ renderer.toneMappingExposure = state.render.exposure;
+ applyBackground();
+ renderer.shadowMap.needsUpdate = true;
+ }
+
+ if (scope === 'all' || scope === 'view') {
+ applyBackdrop();
+ // The renderer's *clear* depends on the background mode as well: a preset
+ // photo, a loaded picture and the camera all show through a transparent
+ // clear (see `backgroundOf`). Without this, switching the background left
+ // the canvas clearing to the old opaque colour and hid the layer that had
+ // just been set up behind it - which is why the background only appeared
+ // after some other change (adding a prop) re-ran this.
+ applyBackground();
+ }
+ if (scope === 'all' || scope === 'view' || scope === 'caption' || scope === 'gion') redrawCaption();
+
+ // A style change flips the face between paint and ink (`faceMode`), so the
+ // 'render' scope has to rebuild the face too - otherwise the old drawing
+ // stays on the plates until something else (a blink) happens to dirty them.
+ if (scope === 'all' || scope === 'face' || scope === 'render') {
+ face.setSource('eyes', state.face.eyes.source);
+ face.setSource('mouth', state.face.mouth.source);
+ face.setParams(buildFaceParams(), faceMode());
+ }
+
+ if (scope === 'all' || scope === 'view') {
+ view.setProjection(state.view.projection);
+ view.apply(state.view, model.size);
+ view.setAutoRotate(state.view.autoRotate, state.view.autoRotateSpeed);
+ }
+
+ if (scope === 'all' || scope === 'pose') {
+ rig.applyPose(state.pose);
+ const root = state.pose.root ?? [0, 0, 0];
+ container.position.set(root[0] ?? 0, root[1] ?? 0, root[2] ?? 0);
+ }
+
+ // Every user-driven change funnels through here, so this is the one place
+ // that has to remember a step for undo. Undo itself sets `suspendHistory`.
+ if (!suspendHistory) history.push(state, SCOPE_LABELS[scope] ?? scope);
+ }
+
+ /** Paint the backdrop bitmap behind the model, for an export. */
+ function backdropSource() {
+ if (state.view.background === 'camera') {
+ const video = backdrop.el?.querySelector?.('video');
+ return video && video.readyState >= 2 ? video : null;
+ }
+ return backdropImage;
+ }
+
+ /**
+ * Freeze the current camera frame into a canvas.
+ *
+ * The AR preview is a live video, so by the time the PNG finishes encoding the
+ * feed has moved on and the saved picture no longer matches what was on screen
+ * when the shutter was pressed. Snapping the frame first keeps the two in step.
+ */
+ function snapshotBackdrop() {
+ if (state.view.background !== 'camera') return undefined;
+ const video = backdrop.el?.querySelector?.('video');
+ if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return undefined;
+ const frame = document.createElement('canvas');
+ frame.width = video.videoWidth;
+ frame.height = video.videoHeight;
+ try {
+ frame.getContext('2d').drawImage(video, 0, 0);
+ } catch {
+ return undefined;
+ }
+ return frame;
+ }
+
+ /**
+ * Paint the backdrop *behind* the rendered model.
+ *
+ * WHY the scratch canvas and `destination-over`: `ctx` already holds the
+ * character on a transparent field (see `exporter.renderStill`), so drawing the
+ * photo straight onto it with the default `source-over` painted OVER the
+ * character and the export came out as a bare photograph. Building the scene in
+ * a scratch canvas first also lets the dark veil sit on top of the photo, which
+ * it could not do once the photo had been slipped underneath.
+ */
+ function drawBackdropInto(ctx, width, height, scale, sourceOverride) {
+ const source = sourceOverride ?? backdropSource();
+ if (!source) return false;
+ const style = backdropStyle(state.view);
+ if (!style.visible) return false;
+ const iw = source.naturalWidth || source.videoWidth || source.width || 0;
+ const ih = source.naturalHeight || source.videoHeight || source.height || 0;
+ if (!iw || !ih) return false;
+
+ const back = document.createElement('canvas');
+ back.width = width;
+ back.height = height;
+ const paint = back.getContext('2d');
+
+ // The same CSS the live layer uses: blur (bleeding past the edges), the fit,
+ // the mirror and the scale/offset transform. The offset is in the element's
+ // own (pre-scale) space, so it is multiplied by `scale` here, exactly as
+ // `transform: scale(s) translate(...)` does in CSS.
+ const bleed = style.blur > 0 ? style.blur * 2 : 0;
+ const w = width + bleed * 2;
+ const h = height + bleed * 2;
+ paint.save();
+ if (style.blur > 0) paint.filter = `blur(${style.blur}px)`;
+ paint.translate(
+ width / 2 + style.scale * style.offset.x * width,
+ height / 2 + style.scale * style.offset.y * height,
+ );
+ paint.scale(style.scale * (style.mirror ? -1 : 1), style.scale);
+ paint.translate(-width / 2, -height / 2);
+
+ if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') {
+ const fit = style.backgroundSize === 'cover'
+ ? Math.max(w / iw, h / ih)
+ : Math.min(w / iw, h / ih);
+ const dw = iw * fit;
+ const dh = ih * fit;
+ paint.drawImage(source, (w - dw) / 2 - bleed, (h - dh) / 2 - bleed, dw, dh);
+ } else if (style.backgroundSize === 'auto') {
+ // CSS repeats the picture at its own size and anchors the grid on the box
+ // centre (`background-position: center`). A canvas pattern instead repeats
+ // at the image's *intrinsic* size, which is `scale`x too small for a
+ // canvas that is `scale`x the CSS box - so the export tiled `scale`x more
+ // densely than the preview. Paint the tiles by hand at `scale`, on a grid
+ // centred on the box centre; the CTM above already carries the offset /
+ // backgroundScale / mirror, so the tiles must not re-apply them.
+ const tw = iw * scale;
+ const th = ih * scale;
+ // Cover only the user-space area the visible canvas needs (widened by the
+ // blur bleed), mapped back through the CTM. The CTM holds no rotation, so
+ // its `a`/`d` scale factors are enough to invert it for the bounds.
+ const matrix = paint.getTransform();
+ const [left, right] = [
+ (-bleed - matrix.e) / matrix.a,
+ (width + bleed - matrix.e) / matrix.a,
+ ].sort((a, b) => a - b);
+ const [top, bottom] = [
+ (-bleed - matrix.f) / matrix.d,
+ (height + bleed - matrix.f) / matrix.d,
+ ].sort((a, b) => a - b);
+ const cx = width / 2;
+ const cy = height / 2;
+ const firstCol = Math.floor((left - cx) / tw);
+ const lastCol = Math.ceil((right - cx) / tw);
+ const firstRow = Math.floor((top - cy) / th);
+ const lastRow = Math.ceil((bottom - cy) / th);
+ for (let row = firstRow; row <= lastRow; row++) {
+ for (let col = firstCol; col <= lastCol; col++) {
+ paint.drawImage(source, cx + col * tw - tw / 2, cy + row * th - th / 2, tw, th);
+ }
+ }
+ } else {
+ paint.drawImage(source, -bleed, -bleed, w, h);
+ }
+ paint.restore();
+
+ if (style.darken > 0) {
+ paint.fillStyle = `rgba(0, 0, 0, ${style.darken})`;
+ paint.fillRect(0, 0, width, height);
+ }
+
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.drawImage(back, 0, 0);
+ ctx.restore();
+ return true;
+ }
+
+ /** Load (or clear) the backdrop bitmap that the exports composite. */
+ function applyBackdrop() {
+ const viewState = state.view;
+ backdrop.apply(viewState);
+ // The AR shutter rides the viewport, not the panel, so it shows/hides here.
+ if (app.arShutter) app.arShutter.hidden = viewState.background !== 'camera';
+ // The touch hint would sit under the shutter, so it steps aside in AR mode.
+ const touchHint = document.getElementById('viewport-hint-touch');
+ if (touchHint) touchHint.style.visibility = viewState.background === 'camera' ? 'hidden' : '';
+ const url = viewState.background === 'preset'
+ ? assetUrl(viewState.backgroundPreset, 'background')
+ : viewState.background === 'image'
+ ? viewState.backgroundImage
+ : viewState.background === 'effect'
+ ? backdrop.effectUrl(viewState.backgroundEffect)
+ : null;
+ if (!url) {
+ if (viewState.background !== 'camera') backdropImage = null;
+ return;
+ }
+ if (backdropImage?.dataset?.url === url) return;
+ const image = new Image();
+ image.dataset.url = url;
+ image.onload = () => { backdropImage = image; app.needsRender = true; };
+ image.onerror = () => { backdropImage = null; };
+ image.src = url;
+ }
+
+ function applyBackground() {
+ exporter.applyBackground(renderer, backgroundOf(state.view));
+ }
+
+ async function captureImage({ scale = 2, transparent = null, lines = null } = {}) {
+ // Freeze the live camera frame up front, so the saved picture holds the frame
+ // that was on screen when the button was pressed (see `snapshotBackdrop`).
+ const frozenBackdrop = snapshotBackdrop();
+ return withHiddenHelpers(async () => {
+ // The face texture is redrawn on a throttle (every 26 ms) while the render
+ // loop runs, so a capture taken straight after a change could still hold the
+ // *previous* expression. That is what made a comic panel occasionally show
+ // its neighbour's face: force the pending redraw through first.
+ face.flush(performance.now(), 0);
+ const wantsTransparent = transparent === true
+ || (transparent == null && state.view.background === 'transparent');
+ // A transparent export of a line drawing drops the paper as well. The whole
+ // point of transparent line art is to lay the lines over another picture,
+ // and a white silhouette would just hide it - so this renders the `outline`
+ // style, which draws the ink and leaves the body out. A shaded style keeps
+ // its body, because there it is an ordinary cut-out of the character.
+ const wantsLines = lines ?? (wantsTransparent && isLineStyle(state.render.style));
+ const drawn = wantsLines ? 'outline' : state.render.style;
+ const background = wantsTransparent ? { mode: 'transparent' } : backgroundOf(state.view);
+
+ const onScreen = styles.style;
+ if (drawn !== onScreen) styles.setStyle(drawn);
+ let result;
+ try {
+ result = await withOutlineFor({ style: drawn, method: wantsLines ? 'screen' : state.render.outlineMethod }, () => exporter.capturePNG({
+ renderer,
+ scene,
+ camera: view.camera,
+ outline,
+ outlineOptions: {
+ enabled: state.render.outlineMethod === 'screen' && state.render.outline !== false,
+ color: state.render.outlineColor,
+ // The export's label buffer is `scale`x the one on screen (renderStill
+ // sets it to the output size), so its radius grows with it to keep the
+ // line's weight relative to the model the same as in the preview.
+ radius: outlineRadiusFor(view, state, scale),
+ },
+ width: canvas.clientWidth || window.innerWidth,
+ height: canvas.clientHeight || window.innerHeight,
+ restore: () => { resizeViewport(); applyStateBackgroundAgain(); },
+ }, { scale, background }));
+ } finally {
+ if (drawn !== onScreen) styles.setStyle(onScreen);
+ }
+
+ // The backdrop, the 擬音 stamps and the bubbles live in DOM layers behind
+ // and above the WebGL canvas, so an export has to paint them in itself - in
+ // the same order as the screen: backdrop, then stamps, then bubbles.
+ const ctx = result.canvas.getContext('2d');
+ // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps and the
+ // bubbles are drawn at below. The backdrop's tiled fit needs it too, so its
+ // tiles come out the size the preview's CSS paints them.
+ const base = canvas.clientWidth || 1280;
+ const outputScale = result.width / Math.max(1, base);
+ let changed = false;
+ if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale, frozenBackdrop)) changed = true;
+ const gionItems = state.gion?.items ?? [];
+ if (gionItems.length) {
+ for (const item of gionItems) await ensureGionSheet(item.sheet);
+ drawGion(ctx, gionItems, gionImages, {
+ width: result.width,
+ height: result.height,
+ scale: outputScale,
+ });
+ changed = true;
+ }
+ const bubbles = CAPTION_KEYS.map((key) => state[key]).filter((caption) => caption?.enabled);
+ if (bubbles.length) {
+ await ensureCaptionFont();
+ for (const caption of bubbles) {
+ drawCaption(ctx, caption, {
+ width: result.width,
+ height: result.height,
+ scale: outputScale,
+ font: captionFont,
+ });
+ }
+ changed = true;
+ }
+ if (changed) result.blob = await exporter.canvasToBlob(result.canvas);
+ return result;
+ });
+ }
+
+ function applyStateBackgroundAgain() {
+ exporter.applyBackground(renderer, backgroundOf(state.view));
+ }
+
+ async function savePNG({
+ scale = state.render.pngScale ?? 2,
+ transparent = state.render.pngTransparent ? true : undefined,
+ } = {}) {
+ const result = await captureImage({ scale, transparent });
+ exporter.downloadBlob(result.blob, `bluebey-${exporter.timestamp()}.png`);
+ toast(`PNGを書き出しました(${result.width}×${result.height})`);
+ }
+
+ /**
+ * The canvas the 下地 export paints on, *without* downloading it. Kept separate
+ * from `saveFaceMap` so the in-page round-trip check can look at exactly the
+ * pixels the button writes.
+ */
+ function faceMapCanvas(kind) {
+ // A redraw is throttled while the render loop runs (see the loop's
+ // `face.flush(now, 26)`), so force any pending one through first or the map
+ // would show the expression before last.
+ face.flush(performance.now(), 0);
+ return kind === 'hair' ? exporter.buildHairMap(face) : exporter.buildFaceMap(face, kind);
+ }
+
+ async function saveFaceMap(kind) {
+ const canvas = faceMapCanvas(kind);
+ if (!canvas) {
+ toast('このモデルには髪のプレートがありません');
+ return;
+ }
+ const blob = await exporter.canvasToBlob(canvas);
+ const label = kind === 'eyes' ? '目' : kind === 'mouth' ? '口' : '髪';
+ exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`);
+ if (kind === 'hair') {
+ toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ + 'この画像に前髪を描いて渡してください');
+ return;
+ }
+ toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。`
+ + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`);
+ }
+
+ /** Hide the gizmo and helpers so they never end up in an export. */
+ async function withHiddenHelpers(fn) {
+ const wasVisible = rig.helper.visible;
+ const guidesWereVisible = clip.guides.map((guide) => guide.visible);
+ rig.helper.visible = false;
+ clip.guides.forEach((guide) => { guide.visible = false; });
+ try {
+ return await fn();
+ } finally {
+ rig.setGizmoVisible(wasVisible);
+ clip.guides.forEach((guide, index) => { guide.visible = guidesWereVisible[index]; });
+ app.needsRender = true;
+ }
+ }
+
+ async function copyPNG() {
+ const result = await captureImage({ scale: 2 });
+ await exporter.copyCanvasToClipboard(result.canvas);
+ toast('画像をクリップボードにコピーしました');
+ }
+
+ /**
+ * Swap in another GLB.
+ *
+ * Kept as the implementation, but no longer wired to anything: the panel's
+ * 「GLBを差し替える」 button and the window-wide drop handler were removed on
+ * 2026-09-27. Putting the feature back is a button plus an action that calls
+ * this (the model returns on reload, so nothing is saved).
+ */
+ async function replaceModel(file) {
+ if (!file) return;
+ try {
+ const buffer = await exporter.readFileAsArrayBuffer(file);
+ const next = await loadModel(buffer);
+ styles.dispose();
+ rig.dispose();
+ container.remove(model.root);
+ scene.remove(container);
+ container.add(next.root);
+ scene.add(container);
+ app.model = next;
+ toast(`${file.name} を読み込みました(このモデルは再読込で元に戻ります)`);
+ app.needsRender = true;
+ } catch (error) {
+ console.error(error);
+ toast('このファイルは読み込めませんでした');
+ }
+ }
+
+ let recording = false;
+ async function toggleRecording(session) {
+ if (!session) {
+ toast('この環境では録画できません');
+ return;
+ }
+ if (!recording) {
+ if (!state.anim.idle) {
+ state.anim.idle = true;
+ panel.sync();
+ }
+ recording = startRecording(session);
+ toast('録画を開始しました(もう一度押すと停止)');
+ } else {
+ const blob = await stopRecording(session);
+ recording = false;
+ if (blob) {
+ exporter.downloadBlob(blob, `bluebey-animation-${exporter.timestamp()}.webm`);
+ toast('録画を保存しました(WebM)');
+ }
+ }
+ app.panel?.setRecording(recording);
+ }
+
+ function setCameraPreset(id) {
+ const presets = {
+ front: { azimuth: 0, polar: 82 },
+ threeQuarter: { azimuth: 34, polar: 78 },
+ side: { azimuth: 90, polar: 84 },
+ back: { azimuth: 180, polar: 82 },
+ top: { azimuth: 24, polar: 26 },
+ };
+ applyState({ view: presets[id] ?? presets.front }, { scope: 'view', sync: true });
+ }
+
+ function restoreFromHistory(kind) {
+ const snapshot = kind === 'undo' ? history.undo() : history.redo();
+ if (!snapshot) {
+ toast(kind === 'undo' ? 'これ以上戻れません' : 'やり直す操作がありません');
+ return;
+ }
+ suspendHistory = true;
+ try {
+ applyState(snapshot, { full: true, sync: true });
+ } finally {
+ suspendHistory = false;
+ }
+ toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました');
+ }
+
+ function applyTheme(id) {
+ const theme = THEMES.find((item) => item.id === id);
+ if (!theme) return;
+ state.render.theme = id;
+ state.render.colors = { ...theme.colors };
+ applyState({}, { scope: 'render', sync: true });
+ }
+
+ /** おまかせ: a seeded random expression, so it can be reproduced and shared. */
+ function rollGacha(seedText) {
+ const seed = seedText || String(Math.floor(Math.random() * 1e9));
+ const roll = rollAll(seed);
+ suspendHistory = true;
+ try {
+ applyState(roll.patch, { scope: 'all' });
+ } finally {
+ suspendHistory = false;
+ }
+ history.push(state, 'おまかせ');
+ app.panel?.sync();
+ const shown = decodeSeed(seed) ?? String(seed);
+ toast(`おまかせ表情(シード ${shown})`);
+ return shown;
+ }
+
+ async function copyShareLink() {
+ try {
+ const encoded = await encodeState(state);
+ const link = `${location.origin}${location.pathname}#s=${encoded}`;
+ await navigator.clipboard.writeText(link);
+ toast(`この見た目のリンクをコピーしました(${link.length}文字)`);
+ } catch (error) {
+ console.error(error);
+ toast('リンクを作れませんでした');
+ }
+ }
+
+ /** The tag every shared picture carries. */
+ const SHARE_TAG = '#ぶるべースタジオ';
+
+ /** The studio's own address - the landing page itself, not a state-reproducing
+ * `#s=` link. Shared alongside the tag so a viewer can find the studio. */
+ function studioUrl() {
+ return `${location.origin}${location.pathname}`;
+ }
+
+ /**
+ * Share the current look as a *picture*, not as a link.
+ *
+ * Neither X nor Facebook's web dialog can attach an image, so the picture goes
+ * through the OS share sheet (`navigator.share` with files) wherever the browser
+ * has one - a single tap on a phone, and it can go to any app. Where there is no
+ * share sheet (most desktops), the PNG is put on the clipboard and the network's
+ * compose window is opened with the tag already typed, so the picture only has to
+ * be pasted. The tag *and* the studio link always travel together: the user asks
+ * for both to be kept when the picture is shared.
+ */
+ async function shareImage(network) {
+ let shot;
+ try {
+ shot = await captureImage({ scale: 1.5, transparent: false });
+ } catch (error) {
+ console.error(error);
+ toast('画像を作れませんでした');
+ return;
+ }
+ const file = new File([shot.blob], `bluebey-${exporter.timestamp()}.png`, { type: 'image/png' });
+
+ if (!network && navigator.canShare?.({ files: [file] })) {
+ try {
+ await navigator.share({ files: [file], text: SHARE_TAG, url: studioUrl() });
+ return;
+ } catch (error) {
+ if (error?.name === 'AbortError') return;
+ console.error(error);
+ }
+ }
+
+ // No share sheet (or a named network): put the picture on the clipboard and
+ // open that network's compose window with the tag and the studio link filled in.
+ let copied = false;
+ try {
+ await exporter.copyCanvasToClipboard(shot.canvas);
+ copied = true;
+ } catch (error) {
+ console.error(error);
+ }
+ const studio = studioUrl();
+ const target = network === 'facebook'
+ ? `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(studio)}`
+ : `https://twitter.com/intent/tweet?text=${encodeURIComponent(SHARE_TAG)}&url=${encodeURIComponent(studio)}`;
+ window.open(target, '_blank');
+ toast(copied
+ ? '画像をコピーしました。開いた画面に貼り付けて投稿してください(Ctrl+V)'
+ : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)');
+ }
+
+ function toggleMouthFlap() {
+ if (mouthFlap.running) {
+ mouthFlap.stop();
+ toast('口パクを止めました');
+ return;
+ }
+ const seconds = mouthFlap.start(state.mouthFlap?.text ?? '', {
+ rate: state.mouthFlap?.rate ?? 1,
+ });
+ toast(`口パクを始めました(音は出ません・約${seconds.toFixed(1)}秒)`);
+ }
+
+ async function toggleCamera(on) {
+ if (!on) {
+ backdrop.stopCamera();
+ applyState({ view: { background: 'solid' } }, { scope: 'view', sync: true });
+ return;
+ }
+ const result = await backdrop.startCamera(state.view.cameraFacing ?? 'environment');
+ if (!result?.ok) {
+ toast(result?.reason ?? 'カメラを使えません');
+ return;
+ }
+ applyState({ view: { background: 'camera' } }, { scope: 'view', sync: true });
+ toast('カメラの映像を背景にしました(そのまま写真に撮れます)');
+ }
+
+ function addProp(kind) {
+ const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
+ state.props.items = [...(state.props.items ?? []), {
+ kind,
+ x: def.x ?? 0,
+ y: def.y ?? 0,
+ z: def.z ?? 0,
+ rotX: def.rotX ?? 0,
+ rotY: def.rotY ?? 0,
+ rotZ: def.rotZ ?? 0,
+ scale: def.scale ?? 1,
+ }];
+ applyState({}, { scope: 'render', sync: true });
+ }
+
+ async function addStoryPanel() {
+ const index = (state.story.panels?.length ?? 0) + 1;
+ // A counter, not the array length: removing a panel and then adding another
+ // used to hand out an id that was already taken, which confused the list.
+ storySeq += 1;
+ const id = `p${storySeq}`;
+ state.story.panels = [...(state.story.panels ?? []), {
+ id,
+ label: `コマ${index}`,
+ pose: JSON.parse(JSON.stringify(state.pose)),
+ face: JSON.parse(JSON.stringify(state.face)),
+ caption: JSON.parse(JSON.stringify(state.caption)),
+ caption2: JSON.parse(JSON.stringify(state.caption2)),
+ // The 擬音 stamps as well, so a panel comes back with everything it showed.
+ gion: JSON.parse(JSON.stringify(state.gion)),
+ // The camera as well as the pose: recalling a panel should put you back
+ // where you were looking when you recorded it.
+ view: JSON.parse(JSON.stringify(state.view)),
+ }];
+ applyState({}, { scope: 'caption', sync: true });
+
+ // Then photograph what was just recorded, so the list shows the shot rather
+ // than only its name.
+ try {
+ const shot = await captureImage({ scale: 0.35, transparent: false });
+ storyThumbs.set(id, {
+ version: (storyThumbs.get(id)?.version ?? 0) + 1,
+ url: thumbDataUrl(shot.canvas),
+ });
+ toast(`コマ${index}を追加しました`);
+ } catch (error) {
+ console.warn('[bluebey] story thumbnail failed', error);
+ toast(`コマ${index}を追加しました(プレビューは作れませんでした)`);
+ }
+ app.panel?.sync();
+ }
+
+ /** Shrink a capture down to a thumbnail data URL for the panel list. */
+ function thumbDataUrl(source, width = 168) {
+ const scale = width / Math.max(1, source.width);
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.max(1, Math.round(source.width * scale));
+ canvas.height = Math.max(1, Math.round(source.height * scale));
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
+ return canvas.toDataURL('image/png');
+ }
+
+ /**
+ * A number in the corner of a comic panel, for sheets whose reading order is
+ * not obvious. Drawn with the vendored caption font, so the sheet does not
+ * depend on whatever fonts the viewer happens to have.
+ */
+ function drawPanelNumber(ctx, number, x, y, cellW, cellH) {
+ const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075));
+ const margin = radius * 0.7;
+ const cx = x + margin + radius;
+ const cy = y + margin + radius;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
+ ctx.fill();
+ ctx.lineWidth = Math.max(2, radius * 0.14);
+ ctx.strokeStyle = '#3f2b52';
+ ctx.stroke();
+ ctx.fillStyle = '#3f2b52';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = `bold ${Math.round(radius * 1.25)}px ${captionFontStack()}`;
+ ctx.fillText(String(number), cx, cy + radius * 0.04);
+ ctx.restore();
+ }
+
+ /**
+ * まんが: replay the panels, capture each one, and hand back a framed sheet.
+ */
+ async function saveStory({ scale = 2 } = {}) {
+ const panels = state.story?.panels ?? [];
+ if (!panels.length) {
+ toast('コマがありません。「今の状態をコマに追加」で作ってください');
+ return;
+ }
+ const saved = JSON.parse(JSON.stringify(state));
+ const captures = [];
+ suspendHistory = true;
+ try {
+ for (const panel of panels) {
+ const pose = panel.pose ?? {};
+ state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] };
+ state.face = JSON.parse(JSON.stringify(saved.face));
+ applyPatch(state.face, panel.face ?? {});
+ state.caption = { ...saved.caption, ...(panel.caption ?? {}) };
+ state.caption2 = { ...saved.caption2, ...(panel.caption2 ?? {}) };
+ // The camera the panel was framed with, so a comic keeps its angles.
+ if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view));
+ refresh('all');
+ // Two frames: one to apply the pose, one to draw it.
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ captures.push(await captureImage({ scale, transparent: false }));
+ }
+ } finally {
+ suspendHistory = false;
+ applyState(saved, { full: true, sync: true });
+ }
+
+ const columns = Math.max(1, Math.min(4, state.story?.columns ?? 2));
+ const gap = state.story?.gap ?? 12;
+ const padding = state.story?.padding ?? 20;
+ const rows = Math.ceil(captures.length / columns);
+ const cellW = Math.max(...captures.map((item) => item.width));
+ const cellH = Math.max(...captures.map((item) => item.height));
+
+ // Once the sheet is more than one column wide, the reading order stops being
+ // obvious from the layout alone, so the panels are numbered. A single column
+ // (or two panels) reads top to bottom without help.
+ const numbered = columns >= 2 && captures.length >= 3;
+ if (numbered) await ensureCaptionFont();
+
+ // A manga page reads by its panel frames. They are drawn inside each cell so
+ // the artwork keeps its full size, and are thick enough to survive the 1×
+ // export.
+ const frame = Math.max(3, Math.round(Math.min(cellW, cellH) * 0.012));
+
+ const sheet = document.createElement('canvas');
+ sheet.width = padding * 2 + cellW * columns + gap * (columns - 1);
+ sheet.height = padding * 2 + cellH * rows + gap * (rows - 1);
+ const ctx = sheet.getContext('2d');
+ ctx.fillStyle = state.story?.sheetBackground ?? '#ffffff';
+ ctx.fillRect(0, 0, sheet.width, sheet.height);
+ captures.forEach((capture, index) => {
+ const x = padding + (index % columns) * (cellW + gap);
+ const y = padding + Math.floor(index / columns) * (cellH + gap);
+ ctx.drawImage(capture.canvas, x, y, cellW, cellH);
+ ctx.lineWidth = frame;
+ ctx.strokeStyle = '#2a1e33';
+ ctx.strokeRect(x + frame / 2, y + frame / 2, cellW - frame, cellH - frame);
+ if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH);
+ });
+
+ const stamp = exporter.timestamp();
+ exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`);
+ toast(`まんがを書き出しました(${captures.length}コマ)`);
+ }
+
+ /** A shared link restores the whole look; otherwise this is the start point. */
+ async function loadFromHash() {
+ // 共有ボタンは `?s=`、ボタン「この見た目のリンクをコピー」は `#s=` を使う。
+ // Facebook は `#` 以降を落とすので、共有はクエリにする必要がある - どちらでも読む。
+ const query = new URLSearchParams(location.search).get('s');
+ const hash = location.hash.replace(/^#/, '');
+ const encoded = query ?? new URLSearchParams(hash).get('s');
+ if (!encoded) return false;
+ const decoded = await decodeState(encoded);
+ if (!decoded?.state) return false;
+ suspendHistory = true;
+ try {
+ applyState(decoded.state, { full: true, sync: true });
+ } finally {
+ suspendHistory = false;
+ }
+ return true;
+ }
+
+ function installTopbar() {
+ // The panel itself is visible from the start, but each of its sections starts
+ // collapsed (see `section()` in src/ui.js): the panel reads as a short list of
+ // headings, and you open the one you want instead of scrolling past twelve.
+ resizeViewport();
+
+ // AR (カメラ=AR) get a camera-app style shutter over the viewport, so a photo
+ // is one tap away. It is a DOM layer over the canvas, so it never lands in the
+ // export; its visibility follows the background mode (see `applyBackdrop`).
+ const shutter = document.createElement('button');
+ shutter.type = 'button';
+ shutter.id = 'ar-shutter';
+ shutter.className = 'ar-shutter';
+ shutter.title = '写真を撮る';
+ shutter.setAttribute('aria-label', '写真を撮る');
+ shutter.hidden = true;
+ shutter.addEventListener('click', () => { savePNG(); });
+ stage.append(shutter);
+ app.arShutter = shutter;
+
+ document.getElementById('btn-shot')?.addEventListener('click', () => app.actions.savePNG());
+ document.getElementById('btn-copy')?.addEventListener('click', () => app.actions.copyPNG());
+ document.getElementById('btn-help')?.addEventListener('click', toggleHelp);
+ document.getElementById('btn-panel')?.addEventListener('click', togglePanel);
+ document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); });
+ const help = document.getElementById('help');
+ help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); });
+
+ // --- the mouse on the character ----------------------------------------
+ // Dragging the model slides it across the view; holding Shift and dragging
+ // turns it around. Bone posing lives in the panel, so the mouse never grabs a
+ // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over
+ // the view, so a tap on the view first just dismisses the sheet.
+ const dragRay = new THREE.Raycaster();
+ const dragNdc = new THREE.Vector2();
+ const dragHitAt = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ dragNdc.set(
+ ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1,
+ -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1,
+ );
+ dragRay.setFromCamera(dragNdc, view.camera);
+ const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body]
+ .filter((mesh) => mesh?.visible);
+ return dragRay.intersectObjects(targets, false)[0] ?? null;
+ };
+ // World units per screen pixel, so the model keeps up with the cursor.
+ const dragScale = () => {
+ const camera = view.camera;
+ const height = Math.max(1, canvas.clientHeight);
+ if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height;
+ const distance = camera.position.distanceTo(view.controls.target);
+ return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height;
+ };
+ let modelDrag = null;
+
+ canvas.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0 || event.target !== canvas) return;
+ if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) {
+ togglePanel();
+ return;
+ }
+ if (!dragHitAt(event)) return;
+ const root = state.pose.root ?? [0, 0, 0];
+ const master = state.pose.bones?.master ?? [0, 0, 0];
+ modelDrag = {
+ pointerId: event.pointerId,
+ spin: event.shiftKey,
+ x: event.clientX,
+ y: event.clientY,
+ root: [...root],
+ masterY: master[1] ?? 0,
+ };
+ view.controls.enabled = false;
+ stage.style.cursor = 'grabbing';
+ try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ }
+ });
+
+ canvas.addEventListener('pointermove', (event) => {
+ if (!modelDrag || event.pointerId !== modelDrag.pointerId) return;
+ const dx = event.clientX - modelDrag.x;
+ const dy = event.clientY - modelDrag.y;
+ if (modelDrag.spin) {
+ const bones = { ...(state.pose.bones ?? {}) };
+ const master = bones.master ?? [0, 0, 0];
+ bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]];
+ state.pose.bones = bones;
+ } else {
+ const k = dragScale();
+ state.pose.root = [
+ round2(modelDrag.root[0] + dx * k),
+ round2(modelDrag.root[1] - dy * k),
+ modelDrag.root[2],
+ ];
+ }
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ });
+
+ const endModelDrag = (event) => {
+ if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return;
+ modelDrag = null;
+ view.controls.enabled = true;
+ stage.style.cursor = '';
+ if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } }
+ capturePose();
+ app.panel?.onRigChanged?.();
+ app.needsRender = true;
+ };
+ canvas.addEventListener('pointerup', endModelDrag);
+ canvas.addEventListener('pointercancel', endModelDrag);
+ }
+
+ function togglePanel() {
+ document.body.classList.toggle('panel-hidden');
+ resizeViewport();
+ }
+
+ // The panel is a side column on a wide screen and a bottom sheet on a small
+ // one, where it starts closed so the 3D view fills the screen (see the
+ // small-screen rules in src/style.css). When the window is resized across that
+ // breakpoint, match the layout the other side expects instead of leaving the
+ // panel stuck in the previous state.
+ const narrowScreenLayout = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)');
+ narrowScreenLayout.addEventListener('change', (event) => {
+ document.body.classList.toggle('panel-hidden', event.matches);
+ resizeViewport();
+ });
+
+ function installKeys() {
+ window.addEventListener('keydown', (event) => {
+ // Undo/redo are the only shortcuts that need a modifier, so they are dealt
+ // with before the "no modifiers" rule below.
+ if ((event.ctrlKey || event.metaKey) && !event.altKey) {
+ const combo = event.key.toLowerCase();
+ if (combo === 'z' || combo === 'y') {
+ event.preventDefault();
+ restoreFromHistory(combo === 'y' || event.shiftKey ? 'redo' : 'undo');
+ return;
+ }
+ }
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
+ const target = event.target;
+ if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return;
+ switch (event.key) {
+ case 's': case 'S': savePNG(); break;
+ case 'c': case 'C': copyPNG(); break;
+ case 'r': case 'R': app.actions.resetPose(); break;
+ case 'g': case 'G': {
+ const visible = !rig.helper.visible;
+ rig.setGizmoVisible(visible);
+ toast(visible ? 'ギズモを表示' : 'ギズモを隠しました');
+ app.needsRender = true;
+ break;
+ }
+ case ' ': event.preventDefault(); app.actions.toggleAnimation(); break;
+ case '?': toggleHelp(); break;
+ case 'Tab': event.preventDefault(); togglePanel(); break;
+ case '1': setCameraPreset('front'); break;
+ case '2': setCameraPreset('threeQuarter'); break;
+ case '3': setCameraPreset('side'); break;
+ case '4': setCameraPreset('back'); break;
+ case '5': setCameraPreset('top'); break;
+ default: break;
+ }
+ });
+ }
+
+}
+
+function toggleHelp(force) {
+ const help = document.getElementById('help');
+ if (help) help.hidden = force === undefined ? !help.hidden : !force;
+}
+
+/** A persistent message for problems that need the user to do something. */
+function showNotice(text) {
+ let node = document.getElementById('notice');
+ if (!node) {
+ node = document.createElement('div');
+ node.id = 'notice';
+ document.body.append(node);
+ }
+ node.textContent = text;
+ node.hidden = false;
+}
+
+/* ------------------------------------------------------------------- camera */
+
+/**
+ * Orbiting camera with both a perspective and an orthographic projection.
+ * Only one of them is enabled at a time; switching copies the framing across so
+ * the model does not jump.
+ */
+class ViewRig {
+ constructor(domElement, onChange) {
+ this.domElement = domElement;
+ this.onChange = onChange;
+ this.projection = 'persp';
+ this.controlsChanged = false;
+
+ this.cameras = {
+ // A tight near/far keeps depth precision high, which matters because the
+ // outline hull is only a few hundredths of a unit away from the surface.
+ // The far plane has to sit well past the furthest the camera may go, or
+ // zooming all the way out slices the character on it - which is exactly
+ // what the old `far = 120` did, because the zoom limit was also 120.
+ persp: new THREE.PerspectiveCamera(30, 1, 1, 2000),
+ ortho: new THREE.OrthographicCamera(-1, 1, 1, -1, -2000, 2000),
+ };
+ this.controlsMap = {};
+ for (const [key, camera] of Object.entries(this.cameras)) {
+ const controls = new OrbitControls(camera, domElement);
+ controls.enableDamping = false;
+ controls.enablePan = true;
+ controls.minDistance = 1;
+ controls.maxDistance = 400;
+ controls.addEventListener('change', () => {
+ this.controlsChanged = true;
+ this.onChange?.();
+ });
+ this.controlsMap[key] = controls;
+ }
+ this.controlsMap.ortho.enabled = false;
+ this.camera = this.cameras.persp;
+ this.controls = this.controlsMap.persp;
+ this.target = new THREE.Vector3(0, 1, 0);
+ this.autoDistance = 12;
+ // `orthoHeight` is where the orthographic camera is *now* - `apply()` writes
+ // the camera's actual height back into it - so the framing `frame()` chose
+ // needs its own field to measure the zoom against (see `zoomFactor`).
+ this.orthoHeight = 6;
+ this.autoOrthoHeight = 6;
+ this.resize();
+ }
+
+ /** Fit the model into view. */
+ frame(size, targetY) {
+ this.target.set(0, targetY, 0);
+ for (const controls of Object.values(this.controlsMap)) controls.target.copy(this.target);
+ const fov = this.cameras.persp.fov * Math.PI / 180;
+ const heightDistance = size.y / (2 * Math.tan(fov / 2));
+ const widthDistance = size.x / (2 * Math.tan(fov / 2) * Math.max(0.4, this.aspect));
+ this.autoDistance = Math.max(heightDistance, widthDistance) * 1.45;
+ this.orthoHeight = size.y * 1.5;
+ this.autoOrthoHeight = this.orthoHeight;
+ for (const camera of Object.values(this.cameras)) {
+ camera.position.set(0, targetY + this.autoDistance * 0.12, this.autoDistance);
+ }
+ this.resize();
+ }
+
+ get aspect() {
+ const width = this.domElement.clientWidth || 1;
+ const height = this.domElement.clientHeight || 1;
+ return width / height;
+ }
+
+ resize() {
+ const width = this.domElement.clientWidth || window.innerWidth;
+ const height = this.domElement.clientHeight || window.innerHeight;
+ const aspect = width / height;
+ this.cameras.persp.aspect = aspect;
+ this.cameras.persp.updateProjectionMatrix();
+ this.setOrthoHeight(this.orthoHeight);
+ }
+
+ setOrthoHeight(height) {
+ const ortho = this.cameras.ortho;
+ const halfHeight = height / 2;
+ const halfWidth = halfHeight * this.aspect;
+ ortho.left = -halfWidth;
+ ortho.right = halfWidth;
+ ortho.top = halfHeight;
+ ortho.bottom = -halfHeight;
+ ortho.zoom = 1;
+ ortho.updateProjectionMatrix();
+ }
+
+ /**
+ * How large the character looks now, relative to the auto-fit framing.
+ *
+ * 1 means "the default framing", which is where the panel's 大きさ sliders sit
+ * and so where the screen-space outline's pixel width is written against. The
+ * two projections scale differently - a perspective camera shows 1/distance as
+ * much per world unit, an orthographic one 1/height, and OrbitControls zooms
+ * the latter with `camera.zoom` rather than by moving it - so each is
+ * normalised by its own auto-fit value.
+ */
+ zoomFactor() {
+ if (this.projection === 'ortho') {
+ const ortho = this.cameras.ortho;
+ const height = (ortho.top - ortho.bottom) / Math.max(ortho.zoom, 0.0001);
+ return this.autoOrthoHeight > 0 && height > 0 ? this.autoOrthoHeight / height : 1;
+ }
+ const distance = this.camera.position.distanceTo(this.controls?.target ?? this.target);
+ return this.autoDistance > 0 && distance > 0 ? this.autoDistance / distance : 1;
+ }
+
+ setProjection(kind) {
+ if (kind === this.projection) return;
+ const previous = this.camera;
+ const next = kind === 'ortho' ? this.cameras.ortho : this.cameras.persp;
+ next.position.copy(previous.position);
+ next.quaternion.copy(previous.quaternion);
+ this.controlsMap[this.projection].enabled = false;
+ this.projection = kind;
+ this.camera = next;
+ this.controls = this.controlsMap[kind];
+ this.controls.enabled = true;
+ this.controls.target.copy(this.target);
+ this.resize();
+ this.onCameraChange?.(next, this.controls);
+ this.controlsChanged = true;
+ this.onChange?.();
+ }
+
+ /** Apply the serialisable view state (azimuth/polar/zoom). */
+ apply(viewState, size) {
+ const distance = viewState.distance > 0 ? viewState.distance : this.autoDistance;
+ const targetY = viewState.targetY || this.target.y;
+ this.target.set(viewState.targetX || 0, targetY, viewState.targetZ || 0);
+ const spherical = new THREE.Spherical(
+ distance,
+ clamp(viewState.polar, 1, 179) * Math.PI / 180,
+ viewState.azimuth * Math.PI / 180,
+ );
+ const offset = new THREE.Vector3().setFromSpherical(spherical);
+ for (const camera of Object.values(this.cameras)) {
+ camera.position.copy(this.target).add(offset);
+ camera.lookAt(this.target);
+ }
+ if (viewState.orthoHeight > 0) this.setOrthoHeight(viewState.orthoHeight);
+ else if (size) this.setOrthoHeight(this.orthoHeight);
+ for (const controls of Object.values(this.controlsMap)) {
+ controls.target.copy(this.target);
+ controls.update();
+ }
+ this.orthoHeight = this.cameras.ortho.top - this.cameras.ortho.bottom;
+ this.cameras.persp.updateProjectionMatrix();
+ this.cameras.ortho.updateProjectionMatrix();
+ }
+
+ /**
+ * Write where the camera actually is back into the serialisable view state.
+ *
+ * WHY: the mouse orbit and pan move the `controls`, not the state. Without this
+ * the state kept the last camera the *panel* set, so any refresh of the view
+ * snapped the camera back to it - which is what made picking a background jump
+ * the camera. Keeping the two in step also means a shared link carries the
+ * camera you framed.
+ */
+ captureInto(viewState) {
+ if (!viewState || typeof viewState !== 'object') return;
+ const target = this.controls?.target ?? this.target;
+ const spherical = new THREE.Spherical().setFromVector3(
+ new THREE.Vector3().subVectors(this.camera.position, target),
+ );
+ viewState.azimuth = Math.round(spherical.theta * RAD_TO_DEG * 100) / 100;
+ viewState.polar = Math.round(spherical.phi * RAD_TO_DEG * 100) / 100;
+ viewState.distance = Math.round(spherical.radius * 1000) / 1000;
+ viewState.targetX = Math.round(target.x * 1000) / 1000;
+ viewState.targetY = Math.round(target.y * 1000) / 1000;
+ viewState.targetZ = Math.round(target.z * 1000) / 1000;
+ if (this.projection === 'ortho') {
+ const ortho = this.cameras.ortho;
+ viewState.orthoHeight = Math.round((ortho.top - ortho.bottom) * 1000) / 1000;
+ }
+ }
+
+ setAutoRotate(enabled, speed) {
+ for (const controls of Object.values(this.controlsMap)) {
+ controls.autoRotate = enabled;
+ controls.autoRotateSpeed = speed;
+ }
+ }
+}
+
+/**
+ * How far the screen-space outline may follow the zoom (see `outlineRadiusFor`).
+ *
+ * A clamp keeps a very deep zoom from turning the line into either a smear or
+ * nothing at all, which is what the linear factor would do at the extremes of
+ * the 大きさ slider.
+ */
+const OUTLINE_ZOOM_MIN = 0.25;
+const OUTLINE_ZOOM_MAX = 3;
+
+/**
+ * The screen-space outline's radius, in pixels of the label buffer.
+ *
+ * The body's lines are inverted hulls, i.e. an offset in *world* units, so they
+ * thicken as the character grows on screen and thin as it shrinks. This pass
+ * works in pixels instead, so left alone its lines - the waist leaves and the
+ * nose - keep one width at every zoom, and the leaf skirt reads as far too heavy
+ * beside the body the moment you pull back. Scaling the radius by the rig's
+ * `zoomFactor` makes the two behave the same way, with `outlinePixels` staying
+ * the width at the auto-fit framing (so the slider keeps its meaning).
+ *
+ * `extra` carries whatever else changes the buffer's pixels-per-CSS-pixel: the
+ * preview's buffer is `quality`x the CSS size (and `quality` itself follows the
+ * display density and the automatic reductions), and an export renders the label
+ * buffer at `scale`x. Passing that factor keeps the line a constant *CSS* width,
+ * so `outlinePixels` reads as CSS pixels whichever buffer it lands in.
+ */
+function outlineRadiusFor(view, state, extra = 1) {
+ const zoom = clamp(view.zoomFactor(), OUTLINE_ZOOM_MIN, OUTLINE_ZOOM_MAX);
+ return (state.render.outlinePixels ?? 2) * zoom * extra;
+}
+
+/* ----------------------------------------------------------------- scenery */
+
+function makeEnvironment(renderer) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 64;
+ canvas.height = 32;
+ const ctx = canvas.getContext('2d');
+ const gradient = ctx.createLinearGradient(0, 0, 0, 32);
+ gradient.addColorStop(0, '#ffffff');
+ gradient.addColorStop(0.5, '#ece7fa');
+ gradient.addColorStop(1, '#b9aade');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 64, 32);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.mapping = THREE.EquirectangularReflectionMapping;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const generator = new THREE.PMREMGenerator(renderer);
+ const environment = generator.fromEquirectangular(texture).texture;
+ texture.dispose();
+ generator.dispose();
+ return environment;
+}
+
+/** Round to 2 decimals: the precision the panel's prop sliders show. */
+const round2 = (value) => Math.round(value * 100) / 100;
+
+const clampNumber = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+
+export function backgroundOf(viewState) {
+ if (viewState.background === 'transparent') return { mode: 'transparent' };
+ // A preset, a loaded photo, a drawn effect line or the phone's camera all
+ // live in a DOM layer behind the WebGL canvas, so the renderer is left clear
+ // and the exports paint the bitmap into the picture themselves (see
+ // drawBackdropInto in main.js).
+ if (viewState.background === 'preset' || viewState.background === 'image'
+ || viewState.background === 'effect' || viewState.background === 'camera') {
+ return { mode: 'transparent' };
+ }
+ return { mode: 'solid', color: viewState.backgroundColor };
+}
+
+/* ------------------------------------------------------------------- errors */
+
+function setLoadingProgress(ratio) {
+ const node = document.getElementById('loading-text');
+ if (node) node.textContent = `ぶるべーを読み込んでいます… ${Math.round(ratio * 100)}%`;
+}
+
+function hideLoading() {
+ const node = document.getElementById('loading');
+ if (node) node.classList.add('done');
+}
+
+function showLoadError(error) {
+ const text = document.getElementById('loading-text');
+ const detail = document.getElementById('loading-error');
+ if (text) text.textContent = '読み込みに失敗しました。';
+ if (detail) {
+ detail.textContent = `${error?.message ?? error}\n\n`
+ + 'このページはローカルサーバー経由で開く必要があります。\n'
+ + 'bluebey-studio フォルダで python serve.py を実行し、\n'
+ + '表示された http://127.0.0.1:8000/ をブラウザで開いてください。';
+ }
+}
+
+const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+const clamp01 = (value) => clamp(value, 0, 1);
diff --git a/bluebey-studio/src/model.js b/bluebey-studio/src/model.js
new file mode 100644
index 0000000..3925e85
--- /dev/null
+++ b/bluebey-studio/src/model.js
@@ -0,0 +1,283 @@
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+
+/**
+ * Loads bluebey.glb and works out everything the studio needs to know about it:
+ * the rig, which meshes are the face overlays, and the original hand-drawn
+ * textures that ship inside the file.
+ *
+ * The GLB layout (materials carry the reliable names; meshes are named after
+ * Blender objects):
+ * body parts Body, Foots, Leaf, Vein, LeftHand, RightHand, Nose
+ * overlays eyes-* (eight hand-drawn variants, one texture each) and Mouth
+ * face plates eyes-plate / mouth-plate, the big shells the studio draws on
+ * hair plate hair-plate, a shell that wraps right round the head (optional)
+ *
+ * The two face plates are what the artwork is painted onto. They are the front
+ * half of the body, so wherever the drawing lands there is a surface to carry
+ * it - the old hand-drawn planes only covered a patch of the head, which clipped
+ * the bottom of a deep smile and most of a teardrop. When a model has no plates
+ * (the 2022 file), the studio falls back to drawing on the hand-drawn planes.
+ */
+
+/** Hand-drawn texture variants inside the GLB, keyed by their material suffix. */
+export const EYE_VARIANTS = [
+ { key: 'opened', label: '開いた目' },
+ { key: 'closed', label: '閉じた目' },
+ { key: 'close-tight', label: 'ぎゅっと閉じ' },
+ { key: 'left-wink', label: '左ウインク' },
+ { key: 'look-up', label: '上を見る' },
+ { key: 'look-down', label: '下を見る' },
+ { key: 'look-left', label: '左を見る' },
+ { key: 'look-right', label: '右を見る' },
+];
+
+/**
+ * Friendly names for the eleven bones of the rig. Keys are lower-cased with
+ * dots removed, because three's GLTFLoader sanitises node names (`armsupport.l`
+ * arrives as `armsupportl`).
+ */
+const BONE_LABELS = {
+ master: '全身',
+ armsupportl: '左うでの付け根',
+ arml: '左うで',
+ handl: '左手先',
+ armr: '右うで',
+ handr: '右手先',
+ legsupportl: '左あしの付け根',
+ footl: '左足',
+ toel: '左つまさき',
+ legsupportr: '右あしの付け根',
+ footr: '右足',
+ toer: '右つまさき',
+};
+
+const normalizeBoneName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase();
+
+/**
+ * The overlay planes hug the head, so nudge them outwards before anything else
+ * happens. They are pushed along their own normals (not away from the head
+ * centre): the mouth in particular needs to clear the head's lower surface and
+ * the ring of leaves around the base, otherwise a low or frowning mouth gets
+ * swallowed by them.
+ */
+const OVERLAY_INFLATE = { eyes: 0.004, mouth: 0.004, hair: 0.008 };
+
+export async function loadModel(source, { onProgress } = {}) {
+ const loader = new GLTFLoader();
+ let gltf;
+ if (typeof source === 'string') {
+ gltf = await loader.loadAsync(source, (event) => {
+ if (onProgress && event.lengthComputable) onProgress(event.loaded / event.total);
+ });
+ } else {
+ // A File/ArrayBuffer, e.g. from drag and drop or the file picker.
+ gltf = await loader.parseAsync(source, '');
+ }
+
+ const root = gltf.scene;
+
+ // The file carries its own camera node; we drive our own.
+ const cameras = [];
+ root.traverse((object) => { if (object.isCamera) cameras.push(object); });
+ for (const camera of cameras) camera.removeFromParent();
+
+ const eyes = new Map();
+ const body = [];
+ /** The original material name of every body mesh (`blb`, `leaf`, `vein`, ...). */
+ const kinds = new Map();
+ /** The plane holding the hand-drawn mouth texture (a texture source only). */
+ let mouthOriginal = null;
+ /** The big shells the artwork is drawn onto, if the file has them. */
+ let eyePlate = null;
+ let mouthPlate = null;
+ /** The shell that wraps round the head, so hair shows from behind (optional). */
+ let hairPlate = null;
+
+ root.traverse((object) => {
+ if (!object.isMesh) return;
+ // Skinned bounds stop matching the pose, so culling would pop meshes away.
+ object.frustumCulled = false;
+ const material = Array.isArray(object.material) ? object.material[0] : object.material;
+ const materialName = (material?.name ?? '').toLowerCase();
+ if (materialName === 'eyes-plate') {
+ eyePlate = object;
+ return;
+ }
+ if (materialName === 'mouth-plate') {
+ mouthPlate = object;
+ return;
+ }
+ if (materialName === 'hair-plate') {
+ hairPlate = object;
+ return;
+ }
+ if (materialName.startsWith('eyes-')) {
+ eyes.set(materialName.slice('eyes-'.length), object);
+ return;
+ }
+ if (materialName === 'mouth') {
+ mouthOriginal = object;
+ return;
+ }
+ body.push(object);
+ kinds.set(object, materialName);
+ });
+
+ // The waist leaves - the `Leaf` blade and the `Vein` rim that shares its mesh.
+ // These are the thin, overlapping shells the inverted-hull outline cannot draw
+ // (see MODEL-GUIDE.md §5), so the screen-space pass takes them over on its own.
+ const leaves = body.filter((mesh) => kinds.get(mesh) === 'leaf' || kinds.get(mesh) === 'vein');
+
+ // The drawing surface: the big plate when present, otherwise the 2022 plane.
+ const eyeMesh = eyePlate ?? eyes.get('opened') ?? eyes.values().next().value;
+ const mouth = mouthPlate ?? mouthOriginal;
+ // The wrapping hair shell, if the model carries one (`hair-plate`).
+ const hairMesh = hairPlate;
+ // Its cylindrical UV has a seam at the back of the head. Triangles that cross
+ // it get one UV near 0 and the next near 1, so the whole texture is stretched
+ // across them - a visible streak down the back. Close the seam per triangle.
+ if (hairMesh) healCylinderSeam(hairMesh);
+ if (!eyeMesh) throw new Error('eye mesh not found in the GLB');
+ if (!mouth) throw new Error('mouth mesh not found in the GLB');
+
+ // The nose ball. It is a bump sitting on the body rather than a part with a
+ // silhouette of its own, so the outline pass treats it per style - see
+ // `outlineExclusion` in main.js.
+ const noseMesh = body.find((mesh) => (mesh.material?.name ?? '').toLowerCase() === 'nose') ?? null;
+
+ // Only the drawing surface stays visible; every other overlay is a texture
+ // source, so hide it. (`mouthOriginal` may be the drawing surface itself.)
+ for (const mesh of eyes.values()) mesh.visible = mesh === eyeMesh;
+ if (mouthOriginal && mouthOriginal !== mouth) mouthOriginal.visible = false;
+ if (mouthPlate && eyePlate) mouthPlate.visible = true;
+
+ /** @type {{ eyes: Record, mouth: THREE.Texture|null }} */
+ const originals = { eyes: {}, mouth: mouthOriginal?.material?.map ?? mouth.material.map ?? null };
+ for (const [key, mesh] of eyes) {
+ if (mesh.material.map) originals.eyes[key] = mesh.material.map;
+ }
+
+ // Push the overlay planes a hair off the body surface, so the two do not
+ // z-fight. The plates are shells of the body, so the offset is tiny; a large
+ // one is what made the mouth look like it floated in profile.
+ const bodyMesh = body.find((mesh) => mesh.geometry?.attributes?.position?.count > 2000) ?? body[0];
+ const headCentre = new THREE.Box3()
+ .setFromBufferAttribute(bodyMesh.geometry.attributes.position)
+ .getCenter(new THREE.Vector3());
+ inflateOverlay(eyeMesh, headCentre, OVERLAY_INFLATE.eyes);
+ inflateOverlay(mouth, headCentre, OVERLAY_INFLATE.mouth);
+ if (hairMesh) inflateOverlay(hairMesh, headCentre, OVERLAY_INFLATE.hair);
+
+ // Recentre: put the character on the origin with its feet on the ground so the
+ // camera maths stays trivial.
+ const box = new THREE.Box3().setFromObject(root);
+ const centre = box.getCenter(new THREE.Vector3());
+ root.position.x -= centre.x;
+ root.position.z -= centre.z;
+ root.position.y -= box.min.y;
+ root.updateMatrixWorld(true);
+
+ const bounds = new THREE.Box3().setFromObject(root);
+ const size = bounds.getSize(new THREE.Vector3());
+ const middle = bounds.getCenter(new THREE.Vector3());
+
+ const skinned = [];
+ root.traverse((object) => { if (object.isSkinnedMesh && object.skeleton) skinned.push(object); });
+ const skeleton = skinned[0]?.skeleton ?? null;
+
+ const bones = [];
+ if (skeleton) {
+ for (const bone of skeleton.bones) {
+ bones.push({
+ name: bone.name,
+ label: BONE_LABELS[normalizeBoneName(bone.name)] ?? bone.name,
+ bone,
+ rest: bone.quaternion.clone(),
+ });
+ }
+ }
+
+ return {
+ gltf,
+ root,
+ size,
+ middle,
+ bounds,
+ skeleton,
+ bones,
+ parts: {
+ body,
+ kinds,
+ leaves,
+ overlays: [eyeMesh, mouth, ...(hairMesh ? [hairMesh] : [])],
+ eyeMesh,
+ mouthMesh: mouth,
+ hairMesh,
+ noseMesh,
+ hasFacePlates: Boolean(eyePlate && mouthPlate),
+ },
+ originals,
+ };
+}
+
+function inflateOverlay(mesh, centre, distance) {
+ const geometry = mesh.geometry;
+ const position = geometry?.attributes?.position;
+ if (!position) return;
+ const normal = geometry.attributes.normal;
+ const radial = new THREE.Vector3();
+ const offset = new THREE.Vector3();
+ const vertexNormal = new THREE.Vector3();
+ for (let i = 0; i < position.count; i++) {
+ radial.fromBufferAttribute(position, i).sub(centre);
+ if (radial.lengthSq() < 1e-12) continue;
+ offset.copy(radial).normalize();
+ if (normal) {
+ // The planes are double sided, so the stored normal may point either way.
+ vertexNormal.fromBufferAttribute(normal, i);
+ if (vertexNormal.lengthSq() > 1e-12) {
+ if (vertexNormal.dot(offset) < 0) vertexNormal.negate();
+ offset.copy(vertexNormal.normalize());
+ }
+ }
+ position.setXYZ(
+ i,
+ position.getX(i) + offset.x * distance,
+ position.getY(i) + offset.y * distance,
+ position.getZ(i) + offset.z * distance,
+ );
+ }
+ position.needsUpdate = true;
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+}
+
+/**
+ * Make every triangle of a cylindrical UV island sit on one side of the seam.
+ *
+ * A cylinder unwrap cuts the surface open along one line (here the back of the
+ * head). A triangle that spans the cut has its corners at u ~ 1 and u ~ 0, so
+ * interpolating them drags the whole texture across the triangle - the streak
+ * seen down the back. Wrapping the low corners up by one turn keeps each
+ * triangle local; the texture's two edges are identical (the covering is drawn
+ * right across), so the join is invisible.
+ */
+function healCylinderSeam(mesh) {
+ const source = mesh?.geometry;
+ if (!source?.attributes?.uv) return;
+ // Give every triangle its own corners first. The seam's low and high sides
+ // share vertices, so editing a shared one would drag its neighbours too; a
+ // non-indexed copy isolates each triangle and the fix cannot leak.
+ const geometry = source.index ? source.toNonIndexed() : source;
+ const uv = geometry.attributes.uv;
+ for (let i = 0; i < uv.count; i += 3) {
+ const top = Math.max(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2));
+ if (top - Math.min(uv.getX(i), uv.getX(i + 1), uv.getX(i + 2)) <= 0.5) continue;
+ for (let k = 0; k < 3; k += 1) {
+ if (uv.getX(i + k) < top - 0.5) uv.setX(i + k, uv.getX(i + k) + 1);
+ }
+ }
+ uv.needsUpdate = true;
+ mesh.geometry = geometry;
+}
diff --git a/bluebey-studio/src/mouthFlap.js b/bluebey-studio/src/mouthFlap.js
new file mode 100644
index 0000000..1109398
--- /dev/null
+++ b/bluebey-studio/src/mouthFlap.js
@@ -0,0 +1,267 @@
+/**
+ * 口パク (mouth flap): open and close the mouth as if talking, with no sound.
+ *
+ * WHY no speech: the studio used to read the caption out with the Web Speech
+ * API, but that voice belongs to the device (it is the OS's own speech engine),
+ * so a recording of it is not something we are free to hand out. What a clip
+ * actually needs is the *mouth motion*, and that needs no voice at all:
+ * `createMouthEnvelope` builds a smooth, seeded signal in the 3-6
+ * syllable-per-second band, and `createMouthFlap` ticks it at about 30 Hz for
+ * as long as the line would take to say.
+ *
+ * Nothing here touches `speechSynthesis`, so there is no permission prompt, no
+ * device dependency, and a recording of the animation is the studio's own work.
+ */
+
+const TAU = Math.PI * 2;
+/** The mouth is sampled at ~30 Hz: enough for an animation, cheap to run. */
+const LEVEL_HZ = 30;
+/** How long the envelope takes to close after `stop()`, in seconds. */
+const CLOSE_SECONDS = 0.3;
+
+const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value));
+const clamp01 = (value) => clamp(value, 0, 1);
+
+/**
+ * mulberry32, the same tiny generator `handDrawn.js` uses. The mouth must be
+ * reproducible for a seed, so `Math.random` is not an option.
+ *
+ * @param {number} seed
+ * @returns {() => number} values in [0, 1)
+ */
+function mulberry32(seed) {
+ let a = seed >>> 0;
+ return function next() {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * The mouth source: a deterministic, smooth mouth opening in 0..1.
+ *
+ * A syllable is one closed -> open -> closed cycle, so the signal is a cosine
+ * pulse whose frequency is slowly modulated between 3 and 6 Hz and shaped by a
+ * slower amplitude wobble. The syllable range and the modulation constants come
+ * from the seed, so two envelopes with the same seed are exactly identical, and
+ * the signal is continuous with a bounded slope: it never jumps, which is what
+ * keeps the mouth from flickering.
+ *
+ * @param {{ seed?: number }} [options]
+ * @returns {{ value: (t: number) => number, start: (at?: number) => void, stop: () => void }}
+ */
+export function createMouthEnvelope({ seed = 1 } = {}) {
+ const random = mulberry32(seed);
+ const phase0 = random() * TAU;
+ const phase1 = random() * TAU;
+ // 4.2..4.8 syllables/s on average, swung by up to 1.2 either way, then kept
+ // inside 3..6 so the read stays in the range of human speech.
+ const rateMean = 4.2 + random() * 0.6;
+ const rateSwing = Math.min(rateMean - 3, 6 - rateMean, 0.7 + random() * 0.8);
+ const rateMod = 0.21 + random() * 0.25;
+ const ampMod = 0.4 + random() * 0.3;
+
+ let startAt = 0;
+ let stopAt = Infinity;
+ let lastAt = 0;
+
+ /** The envelope at `u` seconds after the start, ignoring start and stop. */
+ function raw(u) {
+ // The integral of the modulated rate, so theta stays continuous (a
+ // modulated sine would kink whenever the rate changed).
+ const theta = TAU * (rateMean * u
+ + (rateSwing / (TAU * rateMod)) * (Math.cos(phase0) - Math.cos(TAU * rateMod * u + phase0)));
+ const pulse = 0.5 - 0.5 * Math.cos(theta);
+ const amp = 0.55 + 0.2 * Math.sin(TAU * ampMod * u + phase1);
+ return clamp01(amp * pulse);
+ }
+
+ return {
+ /**
+ * The mouth opening at `t`, in the caller's own time base. Before `start`
+ * it is 0, and after `stop()` it fades to 0 within `CLOSE_SECONDS`.
+ *
+ * @param {number} t
+ * @returns {number} 0..1
+ */
+ value(t) {
+ const time = Number.isFinite(t) ? t : 0;
+ if (time > lastAt) lastAt = time;
+ if (time < startAt) return 0;
+ const fading = time - stopAt;
+ if (fading > 0) {
+ const fade = 1 - fading / CLOSE_SECONDS;
+ if (fade <= 0) return 0;
+ return raw(time - startAt) * fade;
+ }
+ return raw(time - startAt);
+ },
+
+ /** Begin (or restart) the envelope at `at`, opening from closed. */
+ start(at = 0) {
+ startAt = Number.isFinite(at) ? at : 0;
+ stopAt = Infinity;
+ lastAt = startAt;
+ },
+
+ /** Stop: the envelope decays to 0 from wherever it is. */
+ stop() {
+ stopAt = lastAt;
+ },
+ };
+}
+
+/**
+ * Map a 0..1 level to the app's mouth-opening range.
+ *
+ * WHY not `level` straight through: a half-open mouth is the readable one, and
+ * a full gape looks like a shout. 0 stays 0 (a closed mouth must stay closed)
+ * and 1 lands at 0.55. `gain` lets a caller push the mouth wider for a loud
+ * passage, but the result is clamped to 0..0.9 so it is never a full gape.
+ *
+ * @param {number} level
+ * @param {number} [gain=1]
+ * @returns {number} 0..0.9
+ */
+export function levelToMouth(level, gain = 1) {
+ if (!Number.isFinite(level)) return 0;
+ const scale = Number.isFinite(gain) ? gain : 1;
+ return clamp(clamp01(level) * 0.55 * scale, 0, 0.9);
+}
+
+/**
+ * Roughly how long a line takes to say, in seconds **at rate 1**.
+ *
+ * A Japanese syllable is about 0.155 s, so the count of characters is a good
+ * enough clock - and it is the only clock available once there is no voice to
+ * listen to. An empty line still gets a few seconds, so the button never looks
+ * like it did nothing.
+ *
+ * @param {string} text
+ * @returns {number} seconds, 1.1..40 (or 3 for an empty line)
+ */
+export function speakingSeconds(text) {
+ const chars = [...String(text ?? '').trim()].length;
+ if (chars === 0) return 3;
+ return clamp(0.55 + chars * 0.155, 1.1, 40);
+}
+
+/**
+ * The mouth-flap animation.
+ *
+ * `start(text, { rate })` opens the mouth in the seeded syllable rhythm and
+ * stops itself once the line would have been said, then reports `onEnd`. A
+ * second `start` restarts it, and `stop()` fades the mouth shut early - which is
+ * what the panel's button does while it is running.
+ *
+ * @param {{
+ * onLevel?: (level: number) => void,
+ * onEnd?: () => void,
+ * }} [options]
+ */
+export function createMouthFlap({ onLevel, onEnd } = {}) {
+ const envelope = createMouthEnvelope();
+ let handlers = { onLevel, onEnd };
+ let timer = null;
+ let closing = false;
+ let running = false;
+ let startedAt = 0;
+ let lastEmit = 0;
+ let currentRate = 1;
+ /** The envelope's own time at which the line is over. */
+ let limitAt = Infinity;
+
+ const nowSeconds = () => (globalThis.performance?.now?.() ?? Date.now()) / 1000;
+ const rafSupported = typeof globalThis.requestAnimationFrame === 'function';
+ const schedule = (fn) => (rafSupported
+ ? globalThis.requestAnimationFrame(fn)
+ : globalThis.setTimeout(fn, 1000 / LEVEL_HZ));
+ const unschedule = (id) => {
+ if (id == null) return;
+ if (rafSupported) globalThis.cancelAnimationFrame(id);
+ else globalThis.clearTimeout(id);
+ };
+
+ /**
+ * One level sample, about 30 times a second. Once the line has run its
+ * length the loop stops itself, and while the moth is closing it keeps going
+ * until the mouth is shut - so no timer is ever left behind.
+ */
+ function tick() {
+ timer = null;
+ const stamp = nowSeconds();
+ const elapsed = (stamp - startedAt) * currentRate;
+ if (!closing && elapsed >= limitAt) {
+ closing = true;
+ envelope.stop();
+ }
+ const level = envelope.value(elapsed);
+ if (closing || stamp - lastEmit >= 1 / LEVEL_HZ - 0.001) {
+ lastEmit = stamp;
+ handlers.onLevel?.(level);
+ }
+ if (closing && level <= 0) {
+ running = false;
+ handlers.onEnd?.();
+ return;
+ }
+ timer = schedule(tick);
+ }
+
+ /**
+ * Start flapping. `seconds` overrides the length guessed from the text.
+ *
+ * @param {string} text
+ * @param {{ rate?: number, seconds?: number }} [options]
+ * @returns {number} how long the flap will run, in seconds
+ */
+ function start(text, { rate = 1, seconds = null } = {}) {
+ stopNow();
+ currentRate = clamp(Number.isFinite(rate) ? rate : 1, 0.2, 4);
+ limitAt = Number.isFinite(seconds) && seconds > 0 ? seconds : speakingSeconds(text);
+ startedAt = nowSeconds();
+ lastEmit = 0;
+ closing = false;
+ running = true;
+ envelope.start(0);
+ timer = schedule(tick);
+ return limitAt / currentRate;
+ }
+
+ /** Clear the timer immediately (no fade). */
+ function stopNow() {
+ unschedule(timer);
+ timer = null;
+ closing = false;
+ running = false;
+ envelope.stop();
+ }
+
+ /** Fade the mouth shut and let the loop clean itself up. */
+ function stop() {
+ if (timer == null) {
+ running = false;
+ envelope.stop();
+ return;
+ }
+ closing = true;
+ envelope.stop();
+ }
+
+ function dispose() {
+ stopNow();
+ handlers = { onLevel: null, onEnd: null };
+ }
+
+ return {
+ get running() {
+ return running;
+ },
+ start,
+ stop,
+ dispose,
+ };
+}
diff --git a/bluebey-studio/src/outline.js b/bluebey-studio/src/outline.js
new file mode 100644
index 0000000..127d576
--- /dev/null
+++ b/bluebey-studio/src/outline.js
@@ -0,0 +1,368 @@
+import * as THREE from 'three';
+
+/**
+ * A screen-space outline: the label version of "line art".
+ *
+ * The studio's other outline is an *inverted hull* - a copy of a mesh, expanded
+ * along its normals, drawn back-faces-only. That is cheap and its line is
+ * computed from the geometry, so it comes out smooth, but it cannot outline a
+ * thin closed solid: a leaf blade is 0.026 units thick and the hull expands by
+ * 0.022 in every direction, so the expanded front and back cross inside the leaf
+ * and the line breaks up. No amount of mesh fixing removes that; it is the
+ * technique.
+ *
+ * So the thin overlapping parts - the waist leaves, and the nose in the
+ * line-art styles - are handed to this pass instead, and everything else keeps
+ * its hull (see MODEL-GUIDE.md §5). This module renders the scene once into a
+ * buffer holding a *label* per pixel, then draws a full-screen pass that inks
+ * pixels where the labels disagree.
+ *
+ * ## Why labels, not coverage
+ *
+ * The parts want different lines:
+ *
+ * - a LEAF that runs into the body must NOT get a line along the intersection.
+ * Such a line reads as the leaf sinking into the body, and the original
+ * artwork does not draw one either - the leaf simply passes behind the body.
+ * - the NOSE is the opposite. It is a bump sitting on the body, so the ring
+ * where it meets the body *is* its outline. In a line drawing there is no
+ * colour to read the nose by, so that ring is the only thing that shows it.
+ *
+ * A single "coverage" cannot express that difference, so each part writes a
+ * label:
+ *
+ * 0 (paper) nothing is there
+ * BEHIND something that is merely behind: it still writes depth, so it
+ * hides what is behind *it*, but a leaf in front of it is still
+ * outlined - a foot behind the skirt does not swallow the leaf's
+ * edge. This is the default for meshes nobody claimed.
+ * SOLID the body: the leaves *emerge* from it, so a leaf must not be
+ * outlined where it meets it (that reads as the leaf sinking into
+ * the body, and the original artwork draws no line there either).
+ * LEAF a leaf
+ * NOSE the nose
+ *
+ * The label is read with `NearestFilter`, because it is an identity, not a
+ * colour: filtering it would blend two labels into a third value that means
+ * neither.
+ *
+ * ## What this pass deliberately does not do
+ *
+ * It does not look for folds, and so it needs no normals at all - only the
+ * labels. Surface shape was tried (a second buffer of filtered normals, with the
+ * fold test gated to the leaves) and taken out again: it did bring back the
+ * lines where one leaf lies over the next, but it cost a whole extra scene pass
+ * and it read as a grainy speckle across the skirt, because a leaf is thin
+ * enough (0.026) that its own rim is a 145-degree crease and the mesh arrives
+ * with those edges split into separate vertices. The recipe is written up in
+ * MODEL-GUIDE.md §5-2c if it is ever wanted back - for instance to make raised
+ * leaf veins show.
+ *
+ * Callers must keep the outline hulls out of this pass: `exclude()` takes them
+ * (see `outlineExclusion` in main.js). A hull is an expanded copy of a mesh, and
+ * this pass swaps a front-side material onto everything it sees, so a hull would
+ * paint an enlarged copy of the character's own label over all of it.
+ *
+ * A mesh that should *hide* parts of the character without being outlined itself
+ * (the invisible wall) is handed to `occlude()` instead: it is drawn for its
+ * depth alone, with a paper label, so the leaves and the nose buried behind it
+ * get no label - and therefore no line.
+ */
+
+// Taps around the pixel, averaged into a coverage. 16 gives a smooth ramp.
+const TAPS = 16;
+
+/** Written into the alpha channel; see the note above. */
+export const BEHIND_LABEL = 0.1;
+export const SOLID_LABEL = 0.4;
+export const LEAF_LABEL = 0.7;
+export const NOSE_LABEL = 1;
+
+const VERTEX = /* glsl */`
+ varying vec2 vUv;
+ void main() {
+ vUv = uv;
+ // The quad is already in clip space, so no camera maths is involved.
+ gl_Position = vec4(position.xy, 0.0, 1.0);
+ }
+`;
+
+const FRAGMENT = /* glsl */`
+ uniform sampler2D uLabels;
+ uniform vec2 uTexel;
+ uniform vec3 uColor;
+ uniform float uRadius;
+ varying vec2 vUv;
+
+ const float TAU = 6.28318530718;
+
+ // The labels, read back out of the alpha channel (see the top of this file).
+ // They are exact values, not a gradient, so the tests are simple comparisons.
+ //
+ // "paper" means "does not block a line": the untouched background (0) and the
+ // parts that are merely behind (0.1). Only the body (0.4) blocks one.
+ float paper(float a) { return 1.0 - step(0.2, a); }
+ float drawn(float a) { return step(0.6, a); }
+ float nose(float a) { return step(0.85, a); }
+
+ void main() {
+ vec4 here = texture2D(uLabels, vUv);
+ float ink = 0.0;
+
+ for (int i = 0; i < ${TAPS}; i++) {
+ float angle = (float(i) / float(${TAPS})) * TAU;
+ vec2 offset = vec2(cos(angle), sin(angle)) * uTexel * uRadius;
+ vec4 there = texture2D(uLabels, vUv + offset);
+
+ float edge = 0.0;
+ // 1. a drawn part against the paper. A leaf against the *body* fires
+ // nothing here, which is the point: the leaf simply passes behind it.
+ edge = max(edge, max(drawn(here.a) * paper(there.a),
+ drawn(there.a) * paper(here.a)));
+ // 2. the nose against anything that is not the nose, so its whole ring -
+ // including the part against the body - is inked.
+ edge = max(edge, abs(nose(here.a) - nose(there.a)));
+
+ ink += edge;
+ }
+
+ // Averaging the taps turns the flag into a coverage, and that is what takes
+ // the steps out of the line: a pixel half over an edge gets half the ink, so
+ // the line gets soft edges instead of landing on the pixel grid. It also
+ // reads lighter than a hard band of the same width, which is what makes it
+ // sit next to the hull's line without looking heavier.
+ ink /= float(${TAPS});
+ // The ramp starts at about three taps out of sixteen rather than at one, so an
+ // isolated tap is not enough to ink a pixel; a real edge has half the taps
+ // crossing it, so asking for a few costs nothing there.
+ float line = smoothstep(0.19, 0.45, ink);
+ if (line < 0.02) discard;
+ gl_FragColor = vec4(uColor, line);
+ }
+`;
+
+export function createScreenOutline({ renderer, scene, camera, width = 1280, height = 800 }) {
+ const target = new THREE.WebGLRenderTarget(width, height, {
+ // The label is an identity, not a shade: filtering would blend two labels into
+ // a third value that means neither of them. The softness of the line comes
+ // from spreading the taps in the shader, not from blurring this buffer.
+ minFilter: THREE.NearestFilter,
+ magFilter: THREE.NearestFilter,
+ depthBuffer: true,
+ // The alpha channel carries the label, so it must not be filled in.
+ format: THREE.RGBAFormat,
+ });
+ target.texture.generateMipmaps = false;
+
+ /**
+ * The label each part writes into the alpha channel.
+ *
+ * `transparent: true` keeps three.js from defining `OPAQUE`, which would force
+ * the alpha to 1, and `blending: NoBlending` makes the fragment *replace* the
+ * pixel instead of blending into it, so the labels stay exact. `depthWrite`
+ * stays on, which is what hides the parts that are behind something else.
+ */
+ const labelMaterials = new Map();
+ function labelMaterialFor(label) {
+ let material = labelMaterials.get(label);
+ if (material) return material;
+ material = new THREE.MeshBasicMaterial({
+ color: 0xffffff,
+ transparent: true,
+ opacity: label,
+ blending: THREE.NoBlending,
+ depthWrite: true,
+ });
+ material.customProgramCacheKey = () => `bluebey-outline-label-${label}`;
+ labelMaterials.set(label, material);
+ return material;
+ }
+
+ const uniforms = {
+ uLabels: { value: target.texture },
+ uTexel: { value: new THREE.Vector2(1 / width, 1 / height) },
+ uColor: { value: new THREE.Color('#2a1e33') },
+ uRadius: { value: 1.4 },
+ };
+ const material = new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: VERTEX,
+ fragmentShader: FRAGMENT,
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ toneMapped: false,
+ });
+
+ // A single quad in clip space, with the camera taken out of the equation.
+ const quadScene = new THREE.Scene();
+ const quadCamera = new THREE.Camera();
+ quadScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
+
+ /** Objects that must not appear in the outline (ground, shadows, gizmos). */
+ const hidden = [];
+ /** Meshes whose material (and render order) is borrowed for the label pass. */
+ const swapped = [];
+ const exclusion = new Set();
+
+ /**
+ * Meshes that only *occlude* in the label pass (the 見えない壁).
+ *
+ * They are drawn with a paper label and both sides, so their depth hides
+ * whatever is behind them: a leaf or the nose buried in the wall then gets no
+ * label at all, and so no line. Their own silhouette inks nothing either,
+ * because paper against paper is not an edge - which is what keeps the wall
+ * itself invisible instead of drawing a rectangle.
+ */
+ const occluders = new Set();
+ let occluderMaterial = null;
+
+ function occluderMaterialFor() {
+ if (!occluderMaterial) {
+ occluderMaterial = new THREE.MeshBasicMaterial({
+ color: 0xffffff,
+ transparent: true,
+ opacity: 0, // paper: it never reads as a drawn part
+ blending: THREE.NoBlending,
+ depthWrite: true,
+ side: THREE.DoubleSide, // the wall can be seen from either side
+ });
+ }
+ return occluderMaterial;
+ }
+
+ /** Objects to draw in the label pass for their depth alone (see `occluders`). */
+ function occlude(objects) {
+ occluders.clear();
+ for (const object of objects) if (object) occluders.add(object);
+ }
+
+ /**
+ * When set, these meshes write the given label and everything else writes
+ * BEHIND. Without it the whole scene shares one label.
+ *
+ * @type {Map|null}
+ */
+ let focus = null;
+
+ function setSize(nextWidth, nextHeight) {
+ const w = Math.max(1, Math.round(nextWidth));
+ const h = Math.max(1, Math.round(nextHeight));
+ if (target.width === w && target.height === h) return;
+ target.setSize(w, h);
+ uniforms.uTexel.value.set(1 / w, 1 / h);
+ }
+
+ /** Layers/objects to leave out of the label pass (ground, shadow, gizmo, hulls). */
+ function exclude(objects) {
+ exclusion.clear();
+ for (const object of objects) if (object) exclusion.add(object);
+ }
+
+ /**
+ * Hand the pass a split: these meshes get this label, and everything else is
+ * drawn as BEHIND - present, so it still hides what is behind it, but not
+ * blocking a leaf's outline. Callers should therefore name every part that a
+ * leaf must not be outlined against (the body) as well as the outlined ones
+ * (the leaves, the nose).
+ *
+ * `null` puts the whole scene back on one label.
+ *
+ * @param {Array<[THREE.Object3D, number]>|null} parts
+ */
+ function only(parts) {
+ focus = parts ? new Map(parts) : null;
+ }
+
+ /**
+ * Draw one frame. `baseRender` renders the scene the normal way; it is called
+ * between the label pass and the ink so the ink lands on top of it.
+ */
+ function render(baseRender, options = {}) {
+ const enabled = options.enabled !== false;
+ if (!enabled) {
+ baseRender();
+ return;
+ }
+
+ uniforms.uColor.value.set(options.color ?? '#2a1e33');
+ uniforms.uRadius.value = Math.max(0.6, options.radius ?? 1.4);
+
+ // --- 1. the labels pass --------------------------------------------
+ const previousOverride = scene.overrideMaterial;
+ const previousClear = renderer.getClearColor(new THREE.Color());
+ const previousAlpha = renderer.getClearAlpha();
+ hidden.length = 0;
+ swapped.length = 0;
+ const hide = (object) => {
+ if (object.visible) {
+ hidden.push(object);
+ object.visible = false;
+ }
+ };
+ for (const object of exclusion) hide(object);
+
+ if (focus) {
+ // A per-mesh material, so the meshes outside `focus` can occlude without
+ // contributing a line. (`overrideMaterial` would put one label on all of
+ // them, and a leaf ending on a foot would then be read the same way as a
+ // leaf ending on the body.)
+ //
+ // The occluders are also pushed to the front of the draw order. Every body
+ // mesh sits at the same origin, so three.js would otherwise fall back to
+ // insertion order and could draw a far-side leaf *before* the body that
+ // hides it - and writing a label cannot erase what is already in the
+ // buffer, only stop it being drawn. Drawn first, the depth test does it.
+ scene.traverse((object) => {
+ if (!object.isMesh || !object.visible) return;
+ swapped.push([object, object.material, object.renderOrder]);
+ if (occluders.has(object)) {
+ // Depth only, drawn first: the wall hides the labels behind it.
+ object.material = occluderMaterialFor();
+ object.renderOrder = -1;
+ return;
+ }
+ const label = focus.get(object) ?? BEHIND_LABEL;
+ object.material = labelMaterialFor(label);
+ if (label < 0.6) object.renderOrder = -1;
+ });
+ scene.overrideMaterial = null;
+ } else {
+ // No split given: nothing is outlined, so no label may read as "drawn".
+ scene.overrideMaterial = labelMaterialFor(SOLID_LABEL);
+ }
+
+ renderer.setRenderTarget(target);
+ renderer.setClearColor(0x000000, 0);
+ renderer.clear(true, true, false);
+ renderer.render(scene, options.camera ?? camera);
+
+ renderer.setRenderTarget(null);
+ scene.overrideMaterial = previousOverride;
+ for (const object of hidden) object.visible = true;
+ for (const [object, swappedMaterial, renderOrder] of swapped) {
+ object.material = swappedMaterial;
+ object.renderOrder = renderOrder;
+ }
+ renderer.setClearColor(previousClear, previousAlpha);
+
+ // --- 2. the scene itself -------------------------------------------
+ baseRender();
+
+ // --- 3. the ink, straight over the top -----------------------------
+ renderer.autoClear = false;
+ renderer.render(quadScene, quadCamera);
+ renderer.autoClear = true;
+ }
+
+ function dispose() {
+ target.dispose();
+ for (const material of labelMaterials.values()) material.dispose();
+ labelMaterials.clear();
+ material.dispose();
+ occluderMaterial?.dispose();
+ for (const child of quadScene.children) child.geometry.dispose();
+ }
+
+ return { render, setSize, exclude, occlude, only, uniforms, target, material, dispose };
+}
diff --git a/bluebey-studio/src/panel.js b/bluebey-studio/src/panel.js
new file mode 100644
index 0000000..0ee94ac
--- /dev/null
+++ b/bluebey-studio/src/panel.js
@@ -0,0 +1,3144 @@
+import {
+ h, section as uiSection, subhead, hint, slider, check, segmented, buttons,
+ colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon, svgIcon,
+} from './ui.js';
+import {
+ FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS,
+ defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS,
+} from './presets.js';
+import { STYLE_DEFS } from './styles.js';
+import { ENVIRONMENTS } from './look.js';
+import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js';
+import { PROP_LIBRARY, applyPropFaceScale, applyPropText } from './props.js';
+import { HAT_LIBRARY } from './hats.js';
+import {
+ GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH,
+} from './gion.js';
+
+/**
+ * A small icon for each section heading, so the list a tab shows can be scanned
+ * at a glance. Keyed by the section title; a title with no icon just shows text.
+ */
+const SECTION_ICONS = {
+ 'ポーズ例': 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2',
+ '全身とボーンのスライダー': 'M4 7h16M4 12h16M4 17h16M8 7h.01M15 12h.01M11 17h.01',
+ '操作': 'M7 4l10 6-4.3 1.3L10.8 16 7 4Z',
+ 'うごき': 'M8 5l10 7-10 7V5Z',
+ 'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
+ '目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z',
+ '眉': 'M4 11q3.2-3.2 7-2M13 9q3.8-1.2 7 2',
+ '眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3',
+ '髪': 'M4 14c0-4.8 3.6-7.6 8-7.6s8 2.8 8 7.6M4 14c2.6-1.4 5.3-1.9 8-1.8M20 14c-2.6-1.4-5.3-1.9-8-1.8',
+ 'ほっぺ': 'M6.4 14.6a2 1.4 0 1 0 0 .01ZM17.6 14.6a2 1.4 0 1 0 0 .01ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z',
+ '鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4',
+ 'ひげ': 'M4.4 11.2c2.6-1.2 5.2-.6 7.6 2 2.4-2.6 5-3.2 7.6-2',
+ '口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0',
+ '口パク(声は出ません)': 'M4 9v6h3l4 3V6L7 9H4ZM15.5 9.5l4 5M19.5 9.5l-4 5',
+ '見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z',
+ 'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z',
+ '体の色': 'M12 3.4s6 6.2 6 10.1a6 6 0 0 1-12 0C6 9.6 12 3.4 12 3.4Z',
+ '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01',
+ '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
+ '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5',
+ 'セリフ(フキダシ)': 'M4 5h16v10H9l-4 4v-4H4V5Z',
+ '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8',
+ '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01',
+ '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01',
+ 'まんが': 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
+ '共有': 'M8.2 10.6a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 6.4a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 20.8a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM10 10.4l5-2.6M10 13.6l5 2.6',
+ 'テクスチャの下地を書き出す': 'M4 4h16v16H4zM4 9.3h16M4 14.7h16M9.3 4v16M14.7 4v16',
+ '設定': 'M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8ZM12 2.6v2.4M12 19v2.4M2.6 12H5M19 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7',
+};
+
+/** `section`, with the heading icon looked up from its title. */
+const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options });
+
+/** Fallbacks for the fields an older settings file may predate. */
+const BODY_COLOR_FALLBACKS = {
+ body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0',
+ leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0',
+};
+const CAPTION_SLIDER_FALLBACKS = {
+ fontSize: 34, lineHeight: 1.42, padding: 18, radius: 24, borderWidth: 4,
+};
+const CAPTION_COLOR_FALLBACKS = { textColor: '#3f2b52', bubbleColor: '#ffffff', borderColor: '#55386e' };
+const MOUTH_FLAP_FALLBACKS = { rate: 1, mouthGain: 1 };
+const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 };
+
+/**
+ * The licence reminder the three image-import buttons show before they open the
+ * file picker. Kept in one place so the wording can change without hunting for
+ * each button.
+ */
+const IMAGE_LICENSE_NOTICE = '読み込む画像は、ご自身で権利をお持ちか、利用許諾のあるものに限ります。ライセンスをご確認ください。';
+
+/** True when the user confirmed they have the right to use the image. */
+function confirmImageLicense() {
+ return window.confirm(IMAGE_LICENSE_NOTICE);
+}
+
+/**
+ * The panel had no multi-line field, and the two features that take free prose
+ * (the caption and the spoken text) want the same shape, so it is built once
+ * here rather than growing the widget kit for a single caller.
+ */
+function textArea({ label, value = '', rows = 3, onChange }) {
+ const input = h('textarea', {
+ rows,
+ style: {
+ width: '100%',
+ boxSizing: 'border-box',
+ resize: 'vertical',
+ font: 'inherit',
+ fontFamily: 'ui-monospace, "Hiragino Kaku Gothic ProN", "Noto Sans JP", monospace',
+ fontSize: '11.5px',
+ lineHeight: '1.5',
+ padding: '6px 8px',
+ border: '1px solid #e4dff0',
+ borderRadius: '8px',
+ background: '#fff',
+ color: '#2b2433',
+ },
+ });
+ input.value = value ?? '';
+ input.addEventListener('input', () => onChange?.(input.value));
+ return {
+ el: controlRow(label, input, { wide: true }),
+ // Assigning the same text again would jump the caret to the end, so only
+ // write when the value actually differs.
+ set(v) { const next = v ?? ''; if (input.value !== next) input.value = next; },
+ get: () => input.value,
+ };
+}
+
+/**
+ * The placed prop groups, in state order.
+ *
+ * WHY this climbs the scene: the app owns the prop rebuild (see `applyProps` in
+ * src/main.js) and hands `props.js` only the whole-prop scale, so a sign's
+ * per-item `faceScale` has to be re-applied to the freshly built mesh by the one
+ * place that knows that value - this panel. There is no direct scene reference
+ * here, so this walks up from the model's container and collects the props: they
+ * are the only objects tagged `userData.propId`, and the app adds them in order.
+ */
+function placedPropGroups(app) {
+ const scene = app.container?.parent;
+ if (!scene) return [];
+ const groups = [];
+ scene.traverse((object) => {
+ if (object.userData?.propId) groups.push(object);
+ });
+ return groups;
+}
+
+/**
+ * Builds the whole control panel and wires it to the app state.
+ *
+ * Widgets never call the heavy "apply everything" path while they are being
+ * dragged; they patch one scope and ask the app to refresh just that part.
+ */
+export function buildPanel(app, root) {
+ const state = app.state;
+ const model = app.model;
+ const rig = app.rig;
+
+ const syncers = [];
+ const boneSyncers = [];
+ const sync = () => { for (const fn of syncers) fn(); };
+ const syncBones = () => { for (const fn of boneSyncers) fn(); };
+
+ /**
+ * Grey out (and disable) a control whose setting has no effect in the current
+ * mode - e.g. the brow's angle while the brow is a loaded picture.
+ */
+ const setRowEnabled = (widget, on) => {
+ const row = widget.el.closest?.('.row') ?? widget.el;
+ const input = row.querySelector?.('input, select, button');
+ if (input) input.disabled = !on;
+ row.classList?.toggle('is-off', !on);
+ };
+
+ /** Patch one scope of the state without re-reading the whole UI. */
+ /** Replace the state and re-read every widget (used by presets and loads). */
+ const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true });
+
+ root.replaceChildren();
+
+ // --- randomising ----------------------------------------------------------
+ // Shared by 「顔をランダムに」 (face tab) and 「すべてをランダムに」 (the top bar).
+ const rnd = (min, max) => min + (max - min) * Math.random();
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
+ const randomEye = () => ({
+ open: +rnd(0.4, 1).toFixed(2),
+ lookX: +rnd(-0.35, 0.35).toFixed(2),
+ lookY: +rnd(-0.25, 0.25).toFixed(2),
+ eyeX: Math.round(rnd(-16, 16)),
+ });
+ const randomGlasses = () => {
+ const kind = pick(['glasses', 'sunglasses']);
+ const dark = kind === 'sunglasses';
+ return {
+ enabled: true,
+ kind,
+ frameColor: pick(['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6']),
+ lensColor: pick(['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e']),
+ lensOpacity: dark ? +rnd(0.85, 1).toFixed(2) : +rnd(0, 0.35).toFixed(2),
+ lensGap: Math.round(rnd(-24, 56)),
+ frameWidth: +rnd(0.6, 1.8).toFixed(2),
+ scale: +rnd(0.9, 1.15).toFixed(2),
+ offsetY: Math.round(rnd(-18, 18)),
+ tilt: Math.round(rnd(-12, 12)),
+ };
+ };
+ const randomBeard = (shape) => {
+ const base = BEARD_KIND_DEFAULTS[shape] ?? { size: 1, offsetY: 0, spacing: 0 };
+ if (shape === 'off') return { shape };
+ return {
+ shape,
+ ...base,
+ size: +((base.size ?? 1) * rnd(0.8, 1.25)).toFixed(2),
+ offsetY: Math.round((base.offsetY ?? 0) + rnd(-16, 16)),
+ };
+ };
+ const randomHeadMark = () => {
+ const shape = pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']);
+ return { shape, ...(HEAD_MARK_KIND_DEFAULTS[shape] ?? {}) };
+ };
+ const randomFacePatch = () => {
+ const beardShape = pick(['off', 'scotch', 'kaiser', 'cat']);
+ return {
+ eyes: {
+ left: randomEye(),
+ right: randomEye(),
+ cheeks: { enabled: Math.random() < 0.5 },
+ // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。
+ glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(),
+ beard: randomBeard(beardShape),
+ headMark: randomHeadMark(),
+ brow: {
+ enabled: true,
+ // Random brows are line drawings, never ゴル風's loaded picture.
+ image: false,
+ angle: Math.round(rnd(-30, 40)),
+ height: Math.round(rnd(-10, 50)),
+ length: +rnd(0.6, 2.6).toFixed(2),
+ thickness: +rnd(1.2, 3.4).toFixed(2),
+ curve: +rnd(0, 0.3).toFixed(2),
+ spacing: Math.round(rnd(-120, 30)),
+ taper: +rnd(0, 1).toFixed(2),
+ },
+ },
+ // The mouth takes the height and the tilt too, so the face really changes.
+ mouth: {
+ smile: +rnd(-1, 1.2).toFixed(2),
+ // ときどき丸く開く口(O)にする。
+ round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0,
+ open: +rnd(0, 0.5).toFixed(2),
+ width: +rnd(0.7, 1.3).toFixed(2),
+ thickness: +rnd(0.7, 1.6).toFixed(2),
+ tilt: Math.round(rnd(-12, 12)),
+ offsetY: Math.round(rnd(-16, 16)),
+ },
+ };
+ };
+ const randomFace = () => replace({ face: randomFacePatch() }, 'face');
+ const randomAll = () => {
+ // 後ろを向く(180°)や大きく傾くポーズは避ける。見える顔が変わる範囲だけ。
+ const poses = POSE_PRESETS.filter((preset) => {
+ const m = preset.pose?.bones?.master ?? [0, 0, 0];
+ return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40;
+ });
+ const pose = pick(poses.length ? poses : POSE_PRESETS);
+ const theme = pick(THEMES);
+ app.actions.applyPosePreset?.(pose.id);
+ app.actions.applyTheme?.(theme.id);
+ // The viewer style (実写/フラット/線画) and 左右反転 are deliberately left alone.
+ replace({ face: randomFacePatch() }, 'face');
+ sync();
+ };
+
+ // A small action bar above the tabs: undo/redo apply to every tab, so they must
+ // not be buried inside one of them. 「すべてをランダムに」 sits here too.
+ const topBar = h('div', { class: 'panel-topbar' });
+ const iconButton = (label, d, onClick) => {
+ const button = h('button', { type: 'button', class: 'icon-btn', title: label, 'aria-label': label });
+ button.append(icon(d));
+ button.addEventListener('click', onClick);
+ return button;
+ };
+ topBar.append(
+ iconButton('元に戻す', 'M4.8 11.5h9.7a5.2 5.2 0 0 1 0 10.4h-3.6M4.8 11.5 9.6 6.7M4.8 11.5 9.6 16.3', () => app.actions.undo()),
+ iconButton('やり直す', 'M19.2 11.5h-9.7a5.2 5.2 0 0 0 0 10.4h3.6M19.2 11.5 14.4 6.7M19.2 11.5 14.4 16.3', () => app.actions.redo()),
+ iconButton('すべてをランダムに', 'M7.2 9.4a2.6 2.6 0 1 1 3.9 2.3c-.9.5-1.3 1.2-1.3 2.1v.4M9.2 17.4h.01M16.8 6.8v6.1M16.8 15.6h.01', () => randomAll()),
+ );
+ root.append(topBar);
+
+ // Tabs instead of one long scroll: the panel used to be a wall of controls,
+ // and everything is easier to find this way. Each tab is a small stroke icon
+ // (its name is the tooltip and the accessible label) sitting on the top edge
+ // of the content, so the row reads as tabs rather than buttons.
+ const tabBar = tabs(root, [
+ {
+ id: 'pose',
+ label: 'ポーズ',
+ // A standing figure: head, body, arms and legs.
+ icon: 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2',
+ },
+ {
+ id: 'face',
+ label: '顔',
+ // A smiley face: an outline, two eyes and a smile.
+ icon: 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
+ },
+ {
+ id: 'view',
+ label: '見た目',
+ // A painter's palette: the outline, the thumb hole and four colour dots.
+ icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01',
+ },
+ {
+ id: 'scene',
+ label: 'シーン',
+ // A framed picture: background, stage and light all sit inside the frame.
+ icon: 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
+ },
+ {
+ id: 'export',
+ label: '書き出し',
+ // A download arrow dropping into a tray.
+ icon: 'M12 3.8v9.6M8.2 9.8 12 13.6l3.8-3.8M4.5 16.5v1.6a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8v-1.6',
+ },
+ ]);
+ const tabPose = tabBar.panels.pose;
+ const tabFace = tabBar.panels.face;
+ const tabView = tabBar.panels.view;
+ const tabScene = tabBar.panels.scene;
+ const tabExport = tabBar.panels.export;
+
+ /* ------------------------------------------------------------------ pose */
+
+ // ポーズ例: ready-made poses, because most people start by picking one.
+ const posePresetSection = section(tabPose, 'ポーズ例');
+ posePresetSection.add(buttons({
+ items: POSE_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => applyPosePreset(preset),
+ })),
+ }));
+
+ // 全身とボーンのスライダー: pick a bone, then turn it on the three axes.
+ const poseSection = section(tabPose, '全身とボーンのスライダー');
+
+ const boneList = h('div', { class: 'bone-list' });
+ const boneButtons = new Map();
+ for (const bone of model.bones) {
+ const button = h('button', { type: 'button', class: 'bone-item' },
+ h('span', { text: bone.label }),
+ h('small', { text: bone.name }));
+ button.addEventListener('click', () => rig.select(bone.name));
+ boneButtons.set(bone.name, button);
+ boneList.append(button);
+ }
+ poseSection.add(controlRow(null, boneList, { wide: true }));
+
+ const axisSliders = {
+ x: slider({ label: 'よこ(X)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('x', v) }),
+ y: slider({ label: 'たて(Y)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('y', v) }),
+ z: slider({ label: 'ねじり(Z)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('z', v) }),
+ };
+ for (const widget of Object.values(axisSliders)) poseSection.add(widget.el);
+
+ function setAxis(axis, value) {
+ const name = rig.selected;
+ if (!name) return;
+ const delta = rig.getDelta(name);
+ delta[axis] = value;
+ rig.setDelta(name, delta);
+ app.actions.capturePose();
+ app.needsRender = true;
+ }
+
+ poseSection.add(hint('ボーンは上の一覧から選びます。スライダーは「休めの姿勢からの差」です。'
+ + 'ビューポートのぶるべーは、ドラッグで移動、Shift+ドラッグで向きを変えられます。'));
+
+ // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the
+ // main way in, so they stay folded away inside the bone section.
+ const rootSliders = [
+ { label: 'よこ移動', min: -20, max: 20 },
+ { label: 'たて移動', min: -10, max: 20 },
+ { label: 'おくゆき', min: -20, max: 20 },
+ ].map(({ label, min, max }, index) => slider({
+ label, min, max, step: 0.01, value: 0,
+ format: (v) => v.toFixed(2),
+ onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; },
+ }));
+ const poseBox = details(poseSection.body, 'こまかい設定(ぜんたいをずらす・回転ギズモ)');
+ for (const widget of rootSliders) poseBox.add(widget.el);
+
+ const gizmoToggle = check({
+ label: '回転ギズモを表示',
+ // Off by default: the rings belong to the model's surface, so having them
+ // drawn on top of the character is not what you want while composing a
+ // picture. The 'g' key toggles it too.
+ value: false,
+ onChange: (value) => { rig.setGizmoVisible(value); app.needsRender = true; },
+ });
+ poseBox.add(controlRow(null, gizmoToggle.el, { wide: true }));
+
+ // --- 操作 -----------------------------------------------------------------
+ // Deliberately NOT wrapped in a collapsible section: 「ポーズを全部戻す」 and the
+ // undo/redo pair are used often enough that they should be on screen without
+ // opening anything first.
+ tabPose.append(buttons({
+ items: [
+ { id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } },
+ { id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() },
+ { id: 'random', label: '少しランダムに', onClick: () => randomPose() },
+ ],
+ }).el);
+ tabPose.append(hint('「元に戻す」「やり直す」は、パネル上部のアイコンにあります(どのタブでも使えます)。'));
+
+ /** Accepts a preset object (from the buttons) or a preset id (from tools). */
+ function applyPosePreset(presetOrId) {
+ const preset = typeof presetOrId === 'string'
+ ? POSE_PRESETS.find((item) => item.id === presetOrId)
+ : presetOrId;
+ if (!preset) return;
+ // Some poses already raise the arms; leaving the てをふる motion running would
+ // add to the arm the pose just set and fold the flipper over the face, so
+ // switching to any pose stops that motion.
+ if (state.anim.mode === 'wave') {
+ state.anim.mode = 'off';
+ app.animator.reset();
+ app.needsRender = true;
+ }
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ applyPatch(state.pose, preset.pose ?? {});
+ if (!state.pose.root) state.pose.root = [0, 0, 0];
+ replace({}, 'pose');
+ sync();
+ }
+
+ function randomPose() {
+ const names = model.bones.map((bone) => bone.name);
+ for (const name of names) {
+ if (name === 'master') continue;
+ const scale = name.startsWith('arm') ? 22 : 12;
+ rig.setDelta(name, {
+ x: (Math.random() - 0.5) * scale,
+ y: (Math.random() - 0.5) * scale,
+ z: (Math.random() - 0.5) * scale,
+ });
+ }
+ app.actions.capturePose();
+ syncBones();
+ app.needsRender = true;
+ }
+
+ /* ------------------------------------------------------------------ face */
+
+ let faceSection = section(tabFace, 'プリセット');
+
+ // The face tab's sections are created here in the order they should read on
+ // screen (プリセット -> 髪 -> 眉 -> 目 -> 見る先 -> ...), but filled further
+ // down where the widgets are built: the DOM order is fixed at creation.
+ const faceTabSections = {
+ hair: section(tabFace, '髪'),
+ brow: section(tabFace, '眉'),
+ eye: section(tabFace, '目'),
+ look: section(tabFace, '見る先'),
+ glasses: section(tabFace, '眼鏡・サングラス'),
+ cheeks: section(tabFace, 'ほっぺ'),
+ snot: section(tabFace, '鼻ちょうちん'),
+ beard: section(tabFace, 'ひげ'),
+ mouth: section(tabFace, '口'),
+ };
+
+ faceSection.add(buttons({
+ items: FACE_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => applyFacePreset(preset.id),
+ })),
+ }));
+
+ function applyFacePreset(id) {
+ const preset = FACE_PRESETS.find((item) => item.id === id);
+ if (!preset) return;
+ state.face = defaultState().face;
+ applyPatch(state.face, preset.face);
+ app.actions.refresh('face');
+ sync();
+ }
+
+ // --- eyes
+ faceSection = faceTabSections.eye;
+
+ /** Tear sliders, which are added to the こまかい設定 box once it exists. */
+ const tearWidgets = [];
+ // The link switch sits directly above the *left* eye's open slider, because that
+ // is the slider it drives - it was previously below both eyes, where it looked
+ // like it belonged to whatever came next.
+ const linkedToggle = check({
+ label: '左右を連動させる',
+ value: true,
+ onChange: (value) => {
+ state.face.eyes.linked = value;
+ if (value) {
+ // Catch the two eyes up with each other, so switching the link on does
+ // something visible even if they had drifted apart.
+ state.face.eyes.right.open = state.face.eyes.left.open;
+ state.face.eyes.right.closed = state.face.eyes.left.closed;
+ state.face.eyes.right.closedLines = state.face.eyes.left.closedLines;
+ state.face.eyes.right.irisShape = state.face.eyes.left.irisShape;
+ state.face.eyes.right.shape = state.face.eyes.left.shape;
+ // The lids are per eye now too, so link them along with the rest.
+ state.face.eyes.right.lowerLid = state.face.eyes.left.lowerLid;
+ state.face.eyes.right.lidShape = state.face.eyes.left.lidShape;
+ state.face.eyes.right.lidWidth = state.face.eyes.left.lidWidth;
+ state.face.eyes.right.lidTilt = state.face.eyes.left.lidTilt;
+ state.face.eyes.right.lashes = state.face.eyes.left.lashes;
+ state.face.eyes.right.lashAngle = state.face.eyes.left.lashAngle;
+ state.face.eyes.right.lashPos = state.face.eyes.left.lashPos;
+ state.face.eyes.right.white = state.face.eyes.left.white ?? null;
+ state.face.eyes.right.highlight = state.face.eyes.left.highlight ?? null;
+ state.face.eyes.right.lookX = state.face.eyes.left.lookX;
+ state.face.eyes.right.lookY = state.face.eyes.left.lookY;
+ // The tears mirror as a pair: the size and the height match, while the
+ // sideways position and the tilt flip sign.
+ state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0;
+ state.face.eyes.right.tearY = state.face.eyes.left.tearY ?? 0;
+ state.face.eyes.right.tearX = -(state.face.eyes.left.tearX ?? 0);
+ state.face.eyes.right.tearTilt = -(state.face.eyes.left.tearTilt ?? 0);
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ // The eye "shape" folds the old 閉じ方 (line/3/わらう) and the heart pupil into
+ // one menu. `open`/`closed`/`irisShape` stay the stored fields; this reads and
+ // writes them together so the menu always shows the current look.
+ const eyeShapeOf = (eye) => {
+ const shape = eye.shape;
+ const explicitShut = shape === 'three' || shape === 'arch'
+ || (typeof shape === 'string' && shape.startsWith('line'));
+ if (explicitShut) return shape;
+ if ((eye.open ?? 1) <= 0.02) {
+ if (!shape) {
+ if (eye.closed === 'three') return 'three';
+ if (eye.closed === 'arch') return 'arch';
+ return `line${Math.min(3, Math.max(1, Math.round(eye.closedLines ?? 1)))}`;
+ }
+ // An open/heart eye whose lid is all the way down is just a blink.
+ return 'line1';
+ }
+ if (shape) return shape;
+ return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
+ };
+ const applyEyeShape = (eye, value) => {
+ eye.shape = value;
+ eye.irisShape = value === 'heart' ? 'heart' : 'circle';
+ if (value === 'open' || value === 'heart') {
+ eye.open = 1;
+ // Clear any stale shut fields so a blink (open -> 0) shows a plain line and
+ // never the 3 or the arch that was picked before.
+ eye.closed = 'line';
+ eye.closedLines = 1;
+ return;
+ }
+ // A shut artwork is drawn as the eye's content with the lids open; the lid
+ // height is its own slider (上まぶたの高さ), so selecting one opens the lid.
+ eye.open = 1;
+ if (value.startsWith('line')) {
+ eye.closed = 'line';
+ eye.closedLines = Number(value.slice(4)) || 1;
+ return;
+ }
+ eye.closed = value;
+ };
+ const eyeWhiteOf = (eye) => {
+ if (eye.white != null) return eye.white === true;
+ // The usual: an open eye shows its white; a shut eye or a heart does not.
+ return eyeShapeOf(eye) === 'open';
+ };
+ const eyeHighlightOf = (eye) => (eye.highlight != null
+ ? eye.highlight === true
+ : state.face.eyes.highlight !== false);
+
+ const EYE_ICONS = {
+ both: ' ',
+ left: ' ',
+ right: ' ',
+ lid: ' ',
+ };
+ // Each eye group carries the same controls. Linked, one group ("両目") drives
+ // both eyes; unlinked, a group per eye appears and the gaze pad doubles.
+ const eyeGroup = (title, icon = null) => {
+ const body = h('div', { class: 'eye-group-body' });
+ const head = h('div', { class: 'eye-group-head' },
+ icon ? svgIcon(EYE_ICONS[icon] ?? '') : null,
+ h('span', { text: title }));
+ const el = h('div', { class: 'eye-group' }, head, body);
+ return {
+ el,
+ add: (child) => (body.append(child?.el ?? child), child),
+ setVisible: (visible) => { el.style.display = visible ? '' : 'none'; },
+ };
+ };
+
+ const makeEyeControls = (mode) => {
+ const keys = mode === 'both' ? ['left', 'right'] : [mode];
+ const primary = keys[0];
+ const write = (fn) => { for (const k of keys) fn(state.face.eyes[k]); };
+ const touched = () => { app.actions.refresh('face'); sync(); };
+ const group = eyeGroup(mode === 'both' ? '両目' : (mode === 'left' ? '左目' : '右目'), mode);
+
+ // --- まぶた (per eye) ------------------------------------------------
+ // 上まぶたの高さ and 下まぶたの高さ share one opening, so each is held to the
+ // other: the lower lid cannot rise past the upper, and the upper cannot drop
+ // below the lower. The sliders' own limits are kept in step in `syncOne`.
+ const lidSub = eyeGroup('まぶた', 'lid');
+ const open = slider({
+ label: '上まぶたの高さ', min: 0, max: 1, step: 0.01, value: 1,
+ format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`),
+ onInput: (v) => {
+ const floor = state.face.eyes[primary].lowerLid ?? state.face.eyes.lowerLid ?? 0;
+ const vv = Math.max(v, floor);
+ write((e) => { e.open = vv; });
+ if (vv !== v) open.set(vv);
+ touched();
+ },
+ });
+ const lowerLid = slider({
+ label: '下まぶたの高さ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => {
+ const ceil = state.face.eyes[primary].open ?? 1;
+ const vv = Math.min(v, ceil);
+ write((e) => { e.lowerLid = vv; });
+ if (vv !== v) lowerLid.set(vv);
+ touched();
+ },
+ });
+ const lidShape = segmented({
+ label: 'まぶたの形',
+ options: [
+ { value: 'curve', label: '曲線' },
+ { value: 'flat', label: '直線(平ら)' },
+ ],
+ value: 'curve',
+ onChange: (v) => { write((e) => { e.lidShape = v; }); touched(); },
+ });
+ const lidWidth = slider({
+ label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { write((e) => { e.lidWidth = v; }); touched(); },
+ });
+ const lidTilt = slider({
+ label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { write((e) => { e.lidTilt = v; }); touched(); },
+ });
+ const lashes = check({
+ label: 'まつげをつける',
+ value: false,
+ onChange: (v) => { write((e) => { e.lashes = v; }); touched(); },
+ });
+ const lashAngle = slider({
+ label: 'まつげの角度', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { write((e) => { e.lashAngle = v; }); touched(); },
+ });
+ const lashPos = slider({
+ label: 'まつげの位置', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { write((e) => { e.lashPos = v; }); touched(); },
+ });
+ lidSub.add(lidShape);
+ lidSub.add(open);
+ lidSub.add(lowerLid);
+ lidSub.add(lidWidth);
+ lidSub.add(lidTilt);
+ lidSub.add(controlRow(null, lashes.el, { wide: true }));
+ lidSub.add(lashAngle);
+ lidSub.add(lashPos);
+ group.add(lidSub);
+ const openInput = open.el.querySelector('input');
+ const lowerLidInput = lowerLid.el.querySelector('input');
+
+ // 目の形: the artwork the eye shows. A shut shape is drawn with the lids open,
+ // so 上まぶたの高さ can then be lowered onto it.
+ const shape = segmented({
+ label: '目の形',
+ options: [
+ { value: 'open', label: 'ふつう' },
+ { value: 'line1', label: '1線' },
+ { value: 'line2', label: '2線' },
+ { value: 'line3', label: '3線' },
+ { value: 'three', label: '3の目' },
+ { value: 'arch', label: 'わらう' },
+ { value: 'heart', label: 'ハート' },
+ ],
+ value: 'open',
+ onChange: (v) => { write((e) => applyEyeShape(e, v)); touched(); },
+ });
+ // The heart colour only matters for the heart, so it sits right under 目の形
+ // and shows only then.
+ const heartColor = colorField({
+ label: 'ハートの色', value: '#e0344f',
+ swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'],
+ onChange: (v) => { state.face.eyes.heartColor = v; touched(); },
+ });
+ // 大きさ: one knob for the iris, the heart and the shut artwork.
+ const size = slider({
+ label: '大きさ', min: 0.4, max: 1.8, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.irisScale = v; touched(); },
+ });
+ const threeFlip = segmented({
+ label: '3の向き',
+ options: [
+ { value: 'normal', label: '3' },
+ { value: 'flip', label: '3(逆)' },
+ ],
+ value: 'normal',
+ onChange: (v) => { write((e) => { e.threeFlip = v === 'flip'; applyEyeShape(e, 'three'); }); touched(); },
+ });
+ const white = check({
+ label: '白目を出す',
+ value: true,
+ onChange: (v) => { write((e) => { e.white = v; }); touched(); },
+ });
+ const highlight = check({
+ label: '光彩(瞳の白い丸)を出す',
+ value: true,
+ onChange: (v) => { write((e) => { e.highlight = v; }); touched(); },
+ });
+ const pad = xyPad({
+ value: { x: 0, y: 0 },
+ center: 'eye',
+ onChange: ({ x, y }) => { write((e) => { e.lookX = x; e.lookY = y; }); touched(); },
+ });
+ // 涙: per eye. Linked, one set drives both - size and height match, while the
+ // sideways position and the tilt mirror so the pair stays symmetric.
+ const tearOn = check({
+ label: '涙を出す',
+ value: false,
+ onChange: (v) => { write((e) => { e.tearOn = v; }); touched(); },
+ });
+ const writeTear = (patch) => {
+ Object.assign(state.face.eyes[primary], patch);
+ if (mode === 'both') {
+ const mirrored = { ...patch };
+ if (patch.tearX != null) mirrored.tearX = -patch.tearX;
+ if (patch.tearTilt != null) mirrored.tearTilt = -patch.tearTilt;
+ Object.assign(state.face.eyes[primary === 'left' ? 'right' : 'left'], mirrored);
+ }
+ touched();
+ };
+ const tear = slider({
+ label: '涙の大きさ', min: 0.3, max: 1.6, step: 0.01, value: 0.3, format: (v) => v.toFixed(2),
+ onInput: (v) => writeTear({ tear: v }),
+ });
+ const tearY = slider({
+ label: '涙の高さ', min: -80, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => writeTear({ tearY: v }),
+ });
+ const tearX = slider({
+ label: '涙のよこ位置', min: -150, max: 150, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => writeTear({ tearX: v }),
+ });
+ const tearTilt = slider({
+ label: '涙の傾き', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => writeTear({ tearTilt: v }),
+ });
+
+ group.add(shape);
+ group.add(heartColor);
+ group.add(size);
+ group.add(threeFlip);
+ group.add(controlRow(null, white.el, { wide: true }));
+ group.add(controlRow(null, highlight.el, { wide: true }));
+ group.add(controlRow(null, tearOn.el, { wide: true }));
+ group.add(tear);
+ group.add(tearY);
+ group.add(tearX);
+ group.add(tearTilt);
+ group.add(controlRow('目線', h('div', { class: 'pad-wrap' }, pad.el,
+ h('p', { class: 'hint', text: 'ドラッグで目線。ダブルクリックで正面に戻ります。' }))));
+
+ const syncOne = () => {
+ const e = state.face.eyes[primary];
+ const shapeValue = eyeShapeOf(e);
+ const lower = e.lowerLid ?? state.face.eyes.lowerLid ?? 0;
+ const upper = e.open ?? 1;
+ // Keep each lid's slider range from crossing the other before applying the
+ // values, so the displayed thumb cannot sit past the other lid either.
+ if (openInput) openInput.min = String(Math.min(lower, upper));
+ if (lowerLidInput) lowerLidInput.max = String(Math.max(lower, upper));
+ open.set(upper);
+ lowerLid.set(lower);
+ lidShape.set(e.lidShape ?? state.face.eyes.lidShape ?? 'curve');
+ lidWidth.set(e.lidWidth ?? state.face.eyes.lidWidth ?? 14);
+ lidTilt.set(e.lidTilt ?? state.face.eyes.lidTilt ?? 0);
+ lashes.set((e.lashes ?? state.face.eyes.lashes) === true);
+ const lashesOn = (e.lashes ?? state.face.eyes.lashes) === true;
+ lashAngle.set(e.lashAngle ?? state.face.eyes.lashAngle ?? 0);
+ lashPos.set(e.lashPos ?? state.face.eyes.lashPos ?? 0);
+ lashAngle.el.style.display = lashesOn ? '' : 'none';
+ lashPos.el.style.display = lashesOn ? '' : 'none';
+ shape.set(shapeValue);
+ heartColor.set(state.face.eyes.heartColor ?? '#e0344f');
+ heartColor.el.style.display = shapeValue === 'heart' ? '' : 'none';
+ size.set(state.face.eyes.irisScale ?? 1);
+ threeFlip.set(e.threeFlip ? 'flip' : 'normal');
+ threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none';
+ white.set(eyeWhiteOf(e));
+ highlight.set(eyeHighlightOf(e));
+ const tearShown = e.tearOn === true;
+ tearOn.set(tearShown);
+ for (const widget of [tear, tearY, tearX, tearTilt]) {
+ widget.el.style.display = tearShown ? '' : 'none';
+ }
+ tear.set(e.tear ?? 0.3);
+ tearY.set(e.tearY ?? 0);
+ tearX.set(e.tearX ?? 0);
+ tearTilt.set(e.tearTilt ?? 0);
+ pad.set({ x: e.lookX ?? 0, y: e.lookY ?? 0 });
+ };
+ return { group, sync: syncOne };
+ };
+
+ const bothControls = makeEyeControls('both');
+ const leftControls = makeEyeControls('left');
+ const rightControls = makeEyeControls('right');
+
+ faceSection.add(controlRow(null, linkedToggle.el, { wide: true }));
+ faceSection.add(bothControls.group);
+ faceSection.add(leftControls.group);
+ faceSection.add(rightControls.group);
+ faceSection.add(hint('「目の形」の「2線」「3線」は端点がつながった形になります。'
+ + '閉じ目の形も、上まぶた・下まぶたで上下から隠せます。'));
+
+ // 横位置 (per eye) rarely changes, so it gets its own small box.
+ const eyeAdvancedWidgets = {};
+ for (const key of ['left', 'right']) {
+ const label = key === 'left' ? '左目' : '右目';
+ const eyeX = slider({
+ label: `${label}の横位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); },
+ });
+ eyeAdvancedWidgets[key] = { eyeX };
+ tearWidgets.push(eyeX.el);
+ }
+
+ // One button to put every eye setting back to the artwork's defaults. The
+ // artwork *source* is kept: resetting the numbers should not throw away a
+ // hand-drawn image the user loaded.
+ faceSection.add(buttons({
+ items: [{
+ id: 'eyesReset',
+ label: '目の設定をリセット',
+ onClick: () => {
+ const source = state.face.eyes.source;
+ state.face.eyes = { ...defaultState().face.eyes, source };
+ app.actions.refresh('face');
+ sync();
+ },
+ }],
+ }));
+
+ faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、'
+ + 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。'
+ + 'はずすと「左目」「右目」の設定がそれぞれに出て、目線のパッドも2つになります。'
+ + '「左目」「右目」は、ぶるべー自身から見た左右です(正面から見ると、画面では左右が入れ替わって見えます)。'));
+
+ const eyeColors = {
+ white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }),
+ iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }),
+ line: colorField({ label: 'まぶたの線', value: '#55386e', onChange: (v) => { state.face.eyes.line = v; app.actions.refresh('face'); } }),
+ };
+ const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)');
+ for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el);
+
+ // 横位置 (per eye) is rarely used, so it stays in its own small box.
+ const tearBox = details(faceSection.body, '横位置(左右べつ)');
+ for (const el of tearWidgets) tearBox.add(el);
+
+ // --- eyebrows (the original character has none; this is extra range)
+ faceSection = faceTabSections.brow;
+ faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。'));
+
+ const browToggle = check({
+ label: '眉を表示',
+ value: false,
+ onChange: (value) => { state.face.eyes.brow.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, browToggle.el, { wide: true }));
+
+ const browSliders = {
+ angle: slider({
+ label: '角度', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.eyes.brow.angle = v; app.actions.refresh('face'); },
+ }),
+ height: slider({
+ label: '高さ', min: -40, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); },
+ }),
+ length: slider({
+ label: '長さ', min: 0, max: 4, step: 0.01, value: 1,
+ format: (v) => (v < 0.02 ? '点' : v.toFixed(2)),
+ onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); },
+ }),
+ thickness: slider({
+ label: '太さ', min: 0.4, max: 3.5, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.brow.thickness = v; app.actions.refresh('face'); },
+ }),
+ spacing: slider({
+ label: '間隔', min: -220, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.brow.spacing = v; app.actions.refresh('face'); },
+ }),
+ curve: slider({
+ label: '曲がり', min: 0, max: 0.4, step: 0.01, value: 0.14, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.brow.curve = v; app.actions.refresh('face'); },
+ }),
+ taper: slider({
+ label: '鼻側の細さ', min: 0, max: 1, step: 0.01, value: 1,
+ format: (v) => (v >= 0.995 ? 'そのまま' : (v <= 0.005 ? 'とがる' : v.toFixed(2))),
+ onInput: (v) => { state.face.eyes.brow.taper = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(hint('+の角度で内側が下がり、怒った感じになります。-で困り顔。'
+ + '高さは + で上がり、- にすると目にかくれて見えなくなります。'));
+ faceSection.add(browSliders.angle.el);
+ const browBox = details(faceSection.body, 'こまかい設定(高さ・長さ・太さ・色)');
+ browBox.add(browSliders.height.el);
+ browBox.add(browSliders.length.el);
+ browBox.add(browSliders.thickness.el);
+ browBox.add(browSliders.spacing.el);
+ browBox.add(browSliders.curve.el);
+ browBox.add(browSliders.taper.el);
+ const browColor = colorField({
+ label: '眉の色', value: '#55386e',
+ swatches: ['#55386e', '#2a1e33', '#7a5c2e', '#8a4a5e'],
+ onChange: (v) => { state.face.eyes.brow.color = v; app.actions.refresh('face'); },
+ });
+ browBox.add(browColor.el);
+ browBox.add(hint('「間隔」は左右の眉のへだたりです(+で広がり、-で内側に寄って、'
+ + '大きく-にすると左右がくっつきます)。「長さ」を 0 にすると点になります。'
+ + '「鼻側の細さ」を 0 にすると鼻側がとがります。'));
+ browBox.add(buttons({
+ items: [
+ {
+ id: 'golgo',
+ label: 'ゴル風(画像)',
+ onClick: () => {
+ // ゴルゴ13: a very thick, almost straight black wedge, wide at the outer
+ // end and tapering to a point at the nose, angled steeply down towards
+ // it, with the two brows sitting close together.
+ Object.assign(state.face.eyes.brow, {
+ enabled: true,
+ image: true,
+ angle: 34,
+ height: 33,
+ length: 2.82,
+ thickness: 3.6,
+ curve: 0,
+ taper: 0,
+ spacing: -59,
+ color: '#2a1e33',
+ });
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ {
+ id: 'ryotsu',
+ label: '両風(線の眉)',
+ onClick: () => {
+ // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting
+ // low over the eyes. `spacing` is what closes the gap - at this length
+ // the two inner ends meet over the nose (see the panel's note). Turning
+ // `image` off is what makes the line drawing take over from ゴル風's
+ // loaded picture.
+ Object.assign(state.face.eyes.brow, {
+ enabled: true,
+ image: false,
+ angle: 10,
+ height: 4,
+ length: 2.86,
+ thickness: 3.29,
+ curve: 0.4,
+ taper: 1,
+ spacing: -50,
+ color: '#181220',
+ });
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ ],
+ }));
+
+ faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。'
+ + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。'
+ + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。'));
+
+ // --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
+ faceSection = faceTabSections.glasses;
+ faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。'
+ + '線画のときはレンズを塗らず、フレームの線だけになります。'));
+
+ // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明
+ // なので、目を開けたままだと瞳が透けて見えてしまう。種類を変えたらその種類の
+ // 既定値(レンズの色・濃さ・高さ)を一緒に持ってきて、サングラスのときは両目を
+ // 閉じる。フレームや大きさには触らないので、作りこんだフレームは種類を変えても
+ // 失われない。
+ const GLASSES_KINDS = {
+ glasses: { lensColor: '#2b2433', lensOpacity: 0.22, offsetY: 0 },
+ sunglasses: { lensColor: '#1a1620', lensOpacity: 1, offsetY: -40 },
+ };
+ const wearSunglasses = () => {
+ state.face.eyes.glasses.lensOpacity = 1;
+ state.face.eyes.left.open = 0;
+ state.face.eyes.right.open = 0;
+ };
+ const setGlassesKind = (kind) => {
+ Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] });
+ // サングラス hides the eye behind an opaque lens, so both eyes shut with it;
+ // 眼鏡's lens is clear, so switching back opens them again.
+ const open = kind === 'sunglasses' ? 0 : 1;
+ state.face.eyes.left.open = open;
+ state.face.eyes.right.open = open;
+ };
+
+ const glassesToggle = check({
+ label: '眼鏡をかける',
+ value: false,
+ onChange: (value) => {
+ state.face.eyes.glasses.enabled = value;
+ // A loaded state can already be on サングラス, so enabling the shades also
+ // shuts the eyes rather than showing them through the opaque lens.
+ if (value && state.face.eyes.glasses.kind === 'sunglasses') wearSunglasses();
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(controlRow(null, glassesToggle.el, { wide: true }));
+
+ const glassesKindSegment = segmented({
+ label: '種類',
+ options: [
+ { value: 'glasses', label: '眼鏡' },
+ { value: 'sunglasses', label: 'サングラス' },
+ ],
+ value: 'glasses',
+ onChange: (value) => {
+ setGlassesKind(value);
+ // Picking a kind means you mean to wear it, so put the glasses on rather
+ // than leaving the checkbox off and nothing showing on the face.
+ state.face.eyes.glasses.enabled = true;
+ glassesToggle.set(true);
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(glassesKindSegment.el);
+
+ const glassesSliders = {
+ scale: slider({
+ label: '大きさ', min: 0.7, max: 1.5, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.glasses.scale = v; app.actions.refresh('face'); },
+ }),
+ lensOpacity: slider({
+ label: 'レンズの濃さ', min: 0, max: 1, step: 0.01, value: 0.22,
+ format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)),
+ onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); },
+ }),
+ lensGap: slider({
+ label: '左右レンズの間隔', min: -40, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.glasses.lensGap = v; app.actions.refresh('face'); },
+ }),
+ frameWidth: slider({
+ label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: '高さ', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.glasses.offsetY = v; app.actions.refresh('face'); },
+ }),
+ tilt: slider({
+ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.eyes.glasses.tilt = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(glassesSliders.scale.el);
+ faceSection.add(glassesSliders.lensOpacity.el);
+ const glassesBox = details(faceSection.body, 'こまかい設定(フレーム・位置・色)');
+ glassesBox.add(glassesSliders.frameWidth.el);
+ glassesBox.add(glassesSliders.offsetY.el);
+ glassesBox.add(glassesSliders.tilt.el);
+ glassesBox.add(glassesSliders.lensGap.el);
+ const glassesFrameColor = colorField({
+ label: 'フレームの色', value: '#2a1e33',
+ swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'],
+ onChange: (v) => { state.face.eyes.glasses.frameColor = v; app.actions.refresh('face'); },
+ });
+ const glassesLensColor = colorField({
+ label: 'レンズの色', value: '#2b2433',
+ swatches: ['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e'],
+ onChange: (v) => { state.face.eyes.glasses.lensColor = v; app.actions.refresh('face'); },
+ });
+ glassesBox.add(glassesFrameColor.el);
+ glassesBox.add(glassesLensColor.el);
+ glassesBox.add(hint('「レンズの濃さ」を 0 にするとレンズは透明になり、フレームだけの眼鏡になります。'
+ + '「高さ」は眼鏡全体を上下に動かします(+で下がります)。'
+ + '「傾き」は左右のレンズが一緒に傾きます。'));
+ glassesBox.add(buttons({
+ items: [
+ {
+ id: 'glasses',
+ label: '眼鏡',
+ onClick: () => {
+ setGlassesKind('glasses');
+ state.face.eyes.glasses.enabled = true;
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ {
+ id: 'sunglasses',
+ label: 'サングラス',
+ onClick: () => {
+ setGlassesKind('sunglasses');
+ state.face.eyes.glasses.enabled = true;
+ app.actions.refresh('face');
+ sync();
+ },
+ },
+ ],
+ }));
+
+ // --- ほっぺ (a manga blush)
+ faceSection = faceTabSections.cheeks;
+ faceSection.add(hint('ほっぺ(マンガの赤らみ)を、目や眉と同じ絵にかけます。'
+ + '決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。'
+ + 'はじめは出ていません。'));
+
+ const cheeksToggle = check({
+ label: 'ほっぺを出す',
+ value: false,
+ onChange: (value) => { state.face.eyes.cheeks.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, cheeksToggle.el, { wide: true }));
+
+ const cheekSliders = {
+ size: slider({
+ label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.cheeks.size = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -80, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.cheeks.offsetY = v; app.actions.refresh('face'); },
+ }),
+ spacing: slider({
+ label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(cheekSliders.size.el);
+ const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)');
+ cheekBox.add(cheekSliders.offsetY.el);
+ cheekBox.add(cheekSliders.spacing.el);
+ const cheekColor = colorField({
+ label: 'ほっぺの色', value: '#f6a6b8',
+ swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'],
+ onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); },
+ });
+ cheekBox.add(cheekColor.el);
+ cheekBox.add(hint('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、'
+ + '「たかさ」は目からの下がり具合です。'));
+
+ // --- 髪 (a hair-like covering over the whole head; its own section, above 眉)
+ faceSection = faceTabSections.hair;
+ faceSection.add(hint('髪を頭にかぶせます。決まったキャラクターの髪ではなく、'
+ + 'よくあるマンガの形として用意しました。はじめは出ていません。'));
+
+ const headMarkShape = segmented({
+ label: '髪',
+ options: [
+ { value: 'off', label: 'なし' },
+ { value: 'cap', label: 'かぶせ' },
+ { value: 'fringe', label: 'ぎざぎざ' },
+ { value: 'fringe-up', label: 'はちわれ' },
+ { value: 'curve', label: 'カーブ' },
+ ],
+ value: 'off',
+ onChange: (value) => {
+ const mark = state.face.eyes.headMark;
+ mark.shape = value;
+ // Entering a shape applies its own starting size (ぎざぎざ is smaller and
+ // shallower than the shared defaults). Other shapes just keep the values.
+ Object.assign(mark, HEAD_MARK_KIND_DEFAULTS[value] ?? {});
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(headMarkShape.el);
+
+ const headMarkOverEyes = check({
+ label: '髪を目の前に出す',
+ value: true,
+ onChange: (value) => { state.face.eyes.headMark.overEyes = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, headMarkOverEyes.el, { wide: true }));
+
+ const headMarkSliders = {
+ size: slider({
+ label: '大きさ', min: 0.05, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); },
+ }),
+ teeth: slider({
+ label: '歯の深さ', min: 0.02, max: 0.4, step: 0.01, value: 0.12, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.headMark.teeth = v; app.actions.refresh('face'); },
+ }),
+ offsetX: slider({
+ label: 'よこの位置', min: -260, max: 260, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -300, max: 300, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(headMarkSliders.size.el);
+ faceSection.add(headMarkSliders.teeth.el);
+ //「歯の深さ」はぎざぎざのときだけ意味があるので、それ以外では隠します。
+ const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)');
+ headMarkBox.add(headMarkSliders.offsetX.el);
+ headMarkBox.add(headMarkSliders.offsetY.el);
+ const headMarkColor = colorField({
+ label: '髪の色', value: '#8a4fe0',
+ swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'],
+ onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); },
+ });
+ const headMarkColor2 = colorField({
+ label: '2色目', value: '#ffffff',
+ swatches: ['#ffffff', '#f3ead9', '#ffe08a', '#7fd8ff'],
+ onChange: (v) => { state.face.eyes.headMark.color2 = v; app.actions.refresh('face'); },
+ });
+ headMarkBox.add(headMarkColor.el);
+ headMarkBox.add(headMarkColor2.el);
+ headMarkBox.add(hint('「かぶせ」は頭にかぶせる丸い髪、「ぎざぎざ」は生え際がギザギザにとがった髪(「歯の深さ」でギザギザの深さを変えられます)、'
+ + '「はちわれ」は中央が鋭く持ち上がった生え際、「カーブ」はドラえもんのように単純にカーブした髪です。'
+ + '「たかさ」で生え際を目の中心あたりまで下げられます。'
+ + '「髪を目の前に出す」を切ると、髪が目の後ろに回ります。'));
+
+ // --- 鼻ちょうちん: the snot bubble of a sleeping face.
+ faceSection = faceTabSections.snot;
+ const snotToggle = check({
+ label: '鼻ちょうちんを出す',
+ value: false,
+ onChange: (value) => { state.face.eyes.snot.enabled = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, snotToggle.el, { wide: true }));
+ const snotSize = slider({
+ label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.snot.size = v; app.actions.refresh('face'); },
+ });
+ faceSection.add(snotSize.el);
+ const snotBox = details(faceSection.body, 'こまかい設定(位置・色)');
+ const snotSliders = {
+ offsetX: slider({
+ label: 'よこの位置', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.snot.offsetX = v; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.snot.offsetY = v; app.actions.refresh('face'); },
+ }),
+ offsetZ: slider({
+ label: 'おくゆき', min: -10, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.snot.offsetZ = v; app.actions.refresh('face'); },
+ }),
+ };
+ for (const widget of Object.values(snotSliders)) snotBox.add(widget.el);
+ const snotColor = colorField({
+ label: '泡の色', value: '#e3ecff',
+ swatches: ['#e3ecff', '#ffffff', '#cfe0ff', '#bfe8d0'],
+ onChange: (v) => { state.face.eyes.snot.color = v; app.actions.refresh('face'); },
+ });
+ snotBox.add(snotColor.el);
+ snotBox.add(hint('寝ている顔の横に、鼻から出る泡を描きます。'
+ + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。'
+ + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。'));
+
+ // --- ひげ: mustaches and beards, hung under the nose.
+ faceSection = faceTabSections.beard;
+ const beardShape = segmented({
+ label: 'ひげ',
+ options: [
+ { value: 'off', label: 'なし' },
+ { value: 'scotch', label: 'ちょびひげ' },
+ { value: 'kaiser', label: 'ダリ' },
+ { value: 'cat', label: 'ねこ' },
+ ],
+ value: 'off',
+ onChange: (value) => {
+ const beard = state.face.eyes.beard;
+ beard.shape = value;
+ // Each kind starts at its own size and height (a textured kind is drawn at a
+ // different scale from the built-in strokes). A kind with no entry resets to
+ // the neutral values, so switching kinds cannot leave a stray size behind.
+ Object.assign(beard, BEARD_KIND_DEFAULTS[value] ?? { size: 1, offsetY: 0, spacing: 0 });
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(beardShape.el);
+ const beardSliders = {
+ size: slider({
+ label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); },
+ }),
+ spacing: slider({
+ label: '左右の間隔', min: 0, max: 250, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ // Shown 10 higher than the value stored, so the default (stored -10) reads 0.
+ onInput: (v) => { state.face.eyes.beard.spacing = v - 10; app.actions.refresh('face'); },
+ }),
+ offsetY: slider({
+ label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); },
+ }),
+ length: slider({
+ label: 'ひげの長さ(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.beard.length = v; app.actions.refresh('face'); },
+ }),
+ lineGap: slider({
+ label: '線の間隔(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.beard.lineGap = v; app.actions.refresh('face'); },
+ }),
+ };
+ faceSection.add(beardSliders.size.el);
+ const beardBox = details(faceSection.body, 'こまかい設定(位置・色)');
+ beardBox.add(beardSliders.spacing.el);
+ beardBox.add(beardSliders.offsetY.el);
+ beardBox.add(beardSliders.length.el);
+ beardBox.add(beardSliders.lineGap.el);
+ const beardColor = colorField({
+ label: 'ひげの色', value: '#1c1624',
+ swatches: ['#1c1624', '#000000', '#3a2a4a', '#6b4a2a', '#8a8a94'],
+ onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); },
+ });
+ beardBox.add(beardColor.el);
+ beardBox.add(hint('鼻の下に、ちょびひげ・ダリ・ねこ、の瓢を描きます。'
+ + '「左右の間隔」「瓢の長さ」「線の間隔」は「ねこ」で使います。'));
+
+ // --- mouth
+ faceSection = faceTabSections.mouth;
+
+ const mouthToggle = check({
+ label: '口を表示',
+ value: true,
+ onChange: (value) => { state.face.mouth.visible = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, mouthToggle.el, { wide: true }));
+
+ // 口角の角度は「笑いの深さ」に比例して動かす(笑いの深さ 1 で -8°、-1 で 70°、
+ // その間は直線)。笑いの深さを動かすと追従し、手で微調整したいときのために
+ // スライダー自体は残してある。
+ const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile)));
+
+ const mouthSliders = {
+ smile: slider({ label: '曲げ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }),
+ open: slider({ label: '開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }),
+ round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }),
+ width: slider({ label: '幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }),
+ thickness: slider({ label: '太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }),
+ tilt: slider({ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }),
+ offsetY: slider({ label: '高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }),
+ tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }),
+ tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }),
+ corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }),
+ cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }),
+ };
+ // 口には「ふつうの口」と「丸く開く(O)」の2つの形があり、使うスライダーが
+ // まったく違う。形を切り替えると、その形で使うものだけを出す。
+ const mouthModeOf = (mouth) => (mouth.round > 0.004 ? 'round' : 'curve');
+ const mouthRowKeys = {
+ curve: ['smile', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY'],
+ round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'],
+ };
+ // DOM order is fixed here; the mode only shows or hides rows.
+ const mouthRowOrder = ['round', 'smile', 'width', 'thickness', 'open', 'tilt', 'offsetY', 'tongue', 'tonguePos', 'corners', 'cornerAngle'];
+ const mouthMode = segmented({
+ label: '口の形',
+ options: [
+ { value: 'curve', label: 'ふつうの口' },
+ { value: 'round', label: '丸く開く(O)' },
+ ],
+ value: 'curve',
+ onChange: (shape) => {
+ if (shape === 'round') {
+ if (!(state.face.mouth.round > 0.004)) state.face.mouth.round = 0.6;
+ } else {
+ state.face.mouth.round = 0;
+ }
+ app.actions.refresh('face');
+ sync();
+ },
+ });
+ faceSection.add(mouthMode);
+ for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el);
+ faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。'
+ + 'ふつうの口は「曲げ」と「開き」、丸く開く口(O)は「Oの大きさ」で形が決まります。'));
+ faceSection.add(buttons({
+ items: [{
+ id: 'mouthreset',
+ label: '口を初期値に戻す',
+ onClick: () => {
+ state.face.mouth = defaultState().face.mouth;
+ app.actions.refresh('face');
+ sync();
+ },
+ }],
+ }));
+
+ const mouthColors = {
+ color: colorField({ label: '口の線', value: '#ff1a44', onChange: (v) => { state.face.mouth.color = v; app.actions.refresh('face'); } }),
+ innerColor: colorField({ label: '口の中', value: '#4a0f1e', onChange: (v) => { state.face.mouth.innerColor = v; app.actions.refresh('face'); } }),
+ tongueColor: colorField({ label: '舌', value: '#ff2d2d', onChange: (v) => { state.face.mouth.tongueColor = v; app.actions.refresh('face'); } }),
+ cornerColor: colorField({ label: '口角', value: '#725497', onChange: (v) => { state.face.mouth.cornerColor = v; app.actions.refresh('face'); } }),
+ };
+ const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)');
+ for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el);
+
+ /* ---------------------------------------------------------------- caption */
+
+ const captionSection = section(tabScene, 'セリフ(フキダシ)');
+
+ // Two bubbles share this one set of controls: the selector says which bubble
+ // they are editing, and every handler below reads the active one when it runs,
+ // so switching does not have to rewire anything.
+ let activeCaption = 'caption';
+ const cap = () => state[activeCaption];
+ const captionBubbleSegment = segmented({
+ label: 'どの吹き出し',
+ options: [
+ { value: 'caption', label: '①' },
+ { value: 'caption2', label: '②' },
+ { value: 'narration', label: 'ナレーション' },
+ ],
+ value: 'caption',
+ onChange: (value) => { activeCaption = value; sync(); },
+ });
+ captionSection.add(captionBubbleSegment.el);
+ captionSection.add(hint('吹き出しは①と②の2つ、ナレーション枠が1つ出せます。切り替えて、それぞれ別の位置・形・色・縦書きにできます。'));
+
+ const captionToggle = check({
+ label: 'この吹き出しを出す',
+ value: false,
+ onChange: (value) => { cap().enabled = value; app.actions.refresh('caption'); sync(); },
+ });
+ captionSection.add(controlRow(null, captionToggle.el, { wide: true }));
+
+ const captionText = textArea({
+ label: 'セリフ',
+ value: '',
+ rows: 3,
+ onChange: (value) => { cap().text = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(captionText.el);
+
+ const verticalToggle = check({
+ label: '縦書きにする',
+ value: false,
+ onChange: (v) => { cap().vertical = v; app.actions.refresh('caption'); sync(); },
+ });
+ captionSection.add(controlRow(null, verticalToggle.el, { wide: true }));
+
+ captionSection.add(buttons({
+ label: '例',
+ items: CAPTION_PRESETS.map((preset) => ({
+ id: preset.id,
+ label: preset.label,
+ onClick: () => { Object.assign(cap(), preset.caption); app.actions.refresh('caption'); sync(); },
+ })),
+ }));
+
+ const bubbleSegment = segmented({
+ label: '形',
+ options: BUBBLE_STYLES,
+ value: 'round',
+ onChange: (value) => { cap().bubble = value; app.actions.refresh('caption'); },
+ });
+ const tailSegment = tailPad({
+ label: 'しっぽ',
+ value: 'left',
+ onChange: (value) => { cap().tail = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(bubbleSegment.el);
+ captionSection.add(tailSegment.el);
+
+ const captionSliders = {
+ fontSize: slider({
+ label: '文字の大きさ', min: 16, max: 72, step: 1, value: 34, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().fontSize = v; app.actions.refresh('caption'); },
+ }),
+ lineHeight: slider({
+ label: '行の高さ', min: 1, max: 2, step: 0.01, value: 1.42, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().lineHeight = v; app.actions.refresh('caption'); },
+ }),
+ padding: slider({
+ label: '内側の余白', min: 4, max: 40, step: 1, value: 18, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().padding = v; app.actions.refresh('caption'); },
+ }),
+ radius: slider({
+ label: '角の丸み', min: 0, max: 60, step: 1, value: 24, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { cap().radius = v; app.actions.refresh('caption'); },
+ }),
+ borderWidth: slider({
+ label: '線の太さ', min: 0, max: 12, step: 0.5, value: 4, format: (v) => `${v.toFixed(1)}px`,
+ onInput: (v) => { cap().borderWidth = v; app.actions.refresh('caption'); },
+ }),
+ };
+ for (const widget of Object.values(captionSliders)) captionSection.add(widget.el);
+
+ const captionPosition = {
+ x: slider({
+ label: '横位置', min: 0, max: 1, step: 0.01, value: 0.58, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().x = v; app.actions.refresh('caption'); },
+ }),
+ y: slider({
+ label: '縦位置', min: 0, max: 1, step: 0.01, value: 0.16, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().y = v; app.actions.refresh('caption'); },
+ }),
+ };
+ captionSection.add(captionPosition.x.el);
+ captionSection.add(captionPosition.y.el);
+
+ const captionMaxWidthSlider = slider({
+ label: 'はば', min: 0.15, max: 0.8, step: 0.01, value: 0.36, format: (v) => v.toFixed(2),
+ onInput: (v) => { cap().maxWidth = v; app.actions.refresh('caption'); },
+ });
+ captionSection.add(captionMaxWidthSlider.el);
+
+ const alignSegment = segmented({
+ label: 'そろえ',
+ options: [
+ { value: 'left', label: '左' },
+ { value: 'center', label: '中' },
+ { value: 'right', label: '右' },
+ ],
+ value: 'left',
+ onChange: (value) => { cap().align = value; app.actions.refresh('caption'); },
+ });
+ captionSection.add(alignSegment.el);
+
+ const boldToggle = check({
+ label: '太字', value: false,
+ onChange: (value) => { cap().bold = value; app.actions.refresh('caption'); },
+ });
+ const systemFontToggle = check({
+ label: '標準フォントを使う', value: false,
+ onChange: (value) => { cap().font = value ? 'system' : 'rounded'; app.actions.refresh('caption'); },
+ });
+ captionSection.add(controlRow(null, boldToggle.el, { wide: true }));
+ captionSection.add(controlRow(null, systemFontToggle.el, { wide: true }));
+
+ const captionColors = {
+ textColor: colorField({ label: '文字の色', value: '#3f2b52', onChange: (v) => { cap().textColor = v; app.actions.refresh('caption'); } }),
+ bubbleColor: colorField({ label: '吹き出しの色', value: '#ffffff', onChange: (v) => { cap().bubbleColor = v; app.actions.refresh('caption'); } }),
+ borderColor: colorField({ label: '線の色', value: '#55386e', onChange: (v) => { cap().borderColor = v; app.actions.refresh('caption'); } }),
+ };
+ for (const widget of Object.values(captionColors)) captionSection.add(widget.el);
+ captionSection.add(hint('セリフはプレビューと書き出し画像の両方に描きこまれます。位置は画像全体を0〜1とした割合です。'
+ + 'ビューポートで吹き出しをドラッグしても動かせます。'));
+
+ /* -------------------------------------------------------------- 口パク */
+
+ // 口パク lives inside うごき on the pose tab (see below): talking is something
+ // the character *does*, and it is usually recorded together with the motion.
+
+ /* ------------------------------------------------------ movement (pose tab) */
+
+ // This is the pose tab's うごき section; the widgets sit here because they are
+ // driven by the animator alongside the pose state.
+ const moveSection = section(tabPose, 'うごき');
+
+ const animWidgets = {
+ blink: check({ label: 'まばたきする', value: true, onChange: (v) => { state.anim.blink = v; app.animator.reset(); } }),
+ lookAround: check({ label: '目線がうごく', value: false, onChange: (v) => { state.anim.lookAround = v; app.animator.reset(); } }),
+ };
+
+ const motionSegment = segmented({
+ label: 'からだのうごき',
+ options: [
+ { value: 'off', label: 'とまる' },
+ { value: 'idle', label: 'ゆれる' },
+ { value: 'walk', label: '歩く' },
+ { value: 'wave', label: 'てをふる' },
+ { value: 'sleep', label: 'ねている' },
+ ],
+ value: 'off',
+ onChange: (value) => {
+ state.anim.mode = value;
+ // Sleeping always shows the snot bubble, so the motion reads without having
+ // to switch the bubble on as well.
+ if (value === 'sleep') {
+ state.face.eyes.snot.enabled = true;
+ snotToggle.set(true);
+ app.actions.refresh('face');
+ }
+ app.animator.reset();
+ app.needsRender = true;
+ },
+ });
+ moveSection.add(motionSegment.el);
+ for (const widget of Object.values(animWidgets)) moveSection.add(controlRow(null, widget.el, { wide: true }));
+
+ moveSection.add(slider({
+ label: 'まばたきの間隔', min: 1, max: 8, step: 0.1, value: 3.4, format: (v) => `${v.toFixed(1)}秒`,
+ onInput: (v) => { state.anim.blinkInterval = v; },
+ }));
+ moveSection.add(slider({
+ label: '動きの速さ', min: 0.2, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
+ onInput: (v) => { state.anim.speed = v; },
+ }));
+
+ const recordButton = buttons({
+ items: [{ id: 'record', label: '録画を開始', primary: true, onClick: () => app.actions.toggleRecording() }],
+ });
+ moveSection.add(recordButton.el);
+ moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。'));
+
+ // 口パク: the mouth moves as if talking, and no sound is made at all. It lives
+ // in うごき because talking is motion - and it is usually recorded with it.
+ // (The studio used to read the line with the device's own speech voices, but
+ // those belong to the device, so a recording of them is not ours to hand out.)
+ const flapSection = details(moveSection.body, '口パク(声は出ません)');
+ const flapText = textArea({
+ label: '口パクするセリフ',
+ value: '',
+ rows: 3,
+ onChange: (value) => { state.mouthFlap.text = value; },
+ });
+ flapSection.add(flapText.el);
+ flapSection.add(buttons({
+ items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }],
+ }));
+ const flapSliders = {
+ rate: slider({
+ label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
+ onInput: (v) => { state.mouthFlap.rate = v; },
+ }),
+ mouthGain: slider({
+ label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.mouthFlap.mouthGain = v; },
+ }),
+ };
+ for (const widget of Object.values(flapSliders)) flapSection.add(widget.el);
+ flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
+ + '「シーン」タブの「セリフ(フキダシ)」の文とは別で、こちらは長さにだけ使います。'));
+
+ // --- 見る先 (moved here, next to the face controls, from the pose tab) ----
+ const lookAtSection = faceTabSections.look;
+ // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too.
+ const refreshLookAt = () => {
+ app.actions.refresh('lookAt');
+ app.actions.refresh('face');
+ sync();
+ };
+ const lookAtToggle = check({
+ label: '指定した場所を見る',
+ value: false,
+ onChange: (value) => { state.lookAt.enabled = value; refreshLookAt(); },
+ });
+ lookAtSection.add(controlRow(null, lookAtToggle.el, { wide: true }));
+ const lookAtSliders = {
+ x: slider({
+ label: 'よこ(X)', min: -6, max: 6, step: 0.05, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.x = v; refreshLookAt(); },
+ }),
+ y: slider({
+ label: 'たかさ(Y)', min: 0, max: 5, step: 0.05, value: 2.6, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.y = v; refreshLookAt(); },
+ }),
+ z: slider({
+ label: 'おくゆき(Z)', min: -6, max: 6, step: 0.05, value: 3, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.z = v; refreshLookAt(); },
+ }),
+ amount: slider({
+ label: '強さ', min: 0, max: 1, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.lookAt.amount = v; refreshLookAt(); },
+ }),
+ };
+ for (const widget of Object.values(lookAtSliders)) lookAtSection.add(widget.el);
+ const turnBodyToggle = check({
+ label: '体も向ける',
+ value: false,
+ onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); },
+ });
+ lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true }));
+ lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目と髪が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。'));
+
+ /* ---------------------------------------------------------------- display */
+
+ // The face tab's bottom line: a quick way out of whatever has been built up.
+ tabFace.append(buttons({
+ items: [
+ { id: 'faceReset', label: '顔をリセット', onClick: () => replace({ face: defaultState().face }, 'face') },
+ { id: 'faceRandom', label: '顔をランダムに', onClick: () => randomFace() },
+ ],
+ }).el);
+
+ const displaySection = section(tabView, 'スタイル');
+
+ const styleSegment = segmented({
+ label: 'スタイル',
+ options: STYLE_DEFS.map((style) => ({ value: style.value, label: style.label })),
+ value: 'real',
+ onChange: (value) => replace({ render: { style: value } }, 'render'),
+ });
+ displaySection.add(styleSegment.el);
+ displaySection.add(hint('線画は輪郭線+白ぬりです(体に色がつきません)。線だけを他の絵に重ねたいときは、「書き出し」タブで「PNGの背景を透明にする」を入れてください。'));
+
+ const outlineToggle = check({
+ label: '輪郭線を出す', value: true,
+ onChange: (value) => { state.render.outline = value; app.actions.refresh('render'); },
+ });
+ displaySection.add(controlRow(null, outlineToggle.el, { wide: true }));
+ const outlineWidthSlider = slider({
+ label: '線の太さ(体・手足)', min: 0, max: 0.08, step: 0.001, value: 0.022, format: (v) => v.toFixed(3),
+ onInput: (v) => { state.render.outlineWidth = v; app.actions.refresh('render'); },
+ });
+ displaySection.add(outlineWidthSlider.el);
+ const outlinePixelsSlider = slider({
+ label: '線の太さ(葉っぱ・鼻)', min: 1, max: 5, step: 0.1, value: 2,
+ format: (v) => `${v.toFixed(1)} px`,
+ onInput: (v) => { state.render.outlinePixels = v; app.actions.refresh('render'); },
+ });
+ displaySection.add(outlinePixelsSlider.el);
+ displaySection.add(colorField({
+ label: '線の色', value: '#2a1e33',
+ swatches: ['#2a1e33', '#111111', '#55386e', '#0f5c8c', '#8c4a0f'],
+ onChange: (v) => { state.render.outlineColor = v; app.actions.refresh('render'); },
+ }));
+ displaySection.add(colorField({
+ label: '紙の色', value: '#ffffff',
+ swatches: ['#ffffff', '#fdf8ee', '#f4f0fb'],
+ onChange: (v) => { state.render.paper = v; app.actions.refresh('render'); },
+ }));
+ const leafBodyLineCheck = check({
+ label: '葉っぱと体の境目に線を出す', value: true,
+ onChange: (v) => { state.render.leafBodyLine = v; app.actions.refresh('render'); },
+ title: '線画では体も葉っぱも紙なので、切ると葉っぱと体の区別がつかなくなります。'
+ + '「葉が体にめり込んで見える」のが気になるときに切ってください',
+ });
+ displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true }));
+ displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。'));
+ displaySection.add(buttons({
+ items: [{
+ id: 'resetStyle',
+ label: 'プリセットのスタイルに戻す',
+ onClick: () => replace({
+ render: {
+ style: 'real', outline: true, outlineWidth: 0.022, outlinePixels: 2,
+ outlineColor: '#2a1e33', paper: '#ffffff', leafBodyLine: true,
+ },
+ }, 'render'),
+ }],
+ }));
+
+ /* ------------------------------------------------------------- 帽子 */
+
+ const hatSection = section(tabView, '帽子');
+ hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。'));
+ const hatSegment = segmented({
+ label: '帽子',
+ options: HAT_LIBRARY.map((hat) => ({ value: hat.id, label: hat.label })),
+ value: 'none',
+ onChange: (value) => {
+ state.face.hat.kind = value;
+ // Picking one of the built-in hats (including なし) also clears any GLB hat
+ // the user imported, so なし really removes the hat.
+ state.face.hat.custom = false;
+ app.actions.refresh('all');
+ },
+ });
+ hatSection.add(hatSegment.el);
+ syncers.push(() => hatSegment.set(state.face.hat?.custom === true ? 'none' : (state.face.hat?.kind ?? 'none')));
+
+ // 帽子モデル(GLB)の取り込み。頭に乗るよう自動で位置と大きさを合わせる。
+ const hatFilePicker = h('input', { type: 'file', accept: '.glb,.gltf,model/gltf-binary,model/gltf+json', style: { display: 'none' } });
+ hatFilePicker.addEventListener('change', async () => {
+ const file = hatFilePicker.files?.[0];
+ hatFilePicker.value = '';
+ if (!file) return;
+ try {
+ await app.actions.loadHatModel(file);
+ toast(`帽子「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('帽子モデルを読み込めませんでした(GLBをお使いください)');
+ }
+ });
+ hatSection.add(hatFilePicker);
+ hatSection.add(buttons({
+ items: [{ id: 'hatfile', label: '帽子モデルを読み込む(GLB)', onClick: () => hatFilePicker.click() }],
+ }));
+ hatSection.add(hint('GLBの帽子を頭に乗せます。大きさと位置は自動で合わせます。'));
+
+ const hatBox = details(hatSection.body, '位置と傾き');
+ const hatSliders = {
+ height: slider({
+ label: '高さ', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.height = v; app.actions.refresh('all'); },
+ }),
+ x: slider({
+ label: 'よこ位置', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.x = v; app.actions.refresh('all'); },
+ }),
+ z: slider({
+ label: 'おくゆき', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.z = v; app.actions.refresh('all'); },
+ }),
+ tiltX: slider({
+ label: '前後の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltX = v; app.actions.refresh('all'); },
+ }),
+ tiltZ: slider({
+ label: '左右の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltZ = v; app.actions.refresh('all'); },
+ }),
+ };
+ for (const widget of Object.values(hatSliders)) hatBox.add(widget.el);
+ hatBox.add(hint('「高さ」「よこ位置」「おくゆき」で帽子を動かし、「前後の傾き」「左右の傾き」で斜めにかぶせられます。'));
+ syncers.push(() => {
+ hatSliders.height.set(state.face.hat?.height ?? 0);
+ hatSliders.x.set(state.face.hat?.x ?? 0);
+ hatSliders.z.set(state.face.hat?.z ?? 0);
+ hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0);
+ hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0);
+ });
+
+ /* ------------------------------------------------------------- body colour */
+
+ const colourSection = section(tabView, '体の色');
+ const themeSegment = segmented({
+ label: 'テーマ',
+ options: THEMES.map((theme) => ({ value: theme.id, label: theme.label })),
+ value: 'original',
+ onChange: (id) => { app.actions.applyTheme(id); sync(); },
+ });
+ colourSection.add(themeSegment.el);
+ colourSection.add(hint('テーマは出発点です。選んだあとでパーツごとに色を変えられます。'));
+
+ /** Write one part colour, creating `colors` if an old settings file lacked it. */
+ const setBodyColor = (part, value) => {
+ state.render.colors = state.render.colors ?? {};
+ state.render.colors[part] = value;
+ app.actions.refresh('render');
+ };
+ const bodyColors = {
+ body: colorField({ label: '体', value: '#c8b0f0', onChange: (v) => setBodyColor('body', v) }),
+ accent: colorField({ label: '手', value: '#8a4fe0', onChange: (v) => setBodyColor('accent', v) }),
+ nose: colorField({ label: '鼻', value: '#7a4fb0', onChange: (v) => setBodyColor('nose', v) }),
+ leaf: colorField({ label: '葉', value: '#a6dd6a', onChange: (v) => setBodyColor('leaf', v) }),
+ vein: colorField({ label: '葉脈', value: '#7fbf3f', onChange: (v) => setBodyColor('vein', v) }),
+ feet: colorField({ label: '足', value: '#7a4fb0', onChange: (v) => setBodyColor('feet', v) }),
+ };
+ for (const widget of Object.values(bodyColors)) colourSection.add(widget.el);
+
+ const envIntensitySlider = slider({
+ label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); },
+ });
+ colourSection.add(envIntensitySlider.el);
+ colourSection.add(buttons({
+ items: [{
+ id: 'resetColors',
+ label: 'プリセットの色に戻す',
+ onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); },
+ }],
+ }));
+
+ /* -------------------------------------------------------------------- look */
+
+ const lookSection = section(tabView, '見た目の調整');
+ const mirrorToggle = check({
+ label: '左右反転',
+ value: false,
+ onChange: (value) => { state.render.mirror = value; app.actions.refresh('render'); sync(); },
+ });
+ lookSection.add(controlRow(null, mirrorToggle.el, { wide: true }));
+ lookSection.add(hint('体だけでなく、目や口の位置までふくめて反転します。文字と並べるときの向きを決めるときに使います。'));
+
+ const shadowCheck = check({
+ label: '影を落とす',
+ value: true,
+ onChange: (value) => { state.render.shadow = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, shadowCheck.el, { wide: true }));
+ const shadowOpacitySlider = slider({
+ label: '濃さ', min: 0, max: 0.6, step: 0.01, value: 0.22, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.shadowOpacity = v; app.actions.refresh('render'); },
+ });
+ const shadowSoftnessSlider = slider({
+ label: 'ぼかし', min: 0, max: 3, step: 0.05, value: 1.6, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.shadowSoftness = v; app.actions.refresh('render'); },
+ });
+ lookSection.add(shadowOpacitySlider.el);
+ lookSection.add(shadowSoftnessSlider.el);
+ const contactShadowToggle = check({
+ label: '接地影だけ',
+ value: false,
+ onChange: (value) => { state.render.contactShadow = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, contactShadowToggle.el, { wide: true }));
+ const contactBlobToggle = check({
+ label: '足もとの丸い影',
+ value: false,
+ onChange: (value) => { state.render.contactBlob = value; app.actions.refresh('render'); },
+ });
+ lookSection.add(controlRow(null, contactBlobToggle.el, { wide: true }));
+ lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。'
+ + '「接地影だけ」は足もとの丸い影だけにします。'
+ + '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。'));
+
+ const environmentSegment = segmented({
+ label: '照明の種類',
+ options: ENVIRONMENTS.map((env) => ({ value: env.value, label: env.label })),
+ value: 'gradient',
+ onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); },
+ });
+ // 照明の種類は「シーン」タブの「画面・光」に置く(環境の映りこみの元を選ぶものなので)。
+ lookSection.add(buttons({
+ items: [{
+ id: 'resetLook',
+ label: 'プリセットに戻す',
+ onClick: () => replace({
+ render: {
+ mirror: false, shadow: true, shadowOpacity: 0.22, shadowSoftness: 1.6,
+ contactShadow: false, contactBlob: false, environment: 'gradient', envIntensity: 1,
+ },
+ }, 'render'),
+ }],
+ }));
+
+ /* -------------------------------------------------------------- background */
+
+ const backdropSection = section(tabScene, '背景');
+ // 素材 is one button for two state values (a photo or an effect line). This
+ // remembers which of the two was picked last, so leaving 素材 and coming back
+ // returns to it instead of always jumping to the photos.
+ let lastMaterial = 'preset';
+ const backgroundModeSegment = segmented({
+ label: '背景',
+ options: [
+ { value: 'solid', label: '単色' },
+ { value: 'transparent', label: '透明' },
+ { value: 'material', label: '素材' },
+ { value: 'image', label: '自分の画像' },
+ { value: 'camera', label: 'カメラ=AR' },
+ ],
+ value: 'solid',
+ onChange: (value) => {
+ if (value === 'camera') {
+ // The action owns the stream, so let it decide and re-read the state after.
+ Promise.resolve(app.actions.toggleCamera(true)).then(() => sync());
+ return;
+ }
+ if (state.view.background === 'camera') app.actions.toggleCamera(false);
+ state.view.background = value === 'material' ? lastMaterial : value;
+ app.actions.refresh('view');
+ sync();
+ },
+ });
+ backdropSection.add(backgroundModeSegment.el);
+ // 背景の色 only shows in the 単色 mode; it used to live in the スタイル section.
+ const backgroundColour = colorField({
+ label: '背景の色', value: '#ffffff',
+ swatches: ['#ffffff', '#f4f0fb', '#d9f0ff', '#fff0f4', '#1b1230'],
+ onChange: (v) => { state.view.backgroundColor = v; app.actions.refresh('view'); },
+ });
+ backdropSection.add(backgroundColour.el);
+
+ const photoButtons = buttons({
+ label: '写真',
+ items: BACKGROUND_PRESETS.map((preset) => ({
+ id: preset.name,
+ label: preset.label,
+ onClick: () => {
+ state.view.backgroundPreset = preset.name;
+ lastMaterial = 'preset';
+ state.view.background = 'preset';
+ app.actions.refresh('view');
+ sync();
+ },
+ })),
+ });
+
+ const effectButtons = buttons({
+ label: '効果線(まんが)',
+ items: EFFECT_PRESETS.map((effect) => ({
+ id: `effect-${effect.name}`,
+ label: effect.label,
+ onClick: () => {
+ state.view.backgroundEffect = effect.name;
+ lastMaterial = 'effect';
+ state.view.background = 'effect';
+ app.actions.refresh('view');
+ sync();
+ },
+ })),
+ });
+
+ // The two rows are shown only while 素材 is the active mode (see the syncer
+ // below); the border groups them so they read as one choice, not two.
+ const materialGroup = h('div', {
+ class: 'material-group',
+ style: { display: 'none', paddingLeft: '8px', borderLeft: '2px solid #e4dff0' },
+ }, photoButtons.el, effectButtons.el, hint('効果線はアプリが描いています(画像ファイルなし・ネット不要)。集中線=放射、落ち込み線=上から下、疾走線=左右です。'));
+ backdropSection.add(materialGroup);
+
+ const backgroundImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
+ backgroundImagePicker.addEventListener('change', async () => {
+ const file = backgroundImagePicker.files?.[0];
+ backgroundImagePicker.value = '';
+ if (!file) return;
+ try {
+ const dataUrl = await app.backdrop.loadImageFile(file);
+ state.view.backgroundImage = dataUrl;
+ state.view.background = 'image';
+ app.actions.refresh('view');
+ sync();
+ toast(`背景の画像「${file.name}」を読み込みました`);
+ } catch (error) {
+ console.error(error);
+ toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
+ }
+ });
+ backdropSection.add(backgroundImagePicker);
+ backdropSection.add(buttons({
+ items: [{ id: 'bgimg', label: '画像を読み込む', onClick: () => { if (confirmImageLicense()) backgroundImagePicker.click(); } }],
+ }));
+
+ const backgroundFitSegment = segmented({
+ label: '合わせ方',
+ options: [
+ { value: 'cover', label: '全体' },
+ { value: 'contain', label: '収める' },
+ { value: 'stretch', label: '伸ばす' },
+ { value: 'tile', label: '並べる' },
+ ],
+ value: 'cover',
+ onChange: (value) => { state.view.backgroundFit = value; app.actions.refresh('view'); },
+ });
+ const backgroundBlurSlider = slider({
+ label: 'ぼかし', min: 0, max: 20, step: 0.5, value: 0, format: (v) => `${v.toFixed(1)}px`,
+ onInput: (v) => { state.view.backgroundBlur = v; app.actions.refresh('view'); },
+ });
+ const backgroundDarkenSlider = slider({
+ label: '暗さ', min: 0, max: 0.8, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.backgroundDarken = v; app.actions.refresh('view'); },
+ });
+ const backgroundScaleSlider = slider({
+ label: '大きさ', min: 0.5, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.backgroundScale = v; app.actions.refresh('view'); },
+ });
+ backdropSection.add(backgroundFitSegment.el);
+ backdropSection.add(backgroundBlurSlider.el);
+ backdropSection.add(backgroundDarkenSlider.el);
+ backdropSection.add(backgroundScaleSlider.el);
+
+ const cameraFacingSegment = segmented({
+ label: 'カメラ',
+ options: [
+ { value: 'user', label: '前' },
+ { value: 'environment', label: '後ろ' },
+ ],
+ value: 'environment',
+ onChange: (value) => {
+ state.view.cameraFacing = value;
+ // Only a live stream has to be restarted on the other lens.
+ if (state.view.background === 'camera') {
+ Promise.resolve(app.actions.toggleCamera(true)).then(() => sync());
+ return;
+ }
+ app.actions.refresh('view');
+ },
+ });
+ const cameraMirrorToggle = check({
+ label: '左右反転',
+ value: false,
+ onChange: (value) => { state.view.cameraMirror = value; app.actions.refresh('view'); },
+ });
+ backdropSection.add(cameraFacingSegment.el);
+ backdropSection.add(controlRow(null, cameraMirrorToggle.el, { wide: true }));
+
+ // A gyro reading is a delta from the pose the device held when the switch was
+ // turned on, so the camera does not jump when it starts listening.
+ let gyroBase = null;
+ let gyroOff = null;
+ const gyroToggle = check({
+ label: '端末を傾けて見る',
+ value: false,
+ onChange: (value) => {
+ state.view.gyro = value;
+ if (!value) {
+ gyroBase = null;
+ if (gyroOff) { gyroOff(); gyroOff = null; }
+ return;
+ }
+ gyroBase = { azimuth: Number(state.view.azimuth) || 0, polar: Number(state.view.polar) || 76 };
+ app.backdrop?.requestGyro?.();
+ gyroOff = gyroOff ?? app.backdrop?.onGyro?.((reading) => {
+ if (!gyroBase) return;
+ const yaw = reading?.yaw ?? 0;
+ const pitch = reading?.pitch ?? 0;
+ state.view.azimuth = Math.max(-180, Math.min(180, gyroBase.azimuth - yaw));
+ state.view.polar = Math.max(1, Math.min(179, gyroBase.polar - pitch));
+ app.actions.refresh('view');
+ }) ?? null;
+ },
+ });
+ backdropSection.add(controlRow(null, gyroToggle.el, { wide: true }));
+ backdropSection.add(hint('カメラ(AR)を使うには、https のサイトか localhost で開いてください。http のLANアドレスでは、どのブラウザでもカメラは使えません。使えるときは、URLバーのアイコンを「許可」にし、端末の設定でもアプリにカメラを許可してください。'));
+ backdropSection.add(buttons({
+ items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }],
+ }));
+
+ /* ------------------------------------------------------------------- props */
+
+ // 小物: the things you put the character *among*. Each stage feature gets its
+ // own top-level section rather than one shared 舞台 drawer, so they are easier
+ // to find and open independently.
+ const propSection = section(tabScene, '小物');
+ propSection.add(buttons({
+ label: '追加',
+ items: PROP_LIBRARY.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ onClick: () => { app.actions.addProp(entry.id); sync(); },
+ })),
+ }));
+ propSection.add(hint('小物はキャラクターのまわりに置かれ、背景と同じくそのまま写真に写ります。'
+ + 'ビューポートで小物をドラッグすると移動できます(数値でも動かせます)。'));
+ const propListBox = h('div', { class: 'prop-list' });
+ propSection.add(controlRow(null, propListBox, { wide: true }));
+
+ const propWidgets = [];
+ let propShape = '';
+
+ /**
+ * Rebuild the list only when the *set* of props changes: a rebuild mid-drag
+ * would replace the slider the user is holding. The handlers read the item by
+ * index rather than keeping a reference, so a state swap (undo, load) is safe.
+ */
+ const renderProps = () => {
+ const items = state.props?.items ?? [];
+ const shape = items.map((item) => item.kind).join('|');
+ if (shape === propShape && propWidgets.length === items.length) return;
+ propShape = shape;
+ propWidgets.length = 0;
+ propListBox.replaceChildren();
+ items.forEach((item, index) => {
+ const label = PROP_LIBRARY.find((entry) => entry.id === item.kind)?.label ?? item.kind;
+ const block = h('div', {
+ class: 'prop-item',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' },
+ }, subhead(items.length > 1 ? `${label} ${index + 1}` : label));
+ const patch = (key) => (value) => {
+ const target = state.props?.items?.[index];
+ if (target) target[key] = value;
+ app.actions.refresh('render');
+ sync();
+ };
+ const sliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }),
+ y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }),
+ z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }),
+ rotX: slider({ label: '前後の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotX') }),
+ rotY: slider({ label: '回転(よこ)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }),
+ rotZ: slider({ label: '左右の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotZ') }),
+ scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }),
+ };
+ // The numbers are only a fallback for the drag, so they stay folded away.
+ const numbers = details(block, '数値で調整');
+ for (const widget of Object.values(sliders)) numbers.add(widget.el);
+ // 看板 only: the writing area (face *and* frame) can be enlarged on its
+ // own, so the posts and base stay put. The app rebuilds the props from the
+ // state and carries only the whole-prop scale, so the props syncer
+ // re-applies this to the fresh mesh (see `applyPropFaceScale`).
+ let faceScale = null;
+ let signText = null;
+ if (item.kind === 'sign') {
+ signText = textArea({
+ label: '看板の文字(改行できます)',
+ value: '',
+ onChange: (value) => {
+ const target = state.props?.items?.[index];
+ if (target) target.text = value;
+ // Paint straight onto the live face rather than rebuilding every prop
+ // on each keystroke; a later rebuild re-applies it (see main.js).
+ applyPropText(placedPropGroups(app)[index], value);
+ app.needsRender = true;
+ },
+ });
+ block.insertBefore(signText.el, numbers.el);
+ faceScale = slider({
+ label: '文字部分の大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1,
+ format: (v) => v.toFixed(2), onInput: patch('faceScale'),
+ });
+ block.insertBefore(faceScale.el, numbers.el);
+ }
+ block.append(buttons({
+ items: [{
+ id: 'remove',
+ label: '削除',
+ onClick: () => {
+ state.props = state.props ?? { items: [] };
+ state.props.items = (state.props.items ?? []).filter((_, i) => i !== index);
+ app.actions.refresh('render');
+ renderProps();
+ sync();
+ },
+ }],
+ }).el);
+ propListBox.append(block);
+ propWidgets.push({ index, sliders, faceScale, signText });
+ });
+ };
+ renderProps();
+
+ /* ------------------------------------------------------------- 擬音 */
+
+ // 擬音: manga onomatopoeia as stickers over the picture. The sheets are dense,
+ // so the user picks the crop with a marquee instead of the app slicing them
+ // (see src/gion.js for why). Only the sheet name and the crop rectangle live in
+ // the state - never the bitmap - so a shared link stays small.
+ const gionSection = section(tabScene, '擬音(マンガのオノマトペ)');
+ gionSection.add(hint('擬音(ドドド、バン!など)の画像から、四角く切り出したスタンプを画面に貼れます。'
+ + '「擬音をえらぶ」で画像を開き、ドラッグで四角を描いて「追加」を押してください。'
+ + '貼ったあとはビューポートでドラッグして動かせます。'));
+
+ const gionSheetSelect = selectField({
+ label: 'シート',
+ options: GION_SHEETS.map((sheet) => ({ value: sheet.name, label: sheet.label })),
+ value: GION_SHEETS[0]?.name ?? '',
+ onChange: () => {},
+ });
+ if (GION_SHEETS.length) {
+ gionSection.add(gionSheetSelect.el);
+ gionSection.add(buttons({
+ items: [{
+ id: 'pick',
+ label: '擬音をえらぶ',
+ primary: true,
+ onClick: () => openGionPicker(gionSheetSelect.get()),
+ }],
+ }));
+ } else {
+ // No sheet ships with the studio (see src/gion.js), so there is nothing to
+ // pick yet - the section stays, but only with a note on how to add one.
+ gionSection.add(hint('擬音の画像がまだありません。assets/manga-gion/ に画像を置き、'
+ + 'src/gion.js の GION_SHEETS に登録すると使えます。'));
+ }
+
+ const gionListBox = h('div', { class: 'gion-list' });
+ gionSection.add(controlRow(null, gionListBox, { wide: true }));
+
+ let gionShape = '';
+ const gionWidgets = [];
+
+ /** Select a stamp: the viewport outlines it and the list highlights it. */
+ const selectGion = (id) => {
+ app.gionSelected = id ?? null;
+ app.actions.refresh('gion');
+ sync();
+ };
+
+ /**
+ * A little canvas preview of a stamp's crop, drawn from the sheet once it has
+ * loaded. The sheet itself is only fetched when a stamp first needs it.
+ */
+ const gionPreviews = new Map();
+ function gionPreview(item) {
+ const canvas = h('canvas', { class: 'gion-thumb', width: 84, height: 56 });
+ const draw = (image) => {
+ if (!image?.naturalWidth) return;
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ const scale = Math.min(canvas.width / item.sw, canvas.height / item.sh);
+ const w = item.sw * scale;
+ const h = item.sh * scale;
+ ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh,
+ (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
+ };
+ const known = gionPreviews.get(item.sheet);
+ if (known) {
+ draw(known);
+ } else {
+ const image = new Image();
+ image.onload = () => draw(image);
+ image.src = gionSheetUrl(item.sheet);
+ gionPreviews.set(item.sheet, image);
+ }
+ return canvas;
+ }
+
+ /** Rebuild the list only when the *set* of stamps changes (see `renderProps`). */
+ const renderGion = () => {
+ const items = state.gion?.items ?? [];
+ const shape = items
+ .map((item) => `${item.id}:${item.sheet}:${item.sx},${item.sy},${item.sw},${item.sh}`)
+ .join('|');
+ if (shape === gionShape && gionWidgets.length === items.length) return;
+ gionShape = shape;
+ gionWidgets.length = 0;
+ gionListBox.replaceChildren();
+ if (!items.some((item) => item.id === app.gionSelected)) app.gionSelected = null;
+ if (items.length === 0) {
+ gionListBox.append(h('p', { class: 'hint', text: 'まだ擬音は貼られていません。' }));
+ return;
+ }
+ items.forEach((item, index) => {
+ const label = GION_SHEETS.find((sheet) => sheet.name === item.sheet)?.label ?? item.sheet;
+ const block = h('div', {
+ class: 'gion-item',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' },
+ });
+ const preview = gionPreview(item);
+ preview.title = 'クリックで選択';
+ preview.addEventListener('click', () => selectGion(item.id));
+ const head = h('div', { class: 'gion-item-head' }, preview,
+ subhead(`擬音 ${index + 1}(${label})`));
+ block.append(head);
+
+ const patch = (key) => (value) => {
+ const target = state.gion?.items?.[index];
+ if (target) target[key] = value;
+ app.actions.refresh('gion');
+ sync();
+ };
+ const sliders = {
+ w: slider({
+ label: '大きさ', min: 40, max: 900, step: 1, value: DEFAULT_STAMP_WIDTH,
+ format: (v) => `${Math.round(v)}px`, onInput: patch('w'),
+ }),
+ rot: slider({
+ label: '回転', min: -180, max: 180, step: 1, value: 0,
+ format: (v) => `${Math.round(v)}°`, onInput: patch('rot'),
+ }),
+ };
+ const flip = check({ label: '左右反転', value: false, onChange: patch('flip') });
+ block.append(sliders.w.el, sliders.rot.el, controlRow(null, flip.el, { wide: true }));
+ block.append(buttons({
+ items: [
+ { id: 'select', label: 'この擬音を選ぶ', onClick: () => selectGion(item.id) },
+ {
+ id: 'remove',
+ label: '削除',
+ onClick: () => {
+ state.gion = state.gion ?? { items: [] };
+ state.gion.items = (state.gion.items ?? []).filter((_, i) => i !== index);
+ if (app.gionSelected === item.id) app.gionSelected = null;
+ app.actions.refresh('gion');
+ renderGion();
+ sync();
+ },
+ },
+ ],
+ }).el);
+ gionListBox.append(block);
+ gionWidgets.push({ id: item.id, sliders, flip, block });
+ });
+ };
+ renderGion();
+
+ /**
+ * The picker: a full-screen overlay showing the whole sheet, on which the user
+ * drags a rectangle. "追加" turns that rectangle into a stamp in the middle of
+ * the picture; Esc or キャンセル closes without adding anything.
+ */
+ const openGionPicker = (sheetName) => {
+ const sheet = GION_SHEETS.find((entry) => entry.name === sheetName) ?? GION_SHEETS[0];
+ if (!sheet) { toast('擬音のシートがありません'); return; }
+
+ const image = h('img', { class: 'gion-picker-sheet', alt: sheet.label, src: gionSheetUrl(sheet.name) });
+ const marquee = h('div', { class: 'gion-picker-marquee' });
+ const board = h('div', { class: 'gion-picker-board' }, image, marquee);
+ const addButton = h('button', { type: 'button', class: 'btn primary', text: '追加', disabled: true });
+ const cancelButton = h('button', { type: 'button', class: 'btn', text: 'キャンセル' });
+ const overlay = h('div', { class: 'gion-picker' },
+ h('div', { class: 'gion-picker-bar' },
+ h('div', { class: 'gion-picker-title', text: `「${sheet.label}」から擬音をえらぶ` }),
+ h('div', { class: 'gion-picker-actions' },
+ h('span', { class: 'hint', text: 'ドラッグで四角を描く/Escで閉じる' }),
+ addButton,
+ cancelButton)),
+ board);
+
+ /** Where the sheet is currently displayed, in board pixels. */
+ let sheetRect = null;
+ /** The marquee the user is drawing, or the last one drawn. */
+ let current = null;
+ let start = null;
+
+ const layoutSheet = () => {
+ if (!image.naturalWidth) return;
+ const rect = fitSheet(image, { width: board.clientWidth, height: board.clientHeight }, { padding: 12 });
+ image.style.left = `${rect.x}px`;
+ image.style.top = `${rect.y}px`;
+ image.style.width = `${rect.w}px`;
+ image.style.height = `${rect.h}px`;
+ sheetRect = rect;
+ };
+
+ const updateAdd = () => {
+ addButton.disabled = !(current && current.w >= 6 && current.h >= 6 && sheetRect);
+ };
+
+ const drawMarquee = () => {
+ if (!current) { marquee.style.display = 'none'; return; }
+ marquee.style.display = 'block';
+ marquee.style.left = `${current.x}px`;
+ marquee.style.top = `${current.y}px`;
+ marquee.style.width = `${current.w}px`;
+ marquee.style.height = `${current.h}px`;
+ };
+
+ const pointIn = (event, bounds) => ({
+ x: clamp(event.clientX - bounds.left, 0, bounds.width),
+ y: clamp(event.clientY - bounds.top, 0, bounds.height),
+ });
+
+ board.addEventListener('pointerdown', (event) => {
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
+ start = pointIn(event, board.getBoundingClientRect());
+ current = null;
+ drawMarquee();
+ updateAdd();
+ try { board.setPointerCapture(event.pointerId); } catch { /* a nicety, not a need */ }
+ });
+ board.addEventListener('pointermove', (event) => {
+ if (!start) return;
+ current = normalizeRect(start, pointIn(event, board.getBoundingClientRect()));
+ drawMarquee();
+ updateAdd();
+ });
+ const endMarquee = (event) => {
+ if (!start) return;
+ start = null;
+ try { if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId); } catch { /* done anyway */ }
+ updateAdd();
+ };
+ board.addEventListener('pointerup', endMarquee);
+ board.addEventListener('pointercancel', endMarquee);
+ image.addEventListener('load', () => { layoutSheet(); updateAdd(); });
+
+ addButton.addEventListener('click', () => {
+ if (!current || !sheetRect) return;
+ const crop = cropFromMarquee(current, sheetRect, image);
+ app.actions.addGionStamp({ sheet: sheet.name, ...crop });
+ close();
+ });
+ cancelButton.addEventListener('click', () => close());
+ overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); });
+
+ const onKey = (event) => {
+ if (event.key !== 'Escape') return;
+ event.preventDefault();
+ close();
+ };
+ const onResize = () => layoutSheet();
+
+ let closed = false;
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ document.removeEventListener('keydown', onKey);
+ window.removeEventListener('resize', onResize);
+ overlay.remove();
+ };
+
+ document.addEventListener('keydown', onKey);
+ window.addEventListener('resize', onResize);
+ document.body.append(overlay);
+ // A cached sheet is already loaded, so the `load` event never fires for it.
+ if (image.complete && image.naturalWidth) { layoutSheet(); updateAdd(); }
+ };
+
+ /* ------------------------------------------------------------- 見えない壁 */
+
+ // 見えない壁: place up to two finite "boards" and bury the character in them, so
+ // only the part in front still shows. A wall's position is kept *relative to the
+ // character*, so moving ぶるべー carries its walls along (see src/clip.js).
+ const wallSection = section(tabScene, '見えない壁');
+ wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。'
+ + 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。'
+ + '板は2枚まで置けます。板の位置はぶるべーからの相対なので、ぶるべーを動かすと壁も一緒に動きます。'
+ + '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。'));
+
+ const WALL_SLOTS = [
+ { key: 'wall', label: '壁 1' },
+ { key: 'wall2', label: '壁 2' },
+ ];
+
+ const wallGroups = WALL_SLOTS.map(({ key, label }) => {
+ const box = details(wallSection.body, label);
+ const cfg = () => (state.render[key] = state.render[key] ?? defaultState().render[key]);
+ const toggle = check({
+ label: `${label}で一部を隠す`,
+ value: false,
+ onChange: (value) => { cfg().on = value; app.actions.refresh('render'); sync(); },
+ });
+ box.add(controlRow(null, toggle.el, { wide: true }));
+ const sliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().x = v; app.actions.refresh('render'); sync(); } }),
+ y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().y = v; app.actions.refresh('render'); sync(); } }),
+ z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().z = v; app.actions.refresh('render'); sync(); } }),
+ yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().yaw = v; app.actions.refresh('render'); sync(); } }),
+ tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().tilt = v; app.actions.refresh('render'); sync(); } }),
+ size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { cfg().size = v; app.actions.refresh('render'); sync(); } }),
+ };
+ for (const widget of Object.values(sliders)) box.add(widget.el);
+
+ // The wall writes depth but no colour, so it hides what is behind it without
+ // being visible. The guide is the tinted copy of that same rectangle, shown
+ // only while placing - and it is also the switch that says "I am placing the
+ // wall now", so with it off the wall cannot swallow an orbit drag.
+ const guide = check({
+ label: '板を表示(ビューポートでドラッグして動かせます)',
+ value: false,
+ onChange: (value) => { cfg().guide = value; app.actions.refresh('render'); sync(); },
+ });
+ box.add(controlRow(null, guide.el, { wide: true }));
+ return { key, label, toggle, sliders, guide };
+ });
+
+ wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**'
+ + '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。'
+ + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。'
+ + '「板を表示」した板は、ビューポートでドラッグしても動かせます。'));
+ wallSection.add(buttons({
+ items: [{
+ id: 'wallReset',
+ label: '見えない壁をリセット',
+ onClick: () => {
+ state.render.wall = defaultState().render.wall;
+ state.render.wall2 = defaultState().render.wall2;
+ app.actions.refresh('render');
+ sync();
+ },
+ }],
+ }));
+
+ /* ------------------------------------------------------------------ light */
+
+ // 画面・光: the light rig and the camera, which together decide how the picture
+ // is lit and framed.
+ const screenLightSection = section(tabScene, '画面・光');
+ const lightSection = details(screenLightSection.body, 'ひかり');
+ const lightPad = xyPad({
+ value: { x: -0.2, y: 0.45 },
+ onChange: ({ x, y }) => {
+ state.render.lightAzimuth = x * 180;
+ state.render.lightElevation = 5 + Math.max(0, y) * 80;
+ app.actions.refresh('render');
+ },
+ });
+ lightSection.add(controlRow('光の向き', h('div', { class: 'pad-wrap' },
+ lightPad.el,
+ h('p', { class: 'hint', text: 'ドラッグで光源の向きを変えられます。' }))));
+ const lightIntensitySlider = slider({
+ label: '明るさ', min: 0, max: 5, step: 0.05, value: 2.1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.lightIntensity = v; app.actions.refresh('render'); },
+ });
+ const ambientSlider = slider({
+ label: '環境光', min: 0, max: 3, step: 0.05, value: 0.9, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.ambient = v; app.actions.refresh('render'); },
+ });
+ const exposureSlider = slider({
+ label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); },
+ });
+ const lightColorField = colorField({
+ label: '光の色', value: '#ffffff',
+ swatches: ['#ffffff', '#fff3d6', '#d6e8ff', '#ffd9c9', '#e6dcff'],
+ onChange: (v) => { state.render.lightColor = v; app.actions.refresh('render'); },
+ });
+ const ambientColorField = colorField({
+ label: '環境光の色', value: '#ffffff',
+ swatches: ['#ffffff', '#d9d0f2', '#cfe6ff', '#ffe6cc', '#e8ffe0'],
+ onChange: (v) => { state.render.ambientColor = v; app.actions.refresh('render'); },
+ });
+ lightSection.add(environmentSegment.el);
+ lightSection.add(lightIntensitySlider.el);
+ lightSection.add(ambientSlider.el);
+ lightSection.add(lightColorField.el);
+ lightSection.add(ambientColorField.el);
+ lightSection.add(exposureSlider.el);
+ lightSection.add(hint('「照明の種類」は環境の映りこみの元(グラデーション/部屋/なし)です。'
+ + '「光の色」でライトの色、「環境光の色」で影側の色を変えられます。'
+ + 'テクスチャ(環境)の映りこみの強さは「見た目」タブの「環境の映りこみ」で調整できます。'));
+
+ /* ----------------------------------------------------------------- camera */
+
+ const cameraSection = details(screenLightSection.body, 'カメラ');
+
+ const projectionSegment = segmented({
+ label: '映し方',
+ options: [
+ { value: 'persp', label: '遠近あり' },
+ { value: 'ortho', label: '正投影' },
+ ],
+ value: 'persp',
+ onChange: (value) => replace({ view: { projection: value } }, 'view'),
+ });
+ cameraSection.add(projectionSegment.el);
+ cameraSection.add(hint('正投影にすると遠近のゆがみが消え、図面のような絵になります。'));
+
+ cameraSection.add(buttons({
+ label: '向き',
+ items: [
+ { id: 'front', label: '正面', onClick: () => app.actions.setCameraPreset('front') },
+ { id: 'threeQuarter', label: '斜め', onClick: () => app.actions.setCameraPreset('threeQuarter') },
+ { id: 'side', label: '横', onClick: () => app.actions.setCameraPreset('side') },
+ { id: 'back', label: '後ろ', onClick: () => app.actions.setCameraPreset('back') },
+ { id: 'top', label: '見下ろし', onClick: () => app.actions.setCameraPreset('top') },
+ ],
+ }));
+
+ const cameraSliders = {
+ azimuth: slider({
+ label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.view.azimuth = v; app.actions.refresh('view'); },
+ }),
+ polar: slider({
+ label: '高さ', min: 1, max: 179, step: 1, value: 76, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.view.polar = v; app.actions.refresh('view'); },
+ }),
+ size: slider({
+ label: '大きさ', min: 2, max: 200, step: 0.1, value: 12, format: (v) => v.toFixed(1),
+ onInput: (v) => {
+ if (state.view.projection === 'ortho') state.view.orthoHeight = v;
+ else state.view.distance = v;
+ app.actions.refresh('view');
+ },
+ }),
+ targetY: slider({
+ label: '見る高さ', min: 0, max: Math.max(2, model.size.y * 1.2), step: 0.05, value: model.size.y * 0.5, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.view.targetY = v; app.actions.refresh('view'); },
+ }),
+ };
+ for (const widget of Object.values(cameraSliders)) cameraSection.add(widget.el);
+
+ const autoRotateToggle = check({
+ label: '自動で回す', value: false,
+ onChange: (v) => { state.view.autoRotate = v; app.actions.refresh('view'); },
+ });
+ cameraSection.add(controlRow(null, autoRotateToggle.el, { wide: true }));
+ cameraSection.add(slider({
+ label: '回す速さ', min: 0.2, max: 4, step: 0.1, value: 0.8, format: (v) => v.toFixed(1),
+ onInput: (v) => { state.view.autoRotateSpeed = v; app.actions.refresh('view'); },
+ }));
+
+ /* ------------------------------------------------------------------ export */
+
+ const exportSection = section(tabExport, '画像');
+
+ exportSection.add(segmented({
+ label: 'PNGの大きさ',
+ options: [
+ { value: '1', label: '1倍' },
+ { value: '2', label: '2倍' },
+ { value: '3', label: '3倍' },
+ { value: '4', label: '4倍' },
+ ],
+ value: '2',
+ onChange: (value) => { state.render.pngScale = Number(value); },
+ }));
+ exportSection.add(buttons({
+ items: [
+ { id: 'png', label: 'PNGを保存', primary: true, onClick: () => app.actions.savePNG() },
+ { id: 'copy', label: 'クリップボードにコピー', onClick: () => app.actions.copyPNG() },
+ ],
+ }));
+ const transparentCheck = check({
+ label: 'PNGの背景を透明にする', value: false,
+ onChange: (v) => { state.render.pngTransparent = v; },
+ });
+ exportSection.add(transparentCheck.el);
+ exportSection.add(hint('線画のときに透明にすると、体の白ぬりも透明になって線だけが残ります(他の絵に重ねられます)。ふつう・フラットでは体の色はそのまま残ります。'));
+
+ // まんが(コマ)gets its own section: it is a different kind of output.
+ const comicSection = section(tabExport, 'まんが');
+ const storyScaleSegment = segmented({
+ label: '書き出しの大きさ',
+ options: [
+ { value: '1', label: '1×(軽い)' },
+ { value: '1.5', label: '1.5×' },
+ { value: '2', label: '2×(きれい)' },
+ ],
+ value: '1.5',
+ hint: 'コマ1枚の画素数は「いまの画面の大きさ × この倍率」です。'
+ + '2× だと台紙のPNGがとても大きくなります(重いときは 1× か 1.5×)',
+ onChange: (value) => { state.story.scale = Number(value); },
+ });
+ comicSection.add(storyScaleSegment.el);
+ const storyColumnsSlider = slider({
+ label: '列数', min: 1, max: 4, step: 1, value: 1, format: (v) => `${Math.round(v)}列`,
+ onInput: (v) => { state.story.columns = Math.round(v); },
+ });
+ const storyGapSlider = slider({
+ label: '間隔', min: 0, max: 40, step: 1, value: 12, format: (v) => `${Math.round(v)}px`,
+ onInput: (v) => { state.story.gap = v; },
+ });
+ const storyBackgroundField = colorField({
+ label: '台紙の色', value: '#ffffff',
+ onChange: (v) => { state.story.sheetBackground = v; },
+ });
+ comicSection.add(storyColumnsSlider.el);
+ comicSection.add(storyGapSlider.el);
+ comicSection.add(storyBackgroundField.el);
+ comicSection.add(buttons({
+ items: [
+ { id: 'addPanel', label: '今の状態をコマに追加', onClick: () => { app.actions.addStoryPanel(); sync(); } },
+ { id: 'saveStory', label: 'まんがを書き出す', primary: true, onClick: () => app.actions.saveStory({ scale: state.story?.scale ?? 1.5 }) },
+ ],
+ }));
+ comicSection.add(hint('2列以上で3コマ以上のときは、読む順にコーナーへ番号を付けます。'));
+ const storyPanelBox = h('div', { class: 'story-panels', style: { display: 'grid', gap: '6px' } });
+ comicSection.add(controlRow(null, storyPanelBox, { wide: true }));
+ let storyShape = '';
+
+ /** Bring back everything a panel saved: pose, face, both bubbles and the camera. */
+ const recallStoryPanel = (index) => {
+ const panel = state.story?.panels?.[index];
+ if (!panel) return;
+ // Replace the bones outright: a merged pose would keep bones the panel never had.
+ state.pose = { bones: {}, root: [0, 0, 0] };
+ app.actions.applyState({
+ pose: panel.pose ?? {},
+ face: panel.face ?? {},
+ caption: panel.caption ?? {},
+ caption2: panel.caption2 ?? {},
+ // The 擬音 on the shot. A panel saved before this existed clears them, so
+ // recalling it shows what it actually recorded rather than a leftover.
+ gion: panel.gion ?? { items: [] },
+ // The camera it was recorded with, so the view comes back too. A panel
+ // saved before this existed simply keeps the current camera.
+ ...(panel.view ? { view: panel.view } : {}),
+ }, { scope: 'all', sync: true });
+ };
+
+ const removeStoryPanel = (index) => {
+ state.story = state.story ?? {};
+ state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index);
+ // The empty patch is only here to route the edit through the app's own apply
+ // path: that is what records an undo step and re-reads the panel.
+ app.actions.applyState({}, { scope: 'caption', sync: true });
+ };
+
+ /**
+ * Same rebuild policy as the prop list: only when the set of panels changes.
+ *
+ * A preview arrives a moment *after* its panel does (it needs a render), so its
+ * version is part of the identity: when one turns up, the row is drawn again
+ * with it.
+ */
+ const renderStoryPanels = () => {
+ const panels = state.story?.panels ?? [];
+ const thumbs = app.actions.storyThumbs?.() ?? null;
+ const shape = `${panels.length}:${panels
+ .map((panel) => `${panel.id ?? ''}:${thumbs?.get?.(panel.id)?.version ?? 0}`)
+ .join('|')}`;
+ if (shape === storyShape) return;
+ storyShape = shape;
+ storyPanelBox.replaceChildren();
+ panels.forEach((panel, index) => {
+ const thumb = thumbs?.get?.(panel.id)?.url;
+ const preview = thumb
+ ? h('img', {
+ class: 'story-thumb',
+ alt: panel.label ?? `コマ${index + 1}`,
+ src: thumb,
+ title: 'クリックでこのコマを呼び出す',
+ style: {
+ width: '100%', height: '76px', objectFit: 'contain', cursor: 'pointer',
+ background: '#f3f0fa', border: '1px solid #e4dff0', borderRadius: '8px',
+ },
+ onClick: () => recallStoryPanel(index),
+ })
+ : h('div', {
+ text: '(プレビューなし)',
+ style: {
+ padding: '6px 8px', borderRadius: '8px', background: '#f7f5fd',
+ color: '#8a80a0', fontSize: '11px',
+ },
+ });
+ const block = h('div', {
+ class: 'story-panel',
+ style: { borderTop: '1px dashed #e4dff0', paddingTop: '6px', display: 'grid', gap: '4px' },
+ }, subhead(panel.label ?? `コマ${index + 1}`), preview);
+ block.append(buttons({
+ items: [
+ { id: 'recall', label: 'このコマを呼び出す', onClick: () => recallStoryPanel(index) },
+ { id: 'remove', label: '削除', onClick: () => removeStoryPanel(index) },
+ ],
+ }).el);
+ storyPanelBox.append(block);
+ });
+ };
+ renderStoryPanels();
+
+ // The share section comes after まんが, so the reading order is 画像 → まんが →
+ // 共有 (the order the outputs are usually wanted in).
+ /* ------------------------------------------------------------ extra export */
+
+ const extraExportSection = section(tabExport, '共有');
+ extraExportSection.add(buttons({
+ items: [
+ { id: 'share-image', label: '画像をシェア', primary: true, onClick: () => app.actions.shareImage() },
+ { id: 'share-x', label: 'Xで投稿', onClick: () => app.actions.shareImage('x') },
+ { id: 'share-fb', label: 'Facebookでシェア', onClick: () => app.actions.shareImage('facebook') },
+ { id: 'share-link', label: 'この見た目のリンクをコピー', onClick: () => app.actions.copyShareLink() },
+ ],
+ }));
+ extraExportSection.add(hint('いまの画面を画像にして「#ぶるべースタジオ」を付けて投稿します。'
+ + 'スマートフォンでは端末の共有画面が開きます。パソコンでは画像をコピーして投稿画面を開くので、貼り付けて投稿してください(Ctrl+V)。'));
+
+ // 手描きの顔パーツを描くための下地。画面を切り取る他の書き出しと違って、これは
+ // 「スタジオが読み込む大きさ・位置そのまま」の素材なので、別のセクションにして
+ // 共有のあとに置く(素材を作る → 見せる、の順)。
+ const faceMapSection = section(tabExport, 'テクスチャの下地を書き出す');
+ faceMapSection.add(buttons({
+ items: [
+ { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') },
+ { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') },
+ { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') },
+ ],
+ }));
+ faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。'
+ + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や'
+ + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。'
+ + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'
+ + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、'
+ + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、'
+ + '青いよこ線が既定の生え際です。'));
+
+ /* --------------------------------------------------------------- settings */
+
+ const settingsSection = section(tabExport, '設定');
+
+ settingsSection.add(buttons({
+ items: [
+ { id: 'save', label: '設定を保存(JSON)', onClick: () => app.actions.saveSettings() },
+ { id: 'load', label: '設定を読み込む', onClick: () => settingsPicker.click() },
+ ],
+ }));
+
+ const settingsPicker = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' } });
+ settingsPicker.addEventListener('change', async () => {
+ const file = settingsPicker.files?.[0];
+ settingsPicker.value = '';
+ if (!file) return;
+ try {
+ const text = await file.text();
+ app.actions.loadSettings(text);
+ } catch (error) {
+ console.error(error);
+ toast('設定ファイルを読み込めませんでした');
+ }
+ });
+ settingsSection.add(settingsPicker);
+
+ /* -------------------------------------------------------------------- sync */
+
+ // A tiny read-out of what the renderer is actually doing: if the picture ever
+ // looks wrong or never appears, this says why (buffer size, quality, fps).
+ const infoSection = section(tabScene, '画面の情報');
+ const statsLine = h('p', { class: 'hint', text: '計測中…' });
+ infoSection.add(statsLine);
+ infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。'));
+
+ // シーン: the scene controls are built wherever they belong in the code (the
+ // speech bubble sits with the face controls), so put them on the tab in the
+ // order the user meets them: what is behind the character, what is beside it,
+ // the speech bubble, then the light and the read-out.
+ tabScene.append(
+ backdropSection.el, propSection.el, gionSection.el, wallSection.el,
+ captionSection.el, screenLightSection.el, infoSection.el,
+ );
+
+ boneSyncers.push(() => {
+ const name = rig.selected;
+ for (const [key, button] of boneButtons) button.classList.toggle('active', key === name);
+ const delta = name ? rig.getDelta(name) : { x: 0, y: 0, z: 0 };
+ axisSliders.x.set(delta.x);
+ axisSliders.y.set(delta.y);
+ axisSliders.z.set(delta.z);
+ const root = state.pose.root ?? [0, 0, 0];
+ rootSliders.forEach((widget, index) => widget.set(root[index] ?? 0));
+ });
+
+ syncers.push(...boneSyncers);
+
+ syncers.push(() => {
+ const linked = state.face.eyes.linked !== false;
+ bothControls.group.setVisible(linked);
+ leftControls.group.setVisible(!linked);
+ rightControls.group.setVisible(!linked);
+ bothControls.sync();
+ leftControls.sync();
+ rightControls.sync();
+
+ for (const key of ['left', 'right']) {
+ const eye = state.face.eyes[key];
+ const w = eyeAdvancedWidgets[key];
+ w.eyeX.set(eye.eyeX ?? 0);
+ }
+
+ browToggle.set(state.face.eyes.brow.enabled);
+ browSliders.angle.set(state.face.eyes.brow.angle);
+ browSliders.height.set(state.face.eyes.brow.height);
+ browSliders.length.set(state.face.eyes.brow.length);
+ browSliders.thickness.set(state.face.eyes.brow.thickness);
+ browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0);
+ browSliders.curve.set(state.face.eyes.brow.curve);
+ browSliders.taper.set(state.face.eyes.brow.taper ?? 1);
+ // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather
+ // than leave them looking like they should do something.
+ const browIsImage = state.face.eyes.brow.image === true;
+ for (const key of ['angle', 'thickness', 'curve', 'taper']) {
+ setRowEnabled(browSliders[key], !browIsImage);
+ }
+ browColor.set(state.face.eyes.brow.color);
+ glassesToggle.set(state.face.eyes.glasses.enabled === true);
+ glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses');
+ for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]);
+ glassesFrameColor.set(state.face.eyes.glasses.frameColor);
+ glassesLensColor.set(state.face.eyes.glasses.lensColor);
+ eyeColors.white.set(state.face.eyes.white);
+ eyeColors.iris.set(state.face.eyes.iris);
+ eyeColors.line.set(state.face.eyes.line);
+ });
+
+ syncers.push(() => {
+ cheeksToggle.set(state.face.eyes.cheeks.enabled === true);
+ for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]);
+ cheekColor.set(state.face.eyes.cheeks.color);
+ headMarkShape.set(state.face.eyes.headMark.shape ?? 'off');
+ headMarkSliders.teeth.el.style.display = (state.face.eyes.headMark.shape === 'fringe') ? '' : 'none';
+ headMarkOverEyes.set(state.face.eyes.headMark.overEyes !== false);
+ for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]);
+ headMarkColor.set(state.face.eyes.headMark.color);
+ headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff');
+ snotToggle.set(state.face.eyes.snot.enabled === true);
+ snotSize.set(state.face.eyes.snot.size);
+ for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]);
+ snotColor.set(state.face.eyes.snot.color);
+ beardShape.set(state.face.eyes.beard.shape ?? 'off');
+ for (const [key, widget] of Object.entries(beardSliders)) widget.set(state.face.eyes.beard[key]);
+ // 「左右の間隔」は内部値より+10して表示する(既定の -10 が 0 に見える)。
+ beardSliders.spacing.set((state.face.eyes.beard.spacing ?? 0) + 10);
+ beardColor.set(state.face.eyes.beard.color);
+ });
+
+ syncers.push(() => {
+ mouthToggle.set(state.face.mouth.visible);
+ for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]);
+ for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]);
+ const mouthShape = mouthModeOf(state.face.mouth);
+ mouthMode.set(mouthShape);
+ const shown = new Set(mouthRowKeys[mouthShape]);
+ for (const key of mouthRowOrder) mouthSliders[key].el.style.display = shown.has(key) ? '' : 'none';
+ });
+
+ syncers.push(() => {
+ motionSegment.set(state.anim.mode ?? 'off');
+ animWidgets.blink.set(state.anim.blink);
+ animWidgets.lookAround.set(state.anim.lookAround);
+ });
+
+ syncers.push(() => {
+ styleSegment.set(state.render.style);
+ outlineToggle.set(state.render.outline);
+ outlinePixelsSlider.set(state.render.outlinePixels ?? 2);
+ leafBodyLineCheck.set(state.render.leafBodyLine !== false);
+ transparentCheck.set(state.render.pngTransparent);
+ lightPad.set({
+ x: state.render.lightAzimuth / 180,
+ y: Math.max(0, (state.render.lightElevation - 5) / 80),
+ });
+ lightIntensitySlider.set(state.render.lightIntensity ?? 2.1);
+ ambientSlider.set(state.render.ambient ?? 0.9);
+ lightColorField.set(state.render.lightColor ?? '#ffffff');
+ ambientColorField.set(state.render.ambientColor ?? '#ffffff');
+ exposureSlider.set(state.render.exposure ?? 1);
+ const line = ['lineart', 'outline'].includes(state.render.style);
+ outlineToggle.el.querySelector('input').disabled = line;
+ });
+
+ syncers.push(() => {
+ projectionSegment.set(state.view.projection);
+ cameraSliders.azimuth.set(state.view.azimuth);
+ cameraSliders.polar.set(state.view.polar);
+ const ortho = state.view.projection === 'ortho';
+ cameraSliders.size.set(ortho ? (state.view.orthoHeight || 7.5) : (state.view.distance || 12));
+ cameraSliders.targetY.set(state.view.targetY || model.size.y * 0.5);
+ autoRotateToggle.set(state.view.autoRotate);
+ });
+
+ syncers.push(() => {
+ lookAtToggle.set(state.lookAt?.enabled === true);
+ for (const [key, widget] of Object.entries(lookAtSliders)) {
+ widget.set(state.lookAt?.[key] ?? LOOK_AT_FALLBACKS[key]);
+ }
+ turnBodyToggle.set(state.lookAt?.turnBody === true);
+ });
+
+ syncers.push(() => {
+ themeSegment.set(state.render?.theme ?? 'original');
+ for (const [key, widget] of Object.entries(bodyColors)) {
+ widget.set(state.render?.colors?.[key] ?? BODY_COLOR_FALLBACKS[key]);
+ }
+ envIntensitySlider.set(state.render?.envIntensity ?? 1);
+ });
+
+ syncers.push(() => {
+ mirrorToggle.set(state.render?.mirror === true);
+ shadowCheck.set(state.render?.shadow !== false);
+ shadowOpacitySlider.set(state.render?.shadowOpacity ?? 0.22);
+ shadowSoftnessSlider.set(state.render?.shadowSoftness ?? 1.6);
+ contactShadowToggle.set(state.render?.contactShadow === true);
+ contactBlobToggle.set(state.render?.contactBlob === true);
+ environmentSegment.set(state.render?.environment ?? 'gradient');
+ });
+
+ syncers.push(() => {
+ const background = state.view?.background ?? 'solid';
+ // 素材 is active for either half of it, and its rows show only then.
+ const isMaterial = background === 'preset' || background === 'effect';
+ backgroundModeSegment.set(isMaterial ? 'material' : background);
+ materialGroup.style.display = isMaterial ? '' : 'none';
+ backgroundColour.set(state.view?.backgroundColor ?? '#ffffff');
+ backgroundFitSegment.set(state.view?.backgroundFit ?? 'cover');
+ backgroundBlurSlider.set(state.view?.backgroundBlur ?? 0);
+ backgroundDarkenSlider.set(state.view?.backgroundDarken ?? 0);
+ backgroundScaleSlider.set(state.view?.backgroundScale ?? 1);
+ cameraFacingSegment.set(state.view?.cameraFacing ?? 'environment');
+ cameraMirrorToggle.set(state.view?.cameraMirror === true);
+ gyroToggle.set(state.view?.gyro === true);
+ });
+
+ syncers.push(() => {
+ renderProps();
+ const items = state.props?.items ?? [];
+ // A rebuild only carries the whole-prop scale, so re-apply each sign's face
+ // size to the freshly built mesh here (see `placedPropGroups`).
+ const groups = placedPropGroups(app);
+ for (const entry of propWidgets) {
+ const item = items[entry.index] ?? {};
+ entry.sliders.x.set(item.x ?? 0);
+ entry.sliders.y.set(item.y ?? 0);
+ entry.sliders.z.set(item.z ?? 0);
+ entry.sliders.rotX.set(item.rotX ?? 0);
+ entry.sliders.rotY.set(item.rotY ?? 0);
+ entry.sliders.rotZ.set(item.rotZ ?? 0);
+ entry.sliders.scale.set(item.scale ?? 1);
+ entry.faceScale?.set(item.faceScale ?? 1);
+ applyPropFaceScale(groups[entry.index], item.faceScale);
+ entry.signText?.set(item.text ?? '');
+ }
+ });
+
+ syncers.push(() => {
+ renderGion();
+ const items = state.gion?.items ?? [];
+ for (const entry of gionWidgets) {
+ const item = items.find((candidate) => candidate.id === entry.id);
+ if (!item) continue;
+ entry.sliders.w.set(item.w ?? DEFAULT_STAMP_WIDTH);
+ entry.sliders.rot.set(item.rot ?? 0);
+ entry.flip.set(item.flip === true);
+ entry.block.classList.toggle('active', item.id === app.gionSelected);
+ }
+ });
+
+ syncers.push(() => {
+ for (const group of wallGroups) {
+ const wall = state.render?.[group.key] ?? defaultState().render[group.key];
+ group.toggle.set(wall.on === true);
+ for (const [key, widget] of Object.entries(group.sliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0));
+ group.guide.set(wall.guide === true);
+ }
+ });
+
+ syncers.push(() => {
+ captionBubbleSegment.set(activeCaption);
+ const bubble = cap() ?? {};
+ captionToggle.set(bubble.enabled === true);
+ captionText.set(bubble.text ?? '');
+ verticalToggle.set(bubble.vertical === true);
+ bubbleSegment.set(bubble.bubble ?? 'round');
+ tailSegment.set(bubble.tail ?? 'left');
+ for (const [key, widget] of Object.entries(captionSliders)) {
+ widget.set(bubble[key] ?? CAPTION_SLIDER_FALLBACKS[key]);
+ }
+ captionPosition.x.set(bubble.x ?? 0.58);
+ captionPosition.y.set(bubble.y ?? 0.16);
+ captionMaxWidthSlider.set(bubble.maxWidth ?? 0.36);
+ alignSegment.set(bubble.align ?? 'left');
+ boldToggle.set(bubble.bold === true);
+ systemFontToggle.set(bubble.font === 'system');
+ for (const [key, widget] of Object.entries(captionColors)) {
+ widget.set(bubble[key] ?? CAPTION_COLOR_FALLBACKS[key]);
+ }
+ });
+
+ syncers.push(() => {
+ flapText.set(state.mouthFlap?.text ?? '');
+ for (const [key, widget] of Object.entries(flapSliders)) {
+ widget.set(state.mouthFlap?.[key] ?? MOUTH_FLAP_FALLBACKS[key]);
+ }
+ });
+
+ syncers.push(() => {
+ storyScaleSegment.set(String(state.story?.scale ?? 1.5));
+ storyColumnsSlider.set(state.story?.columns ?? 1);
+ storyGapSlider.set(state.story?.gap ?? 12);
+ storyBackgroundField.set(state.story?.sheetBackground ?? '#ffffff');
+ renderStoryPanels();
+ });
+
+ app.actions.applyFacePreset = applyFacePreset;
+ app.actions.applyPosePreset = applyPosePreset;
+
+ sync();
+
+ // The 'g' key toggles the gizmo behind the panel's back, so the checkbox
+ // follows the rig rather than the other way round.
+ syncers.push(() => gizmoToggle.set(rig.helper.visible === true));
+
+ return {
+ sync,
+ onRigChanged: syncBones,
+ /** Which bubble the caption controls are editing (a drag on it calls this). */
+ selectCaption(key) {
+ if (key !== 'caption' && key !== 'caption2' && key !== 'narration') return;
+ activeCaption = key;
+ sync();
+ },
+ /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */
+ selectGion(id) {
+ app.gionSelected = id ?? null;
+ sync();
+ },
+ /** Open the crop picker for a sheet (the 擬音 section's button calls this). */
+ openGionPicker(sheet) {
+ openGionPicker(sheet ?? gionSheetSelect.get());
+ },
+ setStats(text) { statsLine.textContent = text; },
+ setRecording(recording) {
+ recordButton.setLabel('record', recording ? '録画を停止' : '録画を開始');
+ },
+ };
+}
diff --git a/bluebey-studio/src/presets.js b/bluebey-studio/src/presets.js
new file mode 100644
index 0000000..e4fe49f
--- /dev/null
+++ b/bluebey-studio/src/presets.js
@@ -0,0 +1,892 @@
+/**
+ * The complete, serialisable studio state plus the preset libraries.
+ *
+ * Everything here is plain JSON: "設定を保存" writes exactly this object, and
+ * "設定を読み込む" merges it back in, so a look can be archived and shared.
+ *
+ * Eye parameters are named after the character's own left/right, NOT the
+ * viewer's: `left` is the eye on the model's +x side, which is the half of the
+ * eye texture with u > 0.5. In a front view that eye appears on the right of
+ * the screen.
+ */
+
+/**
+ * One speech bubble's defaults.
+ *
+ * A factory rather than a constant, because there are two bubbles (see
+ * `caption2`) and they must not share one object - editing one would edit both.
+ */
+function captionDefaults() {
+ return {
+ enabled: false,
+ text: 'こんにちは、ぶるべーです。',
+ // Anchor of the bubble's box, as a fraction of the image (0..1).
+ x: 0.58,
+ y: 0.16,
+ maxWidth: 0.36, // fraction of the image width for the text column
+ bubble: 'round', // round | rect | shout | none
+ tail: 'left', // left | right | top | bottom | topLeft | topRight | bottomLeft | bottomRight | none
+ fontSize: 34, // pixels at 1x; scales with the export
+ lineHeight: 1.42,
+ padding: 18,
+ radius: 24,
+ textColor: '#3f2b52',
+ bubbleColor: '#ffffff',
+ borderColor: '#55386e',
+ borderWidth: 4,
+ bold: false,
+ align: 'left', // left | center | right
+ vertical: false, // 縦書き (columns run right to left)
+ font: 'rounded', // rounded (vendored) | system
+ };
+}
+
+/**
+ * ナレーション (a narration box): a plain rectangular panel of vertical text, the
+ * manga narration style. Same shape as a bubble, with a rect box, no tail, and
+ * 縦書き on by default.
+ */
+function narrationDefaults() {
+ return {
+ enabled: false,
+ text: 'ここで、ぶるべーは考えた。',
+ x: 0.06,
+ y: 0.08,
+ maxWidth: 0.5,
+ bubble: 'rect',
+ tail: 'none',
+ fontSize: 30,
+ lineHeight: 1.35,
+ padding: 16,
+ radius: 8,
+ textColor: '#2b2433',
+ bubbleColor: '#ffffff',
+ borderColor: '#2b2433',
+ borderWidth: 3,
+ bold: false,
+ align: 'left',
+ vertical: true,
+ font: 'rounded',
+ };
+}
+
+/**
+ * Per-kind starting size and height for ひげ, applied when a kind is picked. The
+ * textured kinds are drawings, so they are drawn at a different scale from the
+ * built-in strokes; a kind with no entry resets to the neutral values.
+ */
+export const BEARD_KIND_DEFAULTS = {
+ scotch: { size: 0.46, offsetY: 74 },
+ kaiser: { size: 1, offsetY: 80, spacing: -10 },
+};
+
+/**
+ * Per-shape starting values for 髪 (headMark), applied when a shape is picked.
+ * `teeth` only matters for ぎざぎざ, so its default is set here rather than on
+ * the shared `size`/`teeth` fields.
+ */
+export const HEAD_MARK_KIND_DEFAULTS = {
+ fringe: { size: 0.83, teeth: 0.06 },
+};
+
+/**
+ * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or
+ * two opposite cuts) can be made. A wall's position is kept *relative to the
+ * character*, so moving ぶるべー carries its walls along (see src/clip.js).
+ */
+function wallDefaults() {
+ return {
+ on: false,
+ x: 0, y: 0, z: 0, // a point the plane passes through, relative to the character
+ yaw: 0, // turn about Y, degrees
+ tilt: 0, // lean about X, degrees
+ // The guide is the only part of the wall that is ever *drawn* (the wall
+ // itself writes depth but no colour, so it hides what is behind it
+ // without being visible). `guide` shows that rectangle while placing,
+ // which is also when it can be dragged in the viewport.
+ guide: false,
+ size: 1, // the wall's size (a real width and height, not a clip)
+ };
+}
+
+export function defaultState() {
+ return {
+ version: 1,
+ view: {
+ projection: 'persp', // persp | ortho
+ fov: 30,
+ azimuth: 0, // degrees, 0 = looking at the face
+ polar: 76, // degrees from +y, 90 = level with the target
+ distance: 0, // 0 = fit to the model automatically
+ orthoHeight: 0, // 0 = fit to the model automatically
+ targetY: 0, // 0 = centre of the model
+ // The mouse orbit and pan write their own target back here (see
+ // ViewRig.captureInto), so refreshing the view - or sharing a link - keeps
+ // the camera the user actually framed.
+ targetX: 0,
+ targetZ: 0,
+ autoRotate: false,
+ autoRotateSpeed: 0.8,
+ // solid | transparent | preset | image | effect | camera
+ // preset = one of the CC0 backdrops in assets/backgrounds
+ // image = something the user loaded (data URL)
+ // effect = a procedurally drawn manga effect line (no file at all)
+ // camera = the phone's camera, for the AR mode
+ background: 'solid',
+ backgroundColor: '#ffffff',
+ backgroundPreset: 'autumn_park',
+ backgroundEffect: 'focus', // focus | fall | speed | ellipse
+ backgroundImage: null, // data URL, set by 「画像を読み込む」
+ backgroundFit: 'cover', // cover | contain | stretch | tile
+ backgroundBlur: 0, // px
+ backgroundDarken: 0, // 0..1 dark veil, helps the character read
+ backgroundScale: 1,
+ backgroundOffset: { x: 0, y: 0 },
+ // AR: which camera, and whether to mirror (front camera feels mirrored)
+ cameraFacing: 'environment', // environment | user
+ cameraMirror: false,
+ gyro: false, // look around by turning the phone
+ },
+ render: {
+ style: 'real', // real | flat | lineart | outline
+ outline: true,
+ outlineWidth: 0.022,
+ outlineColor: '#2a1e33',
+ paper: '#ffffff',
+ shadow: true,
+ // The soft round blob at the character's feet. Off by default: it sat at the
+ // origin and stayed behind when the character was moved. See look.js.
+ contactBlob: false,
+ pngScale: 2,
+ pngTransparent: false,
+ svgWidth: 2048,
+ lightAzimuth: -38,
+ lightElevation: 40,
+ lightIntensity: 2.1,
+ ambient: 0.9,
+ // The colour of the main light and of the ambient (sky) light.
+ lightColor: '#ffffff',
+ ambientColor: '#ffffff',
+ exposure: 1,
+ // Body colours. A theme fills these in; the colour pickers then adjust
+ // them one part at a time, so a theme is a starting point, not a mode.
+ theme: 'original',
+ colors: {
+ body: '#c8b0f0',
+ accent: '#8a4fe0',
+ nose: '#7a4fb0',
+ leaf: '#a6dd6a',
+ vein: '#7fbf3f',
+ feet: '#7a4fb0',
+ },
+ // Mirror the whole character, for laying out a panel that faces its text.
+ mirror: false,
+ // Shadows: the soft blob under the feet, plus the cast shadow.
+ shadowOpacity: 0.22,
+ shadowSoftness: 1.6,
+ shadowOffset: 0,
+ contactShadow: false, // blob only, no cast shadow
+ // Lighting environment. 'room' is three's RoomEnvironment; 'gradient' is
+ // the tiny procedural sky the studio always had.
+ environment: 'gradient', // gradient | room | none
+ envIntensity: 1,
+ // Hand-drawn line work (SVG export and the outline pass).
+ handDrawn: 0, // 0 = off, 1 = a lot
+ handDrawnSeed: 7,
+ handDrawnScale: 40,
+ handDrawnPasses: 1,
+ // How the outline is drawn - the two methods take the parts they are good
+ // at. See MODEL-GUIDE.md §5 and src/outline.js.
+ //
+ // 'screen' (the default) hands the leaves to the screen-space edge pass,
+ // because a hull cannot outline a shell that thin: the expanded
+ // front and back cross inside it and the line breaks up. Every
+ // other part keeps its hull, whose line is computed from the
+ // geometry and so comes out smooth, where the pixel grid would
+ // make it stepped.
+ // 'hull' the inverted-hull copy everywhere - cheap, and the way it was
+ // done before, but the leaves then come out as a tangle.
+ outlineMethod: 'screen',
+ outlinePixels: 2.4, // 'screen' only: line width in screen pixels
+ // Whether a leaf gets a line where it meets the *body*. There is no right
+ // answer here, so it is a setting:
+ // on - the body counts as paper, so the leaves are outlined where they
+ // emerge from it. Without this a line drawing cannot tell the
+ // leaves and the body apart at all (both are paper).
+ // off - the body blocks the line, so the leaf simply passes behind it and
+ // nothing is drawn along the meeting. Cleaner, but the leaves and
+ // the body merge into one white shape.
+ leafBodyLine: true,
+ // 見えない壁 (the invisible wall). Up to two placeable rectangles that hide
+ // whatever is behind them, so the character can be buried in a wall and
+ // only the rest of the body shows. See src/clip.js.
+ wall: wallDefaults(),
+ wall2: wallDefaults(),
+ },
+ pose: {
+ bones: {}, // { boneName: [x, y, z] } in degrees, rest = 0,0,0
+ root: [0, 0, 0], // whole-body offset in model units
+ },
+ anim: {
+ mode: 'off', // off | idle | walk
+ blink: true,
+ blinkInterval: 3.4, // seconds between blinks
+ lookAround: false,
+ speed: 1,
+ },
+ face: {
+ // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js.
+ // `x`/`z` slide it sideways / forwards, `height` lifts it off the head;
+ // `tiltX` leans it forward/back and `tiltZ` leans it sideways.
+ hat: { kind: 'none', custom: false, x: 0, z: 0, height: -0.7, tiltX: 0, tiltZ: 0 },
+ eyes: {
+ source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
+ linked: true, // move both eyes together
+ left: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null },
+ right: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null },
+ irisScale: 1,
+ lookMax: 1.2,
+ highlight: true,
+ lidWidth: 14,
+ lowerLid: 0,
+ lashes: false,
+ lashAngle: 0,
+ lashPos: 0,
+ // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat`
+ // is a straight lid edge (the hard, narrowed look). `lidTilt` rotates the
+ // narrowed eye. See faceArt.js.
+ lidShape: 'curve',
+ lidTilt: 0,
+ heartScale: 2,
+ heartColor: '#e0344f',
+ // The tears live on each eye (see `eyes.left.tear`), so one eye can cry
+ // on its own; only the colour is shared.
+ tearColor: '#8fd8ff',
+ // ほっぺ (a manga blush) and 頭の模様 (a hair-like head covering) are drawn
+ // into the same plate as the eyes and brows. Both are generic devices,
+ // not a copy of any one character, and both start off. The blush is always
+ // four horizontal strokes, so there is no line-count field.
+ cheeks: {
+ enabled: false,
+ size: 1,
+ spacing: 0, // extra gap outwards from the eyes
+ offsetY: 0, // extra drop below the eyes
+ color: '#f6a6b8',
+ },
+ // The hair covering's `shape` is one of `off`, `cap`, `fringe`,
+ // `fringe-up` or `curve`. `overEyes` puts the hair in front of the eyes
+ // (bangs over the face); off leaves the eyes on top.
+ headMark: {
+ shape: 'off',
+ size: 1,
+ // ぎざぎざ only: how far the saw teeth reach down.
+ teeth: 0.12,
+ offsetX: 0,
+ offsetY: 0,
+ overEyes: true,
+ color: '#8a4fe0',
+ color2: '#ffffff',
+ },
+ // 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same
+ // plate as the eyes, out to the side of the nose. Starts off.
+ snot: {
+ enabled: false,
+ size: 1.23,
+ offsetX: -23,
+ offsetY: 0,
+ offsetZ: 16,
+ color: '#e3ecff',
+ },
+ // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`,
+ // `scotch`, `kaiser`, `apron` or `cat`. `spacing` widens the gap between
+ // the cat's left and right whiskers. Starts off.
+ beard: {
+ shape: 'off',
+ size: 1,
+ offsetY: 0,
+ spacing: 0,
+ // ねこ only: how far each whisker reaches (`length`) and how far apart the
+ // three lines of one side sit (`lineGap`). Neither changes the thickness.
+ length: 1,
+ lineGap: 1,
+ color: '#1c1624',
+ },
+ // Eyebrows are drawn into the eye texture. Off by default, because the
+ // original character has no eyebrows.
+ brow: {
+ enabled: false,
+ // When true and a drawing (assets/brows/gol-right.png) was loaded, the
+ // brow is painted from that file (mirrored) instead of the strokes.
+ image: false,
+ angle: 0,
+ height: 0,
+ length: 1,
+ thickness: 1,
+ curve: 0.14,
+ // How wide the nose-side end is: 1 = a plain stroke, 0 = a wedge that
+ // comes to a point (the ゴル風 look). `spacing` widens the gap between
+ // the two brows.
+ taper: 1,
+ spacing: 0,
+ color: '#55386e',
+ },
+ // 眼鏡 / サングラス are drawn into the same texture as the eyes and brows,
+ // so they travel with the head instead of being a separate 3D mesh. Off
+ // by default. `kind` only picks which lens defaults the panel offers; the
+ // drawing itself reads the colours and opacities below.
+ glasses: {
+ enabled: false,
+ kind: 'glasses', // glasses | sunglasses
+ frameColor: '#2a1e33',
+ frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke)
+ lensColor: '#2b2433',
+ lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark
+ lensGap: 0, // px, + moves the two lenses further apart
+ scale: 1, // lens size multiplier
+ offsetY: 0, // px, artwork units (positive = down)
+ tilt: 0, // degrees, both lenses turn together
+ },
+ white: '#ffffff',
+ iris: '#150e1b',
+ line: '#55386e',
+ },
+ mouth: {
+ source: 'parametric', // parametric | original
+ visible: true,
+ smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown
+ width: 1, // the smile line's length (the chord); also the O's width
+ thickness: 1,
+ open: 0, // 0 = closed lips, 1 = wide open
+ tilt: 0, // degrees
+ offsetY: 0, // texture pixels
+ tongue: 0.96, // 0 hides it; scales the whole tongue
+ tonguePos: 0.84, // 0..1 along the smile line
+ corners: 0.71, // length of the corner marks, 0 hides them
+ cornerAngle: -8, // degrees, tilts the corner marks
+ round: 0, // >0 turns the mouth into a round "O"
+ color: '#ff1a44',
+ innerColor: '#4a0f1e',
+ tongueColor: '#ff2d2d',
+ cornerColor: '#725497',
+ line: '#55386e',
+ },
+ },
+ // ---------------------------------------------------------------- caption
+ // The speech bubbles drawn next to the character when exporting. They live in
+ // the state so they are saved, shared and undone like everything else. There
+ // are two of them, so a line and a reply (or a narrator and a speaker) fit.
+ caption: captionDefaults(),
+ // The second bubble starts on the other side, so the two do not overlap.
+ caption2: { ...captionDefaults(), text: '', x: 0.08, y: 0.44 },
+ // A ナレーション box (vertical text, rect, no tail).
+ narration: narrationDefaults(),
+ // -------------------------------------------------------- 口パク (mouth flap)
+ // The mouth moves as if talking, with no sound at all: the device's own
+ // speech voices belong to the device, so a recording of them is not ours to
+ // hand out. Only the *length* of the text matters here.
+ mouthFlap: {
+ text: 'こんにちは。小平市のぶるべーです。',
+ rate: 1, // how fast the syllables come
+ mouthGain: 1, // how wide the mouth opens
+ },
+ // ----------------------------------------------------------------- look at
+ // Where the character should look (world units).
+ lookAt: {
+ enabled: false,
+ x: 0,
+ y: 2.6,
+ z: 3,
+ turnBody: false, // also rotate the whole body a little
+ amount: 1,
+ // The turn and the gaze are *remembered* rather than derived, so switching
+ // the mode (or just 体も向ける) off leaves the character where it is instead
+ // of snapping it back to the front. See `applyLookAt` in main.js.
+ bodyYawDeg: 0,
+ freeze: null,
+ },
+ // ------------------------------------------------------------------- props
+ // 小物. Each entry is a procedural prop from src/props.js.
+ props: {
+ items: [],
+ },
+ // -------------------------------------------------------------------- 擬音
+ // マンガのオノマトペ (擬音) as stickers over the picture. Each item is a crop
+ // of a sheet - `{ id, sheet, sx, sy, sw, sh, x, y, w, rot, flip }` - and only
+ // the sheet *name* is stored, never the bitmap, so a shared link stays small
+ // (see src/gion.js for the geometry).
+ gion: {
+ items: [],
+ },
+ // ------------------------------------------------------------------- story
+ // 4コマ / 紙芝居. Each panel is a small state patch (pose, face, caption).
+ story: {
+ columns: 1,
+ gap: 12,
+ padding: 20,
+ sheetBackground: '#ffffff',
+ // How big each captured panel is, as a multiple of the viewport. 2x doubles
+ // each side, so the sheet PNG gets four times the pixels per panel.
+ scale: 1.5,
+ panels: [],
+ },
+ };
+}
+
+/** Expression presets. Each patches a copy of the default state. */
+export const FACE_PRESETS = [
+ {
+ id: 'normal',
+ label: 'ふつう',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ },
+ // Depth 1 = the hand-drawn original; the corners and tongue below are also
+ // the settings that reproduce it (measured against the artwork).
+ mouth: { smile: 1, width: 1, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0.96, tonguePos: 0.84, corners: 0.71, cornerAngle: -8 },
+ },
+ },
+ {
+ id: 'laugh',
+ label: 'にっこり',
+ face: {
+ eyes: {
+ left: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' },
+ right: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' },
+ highlight: false,
+ },
+ // A laugh is a wide smile with squeezed-shut eyes. An *open* mouth on a
+ // round mascot reads as a shout, so only びっくり keeps the round O.
+ mouth: { smile: 1.15, width: 1.14, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, round: 0, tongue: 0.96, corners: 1.25 },
+ },
+ },
+ {
+ id: 'eating',
+ label: 'たべている',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ },
+ // A small, cute round mouth (the other round mouth, with びっくり).
+ mouth: { smile: 0.2, width: 0.8, thickness: 1.1, open: 0, tilt: 0, offsetY: 6, round: 0.58, tongue: 0.7, corners: 0 },
+ },
+ },
+ {
+ id: 'wink',
+ label: 'ウインク',
+ face: {
+ eyes: {
+ // The shut eye is the original `eyes-close-tight` artwork: three strokes
+ // sharing one vertex, pointing at the nose.
+ left: { shape: 'line3', open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 1 },
+ irisScale: 1,
+ highlight: true,
+ },
+ mouth: { smile: 1.0, width: 1.02, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 1 },
+ },
+ },
+ {
+ id: 'surprised',
+ label: 'びっくり',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ irisScale: 0.74,
+ lookMax: 0.35,
+ highlight: true,
+ },
+ mouth: { smile: 0.2, width: 0.85, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, round: 0.62, tongue: 0.25, corners: 0 },
+ },
+ },
+ {
+ id: 'sleepy',
+ label: 'ねぼけ',
+ face: {
+ eyes: {
+ left: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' },
+ right: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ // A sleeper's snot bubble, with the defaults from the panel.
+ snot: { enabled: true },
+ },
+ mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 },
+ },
+ },
+ {
+ id: 'shy',
+ label: 'てれ',
+ face: {
+ eyes: {
+ // Blushing, eyes half shut and looking away.
+ left: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
+ right: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
+ cheeks: { enabled: true },
+ irisScale: 1,
+ highlight: true,
+ },
+ mouth: { smile: 0.5, width: 0.82, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0.5 },
+ },
+ },
+ {
+ id: 'sidelong',
+ label: 'じと目',
+ face: {
+ eyes: {
+ // Flat, narrowed lids tilted inwards - a sideways, unimpressed look.
+ // 上まぶたの高さ is now independent of the lower lid, so `open` carries the
+ // whole slit (it used to be measured from the raised lower lid).
+ left: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' },
+ right: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' },
+ lowerLid: 0.42,
+ lidShape: 'flat',
+ lidTilt: 12,
+ irisScale: 0.92,
+ highlight: true,
+ },
+ mouth: { smile: 0.12, width: 0.78, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+ {
+ id: 'love',
+ label: 'すき',
+ face: {
+ eyes: {
+ left: { shape: 'heart', open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false },
+ right: { shape: 'heart', open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false },
+ irisScale: 1,
+ // The white behind a heart would poke out around its lobes, so hide it.
+ heartColor: '#e0344f',
+ highlight: false,
+ },
+ mouth: { smile: 1.15, width: 1, thickness: 1.05, open: 0.16, tilt: 4, offsetY: 0, tongue: 0.9, corners: 1.3 },
+ },
+ },
+ {
+ id: 'grumpy',
+ label: 'むっと',
+ face: {
+ eyes: {
+ left: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 },
+ right: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 },
+ irisScale: 0.86,
+ highlight: true,
+ },
+ mouth: { smile: -0.62, width: 0.84, thickness: 1.15, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+ {
+ id: 'cry',
+ label: 'ないてる',
+ face: {
+ eyes: {
+ // Big tears that hang low and lean outwards - the classic "crying hard"
+ // look. The sideways position and the tilt are mirrored between the eyes,
+ // so the pair stays symmetric.
+ left: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: -20 },
+ right: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: 20 },
+ irisScale: 1.02,
+ lowerLid: 0.42,
+ highlight: true,
+ },
+ mouth: { smile: -0.5, width: 0.72, thickness: 1.25, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+];
+
+/**
+ * Poses. `bones` holds per-bone XYZ degrees relative to the rest pose.
+ *
+ * The axes were measured (turn one bone 40° and watch the hand's centre move),
+ * and the two sides are NOT the same. That is what used to send the left hand
+ * behind while the right one came forward:
+ *
+ * arm / hand
+ * X lift. Up on both sides, so the SAME value is symmetric.
+ * Y roll about the flipper - which way the palm faces.
+ * Z forward/back. +Z is BACK on the left and FORWARD on the right, so a
+ * symmetric pose writes the right-hand Z with the opposite sign, while
+ * a walk (opposite arms) writes the SAME sign on both.
+ * legsupport
+ * X out to the side and up (same value on both sides is symmetric)
+ * Y twist (which way the toe points)
+ * Z forward/back, mirrored like `arm`
+ * It pivots at the middle of the body, so the feet travel a long way for
+ * a small angle - keep it under about 15 degrees.
+ * master
+ * X lean forward Y spin around Z roll sideways
+ *
+ * `armsupport` still exists in the rig but it is a control bone that pivots at
+ * the body's centre, so it swings a hand in a wide arc across the face. Every
+ * preset below drives `arm`/`hand` instead.
+ */
+export const POSE_PRESETS = [
+ { id: 'stand', label: '立ち', pose: { bones: {} } },
+ {
+ id: 'attention',
+ label: 'きおつけ',
+ pose: {
+ bones: {
+ // Both flippers dropped to the sides. A negative X lowers the arm, and
+ // the value is the same on both sides (only Z mirrors between them).
+ 'arm.l': [-62, 0, 0],
+ 'arm.r': [-62, 0, 0],
+ },
+ },
+ },
+ {
+ id: 'bow',
+ label: 'おじぎ',
+ pose: { bones: { master: [22, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'deep-bow',
+ label: 'ふかくおじぎ',
+ pose: { bones: { master: [42, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'tilt',
+ label: 'くびかしげ',
+ pose: {
+ bones: {
+ master: [0, 0, 16],
+ // The roll turns the whole body about its base, which swings the feet up
+ // and down, so the legs take the difference. The right-hand leg (on the
+ // left of the screen) is the one that is planted: it opens outwards a
+ // little and keeps the sole down. The left-hand leg bends back behind it.
+ 'legsupport.r': [8, 0, 0],
+ 'legsupport.l': [-6, 0, 14],
+ },
+ },
+ },
+ {
+ id: 'wave',
+ label: 'てをふる',
+ pose: {
+ bones: {
+ master: [0, 0, -4],
+ // The left flipper goes up and slightly forward. Y is the roll about the
+ // flipper's own length, which is what turns the palm to face outwards.
+ 'arm.l': [84, 0, -6],
+ 'hand.l': [0, -30, 0],
+ },
+ },
+ },
+ {
+ id: 'banzai',
+ label: 'ばんざい',
+ pose: {
+ bones: {
+ master: [-8, 0, 0],
+ // Both flippers as far up as the shoulder allows (the pivot sits at the
+ // middle of the body, so they cannot reach above the head).
+ 'arm.l': [106, 0, -12],
+ 'arm.r': [106, 0, 12],
+ },
+ root: [0, 0.2, 0],
+ },
+ },
+ {
+ id: 'stretch',
+ label: 'のびをする',
+ pose: {
+ bones: {
+ master: [-16, 0, 0],
+ 'arm.l': [112, 0, -8],
+ 'arm.r': [112, 0, 8],
+ },
+ root: [0, 0.08, 0],
+ },
+ },
+ {
+ id: 'jump',
+ label: 'ジャンプ',
+ pose: {
+ bones: {
+ master: [-8, 0, 0],
+ 'arm.l': [68, 0, -10],
+ 'arm.r': [68, 0, 10],
+ 'legsupport.l': [10, 0, 0],
+ 'legsupport.r': [10, 0, 0],
+ },
+ root: [0, 0.62, 0],
+ },
+ },
+ {
+ id: 'leap',
+ label: '大ジャンプ',
+ pose: {
+ bones: {
+ master: [-18, 0, 0],
+ 'arm.l': [92, 0, -12],
+ 'arm.r': [92, 0, 12],
+ 'legsupport.l': [14, 0, 0],
+ 'legsupport.r': [14, 0, 0],
+ },
+ root: [0, 1.0, 0],
+ },
+ },
+ {
+ id: 'dance',
+ label: 'おどる',
+ pose: {
+ bones: {
+ master: [-4, 0, -12],
+ // Deliberately uneven: the left flipper is up, the right one is out in
+ // front, and the feet step the other way.
+ 'arm.l': [78, 0, -14],
+ 'arm.r': [14, 0, 34],
+ 'legsupport.l': [0, 0, 8],
+ 'legsupport.r': [0, 0, 8],
+ },
+ root: [0, 0.14, 0],
+ },
+ },
+ {
+ id: 'tip-over',
+ label: 'こてん',
+ pose: {
+ bones: {
+ master: [0, 0, 68],
+ 'arm.l': [26, 0, -18],
+ 'arm.r': [10, 0, 18],
+ },
+ root: [0, 0.25, 0],
+ },
+ },
+ {
+ id: 'lie',
+ label: 'ねている',
+ pose: {
+ bones: {
+ // Flat on its back: X tips the body backwards all the way over, so the
+ // face looks up. The root lift keeps the back resting on the ground
+ // rather than sinking through it (the body's depth becomes its height).
+ master: [-88, 0, 0],
+ // Both flippers rest against the body, loose.
+ 'arm.l': [-18, 0, 0],
+ 'arm.r': [-18, 0, 0],
+ },
+ root: [0, 1.5, 0],
+ },
+ },
+ { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } },
+ { id: 'turn', label: 'うしろむき', pose: { bones: { master: [0, 180, 0] } } },
+ {
+ id: 'present',
+ label: '右を紹介',
+ pose: {
+ bones: {
+ master: [6, -26, 0],
+ // The right flipper sweeps forward to point at whatever is on its right
+ // (+Z is forwards on the right); the left one just steps aside.
+ 'arm.r': [16, 0, 52],
+ 'arm.l': [10, 0, -6],
+ },
+ },
+ },
+ {
+ id: 'present-left',
+ label: '左を紹介',
+ pose: {
+ bones: {
+ master: [6, 26, 0],
+ 'arm.l': [16, 0, -52],
+ 'arm.r': [10, 0, 6],
+ },
+ },
+ },
+];
+
+/** Deep-merge `patch` into `target` (plain objects only). */
+export function applyPatch(target, patch) {
+ for (const [key, value] of Object.entries(patch ?? {})) {
+ if (value && typeof value === 'object' && !Array.isArray(value)
+ && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
+ applyPatch(target[key], value);
+ } else {
+ target[key] = Array.isArray(value) ? value.slice() : value;
+ }
+ }
+ return target;
+}
+
+export function cloneState(state) {
+ return JSON.parse(JSON.stringify(state));
+}
+
+/**
+ * Body colour themes (C-1). Each is a full set of part colours, so picking one
+ * is a single assignment; the individual pickers in the panel then let you drift
+ * away from it without losing the rest.
+ *
+ * ぶるべー is a leaf-sprout mascot, so the themes are named after plants and
+ * weather rather than after colours.
+ */
+export const THEMES = [
+ {
+ id: 'original',
+ label: 'オリジナル',
+ colors: { body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0' },
+ },
+ {
+ id: 'sakura',
+ label: 'さくら',
+ colors: { body: '#f7c6d9', accent: '#e0709b', nose: '#c25b7e', leaf: '#b8e08a', vein: '#8dbb54', feet: '#c25b7e' },
+ },
+ {
+ id: 'matcha',
+ label: 'まっちゃ',
+ colors: { body: '#cfe3ac', accent: '#6f9c3a', nose: '#5c8030', leaf: '#9fd463', vein: '#79ab3c', feet: '#5c8030' },
+ },
+ {
+ id: 'sora',
+ label: 'そら',
+ colors: { body: '#bcd9f7', accent: '#4a7fd4', nose: '#3f6cae', leaf: '#a8e08a', vein: '#7cbb58', feet: '#3f6cae' },
+ },
+ {
+ id: 'lemon',
+ label: 'レモン',
+ colors: { body: '#f8e9a6', accent: '#d8a520', nose: '#b3871a', leaf: '#b9de74', vein: '#8fbb45', feet: '#b3871a' },
+ },
+ {
+ id: 'momo',
+ label: 'もも',
+ colors: { body: '#f9cbae', accent: '#e07a4a', nose: '#bd6238', leaf: '#aede7c', vein: '#84b84e', feet: '#bd6238' },
+ },
+ {
+ id: 'sumi',
+ label: 'すみ',
+ colors: { body: '#9aa2b8', accent: '#4b5468', nose: '#3c4356', leaf: '#8fbf7a', vein: '#6d9a58', feet: '#3c4356' },
+ },
+ {
+ id: 'yozora',
+ label: 'よぞら',
+ colors: { body: '#8f8fd0', accent: '#403f8f', nose: '#34336f', leaf: '#7fc7b0', vein: '#5aa08c', feet: '#34336f' },
+ },
+];
+
+/** Bubble shapes offered for captions. */
+export const BUBBLE_STYLES = [
+ { value: 'round', label: 'まる' },
+ { value: 'rect', label: 'しかく' },
+ { value: 'shout', label: 'さけぶ' },
+ { value: 'whisper', label: 'ささやき' },
+ { value: 'think', label: '思考' },
+ { value: 'none', label: 'なし' },
+];
+
+/** A couple of ready-made captions, so the feature is discoverable. */
+export const CAPTION_PRESETS = [
+ { id: 'greeting', label: 'あいさつ', caption: { enabled: true, text: 'こんにちは、ぶるべーです。' } },
+ { id: 'explain', label: '説明', caption: { enabled: true, text: 'ここがポイントです。', bubble: 'rect', tail: 'left' } },
+ { id: 'surprise', label: 'びっくり', caption: { enabled: true, text: 'えっ!?', bubble: 'shout', tail: 'left', fontSize: 40, bold: true } },
+];
diff --git a/bluebey-studio/src/props.js b/bluebey-studio/src/props.js
new file mode 100644
index 0000000..78525bd
--- /dev/null
+++ b/bluebey-studio/src/props.js
@@ -0,0 +1,646 @@
+import * as THREE from 'three';
+
+/**
+ * Procedural 小物 (props) that can be placed on the stage next to the character:
+ * a lectern, a desk, a microphone stand, a signboard, a potted plant and a
+ * cardboard box.
+ *
+ * Everything is built from three's primitives instead of loaded from a file, for
+ * three reasons. The character is about 4.15 units tall with its feet on y = 0
+ * and its body a sphere of radius ~1.6 centred near y = 2.4, so every generator
+ * below works from that scale, and every group's origin is its *base centre* -
+ * dropping one at a ground position and rotating it about y is all the app has
+ * to do. The studio also re-skins props with the same toon/flat materials and
+ * the same inverted-hull outline as the body, so the geometry stays chunky: no
+ * plate thinner than ~0.05 units (a thin plate's offset hull turns into a smear)
+ * and no textures anywhere. And props must be identical on every run, so every
+ * size, position and angle here is a literal - no Math.random().
+ *
+ * Colours are never hard-coded on a mesh. Each material is tagged with
+ * `userData.part`, and `applyPropColors` fills the colour in from the app's
+ * render.colors palette, which is what lets the colour-theme feature recolour a
+ * prop that was built minutes ago. The mapping keeps lightness apart from hue,
+ * so a prop still reads in the near-monochrome すみ theme:
+ *
+ * wood the body colour, darkened - painted wood, cardboard, soil
+ * paper the body colour, nearly white - the blank sign face
+ * accent the accent colour - legs, frames, pots, mic bodies
+ * leaf the leaf colour - foliage
+ * metal a fixed neutral grey - stands, booms, microphone heads
+ *
+ * `userData.shade` multiplies a part's colour, so one prop can use two tones of
+ * the same part - a cardboard box and its darker inner flaps - without adding a
+ * sixth palette entry.
+ */
+
+/** Reused when something has to be aimed along a direction vector. */
+const UP = new THREE.Vector3(0, 1, 0);
+const WHITE = new THREE.Color(0xffffff);
+/** A fixed grey: a metal stand has to read in every theme, warm or grey. */
+const METAL_GREY = '#b9bec7';
+/** Enough of the palette to build with when the app has not sent one yet. */
+const FALLBACK_COLORS = { body: '#c8b0f0', accent: '#8a4fe0', leaf: '#a6dd6a' };
+
+const PART_NAMES = ['wood', 'metal', 'paper', 'accent', 'leaf'];
+
+const clamp01 = (value) => Math.min(1, Math.max(0, value));
+
+/** The lectern's reading surface tilts up towards the audience, i.e. towards +z. */
+const SLAB_TILT = -0.32;
+
+// ---------------------------------------------------------------------------
+// Building blocks
+// ---------------------------------------------------------------------------
+
+/**
+ * A material per (part, shade) inside one prop. Sharing them keeps the draw
+ * calls down and, more importantly, lets `disposeProp` release each one once.
+ */
+function materialCache() {
+ const cache = new Map();
+ return (part, shade = 1) => {
+ if (!PART_NAMES.includes(part)) throw new Error(`unknown prop material part: ${part}`);
+ const key = `${part}:${shade}`;
+ let material = cache.get(key);
+ if (!material) {
+ const metal = part === 'metal';
+ material = new THREE.MeshStandardMaterial({
+ color: 0xffffff, // filled in by applyPropColors
+ roughness: 0.75,
+ metalness: metal ? 0.6 : 0,
+ });
+ material.userData.part = part;
+ material.userData.shade = shade;
+ cache.set(key, material);
+ }
+ return material;
+ };
+}
+
+/**
+ * Wraps a generator so each prop gets its own material cache. The generator
+ * receives `(group, material, options)`, fills the group and returns nothing.
+ */
+function prop(generator) {
+ return (options = {}) => {
+ const group = new THREE.Group();
+ generator(group, materialCache(), options);
+ return group;
+ };
+}
+
+/** Adds a mesh, matching the body's shadow flags, and returns it. */
+function addMesh(parent, geometry, material, x = 0, y = 0, z = 0) {
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.position.set(x, y, z);
+ mesh.castShadow = true;
+ mesh.receiveShadow = false;
+ parent.add(mesh);
+ return mesh;
+}
+
+/**
+ * The 線画 fill a part takes, as how far it steps from the paper towards the ink
+ * (see `addMesh` in src/styles.js). A prop is paper in 線画, so two parts of a prop
+ * that meet flush - the pencil's lead in its wood, a can's label on its body - come
+ * out as one white shape. Tagging one of them with a tone puts the seam back; it
+ * has no effect on リアル / フラット, where the part's own colour draws it.
+ */
+function tone(mesh, strength) {
+ mesh.userData.tone = strength;
+ return mesh;
+}
+
+/** A cuboid, sized as [width, height, depth] and centred on `at`. */
+function addBox(parent, material, size, at = [0, 0, 0]) {
+ const geometry = new THREE.BoxGeometry(size[0], size[1], size[2]);
+ return addMesh(parent, geometry, material, at[0], at[1], at[2]);
+}
+
+/** A cylinder (or cone, if the two radii differ) centred on `at`. */
+function addCylinder(parent, material, radiusTop, radiusBottom, height, at, segments = 12) {
+ const geometry = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments);
+ return addMesh(parent, geometry, material, at[0], at[1], at[2]);
+}
+
+/** A rounded blob. The caller usually scales it into a leaf or an ellipsoid. */
+function addSphere(parent, material, radius, at, segments = [10, 8]) {
+ const geometry = new THREE.SphereGeometry(radius, segments[0], segments[1]);
+ return addMesh(parent, geometry, material, at[0], at[1], at[2]);
+}
+
+/** A rod from `from` to `to`: stems, booms and struts are all just this. */
+function addStrut(parent, material, from, to, radius, segments = 8) {
+ const start = new THREE.Vector3(from[0], from[1], from[2]);
+ const end = new THREE.Vector3(to[0], to[1], to[2]);
+ const axis = end.clone().sub(start);
+ const length = axis.length();
+ const mesh = addMesh(
+ parent,
+ new THREE.CylinderGeometry(radius, radius, length, segments),
+ material,
+ (start.x + end.x) / 2,
+ (start.y + end.y) / 2,
+ (start.z + end.z) / 2,
+ );
+ mesh.quaternion.setFromUnitVectors(UP, axis.normalize());
+ return mesh;
+}
+
+// ---------------------------------------------------------------------------
+// The props
+// ---------------------------------------------------------------------------
+
+/**
+ * 演台. A foot plate, a pedestal, a slab that tilts up towards the audience and
+ * a gooseneck microphone on the corner nearest the camera. The mic is on the
+ * audience edge on purpose: on the far edge the slab would hide it from a
+ * slightly-above camera.
+ */
+const buildPodium = prop((group, material) => {
+ addBox(group, material('accent'), [1.30, 0.10, 0.98], [0, 0.05, 0]);
+ addBox(group, material('wood'), [1.10, 1.30, 0.78], [0, 0.75, 0]);
+
+ const slab = addBox(group, material('wood'), [1.42, 0.12, 0.86], [0, 1.42, 0.10]);
+ slab.rotation.x = SLAB_TILT;
+ // A lip along the slab's low edge, so the silhouette reads as a lectern top.
+ const lip = addBox(group, material('accent'), [1.02, 0.10, 0.10], [0, 1.498, 0.527]);
+ lip.rotation.x = SLAB_TILT;
+
+ const stem = addCylinder(group, material('metal'), 0.032, 0.032, 0.60, [-0.48, 1.80, 0.26], 8);
+ stem.rotation.x = SLAB_TILT;
+ const head = addSphere(group, material('metal'), 0.09, [-0.48, 2.11, 0.15], [10, 8]);
+ head.scale.set(1, 1.25, 1);
+});
+
+/**
+ * 机. A top, four legs and a pair of side rails. The legs and rails take the
+ * darker `accent` tone so the table keeps its shape in a flat or greyscale
+ * render, where a single flat colour would turn it into a silhouette.
+ */
+const buildDesk = prop((group, material) => {
+ addBox(group, material('wood'), [2.30, 0.12, 1.20], [0, 1.44, 0]);
+ for (const x of [-1.00, 1.00]) {
+ for (const z of [-0.46, 0.46]) {
+ addBox(group, material('accent'), [0.14, 1.38, 0.14], [x, 0.69, z]);
+ }
+ addBox(group, material('accent'), [0.12, 0.10, 1.00], [x, 0.42, 0]);
+ }
+});
+
+/**
+ * マイク. A round weighted base, a pole, a short boom and a body with a grille
+ * head, standing about as high as the character's hands.
+ */
+const buildMic = prop((group, material) => {
+ addCylinder(group, material('metal'), 0.40, 0.42, 0.10, [0, 0.05, 0], 16);
+ addCylinder(group, material('metal'), 0.048, 0.058, 1.35, [0, 0.775, 0], 10);
+ addStrut(group, material('metal'), [0, 1.42, 0], [0, 1.62, 0.28], 0.038);
+
+ const body = addCylinder(group, material('accent'), 0.13, 0.13, 0.44, [0, 1.80, 0.36], 12);
+ body.rotation.x = 0.30;
+ const head = addSphere(group, material('metal'), 0.145, [0, 2.05, 0.42], [12, 8]);
+ head.scale.set(1, 0.9, 1);
+});
+
+/**
+ * 看板. A post, a frame and a blank face. The face is a named child tagged
+ * `userData.canvasTexture`, so a later feature can paint a CanvasTexture onto it
+ * without hunting for the writable mesh. The face *and* its frame are grouped,
+ * so `applyPropFaceScale` can grow the whole board about the face's centre while
+ * the post and base stay put. Both are thin boxes rather than planes: the outline
+ * pass offsets geometry along its normals, and a zero-thickness plane gets a
+ * visibly doubled rim.
+ */
+const buildSign = prop((group, material) => {
+ addBox(group, material('accent'), [0.52, 0.10, 0.52], [0, 0.05, 0]);
+ addBox(group, material('wood'), [0.16, 1.62, 0.16], [0, 0.91, 0]);
+
+ // The board is one group so the face and its frame scale together, about the
+ // board's own centre - which is also the face's centre.
+ const board = new THREE.Group();
+ board.name = 'sign-board';
+ board.userData.faceScaleGroup = true;
+ board.position.set(0, 2.21, 0);
+ group.add(board);
+
+ addBox(board, material('accent'), [1.42, 0.98, 0.12], [0, 0, 0]);
+ const face = addBox(board, material('paper'), [1.18, 0.76, 0.08], [0, 0, 0.06]);
+ face.name = 'sign-face';
+ face.userData.canvasTexture = true;
+});
+
+/** The branches of 観葉植物: where a stem ends, which way its leaf points. */
+const PLANT_BRANCHES = [
+ { end: [0.05, 1.12, 0.04], dir: [0.10, 0.96, 0.14] },
+ { end: [-0.30, 0.98, -0.06], dir: [-0.52, 0.76, -0.30] },
+ { end: [0.28, 1.00, 0.14], dir: [0.48, 0.74, 0.40] },
+ { end: [-0.40, 0.80, -0.16], dir: [-0.86, 0.36, -0.28] },
+ { end: [0.38, 0.82, 0.16], dir: [0.88, 0.32, 0.26] },
+ { end: [-0.04, 1.06, -0.20], dir: [-0.14, 0.95, -0.28] },
+];
+
+/**
+ * 観葉植物. A tapered pot with a rim, dark soil, and leaves that are just
+ * squashed spheres aimed along each branch's direction - the cheapest way to get
+ * a soft, rounded leaf that still takes a clean outline.
+ */
+const buildPlant = prop((group, material) => {
+ addCylinder(group, material('accent'), 0.40, 0.28, 0.56, [0, 0.28, 0], 14);
+ tone(addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14), 0.18);
+ tone(addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14), 0.28);
+
+ for (const branch of PLANT_BRANCHES) {
+ addStrut(group, material('leaf', 0.6), [0, 0.56, 0], branch.end, 0.035);
+
+ const direction = new THREE.Vector3(branch.dir[0], branch.dir[1], branch.dir[2]).normalize();
+ const leaf = addSphere(group, material('leaf'), 0.30, [0, 0, 0], [8, 6]);
+ leaf.scale.set(0.46, 1, 0.22);
+ leaf.position.set(branch.end[0], branch.end[1], branch.end[2]).addScaledVector(direction, 0.26);
+ leaf.quaternion.setFromUnitVectors(UP, direction);
+ }
+});
+
+/**
+ * 段ボール箱. The body and two lid flaps, plus a tape strip when the lid is
+ * shut. The flaps are children of pivot groups sitting on the box's top edges,
+ * so `open: true` is a single rotation each. The flaps are a darker shade of the
+ * same wood part, which is what makes the box read as cardboard rather than a
+ * solid block.
+ */
+const buildBox = prop((group, material, options) => {
+ const flapsDown = options.open !== true;
+ addBox(group, material('wood'), [1.24, 1.00, 1.24], [0, 0.50, 0]);
+
+ const back = new THREE.Group();
+ back.position.set(0, 1.00, -0.62);
+ back.rotation.x = flapsDown ? 0 : -1.05;
+ group.add(back);
+ tone(addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]), 0.20);
+
+ const front = new THREE.Group();
+ front.position.set(0, 1.00, 0.62);
+ front.rotation.x = flapsDown ? 0 : 1.05;
+ group.add(front);
+ tone(addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]), 0.20);
+
+ if (flapsDown) addBox(group, material('paper', 0.95), [0.18, 0.05, 1.26], [0, 1.095, 0]);
+});
+
+/**
+ * 空いた缶詰. A short metal can, opened: a foot bead, the body, a label band and
+ * a mouth rim, plus the single lid peeled back on a hinge at the back of the
+ * mouth so it stands up behind the opening. It is deliberately
+ * small next to the 4-unit-tall character - something to be pointed at rather
+ * than stood behind - so every size here stays under a unit, and the whole can
+ * leans a little so it does not read as a diagram. The label is the one coloured
+ * part; everything else is the neutral `metal` grey, so it still reads as a tin.
+ */
+const buildCan = prop((group, material) => {
+ addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.025, 0], 16);
+ addCylinder(group, material('metal'), 0.145, 0.145, 0.40, [0, 0.20, 0], 16);
+ tone(addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16), 0.20);
+ addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.40, 0], 16);
+ // A darker disc just inside the rim so the mouth reads as empty, not capped.
+ tone(addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16), 0.30);
+
+ // The one lid, peeled back on a hinge at the back of the mouth so its
+ // underside shows. The hinge sits *on* the can's back rim and the disc is
+ // placed so its own rim passes through that same point (its centre is one
+ // radius in front of the hinge), so opening the hinge rotates the lid about
+ // its edge - the two rims stay joined instead of the lid floating in mid-air.
+ // It is twisted slightly sideways so it does not read as a knob sitting
+ // straight up on the can.
+ const lidRadius = 0.132;
+ const hinge = new THREE.Group();
+ hinge.position.set(0, 0.42, -lidRadius);
+ hinge.rotation.x = -1.05;
+ hinge.rotation.z = 0.4;
+ group.add(hinge);
+ addCylinder(hinge, material('metal', 0.9), lidRadius, lidRadius, 0.018, [0, 0, lidRadius], 18);
+
+ group.rotation.z = 0.05;
+});
+
+/**
+ * ベッド. A wooden frame on four legs with a mattress, a headboard at the far end
+ * and a pillow, sized so the character can be posed lying on it.
+ */
+const buildBed = prop((group, material) => {
+ // Sized to take the character lying down: the body is a sphere of radius ~1.6,
+ // so the frame is a little over 3 units across and 4 units long.
+ for (const x of [-1.55, 1.55]) {
+ for (const z of [-1.85, 1.85]) {
+ addBox(group, material('wood'), [0.22, 0.5, 0.22], [x, 0.25, z]);
+ }
+ }
+ addBox(group, material('wood'), [3.5, 0.26, 4.3], [0, 0.63, 0]);
+ // The headboard sits at the far (-z) end, away from the camera.
+ addBox(group, material('wood'), [3.5, 1.5, 0.2], [0, 1.25, -2.15]);
+ addBox(group, material('paper'), [3.3, 0.46, 4.1], [0, 0.99, 0]);
+
+ // The pillow, slightly tipped up against the headboard.
+ const pillow = addBox(group, material('paper', 0.9), [2.2, 0.34, 0.9], [0, 1.39, -1.5]);
+ pillow.rotation.x = -0.08;
+});
+
+/**
+ * 布団. A mattress laid on the floor, a pillow and a quilt folded over the lower
+ * end.
+ */
+const buildFuton = prop((group, material) => {
+ addBox(group, material('paper'), [3.6, 0.3, 4.4], [0, 0.15, 0]);
+ const pillow = addBox(group, material('paper', 0.9), [2.2, 0.3, 0.9], [0, 0.45, -1.6]);
+ pillow.rotation.x = -0.06;
+ addBox(group, material('accent', 0.8), [3.6, 0.24, 2.7], [0, 0.42, 0.85]);
+});
+
+// ---------------------------------------------------------------------------
+// The library
+// ---------------------------------------------------------------------------
+
+/**
+ * `height` is the approximate height in units, measured from the base, so the UI
+ * can offer a sensible scale. Where a prop has options, it is the height of the
+ * default variant: an open cardboard box stands about a third taller again, so
+ * the offered scale stays sensible either way.
+ */
+/** 鉛筆: an oversized pencil standing on its eraser, point up. */
+const buildPencil = prop((group, material) => {
+ const r = 0.13;
+ // Eraser, ferrule and lead are each a band of the same width against the body,
+ // so without a tone of their own they vanish into it in 線画.
+ tone(addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6), 0.20);
+ tone(addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6), 0.24);
+ addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6);
+ addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6);
+ // The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the
+ // two look joined, while its point still pokes out past the wood.
+ tone(addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6), 0.45);
+});
+
+/** コップ: a mug with a rim, a handle and a little drink inside. */
+const buildCup = prop((group, material) => {
+ addCylinder(group, material('paper'), 0.34, 0.30, 0.66, [0, 0.33, 0], 18);
+ addCylinder(group, material('paper', 0.86), 0.30, 0.30, 0.05, [0, 0.645, 0], 18);
+ addCylinder(group, material('wood', 0.5), 0.28, 0.28, 0.02, [0, 0.61, 0], 18);
+ addMesh(group, new THREE.TorusGeometry(0.16, 0.045, 8, 16), material('paper'), 0.36, 0.36, 0);
+});
+
+/** ノート: a fat pad of pages under a thin cover, bound along one long edge. */
+const buildNotebook = prop((group, material) => {
+ const width = 1.5;
+ const depth = 2.0;
+ const pages = 0.18;
+ const cover = 0.04;
+ // The cover pokes out past the pages, so the notebook reads from above.
+ addBox(group, material('wood', 0.6), [width + 0.08, cover, depth + 0.08], [0, cover / 2, 0]);
+ addBox(group, material('paper'), [width, pages, depth], [0, cover + pages / 2, 0]);
+ addBox(
+ group,
+ material('accent'),
+ [0.16, pages + 0.04, depth + 0.02],
+ [-width / 2 + 0.08, cover + pages / 2, 0],
+ );
+});
+
+/** 消しゴム: a chunky two-tone block, a hard accent top on a soft white base. */
+const buildEraser = prop((group, material) => {
+ const width = 0.9;
+ const depth = 0.5;
+ const half = 0.15;
+ addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]);
+ tone(addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]), 0.22);
+});
+
+export const PROP_LIBRARY = [
+ { id: 'podium', label: '演台', height: 2.2, build: buildPodium },
+ { id: 'desk', label: '机', height: 1.5, build: buildDesk },
+ { id: 'mic', label: 'マイク', height: 2.2, build: buildMic },
+ { id: 'sign', label: '看板', height: 2.7, build: buildSign },
+ { id: 'plant', label: '観葉植物', height: 1.7, build: buildPlant },
+ { id: 'box', label: '段ボール箱', height: 1.15, build: buildBox },
+ { id: 'can', label: '空いた缶詰', height: 0.65, build: buildCan },
+ { id: 'bed', label: 'ベッド', height: 2.0, build: buildBed },
+ { id: 'futon', label: '布団', height: 0.65, build: buildFuton },
+ { id: 'cup', label: 'コップ', height: 0.66, build: buildCup },
+ { id: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil },
+ { id: 'notebook', label: 'ノート', height: 0.22, build: buildNotebook },
+ { id: 'eraser', label: '消しゴム', height: 0.3, build: buildEraser },
+];
+
+/**
+ * Where a freshly added prop goes. The character's body is a sphere of radius
+ * ~1.6 around x = 0, z = 0, so anything closer than about 2.2 units would push
+ * into it; each prop therefore sits 2.6-3.0 units out, in the quadrant that
+ * suits its height, turned only slightly - a prop square to the camera reads as
+ * a diagram, and one turned all the way shows its side.
+ */
+export const PROP_DEFAULTS = {
+ podium: { x: -2.1, y: 0, z: 1.9, rotY: 0.30, scale: 1 },
+ desk: { x: -2.6, y: 0, z: 0.5, rotY: 0.40, scale: 1 },
+ mic: { x: 2.0, y: 0, z: 1.8, rotY: -0.40, scale: 1 },
+ // `faceScale` only means something for a 看板: it grows the writing area (the
+ // face and its frame) together, so the post and base stay put. The panel owns
+ // that control.
+ sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1, text: '' },
+ plant: { x: -2.0, y: 0, z: -1.8, rotY: 0.25, scale: 1 },
+ box: { x: 2.0, y: 0, z: -1.7, rotY: 0.55, scale: 1 },
+ can: { x: 1.7, y: 0, z: 1.5, rotY: -0.5, scale: 1 },
+ bed: { x: 0, y: 0, z: -4.7, rotY: 0, scale: 1 },
+ futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 },
+ pencil: { x: -1.4, y: 0, z: 2.6, rotY: 0.3, scale: 1 },
+ cup: { x: 1.5, y: 0, z: 2.5, rotY: -0.4, scale: 1 },
+ notebook: { x: -3.2, y: 0, z: -2.7, rotY: 0.35, scale: 1 },
+ eraser: { x: 3.6, y: 0, z: 2.6, rotY: -0.45, scale: 1 },
+};
+
+/** The palette entry each part reads, derived from the app's colours. */
+function derivePalette(colors = {}) {
+ const body = new THREE.Color(colors.body ?? FALLBACK_COLORS.body);
+ const accent = new THREE.Color(colors.accent ?? FALLBACK_COLORS.accent);
+ const leaf = new THREE.Color(colors.leaf ?? FALLBACK_COLORS.leaf);
+ return {
+ // Darkened rather than mixed towards black, so the surface keeps a hint of
+ // the theme's hue instead of turning into a grey.
+ wood: body.clone().multiplyScalar(0.72),
+ paper: body.clone().lerp(WHITE, 0.85),
+ accent,
+ leaf,
+ metal: new THREE.Color(METAL_GREY),
+ };
+}
+
+/**
+ * Builds one prop.
+ *
+ * @param {string} id one of PROP_LIBRARY's ids
+ * @param {object} [options]
+ * @param {object} [options.colors] the app's render.colors palette
+ * @param {string} [options.outlineColor] kept on the group for the outline pass
+ * @param {number} [options.scale] uniform scale for the whole prop
+ * @returns {THREE.Group} the prop, origin at its base centre, facing +z
+ */
+export function buildProp(id, options = {}) {
+ const spec = PROP_LIBRARY.find((entry) => entry.id === id);
+ if (!spec) {
+ const known = PROP_LIBRARY.map((entry) => entry.id).join(', ');
+ throw new Error(`unknown prop id ${JSON.stringify(id)}; known props: ${known}`);
+ }
+
+ const group = spec.build(options);
+ group.name = `prop:${id}`;
+ group.userData.propId = id;
+ group.userData.outlineColor = options.outlineColor ?? null;
+ if (Number.isFinite(options.scale) && options.scale !== 1) {
+ group.scale.setScalar(options.scale);
+ }
+ return applyPropColors(group, options.colors);
+}
+
+/**
+ * Recolours a built prop in place, for the colour-theme feature. Every mesh
+ * material this module creates carries `userData.part`, so the walk only has to
+ * look at those; anything else (a future prop built by hand) is left alone.
+ */
+export function applyPropColors(group, colors = {}) {
+ const palette = derivePalette(colors);
+ const seen = new Set();
+
+ group.traverse((object) => {
+ const list = Array.isArray(object.material) ? object.material : [object.material];
+ for (const material of list) {
+ if (!material || seen.has(material)) continue;
+ seen.add(material);
+ const part = material.userData?.part;
+ if (!part) continue;
+ const shade = Number.isFinite(material.userData.shade) ? material.userData.shade : 1;
+ material.color.copy(palette[part] ?? palette.wood).multiplyScalar(shade);
+ material.color.r = clamp01(material.color.r);
+ material.color.g = clamp01(material.color.g);
+ material.color.b = clamp01(material.color.b);
+ }
+ });
+
+ return group;
+}
+
+/**
+ * The writing surface of a prop: the mesh tagged `userData.canvasTexture`, i.e.
+ * the 看板's face. A prop without one returns `null`, so callers can treat every
+ * prop the same.
+ */
+export function propFace(group) {
+ let face = null;
+ group?.traverse?.((object) => {
+ if (!face && object.userData?.canvasTexture) face = object;
+ });
+ return face;
+}
+
+/**
+ * Scale the writing area - the 看板's face *and* the frame around it - about the
+ * board's own centre, so the post and base stay where they are. A prop that tags
+ * a group `faceScaleGroup` is scaled as a whole; anything else falls back to the
+ * writable mesh alone. `addBox` puts a mesh's origin at the box centre and the
+ * 看板's board group sits at the face centre, which is what makes this a scale
+ * about the centre rather than about the prop's base.
+ *
+ * WHY this is applied from the panel: the app rebuilds every prop from the state
+ * (`applyProps` in src/main.js) and hands `buildProp` only the whole-prop scale,
+ * so a per-item `faceScale` has to be re-applied to the fresh mesh afterwards.
+ */
+export function applyPropFaceScale(group, faceScale) {
+ let target = null;
+ group?.traverse?.((object) => {
+ if (!target && object.userData?.faceScaleGroup) target = object;
+ });
+ target ??= propFace(group);
+ if (!target) return;
+ const value = Number(faceScale);
+ const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1;
+ target.scale.setScalar(scale);
+}
+
+/**
+ * Paint text onto a prop's writing surface (the 看板's face), or clear it.
+ *
+ * The face's material gets a `CanvasTexture`, drawn here with a system font stack
+ * (no font file is needed, and Japanese falls back to whatever the device has).
+ * Long lines and long blocks are shrunk to fit the board, and explicit newlines
+ * are honoured. The material is per-prop (see `materialCache`), so the map never
+ * leaks to another sign.
+ */
+export function applyPropText(group, text) {
+ if (typeof document === 'undefined') return; // Node (tests): no canvas
+ const face = propFace(group);
+ const material = face?.material;
+ if (!material) return;
+ const value = typeof text === 'string' ? text : '';
+
+ if (!value.trim()) {
+ if (material.map) {
+ material.map.dispose();
+ material.map = null;
+ material.needsUpdate = true;
+ }
+ material.userData.signText = '';
+ return;
+ }
+ if (material.userData.signText === value) return; // nothing changed
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 512;
+ canvas.height = 330;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#f4efe2';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#241a30';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ const family = '"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif';
+ const lines = value.split(/\r\n|\r|\n/);
+ const maxWidth = canvas.width * 0.86;
+ let size = 150;
+ const fits = () => {
+ ctx.font = `700 ${size}px ${family}`;
+ return lines.every((line) => ctx.measureText(line).width <= maxWidth)
+ && lines.length * size * 1.15 <= canvas.height * 0.86;
+ };
+ while (size > 18 && !fits()) size -= 2;
+ ctx.font = `700 ${size}px ${family}`;
+ const step = size * 1.15;
+ const startY = canvas.height / 2 - ((lines.length - 1) * step) / 2;
+ lines.forEach((line, index) => ctx.fillText(line, canvas.width / 2, startY + index * step));
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 4;
+ if (material.map) material.map.dispose();
+ material.map = texture;
+ material.userData.signText = value;
+ material.needsUpdate = true;
+}
+
+/** The world-space box the prop occupies; the app drops its contact shadow from it. */
+export function propBounds(group) {
+ group.updateMatrixWorld(true);
+ return new THREE.Box3().setFromObject(group);
+}
+
+/** Releases every geometry and material in the prop and empties the group. */
+export function disposeProp(group) {
+ const geometries = new Set();
+ const materials = new Set();
+
+ group.traverse((object) => {
+ if (object.geometry) geometries.add(object.geometry);
+ const list = Array.isArray(object.material) ? object.material : [object.material];
+ for (const material of list) if (material) materials.add(material);
+ });
+
+ for (const geometry of geometries) geometry.dispose();
+ for (const material of materials) material.dispose();
+ group.clear();
+}
diff --git a/bluebey-studio/src/rig.js b/bluebey-studio/src/rig.js
new file mode 100644
index 0000000..e3c8a8e
--- /dev/null
+++ b/bluebey-studio/src/rig.js
@@ -0,0 +1,212 @@
+import * as THREE from 'three';
+import { TransformControls } from 'three/addons/controls/TransformControls.js';
+
+/**
+ * Posing. Each bone is driven as a rotation *relative to its rest pose*, so
+ * every bone reads 0/0/0 in the original stance and a saved pose stays valid
+ * even if the model is re-exported with different bone orientations.
+ *
+ * `bone.quaternion = rest * delta` and `delta = rest⁻¹ * quaternion`, with the
+ * delta expressed as XYZ Euler angles in degrees for the UI.
+ */
+
+const RAD = Math.PI / 180;
+const DEG = 180 / Math.PI;
+
+/** three sanitises node names, so `armsupport.l` arrives as `armsupportl`. */
+const normalizeName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase();
+
+/**
+ * The same angle shifted by whole turns to sit next to `reference`. On an exact
+ * half-turn tie the lower (smaller-magnitude) value wins, which keeps the
+ * canonical -180..180 reading when two are equally close.
+ */
+const nearestAngle = (angle, reference) => {
+ const turns = (reference - angle) / 360;
+ const low = angle + 360 * Math.floor(turns);
+ const high = angle + 360 * Math.ceil(turns);
+ return Math.abs(low - reference) <= Math.abs(high - reference) ? low : high;
+};
+
+/** True when two quaternions describe the same rotation (double cover included). */
+const sameRotation = (a, b) => Math.abs(Math.abs(a.dot(b)) - 1) < 1e-6;
+
+export class Rig {
+ constructor({ bones, scene, camera, domElement, orbit, pickTargets, onChange = null }) {
+ this.bones = bones;
+ this.byName = new Map();
+ for (const entry of bones) {
+ this.byName.set(entry.name, entry);
+ const alias = normalizeName(entry.name);
+ if (!this.byName.has(alias)) this.byName.set(alias, entry);
+ }
+ this.scene = scene;
+ this.domElement = domElement;
+ this.orbit = orbit;
+ this.pickTargets = pickTargets;
+ this.onChange = onChange;
+ this.selected = null;
+ // The delta last handed to (or written by) the UI, in degrees. An XYZ Euler
+ // has several equivalent triples for one rotation, so getDelta returns the
+ // one closest to this. That keeps a single-axis drag smooth through the
+ // wrap at 90 deg (the middle axis) instead of jumping to the gimbal-flipped
+ // twin, which made the sliders and the next setDelta spin the character.
+ this.lastDelta = new Map();
+
+ this.controls = new TransformControls(camera, domElement);
+ this.controls.setMode('rotate');
+ this.controls.setSpace('local');
+ this.controls.setSize(0.8);
+ // A *hidden* TransformControls still grabs the pointer in three: its picker
+ // stays raycastable even when the helper is not drawn, so grabbing the body
+ // (the rings sit on the `master` bone, right where you grab) silently turned
+ // the character. Keep the controls disabled until the gizmo is actually
+ // shown, so the mouse can only move the character (see setGizmoVisible).
+ this.controls.enabled = false;
+ this.helper = this.controls.getHelper();
+ this.helper.visible = false;
+ // Whether the user wants the gizmo at all. It starts OFF: the model is the
+ // point, and the rings sat on top of it until you went looking for them.
+ // Selecting a bone must not switch it back on by itself, so `select()`
+ // consults this instead of always showing the helper.
+ this.gizmoWanted = false;
+ scene.add(this.helper);
+
+ this.controls.addEventListener('dragging-changed', (event) => {
+ this.orbit.enabled = !event.value;
+ });
+ this.controls.addEventListener('objectChange', () => {
+ if (this.selected) this.onChange?.(this.selected);
+ });
+ }
+
+ /* ------------------------------------------------------------ selection */
+
+ select(name, { silent = false } = {}) {
+ if (name && !this.byName.has(name)) return;
+ this.selected = name ?? null;
+ if (name) {
+ this.controls.attach(this.byName.get(name).bone);
+ this.helper.visible = this.gizmoWanted;
+ } else {
+ this.controls.detach();
+ this.helper.visible = false;
+ }
+ // Only an actually-shown gizmo may take the pointer (see the constructor).
+ this.controls.enabled = this.helper.visible;
+ if (!silent) this.onChange?.(this.selected);
+ }
+
+ // Clicking the model no longer reaches for a bone: the mouse is for composing
+ // (see the drag handling in src/main.js), and bones are picked from the panel's
+ // bone list, which calls `select()` directly. So there is no pointer handler on
+ // the canvas here at all.
+
+ /* ----------------------------------------------------------------- pose */
+
+ getDelta(name) {
+ const entry = this.byName.get(name);
+ if (!entry) return { x: 0, y: 0, z: 0 };
+ const delta = entry.rest.clone().invert().multiply(entry.bone.quaternion);
+ const base = new THREE.Euler().setFromQuaternion(delta, 'XYZ');
+ const reference = this.lastDelta.get(entry) ?? { x: 0, y: 0, z: 0 };
+
+ // For one rotation an XYZ Euler has a second solution - its "flipped" twin
+ // `(x+180, 180-y, z+180)`. Three always returns the one whose middle angle
+ // stays within +/-90, so past 90 the twin is what continues the drag; offer
+ // both and keep whichever is closest to the last value. Near gimbal lock the
+ // twin no longer reproduces the rotation, so it is dropped by the check.
+ const candidates = [{ x: base.x, y: base.y, z: base.z }];
+ const flipped = new THREE.Euler(base.x + Math.PI, Math.PI - base.y, base.z + Math.PI, 'XYZ');
+ if (sameRotation(delta, new THREE.Quaternion().setFromEuler(flipped))) {
+ candidates.push({ x: flipped.x, y: flipped.y, z: flipped.z });
+ }
+
+ let best = { x: 0, y: 0, z: 0 };
+ let bestDistance = Infinity;
+ for (const candidate of candidates) {
+ const x = nearestAngle(candidate.x * DEG, reference.x);
+ const y = nearestAngle(candidate.y * DEG, reference.y);
+ const z = nearestAngle(candidate.z * DEG, reference.z);
+ const distance = (x - reference.x) ** 2 + (y - reference.y) ** 2 + (z - reference.z) ** 2;
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = { x, y, z };
+ }
+ }
+
+ this.lastDelta.set(entry, best);
+ return { ...best };
+ }
+
+ setDelta(name, { x = 0, y = 0, z = 0 }) {
+ const entry = this.byName.get(name);
+ if (!entry) return;
+ const delta = new THREE.Quaternion().setFromEuler(
+ new THREE.Euler(x * RAD, y * RAD, z * RAD, 'XYZ'),
+ );
+ entry.bone.quaternion.copy(entry.rest).multiply(delta);
+ // Remember exactly what the UI asked for so getDelta can hand it straight
+ // back (this keeps applyPose/getPose an exact round-trip). Keyed by the bone
+ // entry, not the name, so a raw name and its normalised alias agree.
+ this.lastDelta.set(entry, { x, y, z });
+ }
+
+ /** `pose.bones` maps a bone name to `[x, y, z]` degrees; `pose.root` is a translation. */
+ applyPose(pose = {}) {
+ // Presets and saved files use the glTF bone names (`armsupport.l`) while
+ // three sanitises them to `armsupportl`, so compare on the normalised form -
+ // otherwise every preset that touches an arm or leg is silently ignored.
+ const rotations = new Map();
+ for (const [key, value] of Object.entries(pose.bones ?? {})) {
+ rotations.set(normalizeName(key), value);
+ }
+ for (const entry of this.bones) {
+ const value = rotations.get(normalizeName(entry.name));
+ if (Array.isArray(value)) this.setDelta(entry.name, { x: value[0], y: value[1], z: value[2] });
+ else this.setDelta(entry.name, { x: 0, y: 0, z: 0 });
+ }
+ }
+
+ getPose({ onlyMoved = true } = {}) {
+ const rotations = {};
+ for (const entry of this.bones) {
+ const { x, y, z } = this.getDelta(entry.name);
+ const rounded = [round1(x), round1(y), round1(z)];
+ if (onlyMoved && rounded.every((value) => value === 0)) continue;
+ rotations[entry.name] = rounded;
+ }
+ return { bones: rotations };
+ }
+
+ reset(name) {
+ if (name) {
+ this.setDelta(name, { x: 0, y: 0, z: 0 });
+ return;
+ }
+ for (const entry of this.bones) this.setDelta(entry.name, { x: 0, y: 0, z: 0 });
+ }
+
+ /** True when every bone sits at its rest rotation. */
+ isRestPose() {
+ return this.bones.every((entry) => {
+ const { x, y, z } = this.getDelta(entry.name);
+ return Math.abs(x) < 0.01 && Math.abs(y) < 0.01 && Math.abs(z) < 0.01;
+ });
+ }
+
+ setGizmoVisible(visible) {
+ this.gizmoWanted = visible !== false;
+ this.helper.visible = this.gizmoWanted && this.selected != null;
+ // A hidden gizmo must not grab the pointer (see the constructor).
+ this.controls.enabled = this.helper.visible;
+ }
+
+ dispose() {
+ this.controls.detach();
+ this.controls.dispose();
+ this.helper.removeFromParent();
+ }
+}
+
+const round1 = (value) => Math.round(value * 10) / 10;
diff --git a/bluebey-studio/src/style.css b/bluebey-studio/src/style.css
new file mode 100644
index 0000000..a147b1f
--- /dev/null
+++ b/bluebey-studio/src/style.css
@@ -0,0 +1,880 @@
+:root {
+ --accent: #7b53d1;
+ --accent-soft: #efe8fd;
+ --accent-2: #b498ff;
+ --text: #2b2433;
+ --muted: #6f6682;
+ --border: #e4dff0;
+ --card: rgba(255, 255, 255, 0.94);
+ --radius: 12px;
+ --panel-width: 344px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ height: 100%;
+ height: 100vh;
+ /* `dvh` tracks the space mobile browser chrome leaves; the lines above are
+ the fallbacks for browsers that do not know it yet. */
+ height: 100dvh;
+ overflow: hidden;
+}
+
+body {
+ font-family: system-ui, -apple-system, "Segoe UI", "Hiragino Kaku Gothic ProN", "Noto Sans JP", Meiryo, sans-serif;
+ color: var(--text);
+ background: radial-gradient(120% 120% at 30% 0%, #f7f5fc 0%, #ece8f4 60%, #e4e0ee 100%);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ------------------------------------------------------------------ viewport */
+
+/*
+ * The canvas gets its size from #stage, never from its own width/height
+ * attributes: a canvas is a replaced element, so `width: auto` would make its
+ * layout follow those attributes - and since the renderer writes them, that
+ * becomes a resize feedback loop that leaves the picture blank.
+ */
+#stage {
+ position: fixed;
+ inset: 0;
+}
+
+body:not(.panel-hidden) #stage {
+ right: var(--panel-width);
+}
+
+#view {
+ display: block;
+ width: 100%;
+ height: 100%;
+ touch-action: none;
+ outline: none;
+}
+
+/* ------------------------------------------------------------------- topbar */
+
+#topbar {
+ position: fixed;
+ top: 14px;
+ left: 16px;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ max-width: calc(100vw - var(--panel-width) - 48px);
+}
+
+#topbar h1 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: #4b3a72;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7);
+}
+
+.topbar-actions { display: flex; flex-wrap: wrap; gap: 6px; }
+
+#topbar button {
+ font: inherit;
+ font-size: 12px;
+ padding: 6px 11px;
+ border-radius: 999px;
+ border: 1px solid rgba(123, 83, 209, 0.22);
+ background: rgba(255, 255, 255, 0.86);
+ color: #5a4a7d;
+ cursor: pointer;
+ backdrop-filter: blur(6px);
+ transition: background 0.15s, transform 0.1s;
+}
+
+#topbar button:hover { background: #fff; transform: translateY(-1px); }
+#topbar button:active { transform: translateY(0); }
+#topbar button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
+
+/* Shared by the mouse hint and the touch hint (see #viewport-hint-touch). */
+.viewport-hint {
+ position: fixed;
+ left: 18px;
+ bottom: 14px;
+ z-index: 10;
+ margin: 0;
+ font-size: 11.5px;
+ color: #8a80a0;
+ pointer-events: none;
+}
+
+/* Phones get a touch-specific hint instead of this mouse one; swapped in the
+ small-screen media query at the end of the viewport/panel section below. */
+#viewport-hint-touch { display: none; }
+
+/* AR (camera) mode's shutter: a camera-app button over the viewport. It is a DOM
+ layer, so it never appears in the exported PNG. */
+.ar-shutter {
+ position: absolute;
+ left: 50%;
+ bottom: calc(22px + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ z-index: 4;
+ width: 64px;
+ height: 64px;
+ padding: 0;
+ border-radius: 50%;
+ border: 4px solid #ffffff;
+ background: rgba(255, 255, 255, 0.35);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
+ cursor: pointer;
+}
+.ar-shutter::after {
+ content: "";
+ position: absolute;
+ inset: 6px;
+ border-radius: 50%;
+ background: #ffffff;
+}
+.ar-shutter:active::after { background: #d9d2e8; }
+.ar-shutter[hidden] { display: none; }
+
+/* -------------------------------------------------------------------- panel */
+
+#panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: var(--panel-width);
+ padding: 14px 14px 40px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: var(--card);
+ border-left: 1px solid var(--border);
+ backdrop-filter: blur(10px);
+ z-index: 30;
+ scrollbar-width: thin;
+}
+
+#panel::-webkit-scrollbar { width: 9px; }
+#panel::-webkit-scrollbar-thumb { background: #d8d1e8; border-radius: 9px; }
+
+/* The phone sheet's grab handle is a real element (built in the app) so it can
+ carry a drag; it is hidden everywhere else. */
+.panel-grip { display: none; }
+
+body.panel-hidden #panel { display: none; }
+
+body.panel-hidden #topbar { max-width: calc(100vw - 48px); }
+
+@media (max-width: 780px) {
+ :root { --panel-width: min(88vw, 344px); }
+ #topbar { max-width: calc(100vw - 60px); }
+}
+
+/*
+ * Phones and other short touch screens. The panel becomes a bottom sheet over
+ * the 3D view instead of a side column: the canvas keeps the full screen behind
+ * it, so opening the sheet does not resize the render (and the ≡ button, which
+ * already calls resizeViewport, is all the control needed).
+ */
+@media (max-width: 768px), (max-height: 500px) and (pointer: coarse) {
+ /* Full-screen canvas: the sheet floats over it, so its height never feeds
+ back into the renderer. */
+ #stage,
+ body:not(.panel-hidden) #stage {
+ inset: 0;
+ right: 0;
+ }
+
+ #topbar {
+ top: calc(8px + env(safe-area-inset-top));
+ left: calc(10px + env(safe-area-inset-left));
+ right: calc(10px + env(safe-area-inset-right));
+ max-width: none;
+ gap: 8px;
+ }
+
+ /* Let the title give way first (it can ellipsize); the buttons keep their
+ labels and stay on one line. */
+ #topbar h1 {
+ font-size: 13px;
+ white-space: nowrap;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .topbar-actions {
+ flex: 1;
+ flex-wrap: nowrap;
+ justify-content: flex-end;
+ gap: 6px;
+ }
+
+ /* Comfortable tap targets (>= 40px) in a row that still fits 360px. */
+ #topbar button {
+ font-size: 11px;
+ padding: 9px 10px;
+ min-height: 40px;
+ }
+
+ #btn-panel { min-width: 44px; }
+
+ #viewport-hint { display: none; }
+ #viewport-hint-touch {
+ display: block;
+ bottom: calc(14px + env(safe-area-inset-bottom));
+ }
+
+ #panel {
+ top: auto;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: auto;
+ max-height: 65dvh;
+ padding: 0 14px calc(20px + env(safe-area-inset-bottom));
+ border-left: 0;
+ border-top: 1px solid var(--border);
+ border-radius: 18px 18px 0 0;
+ box-shadow: 0 -6px 24px rgba(40, 20, 80, 0.18);
+ }
+
+ /* The grab handle: sticky at the top of the scroll area, so it stays grabbable
+ however far the menu is scrolled, and `touch-action: none` so a drag on it
+ resizes the sheet instead of scrolling it. */
+ .panel-grip {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: sticky;
+ top: 0;
+ z-index: 6;
+ height: 24px;
+ margin: 0 -14px;
+ background: var(--card);
+ touch-action: none;
+ cursor: grab;
+ }
+ .panel-grip::after {
+ content: "";
+ width: 44px;
+ height: 5px;
+ border-radius: 999px;
+ background: #cfc6e4;
+ }
+ .panel-grip:active { cursor: grabbing; }
+
+ body.panel-hidden #topbar { max-width: none; }
+
+ /* The grab handle is sticky at the top of the sheet, so the action bar and the
+ tabs start below it. */
+ .panel-topbar { top: 24px; }
+ .tabs { top: 58px; }
+}
+
+/* ----------------------------------------------------------------- sections */
+
+.tabs {
+ position: sticky;
+ /* Sits just below the sticky .panel-topbar (which is 34px tall). */
+ top: 34px;
+ z-index: 3;
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+ padding: 2px 0 0;
+ margin: -2px 0 8px;
+ border-bottom: 2px solid var(--border);
+ background: linear-gradient(var(--card) 72%, rgba(255, 255, 255, 0));
+}
+
+/* Each tab is a rounded-top tab sitting on the strip's baseline; only the icon
+ shows, and the active one fills with the accent and covers the baseline. */
+.tab {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font: inherit;
+ padding: 9px 2px 7px;
+ border: 1px solid var(--border);
+ border-bottom: none;
+ border-radius: 9px 9px 0 0;
+ background: var(--accent-soft);
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.tab svg { display: block; width: 20px; height: 20px; }
+.tab:hover { color: var(--accent-2); border-color: var(--accent-2); }
+.tab.active {
+ margin-bottom: -2px;
+ padding-bottom: 9px;
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+.tab-panel[hidden] { display: none; }
+
+.details {
+ border: 1px dashed var(--border);
+ border-radius: 9px;
+ padding: 0 9px;
+ background: #fdfcff;
+}
+.details > summary {
+ font-size: 11px;
+ color: var(--muted);
+ cursor: pointer;
+ padding: 6px 0;
+ list-style-position: inside;
+}
+.details[open] > summary { color: var(--accent); }
+.details-body { display: grid; gap: 8px; padding: 2px 0 9px; }
+
+.sec {
+ background: #fff;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-bottom: 10px;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgba(53, 38, 90, 0.04);
+}
+
+.sec-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 10px 12px;
+ margin: 0;
+ border: 0;
+ background: #fff;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ color: #4b3a72;
+ text-align: left;
+ cursor: pointer;
+}
+
+.sec-head .chev { color: var(--muted); font-size: 10px; transition: transform 0.18s; }
+.sec.closed .chev { transform: rotate(-90deg); }
+.sec-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.sec-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sec-icon { flex: 0 0 auto; width: 16px; height: 16px; color: var(--accent); }
+.sec-body { padding: 2px 12px 12px; display: grid; gap: 9px; }
+.sec.closed .sec-body { display: none; }
+
+/* -------------------------------------------------------------------- rows */
+
+.row { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 8px; align-items: center; }
+.row.wide { grid-template-columns: 1fr; gap: 5px; }
+.row > .label { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.control { display: flex; align-items: center; gap: 7px; min-width: 0; }
+.hint { font-size: 11px; line-height: 1.55; color: var(--muted); }
+.hint b { color: #5a4a7d; }
+.row.is-off .control, .row.is-off > .label { opacity: 0.4; }
+.row.is-off .control input { cursor: default; }
+.subhead {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: #8d80ab;
+ text-transform: uppercase;
+ margin-top: 3px;
+}
+.eye-group {
+ border-top: 1px solid var(--border);
+ margin-top: 4px;
+ padding-top: 8px;
+ display: grid;
+ gap: 8px;
+}
+.eye-group-head {
+ font-size: 11.5px;
+ font-weight: 700;
+ color: #6b5c92;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.eye-group-head .grp-icon { color: #8a7ab8; }
+.eye-group-body { display: grid; gap: 8px; }
+.select {
+ flex: 1;
+ min-width: 0;
+ font: inherit;
+ font-size: 11.5px;
+ padding: 5px 6px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--text);
+}
+.stack { display: grid; gap: 6px; }
+.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+
+input[type="range"] {
+ flex: 1;
+ min-width: 0;
+ height: 18px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.num {
+ flex: 0 0 auto;
+ min-width: 40px;
+ font-size: 11px;
+ color: #5a4a7d;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.seg { display: inline-flex; flex-wrap: wrap; padding: 2px; gap: 2px; background: #f2eff9; border-radius: 999px; }
+.seg button {
+ font: inherit;
+ font-size: 11px;
+ padding: 4px 9px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.seg button.active { background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(53, 38, 90, 0.16); }
+
+.btn {
+ font: inherit;
+ font-size: 11.5px;
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--text);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.btn:hover { border-color: var(--accent-2); }
+.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+.btn.primary:hover { background: #6b45c2; }
+.btn:disabled { opacity: 0.45; cursor: default; }
+.buttons { display: flex; flex-wrap: wrap; gap: 6px; }
+
+.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: #4b3a72; cursor: pointer; }
+.chk input { accent-color: var(--accent); width: 14px; height: 14px; }
+
+.pad-wrap { display: flex; gap: 10px; align-items: center; }
+.pad {
+ position: relative;
+ flex: 0 0 auto;
+ width: 118px;
+ height: 118px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background:
+ linear-gradient(#f0ecfa, #f0ecfa) 50% 0 / 1px 100% no-repeat,
+ linear-gradient(#f0ecfa, #f0ecfa) 0 50% / 100% 1px no-repeat,
+ #faf8ff;
+ cursor: crosshair;
+ touch-action: none;
+}
+.pad-dot {
+ position: absolute;
+ width: 13px;
+ height: 13px;
+ margin: -7px 0 0 -7px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35);
+ pointer-events: none;
+}
+.pad-eye {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 46px;
+ height: 46px;
+ border-radius: 50%;
+ border: 1px dashed #d9d1ec;
+ pointer-events: none;
+}
+
+/* Tail picker: a 3x3 grid where a dot's place is the direction it means, so
+ the whole pad fits next to an 82px label even in a narrow panel. */
+.tail-pad {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: repeat(3, 1fr);
+ place-items: center;
+ flex: 0 0 auto;
+ width: 128px;
+ height: 128px;
+ padding: 10px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: #faf8ff;
+}
+.tail-dot {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: #ded6f0;
+ cursor: pointer;
+ transition: background 0.12s, transform 0.1s;
+}
+.tail-dot:hover { background: var(--accent-2); transform: scale(1.1); }
+.tail-dot.active {
+ background: var(--accent);
+ box-shadow: 0 1px 4px rgba(53, 38, 90, 0.35);
+}
+
+.swatches { display: flex; gap: 5px; flex-wrap: wrap; }
+.swatch { width: 17px; height: 17px; border-radius: 5px; border: 1px solid rgba(43, 36, 51, 0.18); cursor: pointer; }
+input[type="color"] {
+ width: 30px;
+ height: 22px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: #fff;
+ cursor: pointer;
+}
+
+.bone-list {
+ max-height: 168px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ padding: 4px;
+ display: grid;
+ gap: 2px;
+ background: #fcfbff;
+}
+.bone-item {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 4px 8px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ font: inherit;
+ font-size: 11.5px;
+ color: var(--text);
+ text-align: left;
+ cursor: pointer;
+}
+.bone-item:hover { background: #f4f0fd; }
+.bone-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
+.bone-item small { color: var(--muted); font-size: 10.5px; }
+
+/* --------------------------------------------------------------------- 擬音 */
+
+/*
+ * The stamps are painted onto the caption canvas, which is `pointer-events:
+ * none`, so each one gets its own transparent drag box on top - the same trick
+ * the bubbles use. The box follows the stamp's rotation. Its z-index sits below
+ * the caption handles (3), because the bubbles are drawn over the stamps and so
+ * must stay grabbable where the two overlap.
+ */
+.gion-handle {
+ position: absolute;
+ display: none;
+ cursor: move;
+ touch-action: none;
+ pointer-events: auto;
+ z-index: 2;
+ border: 1px dashed transparent;
+ border-radius: 4px;
+}
+.gion-handle.selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(123, 83, 209, 0.18);
+}
+
+.gion-list { display: grid; gap: 10px; }
+.gion-item-head { display: flex; align-items: center; gap: 8px; }
+.gion-thumb {
+ flex: 0 0 auto;
+ width: 84px;
+ height: 56px;
+ background: #f3f0fa;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+}
+.gion-item.active .gion-thumb { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
+
+/* The picker covers the whole window, so even a big sheet has room to draw on. */
+.gion-picker {
+ position: fixed;
+ inset: 0;
+ z-index: 95;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px;
+ background: rgba(24, 16, 44, 0.86);
+ backdrop-filter: blur(4px);
+}
+.gion-picker-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ flex-wrap: wrap;
+ color: #fff;
+}
+.gion-picker-title { font-size: 13.5px; font-weight: 700; }
+.gion-picker-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.gion-picker-actions .hint { color: #cfc6e6; }
+.gion-picker-board {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ border-radius: 12px;
+ /* A checkerboard reads as "transparent", so a PNG sheet's holes are obvious. */
+ background:
+ linear-gradient(45deg, #2b2344 25%, transparent 25%) 0 0 / 22px 22px,
+ linear-gradient(-45deg, #2b2344 25%, transparent 25%) 0 11px / 22px 22px,
+ linear-gradient(45deg, transparent 75%, #2b2344 75%) 11px -11px / 22px 22px,
+ linear-gradient(-45deg, transparent 75%, #2b2344 75%) -11px 0 / 22px 22px,
+ #241d3a;
+ cursor: crosshair;
+ touch-action: none;
+}
+.gion-picker-sheet {
+ position: absolute;
+ display: block;
+ user-select: none;
+ -webkit-user-drag: none;
+ pointer-events: none;
+}
+.gion-picker-marquee {
+ position: absolute;
+ display: none;
+ border: 1.5px solid #fff;
+ background: rgba(180, 152, 255, 0.28);
+ box-shadow: 0 0 0 9999px rgba(20, 12, 40, 0.42);
+ pointer-events: none;
+}
+
+/* -------------------------------------------------------------------- toast */
+
+#notice {
+ position: fixed;
+ left: 50%;
+ top: 14px;
+ transform: translateX(-50%);
+ z-index: 95;
+ max-width: min(560px, 90vw);
+ padding: 10px 16px;
+ border-radius: 12px;
+ background: #fff4e5;
+ border: 1px solid #f0c48a;
+ color: #7a4a12;
+ font-size: 12px;
+ line-height: 1.65;
+ white-space: pre-line;
+ box-shadow: 0 6px 20px rgba(43, 36, 51, 0.15);
+}
+
+#notice[hidden] { display: none; }
+
+/* ------------------------------------------------ 起動時のご利用について */
+#start-notice {
+ position: fixed;
+ inset: 0;
+ z-index: 96;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(43, 36, 51, 0.5);
+ backdrop-filter: blur(3px);
+}
+#start-notice[hidden] { display: none; }
+
+.start-sheet {
+ width: min(560px, 100%);
+ max-height: 86vh;
+ overflow: auto;
+ padding: 22px 26px;
+ border-radius: 16px;
+ background: #fff;
+ color: #2b2433;
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
+ font-size: 13.5px;
+ line-height: 1.75;
+}
+.start-sheet h2 { margin: 0 0 12px; font-size: 17px; }
+.start-sheet p { margin: 0 0 10px; }
+.start-sheet ul { margin: 0 0 12px; padding-left: 1.3em; }
+.start-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+}
+.start-skip { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #6b5d7a; }
+.start-ok {
+ border: 0;
+ border-radius: 8px;
+ padding: 9px 22px;
+ background: #6b4ea8;
+ color: #fff;
+ font-size: 13.5px;
+ cursor: pointer;
+}
+
+#toast {
+ position: fixed;
+ left: 50%;
+ bottom: 26px;
+ transform: translate(-50%, 14px);
+ z-index: 70;
+ padding: 8px 16px;
+ border-radius: 999px;
+ background: rgba(43, 36, 51, 0.9);
+ color: #fff;
+ font-size: 12px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s, transform 0.2s;
+}
+#toast.show { opacity: 1; transform: translate(-50%, 0); }
+
+/* ------------------------------------------------------------------ loading */
+
+#loading {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+ display: grid;
+ place-items: center;
+ background: rgba(244, 242, 250, 0.94);
+ transition: opacity 0.35s;
+}
+#loading.done { opacity: 0; pointer-events: none; }
+
+.loading-box { text-align: center; max-width: 520px; padding: 24px; }
+.loading-spinner {
+ width: 34px;
+ height: 34px;
+ margin: 0 auto 14px;
+ border-radius: 50%;
+ border: 3px solid #e0d8f2;
+ border-top-color: var(--accent);
+ animation: spin 0.9s linear infinite;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+#loading-text { margin: 0; font-size: 13px; color: #5a4a7d; }
+#loading-error {
+ margin: 12px 0 0;
+ font-size: 12px;
+ line-height: 1.7;
+ color: #b3261e;
+ white-space: pre-wrap;
+ text-align: left;
+}
+
+/* --------------------------------------------------------------------- help */
+
+#help {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(43, 36, 51, 0.42);
+ backdrop-filter: blur(3px);
+}
+#help[hidden] { display: none; }
+
+.help-sheet {
+ position: relative;
+ width: min(760px, 100%);
+ max-height: min(84vh, 900px);
+ overflow-y: auto;
+ padding: 26px 30px 32px;
+ background: #fff;
+ border-radius: 18px;
+ box-shadow: 0 24px 60px rgba(20, 12, 40, 0.3);
+ font-size: 13px;
+ line-height: 1.75;
+}
+.help-sheet h2 { margin: 0 0 14px; font-size: 17px; color: #4b3a72; }
+.help-sheet h3 { margin: 20px 0 6px; font-size: 13.5px; color: var(--accent); }
+.help-sheet ul { margin: 0; padding-left: 1.25em; }
+.help-sheet li { margin: 3px 0; }
+.help-sheet code { background: #f3f0fa; padding: 1px 5px; border-radius: 5px; font-size: 12px; }
+.help-sheet .keys { list-style: none; padding: 0; display: grid; gap: 4px; }
+.help-close {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ width: 32px;
+ height: 32px;
+ border: 0;
+ border-radius: 50%;
+ background: #f3f0fa;
+ color: #5a4a7d;
+ font-size: 19px;
+ line-height: 1;
+ cursor: pointer;
+}
+.help-close:hover { background: #e8e2f7; }
+
+kbd {
+ display: inline-block;
+ min-width: 20px;
+ padding: 1px 6px;
+ border: 1px solid var(--border);
+ border-bottom-width: 2px;
+ border-radius: 5px;
+ background: #faf9fe;
+ font: inherit;
+ font-size: 11px;
+ text-align: center;
+}
+
+/* The always-visible action bar above the tabs: 元に戻す / やり直す / すべてランダム.
+ Sticky, so it (and the tabs below it) stay on screen while the panel scrolls. */
+.panel-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ background: #fdfcff;
+ display: flex;
+ gap: 4px;
+ justify-content: flex-end;
+ padding: 4px 8px 2px;
+}
+.panel-topbar .icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ color: #2b2433;
+ cursor: pointer;
+}
+.panel-topbar .icon-btn:hover { background: #f4f0fb; }
+.panel-topbar .icon-btn:active { transform: translateY(1px); }
diff --git a/bluebey-studio/src/styles.js b/bluebey-studio/src/styles.js
new file mode 100644
index 0000000..2e4f62d
--- /dev/null
+++ b/bluebey-studio/src/styles.js
@@ -0,0 +1,457 @@
+import * as THREE from 'three';
+
+/**
+ * Render styles for the body meshes, plus the outline pass.
+ *
+ * real the PBR materials exactly as authored in the GLB
+ * flat two-tone toon shading, the classic mascot look
+ * lineart white paper + lines, the face artwork drawn as strokes
+ * outline nothing but the lines, kept invisible via colorWrite = false so the
+ * result composites on top of anything (used by the SVG export)
+ *
+ * The lines come from two systems, each used where it is strong (see
+ * MODEL-GUIDE.md §5): every mesh gets an inverted-hull copy, and the thin
+ * overlapping leaves are handed to the screen-space pass instead (outline.js),
+ * which the caller arranges with `setHullHidden`.
+ */
+
+export const STYLE_DEFS = [
+ { value: 'real', label: 'リアル' },
+ { value: 'flat', label: 'フラット' },
+ { value: 'lineart', label: '線画' },
+];
+
+// 'outline' is still used internally by the SVG export (lines on a transparent
+// background), it is just no longer offered as a viewing style.
+const LINE_STYLES = new Set(['lineart', 'outline']);
+
+export const isLineStyle = (value) => LINE_STYLES.has(value);
+
+/** Parts that need no outline at all. */
+const OUTLINE_SKIP = new Set();
+
+/**
+ * Parts whose outline is only useful in the line-art styles.
+ *
+ * Empty now: the leaves used to be listed here, but the line styles get their
+ * leaf lines from the `Vein` faces themselves (see OUTLINE_SHADED_ONLY), so the
+ * hull is the wrong tool for them in either direction.
+ */
+const OUTLINE_LINE_ONLY = new Set();
+
+/**
+ * Parts that should be outlined in the SHADED styles only.
+ *
+ * The inverted hull draws an outline by expanding a copy of the mesh along its
+ * normals and rendering the back faces. That works on one big round body, but
+ * the leaf skirt is a dozen thin shells lying on top of each other: each hull's
+ * far side shows through its neighbours, which is what produced the faint
+ * overlapping hairlines. In the SVG pass the leaves get their line from their
+ * own `Vein` faces (filled with the ink colour) instead, so the hull is dropped
+ * there and kept for the cartoon styles, where it does read as an edge.
+ */
+const OUTLINE_SHADED_ONLY = new Set(['vein']);
+
+/**
+ * Per-part outline thickness, as a fraction of the global width.
+ *
+ * The hull is offset by a fixed distance in world units, so the right factor
+ * depends on how fine a part's mesh is compared with that offset:
+ *
+ * - the nose is only ~0.24 units across, and the offset that looks right on the
+ * body reads as a thick ring on it, hence the small factor. In the line-art
+ * styles it is the other way round (a hairline looks like dust on paper), so
+ * the nose gets a fuller line there - see OUTLINE_SCALE_LINE.
+ * - the leaves need a *larger* factor, which only shows up in the "hull only"
+ * style: the 13 blades overlap and interpenetrate, so each one's expanded
+ * shell cuts across its neighbours and the line breaks up. A thicker hull
+ * merges those scratches back into a band. Making the leaves thicker does NOT
+ * fix it - measured, see MODEL-GUIDE.md §5-4 - because the overlaps are the
+ * blocker, not the leaf's own thickness.
+ */
+const OUTLINE_SCALE = { nose: 0.4, leaf: 1.7, vein: 2.2 };
+
+/** Fuller lines for the paper-and-ink styles, where a hairline reads as dust. */
+const OUTLINE_SCALE_LINE = { nose: 0.9, leaf: 1.9, vein: 2.4 };
+
+/**
+ * Parts whose outline should only survive where the part itself sticks out past
+ * the body. Their hull is drawn first with depth testing off, so everything the
+ * body covers paints over it: the nose then only gets a line in the views where
+ * it actually pokes out of the silhouette, instead of a ring around it - which is
+ * what the original artwork does, the nose reads by its own colour there.
+ *
+ * In the line-art styles there is no colour to read it by (the nose is paper on
+ * paper), so this is switched off and the nose goes to the screen-space pass
+ * instead - see `screenParts` in main.js.
+ */
+const OUTLINE_SILHOUETTE_ONLY = new Set(['nose']);
+
+export class Styles {
+ constructor({ meshes, paper = '#ffffff', outlineColor = '#2a1e33', outlineWidth = 0.022 }) {
+ this.meshes = meshes;
+ this.paper = paper;
+ this.style = 'real';
+ this.outlineEnabled = true;
+ /**
+ * Source meshes whose hull outline must stay off, because another method
+ * (the screen-space pass) is drawing them. Empty = every hull is available.
+ * Only the leaves ever land here: see MODEL-GUIDE.md §5.
+ */
+ this.hullHidden = new Set();
+
+ this.originals = new Map();
+ for (const mesh of meshes) {
+ this.originals.set(mesh, mesh.material);
+ mesh.castShadow = true;
+ mesh.receiveShadow = false;
+ }
+
+ this.gradientMap = makeGradientMap();
+ this.toonMaterials = new Map();
+ this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false });
+ // A hat is not flat: in a line drawing a white hat on a white head merges
+ // into one shape, so hats get a light tone instead of paper (see addMesh).
+ // The tone is per-mesh (`toneMeshes`, mesh -> material) and the amount it
+ // steps from the paper towards the ink is cached by strength in
+ // `toneMaterials`, so a part on a same-coloured neighbour (a hat band, a
+ // pencil's lead) can ask for a darker fill and its seam then reads.
+ this.toneMaterials = new Map();
+ /** Runtime meshes that want a tone fill rather than paper in 線画. */
+ this.toneMeshes = new Map();
+ this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true });
+
+ this.outlineWidth = outlineWidth;
+ this.outlineColor = outlineColor;
+ /** scale -> { material, uniform }, so the width slider updates all of them. */
+ this.hullMaterials = new Map();
+ this.outlineMeshes = this.createOutlines();
+ this.setStyle('real');
+ }
+
+ /**
+ * The part kind, taken from the material the GLB shipped with.
+ *
+ * It must be read from `originals`, not from `mesh.material`: `setStyle`
+ * replaces the materials, and the replacements (paper, ink, toon) carry no
+ * name, so after the first swap every part would look like an unknown one and
+ * the leaf/nose special cases would silently stop applying.
+ */
+ kindOf(mesh) {
+ return (this.originals.get(mesh)?.name ?? '').toLowerCase();
+ }
+
+ hullMaterialFor(scale, overlay = false) {
+ const key = `${scale}:${overlay ? 'overlay' : 'solid'}`;
+ let entry = this.hullMaterials.get(key);
+ if (!entry) {
+ const uniform = { value: this.outlineWidth * scale };
+ const material = makeHullMaterial(this.outlineColor, uniform);
+ if (overlay) {
+ // Depth-*tested*, but writing none: the hull is drawn before the body
+ // (renderOrder -1), so the body still paints over its interior and the
+ // nose only gets a line where it pokes out of the silhouette. Testing
+ // (rather than ignoring) depth is what lets the 見えない壁 hide it too -
+ // with the test off, a nose buried in the wall left a filled blob,
+ // because nothing was left to paint over the hull's inside.
+ material.depthTest = true;
+ material.depthWrite = false;
+ }
+ entry = { uniform, material, scale };
+ this.hullMaterials.set(key, entry);
+ }
+ return entry.material;
+ }
+
+ /**
+ * The flat fill a runtime mesh wears instead of paper in 線画, cached by how far
+ * it steps from the paper towards the ink. `userData.tone` on the mesh (set by
+ * the builder) carries the amount; the hat default comes from `options.tone`.
+ */
+ toneMaterialFor(strength = DEFAULT_TONE) {
+ const key = String(strength);
+ let material = this.toneMaterials.get(key);
+ if (!material) {
+ material = new THREE.MeshBasicMaterial({
+ color: toneOf(this.paper, strength),
+ toneMapped: false,
+ });
+ material.userData.tone = strength;
+ this.toneMaterials.set(key, material);
+ }
+ return material;
+ }
+
+ createOutlines() {
+ const hulls = [];
+ for (const mesh of this.meshes) {
+ const kind = this.kindOf(mesh);
+ if (OUTLINE_SKIP.has(kind)) continue;
+ const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
+ const material = this.hullMaterialFor(this.scaleFor(kind), overlay);
+ const hull = mesh.isSkinnedMesh
+ ? new THREE.SkinnedMesh(mesh.geometry, material)
+ : new THREE.Mesh(mesh.geometry, material);
+ hull.name = `${mesh.name || 'mesh'}:outline`;
+ hull.position.copy(mesh.position);
+ hull.quaternion.copy(mesh.quaternion);
+ hull.scale.copy(mesh.scale);
+ hull.frustumCulled = false;
+ hull.castShadow = false;
+ hull.receiveShadow = false;
+ if (overlay) hull.renderOrder = -1;
+ hull.userData.kind = kind;
+ hull.userData.source = mesh;
+ hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind);
+ hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
+ if (hull.isSkinnedMesh) hull.bind(mesh.skeleton, mesh.bindMatrix);
+ mesh.parent.add(hull);
+ hulls.push(hull);
+ }
+ return hulls;
+ }
+
+ /**
+ * Register a mesh that is built at runtime (a hat), so it follows the render
+ * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it.
+ *
+ * `options.tone` gives the mesh the light tone fill in 線画 instead of paper,
+ * for parts whose shape would otherwise merge with the body (the hats). A
+ * number instead of `true` picks how far to step towards the ink, and the
+ * builder can override it per mesh with `userData.tone` - which is how a part
+ * draws its seam against a same-coloured neighbour.
+ *
+ * `options.lineOnly` keeps the hull to the line-art styles, so a runtime mesh
+ * (a hat, a prop) gets its ink outline in 線画 but none in リアル / フラット.
+ */
+ addMesh(mesh, options = {}) {
+ if (!mesh || this.originals.has(mesh)) return;
+ this.originals.set(mesh, mesh.material);
+ this.meshes.push(mesh);
+ const tone = mesh.userData.tone ?? options.tone;
+ if (tone) this.toneMeshes.set(mesh, this.toneMaterialFor(tone === true ? DEFAULT_TONE : tone));
+ const kind = this.kindOf(mesh);
+ if (!OUTLINE_SKIP.has(kind)) {
+ const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
+ const hull = new THREE.Mesh(mesh.geometry, this.hullMaterialFor(this.scaleFor(kind), overlay));
+ hull.name = `${mesh.name || 'mesh'}:outline`;
+ hull.position.copy(mesh.position);
+ hull.quaternion.copy(mesh.quaternion);
+ hull.scale.copy(mesh.scale);
+ hull.frustumCulled = false;
+ hull.castShadow = false;
+ hull.receiveShadow = false;
+ if (overlay) hull.renderOrder = -1;
+ hull.userData.kind = kind;
+ hull.userData.source = mesh;
+ hull.userData.lineOnly = options.lineOnly === true || OUTLINE_LINE_ONLY.has(kind);
+ hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
+ mesh.parent?.add(hull);
+ this.outlineMeshes.push(hull);
+ mesh.userData.hull = hull;
+ }
+ this.setStyle(this.style);
+ }
+
+ /** Take a runtime mesh (and its outline hull) back out. */
+ removeMesh(mesh) {
+ if (!mesh || !this.originals.has(mesh)) return;
+ const original = this.originals.get(mesh);
+ const hull = mesh.userData.hull;
+ if (hull) {
+ hull.removeFromParent();
+ const i = this.outlineMeshes.indexOf(hull);
+ if (i >= 0) this.outlineMeshes.splice(i, 1);
+ delete mesh.userData.hull;
+ }
+ // Put the mesh's own material back before dropping it: while a line style is
+ // on it is wearing the *shared* paper/tone material, and the caller is about
+ // to dispose it - which would blank every part using it.
+ if (original) mesh.material = original;
+ this.originals.delete(mesh);
+ this.toneMeshes.delete(mesh);
+ const j = this.meshes.indexOf(mesh);
+ if (j >= 0) this.meshes.splice(j, 1);
+ }
+
+ /** The outline factor for a part in the current style (line art wants more). */
+ scaleFor(kind) {
+ const line = LINE_STYLES.has(this.style);
+ const table = line ? OUTLINE_SCALE_LINE : OUTLINE_SCALE;
+ return table[kind] ?? OUTLINE_SCALE[kind] ?? 1;
+ }
+
+ setStyle(value) {
+ this.style = value;
+ const line = LINE_STYLES.has(value);
+ for (const mesh of this.meshes) {
+ const original = this.originals.get(mesh);
+ let material;
+ if (line) {
+ // Paper on paper in `lineart`; a light tone for hats, which would
+ // otherwise merge into the head; invisible in `outline`, which is the pass
+ // the SVG trace reads. Either way every edge - the body, the nose, and
+ // the leaves - is drawn by the screen-space outline, so no part needs a
+ // material trick of its own any more.
+ material = value === 'lineart'
+ ? (this.toneMeshes.get(mesh) ?? this.paperMaterial)
+ : this.invisibleMaterial;
+ } else if (value === 'flat') {
+ material = this.toonFor(original);
+ } else {
+ material = original;
+ }
+ if (mesh.material !== material) mesh.material = material;
+ }
+
+ // The nose only keeps a line where it pokes out of the body in the shaded
+ // styles; in a line drawing it is handed to the screen-space pass instead
+ // (see `screenParts` in main.js), because a hull cannot ring a bump that sits
+ // flush on the surface it is drawn on - the expanded shell lands *inside* the
+ // body and loses the depth test.
+ for (const hull of this.outlineMeshes) {
+ const kind = hull.userData.kind;
+ const silhouetteOnly = OUTLINE_SILHOUETTE_ONLY.has(kind) && !line;
+ hull.material = this.hullMaterialFor(this.scaleFor(kind), silhouetteOnly);
+ hull.renderOrder = silhouetteOnly ? -1 : 0;
+ }
+
+ // Lines are the whole point of the line-art styles.
+ this.setOutlineVisible(line ? true : this.outlineEnabled);
+ }
+
+ /**
+ * Hand a set of parts to the screen-space outline, or take them back.
+ *
+ * The hull and the screen-space pass each have a shape they cannot draw: a hull
+ * cannot outline a thin closed shell (the leaves), and the screen-space pass
+ * draws a stepped line because it works on the pixel grid. So the leaves go to
+ * the screen-space pass and everything else keeps its hull, which is drawn from
+ * the geometry and therefore comes out smooth.
+ */
+ setHullHidden(meshes) {
+ this.hullHidden = new Set(meshes ?? []);
+ this.setStyle(this.style);
+ }
+
+ toonFor(original) {
+ let material = this.toonMaterials.get(original);
+ if (!material) {
+ material = new THREE.MeshToonMaterial({
+ color: original.color ? original.color.clone() : new THREE.Color(0xffffff),
+ map: original.map ?? null,
+ vertexColors: original.vertexColors === true,
+ gradientMap: this.gradientMap,
+ side: original.side,
+ transparent: original.transparent === true,
+ alphaTest: original.alphaTest ?? 0,
+ depthWrite: original.depthWrite !== false,
+ });
+ this.toonMaterials.set(original, material);
+ }
+ return material;
+ }
+
+ setPaper(color) {
+ this.paper = color;
+ this.paperMaterial.color.set(color);
+ for (const material of this.toneMaterials.values()) {
+ material.color.copy(toneOf(color, material.userData.tone));
+ }
+ }
+
+ setOutlineVisible(visible) {
+ const lineMode = LINE_STYLES.has(this.style);
+ for (const hull of this.outlineMeshes) {
+ const lineOnly = hull.userData.lineOnly === true;
+ const shadedOnly = hull.userData.shadedOnly === true;
+ hull.visible = visible
+ && (!lineOnly || lineMode)
+ && !(shadedOnly && lineMode)
+ && !this.hullHidden.has(hull.userData.source);
+ }
+ }
+
+ setOutlineEnabled(enabled) {
+ this.outlineEnabled = enabled;
+ if (!LINE_STYLES.has(this.style)) this.setOutlineVisible(enabled);
+ }
+
+ setOutlineWidth(width) {
+ this.outlineWidth = width;
+ for (const entry of this.hullMaterials.values()) entry.uniform.value = width * entry.scale;
+ }
+
+ setOutlineColor(color) {
+ this.outlineColor = color;
+ for (const entry of this.hullMaterials.values()) entry.material.color.set(color);
+ }
+
+ /**
+ * The toon materials are cached copies of the originals, so a colour theme that
+ * edits `original.color` has to be copied across or the flat style keeps
+ * showing the old colour. See src/look.js.
+ */
+ refreshColors() {
+ for (const [mesh, original] of this.originals) {
+ const toon = this.toonMaterials.get(original);
+ if (toon && original.color) toon.color.copy(original.color);
+ // The outline hull is a colour of its own, so it is left alone.
+ void mesh;
+ }
+ return this;
+ }
+
+ dispose() {
+ for (const hull of this.outlineMeshes) {
+ hull.removeFromParent();
+ hull.skeleton = null;
+ }
+ this.outlineMeshes = [];
+ for (const entry of this.hullMaterials.values()) entry.material.dispose();
+ this.hullMaterials.clear();
+ this.paperMaterial.dispose();
+ for (const material of this.toneMaterials.values()) material.dispose();
+ this.toneMaterials.clear();
+ this.invisibleMaterial.dispose();
+ this.gradientMap.dispose();
+ for (const material of this.toonMaterials.values()) material.dispose();
+ this.toonMaterials.clear();
+ }
+}
+
+/** How far the hat default steps from the paper towards the ink in 線画. */
+const DEFAULT_TONE = 0.14;
+
+/** A step towards the ink, for the flat fill a runtime mesh gets in 線画. */
+function toneOf(paper, strength = DEFAULT_TONE) {
+ return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), strength);
+}
+
+/** A 3-step ramp gives crisper cartoon bands than the shader's default. */
+function makeGradientMap() {
+ const steps = new Uint8Array([90, 165, 255]);
+ const texture = new THREE.DataTexture(steps, steps.length, 1, THREE.RedFormat);
+ texture.minFilter = THREE.NearestFilter;
+ texture.magFilter = THREE.NearestFilter;
+ texture.generateMipmaps = false;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+function makeHullMaterial(color, uniform) {
+ const material = new THREE.MeshBasicMaterial({
+ color,
+ side: THREE.BackSide,
+ toneMapped: false,
+ });
+ material.onBeforeCompile = (shader) => {
+ shader.uniforms.uOutline = uniform;
+ shader.vertexShader = `uniform float uOutline;\n${shader.vertexShader}`.replace(
+ '#include ',
+ '#include \n\ttransformed += normal * uOutline;',
+ );
+ };
+ material.customProgramCacheKey = () => 'bluebey-outline-hull';
+ return material;
+}
diff --git a/bluebey-studio/src/textOutlines.js b/bluebey-studio/src/textOutlines.js
new file mode 100644
index 0000000..334a92d
--- /dev/null
+++ b/bluebey-studio/src/textOutlines.js
@@ -0,0 +1,432 @@
+import opentype from 'opentype.js';
+
+/**
+ * Caption text as outlines.
+ *
+ * A caption drawn with changes shape in every viewer, because the glyphs
+ * come from whatever font that viewer happens to have. The studio ships one
+ * subset font (M PLUS Rounded 1c) and this module turns the caption into plain
+ * SVG paths instead, so an exported SVG looks the same everywhere and stays
+ * editable as vector art.
+ *
+ * The module runs unchanged in the browser and in Node: it touches no DOM and no
+ * Node built-in at module scope, so the tests can parse the font straight from
+ * disk. `loadFont` is the only asynchronous export; everything else is a pure
+ * function of the font, the text and the options.
+ *
+ * Widths are summed one character at a time, without kerning between them. That
+ * costs a fraction of a pixel on Latin pairs but keeps measuring and wrapping in
+ * exact agreement, which matters more for a short caption.
+ */
+
+/** Families tried in order when a caption falls back to live . */
+const FALLBACK_FAMILIES = [
+ 'M PLUS Rounded 1c',
+ 'Hiragino Maru Gothic ProN',
+ 'Yu Gothic',
+ 'Meiryo',
+ 'sans-serif',
+];
+
+/** CSS keywords that must stay unquoted inside a font stack. */
+const GENERIC_FAMILIES = new Set([
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji',
+ 'fangsong',
+]);
+
+/**
+ * Closing marks and brackets that should not start a line.
+ *
+ * A full kinsoku table needs per-font metrics; this short list covers the marks
+ * a caption actually uses, and dragging the preceding character down is enough
+ * to fix the rest.
+ */
+const CLOSING_PUNCTUATION = new Set([
+ '、', '。', ',', '.', ':', ';', '!', '?',
+ ')', ']', '}', '〕', '〉', '》', '」', '』', '】', '〗', '〙', '〟',
+ '”', '’', '⦆', '»',
+]);
+
+const SPACE = /\s/;
+const LINE_BREAK = /\r\n|\r|\n/;
+
+const DEFAULT_FONT_SIZE = 16;
+const DEFAULT_LINE_HEIGHT = 1.4;
+
+/**
+ * Parsed fonts, keyed by URL. The stored value is the in-flight promise, so two
+ * callers asking for the same URL share one request instead of loading twice.
+ */
+const fontCache = new Map();
+
+/** Family name of the most recent font from `loadFont`, read by `captionFontStack`. */
+let loadedFamily = null;
+
+/**
+ * Load a font and remember it under `url`.
+ *
+ * Resolves to an `opentype.Font`; rejects if the font cannot be fetched or
+ * parsed, and does not cache that failure, so a later call can retry.
+ *
+ * @param {string} url URL (browser) or file path (Node) of the font
+ * @returns {Promise}
+ */
+export async function loadFont(url) {
+ if (!url) throw new Error('loadFont: a font URL is required');
+ if (fontCache.has(url)) return fontCache.get(url);
+
+ const pending = opentype.load(url).then((font) => {
+ loadedFamily = familyNameOf(font) ?? loadedFamily;
+ return font;
+ });
+ // A rejected promise left in the cache would make every retry fail forever.
+ pending.catch(() => fontCache.delete(url));
+ fontCache.set(url, pending);
+ return pending;
+}
+
+/**
+ * Synchronous twin of `loadFont` for tests and offline use.
+ *
+ * @param {ArrayBuffer} arrayBuffer font bytes; a typed-array view is accepted too
+ * @returns {import('opentype.js').Font}
+ */
+export function parseFont(arrayBuffer) {
+ // `opentype.parse` needs a real ArrayBuffer, so unwrap a view (for example a
+ // Node Buffer) before handing it over.
+ const view = ArrayBuffer.isView(arrayBuffer) ? arrayBuffer : null;
+ const buffer = view
+ ? view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)
+ : arrayBuffer;
+ return opentype.parse(buffer);
+}
+
+/**
+ * Does the font really cover every character of `text`?
+ *
+ * Callers use this to decide between outlines and live text: a single missing
+ * glyph means the caption would come out with a hole in it, so the answer is
+ * then `false`. Newlines carry no glyph and are ignored.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {string} text
+ * @returns {boolean}
+ */
+export function hasGlyphs(font, text) {
+ if (!font || typeof text !== 'string') return false;
+ for (const ch of text) {
+ if (ch === '\n' || ch === '\r') continue;
+ if (font.charToGlyphIndex(ch) === 0) return false; // 0 is .notdef
+ }
+ return true;
+}
+
+/**
+ * Size one string in pixels.
+ *
+ * The width is the widest line, so the same call works for a single line and for
+ * text that already contains breaks. `ascent` is above the baseline and
+ * `descent` below it, both in the font's own sign convention.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {string} text
+ * @param {number} [fontSize]
+ * @returns {{width: number, ascent: number, descent: number}}
+ */
+export function measureText(font, text, fontSize = DEFAULT_FONT_SIZE) {
+ const size = Number.isFinite(fontSize) ? fontSize : DEFAULT_FONT_SIZE;
+ const scale = size / (font.unitsPerEm || 1000);
+
+ let width = 0;
+ for (const line of String(text ?? '').split(LINE_BREAK)) {
+ width = Math.max(width, measureLine(font, line, size));
+ }
+ return { width, ascent: font.ascender * scale, descent: font.descender * scale };
+}
+
+/**
+ * Wrap `text` into lines and report the block's size.
+ *
+ * Rules, in the order they apply:
+ * - an explicit `\n` always ends a line;
+ * - a Latin word breaks only at a space, never in the middle;
+ * - CJK characters break anywhere, one break opportunity per character;
+ * - a piece that is too wide on its own still gets a line (nothing is dropped);
+ * - a line is not allowed to start with closing punctuation when dragging the
+ * previous character down would avoid it.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {string} text
+ * @param {object} [options]
+ * @param {number} [options.fontSize=16]
+ * @param {number|null} [options.maxWidth=0] 0/null/undefined means "no wrapping"
+ * @param {number} [options.lineHeight=1.4] multiplier of `fontSize`
+ * @param {'left'|'center'|'right'} [options.align='left']
+ * @returns {{
+ * lines: Array<{text: string, width: number}>,
+ * width: number, height: number, lineHeight: number,
+ * fontSize: number, align: string,
+ * }}
+ */
+export function layoutText(font, text, options = {}) {
+ const opts = options ?? {};
+ const fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : DEFAULT_FONT_SIZE;
+ const lineHeight = Number.isFinite(opts.lineHeight) ? opts.lineHeight : DEFAULT_LINE_HEIGHT;
+ const align = opts.align === 'center' || opts.align === 'right' ? opts.align : 'left';
+ const maxWidth = opts.maxWidth;
+ const wrapWidth = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity;
+
+ const lines = [];
+ for (const paragraph of String(text ?? '').split(LINE_BREAK)) {
+ const atoms = tokenise(font, paragraph, fontSize);
+ for (const wrapped of wrapAtoms(atoms, wrapWidth)) {
+ lines.push({
+ text: wrapped.map((atom) => atom.text).join(''),
+ width: wrapped.reduce((sum, atom) => sum + atom.width, 0),
+ });
+ }
+ }
+
+ let width = 0;
+ for (const line of lines) width = Math.max(width, line.width);
+
+ const height = lines.length * fontSize * lineHeight;
+ return { lines, width, height, lineHeight, fontSize, align };
+}
+
+/**
+ * Convert a layout to one SVG path `d` string.
+ *
+ * `x`/`y` is the top-left corner of the text block. Each line sits on its own
+ * baseline, placed with half-leading so a line box of `fontSize * lineHeight`
+ * surrounds the glyphs evenly, and shifted sideways by `layout.align`.
+ *
+ * Characters the font does not cover are skipped rather than drawn as .notdef,
+ * and a line whose outline cannot be built cleanly is dropped, so the result
+ * never carries `NaN` into the document.
+ *
+ * @param {import('opentype.js').Font} font
+ * @param {object} layout value returned by `layoutText`
+ * @param {object} [options]
+ * @param {number} [options.x=0]
+ * @param {number} [options.y=0]
+ * @param {number} [options.round=2] decimal places in the output
+ * @returns {string}
+ */
+export function textToPathData(font, layout, options = {}) {
+ const opts = options ?? {};
+ const x = Number.isFinite(opts.x) ? opts.x : 0;
+ const y = Number.isFinite(opts.y) ? opts.y : 0;
+ const round = Number.isFinite(opts.round) ? Math.max(0, Math.floor(opts.round)) : 2;
+
+ if (!font || !layout || !Array.isArray(layout.lines) || layout.lines.length === 0) return '';
+
+ const fontSize = Number.isFinite(layout.fontSize) ? layout.fontSize : DEFAULT_FONT_SIZE;
+ const lineHeight = Number.isFinite(layout.lineHeight) ? layout.lineHeight : DEFAULT_LINE_HEIGHT;
+ const blockWidth = Number.isFinite(layout.width) ? layout.width : 0;
+
+ const step = fontSize * lineHeight;
+ const scale = fontSize / (font.unitsPerEm || 1000);
+ const ascent = font.ascender * scale;
+ const descent = -font.descender * scale; // depth below the baseline, positive
+ const halfLeading = (step - (ascent + descent)) / 2;
+
+ const parts = [];
+ for (let i = 0; i < layout.lines.length; i++) {
+ const line = layout.lines[i];
+ const drawable = drawableText(font, line.text);
+ if (!drawable) continue;
+
+ const baseline = y + i * step + halfLeading + ascent;
+ const lineX = x + alignOffset(layout.align, blockWidth, line.width);
+ const d = font.getPath(drawable, lineX, baseline, fontSize).toPathData(round);
+ if (!d || d.includes('NaN') || d.includes('Infinity')) continue;
+ parts.push(d);
+ }
+ return parts.join(' ');
+}
+
+/**
+ * The CSS `font-family` the app should use for live `` or canvas captions.
+ *
+ * Starts with the family of the most recently loaded font, so the fallback text
+ * looks as close as possible to the outlines, and ends with Japanese-safe
+ * families that exist on the machines the studio runs on.
+ *
+ * @returns {string}
+ */
+export function captionFontStack() {
+ const families = [loadedFamily ?? FALLBACK_FAMILIES[0]];
+ const seen = new Set(families.map((name) => name.toLowerCase()));
+ for (const name of FALLBACK_FAMILIES) {
+ if (seen.has(name.toLowerCase())) continue;
+ seen.add(name.toLowerCase());
+ families.push(name);
+ }
+ return families.map(cssFamily).join(', ');
+}
+
+// --- internals ---------------------------------------------------------------
+
+/** Width of one line of text, in pixels. */
+function measureLine(font, line, fontSize) {
+ let width = 0;
+ for (const ch of line) width += font.getAdvanceWidth(ch, fontSize);
+ return width;
+}
+
+/** Family name of a font, or `null` when it does not carry one. */
+function familyNameOf(font) {
+ const names = font?.names?.fontFamily;
+ if (!names) return null;
+ // A parsed font stores one entry per language tag; a font built from scratch
+ // stores a plain string. Accept both.
+ const value = typeof names === 'string' ? names : names.en ?? Object.values(names)[0];
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
+}
+
+/** Only the characters the font can actually draw, newlines removed. */
+function drawableText(font, text) {
+ let out = '';
+ for (const ch of String(text ?? '')) {
+ if (ch === '\n' || ch === '\r') continue;
+ if (font.charToGlyphIndex(ch) === 0) continue; // no outline to draw
+ out += ch;
+ }
+ return out;
+}
+
+/** Sideways shift of a line inside the block, for the block's alignment. */
+function alignOffset(align, blockWidth, lineWidth) {
+ const slack = blockWidth - lineWidth;
+ if (align === 'center') return slack / 2;
+ if (align === 'right') return slack;
+ return 0;
+}
+
+/** Quote a family for CSS unless it is a generic keyword. */
+function cssFamily(name) {
+ if (GENERIC_FAMILIES.has(name.toLowerCase())) return name;
+ return `'${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+}
+
+/** `true` for scripts that may break between any two characters. */
+function isCjk(ch) {
+ const c = ch.codePointAt(0);
+ return (
+ (c >= 0x1100 && c <= 0x11ff) || // Hangul Jamo
+ (c >= 0x2e80 && c <= 0x303f) || // radicals, CJK punctuation
+ (c >= 0x3040 && c <= 0x30ff) || // hiragana, katakana
+ (c >= 0x3130 && c <= 0x318f) || // Hangul compatibility Jamo
+ (c >= 0x3400 && c <= 0x4dbf) || // CJK extension A
+ (c >= 0x4e00 && c <= 0x9fff) || // CJK unified ideographs
+ (c >= 0xa960 && c <= 0xa97f) || // Hangul Jamo extended A
+ (c >= 0xac00 && c <= 0xd7ff) || // Hangul syllables
+ (c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs
+ (c >= 0xfe10 && c <= 0xfe4f) || // vertical and compatibility forms
+ (c >= 0xff00 && c <= 0xffef) || // fullwidth and halfwidth forms
+ (c >= 0x1f200 && c <= 0x1f2ff) || // enclosed ideographic supplement
+ (c >= 0x20000 && c <= 0x2fa1f) // CJK extensions B onwards
+ );
+}
+
+/** One breakable piece of text together with its advance width. */
+function makeAtom(font, text, fontSize, space, closing) {
+ return { text, width: font.getAdvanceWidth(text, fontSize), space, closing };
+}
+
+/**
+ * Split a paragraph into the smallest pieces a line may break between.
+ *
+ * A Latin run stays one atom so it can never be split; every CJK character is
+ * its own atom so a break may fall on either side of it.
+ *
+ * @returns {Array<{text: string, width: number, space: boolean, closing: boolean}>}
+ */
+function tokenise(font, paragraph, fontSize) {
+ const atoms = [];
+ let word = null;
+
+ const endWord = () => {
+ if (word !== null) {
+ atoms.push(word);
+ word = null;
+ }
+ };
+
+ for (const ch of paragraph) {
+ if (SPACE.test(ch)) {
+ endWord();
+ atoms.push(makeAtom(font, ch, fontSize, true, false));
+ } else if (isCjk(ch)) {
+ endWord();
+ atoms.push(makeAtom(font, ch, fontSize, false, CLOSING_PUNCTUATION.has(ch)));
+ } else {
+ if (word === null) word = makeAtom(font, '', fontSize, false, false);
+ word.text += ch;
+ word.width += font.getAdvanceWidth(ch, fontSize);
+ }
+ }
+ endWord();
+ return atoms;
+}
+
+/**
+ * Greedy line breaker over atoms. Always returns at least one line, so an empty
+ * paragraph comes back as one empty line and explicit breaks are preserved.
+ *
+ * @param {Array} atoms
+ * @param {number} maxWidth `Infinity` disables wrapping
+ * @returns {Array>}
+ */
+function wrapAtoms(atoms, maxWidth) {
+ const lines = [];
+ let current = [];
+
+ const currentWidth = () => current.reduce((sum, atom) => sum + atom.width, 0);
+
+ const finish = () => {
+ // A break swallows the spaces next to it, so no line ends or starts blank.
+ while (current.length > 0 && current[current.length - 1].space) current.pop();
+ while (current.length > 0 && current[0].space) current.shift();
+ lines.push(current);
+ current = [];
+ };
+
+ for (const atom of atoms) {
+ if (current.length === 0) {
+ if (atom.space) continue; // never start a line with a space
+ current.push(atom);
+ continue;
+ }
+ if (currentWidth() + atom.width <= maxWidth) {
+ current.push(atom);
+ continue;
+ }
+
+ // The atom does not fit. Keep closing punctuation off the start of the next
+ // line by dragging the previous character down, but only when that leaves
+ // something behind and the pair still respects the maximum width.
+ const carried = current[current.length - 1];
+ const visible = current.filter((a) => !a.space);
+ if (
+ atom.closing &&
+ visible.length > 1 &&
+ !carried.space &&
+ carried.width + atom.width <= maxWidth
+ ) {
+ current.pop();
+ finish();
+ current.push(carried);
+ } else {
+ finish();
+ }
+ // The break already stands in for a space, so it does not start the new line.
+ if (atom.space) continue;
+ current.push(atom);
+ }
+
+ finish();
+ return lines;
+}
diff --git a/bluebey-studio/src/trace.js b/bluebey-studio/src/trace.js
new file mode 100644
index 0000000..9b61d6d
--- /dev/null
+++ b/bluebey-studio/src/trace.js
@@ -0,0 +1,410 @@
+/**
+ * Zero-dependency marching-squares contour tracer.
+ *
+ * Runs unchanged in the browser and in Node: the module has no imports at all,
+ * never touches the DOM and never writes to the console.
+ *
+ * Coordinate system
+ * -----------------
+ * Pixel (x, y) is the unit square whose top-left corner sits at (x, y), so the
+ * centre of pixel (x, y) is at (x + 0.5, y + 0.5) and y grows downwards
+ * (row 0 is the top row of the input).
+ *
+ * Contours
+ * --------
+ * Every returned contour is a *closed* polyline of sub-pixel points; the first
+ * point is not repeated at the end. Contours are oriented so that the inside of
+ * the shape (the region where the sample is at or above the threshold) lies to
+ * the left of the direction of travel. See `contourArea` for the sign this
+ * implies.
+ */
+
+const EDGE_TOP = 0;
+const EDGE_RIGHT = 1;
+const EDGE_BOTTOM = 2;
+const EDGE_LEFT = 3;
+
+/**
+ * Directed segments emitted by each unambiguous marching-squares case.
+ *
+ * The case index is built from the cell corners as
+ * `tl | tr << 1 | br << 2 | bl << 3`. Entries are flat `[from, to, from, to]`
+ * pairs of edge ids; the direction keeps the inside region on the left.
+ */
+const CELL_SEGMENTS = [
+ null, // 0 - nothing inside
+ [EDGE_LEFT, EDGE_TOP, -1, -1], // 1 - top-left only
+ [EDGE_TOP, EDGE_RIGHT, -1, -1], // 2 - top-right only
+ [EDGE_LEFT, EDGE_RIGHT, -1, -1], // 3 - top row
+ [EDGE_RIGHT, EDGE_BOTTOM, -1, -1], // 4 - bottom-right only
+ null, // 5 - saddle (top-left + bottom-right)
+ [EDGE_TOP, EDGE_BOTTOM, -1, -1], // 6 - right column
+ [EDGE_LEFT, EDGE_BOTTOM, -1, -1], // 7 - everything but bottom-left
+ [EDGE_BOTTOM, EDGE_LEFT, -1, -1], // 8 - bottom-left only
+ [EDGE_BOTTOM, EDGE_TOP, -1, -1], // 9 - left column
+ null, // 10 - saddle (top-right + bottom-left)
+ [EDGE_BOTTOM, EDGE_RIGHT, -1, -1], // 11 - everything but bottom-right
+ [EDGE_RIGHT, EDGE_LEFT, -1, -1], // 12 - bottom row
+ [EDGE_RIGHT, EDGE_TOP, -1, -1], // 13 - everything but top-right
+ [EDGE_TOP, EDGE_LEFT, -1, -1], // 14 - everything but top-left
+ null, // 15 - everything inside
+];
+
+// Case 5 (top-left + bottom-right inside). When the centre of the cell is
+// inside, the two inside corners are joined through the middle and the two
+// outside corners are separated; otherwise the inside corners are separated.
+const SADDLE_5_CONNECTED = [EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT, EDGE_TOP];
+const SADDLE_5_SPLIT = [EDGE_LEFT, EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM];
+
+// Case 10 (top-right + bottom-left inside): the mirror image of case 5.
+const SADDLE_10_CONNECTED = [EDGE_TOP, EDGE_LEFT, EDGE_BOTTOM, EDGE_RIGHT];
+const SADDLE_10_SPLIT = [EDGE_TOP, EDGE_RIGHT, EDGE_BOTTOM, EDGE_LEFT];
+
+const DUPLICATE_EPS = 1e-9;
+
+/**
+ * Signed area of a closed polyline, via the shoelace formula. The polyline is
+ * implicitly closed (the last point is joined back to the first), so an open
+ * ring is fine.
+ *
+ * Sign convention: this is the plain shoelace sum `Σ (x_i·y_{i+1} − x_{i+1}·y_i) / 2`
+ * evaluated in the tracer's y-down pixel coordinates. A ring that runs
+ * clockwise *as seen on screen* is therefore positive, and a counter-clockwise
+ * one is negative. Because `traceAlphaContours` keeps the inside on the left,
+ * the outer boundary of a filled region comes out negative and a hole in it
+ * comes out positive.
+ *
+ * @param {Array<{x: number, y: number}>} points
+ * @returns {number} signed area in square pixels
+ */
+export function contourArea(points) {
+ const n = points.length;
+ if (!points || n < 3) return 0;
+ let sum = 0;
+ for (let i = 0; i < n; i++) {
+ const a = points[i];
+ const b = i + 1 === n ? points[0] : points[i + 1];
+ sum += a.x * b.y - b.x * a.y;
+ }
+ return sum / 2;
+}
+
+/**
+ * Build an SVG path `d` attribute with one closed subpath per contour.
+ *
+ * `mapPoint(x, y)` returns the `[X, Y]` pair written to the output, which is
+ * what makes it possible to flip the y axis or apply a scale without touching
+ * the tracer. Coordinates are rounded to `decimals` places.
+ *
+ * @param {Array>} contours
+ * @param {(x: number, y: number) => [number, number]} mapPoint
+ * @param {number} [decimals]
+ * @returns {string}
+ */
+export function contoursToPathData(contours, mapPoint, decimals = 2) {
+ const places = Math.max(0, Math.floor(decimals));
+ const factor = Math.pow(10, places);
+ const parts = [];
+ for (let c = 0; c < contours.length; c++) {
+ const points = contours[c];
+ if (!points || points.length < 2) continue;
+ for (let i = 0; i < points.length; i++) {
+ const mapped = mapPoint(points[i].x, points[i].y);
+ // Rounding before formatting keeps `-0.00` out of the output.
+ const rx = Math.round(mapped[0] * factor) / factor;
+ const ry = Math.round(mapped[1] * factor) / factor;
+ parts.push((i === 0 ? 'M' : 'L') + rx.toFixed(places) + ' ' + ry.toFixed(places));
+ }
+ parts.push('Z');
+ }
+ return parts.join(' ');
+}
+
+// --- closed-ring simplification helpers ------------------------------------
+
+/** Drop points that repeat their predecessor (including across the wrap). */
+function removeConsecutiveDuplicates(points) {
+ const out = [];
+ for (let i = 0; i < points.length; i++) {
+ const p = points[i];
+ const last = out[out.length - 1];
+ if (last && Math.abs(last.x - p.x) <= DUPLICATE_EPS && Math.abs(last.y - p.y) <= DUPLICATE_EPS) {
+ continue;
+ }
+ out.push(p);
+ }
+ while (out.length > 1) {
+ const first = out[0];
+ const last = out[out.length - 1];
+ if (Math.abs(first.x - last.x) <= DUPLICATE_EPS && Math.abs(first.y - last.y) <= DUPLICATE_EPS) {
+ out.pop();
+ } else {
+ break;
+ }
+ }
+ return out;
+}
+
+/** Drop points that sit on the straight segment between their two neighbours. */
+function removeCollinear(points) {
+ let list = points;
+ let changed = true;
+ while (changed && list.length > 3) {
+ changed = false;
+ const n = list.length;
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const a = list[i === 0 ? n - 1 : i - 1];
+ const b = list[i];
+ const c = list[i + 1 === n ? 0 : i + 1];
+ const abx = b.x - a.x;
+ const aby = b.y - a.y;
+ const bcx = c.x - b.x;
+ const bcy = c.y - b.y;
+ const cross = abx * bcy - aby * bcx;
+ // |cross| / (|ab| * |bc|) is sin(turn angle); a small value means a
+ // straight-through point, which carries no shape information.
+ const scale = Math.sqrt((abx * abx + aby * aby) * (bcx * bcx + bcy * bcy));
+ const straight = scale <= DUPLICATE_EPS || Math.abs(cross) <= 1e-9 * scale;
+ if (straight && abx * bcx + aby * bcy >= 0) {
+ changed = true;
+ } else {
+ out.push(b);
+ }
+ }
+ list = out;
+ }
+ return list;
+}
+
+/**
+ * Iterative Douglas–Peucker for an *open* polyline. The two end points are
+ * always kept; the recursion uses an explicit stack so long contours cannot
+ * overflow the call stack.
+ */
+function douglasPeucker(points, tolerance) {
+ const n = points.length;
+ if (n <= 2) return points.slice();
+ const keep = new Uint8Array(n);
+ keep[0] = 1;
+ keep[n - 1] = 1;
+ const stack = [0, n - 1];
+ while (stack.length > 0) {
+ const i1 = stack.pop();
+ const i0 = stack.pop();
+ if (i1 <= i0 + 1) continue;
+ const a = points[i0];
+ const b = points[i1];
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const len = Math.sqrt(dx * dx + dy * dy);
+ let maxDistance = -1;
+ let maxIndex = -1;
+ if (len <= DUPLICATE_EPS) {
+ // Degenerate segment: fall back to the distance from the anchor point.
+ for (let i = i0 + 1; i < i1; i++) {
+ const px = points[i].x - a.x;
+ const py = points[i].y - a.y;
+ const d = Math.sqrt(px * px + py * py);
+ if (d > maxDistance) {
+ maxDistance = d;
+ maxIndex = i;
+ }
+ }
+ } else {
+ for (let i = i0 + 1; i < i1; i++) {
+ const p = points[i];
+ const d = Math.abs(dy * (p.x - a.x) - dx * (p.y - a.y)) / len;
+ if (d > maxDistance) {
+ maxDistance = d;
+ maxIndex = i;
+ }
+ }
+ }
+ if (maxDistance > tolerance && maxIndex > i0) {
+ keep[maxIndex] = 1;
+ stack.push(i0, maxIndex, maxIndex, i1);
+ }
+ }
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ if (keep[i]) out.push(points[i]);
+ }
+ return out;
+}
+
+/**
+ * Simplify a closed ring, wrap-around segment included.
+ *
+ * The ring is cut at the point farthest from `points[0]`, which gives two open
+ * polylines that together cover every segment of the loop exactly once; each
+ * half is then simplified with Douglas–Peucker and the halves are stitched
+ * back together (without duplicating the shared anchors).
+ */
+function simplifyClosedRing(points, tolerance) {
+ const n = points.length;
+ if (n <= 3) return points.slice();
+ let far = 0;
+ let farDistance = -1;
+ const first = points[0];
+ for (let i = 1; i < n; i++) {
+ const dx = points[i].x - first.x;
+ const dy = points[i].y - first.y;
+ const d = dx * dx + dy * dy;
+ if (d > farDistance) {
+ farDistance = d;
+ far = i;
+ }
+ }
+ // A ring whose points all coincide carries no shape; leave it to minArea.
+ if (far <= 0 || farDistance <= DUPLICATE_EPS) return points.slice();
+
+ const head = douglasPeucker(points.slice(0, far + 1), tolerance);
+ const tail = douglasPeucker(points.slice(far).concat([first]), tolerance);
+
+ // `head` ends and `tail` starts on the same anchor, and both end on
+ // `points[0]`; drop the duplicated join so every point appears once.
+ return head.slice(0, -1).concat(tail.slice(0, -1));
+}
+
+/**
+ * Trace the iso-contour of a scalar field at `options.threshold`.
+ *
+ * @param {ArrayLike} alpha width*height samples, row-major, row 0 on top
+ * @param {number} width
+ * @param {number} height
+ * @param {object} [options]
+ * @param {number} [options.threshold=0.5] inside when `alpha/255 >= threshold`
+ * @param {number} [options.simplifyTolerance=0.35] Douglas-Peucker tolerance, px
+ * @param {number} [options.minArea=2] drop rings smaller than this, px²
+ * @returns {Array>}
+ */
+export function traceAlphaContours(alpha, width, height, options = {}) {
+ const threshold = options.threshold ?? 0.5;
+ const tolerance = options.simplifyTolerance ?? 0.35;
+ const minArea = options.minArea ?? 2;
+
+ const w = Math.floor(width);
+ const h = Math.floor(height);
+ if (!alpha || w < 1 || h < 1 || alpha.length < w * h) return [];
+ if (w < 2 && h < 2) return [];
+
+ // Work on a signed field, padded with a 1px outside border: `s >= 0` is
+ // inside. The padding guarantees every crossing is strictly interior, so the
+ // marching always yields closed rings and never touches the array edges.
+ // The border holds the same value a fully transparent pixel maps to, which
+ // makes shapes that run off the image close exactly on the image edge.
+ const pw = w + 2;
+ const ph = h + 2;
+ const s = new Float32Array(pw * ph);
+ s.fill(-threshold);
+ for (let y = 0; y < h; y++) {
+ const src = y * w;
+ const dst = (y + 1) * pw + 1;
+ for (let x = 0; x < w; x++) s[dst + x] = alpha[src + x] / 255 - threshold;
+ }
+ const sample = (x, y) => s[y * pw + x];
+
+ // Crossing points, keyed by the grid edge they sit on. Both cells sharing an
+ // edge call these with the same sample pair in the same order (top→bottom,
+ // left→right), so the coordinates come out bit-identical and can be matched
+ // by key alone.
+ const points = new Map();
+ function crossing(key, a, b, x0, y0, dx, dy) {
+ let p = points.get(key);
+ if (p === undefined) {
+ const t = a / (a - b);
+ p = { x: x0 + dx * t, y: y0 + dy * t };
+ points.set(key, p);
+ }
+ return p;
+ }
+ // Horizontal edge of the padded grid at row `py`, spanning columns px..px+1.
+ const hKey = (px, py) => `h:${px}:${py}`;
+ const hPoint = (px, py) => crossing(hKey(px, py), sample(px, py), sample(px + 1, py), px - 0.5, py - 0.5, 1, 0);
+ // Vertical edge of the padded grid at column `px`, spanning rows py..py+1.
+ const vKey = (px, py) => `v:${px}:${py}`;
+ const vPoint = (px, py) => crossing(vKey(px, py), sample(px, py), sample(px, py + 1), px - 0.5, py - 0.5, 0, 1);
+
+ const edgeKey = [
+ (px, py) => hKey(px, py), // EDGE_TOP
+ (px, py) => vKey(px + 1, py), // EDGE_RIGHT
+ (px, py) => hKey(px, py + 1), // EDGE_BOTTOM
+ (px, py) => vKey(px, py), // EDGE_LEFT
+ ];
+ const edgePoint = [
+ (px, py) => hPoint(px, py), // EDGE_TOP
+ (px, py) => vPoint(px + 1, py), // EDGE_RIGHT
+ (px, py) => hPoint(px, py + 1), // EDGE_BOTTOM
+ (px, py) => vPoint(px, py), // EDGE_LEFT
+ ];
+
+ // Directed segments: `from` -> `to`, inside region on the left of travel.
+ const fromKeys = [];
+ const toKeys = [];
+ const fromPoints = [];
+
+ for (let py = 0; py < ph - 1; py++) {
+ for (let px = 0; px < pw - 1; px++) {
+ const tl = sample(px, py);
+ const tr = sample(px + 1, py);
+ const br = sample(px + 1, py + 1);
+ const bl = sample(px, py + 1);
+ const inside = (v) => (v >= 0 ? 1 : 0);
+ const code = inside(tl) | (inside(tr) << 1) | (inside(br) << 2) | (inside(bl) << 3);
+ let segments = CELL_SEGMENTS[code];
+ if (code === 5) {
+ // With the cell centre inside, the two inside corners join through the
+ // middle; otherwise each is cut off on its own.
+ segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_5_CONNECTED : SADDLE_5_SPLIT;
+ } else if (code === 10) {
+ segments = (tl + tr + br + bl) / 4 >= 0 ? SADDLE_10_CONNECTED : SADDLE_10_SPLIT;
+ }
+ if (!segments) continue;
+ for (let i = 0; i < segments.length; i += 2) {
+ const a = segments[i];
+ const b = segments[i + 1];
+ if (a < 0 || b < 0) continue; // padding of the single-segment cases
+ fromKeys.push(edgeKey[a](px, py));
+ fromPoints.push(edgePoint[a](px, py));
+ toKeys.push(edgeKey[b](px, py));
+ edgePoint[b](px, py); // make sure the shared crossing exists
+ }
+ }
+ }
+
+ // Every crossing has exactly one incoming and one outgoing segment, so the
+ // segments can be walked into closed rings without any ambiguity.
+ const outgoing = new Map();
+ for (let i = 0; i < fromKeys.length; i++) outgoing.set(fromKeys[i], i);
+
+ const segmentCount = fromKeys.length;
+ const used = new Uint8Array(segmentCount);
+ const contours = [];
+
+ for (let start = 0; start < segmentCount; start++) {
+ if (used[start]) continue;
+ const ring = [];
+ let current = start;
+ for (let guard = 0; guard <= segmentCount; guard++) {
+ used[current] = 1;
+ ring.push(fromPoints[current]);
+ const next = outgoing.get(toKeys[current]);
+ if (next === undefined || next === start) break;
+ if (used[next]) break;
+ current = next;
+ }
+ if (ring.length >= 3) contours.push(ring);
+ }
+
+ const result = [];
+ for (let i = 0; i < contours.length; i++) {
+ let ring = removeConsecutiveDuplicates(contours[i]);
+ ring = simplifyClosedRing(ring, tolerance);
+ ring = removeCollinear(ring);
+ if (ring.length < 3) continue;
+ if (Math.abs(contourArea(ring)) < minArea) continue;
+ result.push(ring);
+ }
+ return result;
+}
\ No newline at end of file
diff --git a/bluebey-studio/src/ui.js b/bluebey-studio/src/ui.js
new file mode 100644
index 0000000..91e852b
--- /dev/null
+++ b/bluebey-studio/src/ui.js
@@ -0,0 +1,367 @@
+/**
+ * Small DOM widget kit for the control panel: hyperscript, collapsible sections
+ * and a handful of labelled controls. Everything returns `{ el, set, get }` so
+ * callers can push state back into the widgets when presets are applied.
+ */
+
+export function h(tag, props = {}, ...children) {
+ const node = document.createElement(tag);
+ for (const [key, value] of Object.entries(props ?? {})) {
+ if (value == null || value === false) continue;
+ if (key === 'class') node.className = value;
+ else if (key === 'text') node.textContent = value;
+ else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
+ else if (key === 'dataset' && typeof value === 'object') Object.assign(node.dataset, value);
+ else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value);
+ else if (value === true) node.setAttribute(key, '');
+ else node.setAttribute(key, String(value));
+ }
+ for (const child of children.flat()) {
+ if (child == null || child === false) continue;
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
+ }
+ return node;
+}
+
+const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
+
+let toastTimer = 0;
+
+export function toast(message) {
+ const node = document.getElementById('toast');
+ if (!node) return;
+ node.textContent = message;
+ node.classList.add('show');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => node.classList.remove('show'), 2400);
+}
+
+export function section(parent, title, { open = false, icon = null } = {}) {
+ const body = h('div', { class: 'sec-body' });
+ const head = h('button', { type: 'button', class: 'sec-head' },
+ h('span', { class: 'sec-title' },
+ icon ? secIcon(icon) : null,
+ h('span', { text: title })),
+ h('span', { class: 'chev', text: '▾' }));
+ const el = h('section', { class: `sec${open ? '' : ' closed'}` }, head, body);
+ head.addEventListener('click', () => el.classList.toggle('closed'));
+ parent.append(el);
+ // `add` accepts either an element or a widget object (`{ el }`), so callers
+ // cannot accidentally append `[object Object]`.
+ return { el, body, add: (child) => (body.append(child?.el ?? child), child), setOpen: (open) => el.classList.toggle('closed', !open) };
+}
+
+/** A label + control row. Pass `label` as null for a full-width control. */
+export function controlRow(label, control, { wide = false } = {}) {
+ if (label == null || wide) return h('div', { class: 'row wide' }, label ? h('span', { class: 'label', text: label }) : null, control);
+ return h('div', { class: 'row' },
+ h('span', { class: 'label', text: label, title: label }),
+ control);
+}
+
+/**
+ * A tab's optional icon: one 24x24 stroke path, drawn in `currentColor` so it
+ * follows the tab's text colour (and turns white when the tab is active).
+ */
+function tabIcon(d) {
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('width', '18');
+ svg.setAttribute('height', '18');
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'currentColor');
+ svg.setAttribute('stroke-width', '1.8');
+ svg.setAttribute('stroke-linecap', 'round');
+ svg.setAttribute('stroke-linejoin', 'round');
+ svg.style.verticalAlign = 'baseline';
+ svg.style.flex = '0 0 auto';
+ const path = document.createElementNS(SVG_NS, 'path');
+ path.setAttribute('d', d);
+ svg.append(path);
+ return svg;
+}
+
+/** A small stroke icon built from raw SVG markup (see the icons in panel.js). */
+export function svgIcon(inner, { size = 16, viewBox = '0 0 24 24', className = 'grp-icon' } = {}) {
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', viewBox);
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'currentColor');
+ svg.setAttribute('stroke-width', '1.8');
+ svg.setAttribute('stroke-linecap', 'round');
+ svg.setAttribute('stroke-linejoin', 'round');
+ svg.setAttribute('class', className);
+ svg.innerHTML = inner;
+ return svg;
+}
+
+function secIcon(d) {
+ const svg = tabIcon(d);
+ svg.setAttribute('class', 'sec-icon');
+ svg.setAttribute('width', '16');
+ svg.setAttribute('height', '16');
+ svg.style.verticalAlign = 'middle';
+ return svg;
+}
+
+/**
+ * A standalone 24x24 stroke icon (the same drawing as a tab's), for a plain
+ * button that has no label of its own.
+ */
+export function icon(d, size = 18) {
+ const svg = tabIcon(d);
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ return svg;
+}
+
+/**
+ * Tabbed container: returns the per-tab panels to fill in. When a definition
+ * carries an `icon` (an SVG path `d`) the tab shows only that icon, and the
+ * label becomes its tooltip and accessible name; without one the label shows.
+ */
+export function tabs(parent, definitions) {
+ const bar = h('div', { class: 'tabs' });
+ const buttons = new Map();
+ const panels = {};
+ let active = definitions[0]?.id ?? null;
+
+ const select = (id) => {
+ if (!panels[id]) return;
+ active = id;
+ for (const [key, button] of buttons) button.classList.toggle('active', key === id);
+ for (const [key, panel] of Object.entries(panels)) panel.hidden = key !== id;
+ };
+
+ for (const definition of definitions) {
+ const button = h('button', { type: 'button', class: 'tab' });
+ if (definition.icon) {
+ button.append(tabIcon(definition.icon));
+ button.title = definition.label;
+ button.setAttribute('aria-label', definition.label);
+ } else {
+ button.append(h('span', { class: 'tab-label', text: definition.label }));
+ }
+ button.addEventListener('click', () => select(definition.id));
+ buttons.set(definition.id, button);
+ bar.append(button);
+ panels[definition.id] = h('div', { class: 'tab-panel' });
+ }
+
+ parent.append(bar);
+ for (const definition of definitions) parent.append(panels[definition.id]);
+ select(active);
+ return { select, panels, get active() { return active; } };
+}
+
+/** A collapsed "more options" block, using the native details element. */
+export function details(parent, summary, { open = false } = {}) {
+ const body = h('div', { class: 'details-body' });
+ const el = h('details', { class: 'details' }, h('summary', { text: summary }), body);
+ if (open) el.open = true;
+ parent.append(el);
+ return { el, body, add: (child) => (body.append(child?.el ?? child), child) };
+}
+
+export function subhead(text) {
+ return h('div', { class: 'subhead', text });
+}
+
+export function hint(...lines) {
+ return h('p', { class: 'hint' }, ...lines.flat().map((line) => (line instanceof Node ? line : line)));
+}
+
+export function slider({ label, min = 0, max = 1, step = 0.01, value = 0, format, onInput, onCommit, wide = false }) {
+ const input = h('input', { type: 'range', min, max, step, value });
+ const num = h('span', { class: 'num' });
+ const fmt = format ?? ((v) => (step >= 1 ? String(Math.round(v)) : v.toFixed(2)));
+ const sync = () => { num.textContent = fmt(Number(input.value)); };
+ input.addEventListener('input', () => { sync(); onInput?.(Number(input.value)); });
+ input.addEventListener('change', () => onCommit?.(Number(input.value)));
+ sync();
+ const control = h('div', { class: 'control' }, input, num);
+ return {
+ el: controlRow(label, control, { wide }),
+ set(v) { input.value = String(v); sync(); },
+ get: () => Number(input.value),
+ };
+}
+
+export function check({ label, value = false, onChange, title }) {
+ const input = h('input', { type: 'checkbox' });
+ input.checked = !!value;
+ input.addEventListener('change', () => onChange?.(input.checked));
+ const el = h('label', { class: 'chk', title: title ?? '' }, input, h('span', { text: label }));
+ return { el, set: (v) => { input.checked = !!v; }, get: () => input.checked };
+}
+
+export function segmented({ label, options, value, onChange, wide = false }) {
+ const el = h('div', { class: 'seg' });
+ const nodes = new Map();
+ let current = value;
+ const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); };
+ const build = (list) => {
+ el.replaceChildren();
+ nodes.clear();
+ for (const option of list) {
+ const node = h('button', { type: 'button', title: option.title ?? option.label, text: option.label });
+ node.addEventListener('click', () => {
+ if (current === option.value) return;
+ current = option.value;
+ sync();
+ onChange?.(option.value);
+ });
+ nodes.set(option.value, node);
+ el.append(node);
+ }
+ sync();
+ };
+ build(options);
+ return {
+ el: controlRow(label, el, { wide }),
+ set(v) { current = v; sync(); },
+ get: () => current,
+ setOptions: build,
+ };
+}
+
+export function buttons({ label, items, wide = true }) {
+ const el = h('div', { class: 'buttons' });
+ const nodes = new Map();
+ for (const item of items) {
+ const node = h('button', {
+ type: 'button',
+ class: `btn${item.primary ? ' primary' : ''}`,
+ title: item.title ?? '',
+ text: item.label,
+ });
+ node.addEventListener('click', () => item.onClick?.());
+ const key = item.id ?? item.label;
+ nodes.set(key, node);
+ el.append(node);
+ }
+ return {
+ el: controlRow(label, el, { wide }),
+ button: (key) => nodes.get(key)?.el,
+ setDisabled(key, disabled) { const node = nodes.get(key); if (node) node.disabled = disabled; },
+ setLabel(key, text) { const node = nodes.get(key); if (node) node.textContent = text; },
+ };
+}
+
+export function colorField({ label, value = '#ffffff', onChange, swatches = [], wide = false }) {
+ const input = h('input', { type: 'color', value });
+ input.addEventListener('input', () => onChange?.(input.value));
+ const control = h('div', { class: 'control' }, input);
+ if (swatches.length) {
+ const strip = h('div', { class: 'swatches' });
+ for (const color of swatches) {
+ const chip = h('button', { type: 'button', class: 'swatch', title: color, style: { background: color } });
+ chip.addEventListener('click', () => { input.value = color; onChange?.(color); });
+ strip.append(chip);
+ }
+ control.append(strip);
+ }
+ return {
+ el: controlRow(label, control, { wide }),
+ set(v) { input.value = v; },
+ get: () => input.value,
+ };
+}
+
+/**
+ * Two-axis drag pad. `value` is `{ x, y }` with both components in -1..1 and
+ * `y` positive upwards (screen-like, but flipped so up = up).
+ */
+export function xyPad({ value = { x: 0, y: 0 }, onChange, onCommit, center = null }) {
+ const dot = h('span', { class: 'pad-dot' });
+ const el = h('div', { class: 'pad' }, center === 'eye' ? h('span', { class: 'pad-eye' }) : null, dot);
+ let current = { x: value.x ?? 0, y: value.y ?? 0 };
+ let dragging = false;
+
+ const place = () => {
+ dot.style.left = `${((current.x + 1) / 2) * 100}%`;
+ dot.style.top = `${((1 - (current.y + 1) / 2)) * 100}%`;
+ };
+ const fromEvent = (event) => {
+ const rect = el.getBoundingClientRect();
+ const x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
+ const y = clamp(1 - ((event.clientY - rect.top) / rect.height) * 2, -1, 1);
+ current = { x, y };
+ place();
+ onChange?.({ ...current });
+ };
+
+ el.addEventListener('pointerdown', (event) => {
+ dragging = true;
+ el.setPointerCapture(event.pointerId);
+ fromEvent(event);
+ });
+ el.addEventListener('pointermove', (event) => { if (dragging) fromEvent(event); });
+ const end = (event) => {
+ if (!dragging) return;
+ dragging = false;
+ if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId);
+ onCommit?.({ ...current });
+ };
+ el.addEventListener('pointerup', end);
+ el.addEventListener('pointercancel', end);
+ el.addEventListener('dblclick', () => { current = { x: 0, y: 0 }; place(); onChange?.({ ...current }); onCommit?.({ ...current }); });
+
+ place();
+ return {
+ el,
+ set(v) { current = { x: v.x ?? 0, y: v.y ?? 0 }; place(); },
+ get: () => ({ ...current }),
+ };
+}
+
+/**
+ * Tail-direction picker: a 3x3 grid of dots whose *position* is the direction,
+ * which reads at a glance in a way a flat row of "左上/右上" buttons does not.
+ * The centre dot means no tail. Same `{ el, set, get }` contract as the rest.
+ */
+export function tailPad({ label, value = 'left', onChange }) {
+ // Row-major, so the array order is what the user sees on screen.
+ const CELLS = [
+ ['topLeft', '左上'], ['top', '上'], ['topRight', '右上'],
+ ['left', '左'], ['none', 'なし'], ['right', '右'],
+ ['bottomLeft', '左下'], ['bottom', '下'], ['bottomRight', '右下'],
+ ];
+ const el = h('div', { class: 'tail-pad' });
+ const nodes = new Map();
+ let current = value;
+ const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); };
+ for (const [cell, text] of CELLS) {
+ const node = h('button', { type: 'button', class: 'tail-dot', title: text, 'aria-label': text });
+ node.addEventListener('click', () => {
+ if (current === cell) return;
+ current = cell;
+ sync();
+ onChange?.(cell);
+ });
+ nodes.set(cell, node);
+ el.append(node);
+ }
+ sync();
+ return {
+ el: controlRow(label, el),
+ set(v) { current = v; sync(); },
+ get: () => current,
+ };
+}
+
+export function selectField({ label, options, value, onChange, wide = false }) {
+ const select = h('select', { class: 'select' });
+ for (const option of options) select.append(h('option', { value: option.value, text: option.label }));
+ select.value = value;
+ select.addEventListener('change', () => onChange?.(select.value));
+ return {
+ el: controlRow(label, select, { wide }),
+ set(v) { select.value = v; },
+ get: () => select.value,
+ };
+}
diff --git a/bluebey-studio/src/urlState.js b/bluebey-studio/src/urlState.js
new file mode 100644
index 0000000..d6fb6bd
--- /dev/null
+++ b/bluebey-studio/src/urlState.js
@@ -0,0 +1,237 @@
+/**
+ * Shareable links: the whole look, packed into the URL fragment.
+ *
+ * A studio session is a lot of state, and "save a JSON file and send it" is a
+ * poor answer to "how do I show you what I made" - one link you can paste into
+ * chat is much better. So the state is JSON-encoded, deflated and
+ * base64url-encoded into something short enough to sit in a `#` fragment.
+ *
+ * Three details make it survive the trip:
+ *
+ * - `view.backgroundImage` can be a multi-megabyte data URL (a photo the user
+ * loaded). No link can carry that, so `stripForUrl` replaces just that one
+ * field with `null` and keeps everything else - caption text, story panels,
+ * props. Nothing else is ever dropped.
+ *
+ * - The payload starts with a version tag ('1' = raw deflate, '0' = plain
+ * bytes) so the decoder knows whether to inflate. A browser without
+ * `CompressionStream` falls back to the uncompressed form rather than
+ * failing to produce a link at all.
+ *
+ * - base64url uses `-` and `_` and carries no `=` padding, so the fragment is
+ * safe to paste into chat, Markdown or HTML without being mangled or escaped.
+ *
+ * `decodeState` is deliberately total: any malformed, truncated or hostile input
+ * returns `null` rather than throwing, because it is fed whatever came out of
+ * the address bar.
+ *
+ * Most of a session is still at its default, and a default costs bytes on every
+ * link. `pruneDefaults` drops exactly the fields that equal `defaultState()`, and
+ * `restoreDefaults` merges what is left back onto a fresh default, so the link
+ * carries only what the user actually changed. The pruning is lossless and the
+ * decoder still accepts the older, full-state links.
+ */
+
+import { applyPatch, defaultState } from './presets.js';
+
+/** Above this length a `data:` background image is a photo, not a link. */
+const MAX_INLINE_IMAGE = 2048;
+
+/** Version tags. '1' is the deflated body, '0' the fallback plain body. */
+const TAG_DEFLATE = '1';
+const TAG_PLAIN = '0';
+
+const BASE64URL = /^[A-Za-z0-9_-]+$/;
+
+/**
+ * A copy of `state` that is safe to put in a URL. Only the inline background
+ * image is dropped (replaced with `null`), and only when it is a `data:` URL
+ * long enough to blow up the link.
+ */
+export function stripForUrl(state) {
+ // A JSON round-trip gives the copy for free and drops anything that could not
+ // be serialised anyway, so the link and the live state cannot diverge.
+ const copy = JSON.parse(JSON.stringify(state ?? null));
+ if (!copy || typeof copy !== 'object' || Array.isArray(copy)) return copy;
+
+ const image = copy.view && copy.view.backgroundImage;
+ if (typeof image === 'string' && image.startsWith('data:') && image.length > MAX_INLINE_IMAGE) {
+ copy.view.backgroundImage = null;
+ }
+ return copy;
+}
+
+/** Plain objects only: not `null`, not an array. */
+function isPlainObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+/** Structural equality for the JSON-shaped values this module deals in. */
+function deepEqual(a, b) {
+ if (a === b) return true;
+ if (typeof a !== typeof b || a === null || b === null) return false;
+ if (typeof a !== 'object') return Number.isNaN(a) && Number.isNaN(b);
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ if (Array.isArray(a)) {
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
+ }
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) return false;
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));
+}
+
+/** A detached copy of a JSON-shaped value. */
+function cloneValue(value) {
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+}
+
+/** Marks a value that equals its default, so its key can be left out entirely. */
+const OMIT = Symbol('urlState.omit');
+
+/**
+ * `value` with every sub-tree that deep-equals the matching `def` removed.
+ * Arrays are kept whole - never pruned element by element - because that is how
+ * `applyPatch` replaces them; objects are pruned key by key. `OMIT` means the
+ * value matched its default and can be dropped from the parent.
+ */
+function pruneValue(value, def) {
+ if (deepEqual(value, def)) return OMIT;
+ if (isPlainObject(value)) {
+ if (!isPlainObject(def)) return cloneValue(value);
+ const out = {};
+ for (const [key, child] of Object.entries(value)) {
+ const pruned = pruneValue(child, def[key]);
+ if (pruned !== OMIT) out[key] = pruned;
+ }
+ return Object.keys(out).length === 0 ? OMIT : out;
+ }
+ return Array.isArray(value) ? cloneValue(value) : value;
+}
+
+/**
+ * A copy of `state` with every value equal to `defaultState()` omitted. Lossless:
+ * `restoreDefaults` merges the result onto a fresh default. Arrays that differ
+ * from the default are carried whole so `applyPatch` can replace them.
+ */
+export function pruneDefaults(state) {
+ const pruned = pruneValue(state, defaultState());
+ return pruned === OMIT ? {} : pruned;
+}
+
+/**
+ * The inverse of `pruneDefaults`: a full state, with every field the partial does
+ * not mention left at its default. Merging a previously full state is idempotent,
+ * so links made by the older encoder still decode.
+ */
+export function restoreDefaults(partial) {
+ return applyPatch(defaultState(), partial);
+}
+
+/** True when the encoded fragment is bigger than a URL can comfortably hold. */
+export function isTooLong(text, limit = 1800) {
+ return typeof text === 'string' && text.length > limit;
+}
+
+/**
+ * base64url, without padding. Chunked so a large payload does not blow the
+ * argument limit of `String.fromCharCode`.
+ */
+function toBase64Url(bytes) {
+ let binary = '';
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+/** The inverse of `toBase64Url`. Throws on text that is not valid base64. */
+function fromBase64Url(text) {
+ const base64 = text.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = (4 - (base64.length % 4)) % 4;
+ const binary = atob(base64 + '='.repeat(pad));
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+/** Read a whole ReadableStream into one Uint8Array. */
+async function drain(stream) {
+ const chunks = [];
+ let length = 0;
+ for await (const chunk of stream) {
+ const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
+ chunks.push(bytes);
+ length += bytes.length;
+ }
+ const out = new Uint8Array(length);
+ let offset = 0;
+ for (const bytes of chunks) {
+ out.set(bytes, offset);
+ offset += bytes.length;
+ }
+ return out;
+}
+
+async function deflateRaw(bytes) {
+ const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
+ return drain(stream);
+}
+
+async function inflateRaw(bytes) {
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
+ return drain(stream);
+}
+
+/**
+ * Serialise, deflate and base64url-encode a state. The result is a fragment
+ * payload: it survives a URL, a chat message and an HTML attribute.
+ *
+ * @param {object} state the studio state
+ * @returns {Promise} a URL-safe string (tag + base64url)
+ */
+export async function encodeState(state) {
+ const json = JSON.stringify(pruneDefaults(stripForUrl(state)));
+ const bytes = new TextEncoder().encode(json);
+
+ if (typeof CompressionStream === 'function') {
+ try {
+ return TAG_DEFLATE + toBase64Url(await deflateRaw(bytes));
+ } catch {
+ // Fall through: an unusable compressing stream should not cost the link.
+ }
+ }
+ return TAG_PLAIN + toBase64Url(bytes);
+}
+
+/**
+ * The inverse of `encodeState`. Never throws: a bad tag, bad base64, a failed
+ * inflate or JSON that is not a plain object all come back as `null`.
+ *
+ * @param {string} text the fragment payload
+ * @returns {Promise<{ state: object } | null>}
+ */
+export async function decodeState(text) {
+ try {
+ if (typeof text !== 'string' || text.length < 2) return null;
+
+ const tag = text[0];
+ if (tag !== TAG_DEFLATE && tag !== TAG_PLAIN) return null;
+
+ const body = text.slice(1);
+ if (!BASE64URL.test(body)) return null;
+
+ let bytes = fromBase64Url(body);
+ if (tag === TAG_DEFLATE) {
+ if (typeof DecompressionStream !== 'function') return null;
+ bytes = await inflateRaw(bytes);
+ }
+
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
+ return { state: restoreDefaults(parsed) };
+ } catch {
+ return null;
+ }
+}
diff --git a/bluebey-studio/src/zip.js b/bluebey-studio/src/zip.js
new file mode 100644
index 0000000..21a1955
--- /dev/null
+++ b/bluebey-studio/src/zip.js
@@ -0,0 +1,218 @@
+/**
+ * Store-only ZIP writer.
+ *
+ * The studio exports a whole batch of images at once (one PNG per comic panel
+ * or sticker), and a single download is far nicer to handle than a dozen files,
+ * so the batch is packed into one archive here.
+ *
+ * PNG payloads are already DEFLATE-compressed, so running deflate over them a
+ * second time costs time and saves nothing measurable: every entry is stored
+ * verbatim (compression method 0, `compressedSize === uncompressedSize`) and no
+ * data descriptor is needed.
+ *
+ * The module has no imports, never touches the DOM beyond `Blob`, and stamps
+ * entries with a fixed DOS date unless one is passed in, so the same input
+ * always produces a byte-for-byte identical archive. That keeps the output safe
+ * to cache, diff, hash and test.
+ *
+ * Archive layout (every integer little-endian, no extra fields, no comments, no
+ * directory entries):
+ *
+ * [local file header + stored data] one per file
+ * [central directory entry] one per file
+ * [end of central directory record]
+ */
+
+/** Signature of a local file header ("PK\x03\x04"). */
+const LOCAL_SIGNATURE = 0x04034b50;
+/** Signature of a central directory entry ("PK\x01\x02"). */
+const CENTRAL_SIGNATURE = 0x02014b50;
+/** Signature of the end of central directory record ("PK\x05\x06"). */
+const EOCD_SIGNATURE = 0x06054b50;
+
+/** ZIP 2.0 is the oldest version that covers everything this writer emits. */
+const VERSION_NEEDED = 20;
+/** General purpose bit 11: the entry name is UTF-8, not CP437. */
+const FLAG_UTF8 = 0x0800;
+/** Compression method 0: stored. */
+const METHOD_STORE = 0;
+
+/** Largest value a 32-bit ZIP field can hold. Nothing may reach 4 GiB. */
+const MAX_FIELD = 0xffffffff;
+/** Largest value a 16-bit ZIP field can hold (entry count, name length). */
+const MAX_SHORT = 0xffff;
+
+const LOCAL_HEADER_SIZE = 30;
+const CENTRAL_HEADER_SIZE = 46;
+const EOCD_SIZE = 22;
+
+/** 1980-01-01 00:00 in DOS form: year offset 0, month 1, day 1, midnight. */
+const DEFAULT_TIME = 0;
+const DEFAULT_DATE = (1 << 5) | 1;
+
+/**
+ * CRC-32 (polynomial 0xEDB88320, reflected), the checksum every ZIP entry must
+ * carry. The table is built once at module load; eight table-driven bits per
+ * byte is fast enough that a several-megabyte PNG batch stays imperceptible.
+ */
+const CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let bit = 0; bit < 8; bit++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ table[i] = c >>> 0;
+ }
+ return table;
+})();
+
+/**
+ * @param {Uint8Array} bytes
+ * @returns {number} unsigned 32-bit CRC-32
+ */
+export function crc32(bytes) {
+ let c = 0xffffffff;
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+/** Clip a Date into the DOS date/time pair stored in the headers. */
+function toDosDateTime(date) {
+ const year = date.getFullYear();
+ // The DOS epoch starts in 1980; anything older is clamped to the epoch.
+ if (year < 1980) return { time: DEFAULT_TIME, date: DEFAULT_DATE };
+ const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1);
+ const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
+ return { time: time & MAX_SHORT, date: day & MAX_SHORT };
+}
+
+/**
+ * Entry names live inside the archive, where the separator is always "/": a
+ * backslash (what Windows paths use) would be taken as part of the name, and a
+ * leading slash would look like an absolute path to some extractors.
+ */
+function normalizeName(name) {
+ return String(name ?? '')
+ .replace(/\\/g, '/')
+ .replace(/^\/+/, '');
+}
+
+/** Accept any byte source, plus plain strings meaning UTF-8 text. */
+function toBytes(data, encoder) {
+ if (typeof data === 'string') return encoder.encode(data);
+ if (data instanceof Uint8Array) return data;
+ if (ArrayBuffer.isView(data)) {
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ }
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
+ throw new TypeError('ZIP: file data must be a Uint8Array, an ArrayBuffer or a string');
+}
+
+function writeU16(view, offset, value) {
+ view.setUint16(offset, value & MAX_SHORT, true);
+}
+
+function writeU32(view, offset, value) {
+ view.setUint32(offset, value >>> 0, true);
+}
+
+/**
+ * Pack `files` into a stored (uncompressed) ZIP archive.
+ *
+ * @param {Array<{name: string, data: Uint8Array | string}>} files
+ * a string `data` is encoded as UTF-8 text
+ * @param {object} [options]
+ * @param {Date} [options.date]
+ * timestamp for every entry; defaults to 1980-01-01 00:00 so that the same
+ * input always yields the same bytes
+ * @returns {Blob} an `application/zip` blob
+ */
+export function createZip(files, { date } = {}) {
+ const encoder = new TextEncoder();
+ const stamp = date ? toDosDateTime(date) : { time: DEFAULT_TIME, date: DEFAULT_DATE };
+
+ // First pass: normalise the input and measure it, so the output buffer can be
+ // allocated exactly once instead of being grown and copied.
+ const entries = [];
+ let localTotal = 0;
+ let centralTotal = 0;
+
+ for (const file of files ?? []) {
+ const nameBytes = encoder.encode(normalizeName(file.name));
+ const data = toBytes(file.data, encoder);
+
+ if (data.length > MAX_FIELD) {
+ throw new Error('ZIP: a file is 4 GiB or larger; zip64 is not supported');
+ }
+ if (nameBytes.length > MAX_SHORT) {
+ throw new Error('ZIP: a file name is longer than 65535 bytes');
+ }
+
+ const length = LOCAL_HEADER_SIZE + nameBytes.length + data.length;
+ localTotal += length;
+ centralTotal += CENTRAL_HEADER_SIZE + nameBytes.length;
+ entries.push({ nameBytes, data, crc: crc32(data), length, offset: 0 });
+ }
+
+ if (entries.length > MAX_SHORT) {
+ throw new Error('ZIP: more than 65535 files; zip64 is not supported');
+ }
+ if (localTotal + centralTotal + EOCD_SIZE > MAX_FIELD) {
+ throw new Error('ZIP: the archive is 4 GiB or larger; zip64 is not supported');
+ }
+
+ const bytes = new Uint8Array(localTotal + centralTotal + EOCD_SIZE);
+ const view = new DataView(bytes.buffer);
+
+ let offset = 0;
+ for (const entry of entries) {
+ entry.offset = offset;
+ writeU32(view, offset + 0, LOCAL_SIGNATURE);
+ writeU16(view, offset + 4, VERSION_NEEDED);
+ writeU16(view, offset + 6, FLAG_UTF8);
+ writeU16(view, offset + 8, METHOD_STORE);
+ writeU16(view, offset + 10, stamp.time);
+ writeU16(view, offset + 12, stamp.date);
+ writeU32(view, offset + 14, entry.crc);
+ writeU32(view, offset + 18, entry.data.length);
+ writeU32(view, offset + 22, entry.data.length);
+ writeU16(view, offset + 26, entry.nameBytes.length);
+ writeU16(view, offset + 28, 0); // extra field length
+ bytes.set(entry.nameBytes, offset + LOCAL_HEADER_SIZE);
+ bytes.set(entry.data, offset + LOCAL_HEADER_SIZE + entry.nameBytes.length);
+ offset += entry.length;
+ }
+
+ const centralOffset = offset;
+ for (const entry of entries) {
+ writeU32(view, offset + 0, CENTRAL_SIGNATURE);
+ writeU16(view, offset + 4, VERSION_NEEDED); // version made by (host 0 = MS-DOS)
+ writeU16(view, offset + 6, VERSION_NEEDED);
+ writeU16(view, offset + 8, FLAG_UTF8);
+ writeU16(view, offset + 10, METHOD_STORE);
+ writeU16(view, offset + 12, stamp.time);
+ writeU16(view, offset + 14, stamp.date);
+ writeU32(view, offset + 16, entry.crc);
+ writeU32(view, offset + 20, entry.data.length);
+ writeU32(view, offset + 24, entry.data.length);
+ writeU16(view, offset + 28, entry.nameBytes.length);
+ writeU16(view, offset + 30, 0); // extra field length
+ writeU16(view, offset + 32, 0); // file comment length
+ writeU16(view, offset + 34, 0); // disk number start
+ writeU16(view, offset + 36, 0); // internal attributes
+ writeU32(view, offset + 38, 0); // external attributes
+ writeU32(view, offset + 42, entry.offset);
+ bytes.set(entry.nameBytes, offset + CENTRAL_HEADER_SIZE);
+ offset += CENTRAL_HEADER_SIZE + entry.nameBytes.length;
+ }
+
+ writeU32(view, offset + 0, EOCD_SIGNATURE);
+ writeU16(view, offset + 4, 0); // number of this disk
+ writeU16(view, offset + 6, 0); // disk holding the central directory
+ writeU16(view, offset + 8, entries.length);
+ writeU16(view, offset + 10, entries.length);
+ writeU32(view, offset + 12, offset - centralOffset);
+ writeU32(view, offset + 16, centralOffset);
+ writeU16(view, offset + 20, 0); // archive comment length
+
+ return new Blob([bytes], { type: 'application/zip' });
+}
diff --git a/bluebey-studio/vendor/lib/opentype.module.js b/bluebey-studio/vendor/lib/opentype.module.js
new file mode 100644
index 0000000..3bbee16
--- /dev/null
+++ b/bluebey-studio/vendor/lib/opentype.module.js
@@ -0,0 +1,14460 @@
+/**
+ * https://opentype.js.org v1.3.4 | (c) Frederik De Bleser and other contributors | MIT License | Uses tiny-inflate by Devon Govett and string.prototype.codepointat polyfill by Mathias Bynens
+ */
+
+/*! https://mths.be/codepointat v0.2.0 by @mathias */
+if (!String.prototype.codePointAt) {
+ (function() {
+ var defineProperty = (function() {
+ // IE 8 only supports `Object.defineProperty` on DOM elements
+ try {
+ var object = {};
+ var $defineProperty = Object.defineProperty;
+ var result = $defineProperty(object, object, object) && $defineProperty;
+ } catch(error) {}
+ return result;
+ }());
+ var codePointAt = function(position) {
+ if (this == null) {
+ throw TypeError();
+ }
+ var string = String(this);
+ var size = string.length;
+ // `ToInteger`
+ var index = position ? Number(position) : 0;
+ if (index != index) { // better `isNaN`
+ index = 0;
+ }
+ // Account for out-of-bounds indices:
+ if (index < 0 || index >= size) {
+ return undefined;
+ }
+ // Get the first code unit
+ var first = string.charCodeAt(index);
+ var second;
+ if ( // check if it’s the start of a surrogate pair
+ first >= 0xD800 && first <= 0xDBFF && // high surrogate
+ size > index + 1 // there is a next code unit
+ ) {
+ second = string.charCodeAt(index + 1);
+ if (second >= 0xDC00 && second <= 0xDFFF) { // low surrogate
+ // https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
+ return (first - 0xD800) * 0x400 + second - 0xDC00 + 0x10000;
+ }
+ }
+ return first;
+ };
+ if (defineProperty) {
+ defineProperty(String.prototype, 'codePointAt', {
+ 'value': codePointAt,
+ 'configurable': true,
+ 'writable': true
+ });
+ } else {
+ String.prototype.codePointAt = codePointAt;
+ }
+ }());
+}
+
+var TINF_OK = 0;
+var TINF_DATA_ERROR = -3;
+
+function Tree() {
+ this.table = new Uint16Array(16); /* table of code length counts */
+ this.trans = new Uint16Array(288); /* code -> symbol translation table */
+}
+
+function Data(source, dest) {
+ this.source = source;
+ this.sourceIndex = 0;
+ this.tag = 0;
+ this.bitcount = 0;
+
+ this.dest = dest;
+ this.destLen = 0;
+
+ this.ltree = new Tree(); /* dynamic length/symbol tree */
+ this.dtree = new Tree(); /* dynamic distance tree */
+}
+
+/* --------------------------------------------------- *
+ * -- uninitialized global data (static structures) -- *
+ * --------------------------------------------------- */
+
+var sltree = new Tree();
+var sdtree = new Tree();
+
+/* extra bits and base tables for length codes */
+var length_bits = new Uint8Array(30);
+var length_base = new Uint16Array(30);
+
+/* extra bits and base tables for distance codes */
+var dist_bits = new Uint8Array(30);
+var dist_base = new Uint16Array(30);
+
+/* special ordering of code length codes */
+var clcidx = new Uint8Array([
+ 16, 17, 18, 0, 8, 7, 9, 6,
+ 10, 5, 11, 4, 12, 3, 13, 2,
+ 14, 1, 15
+]);
+
+/* used by tinf_decode_trees, avoids allocations every call */
+var code_tree = new Tree();
+var lengths = new Uint8Array(288 + 32);
+
+/* ----------------------- *
+ * -- utility functions -- *
+ * ----------------------- */
+
+/* build extra bits and base tables */
+function tinf_build_bits_base(bits, base, delta, first) {
+ var i, sum;
+
+ /* build bits table */
+ for (i = 0; i < delta; ++i) { bits[i] = 0; }
+ for (i = 0; i < 30 - delta; ++i) { bits[i + delta] = i / delta | 0; }
+
+ /* build base table */
+ for (sum = first, i = 0; i < 30; ++i) {
+ base[i] = sum;
+ sum += 1 << bits[i];
+ }
+}
+
+/* build the fixed huffman trees */
+function tinf_build_fixed_trees(lt, dt) {
+ var i;
+
+ /* build fixed length tree */
+ for (i = 0; i < 7; ++i) { lt.table[i] = 0; }
+
+ lt.table[7] = 24;
+ lt.table[8] = 152;
+ lt.table[9] = 112;
+
+ for (i = 0; i < 24; ++i) { lt.trans[i] = 256 + i; }
+ for (i = 0; i < 144; ++i) { lt.trans[24 + i] = i; }
+ for (i = 0; i < 8; ++i) { lt.trans[24 + 144 + i] = 280 + i; }
+ for (i = 0; i < 112; ++i) { lt.trans[24 + 144 + 8 + i] = 144 + i; }
+
+ /* build fixed distance tree */
+ for (i = 0; i < 5; ++i) { dt.table[i] = 0; }
+
+ dt.table[5] = 32;
+
+ for (i = 0; i < 32; ++i) { dt.trans[i] = i; }
+}
+
+/* given an array of code lengths, build a tree */
+var offs = new Uint16Array(16);
+
+function tinf_build_tree(t, lengths, off, num) {
+ var i, sum;
+
+ /* clear code length count table */
+ for (i = 0; i < 16; ++i) { t.table[i] = 0; }
+
+ /* scan symbol lengths, and sum code length counts */
+ for (i = 0; i < num; ++i) { t.table[lengths[off + i]]++; }
+
+ t.table[0] = 0;
+
+ /* compute offset table for distribution sort */
+ for (sum = 0, i = 0; i < 16; ++i) {
+ offs[i] = sum;
+ sum += t.table[i];
+ }
+
+ /* create code->symbol translation table (symbols sorted by code) */
+ for (i = 0; i < num; ++i) {
+ if (lengths[off + i]) { t.trans[offs[lengths[off + i]]++] = i; }
+ }
+}
+
+/* ---------------------- *
+ * -- decode functions -- *
+ * ---------------------- */
+
+/* get one bit from source stream */
+function tinf_getbit(d) {
+ /* check if tag is empty */
+ if (!d.bitcount--) {
+ /* load next tag */
+ d.tag = d.source[d.sourceIndex++];
+ d.bitcount = 7;
+ }
+
+ /* shift bit out of tag */
+ var bit = d.tag & 1;
+ d.tag >>>= 1;
+
+ return bit;
+}
+
+/* read a num bit value from a stream and add base */
+function tinf_read_bits(d, num, base) {
+ if (!num)
+ { return base; }
+
+ while (d.bitcount < 24) {
+ d.tag |= d.source[d.sourceIndex++] << d.bitcount;
+ d.bitcount += 8;
+ }
+
+ var val = d.tag & (0xffff >>> (16 - num));
+ d.tag >>>= num;
+ d.bitcount -= num;
+ return val + base;
+}
+
+/* given a data stream and a tree, decode a symbol */
+function tinf_decode_symbol(d, t) {
+ while (d.bitcount < 24) {
+ d.tag |= d.source[d.sourceIndex++] << d.bitcount;
+ d.bitcount += 8;
+ }
+
+ var sum = 0, cur = 0, len = 0;
+ var tag = d.tag;
+
+ /* get more bits while code value is above sum */
+ do {
+ cur = 2 * cur + (tag & 1);
+ tag >>>= 1;
+ ++len;
+
+ sum += t.table[len];
+ cur -= t.table[len];
+ } while (cur >= 0);
+
+ d.tag = tag;
+ d.bitcount -= len;
+
+ return t.trans[sum + cur];
+}
+
+/* given a data stream, decode dynamic trees from it */
+function tinf_decode_trees(d, lt, dt) {
+ var hlit, hdist, hclen;
+ var i, num, length;
+
+ /* get 5 bits HLIT (257-286) */
+ hlit = tinf_read_bits(d, 5, 257);
+
+ /* get 5 bits HDIST (1-32) */
+ hdist = tinf_read_bits(d, 5, 1);
+
+ /* get 4 bits HCLEN (4-19) */
+ hclen = tinf_read_bits(d, 4, 4);
+
+ for (i = 0; i < 19; ++i) { lengths[i] = 0; }
+
+ /* read code lengths for code length alphabet */
+ for (i = 0; i < hclen; ++i) {
+ /* get 3 bits code length (0-7) */
+ var clen = tinf_read_bits(d, 3, 0);
+ lengths[clcidx[i]] = clen;
+ }
+
+ /* build code length tree */
+ tinf_build_tree(code_tree, lengths, 0, 19);
+
+ /* decode code lengths for the dynamic trees */
+ for (num = 0; num < hlit + hdist;) {
+ var sym = tinf_decode_symbol(d, code_tree);
+
+ switch (sym) {
+ case 16:
+ /* copy previous code length 3-6 times (read 2 bits) */
+ var prev = lengths[num - 1];
+ for (length = tinf_read_bits(d, 2, 3); length; --length) {
+ lengths[num++] = prev;
+ }
+ break;
+ case 17:
+ /* repeat code length 0 for 3-10 times (read 3 bits) */
+ for (length = tinf_read_bits(d, 3, 3); length; --length) {
+ lengths[num++] = 0;
+ }
+ break;
+ case 18:
+ /* repeat code length 0 for 11-138 times (read 7 bits) */
+ for (length = tinf_read_bits(d, 7, 11); length; --length) {
+ lengths[num++] = 0;
+ }
+ break;
+ default:
+ /* values 0-15 represent the actual code lengths */
+ lengths[num++] = sym;
+ break;
+ }
+ }
+
+ /* build dynamic trees */
+ tinf_build_tree(lt, lengths, 0, hlit);
+ tinf_build_tree(dt, lengths, hlit, hdist);
+}
+
+/* ----------------------------- *
+ * -- block inflate functions -- *
+ * ----------------------------- */
+
+/* given a stream and two trees, inflate a block of data */
+function tinf_inflate_block_data(d, lt, dt) {
+ while (1) {
+ var sym = tinf_decode_symbol(d, lt);
+
+ /* check for end of block */
+ if (sym === 256) {
+ return TINF_OK;
+ }
+
+ if (sym < 256) {
+ d.dest[d.destLen++] = sym;
+ } else {
+ var length, dist, offs;
+ var i;
+
+ sym -= 257;
+
+ /* possibly get more bits from length code */
+ length = tinf_read_bits(d, length_bits[sym], length_base[sym]);
+
+ dist = tinf_decode_symbol(d, dt);
+
+ /* possibly get more bits from distance code */
+ offs = d.destLen - tinf_read_bits(d, dist_bits[dist], dist_base[dist]);
+
+ /* copy match */
+ for (i = offs; i < offs + length; ++i) {
+ d.dest[d.destLen++] = d.dest[i];
+ }
+ }
+ }
+}
+
+/* inflate an uncompressed block of data */
+function tinf_inflate_uncompressed_block(d) {
+ var length, invlength;
+ var i;
+
+ /* unread from bitbuffer */
+ while (d.bitcount > 8) {
+ d.sourceIndex--;
+ d.bitcount -= 8;
+ }
+
+ /* get length */
+ length = d.source[d.sourceIndex + 1];
+ length = 256 * length + d.source[d.sourceIndex];
+
+ /* get one's complement of length */
+ invlength = d.source[d.sourceIndex + 3];
+ invlength = 256 * invlength + d.source[d.sourceIndex + 2];
+
+ /* check length */
+ if (length !== (~invlength & 0x0000ffff))
+ { return TINF_DATA_ERROR; }
+
+ d.sourceIndex += 4;
+
+ /* copy block */
+ for (i = length; i; --i)
+ { d.dest[d.destLen++] = d.source[d.sourceIndex++]; }
+
+ /* make sure we start next block on a byte boundary */
+ d.bitcount = 0;
+
+ return TINF_OK;
+}
+
+/* inflate stream from source to dest */
+function tinf_uncompress(source, dest) {
+ var d = new Data(source, dest);
+ var bfinal, btype, res;
+
+ do {
+ /* read final block flag */
+ bfinal = tinf_getbit(d);
+
+ /* read block type (2 bits) */
+ btype = tinf_read_bits(d, 2, 0);
+
+ /* decompress block */
+ switch (btype) {
+ case 0:
+ /* decompress uncompressed block */
+ res = tinf_inflate_uncompressed_block(d);
+ break;
+ case 1:
+ /* decompress block with fixed huffman trees */
+ res = tinf_inflate_block_data(d, sltree, sdtree);
+ break;
+ case 2:
+ /* decompress block with dynamic huffman trees */
+ tinf_decode_trees(d, d.ltree, d.dtree);
+ res = tinf_inflate_block_data(d, d.ltree, d.dtree);
+ break;
+ default:
+ res = TINF_DATA_ERROR;
+ }
+
+ if (res !== TINF_OK)
+ { throw new Error('Data error'); }
+
+ } while (!bfinal);
+
+ if (d.destLen < d.dest.length) {
+ if (typeof d.dest.slice === 'function')
+ { return d.dest.slice(0, d.destLen); }
+ else
+ { return d.dest.subarray(0, d.destLen); }
+ }
+
+ return d.dest;
+}
+
+/* -------------------- *
+ * -- initialization -- *
+ * -------------------- */
+
+/* build fixed huffman trees */
+tinf_build_fixed_trees(sltree, sdtree);
+
+/* build extra bits and base tables */
+tinf_build_bits_base(length_bits, length_base, 4, 3);
+tinf_build_bits_base(dist_bits, dist_base, 2, 1);
+
+/* fix a special case */
+length_bits[28] = 0;
+length_base[28] = 258;
+
+var tinyInflate = tinf_uncompress;
+
+// The Bounding Box object
+
+function derive(v0, v1, v2, v3, t) {
+ return Math.pow(1 - t, 3) * v0 +
+ 3 * Math.pow(1 - t, 2) * t * v1 +
+ 3 * (1 - t) * Math.pow(t, 2) * v2 +
+ Math.pow(t, 3) * v3;
+}
+/**
+ * A bounding box is an enclosing box that describes the smallest measure within which all the points lie.
+ * It is used to calculate the bounding box of a glyph or text path.
+ *
+ * On initialization, x1/y1/x2/y2 will be NaN. Check if the bounding box is empty using `isEmpty()`.
+ *
+ * @exports opentype.BoundingBox
+ * @class
+ * @constructor
+ */
+function BoundingBox() {
+ this.x1 = Number.NaN;
+ this.y1 = Number.NaN;
+ this.x2 = Number.NaN;
+ this.y2 = Number.NaN;
+}
+
+/**
+ * Returns true if the bounding box is empty, that is, no points have been added to the box yet.
+ */
+BoundingBox.prototype.isEmpty = function() {
+ return isNaN(this.x1) || isNaN(this.y1) || isNaN(this.x2) || isNaN(this.y2);
+};
+
+/**
+ * Add the point to the bounding box.
+ * The x1/y1/x2/y2 coordinates of the bounding box will now encompass the given point.
+ * @param {number} x - The X coordinate of the point.
+ * @param {number} y - The Y coordinate of the point.
+ */
+BoundingBox.prototype.addPoint = function(x, y) {
+ if (typeof x === 'number') {
+ if (isNaN(this.x1) || isNaN(this.x2)) {
+ this.x1 = x;
+ this.x2 = x;
+ }
+ if (x < this.x1) {
+ this.x1 = x;
+ }
+ if (x > this.x2) {
+ this.x2 = x;
+ }
+ }
+ if (typeof y === 'number') {
+ if (isNaN(this.y1) || isNaN(this.y2)) {
+ this.y1 = y;
+ this.y2 = y;
+ }
+ if (y < this.y1) {
+ this.y1 = y;
+ }
+ if (y > this.y2) {
+ this.y2 = y;
+ }
+ }
+};
+
+/**
+ * Add a X coordinate to the bounding box.
+ * This extends the bounding box to include the X coordinate.
+ * This function is used internally inside of addBezier.
+ * @param {number} x - The X coordinate of the point.
+ */
+BoundingBox.prototype.addX = function(x) {
+ this.addPoint(x, null);
+};
+
+/**
+ * Add a Y coordinate to the bounding box.
+ * This extends the bounding box to include the Y coordinate.
+ * This function is used internally inside of addBezier.
+ * @param {number} y - The Y coordinate of the point.
+ */
+BoundingBox.prototype.addY = function(y) {
+ this.addPoint(null, y);
+};
+
+/**
+ * Add a Bézier curve to the bounding box.
+ * This extends the bounding box to include the entire Bézier.
+ * @param {number} x0 - The starting X coordinate.
+ * @param {number} y0 - The starting Y coordinate.
+ * @param {number} x1 - The X coordinate of the first control point.
+ * @param {number} y1 - The Y coordinate of the first control point.
+ * @param {number} x2 - The X coordinate of the second control point.
+ * @param {number} y2 - The Y coordinate of the second control point.
+ * @param {number} x - The ending X coordinate.
+ * @param {number} y - The ending Y coordinate.
+ */
+BoundingBox.prototype.addBezier = function(x0, y0, x1, y1, x2, y2, x, y) {
+ // This code is based on http://nishiohirokazu.blogspot.com/2009/06/how-to-calculate-bezier-curves-bounding.html
+ // and https://github.com/icons8/svg-path-bounding-box
+
+ var p0 = [x0, y0];
+ var p1 = [x1, y1];
+ var p2 = [x2, y2];
+ var p3 = [x, y];
+
+ this.addPoint(x0, y0);
+ this.addPoint(x, y);
+
+ for (var i = 0; i <= 1; i++) {
+ var b = 6 * p0[i] - 12 * p1[i] + 6 * p2[i];
+ var a = -3 * p0[i] + 9 * p1[i] - 9 * p2[i] + 3 * p3[i];
+ var c = 3 * p1[i] - 3 * p0[i];
+
+ if (a === 0) {
+ if (b === 0) { continue; }
+ var t = -c / b;
+ if (0 < t && t < 1) {
+ if (i === 0) { this.addX(derive(p0[i], p1[i], p2[i], p3[i], t)); }
+ if (i === 1) { this.addY(derive(p0[i], p1[i], p2[i], p3[i], t)); }
+ }
+ continue;
+ }
+
+ var b2ac = Math.pow(b, 2) - 4 * c * a;
+ if (b2ac < 0) { continue; }
+ var t1 = (-b + Math.sqrt(b2ac)) / (2 * a);
+ if (0 < t1 && t1 < 1) {
+ if (i === 0) { this.addX(derive(p0[i], p1[i], p2[i], p3[i], t1)); }
+ if (i === 1) { this.addY(derive(p0[i], p1[i], p2[i], p3[i], t1)); }
+ }
+ var t2 = (-b - Math.sqrt(b2ac)) / (2 * a);
+ if (0 < t2 && t2 < 1) {
+ if (i === 0) { this.addX(derive(p0[i], p1[i], p2[i], p3[i], t2)); }
+ if (i === 1) { this.addY(derive(p0[i], p1[i], p2[i], p3[i], t2)); }
+ }
+ }
+};
+
+/**
+ * Add a quadratic curve to the bounding box.
+ * This extends the bounding box to include the entire quadratic curve.
+ * @param {number} x0 - The starting X coordinate.
+ * @param {number} y0 - The starting Y coordinate.
+ * @param {number} x1 - The X coordinate of the control point.
+ * @param {number} y1 - The Y coordinate of the control point.
+ * @param {number} x - The ending X coordinate.
+ * @param {number} y - The ending Y coordinate.
+ */
+BoundingBox.prototype.addQuad = function(x0, y0, x1, y1, x, y) {
+ var cp1x = x0 + 2 / 3 * (x1 - x0);
+ var cp1y = y0 + 2 / 3 * (y1 - y0);
+ var cp2x = cp1x + 1 / 3 * (x - x0);
+ var cp2y = cp1y + 1 / 3 * (y - y0);
+ this.addBezier(x0, y0, cp1x, cp1y, cp2x, cp2y, x, y);
+};
+
+// Geometric objects
+
+/**
+ * A bézier path containing a set of path commands similar to a SVG path.
+ * Paths can be drawn on a context using `draw`.
+ * @exports opentype.Path
+ * @class
+ * @constructor
+ */
+function Path() {
+ this.commands = [];
+ this.fill = 'black';
+ this.stroke = null;
+ this.strokeWidth = 1;
+}
+
+/**
+ * @param {number} x
+ * @param {number} y
+ */
+Path.prototype.moveTo = function(x, y) {
+ this.commands.push({
+ type: 'M',
+ x: x,
+ y: y
+ });
+};
+
+/**
+ * @param {number} x
+ * @param {number} y
+ */
+Path.prototype.lineTo = function(x, y) {
+ this.commands.push({
+ type: 'L',
+ x: x,
+ y: y
+ });
+};
+
+/**
+ * Draws cubic curve
+ * @function
+ * curveTo
+ * @memberof opentype.Path.prototype
+ * @param {number} x1 - x of control 1
+ * @param {number} y1 - y of control 1
+ * @param {number} x2 - x of control 2
+ * @param {number} y2 - y of control 2
+ * @param {number} x - x of path point
+ * @param {number} y - y of path point
+ */
+
+/**
+ * Draws cubic curve
+ * @function
+ * bezierCurveTo
+ * @memberof opentype.Path.prototype
+ * @param {number} x1 - x of control 1
+ * @param {number} y1 - y of control 1
+ * @param {number} x2 - x of control 2
+ * @param {number} y2 - y of control 2
+ * @param {number} x - x of path point
+ * @param {number} y - y of path point
+ * @see curveTo
+ */
+Path.prototype.curveTo = Path.prototype.bezierCurveTo = function(x1, y1, x2, y2, x, y) {
+ this.commands.push({
+ type: 'C',
+ x1: x1,
+ y1: y1,
+ x2: x2,
+ y2: y2,
+ x: x,
+ y: y
+ });
+};
+
+/**
+ * Draws quadratic curve
+ * @function
+ * quadraticCurveTo
+ * @memberof opentype.Path.prototype
+ * @param {number} x1 - x of control
+ * @param {number} y1 - y of control
+ * @param {number} x - x of path point
+ * @param {number} y - y of path point
+ */
+
+/**
+ * Draws quadratic curve
+ * @function
+ * quadTo
+ * @memberof opentype.Path.prototype
+ * @param {number} x1 - x of control
+ * @param {number} y1 - y of control
+ * @param {number} x - x of path point
+ * @param {number} y - y of path point
+ */
+Path.prototype.quadTo = Path.prototype.quadraticCurveTo = function(x1, y1, x, y) {
+ this.commands.push({
+ type: 'Q',
+ x1: x1,
+ y1: y1,
+ x: x,
+ y: y
+ });
+};
+
+/**
+ * Closes the path
+ * @function closePath
+ * @memberof opentype.Path.prototype
+ */
+
+/**
+ * Close the path
+ * @function close
+ * @memberof opentype.Path.prototype
+ */
+Path.prototype.close = Path.prototype.closePath = function() {
+ this.commands.push({
+ type: 'Z'
+ });
+};
+
+/**
+ * Add the given path or list of commands to the commands of this path.
+ * @param {Array} pathOrCommands - another opentype.Path, an opentype.BoundingBox, or an array of commands.
+ */
+Path.prototype.extend = function(pathOrCommands) {
+ if (pathOrCommands.commands) {
+ pathOrCommands = pathOrCommands.commands;
+ } else if (pathOrCommands instanceof BoundingBox) {
+ var box = pathOrCommands;
+ this.moveTo(box.x1, box.y1);
+ this.lineTo(box.x2, box.y1);
+ this.lineTo(box.x2, box.y2);
+ this.lineTo(box.x1, box.y2);
+ this.close();
+ return;
+ }
+
+ Array.prototype.push.apply(this.commands, pathOrCommands);
+};
+
+/**
+ * Calculate the bounding box of the path.
+ * @returns {opentype.BoundingBox}
+ */
+Path.prototype.getBoundingBox = function() {
+ var box = new BoundingBox();
+
+ var startX = 0;
+ var startY = 0;
+ var prevX = 0;
+ var prevY = 0;
+ for (var i = 0; i < this.commands.length; i++) {
+ var cmd = this.commands[i];
+ switch (cmd.type) {
+ case 'M':
+ box.addPoint(cmd.x, cmd.y);
+ startX = prevX = cmd.x;
+ startY = prevY = cmd.y;
+ break;
+ case 'L':
+ box.addPoint(cmd.x, cmd.y);
+ prevX = cmd.x;
+ prevY = cmd.y;
+ break;
+ case 'Q':
+ box.addQuad(prevX, prevY, cmd.x1, cmd.y1, cmd.x, cmd.y);
+ prevX = cmd.x;
+ prevY = cmd.y;
+ break;
+ case 'C':
+ box.addBezier(prevX, prevY, cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y);
+ prevX = cmd.x;
+ prevY = cmd.y;
+ break;
+ case 'Z':
+ prevX = startX;
+ prevY = startY;
+ break;
+ default:
+ throw new Error('Unexpected path command ' + cmd.type);
+ }
+ }
+ if (box.isEmpty()) {
+ box.addPoint(0, 0);
+ }
+ return box;
+};
+
+/**
+ * Draw the path to a 2D context.
+ * @param {CanvasRenderingContext2D} ctx - A 2D drawing context.
+ */
+Path.prototype.draw = function(ctx) {
+ ctx.beginPath();
+ for (var i = 0; i < this.commands.length; i += 1) {
+ var cmd = this.commands[i];
+ if (cmd.type === 'M') {
+ ctx.moveTo(cmd.x, cmd.y);
+ } else if (cmd.type === 'L') {
+ ctx.lineTo(cmd.x, cmd.y);
+ } else if (cmd.type === 'C') {
+ ctx.bezierCurveTo(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y);
+ } else if (cmd.type === 'Q') {
+ ctx.quadraticCurveTo(cmd.x1, cmd.y1, cmd.x, cmd.y);
+ } else if (cmd.type === 'Z') {
+ ctx.closePath();
+ }
+ }
+
+ if (this.fill) {
+ ctx.fillStyle = this.fill;
+ ctx.fill();
+ }
+
+ if (this.stroke) {
+ ctx.strokeStyle = this.stroke;
+ ctx.lineWidth = this.strokeWidth;
+ ctx.stroke();
+ }
+};
+
+/**
+ * Convert the Path to a string of path data instructions
+ * See http://www.w3.org/TR/SVG/paths.html#PathData
+ * @param {number} [decimalPlaces=2] - The amount of decimal places for floating-point values
+ * @return {string}
+ */
+Path.prototype.toPathData = function(decimalPlaces) {
+ decimalPlaces = decimalPlaces !== undefined ? decimalPlaces : 2;
+
+ function floatToString(v) {
+ if (Math.round(v) === v) {
+ return '' + Math.round(v);
+ } else {
+ return v.toFixed(decimalPlaces);
+ }
+ }
+
+ function packValues() {
+ var arguments$1 = arguments;
+
+ var s = '';
+ for (var i = 0; i < arguments.length; i += 1) {
+ var v = arguments$1[i];
+ if (v >= 0 && i > 0) {
+ s += ' ';
+ }
+
+ s += floatToString(v);
+ }
+
+ return s;
+ }
+
+ var d = '';
+ for (var i = 0; i < this.commands.length; i += 1) {
+ var cmd = this.commands[i];
+ if (cmd.type === 'M') {
+ d += 'M' + packValues(cmd.x, cmd.y);
+ } else if (cmd.type === 'L') {
+ d += 'L' + packValues(cmd.x, cmd.y);
+ } else if (cmd.type === 'C') {
+ d += 'C' + packValues(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y);
+ } else if (cmd.type === 'Q') {
+ d += 'Q' + packValues(cmd.x1, cmd.y1, cmd.x, cmd.y);
+ } else if (cmd.type === 'Z') {
+ d += 'Z';
+ }
+ }
+
+ return d;
+};
+
+/**
+ * Convert the path to an SVG element, as a string.
+ * @param {number} [decimalPlaces=2] - The amount of decimal places for floating-point values
+ * @return {string}
+ */
+Path.prototype.toSVG = function(decimalPlaces) {
+ var svg = ' ';
+ return svg;
+};
+
+/**
+ * Convert the path to a DOM element.
+ * @param {number} [decimalPlaces=2] - The amount of decimal places for floating-point values
+ * @return {SVGPathElement}
+ */
+Path.prototype.toDOMElement = function(decimalPlaces) {
+ var temporaryPath = this.toPathData(decimalPlaces);
+ var newPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+
+ newPath.setAttribute('d', temporaryPath);
+
+ return newPath;
+};
+
+// Run-time checking of preconditions.
+
+function fail(message) {
+ throw new Error(message);
+}
+
+// Precondition function that checks if the given predicate is true.
+// If not, it will throw an error.
+function argument(predicate, message) {
+ if (!predicate) {
+ fail(message);
+ }
+}
+var check = { fail: fail, argument: argument, assert: argument };
+
+// Data types used in the OpenType font file.
+
+var LIMIT16 = 32768; // The limit at which a 16-bit number switches signs == 2^15
+var LIMIT32 = 2147483648; // The limit at which a 32-bit number switches signs == 2 ^ 31
+
+/**
+ * @exports opentype.decode
+ * @class
+ */
+var decode = {};
+/**
+ * @exports opentype.encode
+ * @class
+ */
+var encode = {};
+/**
+ * @exports opentype.sizeOf
+ * @class
+ */
+var sizeOf = {};
+
+// Return a function that always returns the same value.
+function constant(v) {
+ return function() {
+ return v;
+ };
+}
+
+// OpenType data types //////////////////////////////////////////////////////
+
+/**
+ * Convert an 8-bit unsigned integer to a list of 1 byte.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.BYTE = function(v) {
+ check.argument(v >= 0 && v <= 255, 'Byte value should be between 0 and 255.');
+ return [v];
+};
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.BYTE = constant(1);
+
+/**
+ * Convert a 8-bit signed integer to a list of 1 byte.
+ * @param {string}
+ * @returns {Array}
+ */
+encode.CHAR = function(v) {
+ return [v.charCodeAt(0)];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.CHAR = constant(1);
+
+/**
+ * Convert an ASCII string to a list of bytes.
+ * @param {string}
+ * @returns {Array}
+ */
+encode.CHARARRAY = function(v) {
+ if (typeof v === 'undefined') {
+ v = '';
+ console.warn('Undefined CHARARRAY encountered and treated as an empty string. This is probably caused by a missing glyph name.');
+ }
+ var b = [];
+ for (var i = 0; i < v.length; i += 1) {
+ b[i] = v.charCodeAt(i);
+ }
+
+ return b;
+};
+
+/**
+ * @param {Array}
+ * @returns {number}
+ */
+sizeOf.CHARARRAY = function(v) {
+ if (typeof v === 'undefined') {
+ return 0;
+ }
+ return v.length;
+};
+
+/**
+ * Convert a 16-bit unsigned integer to a list of 2 bytes.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.USHORT = function(v) {
+ return [(v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.USHORT = constant(2);
+
+/**
+ * Convert a 16-bit signed integer to a list of 2 bytes.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.SHORT = function(v) {
+ // Two's complement
+ if (v >= LIMIT16) {
+ v = -(2 * LIMIT16 - v);
+ }
+
+ return [(v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.SHORT = constant(2);
+
+/**
+ * Convert a 24-bit unsigned integer to a list of 3 bytes.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.UINT24 = function(v) {
+ return [(v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.UINT24 = constant(3);
+
+/**
+ * Convert a 32-bit unsigned integer to a list of 4 bytes.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.ULONG = function(v) {
+ return [(v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.ULONG = constant(4);
+
+/**
+ * Convert a 32-bit unsigned integer to a list of 4 bytes.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.LONG = function(v) {
+ // Two's complement
+ if (v >= LIMIT32) {
+ v = -(2 * LIMIT32 - v);
+ }
+
+ return [(v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.LONG = constant(4);
+
+encode.FIXED = encode.ULONG;
+sizeOf.FIXED = sizeOf.ULONG;
+
+encode.FWORD = encode.SHORT;
+sizeOf.FWORD = sizeOf.SHORT;
+
+encode.UFWORD = encode.USHORT;
+sizeOf.UFWORD = sizeOf.USHORT;
+
+/**
+ * Convert a 32-bit Apple Mac timestamp integer to a list of 8 bytes, 64-bit timestamp.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.LONGDATETIME = function(v) {
+ return [0, 0, 0, 0, (v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.LONGDATETIME = constant(8);
+
+/**
+ * Convert a 4-char tag to a list of 4 bytes.
+ * @param {string}
+ * @returns {Array}
+ */
+encode.TAG = function(v) {
+ check.argument(v.length === 4, 'Tag should be exactly 4 ASCII characters.');
+ return [v.charCodeAt(0),
+ v.charCodeAt(1),
+ v.charCodeAt(2),
+ v.charCodeAt(3)];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.TAG = constant(4);
+
+// CFF data types ///////////////////////////////////////////////////////////
+
+encode.Card8 = encode.BYTE;
+sizeOf.Card8 = sizeOf.BYTE;
+
+encode.Card16 = encode.USHORT;
+sizeOf.Card16 = sizeOf.USHORT;
+
+encode.OffSize = encode.BYTE;
+sizeOf.OffSize = sizeOf.BYTE;
+
+encode.SID = encode.USHORT;
+sizeOf.SID = sizeOf.USHORT;
+
+// Convert a numeric operand or charstring number to a variable-size list of bytes.
+/**
+ * Convert a numeric operand or charstring number to a variable-size list of bytes.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.NUMBER = function(v) {
+ if (v >= -107 && v <= 107) {
+ return [v + 139];
+ } else if (v >= 108 && v <= 1131) {
+ v = v - 108;
+ return [(v >> 8) + 247, v & 0xFF];
+ } else if (v >= -1131 && v <= -108) {
+ v = -v - 108;
+ return [(v >> 8) + 251, v & 0xFF];
+ } else if (v >= -32768 && v <= 32767) {
+ return encode.NUMBER16(v);
+ } else {
+ return encode.NUMBER32(v);
+ }
+};
+
+/**
+ * @param {number}
+ * @returns {number}
+ */
+sizeOf.NUMBER = function(v) {
+ return encode.NUMBER(v).length;
+};
+
+/**
+ * Convert a signed number between -32768 and +32767 to a three-byte value.
+ * This ensures we always use three bytes, but is not the most compact format.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.NUMBER16 = function(v) {
+ return [28, (v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.NUMBER16 = constant(3);
+
+/**
+ * Convert a signed number between -(2^31) and +(2^31-1) to a five-byte value.
+ * This is useful if you want to be sure you always use four bytes,
+ * at the expense of wasting a few bytes for smaller numbers.
+ * @param {number}
+ * @returns {Array}
+ */
+encode.NUMBER32 = function(v) {
+ return [29, (v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF];
+};
+
+/**
+ * @constant
+ * @type {number}
+ */
+sizeOf.NUMBER32 = constant(5);
+
+/**
+ * @param {number}
+ * @returns {Array}
+ */
+encode.REAL = function(v) {
+ var value = v.toString();
+
+ // Some numbers use an epsilon to encode the value. (e.g. JavaScript will store 0.0000001 as 1e-7)
+ // This code converts it back to a number without the epsilon.
+ var m = /\.(\d*?)(?:9{5,20}|0{5,20})\d{0,2}(?:e(.+)|$)/.exec(value);
+ if (m) {
+ var epsilon = parseFloat('1e' + ((m[2] ? +m[2] : 0) + m[1].length));
+ value = (Math.round(v * epsilon) / epsilon).toString();
+ }
+
+ var nibbles = '';
+ for (var i = 0, ii = value.length; i < ii; i += 1) {
+ var c = value[i];
+ if (c === 'e') {
+ nibbles += value[++i] === '-' ? 'c' : 'b';
+ } else if (c === '.') {
+ nibbles += 'a';
+ } else if (c === '-') {
+ nibbles += 'e';
+ } else {
+ nibbles += c;
+ }
+ }
+
+ nibbles += (nibbles.length & 1) ? 'f' : 'ff';
+ var out = [30];
+ for (var i$1 = 0, ii$1 = nibbles.length; i$1 < ii$1; i$1 += 2) {
+ out.push(parseInt(nibbles.substr(i$1, 2), 16));
+ }
+
+ return out;
+};
+
+/**
+ * @param {number}
+ * @returns {number}
+ */
+sizeOf.REAL = function(v) {
+ return encode.REAL(v).length;
+};
+
+encode.NAME = encode.CHARARRAY;
+sizeOf.NAME = sizeOf.CHARARRAY;
+
+encode.STRING = encode.CHARARRAY;
+sizeOf.STRING = sizeOf.CHARARRAY;
+
+/**
+ * @param {DataView} data
+ * @param {number} offset
+ * @param {number} numBytes
+ * @returns {string}
+ */
+decode.UTF8 = function(data, offset, numBytes) {
+ var codePoints = [];
+ var numChars = numBytes;
+ for (var j = 0; j < numChars; j++, offset += 1) {
+ codePoints[j] = data.getUint8(offset);
+ }
+
+ return String.fromCharCode.apply(null, codePoints);
+};
+
+/**
+ * @param {DataView} data
+ * @param {number} offset
+ * @param {number} numBytes
+ * @returns {string}
+ */
+decode.UTF16 = function(data, offset, numBytes) {
+ var codePoints = [];
+ var numChars = numBytes / 2;
+ for (var j = 0; j < numChars; j++, offset += 2) {
+ codePoints[j] = data.getUint16(offset);
+ }
+
+ return String.fromCharCode.apply(null, codePoints);
+};
+
+/**
+ * Convert a JavaScript string to UTF16-BE.
+ * @param {string}
+ * @returns {Array}
+ */
+encode.UTF16 = function(v) {
+ var b = [];
+ for (var i = 0; i < v.length; i += 1) {
+ var codepoint = v.charCodeAt(i);
+ b[b.length] = (codepoint >> 8) & 0xFF;
+ b[b.length] = codepoint & 0xFF;
+ }
+
+ return b;
+};
+
+/**
+ * @param {string}
+ * @returns {number}
+ */
+sizeOf.UTF16 = function(v) {
+ return v.length * 2;
+};
+
+// Data for converting old eight-bit Macintosh encodings to Unicode.
+// This representation is optimized for decoding; encoding is slower
+// and needs more memory. The assumption is that all opentype.js users
+// want to open fonts, but saving a font will be comparatively rare
+// so it can be more expensive. Keyed by IANA character set name.
+//
+// Python script for generating these strings:
+//
+// s = u''.join([chr(c).decode('mac_greek') for c in range(128, 256)])
+// print(s.encode('utf-8'))
+/**
+ * @private
+ */
+var eightBitMacEncodings = {
+ 'x-mac-croatian': // Python: 'mac_croatian'
+ 'ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®Š™´¨≠ŽØ∞±≤≥∆µ∂∑∏š∫ªºΩžø' +
+ '¿¡¬√ƒ≈ƫȅ ÀÃÕŒœĐ—“”‘’÷◊©⁄€‹›Æ»–·‚„‰ÂćÁčÈÍÎÏÌÓÔđÒÚÛÙıˆ˜¯πË˚¸Êæˇ',
+ 'x-mac-cyrillic': // Python: 'mac_cyrillic'
+ 'АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ†°Ґ£§•¶І®©™Ђђ≠Ѓѓ∞±≤≥іµґЈЄєЇїЉљЊњ' +
+ 'јЅ¬√ƒ≈∆«»… ЋћЌќѕ–—“”‘’÷„ЎўЏџ№Ёёяабвгдежзийклмнопрстуфхцчшщъыьэю',
+ 'x-mac-gaelic': // http://unicode.org/Public/MAPPINGS/VENDORS/APPLE/GAELIC.TXT
+ 'ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØḂ±≤≥ḃĊċḊḋḞḟĠġṀæø' +
+ 'ṁṖṗɼƒſṠ«»… ÀÃÕŒœ–—“”‘’ṡẛÿŸṪ€‹›Ŷŷṫ·Ỳỳ⁊ÂÊÁËÈÍÎÏÌÓÔ♣ÒÚÛÙıÝýŴŵẄẅẀẁẂẃ',
+ 'x-mac-greek': // Python: 'mac_greek'
+ 'Ĺ²É³ÖÜ΅àâä΄¨çéèê룙î‰ôö¦€ùûü†ΓΔΘΛΞΠß®©ΣΪ§≠°·Α±≤≥¥ΒΕΖΗΙΚΜΦΫΨΩ' +
+ 'άΝ¬ΟΡ≈Τ«»… ΥΧΆΈœ–―“”‘’÷ΉΊΌΎέήίόΏύαβψδεφγηιξκλμνοπώρστθωςχυζϊϋΐΰ\u00AD',
+ 'x-mac-icelandic': // Python: 'mac_iceland'
+ 'ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûüݰ¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø' +
+ '¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€ÐðÞþý·‚„‰ÂÊÁËÈÍÎÏÌÓÔÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ',
+ 'x-mac-inuit': // http://unicode.org/Public/MAPPINGS/VENDORS/APPLE/INUIT.TXT
+ 'ᐃᐄᐅᐆᐊᐋᐱᐲᐳᐴᐸᐹᑉᑎᑏᑐᑑᑕᑖᑦᑭᑮᑯᑰᑲᑳᒃᒋᒌᒍᒎᒐᒑ°ᒡᒥᒦ•¶ᒧ®©™ᒨᒪᒫᒻᓂᓃᓄᓅᓇᓈᓐᓯᓰᓱᓲᓴᓵᔅᓕᓖᓗ' +
+ 'ᓘᓚᓛᓪᔨᔩᔪᔫᔭ… ᔮᔾᕕᕖᕗ–—“”‘’ᕘᕙᕚᕝᕆᕇᕈᕉᕋᕌᕐᕿᖀᖁᖂᖃᖄᖅᖏᖐᖑᖒᖓᖔᖕᙱᙲᙳᙴᙵᙶᖖᖠᖡᖢᖣᖤᖥᖦᕼŁł',
+ 'x-mac-ce': // Python: 'mac_latin2'
+ 'ÄĀāÉĄÖÜáąČäčĆć鏟ĎíďĒēĖóėôöõúĚěü†°Ę£§•¶ß®©™ę¨≠ģĮįĪ≤≥īĶ∂∑łĻļĽľĹĺŅ' +
+ 'ņѬ√ńŇ∆«»… ňŐÕőŌ–—“”‘’÷◊ōŔŕŘ‹›řŖŗŠ‚„šŚśÁŤťÍŽžŪÓÔūŮÚůŰűŲųÝýķŻŁżĢˇ',
+ macintosh: // Python: 'mac_roman'
+ 'ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø' +
+ '¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€‹›fifl‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ',
+ 'x-mac-romanian': // Python: 'mac_romanian'
+ 'ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ĂȘ∞±≤≥¥µ∂∑∏π∫ªºΩăș' +
+ '¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€‹›Țț‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ',
+ 'x-mac-turkish': // Python: 'mac_turkish'
+ 'ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø' +
+ '¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸĞğİıŞş‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔÒÚÛÙˆ˜¯˘˙˚¸˝˛ˇ'
+};
+
+/**
+ * Decodes an old-style Macintosh string. Returns either a Unicode JavaScript
+ * string, or 'undefined' if the encoding is unsupported. For example, we do
+ * not support Chinese, Japanese or Korean because these would need large
+ * mapping tables.
+ * @param {DataView} dataView
+ * @param {number} offset
+ * @param {number} dataLength
+ * @param {string} encoding
+ * @returns {string}
+ */
+decode.MACSTRING = function(dataView, offset, dataLength, encoding) {
+ var table = eightBitMacEncodings[encoding];
+ if (table === undefined) {
+ return undefined;
+ }
+
+ var result = '';
+ for (var i = 0; i < dataLength; i++) {
+ var c = dataView.getUint8(offset + i);
+ // In all eight-bit Mac encodings, the characters 0x00..0x7F are
+ // mapped to U+0000..U+007F; we only need to look up the others.
+ if (c <= 0x7F) {
+ result += String.fromCharCode(c);
+ } else {
+ result += table[c & 0x7F];
+ }
+ }
+
+ return result;
+};
+
+// Helper function for encode.MACSTRING. Returns a dictionary for mapping
+// Unicode character codes to their 8-bit MacOS equivalent. This table
+// is not exactly a super cheap data structure, but we do not care because
+// encoding Macintosh strings is only rarely needed in typical applications.
+var macEncodingTableCache = typeof WeakMap === 'function' && new WeakMap();
+var macEncodingCacheKeys;
+var getMacEncodingTable = function (encoding) {
+ // Since we use encoding as a cache key for WeakMap, it has to be
+ // a String object and not a literal. And at least on NodeJS 2.10.1,
+ // WeakMap requires that the same String instance is passed for cache hits.
+ if (!macEncodingCacheKeys) {
+ macEncodingCacheKeys = {};
+ for (var e in eightBitMacEncodings) {
+ /*jshint -W053 */ // Suppress "Do not use String as a constructor."
+ macEncodingCacheKeys[e] = new String(e);
+ }
+ }
+
+ var cacheKey = macEncodingCacheKeys[encoding];
+ if (cacheKey === undefined) {
+ return undefined;
+ }
+
+ // We can't do "if (cache.has(key)) {return cache.get(key)}" here:
+ // since garbage collection may run at any time, it could also kick in
+ // between the calls to cache.has() and cache.get(). In that case,
+ // we would return 'undefined' even though we do support the encoding.
+ if (macEncodingTableCache) {
+ var cachedTable = macEncodingTableCache.get(cacheKey);
+ if (cachedTable !== undefined) {
+ return cachedTable;
+ }
+ }
+
+ var decodingTable = eightBitMacEncodings[encoding];
+ if (decodingTable === undefined) {
+ return undefined;
+ }
+
+ var encodingTable = {};
+ for (var i = 0; i < decodingTable.length; i++) {
+ encodingTable[decodingTable.charCodeAt(i)] = i + 0x80;
+ }
+
+ if (macEncodingTableCache) {
+ macEncodingTableCache.set(cacheKey, encodingTable);
+ }
+
+ return encodingTable;
+};
+
+/**
+ * Encodes an old-style Macintosh string. Returns a byte array upon success.
+ * If the requested encoding is unsupported, or if the input string contains
+ * a character that cannot be expressed in the encoding, the function returns
+ * 'undefined'.
+ * @param {string} str
+ * @param {string} encoding
+ * @returns {Array}
+ */
+encode.MACSTRING = function(str, encoding) {
+ var table = getMacEncodingTable(encoding);
+ if (table === undefined) {
+ return undefined;
+ }
+
+ var result = [];
+ for (var i = 0; i < str.length; i++) {
+ var c = str.charCodeAt(i);
+
+ // In all eight-bit Mac encodings, the characters 0x00..0x7F are
+ // mapped to U+0000..U+007F; we only need to look up the others.
+ if (c >= 0x80) {
+ c = table[c];
+ if (c === undefined) {
+ // str contains a Unicode character that cannot be encoded
+ // in the requested encoding.
+ return undefined;
+ }
+ }
+ result[i] = c;
+ // result.push(c);
+ }
+
+ return result;
+};
+
+/**
+ * @param {string} str
+ * @param {string} encoding
+ * @returns {number}
+ */
+sizeOf.MACSTRING = function(str, encoding) {
+ var b = encode.MACSTRING(str, encoding);
+ if (b !== undefined) {
+ return b.length;
+ } else {
+ return 0;
+ }
+};
+
+// Helper for encode.VARDELTAS
+function isByteEncodable(value) {
+ return value >= -128 && value <= 127;
+}
+
+// Helper for encode.VARDELTAS
+function encodeVarDeltaRunAsZeroes(deltas, pos, result) {
+ var runLength = 0;
+ var numDeltas = deltas.length;
+ while (pos < numDeltas && runLength < 64 && deltas[pos] === 0) {
+ ++pos;
+ ++runLength;
+ }
+ result.push(0x80 | (runLength - 1));
+ return pos;
+}
+
+// Helper for encode.VARDELTAS
+function encodeVarDeltaRunAsBytes(deltas, offset, result) {
+ var runLength = 0;
+ var numDeltas = deltas.length;
+ var pos = offset;
+ while (pos < numDeltas && runLength < 64) {
+ var value = deltas[pos];
+ if (!isByteEncodable(value)) {
+ break;
+ }
+
+ // Within a byte-encoded run of deltas, a single zero is best
+ // stored literally as 0x00 value. However, if we have two or
+ // more zeroes in a sequence, it is better to start a new run.
+ // Fore example, the sequence of deltas [15, 15, 0, 15, 15]
+ // becomes 6 bytes (04 0F 0F 00 0F 0F) when storing the zero
+ // within the current run, but 7 bytes (01 0F 0F 80 01 0F 0F)
+ // when starting a new run.
+ if (value === 0 && pos + 1 < numDeltas && deltas[pos + 1] === 0) {
+ break;
+ }
+
+ ++pos;
+ ++runLength;
+ }
+ result.push(runLength - 1);
+ for (var i = offset; i < pos; ++i) {
+ result.push((deltas[i] + 256) & 0xff);
+ }
+ return pos;
+}
+
+// Helper for encode.VARDELTAS
+function encodeVarDeltaRunAsWords(deltas, offset, result) {
+ var runLength = 0;
+ var numDeltas = deltas.length;
+ var pos = offset;
+ while (pos < numDeltas && runLength < 64) {
+ var value = deltas[pos];
+
+ // Within a word-encoded run of deltas, it is easiest to start
+ // a new run (with a different encoding) whenever we encounter
+ // a zero value. For example, the sequence [0x6666, 0, 0x7777]
+ // needs 7 bytes when storing the zero inside the current run
+ // (42 66 66 00 00 77 77), and equally 7 bytes when starting a
+ // new run (40 66 66 80 40 77 77).
+ if (value === 0) {
+ break;
+ }
+
+ // Within a word-encoded run of deltas, a single value in the
+ // range (-128..127) should be encoded within the current run
+ // because it is more compact. For example, the sequence
+ // [0x6666, 2, 0x7777] becomes 7 bytes when storing the value
+ // literally (42 66 66 00 02 77 77), but 8 bytes when starting
+ // a new run (40 66 66 00 02 40 77 77).
+ if (isByteEncodable(value) && pos + 1 < numDeltas && isByteEncodable(deltas[pos + 1])) {
+ break;
+ }
+
+ ++pos;
+ ++runLength;
+ }
+ result.push(0x40 | (runLength - 1));
+ for (var i = offset; i < pos; ++i) {
+ var val = deltas[i];
+ result.push(((val + 0x10000) >> 8) & 0xff, (val + 0x100) & 0xff);
+ }
+ return pos;
+}
+
+/**
+ * Encode a list of variation adjustment deltas.
+ *
+ * Variation adjustment deltas are used in ‘gvar’ and ‘cvar’ tables.
+ * They indicate how points (in ‘gvar’) or values (in ‘cvar’) get adjusted
+ * when generating instances of variation fonts.
+ *
+ * @see https://www.microsoft.com/typography/otspec/gvar.htm
+ * @see https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6gvar.html
+ * @param {Array}
+ * @return {Array}
+ */
+encode.VARDELTAS = function(deltas) {
+ var pos = 0;
+ var result = [];
+ while (pos < deltas.length) {
+ var value = deltas[pos];
+ if (value === 0) {
+ pos = encodeVarDeltaRunAsZeroes(deltas, pos, result);
+ } else if (value >= -128 && value <= 127) {
+ pos = encodeVarDeltaRunAsBytes(deltas, pos, result);
+ } else {
+ pos = encodeVarDeltaRunAsWords(deltas, pos, result);
+ }
+ }
+ return result;
+};
+
+// Convert a list of values to a CFF INDEX structure.
+// The values should be objects containing name / type / value.
+/**
+ * @param {Array} l
+ * @returns {Array}
+ */
+encode.INDEX = function(l) {
+ //var offset, offsets, offsetEncoder, encodedOffsets, encodedOffset, data,
+ // i, v;
+ // Because we have to know which data type to use to encode the offsets,
+ // we have to go through the values twice: once to encode the data and
+ // calculate the offsets, then again to encode the offsets using the fitting data type.
+ var offset = 1; // First offset is always 1.
+ var offsets = [offset];
+ var data = [];
+ for (var i = 0; i < l.length; i += 1) {
+ var v = encode.OBJECT(l[i]);
+ Array.prototype.push.apply(data, v);
+ offset += v.length;
+ offsets.push(offset);
+ }
+
+ if (data.length === 0) {
+ return [0, 0];
+ }
+
+ var encodedOffsets = [];
+ var offSize = (1 + Math.floor(Math.log(offset) / Math.log(2)) / 8) | 0;
+ var offsetEncoder = [undefined, encode.BYTE, encode.USHORT, encode.UINT24, encode.ULONG][offSize];
+ for (var i$1 = 0; i$1 < offsets.length; i$1 += 1) {
+ var encodedOffset = offsetEncoder(offsets[i$1]);
+ Array.prototype.push.apply(encodedOffsets, encodedOffset);
+ }
+
+ return Array.prototype.concat(encode.Card16(l.length),
+ encode.OffSize(offSize),
+ encodedOffsets,
+ data);
+};
+
+/**
+ * @param {Array}
+ * @returns {number}
+ */
+sizeOf.INDEX = function(v) {
+ return encode.INDEX(v).length;
+};
+
+/**
+ * Convert an object to a CFF DICT structure.
+ * The keys should be numeric.
+ * The values should be objects containing name / type / value.
+ * @param {Object} m
+ * @returns {Array}
+ */
+encode.DICT = function(m) {
+ var d = [];
+ var keys = Object.keys(m);
+ var length = keys.length;
+
+ for (var i = 0; i < length; i += 1) {
+ // Object.keys() return string keys, but our keys are always numeric.
+ var k = parseInt(keys[i], 0);
+ var v = m[k];
+ // Value comes before the key.
+ d = d.concat(encode.OPERAND(v.value, v.type));
+ d = d.concat(encode.OPERATOR(k));
+ }
+
+ return d;
+};
+
+/**
+ * @param {Object}
+ * @returns {number}
+ */
+sizeOf.DICT = function(m) {
+ return encode.DICT(m).length;
+};
+
+/**
+ * @param {number}
+ * @returns {Array}
+ */
+encode.OPERATOR = function(v) {
+ if (v < 1200) {
+ return [v];
+ } else {
+ return [12, v - 1200];
+ }
+};
+
+/**
+ * @param {Array} v
+ * @param {string}
+ * @returns {Array}
+ */
+encode.OPERAND = function(v, type) {
+ var d = [];
+ if (Array.isArray(type)) {
+ for (var i = 0; i < type.length; i += 1) {
+ check.argument(v.length === type.length, 'Not enough arguments given for type' + type);
+ d = d.concat(encode.OPERAND(v[i], type[i]));
+ }
+ } else {
+ if (type === 'SID') {
+ d = d.concat(encode.NUMBER(v));
+ } else if (type === 'offset') {
+ // We make it easy for ourselves and always encode offsets as
+ // 4 bytes. This makes offset calculation for the top dict easier.
+ d = d.concat(encode.NUMBER32(v));
+ } else if (type === 'number') {
+ d = d.concat(encode.NUMBER(v));
+ } else if (type === 'real') {
+ d = d.concat(encode.REAL(v));
+ } else {
+ throw new Error('Unknown operand type ' + type);
+ // FIXME Add support for booleans
+ }
+ }
+
+ return d;
+};
+
+encode.OP = encode.BYTE;
+sizeOf.OP = sizeOf.BYTE;
+
+// memoize charstring encoding using WeakMap if available
+var wmm = typeof WeakMap === 'function' && new WeakMap();
+
+/**
+ * Convert a list of CharString operations to bytes.
+ * @param {Array}
+ * @returns {Array}
+ */
+encode.CHARSTRING = function(ops) {
+ // See encode.MACSTRING for why we don't do "if (wmm && wmm.has(ops))".
+ if (wmm) {
+ var cachedValue = wmm.get(ops);
+ if (cachedValue !== undefined) {
+ return cachedValue;
+ }
+ }
+
+ var d = [];
+ var length = ops.length;
+
+ for (var i = 0; i < length; i += 1) {
+ var op = ops[i];
+ d = d.concat(encode[op.type](op.value));
+ }
+
+ if (wmm) {
+ wmm.set(ops, d);
+ }
+
+ return d;
+};
+
+/**
+ * @param {Array}
+ * @returns {number}
+ */
+sizeOf.CHARSTRING = function(ops) {
+ return encode.CHARSTRING(ops).length;
+};
+
+// Utility functions ////////////////////////////////////////////////////////
+
+/**
+ * Convert an object containing name / type / value to bytes.
+ * @param {Object}
+ * @returns {Array}
+ */
+encode.OBJECT = function(v) {
+ var encodingFunction = encode[v.type];
+ check.argument(encodingFunction !== undefined, 'No encoding function for type ' + v.type);
+ return encodingFunction(v.value);
+};
+
+/**
+ * @param {Object}
+ * @returns {number}
+ */
+sizeOf.OBJECT = function(v) {
+ var sizeOfFunction = sizeOf[v.type];
+ check.argument(sizeOfFunction !== undefined, 'No sizeOf function for type ' + v.type);
+ return sizeOfFunction(v.value);
+};
+
+/**
+ * Convert a table object to bytes.
+ * A table contains a list of fields containing the metadata (name, type and default value).
+ * The table itself has the field values set as attributes.
+ * @param {opentype.Table}
+ * @returns {Array}
+ */
+encode.TABLE = function(table) {
+ var d = [];
+ var length = table.fields.length;
+ var subtables = [];
+ var subtableOffsets = [];
+
+ for (var i = 0; i < length; i += 1) {
+ var field = table.fields[i];
+ var encodingFunction = encode[field.type];
+ check.argument(encodingFunction !== undefined, 'No encoding function for field type ' + field.type + ' (' + field.name + ')');
+ var value = table[field.name];
+ if (value === undefined) {
+ value = field.value;
+ }
+
+ var bytes = encodingFunction(value);
+
+ if (field.type === 'TABLE') {
+ subtableOffsets.push(d.length);
+ d = d.concat([0, 0]);
+ subtables.push(bytes);
+ } else {
+ d = d.concat(bytes);
+ }
+ }
+
+ for (var i$1 = 0; i$1 < subtables.length; i$1 += 1) {
+ var o = subtableOffsets[i$1];
+ var offset = d.length;
+ check.argument(offset < 65536, 'Table ' + table.tableName + ' too big.');
+ d[o] = offset >> 8;
+ d[o + 1] = offset & 0xff;
+ d = d.concat(subtables[i$1]);
+ }
+
+ return d;
+};
+
+/**
+ * @param {opentype.Table}
+ * @returns {number}
+ */
+sizeOf.TABLE = function(table) {
+ var numBytes = 0;
+ var length = table.fields.length;
+
+ for (var i = 0; i < length; i += 1) {
+ var field = table.fields[i];
+ var sizeOfFunction = sizeOf[field.type];
+ check.argument(sizeOfFunction !== undefined, 'No sizeOf function for field type ' + field.type + ' (' + field.name + ')');
+ var value = table[field.name];
+ if (value === undefined) {
+ value = field.value;
+ }
+
+ numBytes += sizeOfFunction(value);
+
+ // Subtables take 2 more bytes for offsets.
+ if (field.type === 'TABLE') {
+ numBytes += 2;
+ }
+ }
+
+ return numBytes;
+};
+
+encode.RECORD = encode.TABLE;
+sizeOf.RECORD = sizeOf.TABLE;
+
+// Merge in a list of bytes.
+encode.LITERAL = function(v) {
+ return v;
+};
+
+sizeOf.LITERAL = function(v) {
+ return v.length;
+};
+
+// Table metadata
+
+/**
+ * @exports opentype.Table
+ * @class
+ * @param {string} tableName
+ * @param {Array} fields
+ * @param {Object} options
+ * @constructor
+ */
+function Table(tableName, fields, options) {
+ // For coverage tables with coverage format 2, we do not want to add the coverage data directly to the table object,
+ // as this will result in wrong encoding order of the coverage data on serialization to bytes.
+ // The fallback of using the field values directly when not present on the table is handled in types.encode.TABLE() already.
+ if (fields.length && (fields[0].name !== 'coverageFormat' || fields[0].value === 1)) {
+ for (var i = 0; i < fields.length; i += 1) {
+ var field = fields[i];
+ this[field.name] = field.value;
+ }
+ }
+
+ this.tableName = tableName;
+ this.fields = fields;
+ if (options) {
+ var optionKeys = Object.keys(options);
+ for (var i$1 = 0; i$1 < optionKeys.length; i$1 += 1) {
+ var k = optionKeys[i$1];
+ var v = options[k];
+ if (this[k] !== undefined) {
+ this[k] = v;
+ }
+ }
+ }
+}
+
+/**
+ * Encodes the table and returns an array of bytes
+ * @return {Array}
+ */
+Table.prototype.encode = function() {
+ return encode.TABLE(this);
+};
+
+/**
+ * Get the size of the table.
+ * @return {number}
+ */
+Table.prototype.sizeOf = function() {
+ return sizeOf.TABLE(this);
+};
+
+/**
+ * @private
+ */
+function ushortList(itemName, list, count) {
+ if (count === undefined) {
+ count = list.length;
+ }
+ var fields = new Array(list.length + 1);
+ fields[0] = {name: itemName + 'Count', type: 'USHORT', value: count};
+ for (var i = 0; i < list.length; i++) {
+ fields[i + 1] = {name: itemName + i, type: 'USHORT', value: list[i]};
+ }
+ return fields;
+}
+
+/**
+ * @private
+ */
+function tableList(itemName, records, itemCallback) {
+ var count = records.length;
+ var fields = new Array(count + 1);
+ fields[0] = {name: itemName + 'Count', type: 'USHORT', value: count};
+ for (var i = 0; i < count; i++) {
+ fields[i + 1] = {name: itemName + i, type: 'TABLE', value: itemCallback(records[i], i)};
+ }
+ return fields;
+}
+
+/**
+ * @private
+ */
+function recordList(itemName, records, itemCallback) {
+ var count = records.length;
+ var fields = [];
+ fields[0] = {name: itemName + 'Count', type: 'USHORT', value: count};
+ for (var i = 0; i < count; i++) {
+ fields = fields.concat(itemCallback(records[i], i));
+ }
+ return fields;
+}
+
+// Common Layout Tables
+
+/**
+ * @exports opentype.Coverage
+ * @class
+ * @param {opentype.Table}
+ * @constructor
+ * @extends opentype.Table
+ */
+function Coverage(coverageTable) {
+ if (coverageTable.format === 1) {
+ Table.call(this, 'coverageTable',
+ [{name: 'coverageFormat', type: 'USHORT', value: 1}]
+ .concat(ushortList('glyph', coverageTable.glyphs))
+ );
+ } else if (coverageTable.format === 2) {
+ Table.call(this, 'coverageTable',
+ [{name: 'coverageFormat', type: 'USHORT', value: 2}]
+ .concat(recordList('rangeRecord', coverageTable.ranges, function(RangeRecord) {
+ return [
+ {name: 'startGlyphID', type: 'USHORT', value: RangeRecord.start},
+ {name: 'endGlyphID', type: 'USHORT', value: RangeRecord.end},
+ {name: 'startCoverageIndex', type: 'USHORT', value: RangeRecord.index} ];
+ }))
+ );
+ } else {
+ check.assert(false, 'Coverage format must be 1 or 2.');
+ }
+}
+Coverage.prototype = Object.create(Table.prototype);
+Coverage.prototype.constructor = Coverage;
+
+function ScriptList(scriptListTable) {
+ Table.call(this, 'scriptListTable',
+ recordList('scriptRecord', scriptListTable, function(scriptRecord, i) {
+ var script = scriptRecord.script;
+ var defaultLangSys = script.defaultLangSys;
+ check.assert(!!defaultLangSys, 'Unable to write GSUB: script ' + scriptRecord.tag + ' has no default language system.');
+ return [
+ {name: 'scriptTag' + i, type: 'TAG', value: scriptRecord.tag},
+ {name: 'script' + i, type: 'TABLE', value: new Table('scriptTable', [
+ {name: 'defaultLangSys', type: 'TABLE', value: new Table('defaultLangSys', [
+ {name: 'lookupOrder', type: 'USHORT', value: 0},
+ {name: 'reqFeatureIndex', type: 'USHORT', value: defaultLangSys.reqFeatureIndex}]
+ .concat(ushortList('featureIndex', defaultLangSys.featureIndexes)))}
+ ].concat(recordList('langSys', script.langSysRecords, function(langSysRecord, i) {
+ var langSys = langSysRecord.langSys;
+ return [
+ {name: 'langSysTag' + i, type: 'TAG', value: langSysRecord.tag},
+ {name: 'langSys' + i, type: 'TABLE', value: new Table('langSys', [
+ {name: 'lookupOrder', type: 'USHORT', value: 0},
+ {name: 'reqFeatureIndex', type: 'USHORT', value: langSys.reqFeatureIndex}
+ ].concat(ushortList('featureIndex', langSys.featureIndexes)))}
+ ];
+ })))}
+ ];
+ })
+ );
+}
+ScriptList.prototype = Object.create(Table.prototype);
+ScriptList.prototype.constructor = ScriptList;
+
+/**
+ * @exports opentype.FeatureList
+ * @class
+ * @param {opentype.Table}
+ * @constructor
+ * @extends opentype.Table
+ */
+function FeatureList(featureListTable) {
+ Table.call(this, 'featureListTable',
+ recordList('featureRecord', featureListTable, function(featureRecord, i) {
+ var feature = featureRecord.feature;
+ return [
+ {name: 'featureTag' + i, type: 'TAG', value: featureRecord.tag},
+ {name: 'feature' + i, type: 'TABLE', value: new Table('featureTable', [
+ {name: 'featureParams', type: 'USHORT', value: feature.featureParams} ].concat(ushortList('lookupListIndex', feature.lookupListIndexes)))}
+ ];
+ })
+ );
+}
+FeatureList.prototype = Object.create(Table.prototype);
+FeatureList.prototype.constructor = FeatureList;
+
+/**
+ * @exports opentype.LookupList
+ * @class
+ * @param {opentype.Table}
+ * @param {Object}
+ * @constructor
+ * @extends opentype.Table
+ */
+function LookupList(lookupListTable, subtableMakers) {
+ Table.call(this, 'lookupListTable', tableList('lookup', lookupListTable, function(lookupTable) {
+ var subtableCallback = subtableMakers[lookupTable.lookupType];
+ check.assert(!!subtableCallback, 'Unable to write GSUB lookup type ' + lookupTable.lookupType + ' tables.');
+ return new Table('lookupTable', [
+ {name: 'lookupType', type: 'USHORT', value: lookupTable.lookupType},
+ {name: 'lookupFlag', type: 'USHORT', value: lookupTable.lookupFlag}
+ ].concat(tableList('subtable', lookupTable.subtables, subtableCallback)));
+ }));
+}
+LookupList.prototype = Object.create(Table.prototype);
+LookupList.prototype.constructor = LookupList;
+
+// Record = same as Table, but inlined (a Table has an offset and its data is further in the stream)
+// Don't use offsets inside Records (probable bug), only in Tables.
+var table = {
+ Table: Table,
+ Record: Table,
+ Coverage: Coverage,
+ ScriptList: ScriptList,
+ FeatureList: FeatureList,
+ LookupList: LookupList,
+ ushortList: ushortList,
+ tableList: tableList,
+ recordList: recordList,
+};
+
+// Parsing utility functions
+
+// Retrieve an unsigned byte from the DataView.
+function getByte(dataView, offset) {
+ return dataView.getUint8(offset);
+}
+
+// Retrieve an unsigned 16-bit short from the DataView.
+// The value is stored in big endian.
+function getUShort(dataView, offset) {
+ return dataView.getUint16(offset, false);
+}
+
+// Retrieve a signed 16-bit short from the DataView.
+// The value is stored in big endian.
+function getShort(dataView, offset) {
+ return dataView.getInt16(offset, false);
+}
+
+// Retrieve an unsigned 32-bit long from the DataView.
+// The value is stored in big endian.
+function getULong(dataView, offset) {
+ return dataView.getUint32(offset, false);
+}
+
+// Retrieve a 32-bit signed fixed-point number (16.16) from the DataView.
+// The value is stored in big endian.
+function getFixed(dataView, offset) {
+ var decimal = dataView.getInt16(offset, false);
+ var fraction = dataView.getUint16(offset + 2, false);
+ return decimal + fraction / 65535;
+}
+
+// Retrieve a 4-character tag from the DataView.
+// Tags are used to identify tables.
+function getTag(dataView, offset) {
+ var tag = '';
+ for (var i = offset; i < offset + 4; i += 1) {
+ tag += String.fromCharCode(dataView.getInt8(i));
+ }
+
+ return tag;
+}
+
+// Retrieve an offset from the DataView.
+// Offsets are 1 to 4 bytes in length, depending on the offSize argument.
+function getOffset(dataView, offset, offSize) {
+ var v = 0;
+ for (var i = 0; i < offSize; i += 1) {
+ v <<= 8;
+ v += dataView.getUint8(offset + i);
+ }
+
+ return v;
+}
+
+// Retrieve a number of bytes from start offset to the end offset from the DataView.
+function getBytes(dataView, startOffset, endOffset) {
+ var bytes = [];
+ for (var i = startOffset; i < endOffset; i += 1) {
+ bytes.push(dataView.getUint8(i));
+ }
+
+ return bytes;
+}
+
+// Convert the list of bytes to a string.
+function bytesToString(bytes) {
+ var s = '';
+ for (var i = 0; i < bytes.length; i += 1) {
+ s += String.fromCharCode(bytes[i]);
+ }
+
+ return s;
+}
+
+var typeOffsets = {
+ byte: 1,
+ uShort: 2,
+ short: 2,
+ uLong: 4,
+ fixed: 4,
+ longDateTime: 8,
+ tag: 4
+};
+
+// A stateful parser that changes the offset whenever a value is retrieved.
+// The data is a DataView.
+function Parser(data, offset) {
+ this.data = data;
+ this.offset = offset;
+ this.relativeOffset = 0;
+}
+
+Parser.prototype.parseByte = function() {
+ var v = this.data.getUint8(this.offset + this.relativeOffset);
+ this.relativeOffset += 1;
+ return v;
+};
+
+Parser.prototype.parseChar = function() {
+ var v = this.data.getInt8(this.offset + this.relativeOffset);
+ this.relativeOffset += 1;
+ return v;
+};
+
+Parser.prototype.parseCard8 = Parser.prototype.parseByte;
+
+Parser.prototype.parseUShort = function() {
+ var v = this.data.getUint16(this.offset + this.relativeOffset);
+ this.relativeOffset += 2;
+ return v;
+};
+
+Parser.prototype.parseCard16 = Parser.prototype.parseUShort;
+Parser.prototype.parseSID = Parser.prototype.parseUShort;
+Parser.prototype.parseOffset16 = Parser.prototype.parseUShort;
+
+Parser.prototype.parseShort = function() {
+ var v = this.data.getInt16(this.offset + this.relativeOffset);
+ this.relativeOffset += 2;
+ return v;
+};
+
+Parser.prototype.parseF2Dot14 = function() {
+ var v = this.data.getInt16(this.offset + this.relativeOffset) / 16384;
+ this.relativeOffset += 2;
+ return v;
+};
+
+Parser.prototype.parseULong = function() {
+ var v = getULong(this.data, this.offset + this.relativeOffset);
+ this.relativeOffset += 4;
+ return v;
+};
+
+Parser.prototype.parseOffset32 = Parser.prototype.parseULong;
+
+Parser.prototype.parseFixed = function() {
+ var v = getFixed(this.data, this.offset + this.relativeOffset);
+ this.relativeOffset += 4;
+ return v;
+};
+
+Parser.prototype.parseString = function(length) {
+ var dataView = this.data;
+ var offset = this.offset + this.relativeOffset;
+ var string = '';
+ this.relativeOffset += length;
+ for (var i = 0; i < length; i++) {
+ string += String.fromCharCode(dataView.getUint8(offset + i));
+ }
+
+ return string;
+};
+
+Parser.prototype.parseTag = function() {
+ return this.parseString(4);
+};
+
+// LONGDATETIME is a 64-bit integer.
+// JavaScript and unix timestamps traditionally use 32 bits, so we
+// only take the last 32 bits.
+// + Since until 2038 those bits will be filled by zeros we can ignore them.
+Parser.prototype.parseLongDateTime = function() {
+ var v = getULong(this.data, this.offset + this.relativeOffset + 4);
+ // Subtract seconds between 01/01/1904 and 01/01/1970
+ // to convert Apple Mac timestamp to Standard Unix timestamp
+ v -= 2082844800;
+ this.relativeOffset += 8;
+ return v;
+};
+
+Parser.prototype.parseVersion = function(minorBase) {
+ var major = getUShort(this.data, this.offset + this.relativeOffset);
+
+ // How to interpret the minor version is very vague in the spec. 0x5000 is 5, 0x1000 is 1
+ // Default returns the correct number if minor = 0xN000 where N is 0-9
+ // Set minorBase to 1 for tables that use minor = N where N is 0-9
+ var minor = getUShort(this.data, this.offset + this.relativeOffset + 2);
+ this.relativeOffset += 4;
+ if (minorBase === undefined) { minorBase = 0x1000; }
+ return major + minor / minorBase / 10;
+};
+
+Parser.prototype.skip = function(type, amount) {
+ if (amount === undefined) {
+ amount = 1;
+ }
+
+ this.relativeOffset += typeOffsets[type] * amount;
+};
+
+///// Parsing lists and records ///////////////////////////////
+
+// Parse a list of 32 bit unsigned integers.
+Parser.prototype.parseULongList = function(count) {
+ if (count === undefined) { count = this.parseULong(); }
+ var offsets = new Array(count);
+ var dataView = this.data;
+ var offset = this.offset + this.relativeOffset;
+ for (var i = 0; i < count; i++) {
+ offsets[i] = dataView.getUint32(offset);
+ offset += 4;
+ }
+
+ this.relativeOffset += count * 4;
+ return offsets;
+};
+
+// Parse a list of 16 bit unsigned integers. The length of the list can be read on the stream
+// or provided as an argument.
+Parser.prototype.parseOffset16List =
+Parser.prototype.parseUShortList = function(count) {
+ if (count === undefined) { count = this.parseUShort(); }
+ var offsets = new Array(count);
+ var dataView = this.data;
+ var offset = this.offset + this.relativeOffset;
+ for (var i = 0; i < count; i++) {
+ offsets[i] = dataView.getUint16(offset);
+ offset += 2;
+ }
+
+ this.relativeOffset += count * 2;
+ return offsets;
+};
+
+// Parses a list of 16 bit signed integers.
+Parser.prototype.parseShortList = function(count) {
+ var list = new Array(count);
+ var dataView = this.data;
+ var offset = this.offset + this.relativeOffset;
+ for (var i = 0; i < count; i++) {
+ list[i] = dataView.getInt16(offset);
+ offset += 2;
+ }
+
+ this.relativeOffset += count * 2;
+ return list;
+};
+
+// Parses a list of bytes.
+Parser.prototype.parseByteList = function(count) {
+ var list = new Array(count);
+ var dataView = this.data;
+ var offset = this.offset + this.relativeOffset;
+ for (var i = 0; i < count; i++) {
+ list[i] = dataView.getUint8(offset++);
+ }
+
+ this.relativeOffset += count;
+ return list;
+};
+
+/**
+ * Parse a list of items.
+ * Record count is optional, if omitted it is read from the stream.
+ * itemCallback is one of the Parser methods.
+ */
+Parser.prototype.parseList = function(count, itemCallback) {
+ if (!itemCallback) {
+ itemCallback = count;
+ count = this.parseUShort();
+ }
+ var list = new Array(count);
+ for (var i = 0; i < count; i++) {
+ list[i] = itemCallback.call(this);
+ }
+ return list;
+};
+
+Parser.prototype.parseList32 = function(count, itemCallback) {
+ if (!itemCallback) {
+ itemCallback = count;
+ count = this.parseULong();
+ }
+ var list = new Array(count);
+ for (var i = 0; i < count; i++) {
+ list[i] = itemCallback.call(this);
+ }
+ return list;
+};
+
+/**
+ * Parse a list of records.
+ * Record count is optional, if omitted it is read from the stream.
+ * Example of recordDescription: { sequenceIndex: Parser.uShort, lookupListIndex: Parser.uShort }
+ */
+Parser.prototype.parseRecordList = function(count, recordDescription) {
+ // If the count argument is absent, read it in the stream.
+ if (!recordDescription) {
+ recordDescription = count;
+ count = this.parseUShort();
+ }
+ var records = new Array(count);
+ var fields = Object.keys(recordDescription);
+ for (var i = 0; i < count; i++) {
+ var rec = {};
+ for (var j = 0; j < fields.length; j++) {
+ var fieldName = fields[j];
+ var fieldType = recordDescription[fieldName];
+ rec[fieldName] = fieldType.call(this);
+ }
+ records[i] = rec;
+ }
+ return records;
+};
+
+Parser.prototype.parseRecordList32 = function(count, recordDescription) {
+ // If the count argument is absent, read it in the stream.
+ if (!recordDescription) {
+ recordDescription = count;
+ count = this.parseULong();
+ }
+ var records = new Array(count);
+ var fields = Object.keys(recordDescription);
+ for (var i = 0; i < count; i++) {
+ var rec = {};
+ for (var j = 0; j < fields.length; j++) {
+ var fieldName = fields[j];
+ var fieldType = recordDescription[fieldName];
+ rec[fieldName] = fieldType.call(this);
+ }
+ records[i] = rec;
+ }
+ return records;
+};
+
+// Parse a data structure into an object
+// Example of description: { sequenceIndex: Parser.uShort, lookupListIndex: Parser.uShort }
+Parser.prototype.parseStruct = function(description) {
+ if (typeof description === 'function') {
+ return description.call(this);
+ } else {
+ var fields = Object.keys(description);
+ var struct = {};
+ for (var j = 0; j < fields.length; j++) {
+ var fieldName = fields[j];
+ var fieldType = description[fieldName];
+ struct[fieldName] = fieldType.call(this);
+ }
+ return struct;
+ }
+};
+
+/**
+ * Parse a GPOS valueRecord
+ * https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#value-record
+ * valueFormat is optional, if omitted it is read from the stream.
+ */
+Parser.prototype.parseValueRecord = function(valueFormat) {
+ if (valueFormat === undefined) {
+ valueFormat = this.parseUShort();
+ }
+ if (valueFormat === 0) {
+ // valueFormat2 in kerning pairs is most often 0
+ // in this case return undefined instead of an empty object, to save space
+ return;
+ }
+ var valueRecord = {};
+
+ if (valueFormat & 0x0001) { valueRecord.xPlacement = this.parseShort(); }
+ if (valueFormat & 0x0002) { valueRecord.yPlacement = this.parseShort(); }
+ if (valueFormat & 0x0004) { valueRecord.xAdvance = this.parseShort(); }
+ if (valueFormat & 0x0008) { valueRecord.yAdvance = this.parseShort(); }
+
+ // Device table (non-variable font) / VariationIndex table (variable font) not supported
+ // https://docs.microsoft.com/fr-fr/typography/opentype/spec/chapter2#devVarIdxTbls
+ if (valueFormat & 0x0010) { valueRecord.xPlaDevice = undefined; this.parseShort(); }
+ if (valueFormat & 0x0020) { valueRecord.yPlaDevice = undefined; this.parseShort(); }
+ if (valueFormat & 0x0040) { valueRecord.xAdvDevice = undefined; this.parseShort(); }
+ if (valueFormat & 0x0080) { valueRecord.yAdvDevice = undefined; this.parseShort(); }
+
+ return valueRecord;
+};
+
+/**
+ * Parse a list of GPOS valueRecords
+ * https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#value-record
+ * valueFormat and valueCount are read from the stream.
+ */
+Parser.prototype.parseValueRecordList = function() {
+ var valueFormat = this.parseUShort();
+ var valueCount = this.parseUShort();
+ var values = new Array(valueCount);
+ for (var i = 0; i < valueCount; i++) {
+ values[i] = this.parseValueRecord(valueFormat);
+ }
+ return values;
+};
+
+Parser.prototype.parsePointer = function(description) {
+ var structOffset = this.parseOffset16();
+ if (structOffset > 0) {
+ // NULL offset => return undefined
+ return new Parser(this.data, this.offset + structOffset).parseStruct(description);
+ }
+ return undefined;
+};
+
+Parser.prototype.parsePointer32 = function(description) {
+ var structOffset = this.parseOffset32();
+ if (structOffset > 0) {
+ // NULL offset => return undefined
+ return new Parser(this.data, this.offset + structOffset).parseStruct(description);
+ }
+ return undefined;
+};
+
+/**
+ * Parse a list of offsets to lists of 16-bit integers,
+ * or a list of offsets to lists of offsets to any kind of items.
+ * If itemCallback is not provided, a list of list of UShort is assumed.
+ * If provided, itemCallback is called on each item and must parse the item.
+ * See examples in tables/gsub.js
+ */
+Parser.prototype.parseListOfLists = function(itemCallback) {
+ var offsets = this.parseOffset16List();
+ var count = offsets.length;
+ var relativeOffset = this.relativeOffset;
+ var list = new Array(count);
+ for (var i = 0; i < count; i++) {
+ var start = offsets[i];
+ if (start === 0) {
+ // NULL offset
+ // Add i as owned property to list. Convenient with assert.
+ list[i] = undefined;
+ continue;
+ }
+ this.relativeOffset = start;
+ if (itemCallback) {
+ var subOffsets = this.parseOffset16List();
+ var subList = new Array(subOffsets.length);
+ for (var j = 0; j < subOffsets.length; j++) {
+ this.relativeOffset = start + subOffsets[j];
+ subList[j] = itemCallback.call(this);
+ }
+ list[i] = subList;
+ } else {
+ list[i] = this.parseUShortList();
+ }
+ }
+ this.relativeOffset = relativeOffset;
+ return list;
+};
+
+///// Complex tables parsing //////////////////////////////////
+
+// Parse a coverage table in a GSUB, GPOS or GDEF table.
+// https://www.microsoft.com/typography/OTSPEC/chapter2.htm
+// parser.offset must point to the start of the table containing the coverage.
+Parser.prototype.parseCoverage = function() {
+ var startOffset = this.offset + this.relativeOffset;
+ var format = this.parseUShort();
+ var count = this.parseUShort();
+ if (format === 1) {
+ return {
+ format: 1,
+ glyphs: this.parseUShortList(count)
+ };
+ } else if (format === 2) {
+ var ranges = new Array(count);
+ for (var i = 0; i < count; i++) {
+ ranges[i] = {
+ start: this.parseUShort(),
+ end: this.parseUShort(),
+ index: this.parseUShort()
+ };
+ }
+ return {
+ format: 2,
+ ranges: ranges
+ };
+ }
+ throw new Error('0x' + startOffset.toString(16) + ': Coverage format must be 1 or 2.');
+};
+
+// Parse a Class Definition Table in a GSUB, GPOS or GDEF table.
+// https://www.microsoft.com/typography/OTSPEC/chapter2.htm
+Parser.prototype.parseClassDef = function() {
+ var startOffset = this.offset + this.relativeOffset;
+ var format = this.parseUShort();
+ if (format === 1) {
+ return {
+ format: 1,
+ startGlyph: this.parseUShort(),
+ classes: this.parseUShortList()
+ };
+ } else if (format === 2) {
+ return {
+ format: 2,
+ ranges: this.parseRecordList({
+ start: Parser.uShort,
+ end: Parser.uShort,
+ classId: Parser.uShort
+ })
+ };
+ }
+ throw new Error('0x' + startOffset.toString(16) + ': ClassDef format must be 1 or 2.');
+};
+
+///// Static methods ///////////////////////////////////
+// These convenience methods can be used as callbacks and should be called with "this" context set to a Parser instance.
+
+Parser.list = function(count, itemCallback) {
+ return function() {
+ return this.parseList(count, itemCallback);
+ };
+};
+
+Parser.list32 = function(count, itemCallback) {
+ return function() {
+ return this.parseList32(count, itemCallback);
+ };
+};
+
+Parser.recordList = function(count, recordDescription) {
+ return function() {
+ return this.parseRecordList(count, recordDescription);
+ };
+};
+
+Parser.recordList32 = function(count, recordDescription) {
+ return function() {
+ return this.parseRecordList32(count, recordDescription);
+ };
+};
+
+Parser.pointer = function(description) {
+ return function() {
+ return this.parsePointer(description);
+ };
+};
+
+Parser.pointer32 = function(description) {
+ return function() {
+ return this.parsePointer32(description);
+ };
+};
+
+Parser.tag = Parser.prototype.parseTag;
+Parser.byte = Parser.prototype.parseByte;
+Parser.uShort = Parser.offset16 = Parser.prototype.parseUShort;
+Parser.uShortList = Parser.prototype.parseUShortList;
+Parser.uLong = Parser.offset32 = Parser.prototype.parseULong;
+Parser.uLongList = Parser.prototype.parseULongList;
+Parser.struct = Parser.prototype.parseStruct;
+Parser.coverage = Parser.prototype.parseCoverage;
+Parser.classDef = Parser.prototype.parseClassDef;
+
+///// Script, Feature, Lookup lists ///////////////////////////////////////////////
+// https://www.microsoft.com/typography/OTSPEC/chapter2.htm
+
+var langSysTable = {
+ reserved: Parser.uShort,
+ reqFeatureIndex: Parser.uShort,
+ featureIndexes: Parser.uShortList
+};
+
+Parser.prototype.parseScriptList = function() {
+ return this.parsePointer(Parser.recordList({
+ tag: Parser.tag,
+ script: Parser.pointer({
+ defaultLangSys: Parser.pointer(langSysTable),
+ langSysRecords: Parser.recordList({
+ tag: Parser.tag,
+ langSys: Parser.pointer(langSysTable)
+ })
+ })
+ })) || [];
+};
+
+Parser.prototype.parseFeatureList = function() {
+ return this.parsePointer(Parser.recordList({
+ tag: Parser.tag,
+ feature: Parser.pointer({
+ featureParams: Parser.offset16,
+ lookupListIndexes: Parser.uShortList
+ })
+ })) || [];
+};
+
+Parser.prototype.parseLookupList = function(lookupTableParsers) {
+ return this.parsePointer(Parser.list(Parser.pointer(function() {
+ var lookupType = this.parseUShort();
+ check.argument(1 <= lookupType && lookupType <= 9, 'GPOS/GSUB lookup type ' + lookupType + ' unknown.');
+ var lookupFlag = this.parseUShort();
+ var useMarkFilteringSet = lookupFlag & 0x10;
+ return {
+ lookupType: lookupType,
+ lookupFlag: lookupFlag,
+ subtables: this.parseList(Parser.pointer(lookupTableParsers[lookupType])),
+ markFilteringSet: useMarkFilteringSet ? this.parseUShort() : undefined
+ };
+ }))) || [];
+};
+
+Parser.prototype.parseFeatureVariationsList = function() {
+ return this.parsePointer32(function() {
+ var majorVersion = this.parseUShort();
+ var minorVersion = this.parseUShort();
+ check.argument(majorVersion === 1 && minorVersion < 1, 'GPOS/GSUB feature variations table unknown.');
+ var featureVariations = this.parseRecordList32({
+ conditionSetOffset: Parser.offset32,
+ featureTableSubstitutionOffset: Parser.offset32
+ });
+ return featureVariations;
+ }) || [];
+};
+
+var parse = {
+ getByte: getByte,
+ getCard8: getByte,
+ getUShort: getUShort,
+ getCard16: getUShort,
+ getShort: getShort,
+ getULong: getULong,
+ getFixed: getFixed,
+ getTag: getTag,
+ getOffset: getOffset,
+ getBytes: getBytes,
+ bytesToString: bytesToString,
+ Parser: Parser,
+};
+
+// The `cmap` table stores the mappings from characters to glyphs.
+
+function parseCmapTableFormat12(cmap, p) {
+ //Skip reserved.
+ p.parseUShort();
+
+ // Length in bytes of the sub-tables.
+ cmap.length = p.parseULong();
+ cmap.language = p.parseULong();
+
+ var groupCount;
+ cmap.groupCount = groupCount = p.parseULong();
+ cmap.glyphIndexMap = {};
+
+ for (var i = 0; i < groupCount; i += 1) {
+ var startCharCode = p.parseULong();
+ var endCharCode = p.parseULong();
+ var startGlyphId = p.parseULong();
+
+ for (var c = startCharCode; c <= endCharCode; c += 1) {
+ cmap.glyphIndexMap[c] = startGlyphId;
+ startGlyphId++;
+ }
+ }
+}
+
+function parseCmapTableFormat4(cmap, p, data, start, offset) {
+ // Length in bytes of the sub-tables.
+ cmap.length = p.parseUShort();
+ cmap.language = p.parseUShort();
+
+ // segCount is stored x 2.
+ var segCount;
+ cmap.segCount = segCount = p.parseUShort() >> 1;
+
+ // Skip searchRange, entrySelector, rangeShift.
+ p.skip('uShort', 3);
+
+ // The "unrolled" mapping from character codes to glyph indices.
+ cmap.glyphIndexMap = {};
+ var endCountParser = new parse.Parser(data, start + offset + 14);
+ var startCountParser = new parse.Parser(data, start + offset + 16 + segCount * 2);
+ var idDeltaParser = new parse.Parser(data, start + offset + 16 + segCount * 4);
+ var idRangeOffsetParser = new parse.Parser(data, start + offset + 16 + segCount * 6);
+ var glyphIndexOffset = start + offset + 16 + segCount * 8;
+ for (var i = 0; i < segCount - 1; i += 1) {
+ var glyphIndex = (void 0);
+ var endCount = endCountParser.parseUShort();
+ var startCount = startCountParser.parseUShort();
+ var idDelta = idDeltaParser.parseShort();
+ var idRangeOffset = idRangeOffsetParser.parseUShort();
+ for (var c = startCount; c <= endCount; c += 1) {
+ if (idRangeOffset !== 0) {
+ // The idRangeOffset is relative to the current position in the idRangeOffset array.
+ // Take the current offset in the idRangeOffset array.
+ glyphIndexOffset = (idRangeOffsetParser.offset + idRangeOffsetParser.relativeOffset - 2);
+
+ // Add the value of the idRangeOffset, which will move us into the glyphIndex array.
+ glyphIndexOffset += idRangeOffset;
+
+ // Then add the character index of the current segment, multiplied by 2 for USHORTs.
+ glyphIndexOffset += (c - startCount) * 2;
+ glyphIndex = parse.getUShort(data, glyphIndexOffset);
+ if (glyphIndex !== 0) {
+ glyphIndex = (glyphIndex + idDelta) & 0xFFFF;
+ }
+ } else {
+ glyphIndex = (c + idDelta) & 0xFFFF;
+ }
+
+ cmap.glyphIndexMap[c] = glyphIndex;
+ }
+ }
+}
+
+// Parse the `cmap` table. This table stores the mappings from characters to glyphs.
+// There are many available formats, but we only support the Windows format 4 and 12.
+// This function returns a `CmapEncoding` object or null if no supported format could be found.
+function parseCmapTable(data, start) {
+ var cmap = {};
+ cmap.version = parse.getUShort(data, start);
+ check.argument(cmap.version === 0, 'cmap table version should be 0.');
+
+ // The cmap table can contain many sub-tables, each with their own format.
+ // We're only interested in a "platform 0" (Unicode format) and "platform 3" (Windows format) table.
+ cmap.numTables = parse.getUShort(data, start + 2);
+ var offset = -1;
+ for (var i = cmap.numTables - 1; i >= 0; i -= 1) {
+ var platformId = parse.getUShort(data, start + 4 + (i * 8));
+ var encodingId = parse.getUShort(data, start + 4 + (i * 8) + 2);
+ if ((platformId === 3 && (encodingId === 0 || encodingId === 1 || encodingId === 10)) ||
+ (platformId === 0 && (encodingId === 0 || encodingId === 1 || encodingId === 2 || encodingId === 3 || encodingId === 4))) {
+ offset = parse.getULong(data, start + 4 + (i * 8) + 4);
+ break;
+ }
+ }
+
+ if (offset === -1) {
+ // There is no cmap table in the font that we support.
+ throw new Error('No valid cmap sub-tables found.');
+ }
+
+ var p = new parse.Parser(data, start + offset);
+ cmap.format = p.parseUShort();
+
+ if (cmap.format === 12) {
+ parseCmapTableFormat12(cmap, p);
+ } else if (cmap.format === 4) {
+ parseCmapTableFormat4(cmap, p, data, start, offset);
+ } else {
+ throw new Error('Only format 4 and 12 cmap tables are supported (found format ' + cmap.format + ').');
+ }
+
+ return cmap;
+}
+
+function addSegment(t, code, glyphIndex) {
+ t.segments.push({
+ end: code,
+ start: code,
+ delta: -(code - glyphIndex),
+ offset: 0,
+ glyphIndex: glyphIndex
+ });
+}
+
+function addTerminatorSegment(t) {
+ t.segments.push({
+ end: 0xFFFF,
+ start: 0xFFFF,
+ delta: 1,
+ offset: 0
+ });
+}
+
+// Make cmap table, format 4 by default, 12 if needed only
+function makeCmapTable(glyphs) {
+ // Plan 0 is the base Unicode Plan but emojis, for example are on another plan, and needs cmap 12 format (with 32bit)
+ var isPlan0Only = true;
+ var i;
+
+ // Check if we need to add cmap format 12 or if format 4 only is fine
+ for (i = glyphs.length - 1; i > 0; i -= 1) {
+ var g = glyphs.get(i);
+ if (g.unicode > 65535) {
+ console.log('Adding CMAP format 12 (needed!)');
+ isPlan0Only = false;
+ break;
+ }
+ }
+
+ var cmapTable = [
+ {name: 'version', type: 'USHORT', value: 0},
+ {name: 'numTables', type: 'USHORT', value: isPlan0Only ? 1 : 2},
+
+ // CMAP 4 header
+ {name: 'platformID', type: 'USHORT', value: 3},
+ {name: 'encodingID', type: 'USHORT', value: 1},
+ {name: 'offset', type: 'ULONG', value: isPlan0Only ? 12 : (12 + 8)}
+ ];
+
+ if (!isPlan0Only)
+ { cmapTable = cmapTable.concat([
+ // CMAP 12 header
+ {name: 'cmap12PlatformID', type: 'USHORT', value: 3}, // We encode only for PlatformID = 3 (Windows) because it is supported everywhere
+ {name: 'cmap12EncodingID', type: 'USHORT', value: 10},
+ {name: 'cmap12Offset', type: 'ULONG', value: 0}
+ ]); }
+
+ cmapTable = cmapTable.concat([
+ // CMAP 4 Subtable
+ {name: 'format', type: 'USHORT', value: 4},
+ {name: 'cmap4Length', type: 'USHORT', value: 0},
+ {name: 'language', type: 'USHORT', value: 0},
+ {name: 'segCountX2', type: 'USHORT', value: 0},
+ {name: 'searchRange', type: 'USHORT', value: 0},
+ {name: 'entrySelector', type: 'USHORT', value: 0},
+ {name: 'rangeShift', type: 'USHORT', value: 0}
+ ]);
+
+ var t = new table.Table('cmap', cmapTable);
+
+ t.segments = [];
+ for (i = 0; i < glyphs.length; i += 1) {
+ var glyph = glyphs.get(i);
+ for (var j = 0; j < glyph.unicodes.length; j += 1) {
+ addSegment(t, glyph.unicodes[j], i);
+ }
+
+ t.segments = t.segments.sort(function (a, b) {
+ return a.start - b.start;
+ });
+ }
+
+ addTerminatorSegment(t);
+
+ var segCount = t.segments.length;
+ var segCountToRemove = 0;
+
+ // CMAP 4
+ // Set up parallel segment arrays.
+ var endCounts = [];
+ var startCounts = [];
+ var idDeltas = [];
+ var idRangeOffsets = [];
+ var glyphIds = [];
+
+ // CMAP 12
+ var cmap12Groups = [];
+
+ // Reminder this loop is not following the specification at 100%
+ // The specification -> find suites of characters and make a group
+ // Here we're doing one group for each letter
+ // Doing as the spec can save 8 times (or more) space
+ for (i = 0; i < segCount; i += 1) {
+ var segment = t.segments[i];
+
+ // CMAP 4
+ if (segment.end <= 65535 && segment.start <= 65535) {
+ endCounts = endCounts.concat({name: 'end_' + i, type: 'USHORT', value: segment.end});
+ startCounts = startCounts.concat({name: 'start_' + i, type: 'USHORT', value: segment.start});
+ idDeltas = idDeltas.concat({name: 'idDelta_' + i, type: 'SHORT', value: segment.delta});
+ idRangeOffsets = idRangeOffsets.concat({name: 'idRangeOffset_' + i, type: 'USHORT', value: segment.offset});
+ if (segment.glyphId !== undefined) {
+ glyphIds = glyphIds.concat({name: 'glyph_' + i, type: 'USHORT', value: segment.glyphId});
+ }
+ } else {
+ // Skip Unicode > 65535 (16bit unsigned max) for CMAP 4, will be added in CMAP 12
+ segCountToRemove += 1;
+ }
+
+ // CMAP 12
+ // Skip Terminator Segment
+ if (!isPlan0Only && segment.glyphIndex !== undefined) {
+ cmap12Groups = cmap12Groups.concat({name: 'cmap12Start_' + i, type: 'ULONG', value: segment.start});
+ cmap12Groups = cmap12Groups.concat({name: 'cmap12End_' + i, type: 'ULONG', value: segment.end});
+ cmap12Groups = cmap12Groups.concat({name: 'cmap12Glyph_' + i, type: 'ULONG', value: segment.glyphIndex});
+ }
+ }
+
+ // CMAP 4 Subtable
+ t.segCountX2 = (segCount - segCountToRemove) * 2;
+ t.searchRange = Math.pow(2, Math.floor(Math.log((segCount - segCountToRemove)) / Math.log(2))) * 2;
+ t.entrySelector = Math.log(t.searchRange / 2) / Math.log(2);
+ t.rangeShift = t.segCountX2 - t.searchRange;
+
+ t.fields = t.fields.concat(endCounts);
+ t.fields.push({name: 'reservedPad', type: 'USHORT', value: 0});
+ t.fields = t.fields.concat(startCounts);
+ t.fields = t.fields.concat(idDeltas);
+ t.fields = t.fields.concat(idRangeOffsets);
+ t.fields = t.fields.concat(glyphIds);
+
+ t.cmap4Length = 14 + // Subtable header
+ endCounts.length * 2 +
+ 2 + // reservedPad
+ startCounts.length * 2 +
+ idDeltas.length * 2 +
+ idRangeOffsets.length * 2 +
+ glyphIds.length * 2;
+
+ if (!isPlan0Only) {
+ // CMAP 12 Subtable
+ var cmap12Length = 16 + // Subtable header
+ cmap12Groups.length * 4;
+
+ t.cmap12Offset = 12 + (2 * 2) + 4 + t.cmap4Length;
+ t.fields = t.fields.concat([
+ {name: 'cmap12Format', type: 'USHORT', value: 12},
+ {name: 'cmap12Reserved', type: 'USHORT', value: 0},
+ {name: 'cmap12Length', type: 'ULONG', value: cmap12Length},
+ {name: 'cmap12Language', type: 'ULONG', value: 0},
+ {name: 'cmap12nGroups', type: 'ULONG', value: cmap12Groups.length / 3}
+ ]);
+
+ t.fields = t.fields.concat(cmap12Groups);
+ }
+
+ return t;
+}
+
+var cmap = { parse: parseCmapTable, make: makeCmapTable };
+
+// Glyph encoding
+
+var cffStandardStrings = [
+ '.notdef', 'space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent', 'ampersand', 'quoteright',
+ 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'hyphen', 'period', 'slash', 'zero', 'one', 'two',
+ 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'colon', 'semicolon', 'less', 'equal', 'greater',
+ 'question', 'at', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
+ 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'bracketleft', 'backslash', 'bracketright', 'asciicircum', 'underscore',
+ 'quoteleft', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
+ 'u', 'v', 'w', 'x', 'y', 'z', 'braceleft', 'bar', 'braceright', 'asciitilde', 'exclamdown', 'cent', 'sterling',
+ 'fraction', 'yen', 'florin', 'section', 'currency', 'quotesingle', 'quotedblleft', 'guillemotleft',
+ 'guilsinglleft', 'guilsinglright', 'fi', 'fl', 'endash', 'dagger', 'daggerdbl', 'periodcentered', 'paragraph',
+ 'bullet', 'quotesinglbase', 'quotedblbase', 'quotedblright', 'guillemotright', 'ellipsis', 'perthousand',
+ 'questiondown', 'grave', 'acute', 'circumflex', 'tilde', 'macron', 'breve', 'dotaccent', 'dieresis', 'ring',
+ 'cedilla', 'hungarumlaut', 'ogonek', 'caron', 'emdash', 'AE', 'ordfeminine', 'Lslash', 'Oslash', 'OE',
+ 'ordmasculine', 'ae', 'dotlessi', 'lslash', 'oslash', 'oe', 'germandbls', 'onesuperior', 'logicalnot', 'mu',
+ 'trademark', 'Eth', 'onehalf', 'plusminus', 'Thorn', 'onequarter', 'divide', 'brokenbar', 'degree', 'thorn',
+ 'threequarters', 'twosuperior', 'registered', 'minus', 'eth', 'multiply', 'threesuperior', 'copyright',
+ 'Aacute', 'Acircumflex', 'Adieresis', 'Agrave', 'Aring', 'Atilde', 'Ccedilla', 'Eacute', 'Ecircumflex',
+ 'Edieresis', 'Egrave', 'Iacute', 'Icircumflex', 'Idieresis', 'Igrave', 'Ntilde', 'Oacute', 'Ocircumflex',
+ 'Odieresis', 'Ograve', 'Otilde', 'Scaron', 'Uacute', 'Ucircumflex', 'Udieresis', 'Ugrave', 'Yacute',
+ 'Ydieresis', 'Zcaron', 'aacute', 'acircumflex', 'adieresis', 'agrave', 'aring', 'atilde', 'ccedilla', 'eacute',
+ 'ecircumflex', 'edieresis', 'egrave', 'iacute', 'icircumflex', 'idieresis', 'igrave', 'ntilde', 'oacute',
+ 'ocircumflex', 'odieresis', 'ograve', 'otilde', 'scaron', 'uacute', 'ucircumflex', 'udieresis', 'ugrave',
+ 'yacute', 'ydieresis', 'zcaron', 'exclamsmall', 'Hungarumlautsmall', 'dollaroldstyle', 'dollarsuperior',
+ 'ampersandsmall', 'Acutesmall', 'parenleftsuperior', 'parenrightsuperior', '266 ff', 'onedotenleader',
+ 'zerooldstyle', 'oneoldstyle', 'twooldstyle', 'threeoldstyle', 'fouroldstyle', 'fiveoldstyle', 'sixoldstyle',
+ 'sevenoldstyle', 'eightoldstyle', 'nineoldstyle', 'commasuperior', 'threequartersemdash', 'periodsuperior',
+ 'questionsmall', 'asuperior', 'bsuperior', 'centsuperior', 'dsuperior', 'esuperior', 'isuperior', 'lsuperior',
+ 'msuperior', 'nsuperior', 'osuperior', 'rsuperior', 'ssuperior', 'tsuperior', 'ff', 'ffi', 'ffl',
+ 'parenleftinferior', 'parenrightinferior', 'Circumflexsmall', 'hyphensuperior', 'Gravesmall', 'Asmall',
+ 'Bsmall', 'Csmall', 'Dsmall', 'Esmall', 'Fsmall', 'Gsmall', 'Hsmall', 'Ismall', 'Jsmall', 'Ksmall', 'Lsmall',
+ 'Msmall', 'Nsmall', 'Osmall', 'Psmall', 'Qsmall', 'Rsmall', 'Ssmall', 'Tsmall', 'Usmall', 'Vsmall', 'Wsmall',
+ 'Xsmall', 'Ysmall', 'Zsmall', 'colonmonetary', 'onefitted', 'rupiah', 'Tildesmall', 'exclamdownsmall',
+ 'centoldstyle', 'Lslashsmall', 'Scaronsmall', 'Zcaronsmall', 'Dieresissmall', 'Brevesmall', 'Caronsmall',
+ 'Dotaccentsmall', 'Macronsmall', 'figuredash', 'hypheninferior', 'Ogoneksmall', 'Ringsmall', 'Cedillasmall',
+ 'questiondownsmall', 'oneeighth', 'threeeighths', 'fiveeighths', 'seveneighths', 'onethird', 'twothirds',
+ 'zerosuperior', 'foursuperior', 'fivesuperior', 'sixsuperior', 'sevensuperior', 'eightsuperior', 'ninesuperior',
+ 'zeroinferior', 'oneinferior', 'twoinferior', 'threeinferior', 'fourinferior', 'fiveinferior', 'sixinferior',
+ 'seveninferior', 'eightinferior', 'nineinferior', 'centinferior', 'dollarinferior', 'periodinferior',
+ 'commainferior', 'Agravesmall', 'Aacutesmall', 'Acircumflexsmall', 'Atildesmall', 'Adieresissmall',
+ 'Aringsmall', 'AEsmall', 'Ccedillasmall', 'Egravesmall', 'Eacutesmall', 'Ecircumflexsmall', 'Edieresissmall',
+ 'Igravesmall', 'Iacutesmall', 'Icircumflexsmall', 'Idieresissmall', 'Ethsmall', 'Ntildesmall', 'Ogravesmall',
+ 'Oacutesmall', 'Ocircumflexsmall', 'Otildesmall', 'Odieresissmall', 'OEsmall', 'Oslashsmall', 'Ugravesmall',
+ 'Uacutesmall', 'Ucircumflexsmall', 'Udieresissmall', 'Yacutesmall', 'Thornsmall', 'Ydieresissmall', '001.000',
+ '001.001', '001.002', '001.003', 'Black', 'Bold', 'Book', 'Light', 'Medium', 'Regular', 'Roman', 'Semibold'];
+
+var cffStandardEncoding = [
+ '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
+ '', '', '', '', 'space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent', 'ampersand', 'quoteright',
+ 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'hyphen', 'period', 'slash', 'zero', 'one', 'two',
+ 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'colon', 'semicolon', 'less', 'equal', 'greater',
+ 'question', 'at', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
+ 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'bracketleft', 'backslash', 'bracketright', 'asciicircum', 'underscore',
+ 'quoteleft', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
+ 'u', 'v', 'w', 'x', 'y', 'z', 'braceleft', 'bar', 'braceright', 'asciitilde', '', '', '', '', '', '', '', '',
+ '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
+ 'exclamdown', 'cent', 'sterling', 'fraction', 'yen', 'florin', 'section', 'currency', 'quotesingle',
+ 'quotedblleft', 'guillemotleft', 'guilsinglleft', 'guilsinglright', 'fi', 'fl', '', 'endash', 'dagger',
+ 'daggerdbl', 'periodcentered', '', 'paragraph', 'bullet', 'quotesinglbase', 'quotedblbase', 'quotedblright',
+ 'guillemotright', 'ellipsis', 'perthousand', '', 'questiondown', '', 'grave', 'acute', 'circumflex', 'tilde',
+ 'macron', 'breve', 'dotaccent', 'dieresis', '', 'ring', 'cedilla', '', 'hungarumlaut', 'ogonek', 'caron',
+ 'emdash', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'AE', '', 'ordfeminine', '', '', '',
+ '', 'Lslash', 'Oslash', 'OE', 'ordmasculine', '', '', '', '', '', 'ae', '', '', '', 'dotlessi', '', '',
+ 'lslash', 'oslash', 'oe', 'germandbls'];
+
+var cffExpertEncoding = [
+ '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
+ '', '', '', '', 'space', 'exclamsmall', 'Hungarumlautsmall', '', 'dollaroldstyle', 'dollarsuperior',
+ 'ampersandsmall', 'Acutesmall', 'parenleftsuperior', 'parenrightsuperior', 'twodotenleader', 'onedotenleader',
+ 'comma', 'hyphen', 'period', 'fraction', 'zerooldstyle', 'oneoldstyle', 'twooldstyle', 'threeoldstyle',
+ 'fouroldstyle', 'fiveoldstyle', 'sixoldstyle', 'sevenoldstyle', 'eightoldstyle', 'nineoldstyle', 'colon',
+ 'semicolon', 'commasuperior', 'threequartersemdash', 'periodsuperior', 'questionsmall', '', 'asuperior',
+ 'bsuperior', 'centsuperior', 'dsuperior', 'esuperior', '', '', 'isuperior', '', '', 'lsuperior', 'msuperior',
+ 'nsuperior', 'osuperior', '', '', 'rsuperior', 'ssuperior', 'tsuperior', '', 'ff', 'fi', 'fl', 'ffi', 'ffl',
+ 'parenleftinferior', '', 'parenrightinferior', 'Circumflexsmall', 'hyphensuperior', 'Gravesmall', 'Asmall',
+ 'Bsmall', 'Csmall', 'Dsmall', 'Esmall', 'Fsmall', 'Gsmall', 'Hsmall', 'Ismall', 'Jsmall', 'Ksmall', 'Lsmall',
+ 'Msmall', 'Nsmall', 'Osmall', 'Psmall', 'Qsmall', 'Rsmall', 'Ssmall', 'Tsmall', 'Usmall', 'Vsmall', 'Wsmall',
+ 'Xsmall', 'Ysmall', 'Zsmall', 'colonmonetary', 'onefitted', 'rupiah', 'Tildesmall', '', '', '', '', '', '', '',
+ '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
+ 'exclamdownsmall', 'centoldstyle', 'Lslashsmall', '', '', 'Scaronsmall', 'Zcaronsmall', 'Dieresissmall',
+ 'Brevesmall', 'Caronsmall', '', 'Dotaccentsmall', '', '', 'Macronsmall', '', '', 'figuredash', 'hypheninferior',
+ '', '', 'Ogoneksmall', 'Ringsmall', 'Cedillasmall', '', '', '', 'onequarter', 'onehalf', 'threequarters',
+ 'questiondownsmall', 'oneeighth', 'threeeighths', 'fiveeighths', 'seveneighths', 'onethird', 'twothirds', '',
+ '', 'zerosuperior', 'onesuperior', 'twosuperior', 'threesuperior', 'foursuperior', 'fivesuperior',
+ 'sixsuperior', 'sevensuperior', 'eightsuperior', 'ninesuperior', 'zeroinferior', 'oneinferior', 'twoinferior',
+ 'threeinferior', 'fourinferior', 'fiveinferior', 'sixinferior', 'seveninferior', 'eightinferior',
+ 'nineinferior', 'centinferior', 'dollarinferior', 'periodinferior', 'commainferior', 'Agravesmall',
+ 'Aacutesmall', 'Acircumflexsmall', 'Atildesmall', 'Adieresissmall', 'Aringsmall', 'AEsmall', 'Ccedillasmall',
+ 'Egravesmall', 'Eacutesmall', 'Ecircumflexsmall', 'Edieresissmall', 'Igravesmall', 'Iacutesmall',
+ 'Icircumflexsmall', 'Idieresissmall', 'Ethsmall', 'Ntildesmall', 'Ogravesmall', 'Oacutesmall',
+ 'Ocircumflexsmall', 'Otildesmall', 'Odieresissmall', 'OEsmall', 'Oslashsmall', 'Ugravesmall', 'Uacutesmall',
+ 'Ucircumflexsmall', 'Udieresissmall', 'Yacutesmall', 'Thornsmall', 'Ydieresissmall'];
+
+var standardNames = [
+ '.notdef', '.null', 'nonmarkingreturn', 'space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent',
+ 'ampersand', 'quotesingle', 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'hyphen', 'period', 'slash',
+ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'colon', 'semicolon', 'less',
+ 'equal', 'greater', 'question', 'at', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
+ 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'bracketleft', 'backslash', 'bracketright',
+ 'asciicircum', 'underscore', 'grave', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
+ 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'braceleft', 'bar', 'braceright', 'asciitilde',
+ 'Adieresis', 'Aring', 'Ccedilla', 'Eacute', 'Ntilde', 'Odieresis', 'Udieresis', 'aacute', 'agrave',
+ 'acircumflex', 'adieresis', 'atilde', 'aring', 'ccedilla', 'eacute', 'egrave', 'ecircumflex', 'edieresis',
+ 'iacute', 'igrave', 'icircumflex', 'idieresis', 'ntilde', 'oacute', 'ograve', 'ocircumflex', 'odieresis',
+ 'otilde', 'uacute', 'ugrave', 'ucircumflex', 'udieresis', 'dagger', 'degree', 'cent', 'sterling', 'section',
+ 'bullet', 'paragraph', 'germandbls', 'registered', 'copyright', 'trademark', 'acute', 'dieresis', 'notequal',
+ 'AE', 'Oslash', 'infinity', 'plusminus', 'lessequal', 'greaterequal', 'yen', 'mu', 'partialdiff', 'summation',
+ 'product', 'pi', 'integral', 'ordfeminine', 'ordmasculine', 'Omega', 'ae', 'oslash', 'questiondown',
+ 'exclamdown', 'logicalnot', 'radical', 'florin', 'approxequal', 'Delta', 'guillemotleft', 'guillemotright',
+ 'ellipsis', 'nonbreakingspace', 'Agrave', 'Atilde', 'Otilde', 'OE', 'oe', 'endash', 'emdash', 'quotedblleft',
+ 'quotedblright', 'quoteleft', 'quoteright', 'divide', 'lozenge', 'ydieresis', 'Ydieresis', 'fraction',
+ 'currency', 'guilsinglleft', 'guilsinglright', 'fi', 'fl', 'daggerdbl', 'periodcentered', 'quotesinglbase',
+ 'quotedblbase', 'perthousand', 'Acircumflex', 'Ecircumflex', 'Aacute', 'Edieresis', 'Egrave', 'Iacute',
+ 'Icircumflex', 'Idieresis', 'Igrave', 'Oacute', 'Ocircumflex', 'apple', 'Ograve', 'Uacute', 'Ucircumflex',
+ 'Ugrave', 'dotlessi', 'circumflex', 'tilde', 'macron', 'breve', 'dotaccent', 'ring', 'cedilla', 'hungarumlaut',
+ 'ogonek', 'caron', 'Lslash', 'lslash', 'Scaron', 'scaron', 'Zcaron', 'zcaron', 'brokenbar', 'Eth', 'eth',
+ 'Yacute', 'yacute', 'Thorn', 'thorn', 'minus', 'multiply', 'onesuperior', 'twosuperior', 'threesuperior',
+ 'onehalf', 'onequarter', 'threequarters', 'franc', 'Gbreve', 'gbreve', 'Idotaccent', 'Scedilla', 'scedilla',
+ 'Cacute', 'cacute', 'Ccaron', 'ccaron', 'dcroat'];
+
+/**
+ * This is the encoding used for fonts created from scratch.
+ * It loops through all glyphs and finds the appropriate unicode value.
+ * Since it's linear time, other encodings will be faster.
+ * @exports opentype.DefaultEncoding
+ * @class
+ * @constructor
+ * @param {opentype.Font}
+ */
+function DefaultEncoding(font) {
+ this.font = font;
+}
+
+DefaultEncoding.prototype.charToGlyphIndex = function(c) {
+ var code = c.codePointAt(0);
+ var glyphs = this.font.glyphs;
+ if (glyphs) {
+ for (var i = 0; i < glyphs.length; i += 1) {
+ var glyph = glyphs.get(i);
+ for (var j = 0; j < glyph.unicodes.length; j += 1) {
+ if (glyph.unicodes[j] === code) {
+ return i;
+ }
+ }
+ }
+ }
+ return null;
+};
+
+/**
+ * @exports opentype.CmapEncoding
+ * @class
+ * @constructor
+ * @param {Object} cmap - a object with the cmap encoded data
+ */
+function CmapEncoding(cmap) {
+ this.cmap = cmap;
+}
+
+/**
+ * @param {string} c - the character
+ * @return {number} The glyph index.
+ */
+CmapEncoding.prototype.charToGlyphIndex = function(c) {
+ return this.cmap.glyphIndexMap[c.codePointAt(0)] || 0;
+};
+
+/**
+ * @exports opentype.CffEncoding
+ * @class
+ * @constructor
+ * @param {string} encoding - The encoding
+ * @param {Array} charset - The character set.
+ */
+function CffEncoding(encoding, charset) {
+ this.encoding = encoding;
+ this.charset = charset;
+}
+
+/**
+ * @param {string} s - The character
+ * @return {number} The index.
+ */
+CffEncoding.prototype.charToGlyphIndex = function(s) {
+ var code = s.codePointAt(0);
+ var charName = this.encoding[code];
+ return this.charset.indexOf(charName);
+};
+
+/**
+ * @exports opentype.GlyphNames
+ * @class
+ * @constructor
+ * @param {Object} post
+ */
+function GlyphNames(post) {
+ switch (post.version) {
+ case 1:
+ this.names = standardNames.slice();
+ break;
+ case 2:
+ this.names = new Array(post.numberOfGlyphs);
+ for (var i = 0; i < post.numberOfGlyphs; i++) {
+ if (post.glyphNameIndex[i] < standardNames.length) {
+ this.names[i] = standardNames[post.glyphNameIndex[i]];
+ } else {
+ this.names[i] = post.names[post.glyphNameIndex[i] - standardNames.length];
+ }
+ }
+
+ break;
+ case 2.5:
+ this.names = new Array(post.numberOfGlyphs);
+ for (var i$1 = 0; i$1 < post.numberOfGlyphs; i$1++) {
+ this.names[i$1] = standardNames[i$1 + post.glyphNameIndex[i$1]];
+ }
+
+ break;
+ case 3:
+ this.names = [];
+ break;
+ default:
+ this.names = [];
+ break;
+ }
+}
+
+/**
+ * Gets the index of a glyph by name.
+ * @param {string} name - The glyph name
+ * @return {number} The index
+ */
+GlyphNames.prototype.nameToGlyphIndex = function(name) {
+ return this.names.indexOf(name);
+};
+
+/**
+ * @param {number} gid
+ * @return {string}
+ */
+GlyphNames.prototype.glyphIndexToName = function(gid) {
+ return this.names[gid];
+};
+
+function addGlyphNamesAll(font) {
+ var glyph;
+ var glyphIndexMap = font.tables.cmap.glyphIndexMap;
+ var charCodes = Object.keys(glyphIndexMap);
+
+ for (var i = 0; i < charCodes.length; i += 1) {
+ var c = charCodes[i];
+ var glyphIndex = glyphIndexMap[c];
+ glyph = font.glyphs.get(glyphIndex);
+ glyph.addUnicode(parseInt(c));
+ }
+
+ for (var i$1 = 0; i$1 < font.glyphs.length; i$1 += 1) {
+ glyph = font.glyphs.get(i$1);
+ if (font.cffEncoding) {
+ if (font.isCIDFont) {
+ glyph.name = 'gid' + i$1;
+ } else {
+ glyph.name = font.cffEncoding.charset[i$1];
+ }
+ } else if (font.glyphNames.names) {
+ glyph.name = font.glyphNames.glyphIndexToName(i$1);
+ }
+ }
+}
+
+function addGlyphNamesToUnicodeMap(font) {
+ font._IndexToUnicodeMap = {};
+
+ var glyphIndexMap = font.tables.cmap.glyphIndexMap;
+ var charCodes = Object.keys(glyphIndexMap);
+
+ for (var i = 0; i < charCodes.length; i += 1) {
+ var c = charCodes[i];
+ var glyphIndex = glyphIndexMap[c];
+ if (font._IndexToUnicodeMap[glyphIndex] === undefined) {
+ font._IndexToUnicodeMap[glyphIndex] = {
+ unicodes: [parseInt(c)]
+ };
+ } else {
+ font._IndexToUnicodeMap[glyphIndex].unicodes.push(parseInt(c));
+ }
+ }
+}
+
+/**
+ * @alias opentype.addGlyphNames
+ * @param {opentype.Font}
+ * @param {Object}
+ */
+function addGlyphNames(font, opt) {
+ if (opt.lowMemory) {
+ addGlyphNamesToUnicodeMap(font);
+ } else {
+ addGlyphNamesAll(font);
+ }
+}
+
+// Drawing utility functions.
+
+// Draw a line on the given context from point `x1,y1` to point `x2,y2`.
+function line(ctx, x1, y1, x2, y2) {
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+}
+
+var draw = { line: line };
+
+// The Glyph object
+// import glyf from './tables/glyf' Can't be imported here, because it's a circular dependency
+
+function getPathDefinition(glyph, path) {
+ var _path = path || new Path();
+ return {
+ configurable: true,
+
+ get: function() {
+ if (typeof _path === 'function') {
+ _path = _path();
+ }
+
+ return _path;
+ },
+
+ set: function(p) {
+ _path = p;
+ }
+ };
+}
+/**
+ * @typedef GlyphOptions
+ * @type Object
+ * @property {string} [name] - The glyph name
+ * @property {number} [unicode]
+ * @property {Array} [unicodes]
+ * @property {number} [xMin]
+ * @property {number} [yMin]
+ * @property {number} [xMax]
+ * @property {number} [yMax]
+ * @property {number} [advanceWidth]
+ */
+
+// A Glyph is an individual mark that often corresponds to a character.
+// Some glyphs, such as ligatures, are a combination of many characters.
+// Glyphs are the basic building blocks of a font.
+//
+// The `Glyph` class contains utility methods for drawing the path and its points.
+/**
+ * @exports opentype.Glyph
+ * @class
+ * @param {GlyphOptions}
+ * @constructor
+ */
+function Glyph(options) {
+ // By putting all the code on a prototype function (which is only declared once)
+ // we reduce the memory requirements for larger fonts by some 2%
+ this.bindConstructorValues(options);
+}
+
+/**
+ * @param {GlyphOptions}
+ */
+Glyph.prototype.bindConstructorValues = function(options) {
+ this.index = options.index || 0;
+
+ // These three values cannot be deferred for memory optimization:
+ this.name = options.name || null;
+ this.unicode = options.unicode || undefined;
+ this.unicodes = options.unicodes || options.unicode !== undefined ? [options.unicode] : [];
+
+ // But by binding these values only when necessary, we reduce can
+ // the memory requirements by almost 3% for larger fonts.
+ if ('xMin' in options) {
+ this.xMin = options.xMin;
+ }
+
+ if ('yMin' in options) {
+ this.yMin = options.yMin;
+ }
+
+ if ('xMax' in options) {
+ this.xMax = options.xMax;
+ }
+
+ if ('yMax' in options) {
+ this.yMax = options.yMax;
+ }
+
+ if ('advanceWidth' in options) {
+ this.advanceWidth = options.advanceWidth;
+ }
+
+ // The path for a glyph is the most memory intensive, and is bound as a value
+ // with a getter/setter to ensure we actually do path parsing only once the
+ // path is actually needed by anything.
+ Object.defineProperty(this, 'path', getPathDefinition(this, options.path));
+};
+
+/**
+ * @param {number}
+ */
+Glyph.prototype.addUnicode = function(unicode) {
+ if (this.unicodes.length === 0) {
+ this.unicode = unicode;
+ }
+
+ this.unicodes.push(unicode);
+};
+
+/**
+ * Calculate the minimum bounding box for this glyph.
+ * @return {opentype.BoundingBox}
+ */
+Glyph.prototype.getBoundingBox = function() {
+ return this.path.getBoundingBox();
+};
+
+/**
+ * Convert the glyph to a Path we can draw on a drawing context.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {Object=} options - xScale, yScale to stretch the glyph.
+ * @param {opentype.Font} if hinting is to be used, the font
+ * @return {opentype.Path}
+ */
+Glyph.prototype.getPath = function(x, y, fontSize, options, font) {
+ x = x !== undefined ? x : 0;
+ y = y !== undefined ? y : 0;
+ fontSize = fontSize !== undefined ? fontSize : 72;
+ var commands;
+ var hPoints;
+ if (!options) { options = { }; }
+ var xScale = options.xScale;
+ var yScale = options.yScale;
+
+ if (options.hinting && font && font.hinting) {
+ // in case of hinting, the hinting engine takes care
+ // of scaling the points (not the path) before hinting.
+ hPoints = this.path && font.hinting.exec(this, fontSize);
+ // in case the hinting engine failed hPoints is undefined
+ // and thus reverts to plain rending
+ }
+
+ if (hPoints) {
+ // Call font.hinting.getCommands instead of `glyf.getPath(hPoints).commands` to avoid a circular dependency
+ commands = font.hinting.getCommands(hPoints);
+ x = Math.round(x);
+ y = Math.round(y);
+ // TODO in case of hinting xyScaling is not yet supported
+ xScale = yScale = 1;
+ } else {
+ commands = this.path.commands;
+ var scale = 1 / (this.path.unitsPerEm || 1000) * fontSize;
+ if (xScale === undefined) { xScale = scale; }
+ if (yScale === undefined) { yScale = scale; }
+ }
+
+ var p = new Path();
+ for (var i = 0; i < commands.length; i += 1) {
+ var cmd = commands[i];
+ if (cmd.type === 'M') {
+ p.moveTo(x + (cmd.x * xScale), y + (-cmd.y * yScale));
+ } else if (cmd.type === 'L') {
+ p.lineTo(x + (cmd.x * xScale), y + (-cmd.y * yScale));
+ } else if (cmd.type === 'Q') {
+ p.quadraticCurveTo(x + (cmd.x1 * xScale), y + (-cmd.y1 * yScale),
+ x + (cmd.x * xScale), y + (-cmd.y * yScale));
+ } else if (cmd.type === 'C') {
+ p.curveTo(x + (cmd.x1 * xScale), y + (-cmd.y1 * yScale),
+ x + (cmd.x2 * xScale), y + (-cmd.y2 * yScale),
+ x + (cmd.x * xScale), y + (-cmd.y * yScale));
+ } else if (cmd.type === 'Z') {
+ p.closePath();
+ }
+ }
+
+ return p;
+};
+
+/**
+ * Split the glyph into contours.
+ * This function is here for backwards compatibility, and to
+ * provide raw access to the TrueType glyph outlines.
+ * @return {Array}
+ */
+Glyph.prototype.getContours = function() {
+ if (this.points === undefined) {
+ return [];
+ }
+
+ var contours = [];
+ var currentContour = [];
+ for (var i = 0; i < this.points.length; i += 1) {
+ var pt = this.points[i];
+ currentContour.push(pt);
+ if (pt.lastPointOfContour) {
+ contours.push(currentContour);
+ currentContour = [];
+ }
+ }
+
+ check.argument(currentContour.length === 0, 'There are still points left in the current contour.');
+ return contours;
+};
+
+/**
+ * Calculate the xMin/yMin/xMax/yMax/lsb/rsb for a Glyph.
+ * @return {Object}
+ */
+Glyph.prototype.getMetrics = function() {
+ var commands = this.path.commands;
+ var xCoords = [];
+ var yCoords = [];
+ for (var i = 0; i < commands.length; i += 1) {
+ var cmd = commands[i];
+ if (cmd.type !== 'Z') {
+ xCoords.push(cmd.x);
+ yCoords.push(cmd.y);
+ }
+
+ if (cmd.type === 'Q' || cmd.type === 'C') {
+ xCoords.push(cmd.x1);
+ yCoords.push(cmd.y1);
+ }
+
+ if (cmd.type === 'C') {
+ xCoords.push(cmd.x2);
+ yCoords.push(cmd.y2);
+ }
+ }
+
+ var metrics = {
+ xMin: Math.min.apply(null, xCoords),
+ yMin: Math.min.apply(null, yCoords),
+ xMax: Math.max.apply(null, xCoords),
+ yMax: Math.max.apply(null, yCoords),
+ leftSideBearing: this.leftSideBearing
+ };
+
+ if (!isFinite(metrics.xMin)) {
+ metrics.xMin = 0;
+ }
+
+ if (!isFinite(metrics.xMax)) {
+ metrics.xMax = this.advanceWidth;
+ }
+
+ if (!isFinite(metrics.yMin)) {
+ metrics.yMin = 0;
+ }
+
+ if (!isFinite(metrics.yMax)) {
+ metrics.yMax = 0;
+ }
+
+ metrics.rightSideBearing = this.advanceWidth - metrics.leftSideBearing - (metrics.xMax - metrics.xMin);
+ return metrics;
+};
+
+/**
+ * Draw the glyph on the given context.
+ * @param {CanvasRenderingContext2D} ctx - A 2D drawing context, like Canvas.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {Object=} options - xScale, yScale to stretch the glyph.
+ */
+Glyph.prototype.draw = function(ctx, x, y, fontSize, options) {
+ this.getPath(x, y, fontSize, options).draw(ctx);
+};
+
+/**
+ * Draw the points of the glyph.
+ * On-curve points will be drawn in blue, off-curve points will be drawn in red.
+ * @param {CanvasRenderingContext2D} ctx - A 2D drawing context, like Canvas.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ */
+Glyph.prototype.drawPoints = function(ctx, x, y, fontSize) {
+ function drawCircles(l, x, y, scale) {
+ ctx.beginPath();
+ for (var j = 0; j < l.length; j += 1) {
+ ctx.moveTo(x + (l[j].x * scale), y + (l[j].y * scale));
+ ctx.arc(x + (l[j].x * scale), y + (l[j].y * scale), 2, 0, Math.PI * 2, false);
+ }
+
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ x = x !== undefined ? x : 0;
+ y = y !== undefined ? y : 0;
+ fontSize = fontSize !== undefined ? fontSize : 24;
+ var scale = 1 / this.path.unitsPerEm * fontSize;
+
+ var blueCircles = [];
+ var redCircles = [];
+ var path = this.path;
+ for (var i = 0; i < path.commands.length; i += 1) {
+ var cmd = path.commands[i];
+ if (cmd.x !== undefined) {
+ blueCircles.push({x: cmd.x, y: -cmd.y});
+ }
+
+ if (cmd.x1 !== undefined) {
+ redCircles.push({x: cmd.x1, y: -cmd.y1});
+ }
+
+ if (cmd.x2 !== undefined) {
+ redCircles.push({x: cmd.x2, y: -cmd.y2});
+ }
+ }
+
+ ctx.fillStyle = 'blue';
+ drawCircles(blueCircles, x, y, scale);
+ ctx.fillStyle = 'red';
+ drawCircles(redCircles, x, y, scale);
+};
+
+/**
+ * Draw lines indicating important font measurements.
+ * Black lines indicate the origin of the coordinate system (point 0,0).
+ * Blue lines indicate the glyph bounding box.
+ * Green line indicates the advance width of the glyph.
+ * @param {CanvasRenderingContext2D} ctx - A 2D drawing context, like Canvas.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ */
+Glyph.prototype.drawMetrics = function(ctx, x, y, fontSize) {
+ var scale;
+ x = x !== undefined ? x : 0;
+ y = y !== undefined ? y : 0;
+ fontSize = fontSize !== undefined ? fontSize : 24;
+ scale = 1 / this.path.unitsPerEm * fontSize;
+ ctx.lineWidth = 1;
+
+ // Draw the origin
+ ctx.strokeStyle = 'black';
+ draw.line(ctx, x, -10000, x, 10000);
+ draw.line(ctx, -10000, y, 10000, y);
+
+ // This code is here due to memory optimization: by not using
+ // defaults in the constructor, we save a notable amount of memory.
+ var xMin = this.xMin || 0;
+ var yMin = this.yMin || 0;
+ var xMax = this.xMax || 0;
+ var yMax = this.yMax || 0;
+ var advanceWidth = this.advanceWidth || 0;
+
+ // Draw the glyph box
+ ctx.strokeStyle = 'blue';
+ draw.line(ctx, x + (xMin * scale), -10000, x + (xMin * scale), 10000);
+ draw.line(ctx, x + (xMax * scale), -10000, x + (xMax * scale), 10000);
+ draw.line(ctx, -10000, y + (-yMin * scale), 10000, y + (-yMin * scale));
+ draw.line(ctx, -10000, y + (-yMax * scale), 10000, y + (-yMax * scale));
+
+ // Draw the advance width
+ ctx.strokeStyle = 'green';
+ draw.line(ctx, x + (advanceWidth * scale), -10000, x + (advanceWidth * scale), 10000);
+};
+
+// The GlyphSet object
+
+// Define a property on the glyph that depends on the path being loaded.
+function defineDependentProperty(glyph, externalName, internalName) {
+ Object.defineProperty(glyph, externalName, {
+ get: function() {
+ // Request the path property to make sure the path is loaded.
+ glyph.path; // jshint ignore:line
+ return glyph[internalName];
+ },
+ set: function(newValue) {
+ glyph[internalName] = newValue;
+ },
+ enumerable: true,
+ configurable: true
+ });
+}
+
+/**
+ * A GlyphSet represents all glyphs available in the font, but modelled using
+ * a deferred glyph loader, for retrieving glyphs only once they are absolutely
+ * necessary, to keep the memory footprint down.
+ * @exports opentype.GlyphSet
+ * @class
+ * @param {opentype.Font}
+ * @param {Array}
+ */
+function GlyphSet(font, glyphs) {
+ this.font = font;
+ this.glyphs = {};
+ if (Array.isArray(glyphs)) {
+ for (var i = 0; i < glyphs.length; i++) {
+ var glyph = glyphs[i];
+ glyph.path.unitsPerEm = font.unitsPerEm;
+ this.glyphs[i] = glyph;
+ }
+ }
+
+ this.length = (glyphs && glyphs.length) || 0;
+}
+
+/**
+ * @param {number} index
+ * @return {opentype.Glyph}
+ */
+GlyphSet.prototype.get = function(index) {
+ // this.glyphs[index] is 'undefined' when low memory mode is on. glyph is pushed on request only.
+ if (this.glyphs[index] === undefined) {
+ this.font._push(index);
+ if (typeof this.glyphs[index] === 'function') {
+ this.glyphs[index] = this.glyphs[index]();
+ }
+
+ var glyph = this.glyphs[index];
+ var unicodeObj = this.font._IndexToUnicodeMap[index];
+
+ if (unicodeObj) {
+ for (var j = 0; j < unicodeObj.unicodes.length; j++)
+ { glyph.addUnicode(unicodeObj.unicodes[j]); }
+ }
+
+ if (this.font.cffEncoding) {
+ if (this.font.isCIDFont) {
+ glyph.name = 'gid' + index;
+ } else {
+ glyph.name = this.font.cffEncoding.charset[index];
+ }
+ } else if (this.font.glyphNames.names) {
+ glyph.name = this.font.glyphNames.glyphIndexToName(index);
+ }
+
+ this.glyphs[index].advanceWidth = this.font._hmtxTableData[index].advanceWidth;
+ this.glyphs[index].leftSideBearing = this.font._hmtxTableData[index].leftSideBearing;
+ } else {
+ if (typeof this.glyphs[index] === 'function') {
+ this.glyphs[index] = this.glyphs[index]();
+ }
+ }
+
+ return this.glyphs[index];
+};
+
+/**
+ * @param {number} index
+ * @param {Object}
+ */
+GlyphSet.prototype.push = function(index, loader) {
+ this.glyphs[index] = loader;
+ this.length++;
+};
+
+/**
+ * @alias opentype.glyphLoader
+ * @param {opentype.Font} font
+ * @param {number} index
+ * @return {opentype.Glyph}
+ */
+function glyphLoader(font, index) {
+ return new Glyph({index: index, font: font});
+}
+
+/**
+ * Generate a stub glyph that can be filled with all metadata *except*
+ * the "points" and "path" properties, which must be loaded only once
+ * the glyph's path is actually requested for text shaping.
+ * @alias opentype.ttfGlyphLoader
+ * @param {opentype.Font} font
+ * @param {number} index
+ * @param {Function} parseGlyph
+ * @param {Object} data
+ * @param {number} position
+ * @param {Function} buildPath
+ * @return {opentype.Glyph}
+ */
+function ttfGlyphLoader(font, index, parseGlyph, data, position, buildPath) {
+ return function() {
+ var glyph = new Glyph({index: index, font: font});
+
+ glyph.path = function() {
+ parseGlyph(glyph, data, position);
+ var path = buildPath(font.glyphs, glyph);
+ path.unitsPerEm = font.unitsPerEm;
+ return path;
+ };
+
+ defineDependentProperty(glyph, 'xMin', '_xMin');
+ defineDependentProperty(glyph, 'xMax', '_xMax');
+ defineDependentProperty(glyph, 'yMin', '_yMin');
+ defineDependentProperty(glyph, 'yMax', '_yMax');
+
+ return glyph;
+ };
+}
+/**
+ * @alias opentype.cffGlyphLoader
+ * @param {opentype.Font} font
+ * @param {number} index
+ * @param {Function} parseCFFCharstring
+ * @param {string} charstring
+ * @return {opentype.Glyph}
+ */
+function cffGlyphLoader(font, index, parseCFFCharstring, charstring) {
+ return function() {
+ var glyph = new Glyph({index: index, font: font});
+
+ glyph.path = function() {
+ var path = parseCFFCharstring(font, glyph, charstring);
+ path.unitsPerEm = font.unitsPerEm;
+ return path;
+ };
+
+ return glyph;
+ };
+}
+
+var glyphset = { GlyphSet: GlyphSet, glyphLoader: glyphLoader, ttfGlyphLoader: ttfGlyphLoader, cffGlyphLoader: cffGlyphLoader };
+
+// The `CFF` table contains the glyph outlines in PostScript format.
+
+// Custom equals function that can also check lists.
+function equals(a, b) {
+ if (a === b) {
+ return true;
+ } else if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) {
+ return false;
+ }
+
+ for (var i = 0; i < a.length; i += 1) {
+ if (!equals(a[i], b[i])) {
+ return false;
+ }
+ }
+
+ return true;
+ } else {
+ return false;
+ }
+}
+
+// Subroutines are encoded using the negative half of the number space.
+// See type 2 chapter 4.7 "Subroutine operators".
+function calcCFFSubroutineBias(subrs) {
+ var bias;
+ if (subrs.length < 1240) {
+ bias = 107;
+ } else if (subrs.length < 33900) {
+ bias = 1131;
+ } else {
+ bias = 32768;
+ }
+
+ return bias;
+}
+
+// Parse a `CFF` INDEX array.
+// An index array consists of a list of offsets, then a list of objects at those offsets.
+function parseCFFIndex(data, start, conversionFn) {
+ var offsets = [];
+ var objects = [];
+ var count = parse.getCard16(data, start);
+ var objectOffset;
+ var endOffset;
+ if (count !== 0) {
+ var offsetSize = parse.getByte(data, start + 2);
+ objectOffset = start + ((count + 1) * offsetSize) + 2;
+ var pos = start + 3;
+ for (var i = 0; i < count + 1; i += 1) {
+ offsets.push(parse.getOffset(data, pos, offsetSize));
+ pos += offsetSize;
+ }
+
+ // The total size of the index array is 4 header bytes + the value of the last offset.
+ endOffset = objectOffset + offsets[count];
+ } else {
+ endOffset = start + 2;
+ }
+
+ for (var i$1 = 0; i$1 < offsets.length - 1; i$1 += 1) {
+ var value = parse.getBytes(data, objectOffset + offsets[i$1], objectOffset + offsets[i$1 + 1]);
+ if (conversionFn) {
+ value = conversionFn(value);
+ }
+
+ objects.push(value);
+ }
+
+ return {objects: objects, startOffset: start, endOffset: endOffset};
+}
+
+function parseCFFIndexLowMemory(data, start) {
+ var offsets = [];
+ var count = parse.getCard16(data, start);
+ var objectOffset;
+ var endOffset;
+ if (count !== 0) {
+ var offsetSize = parse.getByte(data, start + 2);
+ objectOffset = start + ((count + 1) * offsetSize) + 2;
+ var pos = start + 3;
+ for (var i = 0; i < count + 1; i += 1) {
+ offsets.push(parse.getOffset(data, pos, offsetSize));
+ pos += offsetSize;
+ }
+
+ // The total size of the index array is 4 header bytes + the value of the last offset.
+ endOffset = objectOffset + offsets[count];
+ } else {
+ endOffset = start + 2;
+ }
+
+ return {offsets: offsets, startOffset: start, endOffset: endOffset};
+}
+function getCffIndexObject(i, offsets, data, start, conversionFn) {
+ var count = parse.getCard16(data, start);
+ var objectOffset = 0;
+ if (count !== 0) {
+ var offsetSize = parse.getByte(data, start + 2);
+ objectOffset = start + ((count + 1) * offsetSize) + 2;
+ }
+
+ var value = parse.getBytes(data, objectOffset + offsets[i], objectOffset + offsets[i + 1]);
+ if (conversionFn) {
+ value = conversionFn(value);
+ }
+ return value;
+}
+
+// Parse a `CFF` DICT real value.
+function parseFloatOperand(parser) {
+ var s = '';
+ var eof = 15;
+ var lookup = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', 'E', 'E-', null, '-'];
+ while (true) {
+ var b = parser.parseByte();
+ var n1 = b >> 4;
+ var n2 = b & 15;
+
+ if (n1 === eof) {
+ break;
+ }
+
+ s += lookup[n1];
+
+ if (n2 === eof) {
+ break;
+ }
+
+ s += lookup[n2];
+ }
+
+ return parseFloat(s);
+}
+
+// Parse a `CFF` DICT operand.
+function parseOperand(parser, b0) {
+ var b1;
+ var b2;
+ var b3;
+ var b4;
+ if (b0 === 28) {
+ b1 = parser.parseByte();
+ b2 = parser.parseByte();
+ return b1 << 8 | b2;
+ }
+
+ if (b0 === 29) {
+ b1 = parser.parseByte();
+ b2 = parser.parseByte();
+ b3 = parser.parseByte();
+ b4 = parser.parseByte();
+ return b1 << 24 | b2 << 16 | b3 << 8 | b4;
+ }
+
+ if (b0 === 30) {
+ return parseFloatOperand(parser);
+ }
+
+ if (b0 >= 32 && b0 <= 246) {
+ return b0 - 139;
+ }
+
+ if (b0 >= 247 && b0 <= 250) {
+ b1 = parser.parseByte();
+ return (b0 - 247) * 256 + b1 + 108;
+ }
+
+ if (b0 >= 251 && b0 <= 254) {
+ b1 = parser.parseByte();
+ return -(b0 - 251) * 256 - b1 - 108;
+ }
+
+ throw new Error('Invalid b0 ' + b0);
+}
+
+// Convert the entries returned by `parseDict` to a proper dictionary.
+// If a value is a list of one, it is unpacked.
+function entriesToObject(entries) {
+ var o = {};
+ for (var i = 0; i < entries.length; i += 1) {
+ var key = entries[i][0];
+ var values = entries[i][1];
+ var value = (void 0);
+ if (values.length === 1) {
+ value = values[0];
+ } else {
+ value = values;
+ }
+
+ if (o.hasOwnProperty(key) && !isNaN(o[key])) {
+ throw new Error('Object ' + o + ' already has key ' + key);
+ }
+
+ o[key] = value;
+ }
+
+ return o;
+}
+
+// Parse a `CFF` DICT object.
+// A dictionary contains key-value pairs in a compact tokenized format.
+function parseCFFDict(data, start, size) {
+ start = start !== undefined ? start : 0;
+ var parser = new parse.Parser(data, start);
+ var entries = [];
+ var operands = [];
+ size = size !== undefined ? size : data.length;
+
+ while (parser.relativeOffset < size) {
+ var op = parser.parseByte();
+
+ // The first byte for each dict item distinguishes between operator (key) and operand (value).
+ // Values <= 21 are operators.
+ if (op <= 21) {
+ // Two-byte operators have an initial escape byte of 12.
+ if (op === 12) {
+ op = 1200 + parser.parseByte();
+ }
+
+ entries.push([op, operands]);
+ operands = [];
+ } else {
+ // Since the operands (values) come before the operators (keys), we store all operands in a list
+ // until we encounter an operator.
+ operands.push(parseOperand(parser, op));
+ }
+ }
+
+ return entriesToObject(entries);
+}
+
+// Given a String Index (SID), return the value of the string.
+// Strings below index 392 are standard CFF strings and are not encoded in the font.
+function getCFFString(strings, index) {
+ if (index <= 390) {
+ index = cffStandardStrings[index];
+ } else {
+ index = strings[index - 391];
+ }
+
+ return index;
+}
+
+// Interpret a dictionary and return a new dictionary with readable keys and values for missing entries.
+// This function takes `meta` which is a list of objects containing `operand`, `name` and `default`.
+function interpretDict(dict, meta, strings) {
+ var newDict = {};
+ var value;
+
+ // Because we also want to include missing values, we start out from the meta list
+ // and lookup values in the dict.
+ for (var i = 0; i < meta.length; i += 1) {
+ var m = meta[i];
+
+ if (Array.isArray(m.type)) {
+ var values = [];
+ values.length = m.type.length;
+ for (var j = 0; j < m.type.length; j++) {
+ value = dict[m.op] !== undefined ? dict[m.op][j] : undefined;
+ if (value === undefined) {
+ value = m.value !== undefined && m.value[j] !== undefined ? m.value[j] : null;
+ }
+ if (m.type[j] === 'SID') {
+ value = getCFFString(strings, value);
+ }
+ values[j] = value;
+ }
+ newDict[m.name] = values;
+ } else {
+ value = dict[m.op];
+ if (value === undefined) {
+ value = m.value !== undefined ? m.value : null;
+ }
+
+ if (m.type === 'SID') {
+ value = getCFFString(strings, value);
+ }
+ newDict[m.name] = value;
+ }
+ }
+
+ return newDict;
+}
+
+// Parse the CFF header.
+function parseCFFHeader(data, start) {
+ var header = {};
+ header.formatMajor = parse.getCard8(data, start);
+ header.formatMinor = parse.getCard8(data, start + 1);
+ header.size = parse.getCard8(data, start + 2);
+ header.offsetSize = parse.getCard8(data, start + 3);
+ header.startOffset = start;
+ header.endOffset = start + 4;
+ return header;
+}
+
+var TOP_DICT_META = [
+ {name: 'version', op: 0, type: 'SID'},
+ {name: 'notice', op: 1, type: 'SID'},
+ {name: 'copyright', op: 1200, type: 'SID'},
+ {name: 'fullName', op: 2, type: 'SID'},
+ {name: 'familyName', op: 3, type: 'SID'},
+ {name: 'weight', op: 4, type: 'SID'},
+ {name: 'isFixedPitch', op: 1201, type: 'number', value: 0},
+ {name: 'italicAngle', op: 1202, type: 'number', value: 0},
+ {name: 'underlinePosition', op: 1203, type: 'number', value: -100},
+ {name: 'underlineThickness', op: 1204, type: 'number', value: 50},
+ {name: 'paintType', op: 1205, type: 'number', value: 0},
+ {name: 'charstringType', op: 1206, type: 'number', value: 2},
+ {
+ name: 'fontMatrix',
+ op: 1207,
+ type: ['real', 'real', 'real', 'real', 'real', 'real'],
+ value: [0.001, 0, 0, 0.001, 0, 0]
+ },
+ {name: 'uniqueId', op: 13, type: 'number'},
+ {name: 'fontBBox', op: 5, type: ['number', 'number', 'number', 'number'], value: [0, 0, 0, 0]},
+ {name: 'strokeWidth', op: 1208, type: 'number', value: 0},
+ {name: 'xuid', op: 14, type: [], value: null},
+ {name: 'charset', op: 15, type: 'offset', value: 0},
+ {name: 'encoding', op: 16, type: 'offset', value: 0},
+ {name: 'charStrings', op: 17, type: 'offset', value: 0},
+ {name: 'private', op: 18, type: ['number', 'offset'], value: [0, 0]},
+ {name: 'ros', op: 1230, type: ['SID', 'SID', 'number']},
+ {name: 'cidFontVersion', op: 1231, type: 'number', value: 0},
+ {name: 'cidFontRevision', op: 1232, type: 'number', value: 0},
+ {name: 'cidFontType', op: 1233, type: 'number', value: 0},
+ {name: 'cidCount', op: 1234, type: 'number', value: 8720},
+ {name: 'uidBase', op: 1235, type: 'number'},
+ {name: 'fdArray', op: 1236, type: 'offset'},
+ {name: 'fdSelect', op: 1237, type: 'offset'},
+ {name: 'fontName', op: 1238, type: 'SID'}
+];
+
+var PRIVATE_DICT_META = [
+ {name: 'subrs', op: 19, type: 'offset', value: 0},
+ {name: 'defaultWidthX', op: 20, type: 'number', value: 0},
+ {name: 'nominalWidthX', op: 21, type: 'number', value: 0}
+];
+
+// Parse the CFF top dictionary. A CFF table can contain multiple fonts, each with their own top dictionary.
+// The top dictionary contains the essential metadata for the font, together with the private dictionary.
+function parseCFFTopDict(data, strings) {
+ var dict = parseCFFDict(data, 0, data.byteLength);
+ return interpretDict(dict, TOP_DICT_META, strings);
+}
+
+// Parse the CFF private dictionary. We don't fully parse out all the values, only the ones we need.
+function parseCFFPrivateDict(data, start, size, strings) {
+ var dict = parseCFFDict(data, start, size);
+ return interpretDict(dict, PRIVATE_DICT_META, strings);
+}
+
+// Returns a list of "Top DICT"s found using an INDEX list.
+// Used to read both the usual high-level Top DICTs and also the FDArray
+// discovered inside CID-keyed fonts. When a Top DICT has a reference to
+// a Private DICT that is read and saved into the Top DICT.
+//
+// In addition to the expected/optional values as outlined in TOP_DICT_META
+// the following values might be saved into the Top DICT.
+//
+// _subrs [] array of local CFF subroutines from Private DICT
+// _subrsBias bias value computed from number of subroutines
+// (see calcCFFSubroutineBias() and parseCFFCharstring())
+// _defaultWidthX default widths for CFF characters
+// _nominalWidthX bias added to width embedded within glyph description
+//
+// _privateDict saved copy of parsed Private DICT from Top DICT
+function gatherCFFTopDicts(data, start, cffIndex, strings) {
+ var topDictArray = [];
+ for (var iTopDict = 0; iTopDict < cffIndex.length; iTopDict += 1) {
+ var topDictData = new DataView(new Uint8Array(cffIndex[iTopDict]).buffer);
+ var topDict = parseCFFTopDict(topDictData, strings);
+ topDict._subrs = [];
+ topDict._subrsBias = 0;
+ topDict._defaultWidthX = 0;
+ topDict._nominalWidthX = 0;
+ var privateSize = topDict.private[0];
+ var privateOffset = topDict.private[1];
+ if (privateSize !== 0 && privateOffset !== 0) {
+ var privateDict = parseCFFPrivateDict(data, privateOffset + start, privateSize, strings);
+ topDict._defaultWidthX = privateDict.defaultWidthX;
+ topDict._nominalWidthX = privateDict.nominalWidthX;
+ if (privateDict.subrs !== 0) {
+ var subrOffset = privateOffset + privateDict.subrs;
+ var subrIndex = parseCFFIndex(data, subrOffset + start);
+ topDict._subrs = subrIndex.objects;
+ topDict._subrsBias = calcCFFSubroutineBias(topDict._subrs);
+ }
+ topDict._privateDict = privateDict;
+ }
+ topDictArray.push(topDict);
+ }
+ return topDictArray;
+}
+
+// Parse the CFF charset table, which contains internal names for all the glyphs.
+// This function will return a list of glyph names.
+// See Adobe TN #5176 chapter 13, "Charsets".
+function parseCFFCharset(data, start, nGlyphs, strings) {
+ var sid;
+ var count;
+ var parser = new parse.Parser(data, start);
+
+ // The .notdef glyph is not included, so subtract 1.
+ nGlyphs -= 1;
+ var charset = ['.notdef'];
+
+ var format = parser.parseCard8();
+ if (format === 0) {
+ for (var i = 0; i < nGlyphs; i += 1) {
+ sid = parser.parseSID();
+ charset.push(getCFFString(strings, sid));
+ }
+ } else if (format === 1) {
+ while (charset.length <= nGlyphs) {
+ sid = parser.parseSID();
+ count = parser.parseCard8();
+ for (var i$1 = 0; i$1 <= count; i$1 += 1) {
+ charset.push(getCFFString(strings, sid));
+ sid += 1;
+ }
+ }
+ } else if (format === 2) {
+ while (charset.length <= nGlyphs) {
+ sid = parser.parseSID();
+ count = parser.parseCard16();
+ for (var i$2 = 0; i$2 <= count; i$2 += 1) {
+ charset.push(getCFFString(strings, sid));
+ sid += 1;
+ }
+ }
+ } else {
+ throw new Error('Unknown charset format ' + format);
+ }
+
+ return charset;
+}
+
+// Parse the CFF encoding data. Only one encoding can be specified per font.
+// See Adobe TN #5176 chapter 12, "Encodings".
+function parseCFFEncoding(data, start, charset) {
+ var code;
+ var enc = {};
+ var parser = new parse.Parser(data, start);
+ var format = parser.parseCard8();
+ if (format === 0) {
+ var nCodes = parser.parseCard8();
+ for (var i = 0; i < nCodes; i += 1) {
+ code = parser.parseCard8();
+ enc[code] = i;
+ }
+ } else if (format === 1) {
+ var nRanges = parser.parseCard8();
+ code = 1;
+ for (var i$1 = 0; i$1 < nRanges; i$1 += 1) {
+ var first = parser.parseCard8();
+ var nLeft = parser.parseCard8();
+ for (var j = first; j <= first + nLeft; j += 1) {
+ enc[j] = code;
+ code += 1;
+ }
+ }
+ } else {
+ throw new Error('Unknown encoding format ' + format);
+ }
+
+ return new CffEncoding(enc, charset);
+}
+
+// Take in charstring code and return a Glyph object.
+// The encoding is described in the Type 2 Charstring Format
+// https://www.microsoft.com/typography/OTSPEC/charstr2.htm
+function parseCFFCharstring(font, glyph, code) {
+ var c1x;
+ var c1y;
+ var c2x;
+ var c2y;
+ var p = new Path();
+ var stack = [];
+ var nStems = 0;
+ var haveWidth = false;
+ var open = false;
+ var x = 0;
+ var y = 0;
+ var subrs;
+ var subrsBias;
+ var defaultWidthX;
+ var nominalWidthX;
+ if (font.isCIDFont) {
+ var fdIndex = font.tables.cff.topDict._fdSelect[glyph.index];
+ var fdDict = font.tables.cff.topDict._fdArray[fdIndex];
+ subrs = fdDict._subrs;
+ subrsBias = fdDict._subrsBias;
+ defaultWidthX = fdDict._defaultWidthX;
+ nominalWidthX = fdDict._nominalWidthX;
+ } else {
+ subrs = font.tables.cff.topDict._subrs;
+ subrsBias = font.tables.cff.topDict._subrsBias;
+ defaultWidthX = font.tables.cff.topDict._defaultWidthX;
+ nominalWidthX = font.tables.cff.topDict._nominalWidthX;
+ }
+ var width = defaultWidthX;
+
+ function newContour(x, y) {
+ if (open) {
+ p.closePath();
+ }
+
+ p.moveTo(x, y);
+ open = true;
+ }
+
+ function parseStems() {
+ var hasWidthArg;
+
+ // The number of stem operators on the stack is always even.
+ // If the value is uneven, that means a width is specified.
+ hasWidthArg = stack.length % 2 !== 0;
+ if (hasWidthArg && !haveWidth) {
+ width = stack.shift() + nominalWidthX;
+ }
+
+ nStems += stack.length >> 1;
+ stack.length = 0;
+ haveWidth = true;
+ }
+
+ function parse(code) {
+ var b1;
+ var b2;
+ var b3;
+ var b4;
+ var codeIndex;
+ var subrCode;
+ var jpx;
+ var jpy;
+ var c3x;
+ var c3y;
+ var c4x;
+ var c4y;
+
+ var i = 0;
+ while (i < code.length) {
+ var v = code[i];
+ i += 1;
+ switch (v) {
+ case 1: // hstem
+ parseStems();
+ break;
+ case 3: // vstem
+ parseStems();
+ break;
+ case 4: // vmoveto
+ if (stack.length > 1 && !haveWidth) {
+ width = stack.shift() + nominalWidthX;
+ haveWidth = true;
+ }
+
+ y += stack.pop();
+ newContour(x, y);
+ break;
+ case 5: // rlineto
+ while (stack.length > 0) {
+ x += stack.shift();
+ y += stack.shift();
+ p.lineTo(x, y);
+ }
+
+ break;
+ case 6: // hlineto
+ while (stack.length > 0) {
+ x += stack.shift();
+ p.lineTo(x, y);
+ if (stack.length === 0) {
+ break;
+ }
+
+ y += stack.shift();
+ p.lineTo(x, y);
+ }
+
+ break;
+ case 7: // vlineto
+ while (stack.length > 0) {
+ y += stack.shift();
+ p.lineTo(x, y);
+ if (stack.length === 0) {
+ break;
+ }
+
+ x += stack.shift();
+ p.lineTo(x, y);
+ }
+
+ break;
+ case 8: // rrcurveto
+ while (stack.length > 0) {
+ c1x = x + stack.shift();
+ c1y = y + stack.shift();
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ x = c2x + stack.shift();
+ y = c2y + stack.shift();
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ }
+
+ break;
+ case 10: // callsubr
+ codeIndex = stack.pop() + subrsBias;
+ subrCode = subrs[codeIndex];
+ if (subrCode) {
+ parse(subrCode);
+ }
+
+ break;
+ case 11: // return
+ return;
+ case 12: // flex operators
+ v = code[i];
+ i += 1;
+ switch (v) {
+ case 35: // flex
+ // |- dx1 dy1 dx2 dy2 dx3 dy3 dx4 dy4 dx5 dy5 dx6 dy6 fd flex (12 35) |-
+ c1x = x + stack.shift(); // dx1
+ c1y = y + stack.shift(); // dy1
+ c2x = c1x + stack.shift(); // dx2
+ c2y = c1y + stack.shift(); // dy2
+ jpx = c2x + stack.shift(); // dx3
+ jpy = c2y + stack.shift(); // dy3
+ c3x = jpx + stack.shift(); // dx4
+ c3y = jpy + stack.shift(); // dy4
+ c4x = c3x + stack.shift(); // dx5
+ c4y = c3y + stack.shift(); // dy5
+ x = c4x + stack.shift(); // dx6
+ y = c4y + stack.shift(); // dy6
+ stack.shift(); // flex depth
+ p.curveTo(c1x, c1y, c2x, c2y, jpx, jpy);
+ p.curveTo(c3x, c3y, c4x, c4y, x, y);
+ break;
+ case 34: // hflex
+ // |- dx1 dx2 dy2 dx3 dx4 dx5 dx6 hflex (12 34) |-
+ c1x = x + stack.shift(); // dx1
+ c1y = y; // dy1
+ c2x = c1x + stack.shift(); // dx2
+ c2y = c1y + stack.shift(); // dy2
+ jpx = c2x + stack.shift(); // dx3
+ jpy = c2y; // dy3
+ c3x = jpx + stack.shift(); // dx4
+ c3y = c2y; // dy4
+ c4x = c3x + stack.shift(); // dx5
+ c4y = y; // dy5
+ x = c4x + stack.shift(); // dx6
+ p.curveTo(c1x, c1y, c2x, c2y, jpx, jpy);
+ p.curveTo(c3x, c3y, c4x, c4y, x, y);
+ break;
+ case 36: // hflex1
+ // |- dx1 dy1 dx2 dy2 dx3 dx4 dx5 dy5 dx6 hflex1 (12 36) |-
+ c1x = x + stack.shift(); // dx1
+ c1y = y + stack.shift(); // dy1
+ c2x = c1x + stack.shift(); // dx2
+ c2y = c1y + stack.shift(); // dy2
+ jpx = c2x + stack.shift(); // dx3
+ jpy = c2y; // dy3
+ c3x = jpx + stack.shift(); // dx4
+ c3y = c2y; // dy4
+ c4x = c3x + stack.shift(); // dx5
+ c4y = c3y + stack.shift(); // dy5
+ x = c4x + stack.shift(); // dx6
+ p.curveTo(c1x, c1y, c2x, c2y, jpx, jpy);
+ p.curveTo(c3x, c3y, c4x, c4y, x, y);
+ break;
+ case 37: // flex1
+ // |- dx1 dy1 dx2 dy2 dx3 dy3 dx4 dy4 dx5 dy5 d6 flex1 (12 37) |-
+ c1x = x + stack.shift(); // dx1
+ c1y = y + stack.shift(); // dy1
+ c2x = c1x + stack.shift(); // dx2
+ c2y = c1y + stack.shift(); // dy2
+ jpx = c2x + stack.shift(); // dx3
+ jpy = c2y + stack.shift(); // dy3
+ c3x = jpx + stack.shift(); // dx4
+ c3y = jpy + stack.shift(); // dy4
+ c4x = c3x + stack.shift(); // dx5
+ c4y = c3y + stack.shift(); // dy5
+ if (Math.abs(c4x - x) > Math.abs(c4y - y)) {
+ x = c4x + stack.shift();
+ } else {
+ y = c4y + stack.shift();
+ }
+
+ p.curveTo(c1x, c1y, c2x, c2y, jpx, jpy);
+ p.curveTo(c3x, c3y, c4x, c4y, x, y);
+ break;
+ default:
+ console.log('Glyph ' + glyph.index + ': unknown operator ' + 1200 + v);
+ stack.length = 0;
+ }
+ break;
+ case 14: // endchar
+ if (stack.length > 0 && !haveWidth) {
+ width = stack.shift() + nominalWidthX;
+ haveWidth = true;
+ }
+
+ if (open) {
+ p.closePath();
+ open = false;
+ }
+
+ break;
+ case 18: // hstemhm
+ parseStems();
+ break;
+ case 19: // hintmask
+ case 20: // cntrmask
+ parseStems();
+ i += (nStems + 7) >> 3;
+ break;
+ case 21: // rmoveto
+ if (stack.length > 2 && !haveWidth) {
+ width = stack.shift() + nominalWidthX;
+ haveWidth = true;
+ }
+
+ y += stack.pop();
+ x += stack.pop();
+ newContour(x, y);
+ break;
+ case 22: // hmoveto
+ if (stack.length > 1 && !haveWidth) {
+ width = stack.shift() + nominalWidthX;
+ haveWidth = true;
+ }
+
+ x += stack.pop();
+ newContour(x, y);
+ break;
+ case 23: // vstemhm
+ parseStems();
+ break;
+ case 24: // rcurveline
+ while (stack.length > 2) {
+ c1x = x + stack.shift();
+ c1y = y + stack.shift();
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ x = c2x + stack.shift();
+ y = c2y + stack.shift();
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ }
+
+ x += stack.shift();
+ y += stack.shift();
+ p.lineTo(x, y);
+ break;
+ case 25: // rlinecurve
+ while (stack.length > 6) {
+ x += stack.shift();
+ y += stack.shift();
+ p.lineTo(x, y);
+ }
+
+ c1x = x + stack.shift();
+ c1y = y + stack.shift();
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ x = c2x + stack.shift();
+ y = c2y + stack.shift();
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ break;
+ case 26: // vvcurveto
+ if (stack.length % 2) {
+ x += stack.shift();
+ }
+
+ while (stack.length > 0) {
+ c1x = x;
+ c1y = y + stack.shift();
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ x = c2x;
+ y = c2y + stack.shift();
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ }
+
+ break;
+ case 27: // hhcurveto
+ if (stack.length % 2) {
+ y += stack.shift();
+ }
+
+ while (stack.length > 0) {
+ c1x = x + stack.shift();
+ c1y = y;
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ x = c2x + stack.shift();
+ y = c2y;
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ }
+
+ break;
+ case 28: // shortint
+ b1 = code[i];
+ b2 = code[i + 1];
+ stack.push(((b1 << 24) | (b2 << 16)) >> 16);
+ i += 2;
+ break;
+ case 29: // callgsubr
+ codeIndex = stack.pop() + font.gsubrsBias;
+ subrCode = font.gsubrs[codeIndex];
+ if (subrCode) {
+ parse(subrCode);
+ }
+
+ break;
+ case 30: // vhcurveto
+ while (stack.length > 0) {
+ c1x = x;
+ c1y = y + stack.shift();
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ x = c2x + stack.shift();
+ y = c2y + (stack.length === 1 ? stack.shift() : 0);
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ if (stack.length === 0) {
+ break;
+ }
+
+ c1x = x + stack.shift();
+ c1y = y;
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ y = c2y + stack.shift();
+ x = c2x + (stack.length === 1 ? stack.shift() : 0);
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ }
+
+ break;
+ case 31: // hvcurveto
+ while (stack.length > 0) {
+ c1x = x + stack.shift();
+ c1y = y;
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ y = c2y + stack.shift();
+ x = c2x + (stack.length === 1 ? stack.shift() : 0);
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ if (stack.length === 0) {
+ break;
+ }
+
+ c1x = x;
+ c1y = y + stack.shift();
+ c2x = c1x + stack.shift();
+ c2y = c1y + stack.shift();
+ x = c2x + stack.shift();
+ y = c2y + (stack.length === 1 ? stack.shift() : 0);
+ p.curveTo(c1x, c1y, c2x, c2y, x, y);
+ }
+
+ break;
+ default:
+ if (v < 32) {
+ console.log('Glyph ' + glyph.index + ': unknown operator ' + v);
+ } else if (v < 247) {
+ stack.push(v - 139);
+ } else if (v < 251) {
+ b1 = code[i];
+ i += 1;
+ stack.push((v - 247) * 256 + b1 + 108);
+ } else if (v < 255) {
+ b1 = code[i];
+ i += 1;
+ stack.push(-(v - 251) * 256 - b1 - 108);
+ } else {
+ b1 = code[i];
+ b2 = code[i + 1];
+ b3 = code[i + 2];
+ b4 = code[i + 3];
+ i += 4;
+ stack.push(((b1 << 24) | (b2 << 16) | (b3 << 8) | b4) / 65536);
+ }
+ }
+ }
+ }
+
+ parse(code);
+
+ glyph.advanceWidth = width;
+ return p;
+}
+
+function parseCFFFDSelect(data, start, nGlyphs, fdArrayCount) {
+ var fdSelect = [];
+ var fdIndex;
+ var parser = new parse.Parser(data, start);
+ var format = parser.parseCard8();
+ if (format === 0) {
+ // Simple list of nGlyphs elements
+ for (var iGid = 0; iGid < nGlyphs; iGid++) {
+ fdIndex = parser.parseCard8();
+ if (fdIndex >= fdArrayCount) {
+ throw new Error('CFF table CID Font FDSelect has bad FD index value ' + fdIndex + ' (FD count ' + fdArrayCount + ')');
+ }
+ fdSelect.push(fdIndex);
+ }
+ } else if (format === 3) {
+ // Ranges
+ var nRanges = parser.parseCard16();
+ var first = parser.parseCard16();
+ if (first !== 0) {
+ throw new Error('CFF Table CID Font FDSelect format 3 range has bad initial GID ' + first);
+ }
+ var next;
+ for (var iRange = 0; iRange < nRanges; iRange++) {
+ fdIndex = parser.parseCard8();
+ next = parser.parseCard16();
+ if (fdIndex >= fdArrayCount) {
+ throw new Error('CFF table CID Font FDSelect has bad FD index value ' + fdIndex + ' (FD count ' + fdArrayCount + ')');
+ }
+ if (next > nGlyphs) {
+ throw new Error('CFF Table CID Font FDSelect format 3 range has bad GID ' + next);
+ }
+ for (; first < next; first++) {
+ fdSelect.push(fdIndex);
+ }
+ first = next;
+ }
+ if (next !== nGlyphs) {
+ throw new Error('CFF Table CID Font FDSelect format 3 range has bad final GID ' + next);
+ }
+ } else {
+ throw new Error('CFF Table CID Font FDSelect table has unsupported format ' + format);
+ }
+ return fdSelect;
+}
+
+// Parse the `CFF` table, which contains the glyph outlines in PostScript format.
+function parseCFFTable(data, start, font, opt) {
+ font.tables.cff = {};
+ var header = parseCFFHeader(data, start);
+ var nameIndex = parseCFFIndex(data, header.endOffset, parse.bytesToString);
+ var topDictIndex = parseCFFIndex(data, nameIndex.endOffset);
+ var stringIndex = parseCFFIndex(data, topDictIndex.endOffset, parse.bytesToString);
+ var globalSubrIndex = parseCFFIndex(data, stringIndex.endOffset);
+ font.gsubrs = globalSubrIndex.objects;
+ font.gsubrsBias = calcCFFSubroutineBias(font.gsubrs);
+
+ var topDictArray = gatherCFFTopDicts(data, start, topDictIndex.objects, stringIndex.objects);
+ if (topDictArray.length !== 1) {
+ throw new Error('CFF table has too many fonts in \'FontSet\' - count of fonts NameIndex.length = ' + topDictArray.length);
+ }
+
+ var topDict = topDictArray[0];
+ font.tables.cff.topDict = topDict;
+
+ if (topDict._privateDict) {
+ font.defaultWidthX = topDict._privateDict.defaultWidthX;
+ font.nominalWidthX = topDict._privateDict.nominalWidthX;
+ }
+
+ if (topDict.ros[0] !== undefined && topDict.ros[1] !== undefined) {
+ font.isCIDFont = true;
+ }
+
+ if (font.isCIDFont) {
+ var fdArrayOffset = topDict.fdArray;
+ var fdSelectOffset = topDict.fdSelect;
+ if (fdArrayOffset === 0 || fdSelectOffset === 0) {
+ throw new Error('Font is marked as a CID font, but FDArray and/or FDSelect information is missing');
+ }
+ fdArrayOffset += start;
+ var fdArrayIndex = parseCFFIndex(data, fdArrayOffset);
+ var fdArray = gatherCFFTopDicts(data, start, fdArrayIndex.objects, stringIndex.objects);
+ topDict._fdArray = fdArray;
+ fdSelectOffset += start;
+ topDict._fdSelect = parseCFFFDSelect(data, fdSelectOffset, font.numGlyphs, fdArray.length);
+ }
+
+ var privateDictOffset = start + topDict.private[1];
+ var privateDict = parseCFFPrivateDict(data, privateDictOffset, topDict.private[0], stringIndex.objects);
+ font.defaultWidthX = privateDict.defaultWidthX;
+ font.nominalWidthX = privateDict.nominalWidthX;
+
+ if (privateDict.subrs !== 0) {
+ var subrOffset = privateDictOffset + privateDict.subrs;
+ var subrIndex = parseCFFIndex(data, subrOffset);
+ font.subrs = subrIndex.objects;
+ font.subrsBias = calcCFFSubroutineBias(font.subrs);
+ } else {
+ font.subrs = [];
+ font.subrsBias = 0;
+ }
+
+ // Offsets in the top dict are relative to the beginning of the CFF data, so add the CFF start offset.
+ var charStringsIndex;
+ if (opt.lowMemory) {
+ charStringsIndex = parseCFFIndexLowMemory(data, start + topDict.charStrings);
+ font.nGlyphs = charStringsIndex.offsets.length;
+ } else {
+ charStringsIndex = parseCFFIndex(data, start + topDict.charStrings);
+ font.nGlyphs = charStringsIndex.objects.length;
+ }
+
+ var charset = parseCFFCharset(data, start + topDict.charset, font.nGlyphs, stringIndex.objects);
+ if (topDict.encoding === 0) {
+ // Standard encoding
+ font.cffEncoding = new CffEncoding(cffStandardEncoding, charset);
+ } else if (topDict.encoding === 1) {
+ // Expert encoding
+ font.cffEncoding = new CffEncoding(cffExpertEncoding, charset);
+ } else {
+ font.cffEncoding = parseCFFEncoding(data, start + topDict.encoding, charset);
+ }
+
+ // Prefer the CMAP encoding to the CFF encoding.
+ font.encoding = font.encoding || font.cffEncoding;
+
+ font.glyphs = new glyphset.GlyphSet(font);
+ if (opt.lowMemory) {
+ font._push = function(i) {
+ var charString = getCffIndexObject(i, charStringsIndex.offsets, data, start + topDict.charStrings);
+ font.glyphs.push(i, glyphset.cffGlyphLoader(font, i, parseCFFCharstring, charString));
+ };
+ } else {
+ for (var i = 0; i < font.nGlyphs; i += 1) {
+ var charString = charStringsIndex.objects[i];
+ font.glyphs.push(i, glyphset.cffGlyphLoader(font, i, parseCFFCharstring, charString));
+ }
+ }
+}
+
+// Convert a string to a String ID (SID).
+// The list of strings is modified in place.
+function encodeString(s, strings) {
+ var sid;
+
+ // Is the string in the CFF standard strings?
+ var i = cffStandardStrings.indexOf(s);
+ if (i >= 0) {
+ sid = i;
+ }
+
+ // Is the string already in the string index?
+ i = strings.indexOf(s);
+ if (i >= 0) {
+ sid = i + cffStandardStrings.length;
+ } else {
+ sid = cffStandardStrings.length + strings.length;
+ strings.push(s);
+ }
+
+ return sid;
+}
+
+function makeHeader() {
+ return new table.Record('Header', [
+ {name: 'major', type: 'Card8', value: 1},
+ {name: 'minor', type: 'Card8', value: 0},
+ {name: 'hdrSize', type: 'Card8', value: 4},
+ {name: 'major', type: 'Card8', value: 1}
+ ]);
+}
+
+function makeNameIndex(fontNames) {
+ var t = new table.Record('Name INDEX', [
+ {name: 'names', type: 'INDEX', value: []}
+ ]);
+ t.names = [];
+ for (var i = 0; i < fontNames.length; i += 1) {
+ t.names.push({name: 'name_' + i, type: 'NAME', value: fontNames[i]});
+ }
+
+ return t;
+}
+
+// Given a dictionary's metadata, create a DICT structure.
+function makeDict(meta, attrs, strings) {
+ var m = {};
+ for (var i = 0; i < meta.length; i += 1) {
+ var entry = meta[i];
+ var value = attrs[entry.name];
+ if (value !== undefined && !equals(value, entry.value)) {
+ if (entry.type === 'SID') {
+ value = encodeString(value, strings);
+ }
+
+ m[entry.op] = {name: entry.name, type: entry.type, value: value};
+ }
+ }
+
+ return m;
+}
+
+// The Top DICT houses the global font attributes.
+function makeTopDict(attrs, strings) {
+ var t = new table.Record('Top DICT', [
+ {name: 'dict', type: 'DICT', value: {}}
+ ]);
+ t.dict = makeDict(TOP_DICT_META, attrs, strings);
+ return t;
+}
+
+function makeTopDictIndex(topDict) {
+ var t = new table.Record('Top DICT INDEX', [
+ {name: 'topDicts', type: 'INDEX', value: []}
+ ]);
+ t.topDicts = [{name: 'topDict_0', type: 'TABLE', value: topDict}];
+ return t;
+}
+
+function makeStringIndex(strings) {
+ var t = new table.Record('String INDEX', [
+ {name: 'strings', type: 'INDEX', value: []}
+ ]);
+ t.strings = [];
+ for (var i = 0; i < strings.length; i += 1) {
+ t.strings.push({name: 'string_' + i, type: 'STRING', value: strings[i]});
+ }
+
+ return t;
+}
+
+function makeGlobalSubrIndex() {
+ // Currently we don't use subroutines.
+ return new table.Record('Global Subr INDEX', [
+ {name: 'subrs', type: 'INDEX', value: []}
+ ]);
+}
+
+function makeCharsets(glyphNames, strings) {
+ var t = new table.Record('Charsets', [
+ {name: 'format', type: 'Card8', value: 0}
+ ]);
+ for (var i = 0; i < glyphNames.length; i += 1) {
+ var glyphName = glyphNames[i];
+ var glyphSID = encodeString(glyphName, strings);
+ t.fields.push({name: 'glyph_' + i, type: 'SID', value: glyphSID});
+ }
+
+ return t;
+}
+
+function glyphToOps(glyph) {
+ var ops = [];
+ var path = glyph.path;
+ ops.push({name: 'width', type: 'NUMBER', value: glyph.advanceWidth});
+ var x = 0;
+ var y = 0;
+ for (var i = 0; i < path.commands.length; i += 1) {
+ var dx = (void 0);
+ var dy = (void 0);
+ var cmd = path.commands[i];
+ if (cmd.type === 'Q') {
+ // CFF only supports bézier curves, so convert the quad to a bézier.
+ var _13 = 1 / 3;
+ var _23 = 2 / 3;
+
+ // We're going to create a new command so we don't change the original path.
+ // Since all coordinates are relative, we round() them ASAP to avoid propagating errors.
+ cmd = {
+ type: 'C',
+ x: cmd.x,
+ y: cmd.y,
+ x1: Math.round(_13 * x + _23 * cmd.x1),
+ y1: Math.round(_13 * y + _23 * cmd.y1),
+ x2: Math.round(_13 * cmd.x + _23 * cmd.x1),
+ y2: Math.round(_13 * cmd.y + _23 * cmd.y1)
+ };
+ }
+
+ if (cmd.type === 'M') {
+ dx = Math.round(cmd.x - x);
+ dy = Math.round(cmd.y - y);
+ ops.push({name: 'dx', type: 'NUMBER', value: dx});
+ ops.push({name: 'dy', type: 'NUMBER', value: dy});
+ ops.push({name: 'rmoveto', type: 'OP', value: 21});
+ x = Math.round(cmd.x);
+ y = Math.round(cmd.y);
+ } else if (cmd.type === 'L') {
+ dx = Math.round(cmd.x - x);
+ dy = Math.round(cmd.y - y);
+ ops.push({name: 'dx', type: 'NUMBER', value: dx});
+ ops.push({name: 'dy', type: 'NUMBER', value: dy});
+ ops.push({name: 'rlineto', type: 'OP', value: 5});
+ x = Math.round(cmd.x);
+ y = Math.round(cmd.y);
+ } else if (cmd.type === 'C') {
+ var dx1 = Math.round(cmd.x1 - x);
+ var dy1 = Math.round(cmd.y1 - y);
+ var dx2 = Math.round(cmd.x2 - cmd.x1);
+ var dy2 = Math.round(cmd.y2 - cmd.y1);
+ dx = Math.round(cmd.x - cmd.x2);
+ dy = Math.round(cmd.y - cmd.y2);
+ ops.push({name: 'dx1', type: 'NUMBER', value: dx1});
+ ops.push({name: 'dy1', type: 'NUMBER', value: dy1});
+ ops.push({name: 'dx2', type: 'NUMBER', value: dx2});
+ ops.push({name: 'dy2', type: 'NUMBER', value: dy2});
+ ops.push({name: 'dx', type: 'NUMBER', value: dx});
+ ops.push({name: 'dy', type: 'NUMBER', value: dy});
+ ops.push({name: 'rrcurveto', type: 'OP', value: 8});
+ x = Math.round(cmd.x);
+ y = Math.round(cmd.y);
+ }
+
+ // Contours are closed automatically.
+ }
+
+ ops.push({name: 'endchar', type: 'OP', value: 14});
+ return ops;
+}
+
+function makeCharStringsIndex(glyphs) {
+ var t = new table.Record('CharStrings INDEX', [
+ {name: 'charStrings', type: 'INDEX', value: []}
+ ]);
+
+ for (var i = 0; i < glyphs.length; i += 1) {
+ var glyph = glyphs.get(i);
+ var ops = glyphToOps(glyph);
+ t.charStrings.push({name: glyph.name, type: 'CHARSTRING', value: ops});
+ }
+
+ return t;
+}
+
+function makePrivateDict(attrs, strings) {
+ var t = new table.Record('Private DICT', [
+ {name: 'dict', type: 'DICT', value: {}}
+ ]);
+ t.dict = makeDict(PRIVATE_DICT_META, attrs, strings);
+ return t;
+}
+
+function makeCFFTable(glyphs, options) {
+ var t = new table.Table('CFF ', [
+ {name: 'header', type: 'RECORD'},
+ {name: 'nameIndex', type: 'RECORD'},
+ {name: 'topDictIndex', type: 'RECORD'},
+ {name: 'stringIndex', type: 'RECORD'},
+ {name: 'globalSubrIndex', type: 'RECORD'},
+ {name: 'charsets', type: 'RECORD'},
+ {name: 'charStringsIndex', type: 'RECORD'},
+ {name: 'privateDict', type: 'RECORD'}
+ ]);
+
+ var fontScale = 1 / options.unitsPerEm;
+ // We use non-zero values for the offsets so that the DICT encodes them.
+ // This is important because the size of the Top DICT plays a role in offset calculation,
+ // and the size shouldn't change after we've written correct offsets.
+ var attrs = {
+ version: options.version,
+ fullName: options.fullName,
+ familyName: options.familyName,
+ weight: options.weightName,
+ fontBBox: options.fontBBox || [0, 0, 0, 0],
+ fontMatrix: [fontScale, 0, 0, fontScale, 0, 0],
+ charset: 999,
+ encoding: 0,
+ charStrings: 999,
+ private: [0, 999]
+ };
+
+ var privateAttrs = {};
+
+ var glyphNames = [];
+ var glyph;
+
+ // Skip first glyph (.notdef)
+ for (var i = 1; i < glyphs.length; i += 1) {
+ glyph = glyphs.get(i);
+ glyphNames.push(glyph.name);
+ }
+
+ var strings = [];
+
+ t.header = makeHeader();
+ t.nameIndex = makeNameIndex([options.postScriptName]);
+ var topDict = makeTopDict(attrs, strings);
+ t.topDictIndex = makeTopDictIndex(topDict);
+ t.globalSubrIndex = makeGlobalSubrIndex();
+ t.charsets = makeCharsets(glyphNames, strings);
+ t.charStringsIndex = makeCharStringsIndex(glyphs);
+ t.privateDict = makePrivateDict(privateAttrs, strings);
+
+ // Needs to come at the end, to encode all custom strings used in the font.
+ t.stringIndex = makeStringIndex(strings);
+
+ var startOffset = t.header.sizeOf() +
+ t.nameIndex.sizeOf() +
+ t.topDictIndex.sizeOf() +
+ t.stringIndex.sizeOf() +
+ t.globalSubrIndex.sizeOf();
+ attrs.charset = startOffset;
+
+ // We use the CFF standard encoding; proper encoding will be handled in cmap.
+ attrs.encoding = 0;
+ attrs.charStrings = attrs.charset + t.charsets.sizeOf();
+ attrs.private[1] = attrs.charStrings + t.charStringsIndex.sizeOf();
+
+ // Recreate the Top DICT INDEX with the correct offsets.
+ topDict = makeTopDict(attrs, strings);
+ t.topDictIndex = makeTopDictIndex(topDict);
+
+ return t;
+}
+
+var cff = { parse: parseCFFTable, make: makeCFFTable };
+
+// The `head` table contains global information about the font.
+
+// Parse the header `head` table
+function parseHeadTable(data, start) {
+ var head = {};
+ var p = new parse.Parser(data, start);
+ head.version = p.parseVersion();
+ head.fontRevision = Math.round(p.parseFixed() * 1000) / 1000;
+ head.checkSumAdjustment = p.parseULong();
+ head.magicNumber = p.parseULong();
+ check.argument(head.magicNumber === 0x5F0F3CF5, 'Font header has wrong magic number.');
+ head.flags = p.parseUShort();
+ head.unitsPerEm = p.parseUShort();
+ head.created = p.parseLongDateTime();
+ head.modified = p.parseLongDateTime();
+ head.xMin = p.parseShort();
+ head.yMin = p.parseShort();
+ head.xMax = p.parseShort();
+ head.yMax = p.parseShort();
+ head.macStyle = p.parseUShort();
+ head.lowestRecPPEM = p.parseUShort();
+ head.fontDirectionHint = p.parseShort();
+ head.indexToLocFormat = p.parseShort();
+ head.glyphDataFormat = p.parseShort();
+ return head;
+}
+
+function makeHeadTable(options) {
+ // Apple Mac timestamp epoch is 01/01/1904 not 01/01/1970
+ var timestamp = Math.round(new Date().getTime() / 1000) + 2082844800;
+ var createdTimestamp = timestamp;
+
+ if (options.createdTimestamp) {
+ createdTimestamp = options.createdTimestamp + 2082844800;
+ }
+
+ return new table.Table('head', [
+ {name: 'version', type: 'FIXED', value: 0x00010000},
+ {name: 'fontRevision', type: 'FIXED', value: 0x00010000},
+ {name: 'checkSumAdjustment', type: 'ULONG', value: 0},
+ {name: 'magicNumber', type: 'ULONG', value: 0x5F0F3CF5},
+ {name: 'flags', type: 'USHORT', value: 0},
+ {name: 'unitsPerEm', type: 'USHORT', value: 1000},
+ {name: 'created', type: 'LONGDATETIME', value: createdTimestamp},
+ {name: 'modified', type: 'LONGDATETIME', value: timestamp},
+ {name: 'xMin', type: 'SHORT', value: 0},
+ {name: 'yMin', type: 'SHORT', value: 0},
+ {name: 'xMax', type: 'SHORT', value: 0},
+ {name: 'yMax', type: 'SHORT', value: 0},
+ {name: 'macStyle', type: 'USHORT', value: 0},
+ {name: 'lowestRecPPEM', type: 'USHORT', value: 0},
+ {name: 'fontDirectionHint', type: 'SHORT', value: 2},
+ {name: 'indexToLocFormat', type: 'SHORT', value: 0},
+ {name: 'glyphDataFormat', type: 'SHORT', value: 0}
+ ], options);
+}
+
+var head = { parse: parseHeadTable, make: makeHeadTable };
+
+// The `hhea` table contains information for horizontal layout.
+
+// Parse the horizontal header `hhea` table
+function parseHheaTable(data, start) {
+ var hhea = {};
+ var p = new parse.Parser(data, start);
+ hhea.version = p.parseVersion();
+ hhea.ascender = p.parseShort();
+ hhea.descender = p.parseShort();
+ hhea.lineGap = p.parseShort();
+ hhea.advanceWidthMax = p.parseUShort();
+ hhea.minLeftSideBearing = p.parseShort();
+ hhea.minRightSideBearing = p.parseShort();
+ hhea.xMaxExtent = p.parseShort();
+ hhea.caretSlopeRise = p.parseShort();
+ hhea.caretSlopeRun = p.parseShort();
+ hhea.caretOffset = p.parseShort();
+ p.relativeOffset += 8;
+ hhea.metricDataFormat = p.parseShort();
+ hhea.numberOfHMetrics = p.parseUShort();
+ return hhea;
+}
+
+function makeHheaTable(options) {
+ return new table.Table('hhea', [
+ {name: 'version', type: 'FIXED', value: 0x00010000},
+ {name: 'ascender', type: 'FWORD', value: 0},
+ {name: 'descender', type: 'FWORD', value: 0},
+ {name: 'lineGap', type: 'FWORD', value: 0},
+ {name: 'advanceWidthMax', type: 'UFWORD', value: 0},
+ {name: 'minLeftSideBearing', type: 'FWORD', value: 0},
+ {name: 'minRightSideBearing', type: 'FWORD', value: 0},
+ {name: 'xMaxExtent', type: 'FWORD', value: 0},
+ {name: 'caretSlopeRise', type: 'SHORT', value: 1},
+ {name: 'caretSlopeRun', type: 'SHORT', value: 0},
+ {name: 'caretOffset', type: 'SHORT', value: 0},
+ {name: 'reserved1', type: 'SHORT', value: 0},
+ {name: 'reserved2', type: 'SHORT', value: 0},
+ {name: 'reserved3', type: 'SHORT', value: 0},
+ {name: 'reserved4', type: 'SHORT', value: 0},
+ {name: 'metricDataFormat', type: 'SHORT', value: 0},
+ {name: 'numberOfHMetrics', type: 'USHORT', value: 0}
+ ], options);
+}
+
+var hhea = { parse: parseHheaTable, make: makeHheaTable };
+
+// The `hmtx` table contains the horizontal metrics for all glyphs.
+
+function parseHmtxTableAll(data, start, numMetrics, numGlyphs, glyphs) {
+ var advanceWidth;
+ var leftSideBearing;
+ var p = new parse.Parser(data, start);
+ for (var i = 0; i < numGlyphs; i += 1) {
+ // If the font is monospaced, only one entry is needed. This last entry applies to all subsequent glyphs.
+ if (i < numMetrics) {
+ advanceWidth = p.parseUShort();
+ leftSideBearing = p.parseShort();
+ }
+
+ var glyph = glyphs.get(i);
+ glyph.advanceWidth = advanceWidth;
+ glyph.leftSideBearing = leftSideBearing;
+ }
+}
+
+function parseHmtxTableOnLowMemory(font, data, start, numMetrics, numGlyphs) {
+ font._hmtxTableData = {};
+
+ var advanceWidth;
+ var leftSideBearing;
+ var p = new parse.Parser(data, start);
+ for (var i = 0; i < numGlyphs; i += 1) {
+ // If the font is monospaced, only one entry is needed. This last entry applies to all subsequent glyphs.
+ if (i < numMetrics) {
+ advanceWidth = p.parseUShort();
+ leftSideBearing = p.parseShort();
+ }
+
+ font._hmtxTableData[i] = {
+ advanceWidth: advanceWidth,
+ leftSideBearing: leftSideBearing
+ };
+ }
+}
+
+// Parse the `hmtx` table, which contains the horizontal metrics for all glyphs.
+// This function augments the glyph array, adding the advanceWidth and leftSideBearing to each glyph.
+function parseHmtxTable(font, data, start, numMetrics, numGlyphs, glyphs, opt) {
+ if (opt.lowMemory)
+ { parseHmtxTableOnLowMemory(font, data, start, numMetrics, numGlyphs); }
+ else
+ { parseHmtxTableAll(data, start, numMetrics, numGlyphs, glyphs); }
+}
+
+function makeHmtxTable(glyphs) {
+ var t = new table.Table('hmtx', []);
+ for (var i = 0; i < glyphs.length; i += 1) {
+ var glyph = glyphs.get(i);
+ var advanceWidth = glyph.advanceWidth || 0;
+ var leftSideBearing = glyph.leftSideBearing || 0;
+ t.fields.push({name: 'advanceWidth_' + i, type: 'USHORT', value: advanceWidth});
+ t.fields.push({name: 'leftSideBearing_' + i, type: 'SHORT', value: leftSideBearing});
+ }
+
+ return t;
+}
+
+var hmtx = { parse: parseHmtxTable, make: makeHmtxTable };
+
+// The `ltag` table stores IETF BCP-47 language tags. It allows supporting
+
+function makeLtagTable(tags) {
+ var result = new table.Table('ltag', [
+ {name: 'version', type: 'ULONG', value: 1},
+ {name: 'flags', type: 'ULONG', value: 0},
+ {name: 'numTags', type: 'ULONG', value: tags.length}
+ ]);
+
+ var stringPool = '';
+ var stringPoolOffset = 12 + tags.length * 4;
+ for (var i = 0; i < tags.length; ++i) {
+ var pos = stringPool.indexOf(tags[i]);
+ if (pos < 0) {
+ pos = stringPool.length;
+ stringPool += tags[i];
+ }
+
+ result.fields.push({name: 'offset ' + i, type: 'USHORT', value: stringPoolOffset + pos});
+ result.fields.push({name: 'length ' + i, type: 'USHORT', value: tags[i].length});
+ }
+
+ result.fields.push({name: 'stringPool', type: 'CHARARRAY', value: stringPool});
+ return result;
+}
+
+function parseLtagTable(data, start) {
+ var p = new parse.Parser(data, start);
+ var tableVersion = p.parseULong();
+ check.argument(tableVersion === 1, 'Unsupported ltag table version.');
+ // The 'ltag' specification does not define any flags; skip the field.
+ p.skip('uLong', 1);
+ var numTags = p.parseULong();
+
+ var tags = [];
+ for (var i = 0; i < numTags; i++) {
+ var tag = '';
+ var offset = start + p.parseUShort();
+ var length = p.parseUShort();
+ for (var j = offset; j < offset + length; ++j) {
+ tag += String.fromCharCode(data.getInt8(j));
+ }
+
+ tags.push(tag);
+ }
+
+ return tags;
+}
+
+var ltag = { make: makeLtagTable, parse: parseLtagTable };
+
+// The `maxp` table establishes the memory requirements for the font.
+
+// Parse the maximum profile `maxp` table.
+function parseMaxpTable(data, start) {
+ var maxp = {};
+ var p = new parse.Parser(data, start);
+ maxp.version = p.parseVersion();
+ maxp.numGlyphs = p.parseUShort();
+ if (maxp.version === 1.0) {
+ maxp.maxPoints = p.parseUShort();
+ maxp.maxContours = p.parseUShort();
+ maxp.maxCompositePoints = p.parseUShort();
+ maxp.maxCompositeContours = p.parseUShort();
+ maxp.maxZones = p.parseUShort();
+ maxp.maxTwilightPoints = p.parseUShort();
+ maxp.maxStorage = p.parseUShort();
+ maxp.maxFunctionDefs = p.parseUShort();
+ maxp.maxInstructionDefs = p.parseUShort();
+ maxp.maxStackElements = p.parseUShort();
+ maxp.maxSizeOfInstructions = p.parseUShort();
+ maxp.maxComponentElements = p.parseUShort();
+ maxp.maxComponentDepth = p.parseUShort();
+ }
+
+ return maxp;
+}
+
+function makeMaxpTable(numGlyphs) {
+ return new table.Table('maxp', [
+ {name: 'version', type: 'FIXED', value: 0x00005000},
+ {name: 'numGlyphs', type: 'USHORT', value: numGlyphs}
+ ]);
+}
+
+var maxp = { parse: parseMaxpTable, make: makeMaxpTable };
+
+// The `name` naming table.
+
+// NameIDs for the name table.
+var nameTableNames = [
+ 'copyright', // 0
+ 'fontFamily', // 1
+ 'fontSubfamily', // 2
+ 'uniqueID', // 3
+ 'fullName', // 4
+ 'version', // 5
+ 'postScriptName', // 6
+ 'trademark', // 7
+ 'manufacturer', // 8
+ 'designer', // 9
+ 'description', // 10
+ 'manufacturerURL', // 11
+ 'designerURL', // 12
+ 'license', // 13
+ 'licenseURL', // 14
+ 'reserved', // 15
+ 'preferredFamily', // 16
+ 'preferredSubfamily', // 17
+ 'compatibleFullName', // 18
+ 'sampleText', // 19
+ 'postScriptFindFontName', // 20
+ 'wwsFamily', // 21
+ 'wwsSubfamily' // 22
+];
+
+var macLanguages = {
+ 0: 'en',
+ 1: 'fr',
+ 2: 'de',
+ 3: 'it',
+ 4: 'nl',
+ 5: 'sv',
+ 6: 'es',
+ 7: 'da',
+ 8: 'pt',
+ 9: 'no',
+ 10: 'he',
+ 11: 'ja',
+ 12: 'ar',
+ 13: 'fi',
+ 14: 'el',
+ 15: 'is',
+ 16: 'mt',
+ 17: 'tr',
+ 18: 'hr',
+ 19: 'zh-Hant',
+ 20: 'ur',
+ 21: 'hi',
+ 22: 'th',
+ 23: 'ko',
+ 24: 'lt',
+ 25: 'pl',
+ 26: 'hu',
+ 27: 'es',
+ 28: 'lv',
+ 29: 'se',
+ 30: 'fo',
+ 31: 'fa',
+ 32: 'ru',
+ 33: 'zh',
+ 34: 'nl-BE',
+ 35: 'ga',
+ 36: 'sq',
+ 37: 'ro',
+ 38: 'cz',
+ 39: 'sk',
+ 40: 'si',
+ 41: 'yi',
+ 42: 'sr',
+ 43: 'mk',
+ 44: 'bg',
+ 45: 'uk',
+ 46: 'be',
+ 47: 'uz',
+ 48: 'kk',
+ 49: 'az-Cyrl',
+ 50: 'az-Arab',
+ 51: 'hy',
+ 52: 'ka',
+ 53: 'mo',
+ 54: 'ky',
+ 55: 'tg',
+ 56: 'tk',
+ 57: 'mn-CN',
+ 58: 'mn',
+ 59: 'ps',
+ 60: 'ks',
+ 61: 'ku',
+ 62: 'sd',
+ 63: 'bo',
+ 64: 'ne',
+ 65: 'sa',
+ 66: 'mr',
+ 67: 'bn',
+ 68: 'as',
+ 69: 'gu',
+ 70: 'pa',
+ 71: 'or',
+ 72: 'ml',
+ 73: 'kn',
+ 74: 'ta',
+ 75: 'te',
+ 76: 'si',
+ 77: 'my',
+ 78: 'km',
+ 79: 'lo',
+ 80: 'vi',
+ 81: 'id',
+ 82: 'tl',
+ 83: 'ms',
+ 84: 'ms-Arab',
+ 85: 'am',
+ 86: 'ti',
+ 87: 'om',
+ 88: 'so',
+ 89: 'sw',
+ 90: 'rw',
+ 91: 'rn',
+ 92: 'ny',
+ 93: 'mg',
+ 94: 'eo',
+ 128: 'cy',
+ 129: 'eu',
+ 130: 'ca',
+ 131: 'la',
+ 132: 'qu',
+ 133: 'gn',
+ 134: 'ay',
+ 135: 'tt',
+ 136: 'ug',
+ 137: 'dz',
+ 138: 'jv',
+ 139: 'su',
+ 140: 'gl',
+ 141: 'af',
+ 142: 'br',
+ 143: 'iu',
+ 144: 'gd',
+ 145: 'gv',
+ 146: 'ga',
+ 147: 'to',
+ 148: 'el-polyton',
+ 149: 'kl',
+ 150: 'az',
+ 151: 'nn'
+};
+
+// MacOS language ID → MacOS script ID
+//
+// Note that the script ID is not sufficient to determine what encoding
+// to use in TrueType files. For some languages, MacOS used a modification
+// of a mainstream script. For example, an Icelandic name would be stored
+// with smRoman in the TrueType naming table, but the actual encoding
+// is a special Icelandic version of the normal Macintosh Roman encoding.
+// As another example, Inuktitut uses an 8-bit encoding for Canadian Aboriginal
+// Syllables but MacOS had run out of available script codes, so this was
+// done as a (pretty radical) "modification" of Ethiopic.
+//
+// http://unicode.org/Public/MAPPINGS/VENDORS/APPLE/Readme.txt
+var macLanguageToScript = {
+ 0: 0, // langEnglish → smRoman
+ 1: 0, // langFrench → smRoman
+ 2: 0, // langGerman → smRoman
+ 3: 0, // langItalian → smRoman
+ 4: 0, // langDutch → smRoman
+ 5: 0, // langSwedish → smRoman
+ 6: 0, // langSpanish → smRoman
+ 7: 0, // langDanish → smRoman
+ 8: 0, // langPortuguese → smRoman
+ 9: 0, // langNorwegian → smRoman
+ 10: 5, // langHebrew → smHebrew
+ 11: 1, // langJapanese → smJapanese
+ 12: 4, // langArabic → smArabic
+ 13: 0, // langFinnish → smRoman
+ 14: 6, // langGreek → smGreek
+ 15: 0, // langIcelandic → smRoman (modified)
+ 16: 0, // langMaltese → smRoman
+ 17: 0, // langTurkish → smRoman (modified)
+ 18: 0, // langCroatian → smRoman (modified)
+ 19: 2, // langTradChinese → smTradChinese
+ 20: 4, // langUrdu → smArabic
+ 21: 9, // langHindi → smDevanagari
+ 22: 21, // langThai → smThai
+ 23: 3, // langKorean → smKorean
+ 24: 29, // langLithuanian → smCentralEuroRoman
+ 25: 29, // langPolish → smCentralEuroRoman
+ 26: 29, // langHungarian → smCentralEuroRoman
+ 27: 29, // langEstonian → smCentralEuroRoman
+ 28: 29, // langLatvian → smCentralEuroRoman
+ 29: 0, // langSami → smRoman
+ 30: 0, // langFaroese → smRoman (modified)
+ 31: 4, // langFarsi → smArabic (modified)
+ 32: 7, // langRussian → smCyrillic
+ 33: 25, // langSimpChinese → smSimpChinese
+ 34: 0, // langFlemish → smRoman
+ 35: 0, // langIrishGaelic → smRoman (modified)
+ 36: 0, // langAlbanian → smRoman
+ 37: 0, // langRomanian → smRoman (modified)
+ 38: 29, // langCzech → smCentralEuroRoman
+ 39: 29, // langSlovak → smCentralEuroRoman
+ 40: 0, // langSlovenian → smRoman (modified)
+ 41: 5, // langYiddish → smHebrew
+ 42: 7, // langSerbian → smCyrillic
+ 43: 7, // langMacedonian → smCyrillic
+ 44: 7, // langBulgarian → smCyrillic
+ 45: 7, // langUkrainian → smCyrillic (modified)
+ 46: 7, // langByelorussian → smCyrillic
+ 47: 7, // langUzbek → smCyrillic
+ 48: 7, // langKazakh → smCyrillic
+ 49: 7, // langAzerbaijani → smCyrillic
+ 50: 4, // langAzerbaijanAr → smArabic
+ 51: 24, // langArmenian → smArmenian
+ 52: 23, // langGeorgian → smGeorgian
+ 53: 7, // langMoldavian → smCyrillic
+ 54: 7, // langKirghiz → smCyrillic
+ 55: 7, // langTajiki → smCyrillic
+ 56: 7, // langTurkmen → smCyrillic
+ 57: 27, // langMongolian → smMongolian
+ 58: 7, // langMongolianCyr → smCyrillic
+ 59: 4, // langPashto → smArabic
+ 60: 4, // langKurdish → smArabic
+ 61: 4, // langKashmiri → smArabic
+ 62: 4, // langSindhi → smArabic
+ 63: 26, // langTibetan → smTibetan
+ 64: 9, // langNepali → smDevanagari
+ 65: 9, // langSanskrit → smDevanagari
+ 66: 9, // langMarathi → smDevanagari
+ 67: 13, // langBengali → smBengali
+ 68: 13, // langAssamese → smBengali
+ 69: 11, // langGujarati → smGujarati
+ 70: 10, // langPunjabi → smGurmukhi
+ 71: 12, // langOriya → smOriya
+ 72: 17, // langMalayalam → smMalayalam
+ 73: 16, // langKannada → smKannada
+ 74: 14, // langTamil → smTamil
+ 75: 15, // langTelugu → smTelugu
+ 76: 18, // langSinhalese → smSinhalese
+ 77: 19, // langBurmese → smBurmese
+ 78: 20, // langKhmer → smKhmer
+ 79: 22, // langLao → smLao
+ 80: 30, // langVietnamese → smVietnamese
+ 81: 0, // langIndonesian → smRoman
+ 82: 0, // langTagalog → smRoman
+ 83: 0, // langMalayRoman → smRoman
+ 84: 4, // langMalayArabic → smArabic
+ 85: 28, // langAmharic → smEthiopic
+ 86: 28, // langTigrinya → smEthiopic
+ 87: 28, // langOromo → smEthiopic
+ 88: 0, // langSomali → smRoman
+ 89: 0, // langSwahili → smRoman
+ 90: 0, // langKinyarwanda → smRoman
+ 91: 0, // langRundi → smRoman
+ 92: 0, // langNyanja → smRoman
+ 93: 0, // langMalagasy → smRoman
+ 94: 0, // langEsperanto → smRoman
+ 128: 0, // langWelsh → smRoman (modified)
+ 129: 0, // langBasque → smRoman
+ 130: 0, // langCatalan → smRoman
+ 131: 0, // langLatin → smRoman
+ 132: 0, // langQuechua → smRoman
+ 133: 0, // langGuarani → smRoman
+ 134: 0, // langAymara → smRoman
+ 135: 7, // langTatar → smCyrillic
+ 136: 4, // langUighur → smArabic
+ 137: 26, // langDzongkha → smTibetan
+ 138: 0, // langJavaneseRom → smRoman
+ 139: 0, // langSundaneseRom → smRoman
+ 140: 0, // langGalician → smRoman
+ 141: 0, // langAfrikaans → smRoman
+ 142: 0, // langBreton → smRoman (modified)
+ 143: 28, // langInuktitut → smEthiopic (modified)
+ 144: 0, // langScottishGaelic → smRoman (modified)
+ 145: 0, // langManxGaelic → smRoman (modified)
+ 146: 0, // langIrishGaelicScript → smRoman (modified)
+ 147: 0, // langTongan → smRoman
+ 148: 6, // langGreekAncient → smRoman
+ 149: 0, // langGreenlandic → smRoman
+ 150: 0, // langAzerbaijanRoman → smRoman
+ 151: 0 // langNynorsk → smRoman
+};
+
+// While Microsoft indicates a region/country for all its language
+// IDs, we omit the region code if it's equal to the "most likely
+// region subtag" according to Unicode CLDR. For scripts, we omit
+// the subtag if it is equal to the Suppress-Script entry in the
+// IANA language subtag registry for IETF BCP 47.
+//
+// For example, Microsoft states that its language code 0x041A is
+// Croatian in Croatia. We transform this to the BCP 47 language code 'hr'
+// and not 'hr-HR' because Croatia is the default country for Croatian,
+// according to Unicode CLDR. As another example, Microsoft states
+// that 0x101A is Croatian (Latin) in Bosnia-Herzegovina. We transform
+// this to 'hr-BA' and not 'hr-Latn-BA' because Latin is the default script
+// for the Croatian language, according to IANA.
+//
+// http://www.unicode.org/cldr/charts/latest/supplemental/likely_subtags.html
+// http://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
+var windowsLanguages = {
+ 0x0436: 'af',
+ 0x041C: 'sq',
+ 0x0484: 'gsw',
+ 0x045E: 'am',
+ 0x1401: 'ar-DZ',
+ 0x3C01: 'ar-BH',
+ 0x0C01: 'ar',
+ 0x0801: 'ar-IQ',
+ 0x2C01: 'ar-JO',
+ 0x3401: 'ar-KW',
+ 0x3001: 'ar-LB',
+ 0x1001: 'ar-LY',
+ 0x1801: 'ary',
+ 0x2001: 'ar-OM',
+ 0x4001: 'ar-QA',
+ 0x0401: 'ar-SA',
+ 0x2801: 'ar-SY',
+ 0x1C01: 'aeb',
+ 0x3801: 'ar-AE',
+ 0x2401: 'ar-YE',
+ 0x042B: 'hy',
+ 0x044D: 'as',
+ 0x082C: 'az-Cyrl',
+ 0x042C: 'az',
+ 0x046D: 'ba',
+ 0x042D: 'eu',
+ 0x0423: 'be',
+ 0x0845: 'bn',
+ 0x0445: 'bn-IN',
+ 0x201A: 'bs-Cyrl',
+ 0x141A: 'bs',
+ 0x047E: 'br',
+ 0x0402: 'bg',
+ 0x0403: 'ca',
+ 0x0C04: 'zh-HK',
+ 0x1404: 'zh-MO',
+ 0x0804: 'zh',
+ 0x1004: 'zh-SG',
+ 0x0404: 'zh-TW',
+ 0x0483: 'co',
+ 0x041A: 'hr',
+ 0x101A: 'hr-BA',
+ 0x0405: 'cs',
+ 0x0406: 'da',
+ 0x048C: 'prs',
+ 0x0465: 'dv',
+ 0x0813: 'nl-BE',
+ 0x0413: 'nl',
+ 0x0C09: 'en-AU',
+ 0x2809: 'en-BZ',
+ 0x1009: 'en-CA',
+ 0x2409: 'en-029',
+ 0x4009: 'en-IN',
+ 0x1809: 'en-IE',
+ 0x2009: 'en-JM',
+ 0x4409: 'en-MY',
+ 0x1409: 'en-NZ',
+ 0x3409: 'en-PH',
+ 0x4809: 'en-SG',
+ 0x1C09: 'en-ZA',
+ 0x2C09: 'en-TT',
+ 0x0809: 'en-GB',
+ 0x0409: 'en',
+ 0x3009: 'en-ZW',
+ 0x0425: 'et',
+ 0x0438: 'fo',
+ 0x0464: 'fil',
+ 0x040B: 'fi',
+ 0x080C: 'fr-BE',
+ 0x0C0C: 'fr-CA',
+ 0x040C: 'fr',
+ 0x140C: 'fr-LU',
+ 0x180C: 'fr-MC',
+ 0x100C: 'fr-CH',
+ 0x0462: 'fy',
+ 0x0456: 'gl',
+ 0x0437: 'ka',
+ 0x0C07: 'de-AT',
+ 0x0407: 'de',
+ 0x1407: 'de-LI',
+ 0x1007: 'de-LU',
+ 0x0807: 'de-CH',
+ 0x0408: 'el',
+ 0x046F: 'kl',
+ 0x0447: 'gu',
+ 0x0468: 'ha',
+ 0x040D: 'he',
+ 0x0439: 'hi',
+ 0x040E: 'hu',
+ 0x040F: 'is',
+ 0x0470: 'ig',
+ 0x0421: 'id',
+ 0x045D: 'iu',
+ 0x085D: 'iu-Latn',
+ 0x083C: 'ga',
+ 0x0434: 'xh',
+ 0x0435: 'zu',
+ 0x0410: 'it',
+ 0x0810: 'it-CH',
+ 0x0411: 'ja',
+ 0x044B: 'kn',
+ 0x043F: 'kk',
+ 0x0453: 'km',
+ 0x0486: 'quc',
+ 0x0487: 'rw',
+ 0x0441: 'sw',
+ 0x0457: 'kok',
+ 0x0412: 'ko',
+ 0x0440: 'ky',
+ 0x0454: 'lo',
+ 0x0426: 'lv',
+ 0x0427: 'lt',
+ 0x082E: 'dsb',
+ 0x046E: 'lb',
+ 0x042F: 'mk',
+ 0x083E: 'ms-BN',
+ 0x043E: 'ms',
+ 0x044C: 'ml',
+ 0x043A: 'mt',
+ 0x0481: 'mi',
+ 0x047A: 'arn',
+ 0x044E: 'mr',
+ 0x047C: 'moh',
+ 0x0450: 'mn',
+ 0x0850: 'mn-CN',
+ 0x0461: 'ne',
+ 0x0414: 'nb',
+ 0x0814: 'nn',
+ 0x0482: 'oc',
+ 0x0448: 'or',
+ 0x0463: 'ps',
+ 0x0415: 'pl',
+ 0x0416: 'pt',
+ 0x0816: 'pt-PT',
+ 0x0446: 'pa',
+ 0x046B: 'qu-BO',
+ 0x086B: 'qu-EC',
+ 0x0C6B: 'qu',
+ 0x0418: 'ro',
+ 0x0417: 'rm',
+ 0x0419: 'ru',
+ 0x243B: 'smn',
+ 0x103B: 'smj-NO',
+ 0x143B: 'smj',
+ 0x0C3B: 'se-FI',
+ 0x043B: 'se',
+ 0x083B: 'se-SE',
+ 0x203B: 'sms',
+ 0x183B: 'sma-NO',
+ 0x1C3B: 'sms',
+ 0x044F: 'sa',
+ 0x1C1A: 'sr-Cyrl-BA',
+ 0x0C1A: 'sr',
+ 0x181A: 'sr-Latn-BA',
+ 0x081A: 'sr-Latn',
+ 0x046C: 'nso',
+ 0x0432: 'tn',
+ 0x045B: 'si',
+ 0x041B: 'sk',
+ 0x0424: 'sl',
+ 0x2C0A: 'es-AR',
+ 0x400A: 'es-BO',
+ 0x340A: 'es-CL',
+ 0x240A: 'es-CO',
+ 0x140A: 'es-CR',
+ 0x1C0A: 'es-DO',
+ 0x300A: 'es-EC',
+ 0x440A: 'es-SV',
+ 0x100A: 'es-GT',
+ 0x480A: 'es-HN',
+ 0x080A: 'es-MX',
+ 0x4C0A: 'es-NI',
+ 0x180A: 'es-PA',
+ 0x3C0A: 'es-PY',
+ 0x280A: 'es-PE',
+ 0x500A: 'es-PR',
+
+ // Microsoft has defined two different language codes for
+ // “Spanish with modern sorting” and “Spanish with traditional
+ // sorting”. This makes sense for collation APIs, and it would be
+ // possible to express this in BCP 47 language tags via Unicode
+ // extensions (eg., es-u-co-trad is Spanish with traditional
+ // sorting). However, for storing names in fonts, the distinction
+ // does not make sense, so we give “es” in both cases.
+ 0x0C0A: 'es',
+ 0x040A: 'es',
+
+ 0x540A: 'es-US',
+ 0x380A: 'es-UY',
+ 0x200A: 'es-VE',
+ 0x081D: 'sv-FI',
+ 0x041D: 'sv',
+ 0x045A: 'syr',
+ 0x0428: 'tg',
+ 0x085F: 'tzm',
+ 0x0449: 'ta',
+ 0x0444: 'tt',
+ 0x044A: 'te',
+ 0x041E: 'th',
+ 0x0451: 'bo',
+ 0x041F: 'tr',
+ 0x0442: 'tk',
+ 0x0480: 'ug',
+ 0x0422: 'uk',
+ 0x042E: 'hsb',
+ 0x0420: 'ur',
+ 0x0843: 'uz-Cyrl',
+ 0x0443: 'uz',
+ 0x042A: 'vi',
+ 0x0452: 'cy',
+ 0x0488: 'wo',
+ 0x0485: 'sah',
+ 0x0478: 'ii',
+ 0x046A: 'yo'
+};
+
+// Returns a IETF BCP 47 language code, for example 'zh-Hant'
+// for 'Chinese in the traditional script'.
+function getLanguageCode(platformID, languageID, ltag) {
+ switch (platformID) {
+ case 0: // Unicode
+ if (languageID === 0xFFFF) {
+ return 'und';
+ } else if (ltag) {
+ return ltag[languageID];
+ }
+
+ break;
+
+ case 1: // Macintosh
+ return macLanguages[languageID];
+
+ case 3: // Windows
+ return windowsLanguages[languageID];
+ }
+
+ return undefined;
+}
+
+var utf16 = 'utf-16';
+
+// MacOS script ID → encoding. This table stores the default case,
+// which can be overridden by macLanguageEncodings.
+var macScriptEncodings = {
+ 0: 'macintosh', // smRoman
+ 1: 'x-mac-japanese', // smJapanese
+ 2: 'x-mac-chinesetrad', // smTradChinese
+ 3: 'x-mac-korean', // smKorean
+ 6: 'x-mac-greek', // smGreek
+ 7: 'x-mac-cyrillic', // smCyrillic
+ 9: 'x-mac-devanagai', // smDevanagari
+ 10: 'x-mac-gurmukhi', // smGurmukhi
+ 11: 'x-mac-gujarati', // smGujarati
+ 12: 'x-mac-oriya', // smOriya
+ 13: 'x-mac-bengali', // smBengali
+ 14: 'x-mac-tamil', // smTamil
+ 15: 'x-mac-telugu', // smTelugu
+ 16: 'x-mac-kannada', // smKannada
+ 17: 'x-mac-malayalam', // smMalayalam
+ 18: 'x-mac-sinhalese', // smSinhalese
+ 19: 'x-mac-burmese', // smBurmese
+ 20: 'x-mac-khmer', // smKhmer
+ 21: 'x-mac-thai', // smThai
+ 22: 'x-mac-lao', // smLao
+ 23: 'x-mac-georgian', // smGeorgian
+ 24: 'x-mac-armenian', // smArmenian
+ 25: 'x-mac-chinesesimp', // smSimpChinese
+ 26: 'x-mac-tibetan', // smTibetan
+ 27: 'x-mac-mongolian', // smMongolian
+ 28: 'x-mac-ethiopic', // smEthiopic
+ 29: 'x-mac-ce', // smCentralEuroRoman
+ 30: 'x-mac-vietnamese', // smVietnamese
+ 31: 'x-mac-extarabic' // smExtArabic
+};
+
+// MacOS language ID → encoding. This table stores the exceptional
+// cases, which override macScriptEncodings. For writing MacOS naming
+// tables, we need to emit a MacOS script ID. Therefore, we cannot
+// merge macScriptEncodings into macLanguageEncodings.
+//
+// http://unicode.org/Public/MAPPINGS/VENDORS/APPLE/Readme.txt
+var macLanguageEncodings = {
+ 15: 'x-mac-icelandic', // langIcelandic
+ 17: 'x-mac-turkish', // langTurkish
+ 18: 'x-mac-croatian', // langCroatian
+ 24: 'x-mac-ce', // langLithuanian
+ 25: 'x-mac-ce', // langPolish
+ 26: 'x-mac-ce', // langHungarian
+ 27: 'x-mac-ce', // langEstonian
+ 28: 'x-mac-ce', // langLatvian
+ 30: 'x-mac-icelandic', // langFaroese
+ 37: 'x-mac-romanian', // langRomanian
+ 38: 'x-mac-ce', // langCzech
+ 39: 'x-mac-ce', // langSlovak
+ 40: 'x-mac-ce', // langSlovenian
+ 143: 'x-mac-inuit', // langInuktitut
+ 146: 'x-mac-gaelic' // langIrishGaelicScript
+};
+
+function getEncoding(platformID, encodingID, languageID) {
+ switch (platformID) {
+ case 0: // Unicode
+ return utf16;
+
+ case 1: // Apple Macintosh
+ return macLanguageEncodings[languageID] || macScriptEncodings[encodingID];
+
+ case 3: // Microsoft Windows
+ if (encodingID === 1 || encodingID === 10) {
+ return utf16;
+ }
+
+ break;
+ }
+
+ return undefined;
+}
+
+// Parse the naming `name` table.
+// FIXME: Format 1 additional fields are not supported yet.
+// ltag is the content of the `ltag' table, such as ['en', 'zh-Hans', 'de-CH-1904'].
+function parseNameTable(data, start, ltag) {
+ var name = {};
+ var p = new parse.Parser(data, start);
+ var format = p.parseUShort();
+ var count = p.parseUShort();
+ var stringOffset = p.offset + p.parseUShort();
+ for (var i = 0; i < count; i++) {
+ var platformID = p.parseUShort();
+ var encodingID = p.parseUShort();
+ var languageID = p.parseUShort();
+ var nameID = p.parseUShort();
+ var property = nameTableNames[nameID] || nameID;
+ var byteLength = p.parseUShort();
+ var offset = p.parseUShort();
+ var language = getLanguageCode(platformID, languageID, ltag);
+ var encoding = getEncoding(platformID, encodingID, languageID);
+ if (encoding !== undefined && language !== undefined) {
+ var text = (void 0);
+ if (encoding === utf16) {
+ text = decode.UTF16(data, stringOffset + offset, byteLength);
+ } else {
+ text = decode.MACSTRING(data, stringOffset + offset, byteLength, encoding);
+ }
+
+ if (text) {
+ var translations = name[property];
+ if (translations === undefined) {
+ translations = name[property] = {};
+ }
+
+ translations[language] = text;
+ }
+ }
+ }
+
+ var langTagCount = 0;
+ if (format === 1) {
+ // FIXME: Also handle Microsoft's 'name' table 1.
+ langTagCount = p.parseUShort();
+ }
+
+ return name;
+}
+
+// {23: 'foo'} → {'foo': 23}
+// ['bar', 'baz'] → {'bar': 0, 'baz': 1}
+function reverseDict(dict) {
+ var result = {};
+ for (var key in dict) {
+ result[dict[key]] = parseInt(key);
+ }
+
+ return result;
+}
+
+function makeNameRecord(platformID, encodingID, languageID, nameID, length, offset) {
+ return new table.Record('NameRecord', [
+ {name: 'platformID', type: 'USHORT', value: platformID},
+ {name: 'encodingID', type: 'USHORT', value: encodingID},
+ {name: 'languageID', type: 'USHORT', value: languageID},
+ {name: 'nameID', type: 'USHORT', value: nameID},
+ {name: 'length', type: 'USHORT', value: length},
+ {name: 'offset', type: 'USHORT', value: offset}
+ ]);
+}
+
+// Finds the position of needle in haystack, or -1 if not there.
+// Like String.indexOf(), but for arrays.
+function findSubArray(needle, haystack) {
+ var needleLength = needle.length;
+ var limit = haystack.length - needleLength + 1;
+
+ loop:
+ for (var pos = 0; pos < limit; pos++) {
+ for (; pos < limit; pos++) {
+ for (var k = 0; k < needleLength; k++) {
+ if (haystack[pos + k] !== needle[k]) {
+ continue loop;
+ }
+ }
+
+ return pos;
+ }
+ }
+
+ return -1;
+}
+
+function addStringToPool(s, pool) {
+ var offset = findSubArray(s, pool);
+ if (offset < 0) {
+ offset = pool.length;
+ var i = 0;
+ var len = s.length;
+ for (; i < len; ++i) {
+ pool.push(s[i]);
+ }
+
+ }
+
+ return offset;
+}
+
+function makeNameTable(names, ltag) {
+ var nameID;
+ var nameIDs = [];
+
+ var namesWithNumericKeys = {};
+ var nameTableIds = reverseDict(nameTableNames);
+ for (var key in names) {
+ var id = nameTableIds[key];
+ if (id === undefined) {
+ id = key;
+ }
+
+ nameID = parseInt(id);
+
+ if (isNaN(nameID)) {
+ throw new Error('Name table entry "' + key + '" does not exist, see nameTableNames for complete list.');
+ }
+
+ namesWithNumericKeys[nameID] = names[key];
+ nameIDs.push(nameID);
+ }
+
+ var macLanguageIds = reverseDict(macLanguages);
+ var windowsLanguageIds = reverseDict(windowsLanguages);
+
+ var nameRecords = [];
+ var stringPool = [];
+
+ for (var i = 0; i < nameIDs.length; i++) {
+ nameID = nameIDs[i];
+ var translations = namesWithNumericKeys[nameID];
+ for (var lang in translations) {
+ var text = translations[lang];
+
+ // For MacOS, we try to emit the name in the form that was introduced
+ // in the initial version of the TrueType spec (in the late 1980s).
+ // However, this can fail for various reasons: the requested BCP 47
+ // language code might not have an old-style Mac equivalent;
+ // we might not have a codec for the needed character encoding;
+ // or the name might contain characters that cannot be expressed
+ // in the old-style Macintosh encoding. In case of failure, we emit
+ // the name in a more modern fashion (Unicode encoding with BCP 47
+ // language tags) that is recognized by MacOS 10.5, released in 2009.
+ // If fonts were only read by operating systems, we could simply
+ // emit all names in the modern form; this would be much easier.
+ // However, there are many applications and libraries that read
+ // 'name' tables directly, and these will usually only recognize
+ // the ancient form (silently skipping the unrecognized names).
+ var macPlatform = 1; // Macintosh
+ var macLanguage = macLanguageIds[lang];
+ var macScript = macLanguageToScript[macLanguage];
+ var macEncoding = getEncoding(macPlatform, macScript, macLanguage);
+ var macName = encode.MACSTRING(text, macEncoding);
+ if (macName === undefined) {
+ macPlatform = 0; // Unicode
+ macLanguage = ltag.indexOf(lang);
+ if (macLanguage < 0) {
+ macLanguage = ltag.length;
+ ltag.push(lang);
+ }
+
+ macScript = 4; // Unicode 2.0 and later
+ macName = encode.UTF16(text);
+ }
+
+ var macNameOffset = addStringToPool(macName, stringPool);
+ nameRecords.push(makeNameRecord(macPlatform, macScript, macLanguage,
+ nameID, macName.length, macNameOffset));
+
+ var winLanguage = windowsLanguageIds[lang];
+ if (winLanguage !== undefined) {
+ var winName = encode.UTF16(text);
+ var winNameOffset = addStringToPool(winName, stringPool);
+ nameRecords.push(makeNameRecord(3, 1, winLanguage,
+ nameID, winName.length, winNameOffset));
+ }
+ }
+ }
+
+ nameRecords.sort(function(a, b) {
+ return ((a.platformID - b.platformID) ||
+ (a.encodingID - b.encodingID) ||
+ (a.languageID - b.languageID) ||
+ (a.nameID - b.nameID));
+ });
+
+ var t = new table.Table('name', [
+ {name: 'format', type: 'USHORT', value: 0},
+ {name: 'count', type: 'USHORT', value: nameRecords.length},
+ {name: 'stringOffset', type: 'USHORT', value: 6 + nameRecords.length * 12}
+ ]);
+
+ for (var r = 0; r < nameRecords.length; r++) {
+ t.fields.push({name: 'record_' + r, type: 'RECORD', value: nameRecords[r]});
+ }
+
+ t.fields.push({name: 'strings', type: 'LITERAL', value: stringPool});
+ return t;
+}
+
+var _name = { parse: parseNameTable, make: makeNameTable };
+
+// The `OS/2` table contains metrics required in OpenType fonts.
+
+var unicodeRanges = [
+ {begin: 0x0000, end: 0x007F}, // Basic Latin
+ {begin: 0x0080, end: 0x00FF}, // Latin-1 Supplement
+ {begin: 0x0100, end: 0x017F}, // Latin Extended-A
+ {begin: 0x0180, end: 0x024F}, // Latin Extended-B
+ {begin: 0x0250, end: 0x02AF}, // IPA Extensions
+ {begin: 0x02B0, end: 0x02FF}, // Spacing Modifier Letters
+ {begin: 0x0300, end: 0x036F}, // Combining Diacritical Marks
+ {begin: 0x0370, end: 0x03FF}, // Greek and Coptic
+ {begin: 0x2C80, end: 0x2CFF}, // Coptic
+ {begin: 0x0400, end: 0x04FF}, // Cyrillic
+ {begin: 0x0530, end: 0x058F}, // Armenian
+ {begin: 0x0590, end: 0x05FF}, // Hebrew
+ {begin: 0xA500, end: 0xA63F}, // Vai
+ {begin: 0x0600, end: 0x06FF}, // Arabic
+ {begin: 0x07C0, end: 0x07FF}, // NKo
+ {begin: 0x0900, end: 0x097F}, // Devanagari
+ {begin: 0x0980, end: 0x09FF}, // Bengali
+ {begin: 0x0A00, end: 0x0A7F}, // Gurmukhi
+ {begin: 0x0A80, end: 0x0AFF}, // Gujarati
+ {begin: 0x0B00, end: 0x0B7F}, // Oriya
+ {begin: 0x0B80, end: 0x0BFF}, // Tamil
+ {begin: 0x0C00, end: 0x0C7F}, // Telugu
+ {begin: 0x0C80, end: 0x0CFF}, // Kannada
+ {begin: 0x0D00, end: 0x0D7F}, // Malayalam
+ {begin: 0x0E00, end: 0x0E7F}, // Thai
+ {begin: 0x0E80, end: 0x0EFF}, // Lao
+ {begin: 0x10A0, end: 0x10FF}, // Georgian
+ {begin: 0x1B00, end: 0x1B7F}, // Balinese
+ {begin: 0x1100, end: 0x11FF}, // Hangul Jamo
+ {begin: 0x1E00, end: 0x1EFF}, // Latin Extended Additional
+ {begin: 0x1F00, end: 0x1FFF}, // Greek Extended
+ {begin: 0x2000, end: 0x206F}, // General Punctuation
+ {begin: 0x2070, end: 0x209F}, // Superscripts And Subscripts
+ {begin: 0x20A0, end: 0x20CF}, // Currency Symbol
+ {begin: 0x20D0, end: 0x20FF}, // Combining Diacritical Marks For Symbols
+ {begin: 0x2100, end: 0x214F}, // Letterlike Symbols
+ {begin: 0x2150, end: 0x218F}, // Number Forms
+ {begin: 0x2190, end: 0x21FF}, // Arrows
+ {begin: 0x2200, end: 0x22FF}, // Mathematical Operators
+ {begin: 0x2300, end: 0x23FF}, // Miscellaneous Technical
+ {begin: 0x2400, end: 0x243F}, // Control Pictures
+ {begin: 0x2440, end: 0x245F}, // Optical Character Recognition
+ {begin: 0x2460, end: 0x24FF}, // Enclosed Alphanumerics
+ {begin: 0x2500, end: 0x257F}, // Box Drawing
+ {begin: 0x2580, end: 0x259F}, // Block Elements
+ {begin: 0x25A0, end: 0x25FF}, // Geometric Shapes
+ {begin: 0x2600, end: 0x26FF}, // Miscellaneous Symbols
+ {begin: 0x2700, end: 0x27BF}, // Dingbats
+ {begin: 0x3000, end: 0x303F}, // CJK Symbols And Punctuation
+ {begin: 0x3040, end: 0x309F}, // Hiragana
+ {begin: 0x30A0, end: 0x30FF}, // Katakana
+ {begin: 0x3100, end: 0x312F}, // Bopomofo
+ {begin: 0x3130, end: 0x318F}, // Hangul Compatibility Jamo
+ {begin: 0xA840, end: 0xA87F}, // Phags-pa
+ {begin: 0x3200, end: 0x32FF}, // Enclosed CJK Letters And Months
+ {begin: 0x3300, end: 0x33FF}, // CJK Compatibility
+ {begin: 0xAC00, end: 0xD7AF}, // Hangul Syllables
+ {begin: 0xD800, end: 0xDFFF}, // Non-Plane 0 *
+ {begin: 0x10900, end: 0x1091F}, // Phoenicia
+ {begin: 0x4E00, end: 0x9FFF}, // CJK Unified Ideographs
+ {begin: 0xE000, end: 0xF8FF}, // Private Use Area (plane 0)
+ {begin: 0x31C0, end: 0x31EF}, // CJK Strokes
+ {begin: 0xFB00, end: 0xFB4F}, // Alphabetic Presentation Forms
+ {begin: 0xFB50, end: 0xFDFF}, // Arabic Presentation Forms-A
+ {begin: 0xFE20, end: 0xFE2F}, // Combining Half Marks
+ {begin: 0xFE10, end: 0xFE1F}, // Vertical Forms
+ {begin: 0xFE50, end: 0xFE6F}, // Small Form Variants
+ {begin: 0xFE70, end: 0xFEFF}, // Arabic Presentation Forms-B
+ {begin: 0xFF00, end: 0xFFEF}, // Halfwidth And Fullwidth Forms
+ {begin: 0xFFF0, end: 0xFFFF}, // Specials
+ {begin: 0x0F00, end: 0x0FFF}, // Tibetan
+ {begin: 0x0700, end: 0x074F}, // Syriac
+ {begin: 0x0780, end: 0x07BF}, // Thaana
+ {begin: 0x0D80, end: 0x0DFF}, // Sinhala
+ {begin: 0x1000, end: 0x109F}, // Myanmar
+ {begin: 0x1200, end: 0x137F}, // Ethiopic
+ {begin: 0x13A0, end: 0x13FF}, // Cherokee
+ {begin: 0x1400, end: 0x167F}, // Unified Canadian Aboriginal Syllabics
+ {begin: 0x1680, end: 0x169F}, // Ogham
+ {begin: 0x16A0, end: 0x16FF}, // Runic
+ {begin: 0x1780, end: 0x17FF}, // Khmer
+ {begin: 0x1800, end: 0x18AF}, // Mongolian
+ {begin: 0x2800, end: 0x28FF}, // Braille Patterns
+ {begin: 0xA000, end: 0xA48F}, // Yi Syllables
+ {begin: 0x1700, end: 0x171F}, // Tagalog
+ {begin: 0x10300, end: 0x1032F}, // Old Italic
+ {begin: 0x10330, end: 0x1034F}, // Gothic
+ {begin: 0x10400, end: 0x1044F}, // Deseret
+ {begin: 0x1D000, end: 0x1D0FF}, // Byzantine Musical Symbols
+ {begin: 0x1D400, end: 0x1D7FF}, // Mathematical Alphanumeric Symbols
+ {begin: 0xFF000, end: 0xFFFFD}, // Private Use (plane 15)
+ {begin: 0xFE00, end: 0xFE0F}, // Variation Selectors
+ {begin: 0xE0000, end: 0xE007F}, // Tags
+ {begin: 0x1900, end: 0x194F}, // Limbu
+ {begin: 0x1950, end: 0x197F}, // Tai Le
+ {begin: 0x1980, end: 0x19DF}, // New Tai Lue
+ {begin: 0x1A00, end: 0x1A1F}, // Buginese
+ {begin: 0x2C00, end: 0x2C5F}, // Glagolitic
+ {begin: 0x2D30, end: 0x2D7F}, // Tifinagh
+ {begin: 0x4DC0, end: 0x4DFF}, // Yijing Hexagram Symbols
+ {begin: 0xA800, end: 0xA82F}, // Syloti Nagri
+ {begin: 0x10000, end: 0x1007F}, // Linear B Syllabary
+ {begin: 0x10140, end: 0x1018F}, // Ancient Greek Numbers
+ {begin: 0x10380, end: 0x1039F}, // Ugaritic
+ {begin: 0x103A0, end: 0x103DF}, // Old Persian
+ {begin: 0x10450, end: 0x1047F}, // Shavian
+ {begin: 0x10480, end: 0x104AF}, // Osmanya
+ {begin: 0x10800, end: 0x1083F}, // Cypriot Syllabary
+ {begin: 0x10A00, end: 0x10A5F}, // Kharoshthi
+ {begin: 0x1D300, end: 0x1D35F}, // Tai Xuan Jing Symbols
+ {begin: 0x12000, end: 0x123FF}, // Cuneiform
+ {begin: 0x1D360, end: 0x1D37F}, // Counting Rod Numerals
+ {begin: 0x1B80, end: 0x1BBF}, // Sundanese
+ {begin: 0x1C00, end: 0x1C4F}, // Lepcha
+ {begin: 0x1C50, end: 0x1C7F}, // Ol Chiki
+ {begin: 0xA880, end: 0xA8DF}, // Saurashtra
+ {begin: 0xA900, end: 0xA92F}, // Kayah Li
+ {begin: 0xA930, end: 0xA95F}, // Rejang
+ {begin: 0xAA00, end: 0xAA5F}, // Cham
+ {begin: 0x10190, end: 0x101CF}, // Ancient Symbols
+ {begin: 0x101D0, end: 0x101FF}, // Phaistos Disc
+ {begin: 0x102A0, end: 0x102DF}, // Carian
+ {begin: 0x1F030, end: 0x1F09F} // Domino Tiles
+];
+
+function getUnicodeRange(unicode) {
+ for (var i = 0; i < unicodeRanges.length; i += 1) {
+ var range = unicodeRanges[i];
+ if (unicode >= range.begin && unicode < range.end) {
+ return i;
+ }
+ }
+
+ return -1;
+}
+
+// Parse the OS/2 and Windows metrics `OS/2` table
+function parseOS2Table(data, start) {
+ var os2 = {};
+ var p = new parse.Parser(data, start);
+ os2.version = p.parseUShort();
+ os2.xAvgCharWidth = p.parseShort();
+ os2.usWeightClass = p.parseUShort();
+ os2.usWidthClass = p.parseUShort();
+ os2.fsType = p.parseUShort();
+ os2.ySubscriptXSize = p.parseShort();
+ os2.ySubscriptYSize = p.parseShort();
+ os2.ySubscriptXOffset = p.parseShort();
+ os2.ySubscriptYOffset = p.parseShort();
+ os2.ySuperscriptXSize = p.parseShort();
+ os2.ySuperscriptYSize = p.parseShort();
+ os2.ySuperscriptXOffset = p.parseShort();
+ os2.ySuperscriptYOffset = p.parseShort();
+ os2.yStrikeoutSize = p.parseShort();
+ os2.yStrikeoutPosition = p.parseShort();
+ os2.sFamilyClass = p.parseShort();
+ os2.panose = [];
+ for (var i = 0; i < 10; i++) {
+ os2.panose[i] = p.parseByte();
+ }
+
+ os2.ulUnicodeRange1 = p.parseULong();
+ os2.ulUnicodeRange2 = p.parseULong();
+ os2.ulUnicodeRange3 = p.parseULong();
+ os2.ulUnicodeRange4 = p.parseULong();
+ os2.achVendID = String.fromCharCode(p.parseByte(), p.parseByte(), p.parseByte(), p.parseByte());
+ os2.fsSelection = p.parseUShort();
+ os2.usFirstCharIndex = p.parseUShort();
+ os2.usLastCharIndex = p.parseUShort();
+ os2.sTypoAscender = p.parseShort();
+ os2.sTypoDescender = p.parseShort();
+ os2.sTypoLineGap = p.parseShort();
+ os2.usWinAscent = p.parseUShort();
+ os2.usWinDescent = p.parseUShort();
+ if (os2.version >= 1) {
+ os2.ulCodePageRange1 = p.parseULong();
+ os2.ulCodePageRange2 = p.parseULong();
+ }
+
+ if (os2.version >= 2) {
+ os2.sxHeight = p.parseShort();
+ os2.sCapHeight = p.parseShort();
+ os2.usDefaultChar = p.parseUShort();
+ os2.usBreakChar = p.parseUShort();
+ os2.usMaxContent = p.parseUShort();
+ }
+
+ return os2;
+}
+
+function makeOS2Table(options) {
+ return new table.Table('OS/2', [
+ {name: 'version', type: 'USHORT', value: 0x0003},
+ {name: 'xAvgCharWidth', type: 'SHORT', value: 0},
+ {name: 'usWeightClass', type: 'USHORT', value: 0},
+ {name: 'usWidthClass', type: 'USHORT', value: 0},
+ {name: 'fsType', type: 'USHORT', value: 0},
+ {name: 'ySubscriptXSize', type: 'SHORT', value: 650},
+ {name: 'ySubscriptYSize', type: 'SHORT', value: 699},
+ {name: 'ySubscriptXOffset', type: 'SHORT', value: 0},
+ {name: 'ySubscriptYOffset', type: 'SHORT', value: 140},
+ {name: 'ySuperscriptXSize', type: 'SHORT', value: 650},
+ {name: 'ySuperscriptYSize', type: 'SHORT', value: 699},
+ {name: 'ySuperscriptXOffset', type: 'SHORT', value: 0},
+ {name: 'ySuperscriptYOffset', type: 'SHORT', value: 479},
+ {name: 'yStrikeoutSize', type: 'SHORT', value: 49},
+ {name: 'yStrikeoutPosition', type: 'SHORT', value: 258},
+ {name: 'sFamilyClass', type: 'SHORT', value: 0},
+ {name: 'bFamilyType', type: 'BYTE', value: 0},
+ {name: 'bSerifStyle', type: 'BYTE', value: 0},
+ {name: 'bWeight', type: 'BYTE', value: 0},
+ {name: 'bProportion', type: 'BYTE', value: 0},
+ {name: 'bContrast', type: 'BYTE', value: 0},
+ {name: 'bStrokeVariation', type: 'BYTE', value: 0},
+ {name: 'bArmStyle', type: 'BYTE', value: 0},
+ {name: 'bLetterform', type: 'BYTE', value: 0},
+ {name: 'bMidline', type: 'BYTE', value: 0},
+ {name: 'bXHeight', type: 'BYTE', value: 0},
+ {name: 'ulUnicodeRange1', type: 'ULONG', value: 0},
+ {name: 'ulUnicodeRange2', type: 'ULONG', value: 0},
+ {name: 'ulUnicodeRange3', type: 'ULONG', value: 0},
+ {name: 'ulUnicodeRange4', type: 'ULONG', value: 0},
+ {name: 'achVendID', type: 'CHARARRAY', value: 'XXXX'},
+ {name: 'fsSelection', type: 'USHORT', value: 0},
+ {name: 'usFirstCharIndex', type: 'USHORT', value: 0},
+ {name: 'usLastCharIndex', type: 'USHORT', value: 0},
+ {name: 'sTypoAscender', type: 'SHORT', value: 0},
+ {name: 'sTypoDescender', type: 'SHORT', value: 0},
+ {name: 'sTypoLineGap', type: 'SHORT', value: 0},
+ {name: 'usWinAscent', type: 'USHORT', value: 0},
+ {name: 'usWinDescent', type: 'USHORT', value: 0},
+ {name: 'ulCodePageRange1', type: 'ULONG', value: 0},
+ {name: 'ulCodePageRange2', type: 'ULONG', value: 0},
+ {name: 'sxHeight', type: 'SHORT', value: 0},
+ {name: 'sCapHeight', type: 'SHORT', value: 0},
+ {name: 'usDefaultChar', type: 'USHORT', value: 0},
+ {name: 'usBreakChar', type: 'USHORT', value: 0},
+ {name: 'usMaxContext', type: 'USHORT', value: 0}
+ ], options);
+}
+
+var os2 = { parse: parseOS2Table, make: makeOS2Table, unicodeRanges: unicodeRanges, getUnicodeRange: getUnicodeRange };
+
+// The `post` table stores additional PostScript information, such as glyph names.
+
+// Parse the PostScript `post` table
+function parsePostTable(data, start) {
+ var post = {};
+ var p = new parse.Parser(data, start);
+ post.version = p.parseVersion();
+ post.italicAngle = p.parseFixed();
+ post.underlinePosition = p.parseShort();
+ post.underlineThickness = p.parseShort();
+ post.isFixedPitch = p.parseULong();
+ post.minMemType42 = p.parseULong();
+ post.maxMemType42 = p.parseULong();
+ post.minMemType1 = p.parseULong();
+ post.maxMemType1 = p.parseULong();
+ switch (post.version) {
+ case 1:
+ post.names = standardNames.slice();
+ break;
+ case 2:
+ post.numberOfGlyphs = p.parseUShort();
+ post.glyphNameIndex = new Array(post.numberOfGlyphs);
+ for (var i = 0; i < post.numberOfGlyphs; i++) {
+ post.glyphNameIndex[i] = p.parseUShort();
+ }
+
+ post.names = [];
+ for (var i$1 = 0; i$1 < post.numberOfGlyphs; i$1++) {
+ if (post.glyphNameIndex[i$1] >= standardNames.length) {
+ var nameLength = p.parseChar();
+ post.names.push(p.parseString(nameLength));
+ }
+ }
+
+ break;
+ case 2.5:
+ post.numberOfGlyphs = p.parseUShort();
+ post.offset = new Array(post.numberOfGlyphs);
+ for (var i$2 = 0; i$2 < post.numberOfGlyphs; i$2++) {
+ post.offset[i$2] = p.parseChar();
+ }
+
+ break;
+ }
+ return post;
+}
+
+function makePostTable() {
+ return new table.Table('post', [
+ {name: 'version', type: 'FIXED', value: 0x00030000},
+ {name: 'italicAngle', type: 'FIXED', value: 0},
+ {name: 'underlinePosition', type: 'FWORD', value: 0},
+ {name: 'underlineThickness', type: 'FWORD', value: 0},
+ {name: 'isFixedPitch', type: 'ULONG', value: 0},
+ {name: 'minMemType42', type: 'ULONG', value: 0},
+ {name: 'maxMemType42', type: 'ULONG', value: 0},
+ {name: 'minMemType1', type: 'ULONG', value: 0},
+ {name: 'maxMemType1', type: 'ULONG', value: 0}
+ ]);
+}
+
+var post = { parse: parsePostTable, make: makePostTable };
+
+// The `GSUB` table contains ligatures, among other things.
+
+var subtableParsers = new Array(9); // subtableParsers[0] is unused
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#SS
+subtableParsers[1] = function parseLookup1() {
+ var start = this.offset + this.relativeOffset;
+ var substFormat = this.parseUShort();
+ if (substFormat === 1) {
+ return {
+ substFormat: 1,
+ coverage: this.parsePointer(Parser.coverage),
+ deltaGlyphId: this.parseUShort()
+ };
+ } else if (substFormat === 2) {
+ return {
+ substFormat: 2,
+ coverage: this.parsePointer(Parser.coverage),
+ substitute: this.parseOffset16List()
+ };
+ }
+ check.assert(false, '0x' + start.toString(16) + ': lookup type 1 format must be 1 or 2.');
+};
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#MS
+subtableParsers[2] = function parseLookup2() {
+ var substFormat = this.parseUShort();
+ check.argument(substFormat === 1, 'GSUB Multiple Substitution Subtable identifier-format must be 1');
+ return {
+ substFormat: substFormat,
+ coverage: this.parsePointer(Parser.coverage),
+ sequences: this.parseListOfLists()
+ };
+};
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#AS
+subtableParsers[3] = function parseLookup3() {
+ var substFormat = this.parseUShort();
+ check.argument(substFormat === 1, 'GSUB Alternate Substitution Subtable identifier-format must be 1');
+ return {
+ substFormat: substFormat,
+ coverage: this.parsePointer(Parser.coverage),
+ alternateSets: this.parseListOfLists()
+ };
+};
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#LS
+subtableParsers[4] = function parseLookup4() {
+ var substFormat = this.parseUShort();
+ check.argument(substFormat === 1, 'GSUB ligature table identifier-format must be 1');
+ return {
+ substFormat: substFormat,
+ coverage: this.parsePointer(Parser.coverage),
+ ligatureSets: this.parseListOfLists(function() {
+ return {
+ ligGlyph: this.parseUShort(),
+ components: this.parseUShortList(this.parseUShort() - 1)
+ };
+ })
+ };
+};
+
+var lookupRecordDesc = {
+ sequenceIndex: Parser.uShort,
+ lookupListIndex: Parser.uShort
+};
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#CSF
+subtableParsers[5] = function parseLookup5() {
+ var start = this.offset + this.relativeOffset;
+ var substFormat = this.parseUShort();
+
+ if (substFormat === 1) {
+ return {
+ substFormat: substFormat,
+ coverage: this.parsePointer(Parser.coverage),
+ ruleSets: this.parseListOfLists(function() {
+ var glyphCount = this.parseUShort();
+ var substCount = this.parseUShort();
+ return {
+ input: this.parseUShortList(glyphCount - 1),
+ lookupRecords: this.parseRecordList(substCount, lookupRecordDesc)
+ };
+ })
+ };
+ } else if (substFormat === 2) {
+ return {
+ substFormat: substFormat,
+ coverage: this.parsePointer(Parser.coverage),
+ classDef: this.parsePointer(Parser.classDef),
+ classSets: this.parseListOfLists(function() {
+ var glyphCount = this.parseUShort();
+ var substCount = this.parseUShort();
+ return {
+ classes: this.parseUShortList(glyphCount - 1),
+ lookupRecords: this.parseRecordList(substCount, lookupRecordDesc)
+ };
+ })
+ };
+ } else if (substFormat === 3) {
+ var glyphCount = this.parseUShort();
+ var substCount = this.parseUShort();
+ return {
+ substFormat: substFormat,
+ coverages: this.parseList(glyphCount, Parser.pointer(Parser.coverage)),
+ lookupRecords: this.parseRecordList(substCount, lookupRecordDesc)
+ };
+ }
+ check.assert(false, '0x' + start.toString(16) + ': lookup type 5 format must be 1, 2 or 3.');
+};
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#CC
+subtableParsers[6] = function parseLookup6() {
+ var start = this.offset + this.relativeOffset;
+ var substFormat = this.parseUShort();
+ if (substFormat === 1) {
+ return {
+ substFormat: 1,
+ coverage: this.parsePointer(Parser.coverage),
+ chainRuleSets: this.parseListOfLists(function() {
+ return {
+ backtrack: this.parseUShortList(),
+ input: this.parseUShortList(this.parseShort() - 1),
+ lookahead: this.parseUShortList(),
+ lookupRecords: this.parseRecordList(lookupRecordDesc)
+ };
+ })
+ };
+ } else if (substFormat === 2) {
+ return {
+ substFormat: 2,
+ coverage: this.parsePointer(Parser.coverage),
+ backtrackClassDef: this.parsePointer(Parser.classDef),
+ inputClassDef: this.parsePointer(Parser.classDef),
+ lookaheadClassDef: this.parsePointer(Parser.classDef),
+ chainClassSet: this.parseListOfLists(function() {
+ return {
+ backtrack: this.parseUShortList(),
+ input: this.parseUShortList(this.parseShort() - 1),
+ lookahead: this.parseUShortList(),
+ lookupRecords: this.parseRecordList(lookupRecordDesc)
+ };
+ })
+ };
+ } else if (substFormat === 3) {
+ return {
+ substFormat: 3,
+ backtrackCoverage: this.parseList(Parser.pointer(Parser.coverage)),
+ inputCoverage: this.parseList(Parser.pointer(Parser.coverage)),
+ lookaheadCoverage: this.parseList(Parser.pointer(Parser.coverage)),
+ lookupRecords: this.parseRecordList(lookupRecordDesc)
+ };
+ }
+ check.assert(false, '0x' + start.toString(16) + ': lookup type 6 format must be 1, 2 or 3.');
+};
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#ES
+subtableParsers[7] = function parseLookup7() {
+ // Extension Substitution subtable
+ var substFormat = this.parseUShort();
+ check.argument(substFormat === 1, 'GSUB Extension Substitution subtable identifier-format must be 1');
+ var extensionLookupType = this.parseUShort();
+ var extensionParser = new Parser(this.data, this.offset + this.parseULong());
+ return {
+ substFormat: 1,
+ lookupType: extensionLookupType,
+ extension: subtableParsers[extensionLookupType].call(extensionParser)
+ };
+};
+
+// https://www.microsoft.com/typography/OTSPEC/GSUB.htm#RCCS
+subtableParsers[8] = function parseLookup8() {
+ var substFormat = this.parseUShort();
+ check.argument(substFormat === 1, 'GSUB Reverse Chaining Contextual Single Substitution Subtable identifier-format must be 1');
+ return {
+ substFormat: substFormat,
+ coverage: this.parsePointer(Parser.coverage),
+ backtrackCoverage: this.parseList(Parser.pointer(Parser.coverage)),
+ lookaheadCoverage: this.parseList(Parser.pointer(Parser.coverage)),
+ substitutes: this.parseUShortList()
+ };
+};
+
+// https://www.microsoft.com/typography/OTSPEC/gsub.htm
+function parseGsubTable(data, start) {
+ start = start || 0;
+ var p = new Parser(data, start);
+ var tableVersion = p.parseVersion(1);
+ check.argument(tableVersion === 1 || tableVersion === 1.1, 'Unsupported GSUB table version.');
+ if (tableVersion === 1) {
+ return {
+ version: tableVersion,
+ scripts: p.parseScriptList(),
+ features: p.parseFeatureList(),
+ lookups: p.parseLookupList(subtableParsers)
+ };
+ } else {
+ return {
+ version: tableVersion,
+ scripts: p.parseScriptList(),
+ features: p.parseFeatureList(),
+ lookups: p.parseLookupList(subtableParsers),
+ variations: p.parseFeatureVariationsList()
+ };
+ }
+
+}
+
+// GSUB Writing //////////////////////////////////////////////
+var subtableMakers = new Array(9);
+
+subtableMakers[1] = function makeLookup1(subtable) {
+ if (subtable.substFormat === 1) {
+ return new table.Table('substitutionTable', [
+ {name: 'substFormat', type: 'USHORT', value: 1},
+ {name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage)},
+ {name: 'deltaGlyphID', type: 'USHORT', value: subtable.deltaGlyphId}
+ ]);
+ } else {
+ return new table.Table('substitutionTable', [
+ {name: 'substFormat', type: 'USHORT', value: 2},
+ {name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage)}
+ ].concat(table.ushortList('substitute', subtable.substitute)));
+ }
+};
+
+subtableMakers[2] = function makeLookup2(subtable) {
+ check.assert(subtable.substFormat === 1, 'Lookup type 2 substFormat must be 1.');
+ return new table.Table('substitutionTable', [
+ {name: 'substFormat', type: 'USHORT', value: 1},
+ {name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage)}
+ ].concat(table.tableList('seqSet', subtable.sequences, function(sequenceSet) {
+ return new table.Table('sequenceSetTable', table.ushortList('sequence', sequenceSet));
+ })));
+};
+
+subtableMakers[3] = function makeLookup3(subtable) {
+ check.assert(subtable.substFormat === 1, 'Lookup type 3 substFormat must be 1.');
+ return new table.Table('substitutionTable', [
+ {name: 'substFormat', type: 'USHORT', value: 1},
+ {name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage)}
+ ].concat(table.tableList('altSet', subtable.alternateSets, function(alternateSet) {
+ return new table.Table('alternateSetTable', table.ushortList('alternate', alternateSet));
+ })));
+};
+
+subtableMakers[4] = function makeLookup4(subtable) {
+ check.assert(subtable.substFormat === 1, 'Lookup type 4 substFormat must be 1.');
+ return new table.Table('substitutionTable', [
+ {name: 'substFormat', type: 'USHORT', value: 1},
+ {name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage)}
+ ].concat(table.tableList('ligSet', subtable.ligatureSets, function(ligatureSet) {
+ return new table.Table('ligatureSetTable', table.tableList('ligature', ligatureSet, function(ligature) {
+ return new table.Table('ligatureTable',
+ [{name: 'ligGlyph', type: 'USHORT', value: ligature.ligGlyph}]
+ .concat(table.ushortList('component', ligature.components, ligature.components.length + 1))
+ );
+ }));
+ })));
+};
+
+subtableMakers[6] = function makeLookup6(subtable) {
+ if (subtable.substFormat === 1) {
+ var returnTable = new table.Table('chainContextTable', [
+ {name: 'substFormat', type: 'USHORT', value: subtable.substFormat},
+ {name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage)}
+ ].concat(table.tableList('chainRuleSet', subtable.chainRuleSets, function(chainRuleSet) {
+ return new table.Table('chainRuleSetTable', table.tableList('chainRule', chainRuleSet, function(chainRule) {
+ var tableData = table.ushortList('backtrackGlyph', chainRule.backtrack, chainRule.backtrack.length)
+ .concat(table.ushortList('inputGlyph', chainRule.input, chainRule.input.length + 1))
+ .concat(table.ushortList('lookaheadGlyph', chainRule.lookahead, chainRule.lookahead.length))
+ .concat(table.ushortList('substitution', [], chainRule.lookupRecords.length));
+
+ chainRule.lookupRecords.forEach(function (record, i) {
+ tableData = tableData
+ .concat({name: 'sequenceIndex' + i, type: 'USHORT', value: record.sequenceIndex})
+ .concat({name: 'lookupListIndex' + i, type: 'USHORT', value: record.lookupListIndex});
+ });
+ return new table.Table('chainRuleTable', tableData);
+ }));
+ })));
+ return returnTable;
+ } else if (subtable.substFormat === 2) {
+ check.assert(false, 'lookup type 6 format 2 is not yet supported.');
+ } else if (subtable.substFormat === 3) {
+ var tableData = [
+ {name: 'substFormat', type: 'USHORT', value: subtable.substFormat} ];
+
+ tableData.push({name: 'backtrackGlyphCount', type: 'USHORT', value: subtable.backtrackCoverage.length});
+ subtable.backtrackCoverage.forEach(function (coverage, i) {
+ tableData.push({name: 'backtrackCoverage' + i, type: 'TABLE', value: new table.Coverage(coverage)});
+ });
+ tableData.push({name: 'inputGlyphCount', type: 'USHORT', value: subtable.inputCoverage.length});
+ subtable.inputCoverage.forEach(function (coverage, i) {
+ tableData.push({name: 'inputCoverage' + i, type: 'TABLE', value: new table.Coverage(coverage)});
+ });
+ tableData.push({name: 'lookaheadGlyphCount', type: 'USHORT', value: subtable.lookaheadCoverage.length});
+ subtable.lookaheadCoverage.forEach(function (coverage, i) {
+ tableData.push({name: 'lookaheadCoverage' + i, type: 'TABLE', value: new table.Coverage(coverage)});
+ });
+
+ tableData.push({name: 'substitutionCount', type: 'USHORT', value: subtable.lookupRecords.length});
+ subtable.lookupRecords.forEach(function (record, i) {
+ tableData = tableData
+ .concat({name: 'sequenceIndex' + i, type: 'USHORT', value: record.sequenceIndex})
+ .concat({name: 'lookupListIndex' + i, type: 'USHORT', value: record.lookupListIndex});
+ });
+
+ var returnTable$1 = new table.Table('chainContextTable', tableData);
+
+ return returnTable$1;
+ }
+
+ check.assert(false, 'lookup type 6 format must be 1, 2 or 3.');
+};
+
+function makeGsubTable(gsub) {
+ return new table.Table('GSUB', [
+ {name: 'version', type: 'ULONG', value: 0x10000},
+ {name: 'scripts', type: 'TABLE', value: new table.ScriptList(gsub.scripts)},
+ {name: 'features', type: 'TABLE', value: new table.FeatureList(gsub.features)},
+ {name: 'lookups', type: 'TABLE', value: new table.LookupList(gsub.lookups, subtableMakers)}
+ ]);
+}
+
+var gsub = { parse: parseGsubTable, make: makeGsubTable };
+
+// The `GPOS` table contains kerning pairs, among other things.
+
+// Parse the metadata `meta` table.
+// https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6meta.html
+function parseMetaTable(data, start) {
+ var p = new parse.Parser(data, start);
+ var tableVersion = p.parseULong();
+ check.argument(tableVersion === 1, 'Unsupported META table version.');
+ p.parseULong(); // flags - currently unused and set to 0
+ p.parseULong(); // tableOffset
+ var numDataMaps = p.parseULong();
+
+ var tags = {};
+ for (var i = 0; i < numDataMaps; i++) {
+ var tag = p.parseTag();
+ var dataOffset = p.parseULong();
+ var dataLength = p.parseULong();
+ var text = decode.UTF8(data, start + dataOffset, dataLength);
+
+ tags[tag] = text;
+ }
+ return tags;
+}
+
+function makeMetaTable(tags) {
+ var numTags = Object.keys(tags).length;
+ var stringPool = '';
+ var stringPoolOffset = 16 + numTags * 12;
+
+ var result = new table.Table('meta', [
+ {name: 'version', type: 'ULONG', value: 1},
+ {name: 'flags', type: 'ULONG', value: 0},
+ {name: 'offset', type: 'ULONG', value: stringPoolOffset},
+ {name: 'numTags', type: 'ULONG', value: numTags}
+ ]);
+
+ for (var tag in tags) {
+ var pos = stringPool.length;
+ stringPool += tags[tag];
+
+ result.fields.push({name: 'tag ' + tag, type: 'TAG', value: tag});
+ result.fields.push({name: 'offset ' + tag, type: 'ULONG', value: stringPoolOffset + pos});
+ result.fields.push({name: 'length ' + tag, type: 'ULONG', value: tags[tag].length});
+ }
+
+ result.fields.push({name: 'stringPool', type: 'CHARARRAY', value: stringPool});
+
+ return result;
+}
+
+var meta = { parse: parseMetaTable, make: makeMetaTable };
+
+// The `sfnt` wrapper provides organization for the tables in the font.
+
+function log2(v) {
+ return Math.log(v) / Math.log(2) | 0;
+}
+
+function computeCheckSum(bytes) {
+ while (bytes.length % 4 !== 0) {
+ bytes.push(0);
+ }
+
+ var sum = 0;
+ for (var i = 0; i < bytes.length; i += 4) {
+ sum += (bytes[i] << 24) +
+ (bytes[i + 1] << 16) +
+ (bytes[i + 2] << 8) +
+ (bytes[i + 3]);
+ }
+
+ sum %= Math.pow(2, 32);
+ return sum;
+}
+
+function makeTableRecord(tag, checkSum, offset, length) {
+ return new table.Record('Table Record', [
+ {name: 'tag', type: 'TAG', value: tag !== undefined ? tag : ''},
+ {name: 'checkSum', type: 'ULONG', value: checkSum !== undefined ? checkSum : 0},
+ {name: 'offset', type: 'ULONG', value: offset !== undefined ? offset : 0},
+ {name: 'length', type: 'ULONG', value: length !== undefined ? length : 0}
+ ]);
+}
+
+function makeSfntTable(tables) {
+ var sfnt = new table.Table('sfnt', [
+ {name: 'version', type: 'TAG', value: 'OTTO'},
+ {name: 'numTables', type: 'USHORT', value: 0},
+ {name: 'searchRange', type: 'USHORT', value: 0},
+ {name: 'entrySelector', type: 'USHORT', value: 0},
+ {name: 'rangeShift', type: 'USHORT', value: 0}
+ ]);
+ sfnt.tables = tables;
+ sfnt.numTables = tables.length;
+ var highestPowerOf2 = Math.pow(2, log2(sfnt.numTables));
+ sfnt.searchRange = 16 * highestPowerOf2;
+ sfnt.entrySelector = log2(highestPowerOf2);
+ sfnt.rangeShift = sfnt.numTables * 16 - sfnt.searchRange;
+
+ var recordFields = [];
+ var tableFields = [];
+
+ var offset = sfnt.sizeOf() + (makeTableRecord().sizeOf() * sfnt.numTables);
+ while (offset % 4 !== 0) {
+ offset += 1;
+ tableFields.push({name: 'padding', type: 'BYTE', value: 0});
+ }
+
+ for (var i = 0; i < tables.length; i += 1) {
+ var t = tables[i];
+ check.argument(t.tableName.length === 4, 'Table name' + t.tableName + ' is invalid.');
+ var tableLength = t.sizeOf();
+ var tableRecord = makeTableRecord(t.tableName, computeCheckSum(t.encode()), offset, tableLength);
+ recordFields.push({name: tableRecord.tag + ' Table Record', type: 'RECORD', value: tableRecord});
+ tableFields.push({name: t.tableName + ' table', type: 'RECORD', value: t});
+ offset += tableLength;
+ check.argument(!isNaN(offset), 'Something went wrong calculating the offset.');
+ while (offset % 4 !== 0) {
+ offset += 1;
+ tableFields.push({name: 'padding', type: 'BYTE', value: 0});
+ }
+ }
+
+ // Table records need to be sorted alphabetically.
+ recordFields.sort(function(r1, r2) {
+ if (r1.value.tag > r2.value.tag) {
+ return 1;
+ } else {
+ return -1;
+ }
+ });
+
+ sfnt.fields = sfnt.fields.concat(recordFields);
+ sfnt.fields = sfnt.fields.concat(tableFields);
+ return sfnt;
+}
+
+// Get the metrics for a character. If the string has more than one character
+// this function returns metrics for the first available character.
+// You can provide optional fallback metrics if no characters are available.
+function metricsForChar(font, chars, notFoundMetrics) {
+ for (var i = 0; i < chars.length; i += 1) {
+ var glyphIndex = font.charToGlyphIndex(chars[i]);
+ if (glyphIndex > 0) {
+ var glyph = font.glyphs.get(glyphIndex);
+ return glyph.getMetrics();
+ }
+ }
+
+ return notFoundMetrics;
+}
+
+function average(vs) {
+ var sum = 0;
+ for (var i = 0; i < vs.length; i += 1) {
+ sum += vs[i];
+ }
+
+ return sum / vs.length;
+}
+
+// Convert the font object to a SFNT data structure.
+// This structure contains all the necessary tables and metadata to create a binary OTF file.
+function fontToSfntTable(font) {
+ var xMins = [];
+ var yMins = [];
+ var xMaxs = [];
+ var yMaxs = [];
+ var advanceWidths = [];
+ var leftSideBearings = [];
+ var rightSideBearings = [];
+ var firstCharIndex;
+ var lastCharIndex = 0;
+ var ulUnicodeRange1 = 0;
+ var ulUnicodeRange2 = 0;
+ var ulUnicodeRange3 = 0;
+ var ulUnicodeRange4 = 0;
+
+ for (var i = 0; i < font.glyphs.length; i += 1) {
+ var glyph = font.glyphs.get(i);
+ var unicode = glyph.unicode | 0;
+
+ if (isNaN(glyph.advanceWidth)) {
+ throw new Error('Glyph ' + glyph.name + ' (' + i + '): advanceWidth is not a number.');
+ }
+
+ if (firstCharIndex > unicode || firstCharIndex === undefined) {
+ // ignore .notdef char
+ if (unicode > 0) {
+ firstCharIndex = unicode;
+ }
+ }
+
+ if (lastCharIndex < unicode) {
+ lastCharIndex = unicode;
+ }
+
+ var position = os2.getUnicodeRange(unicode);
+ if (position < 32) {
+ ulUnicodeRange1 |= 1 << position;
+ } else if (position < 64) {
+ ulUnicodeRange2 |= 1 << position - 32;
+ } else if (position < 96) {
+ ulUnicodeRange3 |= 1 << position - 64;
+ } else if (position < 123) {
+ ulUnicodeRange4 |= 1 << position - 96;
+ } else {
+ throw new Error('Unicode ranges bits > 123 are reserved for internal usage');
+ }
+ // Skip non-important characters.
+ if (glyph.name === '.notdef') { continue; }
+ var metrics = glyph.getMetrics();
+ xMins.push(metrics.xMin);
+ yMins.push(metrics.yMin);
+ xMaxs.push(metrics.xMax);
+ yMaxs.push(metrics.yMax);
+ leftSideBearings.push(metrics.leftSideBearing);
+ rightSideBearings.push(metrics.rightSideBearing);
+ advanceWidths.push(glyph.advanceWidth);
+ }
+
+ var globals = {
+ xMin: Math.min.apply(null, xMins),
+ yMin: Math.min.apply(null, yMins),
+ xMax: Math.max.apply(null, xMaxs),
+ yMax: Math.max.apply(null, yMaxs),
+ advanceWidthMax: Math.max.apply(null, advanceWidths),
+ advanceWidthAvg: average(advanceWidths),
+ minLeftSideBearing: Math.min.apply(null, leftSideBearings),
+ maxLeftSideBearing: Math.max.apply(null, leftSideBearings),
+ minRightSideBearing: Math.min.apply(null, rightSideBearings)
+ };
+ globals.ascender = font.ascender;
+ globals.descender = font.descender;
+
+ var headTable = head.make({
+ flags: 3, // 00000011 (baseline for font at y=0; left sidebearing point at x=0)
+ unitsPerEm: font.unitsPerEm,
+ xMin: globals.xMin,
+ yMin: globals.yMin,
+ xMax: globals.xMax,
+ yMax: globals.yMax,
+ lowestRecPPEM: 3,
+ createdTimestamp: font.createdTimestamp
+ });
+
+ var hheaTable = hhea.make({
+ ascender: globals.ascender,
+ descender: globals.descender,
+ advanceWidthMax: globals.advanceWidthMax,
+ minLeftSideBearing: globals.minLeftSideBearing,
+ minRightSideBearing: globals.minRightSideBearing,
+ xMaxExtent: globals.maxLeftSideBearing + (globals.xMax - globals.xMin),
+ numberOfHMetrics: font.glyphs.length
+ });
+
+ var maxpTable = maxp.make(font.glyphs.length);
+
+ var os2Table = os2.make(Object.assign({
+ xAvgCharWidth: Math.round(globals.advanceWidthAvg),
+ usFirstCharIndex: firstCharIndex,
+ usLastCharIndex: lastCharIndex,
+ ulUnicodeRange1: ulUnicodeRange1,
+ ulUnicodeRange2: ulUnicodeRange2,
+ ulUnicodeRange3: ulUnicodeRange3,
+ ulUnicodeRange4: ulUnicodeRange4,
+ // See http://typophile.com/node/13081 for more info on vertical metrics.
+ // We get metrics for typical characters (such as "x" for xHeight).
+ // We provide some fallback characters if characters are unavailable: their
+ // ordering was chosen experimentally.
+ sTypoAscender: globals.ascender,
+ sTypoDescender: globals.descender,
+ sTypoLineGap: 0,
+ usWinAscent: globals.yMax,
+ usWinDescent: Math.abs(globals.yMin),
+ ulCodePageRange1: 1, // FIXME: hard-code Latin 1 support for now
+ sxHeight: metricsForChar(font, 'xyvw', {yMax: Math.round(globals.ascender / 2)}).yMax,
+ sCapHeight: metricsForChar(font, 'HIKLEFJMNTZBDPRAGOQSUVWXY', globals).yMax,
+ usDefaultChar: font.hasChar(' ') ? 32 : 0, // Use space as the default character, if available.
+ usBreakChar: font.hasChar(' ') ? 32 : 0, // Use space as the break character, if available.
+ }, font.tables.os2));
+
+ var hmtxTable = hmtx.make(font.glyphs);
+ var cmapTable = cmap.make(font.glyphs);
+
+ var englishFamilyName = font.getEnglishName('fontFamily');
+ var englishStyleName = font.getEnglishName('fontSubfamily');
+ var englishFullName = englishFamilyName + ' ' + englishStyleName;
+ var postScriptName = font.getEnglishName('postScriptName');
+ if (!postScriptName) {
+ postScriptName = englishFamilyName.replace(/\s/g, '') + '-' + englishStyleName;
+ }
+
+ var names = {};
+ for (var n in font.names) {
+ names[n] = font.names[n];
+ }
+
+ if (!names.uniqueID) {
+ names.uniqueID = {en: font.getEnglishName('manufacturer') + ':' + englishFullName};
+ }
+
+ if (!names.postScriptName) {
+ names.postScriptName = {en: postScriptName};
+ }
+
+ if (!names.preferredFamily) {
+ names.preferredFamily = font.names.fontFamily;
+ }
+
+ if (!names.preferredSubfamily) {
+ names.preferredSubfamily = font.names.fontSubfamily;
+ }
+
+ var languageTags = [];
+ var nameTable = _name.make(names, languageTags);
+ var ltagTable = (languageTags.length > 0 ? ltag.make(languageTags) : undefined);
+
+ var postTable = post.make();
+ var cffTable = cff.make(font.glyphs, {
+ version: font.getEnglishName('version'),
+ fullName: englishFullName,
+ familyName: englishFamilyName,
+ weightName: englishStyleName,
+ postScriptName: postScriptName,
+ unitsPerEm: font.unitsPerEm,
+ fontBBox: [0, globals.yMin, globals.ascender, globals.advanceWidthMax]
+ });
+
+ var metaTable = (font.metas && Object.keys(font.metas).length > 0) ? meta.make(font.metas) : undefined;
+
+ // The order does not matter because makeSfntTable() will sort them.
+ var tables = [headTable, hheaTable, maxpTable, os2Table, nameTable, cmapTable, postTable, cffTable, hmtxTable];
+ if (ltagTable) {
+ tables.push(ltagTable);
+ }
+ // Optional tables
+ if (font.tables.gsub) {
+ tables.push(gsub.make(font.tables.gsub));
+ }
+ if (metaTable) {
+ tables.push(metaTable);
+ }
+
+ var sfntTable = makeSfntTable(tables);
+
+ // Compute the font's checkSum and store it in head.checkSumAdjustment.
+ var bytes = sfntTable.encode();
+ var checkSum = computeCheckSum(bytes);
+ var tableFields = sfntTable.fields;
+ var checkSumAdjusted = false;
+ for (var i$1 = 0; i$1 < tableFields.length; i$1 += 1) {
+ if (tableFields[i$1].name === 'head table') {
+ tableFields[i$1].value.checkSumAdjustment = 0xB1B0AFBA - checkSum;
+ checkSumAdjusted = true;
+ break;
+ }
+ }
+
+ if (!checkSumAdjusted) {
+ throw new Error('Could not find head table with checkSum to adjust.');
+ }
+
+ return sfntTable;
+}
+
+var sfnt = { make: makeSfntTable, fontToTable: fontToSfntTable, computeCheckSum: computeCheckSum };
+
+// The Layout object is the prototype of Substitution objects, and provides
+
+function searchTag(arr, tag) {
+ /* jshint bitwise: false */
+ var imin = 0;
+ var imax = arr.length - 1;
+ while (imin <= imax) {
+ var imid = (imin + imax) >>> 1;
+ var val = arr[imid].tag;
+ if (val === tag) {
+ return imid;
+ } else if (val < tag) {
+ imin = imid + 1;
+ } else { imax = imid - 1; }
+ }
+ // Not found: return -1-insertion point
+ return -imin - 1;
+}
+
+function binSearch(arr, value) {
+ /* jshint bitwise: false */
+ var imin = 0;
+ var imax = arr.length - 1;
+ while (imin <= imax) {
+ var imid = (imin + imax) >>> 1;
+ var val = arr[imid];
+ if (val === value) {
+ return imid;
+ } else if (val < value) {
+ imin = imid + 1;
+ } else { imax = imid - 1; }
+ }
+ // Not found: return -1-insertion point
+ return -imin - 1;
+}
+
+// binary search in a list of ranges (coverage, class definition)
+function searchRange(ranges, value) {
+ // jshint bitwise: false
+ var range;
+ var imin = 0;
+ var imax = ranges.length - 1;
+ while (imin <= imax) {
+ var imid = (imin + imax) >>> 1;
+ range = ranges[imid];
+ var start = range.start;
+ if (start === value) {
+ return range;
+ } else if (start < value) {
+ imin = imid + 1;
+ } else { imax = imid - 1; }
+ }
+ if (imin > 0) {
+ range = ranges[imin - 1];
+ if (value > range.end) { return 0; }
+ return range;
+ }
+}
+
+/**
+ * @exports opentype.Layout
+ * @class
+ */
+function Layout(font, tableName) {
+ this.font = font;
+ this.tableName = tableName;
+}
+
+Layout.prototype = {
+
+ /**
+ * Binary search an object by "tag" property
+ * @instance
+ * @function searchTag
+ * @memberof opentype.Layout
+ * @param {Array} arr
+ * @param {string} tag
+ * @return {number}
+ */
+ searchTag: searchTag,
+
+ /**
+ * Binary search in a list of numbers
+ * @instance
+ * @function binSearch
+ * @memberof opentype.Layout
+ * @param {Array} arr
+ * @param {number} value
+ * @return {number}
+ */
+ binSearch: binSearch,
+
+ /**
+ * Get or create the Layout table (GSUB, GPOS etc).
+ * @param {boolean} create - Whether to create a new one.
+ * @return {Object} The GSUB or GPOS table.
+ */
+ getTable: function(create) {
+ var layout = this.font.tables[this.tableName];
+ if (!layout && create) {
+ layout = this.font.tables[this.tableName] = this.createDefaultTable();
+ }
+ return layout;
+ },
+
+ /**
+ * Returns all scripts in the substitution table.
+ * @instance
+ * @return {Array}
+ */
+ getScriptNames: function() {
+ var layout = this.getTable();
+ if (!layout) { return []; }
+ return layout.scripts.map(function(script) {
+ return script.tag;
+ });
+ },
+
+ /**
+ * Returns the best bet for a script name.
+ * Returns 'DFLT' if it exists.
+ * If not, returns 'latn' if it exists.
+ * If neither exist, returns undefined.
+ */
+ getDefaultScriptName: function() {
+ var layout = this.getTable();
+ if (!layout) { return; }
+ var hasLatn = false;
+ for (var i = 0; i < layout.scripts.length; i++) {
+ var name = layout.scripts[i].tag;
+ if (name === 'DFLT') { return name; }
+ if (name === 'latn') { hasLatn = true; }
+ }
+ if (hasLatn) { return 'latn'; }
+ },
+
+ /**
+ * Returns all LangSysRecords in the given script.
+ * @instance
+ * @param {string} [script='DFLT']
+ * @param {boolean} create - forces the creation of this script table if it doesn't exist.
+ * @return {Object} An object with tag and script properties.
+ */
+ getScriptTable: function(script, create) {
+ var layout = this.getTable(create);
+ if (layout) {
+ script = script || 'DFLT';
+ var scripts = layout.scripts;
+ var pos = searchTag(layout.scripts, script);
+ if (pos >= 0) {
+ return scripts[pos].script;
+ } else if (create) {
+ var scr = {
+ tag: script,
+ script: {
+ defaultLangSys: {reserved: 0, reqFeatureIndex: 0xffff, featureIndexes: []},
+ langSysRecords: []
+ }
+ };
+ scripts.splice(-1 - pos, 0, scr);
+ return scr.script;
+ }
+ }
+ },
+
+ /**
+ * Returns a language system table
+ * @instance
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dlft']
+ * @param {boolean} create - forces the creation of this langSysTable if it doesn't exist.
+ * @return {Object}
+ */
+ getLangSysTable: function(script, language, create) {
+ var scriptTable = this.getScriptTable(script, create);
+ if (scriptTable) {
+ if (!language || language === 'dflt' || language === 'DFLT') {
+ return scriptTable.defaultLangSys;
+ }
+ var pos = searchTag(scriptTable.langSysRecords, language);
+ if (pos >= 0) {
+ return scriptTable.langSysRecords[pos].langSys;
+ } else if (create) {
+ var langSysRecord = {
+ tag: language,
+ langSys: {reserved: 0, reqFeatureIndex: 0xffff, featureIndexes: []}
+ };
+ scriptTable.langSysRecords.splice(-1 - pos, 0, langSysRecord);
+ return langSysRecord.langSys;
+ }
+ }
+ },
+
+ /**
+ * Get a specific feature table.
+ * @instance
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dlft']
+ * @param {string} feature - One of the codes listed at https://www.microsoft.com/typography/OTSPEC/featurelist.htm
+ * @param {boolean} create - forces the creation of the feature table if it doesn't exist.
+ * @return {Object}
+ */
+ getFeatureTable: function(script, language, feature, create) {
+ var langSysTable = this.getLangSysTable(script, language, create);
+ if (langSysTable) {
+ var featureRecord;
+ var featIndexes = langSysTable.featureIndexes;
+ var allFeatures = this.font.tables[this.tableName].features;
+ // The FeatureIndex array of indices is in arbitrary order,
+ // even if allFeatures is sorted alphabetically by feature tag.
+ for (var i = 0; i < featIndexes.length; i++) {
+ featureRecord = allFeatures[featIndexes[i]];
+ if (featureRecord.tag === feature) {
+ return featureRecord.feature;
+ }
+ }
+ if (create) {
+ var index = allFeatures.length;
+ // Automatic ordering of features would require to shift feature indexes in the script list.
+ check.assert(index === 0 || feature >= allFeatures[index - 1].tag, 'Features must be added in alphabetical order.');
+ featureRecord = {
+ tag: feature,
+ feature: { params: 0, lookupListIndexes: [] }
+ };
+ allFeatures.push(featureRecord);
+ featIndexes.push(index);
+ return featureRecord.feature;
+ }
+ }
+ },
+
+ /**
+ * Get the lookup tables of a given type for a script/language/feature.
+ * @instance
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dlft']
+ * @param {string} feature - 4-letter feature code
+ * @param {number} lookupType - 1 to 9
+ * @param {boolean} create - forces the creation of the lookup table if it doesn't exist, with no subtables.
+ * @return {Object[]}
+ */
+ getLookupTables: function(script, language, feature, lookupType, create) {
+ var featureTable = this.getFeatureTable(script, language, feature, create);
+ var tables = [];
+ if (featureTable) {
+ var lookupTable;
+ var lookupListIndexes = featureTable.lookupListIndexes;
+ var allLookups = this.font.tables[this.tableName].lookups;
+ // lookupListIndexes are in no particular order, so use naive search.
+ for (var i = 0; i < lookupListIndexes.length; i++) {
+ lookupTable = allLookups[lookupListIndexes[i]];
+ if (lookupTable.lookupType === lookupType) {
+ tables.push(lookupTable);
+ }
+ }
+ if (tables.length === 0 && create) {
+ lookupTable = {
+ lookupType: lookupType,
+ lookupFlag: 0,
+ subtables: [],
+ markFilteringSet: undefined
+ };
+ var index = allLookups.length;
+ allLookups.push(lookupTable);
+ lookupListIndexes.push(index);
+ return [lookupTable];
+ }
+ }
+ return tables;
+ },
+
+ /**
+ * Find a glyph in a class definition table
+ * https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#class-definition-table
+ * @param {object} classDefTable - an OpenType Layout class definition table
+ * @param {number} glyphIndex - the index of the glyph to find
+ * @returns {number} -1 if not found
+ */
+ getGlyphClass: function(classDefTable, glyphIndex) {
+ switch (classDefTable.format) {
+ case 1:
+ if (classDefTable.startGlyph <= glyphIndex && glyphIndex < classDefTable.startGlyph + classDefTable.classes.length) {
+ return classDefTable.classes[glyphIndex - classDefTable.startGlyph];
+ }
+ return 0;
+ case 2:
+ var range = searchRange(classDefTable.ranges, glyphIndex);
+ return range ? range.classId : 0;
+ }
+ },
+
+ /**
+ * Find a glyph in a coverage table
+ * https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#coverage-table
+ * @param {object} coverageTable - an OpenType Layout coverage table
+ * @param {number} glyphIndex - the index of the glyph to find
+ * @returns {number} -1 if not found
+ */
+ getCoverageIndex: function(coverageTable, glyphIndex) {
+ switch (coverageTable.format) {
+ case 1:
+ var index = binSearch(coverageTable.glyphs, glyphIndex);
+ return index >= 0 ? index : -1;
+ case 2:
+ var range = searchRange(coverageTable.ranges, glyphIndex);
+ return range ? range.index + glyphIndex - range.start : -1;
+ }
+ },
+
+ /**
+ * Returns the list of glyph indexes of a coverage table.
+ * Format 1: the list is stored raw
+ * Format 2: compact list as range records.
+ * @instance
+ * @param {Object} coverageTable
+ * @return {Array}
+ */
+ expandCoverage: function(coverageTable) {
+ if (coverageTable.format === 1) {
+ return coverageTable.glyphs;
+ } else {
+ var glyphs = [];
+ var ranges = coverageTable.ranges;
+ for (var i = 0; i < ranges.length; i++) {
+ var range = ranges[i];
+ var start = range.start;
+ var end = range.end;
+ for (var j = start; j <= end; j++) {
+ glyphs.push(j);
+ }
+ }
+ return glyphs;
+ }
+ }
+
+};
+
+// The Position object provides utility methods to manipulate
+
+/**
+ * @exports opentype.Position
+ * @class
+ * @extends opentype.Layout
+ * @param {opentype.Font}
+ * @constructor
+ */
+function Position(font) {
+ Layout.call(this, font, 'gpos');
+}
+
+Position.prototype = Layout.prototype;
+
+/**
+ * Init some data for faster and easier access later.
+ */
+Position.prototype.init = function() {
+ var script = this.getDefaultScriptName();
+ this.defaultKerningTables = this.getKerningTables(script);
+};
+
+/**
+ * Find a glyph pair in a list of lookup tables of type 2 and retrieve the xAdvance kerning value.
+ *
+ * @param {integer} leftIndex - left glyph index
+ * @param {integer} rightIndex - right glyph index
+ * @returns {integer}
+ */
+Position.prototype.getKerningValue = function(kerningLookups, leftIndex, rightIndex) {
+ for (var i = 0; i < kerningLookups.length; i++) {
+ var subtables = kerningLookups[i].subtables;
+ for (var j = 0; j < subtables.length; j++) {
+ var subtable = subtables[j];
+ var covIndex = this.getCoverageIndex(subtable.coverage, leftIndex);
+ if (covIndex < 0) { continue; }
+ switch (subtable.posFormat) {
+ case 1:
+ // Search Pair Adjustment Positioning Format 1
+ var pairSet = subtable.pairSets[covIndex];
+ for (var k = 0; k < pairSet.length; k++) {
+ var pair = pairSet[k];
+ if (pair.secondGlyph === rightIndex) {
+ return pair.value1 && pair.value1.xAdvance || 0;
+ }
+ }
+ break; // left glyph found, not right glyph - try next subtable
+ case 2:
+ // Search Pair Adjustment Positioning Format 2
+ var class1 = this.getGlyphClass(subtable.classDef1, leftIndex);
+ var class2 = this.getGlyphClass(subtable.classDef2, rightIndex);
+ var pair$1 = subtable.classRecords[class1][class2];
+ return pair$1.value1 && pair$1.value1.xAdvance || 0;
+ }
+ }
+ }
+ return 0;
+};
+
+/**
+ * List all kerning lookup tables.
+ *
+ * @param {string} [script='DFLT'] - use font.position.getDefaultScriptName() for a better default value
+ * @param {string} [language='dflt']
+ * @return {object[]} The list of kerning lookup tables (may be empty), or undefined if there is no GPOS table (and we should use the kern table)
+ */
+Position.prototype.getKerningTables = function(script, language) {
+ if (this.font.tables.gpos) {
+ return this.getLookupTables(script, language, 'kern', 2);
+ }
+};
+
+// The Substitution object provides utility methods to manipulate
+
+/**
+ * @exports opentype.Substitution
+ * @class
+ * @extends opentype.Layout
+ * @param {opentype.Font}
+ * @constructor
+ */
+function Substitution(font) {
+ Layout.call(this, font, 'gsub');
+}
+
+// Check if 2 arrays of primitives are equal.
+function arraysEqual(ar1, ar2) {
+ var n = ar1.length;
+ if (n !== ar2.length) { return false; }
+ for (var i = 0; i < n; i++) {
+ if (ar1[i] !== ar2[i]) { return false; }
+ }
+ return true;
+}
+
+// Find the first subtable of a lookup table in a particular format.
+function getSubstFormat(lookupTable, format, defaultSubtable) {
+ var subtables = lookupTable.subtables;
+ for (var i = 0; i < subtables.length; i++) {
+ var subtable = subtables[i];
+ if (subtable.substFormat === format) {
+ return subtable;
+ }
+ }
+ if (defaultSubtable) {
+ subtables.push(defaultSubtable);
+ return defaultSubtable;
+ }
+ return undefined;
+}
+
+Substitution.prototype = Layout.prototype;
+
+/**
+ * Create a default GSUB table.
+ * @return {Object} gsub - The GSUB table.
+ */
+Substitution.prototype.createDefaultTable = function() {
+ // Generate a default empty GSUB table with just a DFLT script and dflt lang sys.
+ return {
+ version: 1,
+ scripts: [{
+ tag: 'DFLT',
+ script: {
+ defaultLangSys: { reserved: 0, reqFeatureIndex: 0xffff, featureIndexes: [] },
+ langSysRecords: []
+ }
+ }],
+ features: [],
+ lookups: []
+ };
+};
+
+/**
+ * List all single substitutions (lookup type 1) for a given script, language, and feature.
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ * @param {string} feature - 4-character feature name ('aalt', 'salt', 'ss01'...)
+ * @return {Array} substitutions - The list of substitutions.
+ */
+Substitution.prototype.getSingle = function(feature, script, language) {
+ var substitutions = [];
+ var lookupTables = this.getLookupTables(script, language, feature, 1);
+ for (var idx = 0; idx < lookupTables.length; idx++) {
+ var subtables = lookupTables[idx].subtables;
+ for (var i = 0; i < subtables.length; i++) {
+ var subtable = subtables[i];
+ var glyphs = this.expandCoverage(subtable.coverage);
+ var j = (void 0);
+ if (subtable.substFormat === 1) {
+ var delta = subtable.deltaGlyphId;
+ for (j = 0; j < glyphs.length; j++) {
+ var glyph = glyphs[j];
+ substitutions.push({ sub: glyph, by: glyph + delta });
+ }
+ } else {
+ var substitute = subtable.substitute;
+ for (j = 0; j < glyphs.length; j++) {
+ substitutions.push({ sub: glyphs[j], by: substitute[j] });
+ }
+ }
+ }
+ }
+ return substitutions;
+};
+
+/**
+ * List all multiple substitutions (lookup type 2) for a given script, language, and feature.
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ * @param {string} feature - 4-character feature name ('ccmp', 'stch')
+ * @return {Array} substitutions - The list of substitutions.
+ */
+Substitution.prototype.getMultiple = function(feature, script, language) {
+ var substitutions = [];
+ var lookupTables = this.getLookupTables(script, language, feature, 2);
+ for (var idx = 0; idx < lookupTables.length; idx++) {
+ var subtables = lookupTables[idx].subtables;
+ for (var i = 0; i < subtables.length; i++) {
+ var subtable = subtables[i];
+ var glyphs = this.expandCoverage(subtable.coverage);
+ var j = (void 0);
+
+ for (j = 0; j < glyphs.length; j++) {
+ var glyph = glyphs[j];
+ var replacements = subtable.sequences[j];
+ substitutions.push({ sub: glyph, by: replacements });
+ }
+ }
+ }
+ return substitutions;
+};
+
+/**
+ * List all alternates (lookup type 3) for a given script, language, and feature.
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ * @param {string} feature - 4-character feature name ('aalt', 'salt'...)
+ * @return {Array} alternates - The list of alternates
+ */
+Substitution.prototype.getAlternates = function(feature, script, language) {
+ var alternates = [];
+ var lookupTables = this.getLookupTables(script, language, feature, 3);
+ for (var idx = 0; idx < lookupTables.length; idx++) {
+ var subtables = lookupTables[idx].subtables;
+ for (var i = 0; i < subtables.length; i++) {
+ var subtable = subtables[i];
+ var glyphs = this.expandCoverage(subtable.coverage);
+ var alternateSets = subtable.alternateSets;
+ for (var j = 0; j < glyphs.length; j++) {
+ alternates.push({ sub: glyphs[j], by: alternateSets[j] });
+ }
+ }
+ }
+ return alternates;
+};
+
+/**
+ * List all ligatures (lookup type 4) for a given script, language, and feature.
+ * The result is an array of ligature objects like { sub: [ids], by: id }
+ * @param {string} feature - 4-letter feature name ('liga', 'rlig', 'dlig'...)
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ * @return {Array} ligatures - The list of ligatures.
+ */
+Substitution.prototype.getLigatures = function(feature, script, language) {
+ var ligatures = [];
+ var lookupTables = this.getLookupTables(script, language, feature, 4);
+ for (var idx = 0; idx < lookupTables.length; idx++) {
+ var subtables = lookupTables[idx].subtables;
+ for (var i = 0; i < subtables.length; i++) {
+ var subtable = subtables[i];
+ var glyphs = this.expandCoverage(subtable.coverage);
+ var ligatureSets = subtable.ligatureSets;
+ for (var j = 0; j < glyphs.length; j++) {
+ var startGlyph = glyphs[j];
+ var ligSet = ligatureSets[j];
+ for (var k = 0; k < ligSet.length; k++) {
+ var lig = ligSet[k];
+ ligatures.push({
+ sub: [startGlyph].concat(lig.components),
+ by: lig.ligGlyph
+ });
+ }
+ }
+ }
+ }
+ return ligatures;
+};
+
+/**
+ * Add or modify a single substitution (lookup type 1)
+ * Format 2, more flexible, is always used.
+ * @param {string} feature - 4-letter feature name ('liga', 'rlig', 'dlig'...)
+ * @param {Object} substitution - { sub: id, by: id } (format 1 is not supported)
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ */
+Substitution.prototype.addSingle = function(feature, substitution, script, language) {
+ var lookupTable = this.getLookupTables(script, language, feature, 1, true)[0];
+ var subtable = getSubstFormat(lookupTable, 2, { // lookup type 1 subtable, format 2, coverage format 1
+ substFormat: 2,
+ coverage: {format: 1, glyphs: []},
+ substitute: []
+ });
+ check.assert(subtable.coverage.format === 1, 'Single: unable to modify coverage table format ' + subtable.coverage.format);
+ var coverageGlyph = substitution.sub;
+ var pos = this.binSearch(subtable.coverage.glyphs, coverageGlyph);
+ if (pos < 0) {
+ pos = -1 - pos;
+ subtable.coverage.glyphs.splice(pos, 0, coverageGlyph);
+ subtable.substitute.splice(pos, 0, 0);
+ }
+ subtable.substitute[pos] = substitution.by;
+};
+
+/**
+ * Add or modify a multiple substitution (lookup type 2)
+ * @param {string} feature - 4-letter feature name ('ccmp', 'stch')
+ * @param {Object} substitution - { sub: id, by: [id] } for format 2.
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ */
+Substitution.prototype.addMultiple = function(feature, substitution, script, language) {
+ check.assert(substitution.by instanceof Array && substitution.by.length > 1, 'Multiple: "by" must be an array of two or more ids');
+ var lookupTable = this.getLookupTables(script, language, feature, 2, true)[0];
+ var subtable = getSubstFormat(lookupTable, 1, { // lookup type 2 subtable, format 1, coverage format 1
+ substFormat: 1,
+ coverage: {format: 1, glyphs: []},
+ sequences: []
+ });
+ check.assert(subtable.coverage.format === 1, 'Multiple: unable to modify coverage table format ' + subtable.coverage.format);
+ var coverageGlyph = substitution.sub;
+ var pos = this.binSearch(subtable.coverage.glyphs, coverageGlyph);
+ if (pos < 0) {
+ pos = -1 - pos;
+ subtable.coverage.glyphs.splice(pos, 0, coverageGlyph);
+ subtable.sequences.splice(pos, 0, 0);
+ }
+ subtable.sequences[pos] = substitution.by;
+};
+
+/**
+ * Add or modify an alternate substitution (lookup type 3)
+ * @param {string} feature - 4-letter feature name ('liga', 'rlig', 'dlig'...)
+ * @param {Object} substitution - { sub: id, by: [ids] }
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ */
+Substitution.prototype.addAlternate = function(feature, substitution, script, language) {
+ var lookupTable = this.getLookupTables(script, language, feature, 3, true)[0];
+ var subtable = getSubstFormat(lookupTable, 1, { // lookup type 3 subtable, format 1, coverage format 1
+ substFormat: 1,
+ coverage: {format: 1, glyphs: []},
+ alternateSets: []
+ });
+ check.assert(subtable.coverage.format === 1, 'Alternate: unable to modify coverage table format ' + subtable.coverage.format);
+ var coverageGlyph = substitution.sub;
+ var pos = this.binSearch(subtable.coverage.glyphs, coverageGlyph);
+ if (pos < 0) {
+ pos = -1 - pos;
+ subtable.coverage.glyphs.splice(pos, 0, coverageGlyph);
+ subtable.alternateSets.splice(pos, 0, 0);
+ }
+ subtable.alternateSets[pos] = substitution.by;
+};
+
+/**
+ * Add a ligature (lookup type 4)
+ * Ligatures with more components must be stored ahead of those with fewer components in order to be found
+ * @param {string} feature - 4-letter feature name ('liga', 'rlig', 'dlig'...)
+ * @param {Object} ligature - { sub: [ids], by: id }
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ */
+Substitution.prototype.addLigature = function(feature, ligature, script, language) {
+ var lookupTable = this.getLookupTables(script, language, feature, 4, true)[0];
+ var subtable = lookupTable.subtables[0];
+ if (!subtable) {
+ subtable = { // lookup type 4 subtable, format 1, coverage format 1
+ substFormat: 1,
+ coverage: { format: 1, glyphs: [] },
+ ligatureSets: []
+ };
+ lookupTable.subtables[0] = subtable;
+ }
+ check.assert(subtable.coverage.format === 1, 'Ligature: unable to modify coverage table format ' + subtable.coverage.format);
+ var coverageGlyph = ligature.sub[0];
+ var ligComponents = ligature.sub.slice(1);
+ var ligatureTable = {
+ ligGlyph: ligature.by,
+ components: ligComponents
+ };
+ var pos = this.binSearch(subtable.coverage.glyphs, coverageGlyph);
+ if (pos >= 0) {
+ // ligatureSet already exists
+ var ligatureSet = subtable.ligatureSets[pos];
+ for (var i = 0; i < ligatureSet.length; i++) {
+ // If ligature already exists, return.
+ if (arraysEqual(ligatureSet[i].components, ligComponents)) {
+ return;
+ }
+ }
+ // ligature does not exist: add it.
+ ligatureSet.push(ligatureTable);
+ } else {
+ // Create a new ligatureSet and add coverage for the first glyph.
+ pos = -1 - pos;
+ subtable.coverage.glyphs.splice(pos, 0, coverageGlyph);
+ subtable.ligatureSets.splice(pos, 0, [ligatureTable]);
+ }
+};
+
+/**
+ * List all feature data for a given script and language.
+ * @param {string} feature - 4-letter feature name
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ * @return {Array} substitutions - The list of substitutions.
+ */
+Substitution.prototype.getFeature = function(feature, script, language) {
+ if (/ss\d\d/.test(feature)) {
+ // ss01 - ss20
+ return this.getSingle(feature, script, language);
+ }
+ switch (feature) {
+ case 'aalt':
+ case 'salt':
+ return this.getSingle(feature, script, language)
+ .concat(this.getAlternates(feature, script, language));
+ case 'dlig':
+ case 'liga':
+ case 'rlig':
+ return this.getLigatures(feature, script, language);
+ case 'ccmp':
+ return this.getMultiple(feature, script, language)
+ .concat(this.getLigatures(feature, script, language));
+ case 'stch':
+ return this.getMultiple(feature, script, language);
+ }
+ return undefined;
+};
+
+/**
+ * Add a substitution to a feature for a given script and language.
+ * @param {string} feature - 4-letter feature name
+ * @param {Object} sub - the substitution to add (an object like { sub: id or [ids], by: id or [ids] })
+ * @param {string} [script='DFLT']
+ * @param {string} [language='dflt']
+ */
+Substitution.prototype.add = function(feature, sub, script, language) {
+ if (/ss\d\d/.test(feature)) {
+ // ss01 - ss20
+ return this.addSingle(feature, sub, script, language);
+ }
+ switch (feature) {
+ case 'aalt':
+ case 'salt':
+ if (typeof sub.by === 'number') {
+ return this.addSingle(feature, sub, script, language);
+ }
+ return this.addAlternate(feature, sub, script, language);
+ case 'dlig':
+ case 'liga':
+ case 'rlig':
+ return this.addLigature(feature, sub, script, language);
+ case 'ccmp':
+ if (sub.by instanceof Array) {
+ return this.addMultiple(feature, sub, script, language);
+ }
+ return this.addLigature(feature, sub, script, language);
+ }
+ return undefined;
+};
+
+function isBrowser() {
+ return typeof window !== 'undefined';
+}
+
+function nodeBufferToArrayBuffer(buffer) {
+ var ab = new ArrayBuffer(buffer.length);
+ var view = new Uint8Array(ab);
+ for (var i = 0; i < buffer.length; ++i) {
+ view[i] = buffer[i];
+ }
+
+ return ab;
+}
+
+function arrayBufferToNodeBuffer(ab) {
+ var buffer = new Buffer(ab.byteLength);
+ var view = new Uint8Array(ab);
+ for (var i = 0; i < buffer.length; ++i) {
+ buffer[i] = view[i];
+ }
+
+ return buffer;
+}
+
+function checkArgument(expression, message) {
+ if (!expression) {
+ throw message;
+ }
+}
+
+// The `glyf` table describes the glyphs in TrueType outline format.
+
+// Parse the coordinate data for a glyph.
+function parseGlyphCoordinate(p, flag, previousValue, shortVectorBitMask, sameBitMask) {
+ var v;
+ if ((flag & shortVectorBitMask) > 0) {
+ // The coordinate is 1 byte long.
+ v = p.parseByte();
+ // The `same` bit is re-used for short values to signify the sign of the value.
+ if ((flag & sameBitMask) === 0) {
+ v = -v;
+ }
+
+ v = previousValue + v;
+ } else {
+ // The coordinate is 2 bytes long.
+ // If the `same` bit is set, the coordinate is the same as the previous coordinate.
+ if ((flag & sameBitMask) > 0) {
+ v = previousValue;
+ } else {
+ // Parse the coordinate as a signed 16-bit delta value.
+ v = previousValue + p.parseShort();
+ }
+ }
+
+ return v;
+}
+
+// Parse a TrueType glyph.
+function parseGlyph(glyph, data, start) {
+ var p = new parse.Parser(data, start);
+ glyph.numberOfContours = p.parseShort();
+ glyph._xMin = p.parseShort();
+ glyph._yMin = p.parseShort();
+ glyph._xMax = p.parseShort();
+ glyph._yMax = p.parseShort();
+ var flags;
+ var flag;
+
+ if (glyph.numberOfContours > 0) {
+ // This glyph is not a composite.
+ var endPointIndices = glyph.endPointIndices = [];
+ for (var i = 0; i < glyph.numberOfContours; i += 1) {
+ endPointIndices.push(p.parseUShort());
+ }
+
+ glyph.instructionLength = p.parseUShort();
+ glyph.instructions = [];
+ for (var i$1 = 0; i$1 < glyph.instructionLength; i$1 += 1) {
+ glyph.instructions.push(p.parseByte());
+ }
+
+ var numberOfCoordinates = endPointIndices[endPointIndices.length - 1] + 1;
+ flags = [];
+ for (var i$2 = 0; i$2 < numberOfCoordinates; i$2 += 1) {
+ flag = p.parseByte();
+ flags.push(flag);
+ // If bit 3 is set, we repeat this flag n times, where n is the next byte.
+ if ((flag & 8) > 0) {
+ var repeatCount = p.parseByte();
+ for (var j = 0; j < repeatCount; j += 1) {
+ flags.push(flag);
+ i$2 += 1;
+ }
+ }
+ }
+
+ check.argument(flags.length === numberOfCoordinates, 'Bad flags.');
+
+ if (endPointIndices.length > 0) {
+ var points = [];
+ var point;
+ // X/Y coordinates are relative to the previous point, except for the first point which is relative to 0,0.
+ if (numberOfCoordinates > 0) {
+ for (var i$3 = 0; i$3 < numberOfCoordinates; i$3 += 1) {
+ flag = flags[i$3];
+ point = {};
+ point.onCurve = !!(flag & 1);
+ point.lastPointOfContour = endPointIndices.indexOf(i$3) >= 0;
+ points.push(point);
+ }
+
+ var px = 0;
+ for (var i$4 = 0; i$4 < numberOfCoordinates; i$4 += 1) {
+ flag = flags[i$4];
+ point = points[i$4];
+ point.x = parseGlyphCoordinate(p, flag, px, 2, 16);
+ px = point.x;
+ }
+
+ var py = 0;
+ for (var i$5 = 0; i$5 < numberOfCoordinates; i$5 += 1) {
+ flag = flags[i$5];
+ point = points[i$5];
+ point.y = parseGlyphCoordinate(p, flag, py, 4, 32);
+ py = point.y;
+ }
+ }
+
+ glyph.points = points;
+ } else {
+ glyph.points = [];
+ }
+ } else if (glyph.numberOfContours === 0) {
+ glyph.points = [];
+ } else {
+ glyph.isComposite = true;
+ glyph.points = [];
+ glyph.components = [];
+ var moreComponents = true;
+ while (moreComponents) {
+ flags = p.parseUShort();
+ var component = {
+ glyphIndex: p.parseUShort(),
+ xScale: 1,
+ scale01: 0,
+ scale10: 0,
+ yScale: 1,
+ dx: 0,
+ dy: 0
+ };
+ if ((flags & 1) > 0) {
+ // The arguments are words
+ if ((flags & 2) > 0) {
+ // values are offset
+ component.dx = p.parseShort();
+ component.dy = p.parseShort();
+ } else {
+ // values are matched points
+ component.matchedPoints = [p.parseUShort(), p.parseUShort()];
+ }
+
+ } else {
+ // The arguments are bytes
+ if ((flags & 2) > 0) {
+ // values are offset
+ component.dx = p.parseChar();
+ component.dy = p.parseChar();
+ } else {
+ // values are matched points
+ component.matchedPoints = [p.parseByte(), p.parseByte()];
+ }
+ }
+
+ if ((flags & 8) > 0) {
+ // We have a scale
+ component.xScale = component.yScale = p.parseF2Dot14();
+ } else if ((flags & 64) > 0) {
+ // We have an X / Y scale
+ component.xScale = p.parseF2Dot14();
+ component.yScale = p.parseF2Dot14();
+ } else if ((flags & 128) > 0) {
+ // We have a 2x2 transformation
+ component.xScale = p.parseF2Dot14();
+ component.scale01 = p.parseF2Dot14();
+ component.scale10 = p.parseF2Dot14();
+ component.yScale = p.parseF2Dot14();
+ }
+
+ glyph.components.push(component);
+ moreComponents = !!(flags & 32);
+ }
+ if (flags & 0x100) {
+ // We have instructions
+ glyph.instructionLength = p.parseUShort();
+ glyph.instructions = [];
+ for (var i$6 = 0; i$6 < glyph.instructionLength; i$6 += 1) {
+ glyph.instructions.push(p.parseByte());
+ }
+ }
+ }
+}
+
+// Transform an array of points and return a new array.
+function transformPoints(points, transform) {
+ var newPoints = [];
+ for (var i = 0; i < points.length; i += 1) {
+ var pt = points[i];
+ var newPt = {
+ x: transform.xScale * pt.x + transform.scale01 * pt.y + transform.dx,
+ y: transform.scale10 * pt.x + transform.yScale * pt.y + transform.dy,
+ onCurve: pt.onCurve,
+ lastPointOfContour: pt.lastPointOfContour
+ };
+ newPoints.push(newPt);
+ }
+
+ return newPoints;
+}
+
+function getContours(points) {
+ var contours = [];
+ var currentContour = [];
+ for (var i = 0; i < points.length; i += 1) {
+ var pt = points[i];
+ currentContour.push(pt);
+ if (pt.lastPointOfContour) {
+ contours.push(currentContour);
+ currentContour = [];
+ }
+ }
+
+ check.argument(currentContour.length === 0, 'There are still points left in the current contour.');
+ return contours;
+}
+
+// Convert the TrueType glyph outline to a Path.
+function getPath(points) {
+ var p = new Path();
+ if (!points) {
+ return p;
+ }
+
+ var contours = getContours(points);
+
+ for (var contourIndex = 0; contourIndex < contours.length; ++contourIndex) {
+ var contour = contours[contourIndex];
+
+ var prev = null;
+ var curr = contour[contour.length - 1];
+ var next = contour[0];
+
+ if (curr.onCurve) {
+ p.moveTo(curr.x, curr.y);
+ } else {
+ if (next.onCurve) {
+ p.moveTo(next.x, next.y);
+ } else {
+ // If both first and last points are off-curve, start at their middle.
+ var start = {x: (curr.x + next.x) * 0.5, y: (curr.y + next.y) * 0.5};
+ p.moveTo(start.x, start.y);
+ }
+ }
+
+ for (var i = 0; i < contour.length; ++i) {
+ prev = curr;
+ curr = next;
+ next = contour[(i + 1) % contour.length];
+
+ if (curr.onCurve) {
+ // This is a straight line.
+ p.lineTo(curr.x, curr.y);
+ } else {
+ var prev2 = prev;
+ var next2 = next;
+
+ if (!prev.onCurve) {
+ prev2 = { x: (curr.x + prev.x) * 0.5, y: (curr.y + prev.y) * 0.5 };
+ }
+
+ if (!next.onCurve) {
+ next2 = { x: (curr.x + next.x) * 0.5, y: (curr.y + next.y) * 0.5 };
+ }
+
+ p.quadraticCurveTo(curr.x, curr.y, next2.x, next2.y);
+ }
+ }
+
+ p.closePath();
+ }
+ return p;
+}
+
+function buildPath(glyphs, glyph) {
+ if (glyph.isComposite) {
+ for (var j = 0; j < glyph.components.length; j += 1) {
+ var component = glyph.components[j];
+ var componentGlyph = glyphs.get(component.glyphIndex);
+ // Force the ttfGlyphLoader to parse the glyph.
+ componentGlyph.getPath();
+ if (componentGlyph.points) {
+ var transformedPoints = (void 0);
+ if (component.matchedPoints === undefined) {
+ // component positioned by offset
+ transformedPoints = transformPoints(componentGlyph.points, component);
+ } else {
+ // component positioned by matched points
+ if ((component.matchedPoints[0] > glyph.points.length - 1) ||
+ (component.matchedPoints[1] > componentGlyph.points.length - 1)) {
+ throw Error('Matched points out of range in ' + glyph.name);
+ }
+ var firstPt = glyph.points[component.matchedPoints[0]];
+ var secondPt = componentGlyph.points[component.matchedPoints[1]];
+ var transform = {
+ xScale: component.xScale, scale01: component.scale01,
+ scale10: component.scale10, yScale: component.yScale,
+ dx: 0, dy: 0
+ };
+ secondPt = transformPoints([secondPt], transform)[0];
+ transform.dx = firstPt.x - secondPt.x;
+ transform.dy = firstPt.y - secondPt.y;
+ transformedPoints = transformPoints(componentGlyph.points, transform);
+ }
+ glyph.points = glyph.points.concat(transformedPoints);
+ }
+ }
+ }
+
+ return getPath(glyph.points);
+}
+
+function parseGlyfTableAll(data, start, loca, font) {
+ var glyphs = new glyphset.GlyphSet(font);
+
+ // The last element of the loca table is invalid.
+ for (var i = 0; i < loca.length - 1; i += 1) {
+ var offset = loca[i];
+ var nextOffset = loca[i + 1];
+ if (offset !== nextOffset) {
+ glyphs.push(i, glyphset.ttfGlyphLoader(font, i, parseGlyph, data, start + offset, buildPath));
+ } else {
+ glyphs.push(i, glyphset.glyphLoader(font, i));
+ }
+ }
+
+ return glyphs;
+}
+
+function parseGlyfTableOnLowMemory(data, start, loca, font) {
+ var glyphs = new glyphset.GlyphSet(font);
+
+ font._push = function(i) {
+ var offset = loca[i];
+ var nextOffset = loca[i + 1];
+ if (offset !== nextOffset) {
+ glyphs.push(i, glyphset.ttfGlyphLoader(font, i, parseGlyph, data, start + offset, buildPath));
+ } else {
+ glyphs.push(i, glyphset.glyphLoader(font, i));
+ }
+ };
+
+ return glyphs;
+}
+
+// Parse all the glyphs according to the offsets from the `loca` table.
+function parseGlyfTable(data, start, loca, font, opt) {
+ if (opt.lowMemory)
+ { return parseGlyfTableOnLowMemory(data, start, loca, font); }
+ else
+ { return parseGlyfTableAll(data, start, loca, font); }
+}
+
+var glyf = { getPath: getPath, parse: parseGlyfTable};
+
+/* A TrueType font hinting interpreter.
+*
+* (c) 2017 Axel Kittenberger
+*
+* This interpreter has been implemented according to this documentation:
+* https://developer.apple.com/fonts/TrueType-Reference-Manual/RM05/Chap5.html
+*
+* According to the documentation F24DOT6 values are used for pixels.
+* That means calculation is 1/64 pixel accurate and uses integer operations.
+* However, Javascript has floating point operations by default and only
+* those are available. One could make a case to simulate the 1/64 accuracy
+* exactly by truncating after every division operation
+* (for example with << 0) to get pixel exactly results as other TrueType
+* implementations. It may make sense since some fonts are pixel optimized
+* by hand using DELTAP instructions. The current implementation doesn't
+* and rather uses full floating point precision.
+*
+* xScale, yScale and rotation is currently ignored.
+*
+* A few non-trivial instructions are missing as I didn't encounter yet
+* a font that used them to test a possible implementation.
+*
+* Some fonts seem to use undocumented features regarding the twilight zone.
+* Only some of them are implemented as they were encountered.
+*
+* The exports.DEBUG statements are removed on the minified distribution file.
+*/
+
+var instructionTable;
+var exec;
+var execGlyph;
+var execComponent;
+
+/*
+* Creates a hinting object.
+*
+* There ought to be exactly one
+* for each truetype font that is used for hinting.
+*/
+function Hinting(font) {
+ // the font this hinting object is for
+ this.font = font;
+
+ this.getCommands = function (hPoints) {
+ return glyf.getPath(hPoints).commands;
+ };
+
+ // cached states
+ this._fpgmState =
+ this._prepState =
+ undefined;
+
+ // errorState
+ // 0 ... all okay
+ // 1 ... had an error in a glyf,
+ // continue working but stop spamming
+ // the console
+ // 2 ... error at prep, stop hinting at this ppem
+ // 3 ... error at fpeg, stop hinting for this font at all
+ this._errorState = 0;
+}
+
+/*
+* Not rounding.
+*/
+function roundOff(v) {
+ return v;
+}
+
+/*
+* Rounding to grid.
+*/
+function roundToGrid(v) {
+ //Rounding in TT is supposed to "symmetrical around zero"
+ return Math.sign(v) * Math.round(Math.abs(v));
+}
+
+/*
+* Rounding to double grid.
+*/
+function roundToDoubleGrid(v) {
+ return Math.sign(v) * Math.round(Math.abs(v * 2)) / 2;
+}
+
+/*
+* Rounding to half grid.
+*/
+function roundToHalfGrid(v) {
+ return Math.sign(v) * (Math.round(Math.abs(v) + 0.5) - 0.5);
+}
+
+/*
+* Rounding to up to grid.
+*/
+function roundUpToGrid(v) {
+ return Math.sign(v) * Math.ceil(Math.abs(v));
+}
+
+/*
+* Rounding to down to grid.
+*/
+function roundDownToGrid(v) {
+ return Math.sign(v) * Math.floor(Math.abs(v));
+}
+
+/*
+* Super rounding.
+*/
+var roundSuper = function (v) {
+ var period = this.srPeriod;
+ var phase = this.srPhase;
+ var threshold = this.srThreshold;
+ var sign = 1;
+
+ if (v < 0) {
+ v = -v;
+ sign = -1;
+ }
+
+ v += threshold - phase;
+
+ v = Math.trunc(v / period) * period;
+
+ v += phase;
+
+ // according to http://xgridfit.sourceforge.net/round.html
+ if (v < 0) { return phase * sign; }
+
+ return v * sign;
+};
+
+/*
+* Unit vector of x-axis.
+*/
+var xUnitVector = {
+ x: 1,
+
+ y: 0,
+
+ axis: 'x',
+
+ // Gets the projected distance between two points.
+ // o1/o2 ... if true, respective original position is used.
+ distance: function (p1, p2, o1, o2) {
+ return (o1 ? p1.xo : p1.x) - (o2 ? p2.xo : p2.x);
+ },
+
+ // Moves point p so the moved position has the same relative
+ // position to the moved positions of rp1 and rp2 than the
+ // original positions had.
+ //
+ // See APPENDIX on INTERPOLATE at the bottom of this file.
+ interpolate: function (p, rp1, rp2, pv) {
+ var do1;
+ var do2;
+ var doa1;
+ var doa2;
+ var dm1;
+ var dm2;
+ var dt;
+
+ if (!pv || pv === this) {
+ do1 = p.xo - rp1.xo;
+ do2 = p.xo - rp2.xo;
+ dm1 = rp1.x - rp1.xo;
+ dm2 = rp2.x - rp2.xo;
+ doa1 = Math.abs(do1);
+ doa2 = Math.abs(do2);
+ dt = doa1 + doa2;
+
+ if (dt === 0) {
+ p.x = p.xo + (dm1 + dm2) / 2;
+ return;
+ }
+
+ p.x = p.xo + (dm1 * doa2 + dm2 * doa1) / dt;
+ return;
+ }
+
+ do1 = pv.distance(p, rp1, true, true);
+ do2 = pv.distance(p, rp2, true, true);
+ dm1 = pv.distance(rp1, rp1, false, true);
+ dm2 = pv.distance(rp2, rp2, false, true);
+ doa1 = Math.abs(do1);
+ doa2 = Math.abs(do2);
+ dt = doa1 + doa2;
+
+ if (dt === 0) {
+ xUnitVector.setRelative(p, p, (dm1 + dm2) / 2, pv, true);
+ return;
+ }
+
+ xUnitVector.setRelative(p, p, (dm1 * doa2 + dm2 * doa1) / dt, pv, true);
+ },
+
+ // Slope of line normal to this
+ normalSlope: Number.NEGATIVE_INFINITY,
+
+ // Sets the point 'p' relative to point 'rp'
+ // by the distance 'd'.
+ //
+ // See APPENDIX on SETRELATIVE at the bottom of this file.
+ //
+ // p ... point to set
+ // rp ... reference point
+ // d ... distance on projection vector
+ // pv ... projection vector (undefined = this)
+ // org ... if true, uses the original position of rp as reference.
+ setRelative: function (p, rp, d, pv, org) {
+ if (!pv || pv === this) {
+ p.x = (org ? rp.xo : rp.x) + d;
+ return;
+ }
+
+ var rpx = org ? rp.xo : rp.x;
+ var rpy = org ? rp.yo : rp.y;
+ var rpdx = rpx + d * pv.x;
+ var rpdy = rpy + d * pv.y;
+
+ p.x = rpdx + (p.y - rpdy) / pv.normalSlope;
+ },
+
+ // Slope of vector line.
+ slope: 0,
+
+ // Touches the point p.
+ touch: function (p) {
+ p.xTouched = true;
+ },
+
+ // Tests if a point p is touched.
+ touched: function (p) {
+ return p.xTouched;
+ },
+
+ // Untouches the point p.
+ untouch: function (p) {
+ p.xTouched = false;
+ }
+};
+
+/*
+* Unit vector of y-axis.
+*/
+var yUnitVector = {
+ x: 0,
+
+ y: 1,
+
+ axis: 'y',
+
+ // Gets the projected distance between two points.
+ // o1/o2 ... if true, respective original position is used.
+ distance: function (p1, p2, o1, o2) {
+ return (o1 ? p1.yo : p1.y) - (o2 ? p2.yo : p2.y);
+ },
+
+ // Moves point p so the moved position has the same relative
+ // position to the moved positions of rp1 and rp2 than the
+ // original positions had.
+ //
+ // See APPENDIX on INTERPOLATE at the bottom of this file.
+ interpolate: function (p, rp1, rp2, pv) {
+ var do1;
+ var do2;
+ var doa1;
+ var doa2;
+ var dm1;
+ var dm2;
+ var dt;
+
+ if (!pv || pv === this) {
+ do1 = p.yo - rp1.yo;
+ do2 = p.yo - rp2.yo;
+ dm1 = rp1.y - rp1.yo;
+ dm2 = rp2.y - rp2.yo;
+ doa1 = Math.abs(do1);
+ doa2 = Math.abs(do2);
+ dt = doa1 + doa2;
+
+ if (dt === 0) {
+ p.y = p.yo + (dm1 + dm2) / 2;
+ return;
+ }
+
+ p.y = p.yo + (dm1 * doa2 + dm2 * doa1) / dt;
+ return;
+ }
+
+ do1 = pv.distance(p, rp1, true, true);
+ do2 = pv.distance(p, rp2, true, true);
+ dm1 = pv.distance(rp1, rp1, false, true);
+ dm2 = pv.distance(rp2, rp2, false, true);
+ doa1 = Math.abs(do1);
+ doa2 = Math.abs(do2);
+ dt = doa1 + doa2;
+
+ if (dt === 0) {
+ yUnitVector.setRelative(p, p, (dm1 + dm2) / 2, pv, true);
+ return;
+ }
+
+ yUnitVector.setRelative(p, p, (dm1 * doa2 + dm2 * doa1) / dt, pv, true);
+ },
+
+ // Slope of line normal to this.
+ normalSlope: 0,
+
+ // Sets the point 'p' relative to point 'rp'
+ // by the distance 'd'
+ //
+ // See APPENDIX on SETRELATIVE at the bottom of this file.
+ //
+ // p ... point to set
+ // rp ... reference point
+ // d ... distance on projection vector
+ // pv ... projection vector (undefined = this)
+ // org ... if true, uses the original position of rp as reference.
+ setRelative: function (p, rp, d, pv, org) {
+ if (!pv || pv === this) {
+ p.y = (org ? rp.yo : rp.y) + d;
+ return;
+ }
+
+ var rpx = org ? rp.xo : rp.x;
+ var rpy = org ? rp.yo : rp.y;
+ var rpdx = rpx + d * pv.x;
+ var rpdy = rpy + d * pv.y;
+
+ p.y = rpdy + pv.normalSlope * (p.x - rpdx);
+ },
+
+ // Slope of vector line.
+ slope: Number.POSITIVE_INFINITY,
+
+ // Touches the point p.
+ touch: function (p) {
+ p.yTouched = true;
+ },
+
+ // Tests if a point p is touched.
+ touched: function (p) {
+ return p.yTouched;
+ },
+
+ // Untouches the point p.
+ untouch: function (p) {
+ p.yTouched = false;
+ }
+};
+
+Object.freeze(xUnitVector);
+Object.freeze(yUnitVector);
+
+/*
+* Creates a unit vector that is not x- or y-axis.
+*/
+function UnitVector(x, y) {
+ this.x = x;
+ this.y = y;
+ this.axis = undefined;
+ this.slope = y / x;
+ this.normalSlope = -x / y;
+ Object.freeze(this);
+}
+
+/*
+* Gets the projected distance between two points.
+* o1/o2 ... if true, respective original position is used.
+*/
+UnitVector.prototype.distance = function(p1, p2, o1, o2) {
+ return (
+ this.x * xUnitVector.distance(p1, p2, o1, o2) +
+ this.y * yUnitVector.distance(p1, p2, o1, o2)
+ );
+};
+
+/*
+* Moves point p so the moved position has the same relative
+* position to the moved positions of rp1 and rp2 than the
+* original positions had.
+*
+* See APPENDIX on INTERPOLATE at the bottom of this file.
+*/
+UnitVector.prototype.interpolate = function(p, rp1, rp2, pv) {
+ var dm1;
+ var dm2;
+ var do1;
+ var do2;
+ var doa1;
+ var doa2;
+ var dt;
+
+ do1 = pv.distance(p, rp1, true, true);
+ do2 = pv.distance(p, rp2, true, true);
+ dm1 = pv.distance(rp1, rp1, false, true);
+ dm2 = pv.distance(rp2, rp2, false, true);
+ doa1 = Math.abs(do1);
+ doa2 = Math.abs(do2);
+ dt = doa1 + doa2;
+
+ if (dt === 0) {
+ this.setRelative(p, p, (dm1 + dm2) / 2, pv, true);
+ return;
+ }
+
+ this.setRelative(p, p, (dm1 * doa2 + dm2 * doa1) / dt, pv, true);
+};
+
+/*
+* Sets the point 'p' relative to point 'rp'
+* by the distance 'd'
+*
+* See APPENDIX on SETRELATIVE at the bottom of this file.
+*
+* p ... point to set
+* rp ... reference point
+* d ... distance on projection vector
+* pv ... projection vector (undefined = this)
+* org ... if true, uses the original position of rp as reference.
+*/
+UnitVector.prototype.setRelative = function(p, rp, d, pv, org) {
+ pv = pv || this;
+
+ var rpx = org ? rp.xo : rp.x;
+ var rpy = org ? rp.yo : rp.y;
+ var rpdx = rpx + d * pv.x;
+ var rpdy = rpy + d * pv.y;
+
+ var pvns = pv.normalSlope;
+ var fvs = this.slope;
+
+ var px = p.x;
+ var py = p.y;
+
+ p.x = (fvs * px - pvns * rpdx + rpdy - py) / (fvs - pvns);
+ p.y = fvs * (p.x - px) + py;
+};
+
+/*
+* Touches the point p.
+*/
+UnitVector.prototype.touch = function(p) {
+ p.xTouched = true;
+ p.yTouched = true;
+};
+
+/*
+* Returns a unit vector with x/y coordinates.
+*/
+function getUnitVector(x, y) {
+ var d = Math.sqrt(x * x + y * y);
+
+ x /= d;
+ y /= d;
+
+ if (x === 1 && y === 0) { return xUnitVector; }
+ else if (x === 0 && y === 1) { return yUnitVector; }
+ else { return new UnitVector(x, y); }
+}
+
+/*
+* Creates a point in the hinting engine.
+*/
+function HPoint(
+ x,
+ y,
+ lastPointOfContour,
+ onCurve
+) {
+ this.x = this.xo = Math.round(x * 64) / 64; // hinted x value and original x-value
+ this.y = this.yo = Math.round(y * 64) / 64; // hinted y value and original y-value
+
+ this.lastPointOfContour = lastPointOfContour;
+ this.onCurve = onCurve;
+ this.prevPointOnContour = undefined;
+ this.nextPointOnContour = undefined;
+ this.xTouched = false;
+ this.yTouched = false;
+
+ Object.preventExtensions(this);
+}
+
+/*
+* Returns the next touched point on the contour.
+*
+* v ... unit vector to test touch axis.
+*/
+HPoint.prototype.nextTouched = function(v) {
+ var p = this.nextPointOnContour;
+
+ while (!v.touched(p) && p !== this) { p = p.nextPointOnContour; }
+
+ return p;
+};
+
+/*
+* Returns the previous touched point on the contour
+*
+* v ... unit vector to test touch axis.
+*/
+HPoint.prototype.prevTouched = function(v) {
+ var p = this.prevPointOnContour;
+
+ while (!v.touched(p) && p !== this) { p = p.prevPointOnContour; }
+
+ return p;
+};
+
+/*
+* The zero point.
+*/
+var HPZero = Object.freeze(new HPoint(0, 0));
+
+/*
+* The default state of the interpreter.
+*
+* Note: Freezing the defaultState and then deriving from it
+* makes the V8 Javascript engine going awkward,
+* so this is avoided, albeit the defaultState shouldn't
+* ever change.
+*/
+var defaultState = {
+ cvCutIn: 17 / 16, // control value cut in
+ deltaBase: 9,
+ deltaShift: 0.125,
+ loop: 1, // loops some instructions
+ minDis: 1, // minimum distance
+ autoFlip: true
+};
+
+/*
+* The current state of the interpreter.
+*
+* env ... 'fpgm' or 'prep' or 'glyf'
+* prog ... the program
+*/
+function State(env, prog) {
+ this.env = env;
+ this.stack = [];
+ this.prog = prog;
+
+ switch (env) {
+ case 'glyf' :
+ this.zp0 = this.zp1 = this.zp2 = 1;
+ this.rp0 = this.rp1 = this.rp2 = 0;
+ /* fall through */
+ case 'prep' :
+ this.fv = this.pv = this.dpv = xUnitVector;
+ this.round = roundToGrid;
+ }
+}
+
+/*
+* Executes a glyph program.
+*
+* This does the hinting for each glyph.
+*
+* Returns an array of moved points.
+*
+* glyph: the glyph to hint
+* ppem: the size the glyph is rendered for
+*/
+Hinting.prototype.exec = function(glyph, ppem) {
+ if (typeof ppem !== 'number') {
+ throw new Error('Point size is not a number!');
+ }
+
+ // Received a fatal error, don't do any hinting anymore.
+ if (this._errorState > 2) { return; }
+
+ var font = this.font;
+ var prepState = this._prepState;
+
+ if (!prepState || prepState.ppem !== ppem) {
+ var fpgmState = this._fpgmState;
+
+ if (!fpgmState) {
+ // Executes the fpgm state.
+ // This is used by fonts to define functions.
+ State.prototype = defaultState;
+
+ fpgmState =
+ this._fpgmState =
+ new State('fpgm', font.tables.fpgm);
+
+ fpgmState.funcs = [ ];
+ fpgmState.font = font;
+
+ if (exports.DEBUG) {
+ console.log('---EXEC FPGM---');
+ fpgmState.step = -1;
+ }
+
+ try {
+ exec(fpgmState);
+ } catch (e) {
+ console.log('Hinting error in FPGM:' + e);
+ this._errorState = 3;
+ return;
+ }
+ }
+
+ // Executes the prep program for this ppem setting.
+ // This is used by fonts to set cvt values
+ // depending on to be rendered font size.
+
+ State.prototype = fpgmState;
+ prepState =
+ this._prepState =
+ new State('prep', font.tables.prep);
+
+ prepState.ppem = ppem;
+
+ // Creates a copy of the cvt table
+ // and scales it to the current ppem setting.
+ var oCvt = font.tables.cvt;
+ if (oCvt) {
+ var cvt = prepState.cvt = new Array(oCvt.length);
+ var scale = ppem / font.unitsPerEm;
+ for (var c = 0; c < oCvt.length; c++) {
+ cvt[c] = oCvt[c] * scale;
+ }
+ } else {
+ prepState.cvt = [];
+ }
+
+ if (exports.DEBUG) {
+ console.log('---EXEC PREP---');
+ prepState.step = -1;
+ }
+
+ try {
+ exec(prepState);
+ } catch (e) {
+ if (this._errorState < 2) {
+ console.log('Hinting error in PREP:' + e);
+ }
+ this._errorState = 2;
+ }
+ }
+
+ if (this._errorState > 1) { return; }
+
+ try {
+ return execGlyph(glyph, prepState);
+ } catch (e) {
+ if (this._errorState < 1) {
+ console.log('Hinting error:' + e);
+ console.log('Note: further hinting errors are silenced');
+ }
+ this._errorState = 1;
+ return undefined;
+ }
+};
+
+/*
+* Executes the hinting program for a glyph.
+*/
+execGlyph = function(glyph, prepState) {
+ // original point positions
+ var xScale = prepState.ppem / prepState.font.unitsPerEm;
+ var yScale = xScale;
+ var components = glyph.components;
+ var contours;
+ var gZone;
+ var state;
+
+ State.prototype = prepState;
+ if (!components) {
+ state = new State('glyf', glyph.instructions);
+ if (exports.DEBUG) {
+ console.log('---EXEC GLYPH---');
+ state.step = -1;
+ }
+ execComponent(glyph, state, xScale, yScale);
+ gZone = state.gZone;
+ } else {
+ var font = prepState.font;
+ gZone = [];
+ contours = [];
+ for (var i = 0; i < components.length; i++) {
+ var c = components[i];
+ var cg = font.glyphs.get(c.glyphIndex);
+
+ state = new State('glyf', cg.instructions);
+
+ if (exports.DEBUG) {
+ console.log('---EXEC COMP ' + i + '---');
+ state.step = -1;
+ }
+
+ execComponent(cg, state, xScale, yScale);
+ // appends the computed points to the result array
+ // post processes the component points
+ var dx = Math.round(c.dx * xScale);
+ var dy = Math.round(c.dy * yScale);
+ var gz = state.gZone;
+ var cc = state.contours;
+ for (var pi = 0; pi < gz.length; pi++) {
+ var p = gz[pi];
+ p.xTouched = p.yTouched = false;
+ p.xo = p.x = p.x + dx;
+ p.yo = p.y = p.y + dy;
+ }
+
+ var gLen = gZone.length;
+ gZone.push.apply(gZone, gz);
+ for (var j = 0; j < cc.length; j++) {
+ contours.push(cc[j] + gLen);
+ }
+ }
+
+ if (glyph.instructions && !state.inhibitGridFit) {
+ // the composite has instructions on its own
+ state = new State('glyf', glyph.instructions);
+
+ state.gZone = state.z0 = state.z1 = state.z2 = gZone;
+
+ state.contours = contours;
+
+ // note: HPZero cannot be used here, since
+ // the point might be modified
+ gZone.push(
+ new HPoint(0, 0),
+ new HPoint(Math.round(glyph.advanceWidth * xScale), 0)
+ );
+
+ if (exports.DEBUG) {
+ console.log('---EXEC COMPOSITE---');
+ state.step = -1;
+ }
+
+ exec(state);
+
+ gZone.length -= 2;
+ }
+ }
+
+ return gZone;
+};
+
+/*
+* Executes the hinting program for a component of a multi-component glyph
+* or of the glyph itself for a non-component glyph.
+*/
+execComponent = function(glyph, state, xScale, yScale)
+{
+ var points = glyph.points || [];
+ var pLen = points.length;
+ var gZone = state.gZone = state.z0 = state.z1 = state.z2 = [];
+ var contours = state.contours = [];
+
+ // Scales the original points and
+ // makes copies for the hinted points.
+ var cp; // current point
+ for (var i = 0; i < pLen; i++) {
+ cp = points[i];
+
+ gZone[i] = new HPoint(
+ cp.x * xScale,
+ cp.y * yScale,
+ cp.lastPointOfContour,
+ cp.onCurve
+ );
+ }
+
+ // Chain links the contours.
+ var sp; // start point
+ var np; // next point
+
+ for (var i$1 = 0; i$1 < pLen; i$1++) {
+ cp = gZone[i$1];
+
+ if (!sp) {
+ sp = cp;
+ contours.push(i$1);
+ }
+
+ if (cp.lastPointOfContour) {
+ cp.nextPointOnContour = sp;
+ sp.prevPointOnContour = cp;
+ sp = undefined;
+ } else {
+ np = gZone[i$1 + 1];
+ cp.nextPointOnContour = np;
+ np.prevPointOnContour = cp;
+ }
+ }
+
+ if (state.inhibitGridFit) { return; }
+
+ if (exports.DEBUG) {
+ console.log('PROCESSING GLYPH', state.stack);
+ for (var i$2 = 0; i$2 < pLen; i$2++) {
+ console.log(i$2, gZone[i$2].x, gZone[i$2].y);
+ }
+ }
+
+ gZone.push(
+ new HPoint(0, 0),
+ new HPoint(Math.round(glyph.advanceWidth * xScale), 0)
+ );
+
+ exec(state);
+
+ // Removes the extra points.
+ gZone.length -= 2;
+
+ if (exports.DEBUG) {
+ console.log('FINISHED GLYPH', state.stack);
+ for (var i$3 = 0; i$3 < pLen; i$3++) {
+ console.log(i$3, gZone[i$3].x, gZone[i$3].y);
+ }
+ }
+};
+
+/*
+* Executes the program loaded in state.
+*/
+exec = function(state) {
+ var prog = state.prog;
+
+ if (!prog) { return; }
+
+ var pLen = prog.length;
+ var ins;
+
+ for (state.ip = 0; state.ip < pLen; state.ip++) {
+ if (exports.DEBUG) { state.step++; }
+ ins = instructionTable[prog[state.ip]];
+
+ if (!ins) {
+ throw new Error(
+ 'unknown instruction: 0x' +
+ Number(prog[state.ip]).toString(16)
+ );
+ }
+
+ ins(state);
+
+ // very extensive debugging for each step
+ /*
+ if (exports.DEBUG) {
+ var da;
+ if (state.gZone) {
+ da = [];
+ for (let i = 0; i < state.gZone.length; i++)
+ {
+ da.push(i + ' ' +
+ state.gZone[i].x * 64 + ' ' +
+ state.gZone[i].y * 64 + ' ' +
+ (state.gZone[i].xTouched ? 'x' : '') +
+ (state.gZone[i].yTouched ? 'y' : '')
+ );
+ }
+ console.log('GZ', da);
+ }
+
+ if (state.tZone) {
+ da = [];
+ for (let i = 0; i < state.tZone.length; i++) {
+ da.push(i + ' ' +
+ state.tZone[i].x * 64 + ' ' +
+ state.tZone[i].y * 64 + ' ' +
+ (state.tZone[i].xTouched ? 'x' : '') +
+ (state.tZone[i].yTouched ? 'y' : '')
+ );
+ }
+ console.log('TZ', da);
+ }
+
+ if (state.stack.length > 10) {
+ console.log(
+ state.stack.length,
+ '...', state.stack.slice(state.stack.length - 10)
+ );
+ } else {
+ console.log(state.stack.length, state.stack);
+ }
+ }
+ */
+ }
+};
+
+/*
+* Initializes the twilight zone.
+*
+* This is only done if a SZPx instruction
+* refers to the twilight zone.
+*/
+function initTZone(state)
+{
+ var tZone = state.tZone = new Array(state.gZone.length);
+
+ // no idea if this is actually correct...
+ for (var i = 0; i < tZone.length; i++)
+ {
+ tZone[i] = new HPoint(0, 0);
+ }
+}
+
+/*
+* Skips the instruction pointer ahead over an IF/ELSE block.
+* handleElse .. if true breaks on matching ELSE
+*/
+function skip(state, handleElse)
+{
+ var prog = state.prog;
+ var ip = state.ip;
+ var nesting = 1;
+ var ins;
+
+ do {
+ ins = prog[++ip];
+ if (ins === 0x58) // IF
+ { nesting++; }
+ else if (ins === 0x59) // EIF
+ { nesting--; }
+ else if (ins === 0x40) // NPUSHB
+ { ip += prog[ip + 1] + 1; }
+ else if (ins === 0x41) // NPUSHW
+ { ip += 2 * prog[ip + 1] + 1; }
+ else if (ins >= 0xB0 && ins <= 0xB7) // PUSHB
+ { ip += ins - 0xB0 + 1; }
+ else if (ins >= 0xB8 && ins <= 0xBF) // PUSHW
+ { ip += (ins - 0xB8 + 1) * 2; }
+ else if (handleElse && nesting === 1 && ins === 0x1B) // ELSE
+ { break; }
+ } while (nesting > 0);
+
+ state.ip = ip;
+}
+
+/*----------------------------------------------------------*
+* And then a lot of instructions... *
+*----------------------------------------------------------*/
+
+// SVTCA[a] Set freedom and projection Vectors To Coordinate Axis
+// 0x00-0x01
+function SVTCA(v, state) {
+ if (exports.DEBUG) { console.log(state.step, 'SVTCA[' + v.axis + ']'); }
+
+ state.fv = state.pv = state.dpv = v;
+}
+
+// SPVTCA[a] Set Projection Vector to Coordinate Axis
+// 0x02-0x03
+function SPVTCA(v, state) {
+ if (exports.DEBUG) { console.log(state.step, 'SPVTCA[' + v.axis + ']'); }
+
+ state.pv = state.dpv = v;
+}
+
+// SFVTCA[a] Set Freedom Vector to Coordinate Axis
+// 0x04-0x05
+function SFVTCA(v, state) {
+ if (exports.DEBUG) { console.log(state.step, 'SFVTCA[' + v.axis + ']'); }
+
+ state.fv = v;
+}
+
+// SPVTL[a] Set Projection Vector To Line
+// 0x06-0x07
+function SPVTL(a, state) {
+ var stack = state.stack;
+ var p2i = stack.pop();
+ var p1i = stack.pop();
+ var p2 = state.z2[p2i];
+ var p1 = state.z1[p1i];
+
+ if (exports.DEBUG) { console.log('SPVTL[' + a + ']', p2i, p1i); }
+
+ var dx;
+ var dy;
+
+ if (!a) {
+ dx = p1.x - p2.x;
+ dy = p1.y - p2.y;
+ } else {
+ dx = p2.y - p1.y;
+ dy = p1.x - p2.x;
+ }
+
+ state.pv = state.dpv = getUnitVector(dx, dy);
+}
+
+// SFVTL[a] Set Freedom Vector To Line
+// 0x08-0x09
+function SFVTL(a, state) {
+ var stack = state.stack;
+ var p2i = stack.pop();
+ var p1i = stack.pop();
+ var p2 = state.z2[p2i];
+ var p1 = state.z1[p1i];
+
+ if (exports.DEBUG) { console.log('SFVTL[' + a + ']', p2i, p1i); }
+
+ var dx;
+ var dy;
+
+ if (!a) {
+ dx = p1.x - p2.x;
+ dy = p1.y - p2.y;
+ } else {
+ dx = p2.y - p1.y;
+ dy = p1.x - p2.x;
+ }
+
+ state.fv = getUnitVector(dx, dy);
+}
+
+// SPVFS[] Set Projection Vector From Stack
+// 0x0A
+function SPVFS(state) {
+ var stack = state.stack;
+ var y = stack.pop();
+ var x = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SPVFS[]', y, x); }
+
+ state.pv = state.dpv = getUnitVector(x, y);
+}
+
+// SFVFS[] Set Freedom Vector From Stack
+// 0x0B
+function SFVFS(state) {
+ var stack = state.stack;
+ var y = stack.pop();
+ var x = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SPVFS[]', y, x); }
+
+ state.fv = getUnitVector(x, y);
+}
+
+// GPV[] Get Projection Vector
+// 0x0C
+function GPV(state) {
+ var stack = state.stack;
+ var pv = state.pv;
+
+ if (exports.DEBUG) { console.log(state.step, 'GPV[]'); }
+
+ stack.push(pv.x * 0x4000);
+ stack.push(pv.y * 0x4000);
+}
+
+// GFV[] Get Freedom Vector
+// 0x0C
+function GFV(state) {
+ var stack = state.stack;
+ var fv = state.fv;
+
+ if (exports.DEBUG) { console.log(state.step, 'GFV[]'); }
+
+ stack.push(fv.x * 0x4000);
+ stack.push(fv.y * 0x4000);
+}
+
+// SFVTPV[] Set Freedom Vector To Projection Vector
+// 0x0E
+function SFVTPV(state) {
+ state.fv = state.pv;
+
+ if (exports.DEBUG) { console.log(state.step, 'SFVTPV[]'); }
+}
+
+// ISECT[] moves point p to the InterSECTion of two lines
+// 0x0F
+function ISECT(state)
+{
+ var stack = state.stack;
+ var pa0i = stack.pop();
+ var pa1i = stack.pop();
+ var pb0i = stack.pop();
+ var pb1i = stack.pop();
+ var pi = stack.pop();
+ var z0 = state.z0;
+ var z1 = state.z1;
+ var pa0 = z0[pa0i];
+ var pa1 = z0[pa1i];
+ var pb0 = z1[pb0i];
+ var pb1 = z1[pb1i];
+ var p = state.z2[pi];
+
+ if (exports.DEBUG) { console.log('ISECT[], ', pa0i, pa1i, pb0i, pb1i, pi); }
+
+ // math from
+ // en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line
+
+ var x1 = pa0.x;
+ var y1 = pa0.y;
+ var x2 = pa1.x;
+ var y2 = pa1.y;
+ var x3 = pb0.x;
+ var y3 = pb0.y;
+ var x4 = pb1.x;
+ var y4 = pb1.y;
+
+ var div = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
+ var f1 = x1 * y2 - y1 * x2;
+ var f2 = x3 * y4 - y3 * x4;
+
+ p.x = (f1 * (x3 - x4) - f2 * (x1 - x2)) / div;
+ p.y = (f1 * (y3 - y4) - f2 * (y1 - y2)) / div;
+}
+
+// SRP0[] Set Reference Point 0
+// 0x10
+function SRP0(state) {
+ state.rp0 = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SRP0[]', state.rp0); }
+}
+
+// SRP1[] Set Reference Point 1
+// 0x11
+function SRP1(state) {
+ state.rp1 = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SRP1[]', state.rp1); }
+}
+
+// SRP1[] Set Reference Point 2
+// 0x12
+function SRP2(state) {
+ state.rp2 = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SRP2[]', state.rp2); }
+}
+
+// SZP0[] Set Zone Pointer 0
+// 0x13
+function SZP0(state) {
+ var n = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SZP0[]', n); }
+
+ state.zp0 = n;
+
+ switch (n) {
+ case 0:
+ if (!state.tZone) { initTZone(state); }
+ state.z0 = state.tZone;
+ break;
+ case 1 :
+ state.z0 = state.gZone;
+ break;
+ default :
+ throw new Error('Invalid zone pointer');
+ }
+}
+
+// SZP1[] Set Zone Pointer 1
+// 0x14
+function SZP1(state) {
+ var n = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SZP1[]', n); }
+
+ state.zp1 = n;
+
+ switch (n) {
+ case 0:
+ if (!state.tZone) { initTZone(state); }
+ state.z1 = state.tZone;
+ break;
+ case 1 :
+ state.z1 = state.gZone;
+ break;
+ default :
+ throw new Error('Invalid zone pointer');
+ }
+}
+
+// SZP2[] Set Zone Pointer 2
+// 0x15
+function SZP2(state) {
+ var n = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SZP2[]', n); }
+
+ state.zp2 = n;
+
+ switch (n) {
+ case 0:
+ if (!state.tZone) { initTZone(state); }
+ state.z2 = state.tZone;
+ break;
+ case 1 :
+ state.z2 = state.gZone;
+ break;
+ default :
+ throw new Error('Invalid zone pointer');
+ }
+}
+
+// SZPS[] Set Zone PointerS
+// 0x16
+function SZPS(state) {
+ var n = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SZPS[]', n); }
+
+ state.zp0 = state.zp1 = state.zp2 = n;
+
+ switch (n) {
+ case 0:
+ if (!state.tZone) { initTZone(state); }
+ state.z0 = state.z1 = state.z2 = state.tZone;
+ break;
+ case 1 :
+ state.z0 = state.z1 = state.z2 = state.gZone;
+ break;
+ default :
+ throw new Error('Invalid zone pointer');
+ }
+}
+
+// SLOOP[] Set LOOP variable
+// 0x17
+function SLOOP(state) {
+ state.loop = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SLOOP[]', state.loop); }
+}
+
+// RTG[] Round To Grid
+// 0x18
+function RTG(state) {
+ if (exports.DEBUG) { console.log(state.step, 'RTG[]'); }
+
+ state.round = roundToGrid;
+}
+
+// RTHG[] Round To Half Grid
+// 0x19
+function RTHG(state) {
+ if (exports.DEBUG) { console.log(state.step, 'RTHG[]'); }
+
+ state.round = roundToHalfGrid;
+}
+
+// SMD[] Set Minimum Distance
+// 0x1A
+function SMD(state) {
+ var d = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SMD[]', d); }
+
+ state.minDis = d / 0x40;
+}
+
+// ELSE[] ELSE clause
+// 0x1B
+function ELSE(state) {
+ // This instruction has been reached by executing a then branch
+ // so it just skips ahead until matching EIF.
+ //
+ // In case the IF was negative the IF[] instruction already
+ // skipped forward over the ELSE[]
+
+ if (exports.DEBUG) { console.log(state.step, 'ELSE[]'); }
+
+ skip(state, false);
+}
+
+// JMPR[] JuMP Relative
+// 0x1C
+function JMPR(state) {
+ var o = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'JMPR[]', o); }
+
+ // A jump by 1 would do nothing.
+ state.ip += o - 1;
+}
+
+// SCVTCI[] Set Control Value Table Cut-In
+// 0x1D
+function SCVTCI(state) {
+ var n = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SCVTCI[]', n); }
+
+ state.cvCutIn = n / 0x40;
+}
+
+// DUP[] DUPlicate top stack element
+// 0x20
+function DUP(state) {
+ var stack = state.stack;
+
+ if (exports.DEBUG) { console.log(state.step, 'DUP[]'); }
+
+ stack.push(stack[stack.length - 1]);
+}
+
+// POP[] POP top stack element
+// 0x21
+function POP(state) {
+ if (exports.DEBUG) { console.log(state.step, 'POP[]'); }
+
+ state.stack.pop();
+}
+
+// CLEAR[] CLEAR the stack
+// 0x22
+function CLEAR(state) {
+ if (exports.DEBUG) { console.log(state.step, 'CLEAR[]'); }
+
+ state.stack.length = 0;
+}
+
+// SWAP[] SWAP the top two elements on the stack
+// 0x23
+function SWAP(state) {
+ var stack = state.stack;
+
+ var a = stack.pop();
+ var b = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SWAP[]'); }
+
+ stack.push(a);
+ stack.push(b);
+}
+
+// DEPTH[] DEPTH of the stack
+// 0x24
+function DEPTH(state) {
+ var stack = state.stack;
+
+ if (exports.DEBUG) { console.log(state.step, 'DEPTH[]'); }
+
+ stack.push(stack.length);
+}
+
+// LOOPCALL[] LOOPCALL function
+// 0x2A
+function LOOPCALL(state) {
+ var stack = state.stack;
+ var fn = stack.pop();
+ var c = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'LOOPCALL[]', fn, c); }
+
+ // saves callers program
+ var cip = state.ip;
+ var cprog = state.prog;
+
+ state.prog = state.funcs[fn];
+
+ // executes the function
+ for (var i = 0; i < c; i++) {
+ exec(state);
+
+ if (exports.DEBUG) { console.log(
+ ++state.step,
+ i + 1 < c ? 'next loopcall' : 'done loopcall',
+ i
+ ); }
+ }
+
+ // restores the callers program
+ state.ip = cip;
+ state.prog = cprog;
+}
+
+// CALL[] CALL function
+// 0x2B
+function CALL(state) {
+ var fn = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'CALL[]', fn); }
+
+ // saves callers program
+ var cip = state.ip;
+ var cprog = state.prog;
+
+ state.prog = state.funcs[fn];
+
+ // executes the function
+ exec(state);
+
+ // restores the callers program
+ state.ip = cip;
+ state.prog = cprog;
+
+ if (exports.DEBUG) { console.log(++state.step, 'returning from', fn); }
+}
+
+// CINDEX[] Copy the INDEXed element to the top of the stack
+// 0x25
+function CINDEX(state) {
+ var stack = state.stack;
+ var k = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'CINDEX[]', k); }
+
+ // In case of k == 1, it copies the last element after popping
+ // thus stack.length - k.
+ stack.push(stack[stack.length - k]);
+}
+
+// MINDEX[] Move the INDEXed element to the top of the stack
+// 0x26
+function MINDEX(state) {
+ var stack = state.stack;
+ var k = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'MINDEX[]', k); }
+
+ stack.push(stack.splice(stack.length - k, 1)[0]);
+}
+
+// FDEF[] Function DEFinition
+// 0x2C
+function FDEF(state) {
+ if (state.env !== 'fpgm') { throw new Error('FDEF not allowed here'); }
+ var stack = state.stack;
+ var prog = state.prog;
+ var ip = state.ip;
+
+ var fn = stack.pop();
+ var ipBegin = ip;
+
+ if (exports.DEBUG) { console.log(state.step, 'FDEF[]', fn); }
+
+ while (prog[++ip] !== 0x2D){ }
+
+ state.ip = ip;
+ state.funcs[fn] = prog.slice(ipBegin + 1, ip);
+}
+
+// MDAP[a] Move Direct Absolute Point
+// 0x2E-0x2F
+function MDAP(round, state) {
+ var pi = state.stack.pop();
+ var p = state.z0[pi];
+ var fv = state.fv;
+ var pv = state.pv;
+
+ if (exports.DEBUG) { console.log(state.step, 'MDAP[' + round + ']', pi); }
+
+ var d = pv.distance(p, HPZero);
+
+ if (round) { d = state.round(d); }
+
+ fv.setRelative(p, HPZero, d, pv);
+ fv.touch(p);
+
+ state.rp0 = state.rp1 = pi;
+}
+
+// IUP[a] Interpolate Untouched Points through the outline
+// 0x30
+function IUP(v, state) {
+ var z2 = state.z2;
+ var pLen = z2.length - 2;
+ var cp;
+ var pp;
+ var np;
+
+ if (exports.DEBUG) { console.log(state.step, 'IUP[' + v.axis + ']'); }
+
+ for (var i = 0; i < pLen; i++) {
+ cp = z2[i]; // current point
+
+ // if this point has been touched go on
+ if (v.touched(cp)) { continue; }
+
+ pp = cp.prevTouched(v);
+
+ // no point on the contour has been touched?
+ if (pp === cp) { continue; }
+
+ np = cp.nextTouched(v);
+
+ if (pp === np) {
+ // only one point on the contour has been touched
+ // so simply moves the point like that
+
+ v.setRelative(cp, cp, v.distance(pp, pp, false, true), v, true);
+ }
+
+ v.interpolate(cp, pp, np, v);
+ }
+}
+
+// SHP[] SHift Point using reference point
+// 0x32-0x33
+function SHP(a, state) {
+ var stack = state.stack;
+ var rpi = a ? state.rp1 : state.rp2;
+ var rp = (a ? state.z0 : state.z1)[rpi];
+ var fv = state.fv;
+ var pv = state.pv;
+ var loop = state.loop;
+ var z2 = state.z2;
+
+ while (loop--)
+ {
+ var pi = stack.pop();
+ var p = z2[pi];
+
+ var d = pv.distance(rp, rp, false, true);
+ fv.setRelative(p, p, d, pv);
+ fv.touch(p);
+
+ if (exports.DEBUG) {
+ console.log(
+ state.step,
+ (state.loop > 1 ?
+ 'loop ' + (state.loop - loop) + ': ' :
+ ''
+ ) +
+ 'SHP[' + (a ? 'rp1' : 'rp2') + ']', pi
+ );
+ }
+ }
+
+ state.loop = 1;
+}
+
+// SHC[] SHift Contour using reference point
+// 0x36-0x37
+function SHC(a, state) {
+ var stack = state.stack;
+ var rpi = a ? state.rp1 : state.rp2;
+ var rp = (a ? state.z0 : state.z1)[rpi];
+ var fv = state.fv;
+ var pv = state.pv;
+ var ci = stack.pop();
+ var sp = state.z2[state.contours[ci]];
+ var p = sp;
+
+ if (exports.DEBUG) { console.log(state.step, 'SHC[' + a + ']', ci); }
+
+ var d = pv.distance(rp, rp, false, true);
+
+ do {
+ if (p !== rp) { fv.setRelative(p, p, d, pv); }
+ p = p.nextPointOnContour;
+ } while (p !== sp);
+}
+
+// SHZ[] SHift Zone using reference point
+// 0x36-0x37
+function SHZ(a, state) {
+ var stack = state.stack;
+ var rpi = a ? state.rp1 : state.rp2;
+ var rp = (a ? state.z0 : state.z1)[rpi];
+ var fv = state.fv;
+ var pv = state.pv;
+
+ var e = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SHZ[' + a + ']', e); }
+
+ var z;
+ switch (e) {
+ case 0 : z = state.tZone; break;
+ case 1 : z = state.gZone; break;
+ default : throw new Error('Invalid zone');
+ }
+
+ var p;
+ var d = pv.distance(rp, rp, false, true);
+ var pLen = z.length - 2;
+ for (var i = 0; i < pLen; i++)
+ {
+ p = z[i];
+ fv.setRelative(p, p, d, pv);
+ //if (p !== rp) fv.setRelative(p, p, d, pv);
+ }
+}
+
+// SHPIX[] SHift point by a PIXel amount
+// 0x38
+function SHPIX(state) {
+ var stack = state.stack;
+ var loop = state.loop;
+ var fv = state.fv;
+ var d = stack.pop() / 0x40;
+ var z2 = state.z2;
+
+ while (loop--) {
+ var pi = stack.pop();
+ var p = z2[pi];
+
+ if (exports.DEBUG) {
+ console.log(
+ state.step,
+ (state.loop > 1 ? 'loop ' + (state.loop - loop) + ': ' : '') +
+ 'SHPIX[]', pi, d
+ );
+ }
+
+ fv.setRelative(p, p, d);
+ fv.touch(p);
+ }
+
+ state.loop = 1;
+}
+
+// IP[] Interpolate Point
+// 0x39
+function IP(state) {
+ var stack = state.stack;
+ var rp1i = state.rp1;
+ var rp2i = state.rp2;
+ var loop = state.loop;
+ var rp1 = state.z0[rp1i];
+ var rp2 = state.z1[rp2i];
+ var fv = state.fv;
+ var pv = state.dpv;
+ var z2 = state.z2;
+
+ while (loop--) {
+ var pi = stack.pop();
+ var p = z2[pi];
+
+ if (exports.DEBUG) {
+ console.log(
+ state.step,
+ (state.loop > 1 ? 'loop ' + (state.loop - loop) + ': ' : '') +
+ 'IP[]', pi, rp1i, '<->', rp2i
+ );
+ }
+
+ fv.interpolate(p, rp1, rp2, pv);
+
+ fv.touch(p);
+ }
+
+ state.loop = 1;
+}
+
+// MSIRP[a] Move Stack Indirect Relative Point
+// 0x3A-0x3B
+function MSIRP(a, state) {
+ var stack = state.stack;
+ var d = stack.pop() / 64;
+ var pi = stack.pop();
+ var p = state.z1[pi];
+ var rp0 = state.z0[state.rp0];
+ var fv = state.fv;
+ var pv = state.pv;
+
+ fv.setRelative(p, rp0, d, pv);
+ fv.touch(p);
+
+ if (exports.DEBUG) { console.log(state.step, 'MSIRP[' + a + ']', d, pi); }
+
+ state.rp1 = state.rp0;
+ state.rp2 = pi;
+ if (a) { state.rp0 = pi; }
+}
+
+// ALIGNRP[] Align to reference point.
+// 0x3C
+function ALIGNRP(state) {
+ var stack = state.stack;
+ var rp0i = state.rp0;
+ var rp0 = state.z0[rp0i];
+ var loop = state.loop;
+ var fv = state.fv;
+ var pv = state.pv;
+ var z1 = state.z1;
+
+ while (loop--) {
+ var pi = stack.pop();
+ var p = z1[pi];
+
+ if (exports.DEBUG) {
+ console.log(
+ state.step,
+ (state.loop > 1 ? 'loop ' + (state.loop - loop) + ': ' : '') +
+ 'ALIGNRP[]', pi
+ );
+ }
+
+ fv.setRelative(p, rp0, 0, pv);
+ fv.touch(p);
+ }
+
+ state.loop = 1;
+}
+
+// RTG[] Round To Double Grid
+// 0x3D
+function RTDG(state) {
+ if (exports.DEBUG) { console.log(state.step, 'RTDG[]'); }
+
+ state.round = roundToDoubleGrid;
+}
+
+// MIAP[a] Move Indirect Absolute Point
+// 0x3E-0x3F
+function MIAP(round, state) {
+ var stack = state.stack;
+ var n = stack.pop();
+ var pi = stack.pop();
+ var p = state.z0[pi];
+ var fv = state.fv;
+ var pv = state.pv;
+ var cv = state.cvt[n];
+
+ if (exports.DEBUG) {
+ console.log(
+ state.step,
+ 'MIAP[' + round + ']',
+ n, '(', cv, ')', pi
+ );
+ }
+
+ var d = pv.distance(p, HPZero);
+
+ if (round) {
+ if (Math.abs(d - cv) < state.cvCutIn) { d = cv; }
+
+ d = state.round(d);
+ }
+
+ fv.setRelative(p, HPZero, d, pv);
+
+ if (state.zp0 === 0) {
+ p.xo = p.x;
+ p.yo = p.y;
+ }
+
+ fv.touch(p);
+
+ state.rp0 = state.rp1 = pi;
+}
+
+// NPUSB[] PUSH N Bytes
+// 0x40
+function NPUSHB(state) {
+ var prog = state.prog;
+ var ip = state.ip;
+ var stack = state.stack;
+
+ var n = prog[++ip];
+
+ if (exports.DEBUG) { console.log(state.step, 'NPUSHB[]', n); }
+
+ for (var i = 0; i < n; i++) { stack.push(prog[++ip]); }
+
+ state.ip = ip;
+}
+
+// NPUSHW[] PUSH N Words
+// 0x41
+function NPUSHW(state) {
+ var ip = state.ip;
+ var prog = state.prog;
+ var stack = state.stack;
+ var n = prog[++ip];
+
+ if (exports.DEBUG) { console.log(state.step, 'NPUSHW[]', n); }
+
+ for (var i = 0; i < n; i++) {
+ var w = (prog[++ip] << 8) | prog[++ip];
+ if (w & 0x8000) { w = -((w ^ 0xffff) + 1); }
+ stack.push(w);
+ }
+
+ state.ip = ip;
+}
+
+// WS[] Write Store
+// 0x42
+function WS(state) {
+ var stack = state.stack;
+ var store = state.store;
+
+ if (!store) { store = state.store = []; }
+
+ var v = stack.pop();
+ var l = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'WS', v, l); }
+
+ store[l] = v;
+}
+
+// RS[] Read Store
+// 0x43
+function RS(state) {
+ var stack = state.stack;
+ var store = state.store;
+
+ var l = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'RS', l); }
+
+ var v = (store && store[l]) || 0;
+
+ stack.push(v);
+}
+
+// WCVTP[] Write Control Value Table in Pixel units
+// 0x44
+function WCVTP(state) {
+ var stack = state.stack;
+
+ var v = stack.pop();
+ var l = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'WCVTP', v, l); }
+
+ state.cvt[l] = v / 0x40;
+}
+
+// RCVT[] Read Control Value Table entry
+// 0x45
+function RCVT(state) {
+ var stack = state.stack;
+ var cvte = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'RCVT', cvte); }
+
+ stack.push(state.cvt[cvte] * 0x40);
+}
+
+// GC[] Get Coordinate projected onto the projection vector
+// 0x46-0x47
+function GC(a, state) {
+ var stack = state.stack;
+ var pi = stack.pop();
+ var p = state.z2[pi];
+
+ if (exports.DEBUG) { console.log(state.step, 'GC[' + a + ']', pi); }
+
+ stack.push(state.dpv.distance(p, HPZero, a, false) * 0x40);
+}
+
+// MD[a] Measure Distance
+// 0x49-0x4A
+function MD(a, state) {
+ var stack = state.stack;
+ var pi2 = stack.pop();
+ var pi1 = stack.pop();
+ var p2 = state.z1[pi2];
+ var p1 = state.z0[pi1];
+ var d = state.dpv.distance(p1, p2, a, a);
+
+ if (exports.DEBUG) { console.log(state.step, 'MD[' + a + ']', pi2, pi1, '->', d); }
+
+ state.stack.push(Math.round(d * 64));
+}
+
+// MPPEM[] Measure Pixels Per EM
+// 0x4B
+function MPPEM(state) {
+ if (exports.DEBUG) { console.log(state.step, 'MPPEM[]'); }
+ state.stack.push(state.ppem);
+}
+
+// FLIPON[] set the auto FLIP Boolean to ON
+// 0x4D
+function FLIPON(state) {
+ if (exports.DEBUG) { console.log(state.step, 'FLIPON[]'); }
+ state.autoFlip = true;
+}
+
+// LT[] Less Than
+// 0x50
+function LT(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'LT[]', e2, e1); }
+
+ stack.push(e1 < e2 ? 1 : 0);
+}
+
+// LTEQ[] Less Than or EQual
+// 0x53
+function LTEQ(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'LTEQ[]', e2, e1); }
+
+ stack.push(e1 <= e2 ? 1 : 0);
+}
+
+// GTEQ[] Greater Than
+// 0x52
+function GT(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'GT[]', e2, e1); }
+
+ stack.push(e1 > e2 ? 1 : 0);
+}
+
+// GTEQ[] Greater Than or EQual
+// 0x53
+function GTEQ(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'GTEQ[]', e2, e1); }
+
+ stack.push(e1 >= e2 ? 1 : 0);
+}
+
+// EQ[] EQual
+// 0x54
+function EQ(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'EQ[]', e2, e1); }
+
+ stack.push(e2 === e1 ? 1 : 0);
+}
+
+// NEQ[] Not EQual
+// 0x55
+function NEQ(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'NEQ[]', e2, e1); }
+
+ stack.push(e2 !== e1 ? 1 : 0);
+}
+
+// ODD[] ODD
+// 0x56
+function ODD(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'ODD[]', n); }
+
+ stack.push(Math.trunc(n) % 2 ? 1 : 0);
+}
+
+// EVEN[] EVEN
+// 0x57
+function EVEN(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'EVEN[]', n); }
+
+ stack.push(Math.trunc(n) % 2 ? 0 : 1);
+}
+
+// IF[] IF test
+// 0x58
+function IF(state) {
+ var test = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'IF[]', test); }
+
+ // if test is true it just continues
+ // if not the ip is skipped until matching ELSE or EIF
+ if (!test) {
+ skip(state, true);
+
+ if (exports.DEBUG) { console.log(state.step, 'EIF[]'); }
+ }
+}
+
+// EIF[] End IF
+// 0x59
+function EIF(state) {
+ // this can be reached normally when
+ // executing an else branch.
+ // -> just ignore it
+
+ if (exports.DEBUG) { console.log(state.step, 'EIF[]'); }
+}
+
+// AND[] logical AND
+// 0x5A
+function AND(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'AND[]', e2, e1); }
+
+ stack.push(e2 && e1 ? 1 : 0);
+}
+
+// OR[] logical OR
+// 0x5B
+function OR(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'OR[]', e2, e1); }
+
+ stack.push(e2 || e1 ? 1 : 0);
+}
+
+// NOT[] logical NOT
+// 0x5C
+function NOT(state) {
+ var stack = state.stack;
+ var e = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'NOT[]', e); }
+
+ stack.push(e ? 0 : 1);
+}
+
+// DELTAP1[] DELTA exception P1
+// DELTAP2[] DELTA exception P2
+// DELTAP3[] DELTA exception P3
+// 0x5D, 0x71, 0x72
+function DELTAP123(b, state) {
+ var stack = state.stack;
+ var n = stack.pop();
+ var fv = state.fv;
+ var pv = state.pv;
+ var ppem = state.ppem;
+ var base = state.deltaBase + (b - 1) * 16;
+ var ds = state.deltaShift;
+ var z0 = state.z0;
+
+ if (exports.DEBUG) { console.log(state.step, 'DELTAP[' + b + ']', n, stack); }
+
+ for (var i = 0; i < n; i++) {
+ var pi = stack.pop();
+ var arg = stack.pop();
+ var appem = base + ((arg & 0xF0) >> 4);
+ if (appem !== ppem) { continue; }
+
+ var mag = (arg & 0x0F) - 8;
+ if (mag >= 0) { mag++; }
+ if (exports.DEBUG) { console.log(state.step, 'DELTAPFIX', pi, 'by', mag * ds); }
+
+ var p = z0[pi];
+ fv.setRelative(p, p, mag * ds, pv);
+ }
+}
+
+// SDB[] Set Delta Base in the graphics state
+// 0x5E
+function SDB(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SDB[]', n); }
+
+ state.deltaBase = n;
+}
+
+// SDS[] Set Delta Shift in the graphics state
+// 0x5F
+function SDS(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SDS[]', n); }
+
+ state.deltaShift = Math.pow(0.5, n);
+}
+
+// ADD[] ADD
+// 0x60
+function ADD(state) {
+ var stack = state.stack;
+ var n2 = stack.pop();
+ var n1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'ADD[]', n2, n1); }
+
+ stack.push(n1 + n2);
+}
+
+// SUB[] SUB
+// 0x61
+function SUB(state) {
+ var stack = state.stack;
+ var n2 = stack.pop();
+ var n1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SUB[]', n2, n1); }
+
+ stack.push(n1 - n2);
+}
+
+// DIV[] DIV
+// 0x62
+function DIV(state) {
+ var stack = state.stack;
+ var n2 = stack.pop();
+ var n1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'DIV[]', n2, n1); }
+
+ stack.push(n1 * 64 / n2);
+}
+
+// MUL[] MUL
+// 0x63
+function MUL(state) {
+ var stack = state.stack;
+ var n2 = stack.pop();
+ var n1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'MUL[]', n2, n1); }
+
+ stack.push(n1 * n2 / 64);
+}
+
+// ABS[] ABSolute value
+// 0x64
+function ABS(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'ABS[]', n); }
+
+ stack.push(Math.abs(n));
+}
+
+// NEG[] NEGate
+// 0x65
+function NEG(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'NEG[]', n); }
+
+ stack.push(-n);
+}
+
+// FLOOR[] FLOOR
+// 0x66
+function FLOOR(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'FLOOR[]', n); }
+
+ stack.push(Math.floor(n / 0x40) * 0x40);
+}
+
+// CEILING[] CEILING
+// 0x67
+function CEILING(state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'CEILING[]', n); }
+
+ stack.push(Math.ceil(n / 0x40) * 0x40);
+}
+
+// ROUND[ab] ROUND value
+// 0x68-0x6B
+function ROUND(dt, state) {
+ var stack = state.stack;
+ var n = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'ROUND[]'); }
+
+ stack.push(state.round(n / 0x40) * 0x40);
+}
+
+// WCVTF[] Write Control Value Table in Funits
+// 0x70
+function WCVTF(state) {
+ var stack = state.stack;
+ var v = stack.pop();
+ var l = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'WCVTF[]', v, l); }
+
+ state.cvt[l] = v * state.ppem / state.font.unitsPerEm;
+}
+
+// DELTAC1[] DELTA exception C1
+// DELTAC2[] DELTA exception C2
+// DELTAC3[] DELTA exception C3
+// 0x73, 0x74, 0x75
+function DELTAC123(b, state) {
+ var stack = state.stack;
+ var n = stack.pop();
+ var ppem = state.ppem;
+ var base = state.deltaBase + (b - 1) * 16;
+ var ds = state.deltaShift;
+
+ if (exports.DEBUG) { console.log(state.step, 'DELTAC[' + b + ']', n, stack); }
+
+ for (var i = 0; i < n; i++) {
+ var c = stack.pop();
+ var arg = stack.pop();
+ var appem = base + ((arg & 0xF0) >> 4);
+ if (appem !== ppem) { continue; }
+
+ var mag = (arg & 0x0F) - 8;
+ if (mag >= 0) { mag++; }
+
+ var delta = mag * ds;
+
+ if (exports.DEBUG) { console.log(state.step, 'DELTACFIX', c, 'by', delta); }
+
+ state.cvt[c] += delta;
+ }
+}
+
+// SROUND[] Super ROUND
+// 0x76
+function SROUND(state) {
+ var n = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'SROUND[]', n); }
+
+ state.round = roundSuper;
+
+ var period;
+
+ switch (n & 0xC0) {
+ case 0x00:
+ period = 0.5;
+ break;
+ case 0x40:
+ period = 1;
+ break;
+ case 0x80:
+ period = 2;
+ break;
+ default:
+ throw new Error('invalid SROUND value');
+ }
+
+ state.srPeriod = period;
+
+ switch (n & 0x30) {
+ case 0x00:
+ state.srPhase = 0;
+ break;
+ case 0x10:
+ state.srPhase = 0.25 * period;
+ break;
+ case 0x20:
+ state.srPhase = 0.5 * period;
+ break;
+ case 0x30:
+ state.srPhase = 0.75 * period;
+ break;
+ default: throw new Error('invalid SROUND value');
+ }
+
+ n &= 0x0F;
+
+ if (n === 0) { state.srThreshold = 0; }
+ else { state.srThreshold = (n / 8 - 0.5) * period; }
+}
+
+// S45ROUND[] Super ROUND 45 degrees
+// 0x77
+function S45ROUND(state) {
+ var n = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'S45ROUND[]', n); }
+
+ state.round = roundSuper;
+
+ var period;
+
+ switch (n & 0xC0) {
+ case 0x00:
+ period = Math.sqrt(2) / 2;
+ break;
+ case 0x40:
+ period = Math.sqrt(2);
+ break;
+ case 0x80:
+ period = 2 * Math.sqrt(2);
+ break;
+ default:
+ throw new Error('invalid S45ROUND value');
+ }
+
+ state.srPeriod = period;
+
+ switch (n & 0x30) {
+ case 0x00:
+ state.srPhase = 0;
+ break;
+ case 0x10:
+ state.srPhase = 0.25 * period;
+ break;
+ case 0x20:
+ state.srPhase = 0.5 * period;
+ break;
+ case 0x30:
+ state.srPhase = 0.75 * period;
+ break;
+ default:
+ throw new Error('invalid S45ROUND value');
+ }
+
+ n &= 0x0F;
+
+ if (n === 0) { state.srThreshold = 0; }
+ else { state.srThreshold = (n / 8 - 0.5) * period; }
+}
+
+// ROFF[] Round Off
+// 0x7A
+function ROFF(state) {
+ if (exports.DEBUG) { console.log(state.step, 'ROFF[]'); }
+
+ state.round = roundOff;
+}
+
+// RUTG[] Round Up To Grid
+// 0x7C
+function RUTG(state) {
+ if (exports.DEBUG) { console.log(state.step, 'RUTG[]'); }
+
+ state.round = roundUpToGrid;
+}
+
+// RDTG[] Round Down To Grid
+// 0x7D
+function RDTG(state) {
+ if (exports.DEBUG) { console.log(state.step, 'RDTG[]'); }
+
+ state.round = roundDownToGrid;
+}
+
+// SCANCTRL[] SCAN conversion ConTRoL
+// 0x85
+function SCANCTRL(state) {
+ var n = state.stack.pop();
+
+ // ignored by opentype.js
+
+ if (exports.DEBUG) { console.log(state.step, 'SCANCTRL[]', n); }
+}
+
+// SDPVTL[a] Set Dual Projection Vector To Line
+// 0x86-0x87
+function SDPVTL(a, state) {
+ var stack = state.stack;
+ var p2i = stack.pop();
+ var p1i = stack.pop();
+ var p2 = state.z2[p2i];
+ var p1 = state.z1[p1i];
+
+ if (exports.DEBUG) { console.log(state.step, 'SDPVTL[' + a + ']', p2i, p1i); }
+
+ var dx;
+ var dy;
+
+ if (!a) {
+ dx = p1.x - p2.x;
+ dy = p1.y - p2.y;
+ } else {
+ dx = p2.y - p1.y;
+ dy = p1.x - p2.x;
+ }
+
+ state.dpv = getUnitVector(dx, dy);
+}
+
+// GETINFO[] GET INFOrmation
+// 0x88
+function GETINFO(state) {
+ var stack = state.stack;
+ var sel = stack.pop();
+ var r = 0;
+
+ if (exports.DEBUG) { console.log(state.step, 'GETINFO[]', sel); }
+
+ // v35 as in no subpixel hinting
+ if (sel & 0x01) { r = 35; }
+
+ // TODO rotation and stretch currently not supported
+ // and thus those GETINFO are always 0.
+
+ // opentype.js is always gray scaling
+ if (sel & 0x20) { r |= 0x1000; }
+
+ stack.push(r);
+}
+
+// ROLL[] ROLL the top three stack elements
+// 0x8A
+function ROLL(state) {
+ var stack = state.stack;
+ var a = stack.pop();
+ var b = stack.pop();
+ var c = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'ROLL[]'); }
+
+ stack.push(b);
+ stack.push(a);
+ stack.push(c);
+}
+
+// MAX[] MAXimum of top two stack elements
+// 0x8B
+function MAX(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'MAX[]', e2, e1); }
+
+ stack.push(Math.max(e1, e2));
+}
+
+// MIN[] MINimum of top two stack elements
+// 0x8C
+function MIN(state) {
+ var stack = state.stack;
+ var e2 = stack.pop();
+ var e1 = stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'MIN[]', e2, e1); }
+
+ stack.push(Math.min(e1, e2));
+}
+
+// SCANTYPE[] SCANTYPE
+// 0x8D
+function SCANTYPE(state) {
+ var n = state.stack.pop();
+ // ignored by opentype.js
+ if (exports.DEBUG) { console.log(state.step, 'SCANTYPE[]', n); }
+}
+
+// INSTCTRL[] INSTCTRL
+// 0x8D
+function INSTCTRL(state) {
+ var s = state.stack.pop();
+ var v = state.stack.pop();
+
+ if (exports.DEBUG) { console.log(state.step, 'INSTCTRL[]', s, v); }
+
+ switch (s) {
+ case 1 : state.inhibitGridFit = !!v; return;
+ case 2 : state.ignoreCvt = !!v; return;
+ default: throw new Error('invalid INSTCTRL[] selector');
+ }
+}
+
+// PUSHB[abc] PUSH Bytes
+// 0xB0-0xB7
+function PUSHB(n, state) {
+ var stack = state.stack;
+ var prog = state.prog;
+ var ip = state.ip;
+
+ if (exports.DEBUG) { console.log(state.step, 'PUSHB[' + n + ']'); }
+
+ for (var i = 0; i < n; i++) { stack.push(prog[++ip]); }
+
+ state.ip = ip;
+}
+
+// PUSHW[abc] PUSH Words
+// 0xB8-0xBF
+function PUSHW(n, state) {
+ var ip = state.ip;
+ var prog = state.prog;
+ var stack = state.stack;
+
+ if (exports.DEBUG) { console.log(state.ip, 'PUSHW[' + n + ']'); }
+
+ for (var i = 0; i < n; i++) {
+ var w = (prog[++ip] << 8) | prog[++ip];
+ if (w & 0x8000) { w = -((w ^ 0xffff) + 1); }
+ stack.push(w);
+ }
+
+ state.ip = ip;
+}
+
+// MDRP[abcde] Move Direct Relative Point
+// 0xD0-0xEF
+// (if indirect is 0)
+//
+// and
+//
+// MIRP[abcde] Move Indirect Relative Point
+// 0xE0-0xFF
+// (if indirect is 1)
+
+function MDRP_MIRP(indirect, setRp0, keepD, ro, dt, state) {
+ var stack = state.stack;
+ var cvte = indirect && stack.pop();
+ var pi = stack.pop();
+ var rp0i = state.rp0;
+ var rp = state.z0[rp0i];
+ var p = state.z1[pi];
+
+ var md = state.minDis;
+ var fv = state.fv;
+ var pv = state.dpv;
+ var od; // original distance
+ var d; // moving distance
+ var sign; // sign of distance
+ var cv;
+
+ d = od = pv.distance(p, rp, true, true);
+ sign = d >= 0 ? 1 : -1; // Math.sign would be 0 in case of 0
+
+ // TODO consider autoFlip
+ d = Math.abs(d);
+
+ if (indirect) {
+ cv = state.cvt[cvte];
+
+ if (ro && Math.abs(d - cv) < state.cvCutIn) { d = cv; }
+ }
+
+ if (keepD && d < md) { d = md; }
+
+ if (ro) { d = state.round(d); }
+
+ fv.setRelative(p, rp, sign * d, pv);
+ fv.touch(p);
+
+ if (exports.DEBUG) {
+ console.log(
+ state.step,
+ (indirect ? 'MIRP[' : 'MDRP[') +
+ (setRp0 ? 'M' : 'm') +
+ (keepD ? '>' : '_') +
+ (ro ? 'R' : '_') +
+ (dt === 0 ? 'Gr' : (dt === 1 ? 'Bl' : (dt === 2 ? 'Wh' : ''))) +
+ ']',
+ indirect ?
+ cvte + '(' + state.cvt[cvte] + ',' + cv + ')' :
+ '',
+ pi,
+ '(d =', od, '->', sign * d, ')'
+ );
+ }
+
+ state.rp1 = state.rp0;
+ state.rp2 = pi;
+ if (setRp0) { state.rp0 = pi; }
+}
+
+/*
+* The instruction table.
+*/
+instructionTable = [
+ /* 0x00 */ SVTCA.bind(undefined, yUnitVector),
+ /* 0x01 */ SVTCA.bind(undefined, xUnitVector),
+ /* 0x02 */ SPVTCA.bind(undefined, yUnitVector),
+ /* 0x03 */ SPVTCA.bind(undefined, xUnitVector),
+ /* 0x04 */ SFVTCA.bind(undefined, yUnitVector),
+ /* 0x05 */ SFVTCA.bind(undefined, xUnitVector),
+ /* 0x06 */ SPVTL.bind(undefined, 0),
+ /* 0x07 */ SPVTL.bind(undefined, 1),
+ /* 0x08 */ SFVTL.bind(undefined, 0),
+ /* 0x09 */ SFVTL.bind(undefined, 1),
+ /* 0x0A */ SPVFS,
+ /* 0x0B */ SFVFS,
+ /* 0x0C */ GPV,
+ /* 0x0D */ GFV,
+ /* 0x0E */ SFVTPV,
+ /* 0x0F */ ISECT,
+ /* 0x10 */ SRP0,
+ /* 0x11 */ SRP1,
+ /* 0x12 */ SRP2,
+ /* 0x13 */ SZP0,
+ /* 0x14 */ SZP1,
+ /* 0x15 */ SZP2,
+ /* 0x16 */ SZPS,
+ /* 0x17 */ SLOOP,
+ /* 0x18 */ RTG,
+ /* 0x19 */ RTHG,
+ /* 0x1A */ SMD,
+ /* 0x1B */ ELSE,
+ /* 0x1C */ JMPR,
+ /* 0x1D */ SCVTCI,
+ /* 0x1E */ undefined, // TODO SSWCI
+ /* 0x1F */ undefined, // TODO SSW
+ /* 0x20 */ DUP,
+ /* 0x21 */ POP,
+ /* 0x22 */ CLEAR,
+ /* 0x23 */ SWAP,
+ /* 0x24 */ DEPTH,
+ /* 0x25 */ CINDEX,
+ /* 0x26 */ MINDEX,
+ /* 0x27 */ undefined, // TODO ALIGNPTS
+ /* 0x28 */ undefined,
+ /* 0x29 */ undefined, // TODO UTP
+ /* 0x2A */ LOOPCALL,
+ /* 0x2B */ CALL,
+ /* 0x2C */ FDEF,
+ /* 0x2D */ undefined, // ENDF (eaten by FDEF)
+ /* 0x2E */ MDAP.bind(undefined, 0),
+ /* 0x2F */ MDAP.bind(undefined, 1),
+ /* 0x30 */ IUP.bind(undefined, yUnitVector),
+ /* 0x31 */ IUP.bind(undefined, xUnitVector),
+ /* 0x32 */ SHP.bind(undefined, 0),
+ /* 0x33 */ SHP.bind(undefined, 1),
+ /* 0x34 */ SHC.bind(undefined, 0),
+ /* 0x35 */ SHC.bind(undefined, 1),
+ /* 0x36 */ SHZ.bind(undefined, 0),
+ /* 0x37 */ SHZ.bind(undefined, 1),
+ /* 0x38 */ SHPIX,
+ /* 0x39 */ IP,
+ /* 0x3A */ MSIRP.bind(undefined, 0),
+ /* 0x3B */ MSIRP.bind(undefined, 1),
+ /* 0x3C */ ALIGNRP,
+ /* 0x3D */ RTDG,
+ /* 0x3E */ MIAP.bind(undefined, 0),
+ /* 0x3F */ MIAP.bind(undefined, 1),
+ /* 0x40 */ NPUSHB,
+ /* 0x41 */ NPUSHW,
+ /* 0x42 */ WS,
+ /* 0x43 */ RS,
+ /* 0x44 */ WCVTP,
+ /* 0x45 */ RCVT,
+ /* 0x46 */ GC.bind(undefined, 0),
+ /* 0x47 */ GC.bind(undefined, 1),
+ /* 0x48 */ undefined, // TODO SCFS
+ /* 0x49 */ MD.bind(undefined, 0),
+ /* 0x4A */ MD.bind(undefined, 1),
+ /* 0x4B */ MPPEM,
+ /* 0x4C */ undefined, // TODO MPS
+ /* 0x4D */ FLIPON,
+ /* 0x4E */ undefined, // TODO FLIPOFF
+ /* 0x4F */ undefined, // TODO DEBUG
+ /* 0x50 */ LT,
+ /* 0x51 */ LTEQ,
+ /* 0x52 */ GT,
+ /* 0x53 */ GTEQ,
+ /* 0x54 */ EQ,
+ /* 0x55 */ NEQ,
+ /* 0x56 */ ODD,
+ /* 0x57 */ EVEN,
+ /* 0x58 */ IF,
+ /* 0x59 */ EIF,
+ /* 0x5A */ AND,
+ /* 0x5B */ OR,
+ /* 0x5C */ NOT,
+ /* 0x5D */ DELTAP123.bind(undefined, 1),
+ /* 0x5E */ SDB,
+ /* 0x5F */ SDS,
+ /* 0x60 */ ADD,
+ /* 0x61 */ SUB,
+ /* 0x62 */ DIV,
+ /* 0x63 */ MUL,
+ /* 0x64 */ ABS,
+ /* 0x65 */ NEG,
+ /* 0x66 */ FLOOR,
+ /* 0x67 */ CEILING,
+ /* 0x68 */ ROUND.bind(undefined, 0),
+ /* 0x69 */ ROUND.bind(undefined, 1),
+ /* 0x6A */ ROUND.bind(undefined, 2),
+ /* 0x6B */ ROUND.bind(undefined, 3),
+ /* 0x6C */ undefined, // TODO NROUND[ab]
+ /* 0x6D */ undefined, // TODO NROUND[ab]
+ /* 0x6E */ undefined, // TODO NROUND[ab]
+ /* 0x6F */ undefined, // TODO NROUND[ab]
+ /* 0x70 */ WCVTF,
+ /* 0x71 */ DELTAP123.bind(undefined, 2),
+ /* 0x72 */ DELTAP123.bind(undefined, 3),
+ /* 0x73 */ DELTAC123.bind(undefined, 1),
+ /* 0x74 */ DELTAC123.bind(undefined, 2),
+ /* 0x75 */ DELTAC123.bind(undefined, 3),
+ /* 0x76 */ SROUND,
+ /* 0x77 */ S45ROUND,
+ /* 0x78 */ undefined, // TODO JROT[]
+ /* 0x79 */ undefined, // TODO JROF[]
+ /* 0x7A */ ROFF,
+ /* 0x7B */ undefined,
+ /* 0x7C */ RUTG,
+ /* 0x7D */ RDTG,
+ /* 0x7E */ POP, // actually SANGW, supposed to do only a pop though
+ /* 0x7F */ POP, // actually AA, supposed to do only a pop though
+ /* 0x80 */ undefined, // TODO FLIPPT
+ /* 0x81 */ undefined, // TODO FLIPRGON
+ /* 0x82 */ undefined, // TODO FLIPRGOFF
+ /* 0x83 */ undefined,
+ /* 0x84 */ undefined,
+ /* 0x85 */ SCANCTRL,
+ /* 0x86 */ SDPVTL.bind(undefined, 0),
+ /* 0x87 */ SDPVTL.bind(undefined, 1),
+ /* 0x88 */ GETINFO,
+ /* 0x89 */ undefined, // TODO IDEF
+ /* 0x8A */ ROLL,
+ /* 0x8B */ MAX,
+ /* 0x8C */ MIN,
+ /* 0x8D */ SCANTYPE,
+ /* 0x8E */ INSTCTRL,
+ /* 0x8F */ undefined,
+ /* 0x90 */ undefined,
+ /* 0x91 */ undefined,
+ /* 0x92 */ undefined,
+ /* 0x93 */ undefined,
+ /* 0x94 */ undefined,
+ /* 0x95 */ undefined,
+ /* 0x96 */ undefined,
+ /* 0x97 */ undefined,
+ /* 0x98 */ undefined,
+ /* 0x99 */ undefined,
+ /* 0x9A */ undefined,
+ /* 0x9B */ undefined,
+ /* 0x9C */ undefined,
+ /* 0x9D */ undefined,
+ /* 0x9E */ undefined,
+ /* 0x9F */ undefined,
+ /* 0xA0 */ undefined,
+ /* 0xA1 */ undefined,
+ /* 0xA2 */ undefined,
+ /* 0xA3 */ undefined,
+ /* 0xA4 */ undefined,
+ /* 0xA5 */ undefined,
+ /* 0xA6 */ undefined,
+ /* 0xA7 */ undefined,
+ /* 0xA8 */ undefined,
+ /* 0xA9 */ undefined,
+ /* 0xAA */ undefined,
+ /* 0xAB */ undefined,
+ /* 0xAC */ undefined,
+ /* 0xAD */ undefined,
+ /* 0xAE */ undefined,
+ /* 0xAF */ undefined,
+ /* 0xB0 */ PUSHB.bind(undefined, 1),
+ /* 0xB1 */ PUSHB.bind(undefined, 2),
+ /* 0xB2 */ PUSHB.bind(undefined, 3),
+ /* 0xB3 */ PUSHB.bind(undefined, 4),
+ /* 0xB4 */ PUSHB.bind(undefined, 5),
+ /* 0xB5 */ PUSHB.bind(undefined, 6),
+ /* 0xB6 */ PUSHB.bind(undefined, 7),
+ /* 0xB7 */ PUSHB.bind(undefined, 8),
+ /* 0xB8 */ PUSHW.bind(undefined, 1),
+ /* 0xB9 */ PUSHW.bind(undefined, 2),
+ /* 0xBA */ PUSHW.bind(undefined, 3),
+ /* 0xBB */ PUSHW.bind(undefined, 4),
+ /* 0xBC */ PUSHW.bind(undefined, 5),
+ /* 0xBD */ PUSHW.bind(undefined, 6),
+ /* 0xBE */ PUSHW.bind(undefined, 7),
+ /* 0xBF */ PUSHW.bind(undefined, 8),
+ /* 0xC0 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 0, 0),
+ /* 0xC1 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 0, 1),
+ /* 0xC2 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 0, 2),
+ /* 0xC3 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 0, 3),
+ /* 0xC4 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 1, 0),
+ /* 0xC5 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 1, 1),
+ /* 0xC6 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 1, 2),
+ /* 0xC7 */ MDRP_MIRP.bind(undefined, 0, 0, 0, 1, 3),
+ /* 0xC8 */ MDRP_MIRP.bind(undefined, 0, 0, 1, 0, 0),
+ /* 0xC9 */ MDRP_MIRP.bind(undefined, 0, 0, 1, 0, 1),
+ /* 0xCA */ MDRP_MIRP.bind(undefined, 0, 0, 1, 0, 2),
+ /* 0xCB */ MDRP_MIRP.bind(undefined, 0, 0, 1, 0, 3),
+ /* 0xCC */ MDRP_MIRP.bind(undefined, 0, 0, 1, 1, 0),
+ /* 0xCD */ MDRP_MIRP.bind(undefined, 0, 0, 1, 1, 1),
+ /* 0xCE */ MDRP_MIRP.bind(undefined, 0, 0, 1, 1, 2),
+ /* 0xCF */ MDRP_MIRP.bind(undefined, 0, 0, 1, 1, 3),
+ /* 0xD0 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 0, 0),
+ /* 0xD1 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 0, 1),
+ /* 0xD2 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 0, 2),
+ /* 0xD3 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 0, 3),
+ /* 0xD4 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 1, 0),
+ /* 0xD5 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 1, 1),
+ /* 0xD6 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 1, 2),
+ /* 0xD7 */ MDRP_MIRP.bind(undefined, 0, 1, 0, 1, 3),
+ /* 0xD8 */ MDRP_MIRP.bind(undefined, 0, 1, 1, 0, 0),
+ /* 0xD9 */ MDRP_MIRP.bind(undefined, 0, 1, 1, 0, 1),
+ /* 0xDA */ MDRP_MIRP.bind(undefined, 0, 1, 1, 0, 2),
+ /* 0xDB */ MDRP_MIRP.bind(undefined, 0, 1, 1, 0, 3),
+ /* 0xDC */ MDRP_MIRP.bind(undefined, 0, 1, 1, 1, 0),
+ /* 0xDD */ MDRP_MIRP.bind(undefined, 0, 1, 1, 1, 1),
+ /* 0xDE */ MDRP_MIRP.bind(undefined, 0, 1, 1, 1, 2),
+ /* 0xDF */ MDRP_MIRP.bind(undefined, 0, 1, 1, 1, 3),
+ /* 0xE0 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 0, 0),
+ /* 0xE1 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 0, 1),
+ /* 0xE2 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 0, 2),
+ /* 0xE3 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 0, 3),
+ /* 0xE4 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 1, 0),
+ /* 0xE5 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 1, 1),
+ /* 0xE6 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 1, 2),
+ /* 0xE7 */ MDRP_MIRP.bind(undefined, 1, 0, 0, 1, 3),
+ /* 0xE8 */ MDRP_MIRP.bind(undefined, 1, 0, 1, 0, 0),
+ /* 0xE9 */ MDRP_MIRP.bind(undefined, 1, 0, 1, 0, 1),
+ /* 0xEA */ MDRP_MIRP.bind(undefined, 1, 0, 1, 0, 2),
+ /* 0xEB */ MDRP_MIRP.bind(undefined, 1, 0, 1, 0, 3),
+ /* 0xEC */ MDRP_MIRP.bind(undefined, 1, 0, 1, 1, 0),
+ /* 0xED */ MDRP_MIRP.bind(undefined, 1, 0, 1, 1, 1),
+ /* 0xEE */ MDRP_MIRP.bind(undefined, 1, 0, 1, 1, 2),
+ /* 0xEF */ MDRP_MIRP.bind(undefined, 1, 0, 1, 1, 3),
+ /* 0xF0 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 0, 0),
+ /* 0xF1 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 0, 1),
+ /* 0xF2 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 0, 2),
+ /* 0xF3 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 0, 3),
+ /* 0xF4 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 1, 0),
+ /* 0xF5 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 1, 1),
+ /* 0xF6 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 1, 2),
+ /* 0xF7 */ MDRP_MIRP.bind(undefined, 1, 1, 0, 1, 3),
+ /* 0xF8 */ MDRP_MIRP.bind(undefined, 1, 1, 1, 0, 0),
+ /* 0xF9 */ MDRP_MIRP.bind(undefined, 1, 1, 1, 0, 1),
+ /* 0xFA */ MDRP_MIRP.bind(undefined, 1, 1, 1, 0, 2),
+ /* 0xFB */ MDRP_MIRP.bind(undefined, 1, 1, 1, 0, 3),
+ /* 0xFC */ MDRP_MIRP.bind(undefined, 1, 1, 1, 1, 0),
+ /* 0xFD */ MDRP_MIRP.bind(undefined, 1, 1, 1, 1, 1),
+ /* 0xFE */ MDRP_MIRP.bind(undefined, 1, 1, 1, 1, 2),
+ /* 0xFF */ MDRP_MIRP.bind(undefined, 1, 1, 1, 1, 3)
+];
+
+/*****************************
+ Mathematical Considerations
+******************************
+
+fv ... refers to freedom vector
+pv ... refers to projection vector
+rp ... refers to reference point
+p ... refers to to point being operated on
+d ... refers to distance
+
+SETRELATIVE:
+============
+
+case freedom vector == x-axis:
+------------------------------
+
+ (pv)
+ .-'
+ rpd .-'
+ .-*
+ d .-'90°'
+ .-' '
+ .-' '
+ *-' ' b
+ rp '
+ '
+ '
+ p *----------*-------------- (fv)
+ pm
+
+ rpdx = rpx + d * pv.x
+ rpdy = rpy + d * pv.y
+
+ equation of line b
+
+ y - rpdy = pvns * (x- rpdx)
+
+ y = p.y
+
+ x = rpdx + ( p.y - rpdy ) / pvns
+
+
+case freedom vector == y-axis:
+------------------------------
+
+ * pm
+ |\
+ | \
+ | \
+ | \
+ | \
+ | \
+ | \
+ | \
+ | \
+ | \ b
+ | \
+ | \
+ | \ .-' (pv)
+ | 90° \.-'
+ | .-'* rpd
+ | .-'
+ * *-' d
+ p rp
+
+ rpdx = rpx + d * pv.x
+ rpdy = rpy + d * pv.y
+
+ equation of line b:
+ pvns ... normal slope to pv
+
+ y - rpdy = pvns * (x - rpdx)
+
+ x = p.x
+
+ y = rpdy + pvns * (p.x - rpdx)
+
+
+
+generic case:
+-------------
+
+
+ .'(fv)
+ .'
+ .* pm
+ .' !
+ .' .
+ .' !
+ .' . b
+ .' !
+ * .
+ p !
+ 90° . ... (pv)
+ ...-*-'''
+ ...---''' rpd
+ ...---''' d
+ *--'''
+ rp
+
+ rpdx = rpx + d * pv.x
+ rpdy = rpy + d * pv.y
+
+ equation of line b:
+ pvns... normal slope to pv
+
+ y - rpdy = pvns * (x - rpdx)
+
+ equation of freedom vector line:
+ fvs ... slope of freedom vector (=fy/fx)
+
+ y - py = fvs * (x - px)
+
+
+ on pm both equations are true for same x/y
+
+ y - rpdy = pvns * (x - rpdx)
+
+ y - py = fvs * (x - px)
+
+ form to y and set equal:
+
+ pvns * (x - rpdx) + rpdy = fvs * (x - px) + py
+
+ expand:
+
+ pvns * x - pvns * rpdx + rpdy = fvs * x - fvs * px + py
+
+ switch:
+
+ fvs * x - fvs * px + py = pvns * x - pvns * rpdx + rpdy
+
+ solve for x:
+
+ fvs * x - pvns * x = fvs * px - pvns * rpdx - py + rpdy
+
+
+
+ fvs * px - pvns * rpdx + rpdy - py
+ x = -----------------------------------
+ fvs - pvns
+
+ and:
+
+ y = fvs * (x - px) + py
+
+
+
+INTERPOLATE:
+============
+
+Examples of point interpolation.
+
+The weight of the movement of the reference point gets bigger
+the further the other reference point is away, thus the safest
+option (that is avoiding 0/0 divisions) is to weight the
+original distance of the other point by the sum of both distances.
+
+If the sum of both distances is 0, then move the point by the
+arithmetic average of the movement of both reference points.
+
+
+
+
+ (+6)
+ rp1o *---->*rp1
+ . . (+12)
+ . . rp2o *---------->* rp2
+ . . . .
+ . . . .
+ . 10 20 . .
+ |.........|...................| .
+ . . .
+ . . (+8) .
+ po *------>*p .
+ . . .
+ . 12 . 24 .
+ |...........|.......................|
+ 36
+
+
+-------
+
+
+
+ (+10)
+ rp1o *-------->*rp1
+ . . (-10)
+ . . rp2 *<---------* rpo2
+ . . . .
+ . . . .
+ . 10 . 30 . .
+ |.........|.............................|
+ . .
+ . (+5) .
+ po *--->* p .
+ . . .
+ . . 20 .
+ |....|..............|
+ 5 15
+
+
+-------
+
+
+ (+10)
+ rp1o *-------->*rp1
+ . .
+ . .
+ rp2o *-------->*rp2
+
+
+ (+10)
+ po *-------->* p
+
+-------
+
+
+ (+10)
+ rp1o *-------->*rp1
+ . .
+ . .(+30)
+ rp2o *---------------------------->*rp2
+
+
+ (+25)
+ po *----------------------->* p
+
+
+
+vim: set ts=4 sw=4 expandtab:
+*****/
+
+/**
+ * Converts a string into a list of tokens.
+ */
+
+/**
+ * Create a new token
+ * @param {string} char a single char
+ */
+function Token(char) {
+ this.char = char;
+ this.state = {};
+ this.activeState = null;
+}
+
+/**
+ * Create a new context range
+ * @param {number} startIndex range start index
+ * @param {number} endOffset range end index offset
+ * @param {string} contextName owner context name
+ */
+function ContextRange(startIndex, endOffset, contextName) {
+ this.contextName = contextName;
+ this.startIndex = startIndex;
+ this.endOffset = endOffset;
+}
+
+/**
+ * Check context start and end
+ * @param {string} contextName a unique context name
+ * @param {function} checkStart a predicate function the indicates a context's start
+ * @param {function} checkEnd a predicate function the indicates a context's end
+ */
+function ContextChecker(contextName, checkStart, checkEnd) {
+ this.contextName = contextName;
+ this.openRange = null;
+ this.ranges = [];
+ this.checkStart = checkStart;
+ this.checkEnd = checkEnd;
+}
+
+/**
+ * @typedef ContextParams
+ * @type Object
+ * @property {array} context context items
+ * @property {number} currentIndex current item index
+ */
+
+/**
+ * Create a context params
+ * @param {array} context a list of items
+ * @param {number} currentIndex current item index
+ */
+function ContextParams(context, currentIndex) {
+ this.context = context;
+ this.index = currentIndex;
+ this.length = context.length;
+ this.current = context[currentIndex];
+ this.backtrack = context.slice(0, currentIndex);
+ this.lookahead = context.slice(currentIndex + 1);
+}
+
+/**
+ * Create an event instance
+ * @param {string} eventId event unique id
+ */
+function Event(eventId) {
+ this.eventId = eventId;
+ this.subscribers = [];
+}
+
+/**
+ * Initialize a core events and auto subscribe required event handlers
+ * @param {any} events an object that enlists core events handlers
+ */
+function initializeCoreEvents(events) {
+ var this$1 = this;
+
+ var coreEvents = [
+ 'start', 'end', 'next', 'newToken', 'contextStart',
+ 'contextEnd', 'insertToken', 'removeToken', 'removeRange',
+ 'replaceToken', 'replaceRange', 'composeRUD', 'updateContextsRanges'
+ ];
+
+ coreEvents.forEach(function (eventId) {
+ Object.defineProperty(this$1.events, eventId, {
+ value: new Event(eventId)
+ });
+ });
+
+ if (!!events) {
+ coreEvents.forEach(function (eventId) {
+ var event = events[eventId];
+ if (typeof event === 'function') {
+ this$1.events[eventId].subscribe(event);
+ }
+ });
+ }
+ var requiresContextUpdate = [
+ 'insertToken', 'removeToken', 'removeRange',
+ 'replaceToken', 'replaceRange', 'composeRUD'
+ ];
+ requiresContextUpdate.forEach(function (eventId) {
+ this$1.events[eventId].subscribe(
+ this$1.updateContextsRanges
+ );
+ });
+}
+
+/**
+ * Converts a string into a list of tokens
+ * @param {any} events tokenizer core events
+ */
+function Tokenizer(events) {
+ this.tokens = [];
+ this.registeredContexts = {};
+ this.contextCheckers = [];
+ this.events = {};
+ this.registeredModifiers = [];
+
+ initializeCoreEvents.call(this, events);
+}
+
+/**
+ * Sets the state of a token, usually called by a state modifier.
+ * @param {string} key state item key
+ * @param {any} value state item value
+ */
+Token.prototype.setState = function(key, value) {
+ this.state[key] = value;
+ this.activeState = { key: key, value: this.state[key] };
+ return this.activeState;
+};
+
+Token.prototype.getState = function (stateId) {
+ return this.state[stateId] || null;
+};
+
+/**
+ * Checks if an index exists in the tokens list.
+ * @param {number} index token index
+ */
+Tokenizer.prototype.inboundIndex = function(index) {
+ return index >= 0 && index < this.tokens.length;
+};
+
+/**
+ * Compose and apply a list of operations (replace, update, delete)
+ * @param {array} RUDs replace, update and delete operations
+ * TODO: Perf. Optimization (lengthBefore === lengthAfter ? dispatch once)
+ */
+Tokenizer.prototype.composeRUD = function (RUDs) {
+ var this$1 = this;
+
+ var silent = true;
+ var state = RUDs.map(function (RUD) { return (
+ this$1[RUD[0]].apply(this$1, RUD.slice(1).concat(silent))
+ ); });
+ var hasFAILObject = function (obj) { return (
+ typeof obj === 'object' &&
+ obj.hasOwnProperty('FAIL')
+ ); };
+ if (state.every(hasFAILObject)) {
+ return {
+ FAIL: "composeRUD: one or more operations hasn't completed successfully",
+ report: state.filter(hasFAILObject)
+ };
+ }
+ this.dispatch('composeRUD', [state.filter(function (op) { return !hasFAILObject(op); })]);
+};
+
+/**
+ * Replace a range of tokens with a list of tokens
+ * @param {number} startIndex range start index
+ * @param {number} offset range offset
+ * @param {token} tokens a list of tokens to replace
+ * @param {boolean} silent dispatch events and update context ranges
+ */
+Tokenizer.prototype.replaceRange = function (startIndex, offset, tokens, silent) {
+ offset = offset !== null ? offset : this.tokens.length;
+ var isTokenType = tokens.every(function (token) { return token instanceof Token; });
+ if (!isNaN(startIndex) && this.inboundIndex(startIndex) && isTokenType) {
+ var replaced = this.tokens.splice.apply(
+ this.tokens, [startIndex, offset].concat(tokens)
+ );
+ if (!silent) { this.dispatch('replaceToken', [startIndex, offset, tokens]); }
+ return [replaced, tokens];
+ } else {
+ return { FAIL: 'replaceRange: invalid tokens or startIndex.' };
+ }
+};
+
+/**
+ * Replace a token with another token
+ * @param {number} index token index
+ * @param {token} token a token to replace
+ * @param {boolean} silent dispatch events and update context ranges
+ */
+Tokenizer.prototype.replaceToken = function (index, token, silent) {
+ if (!isNaN(index) && this.inboundIndex(index) && token instanceof Token) {
+ var replaced = this.tokens.splice(index, 1, token);
+ if (!silent) { this.dispatch('replaceToken', [index, token]); }
+ return [replaced[0], token];
+ } else {
+ return { FAIL: 'replaceToken: invalid token or index.' };
+ }
+};
+
+/**
+ * Removes a range of tokens
+ * @param {number} startIndex range start index
+ * @param {number} offset range offset
+ * @param {boolean} silent dispatch events and update context ranges
+ */
+Tokenizer.prototype.removeRange = function(startIndex, offset, silent) {
+ offset = !isNaN(offset) ? offset : this.tokens.length;
+ var tokens = this.tokens.splice(startIndex, offset);
+ if (!silent) { this.dispatch('removeRange', [tokens, startIndex, offset]); }
+ return tokens;
+};
+
+/**
+ * Remove a token at a certain index
+ * @param {number} index token index
+ * @param {boolean} silent dispatch events and update context ranges
+ */
+Tokenizer.prototype.removeToken = function(index, silent) {
+ if (!isNaN(index) && this.inboundIndex(index)) {
+ var token = this.tokens.splice(index, 1);
+ if (!silent) { this.dispatch('removeToken', [token, index]); }
+ return token;
+ } else {
+ return { FAIL: 'removeToken: invalid token index.' };
+ }
+};
+
+/**
+ * Insert a list of tokens at a certain index
+ * @param {array} tokens a list of tokens to insert
+ * @param {number} index insert the list of tokens at index
+ * @param {boolean} silent dispatch events and update context ranges
+ */
+Tokenizer.prototype.insertToken = function (tokens, index, silent) {
+ var tokenType = tokens.every(
+ function (token) { return token instanceof Token; }
+ );
+ if (tokenType) {
+ this.tokens.splice.apply(
+ this.tokens, [index, 0].concat(tokens)
+ );
+ if (!silent) { this.dispatch('insertToken', [tokens, index]); }
+ return tokens;
+ } else {
+ return { FAIL: 'insertToken: invalid token(s).' };
+ }
+};
+
+/**
+ * A state modifier that is called on 'newToken' event
+ * @param {string} modifierId state modifier id
+ * @param {function} condition a predicate function that returns true or false
+ * @param {function} modifier a function to update token state
+ */
+Tokenizer.prototype.registerModifier = function(modifierId, condition, modifier) {
+ this.events.newToken.subscribe(function(token, contextParams) {
+ var conditionParams = [token, contextParams];
+ var canApplyModifier = (
+ condition === null ||
+ condition.apply(this, conditionParams) === true
+ );
+ var modifierParams = [token, contextParams];
+ if (canApplyModifier) {
+ var newStateValue = modifier.apply(this, modifierParams);
+ token.setState(modifierId, newStateValue);
+ }
+ });
+ this.registeredModifiers.push(modifierId);
+};
+
+/**
+ * Subscribe a handler to an event
+ * @param {function} eventHandler an event handler function
+ */
+Event.prototype.subscribe = function (eventHandler) {
+ if (typeof eventHandler === 'function') {
+ return ((this.subscribers.push(eventHandler)) - 1);
+ } else {
+ return { FAIL: ("invalid '" + (this.eventId) + "' event handler")};
+ }
+};
+
+/**
+ * Unsubscribe an event handler
+ * @param {string} subsId subscription id
+ */
+Event.prototype.unsubscribe = function (subsId) {
+ this.subscribers.splice(subsId, 1);
+};
+
+/**
+ * Sets context params current value index
+ * @param {number} index context params current value index
+ */
+ContextParams.prototype.setCurrentIndex = function(index) {
+ this.index = index;
+ this.current = this.context[index];
+ this.backtrack = this.context.slice(0, index);
+ this.lookahead = this.context.slice(index + 1);
+};
+
+/**
+ * Get an item at an offset from the current value
+ * example (current value is 3):
+ * 1 2 [3] 4 5 | items values
+ * -2 -1 0 1 2 | offset values
+ * @param {number} offset an offset from current value index
+ */
+ContextParams.prototype.get = function (offset) {
+ switch (true) {
+ case (offset === 0):
+ return this.current;
+ case (offset < 0 && Math.abs(offset) <= this.backtrack.length):
+ return this.backtrack.slice(offset)[0];
+ case (offset > 0 && offset <= this.lookahead.length):
+ return this.lookahead[offset - 1];
+ default:
+ return null;
+ }
+};
+
+/**
+ * Converts a context range into a string value
+ * @param {contextRange} range a context range
+ */
+Tokenizer.prototype.rangeToText = function (range) {
+ if (range instanceof ContextRange) {
+ return (
+ this.getRangeTokens(range)
+ .map(function (token) { return token.char; }).join('')
+ );
+ }
+};
+
+/**
+ * Converts all tokens into a string
+ */
+Tokenizer.prototype.getText = function () {
+ return this.tokens.map(function (token) { return token.char; }).join('');
+};
+
+/**
+ * Get a context by name
+ * @param {string} contextName context name to get
+ */
+Tokenizer.prototype.getContext = function (contextName) {
+ var context = this.registeredContexts[contextName];
+ return !!context ? context : null;
+};
+
+/**
+ * Subscribes a new event handler to an event
+ * @param {string} eventName event name to subscribe to
+ * @param {function} eventHandler a function to be invoked on event
+ */
+Tokenizer.prototype.on = function(eventName, eventHandler) {
+ var event = this.events[eventName];
+ if (!!event) {
+ return event.subscribe(eventHandler);
+ } else {
+ return null;
+ }
+};
+
+/**
+ * Dispatches an event
+ * @param {string} eventName event name
+ * @param {any} args event handler arguments
+ */
+Tokenizer.prototype.dispatch = function(eventName, args) {
+ var this$1 = this;
+
+ var event = this.events[eventName];
+ if (event instanceof Event) {
+ event.subscribers.forEach(function (subscriber) {
+ subscriber.apply(this$1, args || []);
+ });
+ }
+};
+
+/**
+ * Register a new context checker
+ * @param {string} contextName a unique context name
+ * @param {function} contextStartCheck a predicate function that returns true on context start
+ * @param {function} contextEndCheck a predicate function that returns true on context end
+ * TODO: call tokenize on registration to update context ranges with the new context.
+ */
+Tokenizer.prototype.registerContextChecker = function(contextName, contextStartCheck, contextEndCheck) {
+ if (!!this.getContext(contextName)) { return {
+ FAIL:
+ ("context name '" + contextName + "' is already registered.")
+ }; }
+ if (typeof contextStartCheck !== 'function') { return {
+ FAIL:
+ "missing context start check."
+ }; }
+ if (typeof contextEndCheck !== 'function') { return {
+ FAIL:
+ "missing context end check."
+ }; }
+ var contextCheckers = new ContextChecker(
+ contextName, contextStartCheck, contextEndCheck
+ );
+ this.registeredContexts[contextName] = contextCheckers;
+ this.contextCheckers.push(contextCheckers);
+ return contextCheckers;
+};
+
+/**
+ * Gets a context range tokens
+ * @param {contextRange} range a context range
+ */
+Tokenizer.prototype.getRangeTokens = function(range) {
+ var endIndex = range.startIndex + range.endOffset;
+ return [].concat(
+ this.tokens
+ .slice(range.startIndex, endIndex)
+ );
+};
+
+/**
+ * Gets the ranges of a context
+ * @param {string} contextName context name
+ */
+Tokenizer.prototype.getContextRanges = function(contextName) {
+ var context = this.getContext(contextName);
+ if (!!context) {
+ return context.ranges;
+ } else {
+ return { FAIL: ("context checker '" + contextName + "' is not registered.") };
+ }
+};
+
+/**
+ * Resets context ranges to run context update
+ */
+Tokenizer.prototype.resetContextsRanges = function () {
+ var registeredContexts = this.registeredContexts;
+ for (var contextName in registeredContexts) {
+ if (registeredContexts.hasOwnProperty(contextName)) {
+ var context = registeredContexts[contextName];
+ context.ranges = [];
+ }
+ }
+};
+
+/**
+ * Updates context ranges
+ */
+Tokenizer.prototype.updateContextsRanges = function () {
+ this.resetContextsRanges();
+ var chars = this.tokens.map(function (token) { return token.char; });
+ for (var i = 0; i < chars.length; i++) {
+ var contextParams = new ContextParams(chars, i);
+ this.runContextCheck(contextParams);
+ }
+ this.dispatch('updateContextsRanges', [this.registeredContexts]);
+};
+
+/**
+ * Sets the end offset of an open range
+ * @param {number} offset range end offset
+ * @param {string} contextName context name
+ */
+Tokenizer.prototype.setEndOffset = function (offset, contextName) {
+ var startIndex = this.getContext(contextName).openRange.startIndex;
+ var range = new ContextRange(startIndex, offset, contextName);
+ var ranges = this.getContext(contextName).ranges;
+ range.rangeId = contextName + "." + (ranges.length);
+ ranges.push(range);
+ this.getContext(contextName).openRange = null;
+ return range;
+};
+
+/**
+ * Runs a context check on the current context
+ * @param {contextParams} contextParams current context params
+ */
+Tokenizer.prototype.runContextCheck = function(contextParams) {
+ var this$1 = this;
+
+ var index = contextParams.index;
+ this.contextCheckers.forEach(function (contextChecker) {
+ var contextName = contextChecker.contextName;
+ var openRange = this$1.getContext(contextName).openRange;
+ if (!openRange && contextChecker.checkStart(contextParams)) {
+ openRange = new ContextRange(index, null, contextName);
+ this$1.getContext(contextName).openRange = openRange;
+ this$1.dispatch('contextStart', [contextName, index]);
+ }
+ if (!!openRange && contextChecker.checkEnd(contextParams)) {
+ var offset = (index - openRange.startIndex) + 1;
+ var range = this$1.setEndOffset(offset, contextName);
+ this$1.dispatch('contextEnd', [contextName, range]);
+ }
+ });
+};
+
+/**
+ * Converts a text into a list of tokens
+ * @param {string} text a text to tokenize
+ */
+Tokenizer.prototype.tokenize = function (text) {
+ this.tokens = [];
+ this.resetContextsRanges();
+ var chars = Array.from(text);
+ this.dispatch('start');
+ for (var i = 0; i < chars.length; i++) {
+ var char = chars[i];
+ var contextParams = new ContextParams(chars, i);
+ this.dispatch('next', [contextParams]);
+ this.runContextCheck(contextParams);
+ var token = new Token(char);
+ this.tokens.push(token);
+ this.dispatch('newToken', [token, contextParams]);
+ }
+ this.dispatch('end', [this.tokens]);
+ return this.tokens;
+};
+
+// ╭─┄┄┄────────────────────────┄─────────────────────────────────────────────╮
+// ┊ Character Class Assertions ┊ Checks if a char belongs to a certain class ┊
+// ╰─╾──────────────────────────┄─────────────────────────────────────────────╯
+// jscs:disable maximumLineLength
+/**
+ * Check if a char is Arabic
+ * @param {string} c a single char
+ */
+function isArabicChar(c) {
+ return /[\u0600-\u065F\u066A-\u06D2\u06FA-\u06FF]/.test(c);
+}
+
+/**
+ * Check if a char is an isolated arabic char
+ * @param {string} c a single char
+ */
+function isIsolatedArabicChar(char) {
+ return /[\u0630\u0690\u0621\u0631\u0661\u0671\u0622\u0632\u0672\u0692\u06C2\u0623\u0673\u0693\u06C3\u0624\u0694\u06C4\u0625\u0675\u0695\u06C5\u06E5\u0676\u0696\u06C6\u0627\u0677\u0697\u06C7\u0648\u0688\u0698\u06C8\u0689\u0699\u06C9\u068A\u06CA\u066B\u068B\u06CB\u068C\u068D\u06CD\u06FD\u068E\u06EE\u06FE\u062F\u068F\u06CF\u06EF]/.test(char);
+}
+
+/**
+ * Check if a char is an Arabic Tashkeel char
+ * @param {string} c a single char
+ */
+function isTashkeelArabicChar(char) {
+ return /[\u0600-\u0605\u060C-\u060E\u0610-\u061B\u061E\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/.test(char);
+}
+
+/**
+ * Check if a char is Latin
+ * @param {string} c a single char
+ */
+function isLatinChar(c) {
+ return /[A-z]/.test(c);
+}
+
+/**
+ * Check if a char is whitespace char
+ * @param {string} c a single char
+ */
+function isWhiteSpace(c) {
+ return /\s/.test(c);
+}
+
+/**
+ * Query a feature by some of it's properties to lookup a glyph substitution.
+ */
+
+/**
+ * Create feature query instance
+ * @param {Font} font opentype font instance
+ */
+function FeatureQuery(font) {
+ this.font = font;
+ this.features = {};
+}
+
+/**
+ * @typedef SubstitutionAction
+ * @type Object
+ * @property {number} id substitution type
+ * @property {string} tag feature tag
+ * @property {any} substitution substitution value(s)
+ */
+
+/**
+ * Create a substitution action instance
+ * @param {SubstitutionAction} action
+ */
+function SubstitutionAction(action) {
+ this.id = action.id;
+ this.tag = action.tag;
+ this.substitution = action.substitution;
+}
+
+/**
+ * Lookup a coverage table
+ * @param {number} glyphIndex glyph index
+ * @param {CoverageTable} coverage coverage table
+ */
+function lookupCoverage(glyphIndex, coverage) {
+ if (!glyphIndex) { return -1; }
+ switch (coverage.format) {
+ case 1:
+ return coverage.glyphs.indexOf(glyphIndex);
+
+ case 2:
+ var ranges = coverage.ranges;
+ for (var i = 0; i < ranges.length; i++) {
+ var range = ranges[i];
+ if (glyphIndex >= range.start && glyphIndex <= range.end) {
+ var offset = glyphIndex - range.start;
+ return range.index + offset;
+ }
+ }
+ break;
+ default:
+ return -1; // not found
+ }
+ return -1;
+}
+
+/**
+ * Handle a single substitution - format 1
+ * @param {ContextParams} contextParams context params to lookup
+ */
+function singleSubstitutionFormat1(glyphIndex, subtable) {
+ var substituteIndex = lookupCoverage(glyphIndex, subtable.coverage);
+ if (substituteIndex === -1) { return null; }
+ return glyphIndex + subtable.deltaGlyphId;
+}
+
+/**
+ * Handle a single substitution - format 2
+ * @param {ContextParams} contextParams context params to lookup
+ */
+function singleSubstitutionFormat2(glyphIndex, subtable) {
+ var substituteIndex = lookupCoverage(glyphIndex, subtable.coverage);
+ if (substituteIndex === -1) { return null; }
+ return subtable.substitute[substituteIndex];
+}
+
+/**
+ * Lookup a list of coverage tables
+ * @param {any} coverageList a list of coverage tables
+ * @param {ContextParams} contextParams context params to lookup
+ */
+function lookupCoverageList(coverageList, contextParams) {
+ var lookupList = [];
+ for (var i = 0; i < coverageList.length; i++) {
+ var coverage = coverageList[i];
+ var glyphIndex = contextParams.current;
+ glyphIndex = Array.isArray(glyphIndex) ? glyphIndex[0] : glyphIndex;
+ var lookupIndex = lookupCoverage(glyphIndex, coverage);
+ if (lookupIndex !== -1) {
+ lookupList.push(lookupIndex);
+ }
+ }
+ if (lookupList.length !== coverageList.length) { return -1; }
+ return lookupList;
+}
+
+/**
+ * Handle chaining context substitution - format 3
+ * @param {ContextParams} contextParams context params to lookup
+ */
+function chainingSubstitutionFormat3(contextParams, subtable) {
+ var lookupsCount = (
+ subtable.inputCoverage.length +
+ subtable.lookaheadCoverage.length +
+ subtable.backtrackCoverage.length
+ );
+ if (contextParams.context.length < lookupsCount) { return []; }
+ // INPUT LOOKUP //
+ var inputLookups = lookupCoverageList(
+ subtable.inputCoverage, contextParams
+ );
+ if (inputLookups === -1) { return []; }
+ // LOOKAHEAD LOOKUP //
+ var lookaheadOffset = subtable.inputCoverage.length - 1;
+ if (contextParams.lookahead.length < subtable.lookaheadCoverage.length) { return []; }
+ var lookaheadContext = contextParams.lookahead.slice(lookaheadOffset);
+ while (lookaheadContext.length && isTashkeelArabicChar(lookaheadContext[0].char)) {
+ lookaheadContext.shift();
+ }
+ var lookaheadParams = new ContextParams(lookaheadContext, 0);
+ var lookaheadLookups = lookupCoverageList(
+ subtable.lookaheadCoverage, lookaheadParams
+ );
+ // BACKTRACK LOOKUP //
+ var backtrackContext = [].concat(contextParams.backtrack);
+ backtrackContext.reverse();
+ while (backtrackContext.length && isTashkeelArabicChar(backtrackContext[0].char)) {
+ backtrackContext.shift();
+ }
+ if (backtrackContext.length < subtable.backtrackCoverage.length) { return []; }
+ var backtrackParams = new ContextParams(backtrackContext, 0);
+ var backtrackLookups = lookupCoverageList(
+ subtable.backtrackCoverage, backtrackParams
+ );
+ var contextRulesMatch = (
+ inputLookups.length === subtable.inputCoverage.length &&
+ lookaheadLookups.length === subtable.lookaheadCoverage.length &&
+ backtrackLookups.length === subtable.backtrackCoverage.length
+ );
+ var substitutions = [];
+ if (contextRulesMatch) {
+ for (var i = 0; i < subtable.lookupRecords.length; i++) {
+ var lookupRecord = subtable.lookupRecords[i];
+ var lookupListIndex = lookupRecord.lookupListIndex;
+ var lookupTable = this.getLookupByIndex(lookupListIndex);
+ for (var s = 0; s < lookupTable.subtables.length; s++) {
+ var subtable$1 = lookupTable.subtables[s];
+ var lookup = this.getLookupMethod(lookupTable, subtable$1);
+ var substitutionType = this.getSubstitutionType(lookupTable, subtable$1);
+ if (substitutionType === '12') {
+ for (var n = 0; n < inputLookups.length; n++) {
+ var glyphIndex = contextParams.get(n);
+ var substitution = lookup(glyphIndex);
+ if (substitution) { substitutions.push(substitution); }
+ }
+ }
+ }
+ }
+ }
+ return substitutions;
+}
+
+/**
+ * Handle ligature substitution - format 1
+ * @param {ContextParams} contextParams context params to lookup
+ */
+function ligatureSubstitutionFormat1(contextParams, subtable) {
+ // COVERAGE LOOKUP //
+ var glyphIndex = contextParams.current;
+ var ligSetIndex = lookupCoverage(glyphIndex, subtable.coverage);
+ if (ligSetIndex === -1) { return null; }
+ // COMPONENTS LOOKUP
+ // (!) note, components are ordered in the written direction.
+ var ligature;
+ var ligatureSet = subtable.ligatureSets[ligSetIndex];
+ for (var s = 0; s < ligatureSet.length; s++) {
+ ligature = ligatureSet[s];
+ for (var l = 0; l < ligature.components.length; l++) {
+ var lookaheadItem = contextParams.lookahead[l];
+ var component = ligature.components[l];
+ if (lookaheadItem !== component) { break; }
+ if (l === ligature.components.length - 1) { return ligature; }
+ }
+ }
+ return null;
+}
+
+/**
+ * Handle decomposition substitution - format 1
+ * @param {number} glyphIndex glyph index
+ * @param {any} subtable subtable
+ */
+function decompositionSubstitutionFormat1(glyphIndex, subtable) {
+ var substituteIndex = lookupCoverage(glyphIndex, subtable.coverage);
+ if (substituteIndex === -1) { return null; }
+ return subtable.sequences[substituteIndex];
+}
+
+/**
+ * Get default script features indexes
+ */
+FeatureQuery.prototype.getDefaultScriptFeaturesIndexes = function () {
+ var scripts = this.font.tables.gsub.scripts;
+ for (var s = 0; s < scripts.length; s++) {
+ var script = scripts[s];
+ if (script.tag === 'DFLT') { return (
+ script.script.defaultLangSys.featureIndexes
+ ); }
+ }
+ return [];
+};
+
+/**
+ * Get feature indexes of a specific script
+ * @param {string} scriptTag script tag
+ */
+FeatureQuery.prototype.getScriptFeaturesIndexes = function(scriptTag) {
+ var tables = this.font.tables;
+ if (!tables.gsub) { return []; }
+ if (!scriptTag) { return this.getDefaultScriptFeaturesIndexes(); }
+ var scripts = this.font.tables.gsub.scripts;
+ for (var i = 0; i < scripts.length; i++) {
+ var script = scripts[i];
+ if (script.tag === scriptTag && script.script.defaultLangSys) {
+ return script.script.defaultLangSys.featureIndexes;
+ } else {
+ var langSysRecords = script.langSysRecords;
+ if (!!langSysRecords) {
+ for (var j = 0; j < langSysRecords.length; j++) {
+ var langSysRecord = langSysRecords[j];
+ if (langSysRecord.tag === scriptTag) {
+ var langSys = langSysRecord.langSys;
+ return langSys.featureIndexes;
+ }
+ }
+ }
+ }
+ }
+ return this.getDefaultScriptFeaturesIndexes();
+};
+
+/**
+ * Map a feature tag to a gsub feature
+ * @param {any} features gsub features
+ * @param {string} scriptTag script tag
+ */
+FeatureQuery.prototype.mapTagsToFeatures = function (features, scriptTag) {
+ var tags = {};
+ for (var i = 0; i < features.length; i++) {
+ var tag = features[i].tag;
+ var feature = features[i].feature;
+ tags[tag] = feature;
+ }
+ this.features[scriptTag].tags = tags;
+};
+
+/**
+ * Get features of a specific script
+ * @param {string} scriptTag script tag
+ */
+FeatureQuery.prototype.getScriptFeatures = function (scriptTag) {
+ var features = this.features[scriptTag];
+ if (this.features.hasOwnProperty(scriptTag)) { return features; }
+ var featuresIndexes = this.getScriptFeaturesIndexes(scriptTag);
+ if (!featuresIndexes) { return null; }
+ var gsub = this.font.tables.gsub;
+ features = featuresIndexes.map(function (index) { return gsub.features[index]; });
+ this.features[scriptTag] = features;
+ this.mapTagsToFeatures(features, scriptTag);
+ return features;
+};
+
+/**
+ * Get substitution type
+ * @param {any} lookupTable lookup table
+ * @param {any} subtable subtable
+ */
+FeatureQuery.prototype.getSubstitutionType = function(lookupTable, subtable) {
+ var lookupType = lookupTable.lookupType.toString();
+ var substFormat = subtable.substFormat.toString();
+ return lookupType + substFormat;
+};
+
+/**
+ * Get lookup method
+ * @param {any} lookupTable lookup table
+ * @param {any} subtable subtable
+ */
+FeatureQuery.prototype.getLookupMethod = function(lookupTable, subtable) {
+ var this$1 = this;
+
+ var substitutionType = this.getSubstitutionType(lookupTable, subtable);
+ switch (substitutionType) {
+ case '11':
+ return function (glyphIndex) { return singleSubstitutionFormat1.apply(
+ this$1, [glyphIndex, subtable]
+ ); };
+ case '12':
+ return function (glyphIndex) { return singleSubstitutionFormat2.apply(
+ this$1, [glyphIndex, subtable]
+ ); };
+ case '63':
+ return function (contextParams) { return chainingSubstitutionFormat3.apply(
+ this$1, [contextParams, subtable]
+ ); };
+ case '41':
+ return function (contextParams) { return ligatureSubstitutionFormat1.apply(
+ this$1, [contextParams, subtable]
+ ); };
+ case '21':
+ return function (glyphIndex) { return decompositionSubstitutionFormat1.apply(
+ this$1, [glyphIndex, subtable]
+ ); };
+ default:
+ throw new Error(
+ "lookupType: " + (lookupTable.lookupType) + " - " +
+ "substFormat: " + (subtable.substFormat) + " " +
+ "is not yet supported"
+ );
+ }
+};
+
+/**
+ * [ LOOKUP TYPES ]
+ * -------------------------------
+ * Single 1;
+ * Multiple 2;
+ * Alternate 3;
+ * Ligature 4;
+ * Context 5;
+ * ChainingContext 6;
+ * ExtensionSubstitution 7;
+ * ReverseChainingContext 8;
+ * -------------------------------
+ *
+ */
+
+/**
+ * @typedef FQuery
+ * @type Object
+ * @param {string} tag feature tag
+ * @param {string} script feature script
+ * @param {ContextParams} contextParams context params
+ */
+
+/**
+ * Lookup a feature using a query parameters
+ * @param {FQuery} query feature query
+ */
+FeatureQuery.prototype.lookupFeature = function (query) {
+ var contextParams = query.contextParams;
+ var currentIndex = contextParams.index;
+ var feature = this.getFeature({
+ tag: query.tag, script: query.script
+ });
+ if (!feature) { return new Error(
+ "font '" + (this.font.names.fullName.en) + "' " +
+ "doesn't support feature '" + (query.tag) + "' " +
+ "for script '" + (query.script) + "'."
+ ); }
+ var lookups = this.getFeatureLookups(feature);
+ var substitutions = [].concat(contextParams.context);
+ for (var l = 0; l < lookups.length; l++) {
+ var lookupTable = lookups[l];
+ var subtables = this.getLookupSubtables(lookupTable);
+ for (var s = 0; s < subtables.length; s++) {
+ var subtable = subtables[s];
+ var substType = this.getSubstitutionType(lookupTable, subtable);
+ var lookup = this.getLookupMethod(lookupTable, subtable);
+ var substitution = (void 0);
+ switch (substType) {
+ case '11':
+ substitution = lookup(contextParams.current);
+ if (substitution) {
+ substitutions.splice(currentIndex, 1, new SubstitutionAction({
+ id: 11, tag: query.tag, substitution: substitution
+ }));
+ }
+ break;
+ case '12':
+ substitution = lookup(contextParams.current);
+ if (substitution) {
+ substitutions.splice(currentIndex, 1, new SubstitutionAction({
+ id: 12, tag: query.tag, substitution: substitution
+ }));
+ }
+ break;
+ case '63':
+ substitution = lookup(contextParams);
+ if (Array.isArray(substitution) && substitution.length) {
+ substitutions.splice(currentIndex, 1, new SubstitutionAction({
+ id: 63, tag: query.tag, substitution: substitution
+ }));
+ }
+ break;
+ case '41':
+ substitution = lookup(contextParams);
+ if (substitution) {
+ substitutions.splice(currentIndex, 1, new SubstitutionAction({
+ id: 41, tag: query.tag, substitution: substitution
+ }));
+ }
+ break;
+ case '21':
+ substitution = lookup(contextParams.current);
+ if (substitution) {
+ substitutions.splice(currentIndex, 1, new SubstitutionAction({
+ id: 21, tag: query.tag, substitution: substitution
+ }));
+ }
+ break;
+ }
+ contextParams = new ContextParams(substitutions, currentIndex);
+ if (Array.isArray(substitution) && !substitution.length) { continue; }
+ substitution = null;
+ }
+ }
+ return substitutions.length ? substitutions : null;
+};
+
+/**
+ * Checks if a font supports a specific features
+ * @param {FQuery} query feature query object
+ */
+FeatureQuery.prototype.supports = function (query) {
+ if (!query.script) { return false; }
+ this.getScriptFeatures(query.script);
+ var supportedScript = this.features.hasOwnProperty(query.script);
+ if (!query.tag) { return supportedScript; }
+ var supportedFeature = (
+ this.features[query.script].some(function (feature) { return feature.tag === query.tag; })
+ );
+ return supportedScript && supportedFeature;
+};
+
+/**
+ * Get lookup table subtables
+ * @param {any} lookupTable lookup table
+ */
+FeatureQuery.prototype.getLookupSubtables = function (lookupTable) {
+ return lookupTable.subtables || null;
+};
+
+/**
+ * Get lookup table by index
+ * @param {number} index lookup table index
+ */
+FeatureQuery.prototype.getLookupByIndex = function (index) {
+ var lookups = this.font.tables.gsub.lookups;
+ return lookups[index] || null;
+};
+
+/**
+ * Get lookup tables for a feature
+ * @param {string} feature
+ */
+FeatureQuery.prototype.getFeatureLookups = function (feature) {
+ // TODO: memoize
+ return feature.lookupListIndexes.map(this.getLookupByIndex.bind(this));
+};
+
+/**
+ * Query a feature by it's properties
+ * @param {any} query an object that describes the properties of a query
+ */
+FeatureQuery.prototype.getFeature = function getFeature(query) {
+ if (!this.font) { return { FAIL: "No font was found"}; }
+ if (!this.features.hasOwnProperty(query.script)) {
+ this.getScriptFeatures(query.script);
+ }
+ var scriptFeatures = this.features[query.script];
+ if (!scriptFeatures) { return (
+ { FAIL: ("No feature for script " + (query.script))}
+ ); }
+ if (!scriptFeatures.tags[query.tag]) { return null; }
+ return this.features[query.script].tags[query.tag];
+};
+
+/**
+ * Arabic word context checkers
+ */
+
+function arabicWordStartCheck(contextParams) {
+ var char = contextParams.current;
+ var prevChar = contextParams.get(-1);
+ return (
+ // ? arabic first char
+ (prevChar === null && isArabicChar(char)) ||
+ // ? arabic char preceded with a non arabic char
+ (!isArabicChar(prevChar) && isArabicChar(char))
+ );
+}
+
+function arabicWordEndCheck(contextParams) {
+ var nextChar = contextParams.get(1);
+ return (
+ // ? last arabic char
+ (nextChar === null) ||
+ // ? next char is not arabic
+ (!isArabicChar(nextChar))
+ );
+}
+
+var arabicWordCheck = {
+ startCheck: arabicWordStartCheck,
+ endCheck: arabicWordEndCheck
+};
+
+/**
+ * Arabic sentence context checkers
+ */
+
+function arabicSentenceStartCheck(contextParams) {
+ var char = contextParams.current;
+ var prevChar = contextParams.get(-1);
+ return (
+ // ? an arabic char preceded with a non arabic char
+ (isArabicChar(char) || isTashkeelArabicChar(char)) &&
+ !isArabicChar(prevChar)
+ );
+}
+
+function arabicSentenceEndCheck(contextParams) {
+ var nextChar = contextParams.get(1);
+ switch (true) {
+ case nextChar === null:
+ return true;
+ case (!isArabicChar(nextChar) && !isTashkeelArabicChar(nextChar)):
+ var nextIsWhitespace = isWhiteSpace(nextChar);
+ if (!nextIsWhitespace) { return true; }
+ if (nextIsWhitespace) {
+ var arabicCharAhead = false;
+ arabicCharAhead = (
+ contextParams.lookahead.some(
+ function (c) { return isArabicChar(c) || isTashkeelArabicChar(c); }
+ )
+ );
+ if (!arabicCharAhead) { return true; }
+ }
+ break;
+ default:
+ return false;
+ }
+}
+
+var arabicSentenceCheck = {
+ startCheck: arabicSentenceStartCheck,
+ endCheck: arabicSentenceEndCheck
+};
+
+/**
+ * Apply single substitution format 1
+ * @param {Array} substitutions substitutions
+ * @param {any} tokens a list of tokens
+ * @param {number} index token index
+ */
+function singleSubstitutionFormat1$1(action, tokens, index) {
+ tokens[index].setState(action.tag, action.substitution);
+}
+
+/**
+ * Apply single substitution format 2
+ * @param {Array} substitutions substitutions
+ * @param {any} tokens a list of tokens
+ * @param {number} index token index
+ */
+function singleSubstitutionFormat2$1(action, tokens, index) {
+ tokens[index].setState(action.tag, action.substitution);
+}
+
+/**
+ * Apply chaining context substitution format 3
+ * @param {Array} substitutions substitutions
+ * @param {any} tokens a list of tokens
+ * @param {number} index token index
+ */
+function chainingSubstitutionFormat3$1(action, tokens, index) {
+ action.substitution.forEach(function (subst, offset) {
+ var token = tokens[index + offset];
+ token.setState(action.tag, subst);
+ });
+}
+
+/**
+ * Apply ligature substitution format 1
+ * @param {Array} substitutions substitutions
+ * @param {any} tokens a list of tokens
+ * @param {number} index token index
+ */
+function ligatureSubstitutionFormat1$1(action, tokens, index) {
+ var token = tokens[index];
+ token.setState(action.tag, action.substitution.ligGlyph);
+ var compsCount = action.substitution.components.length;
+ for (var i = 0; i < compsCount; i++) {
+ token = tokens[index + i + 1];
+ token.setState('deleted', true);
+ }
+}
+
+/**
+ * Supported substitutions
+ */
+var SUBSTITUTIONS = {
+ 11: singleSubstitutionFormat1$1,
+ 12: singleSubstitutionFormat2$1,
+ 63: chainingSubstitutionFormat3$1,
+ 41: ligatureSubstitutionFormat1$1
+};
+
+/**
+ * Apply substitutions to a list of tokens
+ * @param {Array} substitutions substitutions
+ * @param {any} tokens a list of tokens
+ * @param {number} index token index
+ */
+function applySubstitution(action, tokens, index) {
+ if (action instanceof SubstitutionAction && SUBSTITUTIONS[action.id]) {
+ SUBSTITUTIONS[action.id](action, tokens, index);
+ }
+}
+
+/**
+ * Apply Arabic presentation forms to a range of tokens
+ */
+
+/**
+ * Check if a char can be connected to it's preceding char
+ * @param {ContextParams} charContextParams context params of a char
+ */
+function willConnectPrev(charContextParams) {
+ var backtrack = [].concat(charContextParams.backtrack);
+ for (var i = backtrack.length - 1; i >= 0; i--) {
+ var prevChar = backtrack[i];
+ var isolated = isIsolatedArabicChar(prevChar);
+ var tashkeel = isTashkeelArabicChar(prevChar);
+ if (!isolated && !tashkeel) { return true; }
+ if (isolated) { return false; }
+ }
+ return false;
+}
+
+/**
+ * Check if a char can be connected to it's proceeding char
+ * @param {ContextParams} charContextParams context params of a char
+ */
+function willConnectNext(charContextParams) {
+ if (isIsolatedArabicChar(charContextParams.current)) { return false; }
+ for (var i = 0; i < charContextParams.lookahead.length; i++) {
+ var nextChar = charContextParams.lookahead[i];
+ var tashkeel = isTashkeelArabicChar(nextChar);
+ if (!tashkeel) { return true; }
+ }
+ return false;
+}
+
+/**
+ * Apply arabic presentation forms to a list of tokens
+ * @param {ContextRange} range a range of tokens
+ */
+function arabicPresentationForms(range) {
+ var this$1 = this;
+
+ var script = 'arab';
+ var tags = this.featuresTags[script];
+ var tokens = this.tokenizer.getRangeTokens(range);
+ if (tokens.length === 1) { return; }
+ var contextParams = new ContextParams(
+ tokens.map(function (token) { return token.getState('glyphIndex'); }
+ ), 0);
+ var charContextParams = new ContextParams(
+ tokens.map(function (token) { return token.char; }
+ ), 0);
+ tokens.forEach(function (token, index) {
+ if (isTashkeelArabicChar(token.char)) { return; }
+ contextParams.setCurrentIndex(index);
+ charContextParams.setCurrentIndex(index);
+ var CONNECT = 0; // 2 bits 00 (10: can connect next) (01: can connect prev)
+ if (willConnectPrev(charContextParams)) { CONNECT |= 1; }
+ if (willConnectNext(charContextParams)) { CONNECT |= 2; }
+ var tag;
+ switch (CONNECT) {
+ case 1: (tag = 'fina'); break;
+ case 2: (tag = 'init'); break;
+ case 3: (tag = 'medi'); break;
+ }
+ if (tags.indexOf(tag) === -1) { return; }
+ var substitutions = this$1.query.lookupFeature({
+ tag: tag, script: script, contextParams: contextParams
+ });
+ if (substitutions instanceof Error) { return console.info(substitutions.message); }
+ substitutions.forEach(function (action, index) {
+ if (action instanceof SubstitutionAction) {
+ applySubstitution(action, tokens, index);
+ contextParams.context[index] = action.substitution;
+ }
+ });
+ });
+}
+
+/**
+ * Apply Arabic required ligatures feature to a range of tokens
+ */
+
+/**
+ * Update context params
+ * @param {any} tokens a list of tokens
+ * @param {number} index current item index
+ */
+function getContextParams(tokens, index) {
+ var context = tokens.map(function (token) { return token.activeState.value; });
+ return new ContextParams(context, index || 0);
+}
+
+/**
+ * Apply Arabic required ligatures to a context range
+ * @param {ContextRange} range a range of tokens
+ */
+function arabicRequiredLigatures(range) {
+ var this$1 = this;
+
+ var script = 'arab';
+ var tokens = this.tokenizer.getRangeTokens(range);
+ var contextParams = getContextParams(tokens);
+ contextParams.context.forEach(function (glyphIndex, index) {
+ contextParams.setCurrentIndex(index);
+ var substitutions = this$1.query.lookupFeature({
+ tag: 'rlig', script: script, contextParams: contextParams
+ });
+ if (substitutions.length) {
+ substitutions.forEach(
+ function (action) { return applySubstitution(action, tokens, index); }
+ );
+ contextParams = getContextParams(tokens);
+ }
+ });
+}
+
+/**
+ * Latin word context checkers
+ */
+
+function latinWordStartCheck(contextParams) {
+ var char = contextParams.current;
+ var prevChar = contextParams.get(-1);
+ return (
+ // ? latin first char
+ (prevChar === null && isLatinChar(char)) ||
+ // ? latin char preceded with a non latin char
+ (!isLatinChar(prevChar) && isLatinChar(char))
+ );
+}
+
+function latinWordEndCheck(contextParams) {
+ var nextChar = contextParams.get(1);
+ return (
+ // ? last latin char
+ (nextChar === null) ||
+ // ? next char is not latin
+ (!isLatinChar(nextChar))
+ );
+}
+
+var latinWordCheck = {
+ startCheck: latinWordStartCheck,
+ endCheck: latinWordEndCheck
+};
+
+/**
+ * Apply Latin ligature feature to a range of tokens
+ */
+
+/**
+ * Update context params
+ * @param {any} tokens a list of tokens
+ * @param {number} index current item index
+ */
+function getContextParams$1(tokens, index) {
+ var context = tokens.map(function (token) { return token.activeState.value; });
+ return new ContextParams(context, index || 0);
+}
+
+/**
+ * Apply Arabic required ligatures to a context range
+ * @param {ContextRange} range a range of tokens
+ */
+function latinLigature(range) {
+ var this$1 = this;
+
+ var script = 'latn';
+ var tokens = this.tokenizer.getRangeTokens(range);
+ var contextParams = getContextParams$1(tokens);
+ contextParams.context.forEach(function (glyphIndex, index) {
+ contextParams.setCurrentIndex(index);
+ var substitutions = this$1.query.lookupFeature({
+ tag: 'liga', script: script, contextParams: contextParams
+ });
+ if (substitutions.length) {
+ substitutions.forEach(
+ function (action) { return applySubstitution(action, tokens, index); }
+ );
+ contextParams = getContextParams$1(tokens);
+ }
+ });
+}
+
+/**
+ * Infer bidirectional properties for a given text and apply
+ * the corresponding layout rules.
+ */
+
+/**
+ * Create Bidi. features
+ * @param {string} baseDir text base direction. value either 'ltr' or 'rtl'
+ */
+function Bidi(baseDir) {
+ this.baseDir = baseDir || 'ltr';
+ this.tokenizer = new Tokenizer();
+ this.featuresTags = {};
+}
+
+/**
+ * Sets Bidi text
+ * @param {string} text a text input
+ */
+Bidi.prototype.setText = function (text) {
+ this.text = text;
+};
+
+/**
+ * Store essential context checks:
+ * arabic word check for applying gsub features
+ * arabic sentence check for adjusting arabic layout
+ */
+Bidi.prototype.contextChecks = ({
+ latinWordCheck: latinWordCheck,
+ arabicWordCheck: arabicWordCheck,
+ arabicSentenceCheck: arabicSentenceCheck
+});
+
+/**
+ * Register arabic word check
+ */
+function registerContextChecker(checkId) {
+ var check = this.contextChecks[(checkId + "Check")];
+ return this.tokenizer.registerContextChecker(
+ checkId, check.startCheck, check.endCheck
+ );
+}
+
+/**
+ * Perform pre tokenization procedure then
+ * tokenize text input
+ */
+function tokenizeText() {
+ registerContextChecker.call(this, 'latinWord');
+ registerContextChecker.call(this, 'arabicWord');
+ registerContextChecker.call(this, 'arabicSentence');
+ return this.tokenizer.tokenize(this.text);
+}
+
+/**
+ * Reverse arabic sentence layout
+ * TODO: check base dir before applying adjustments - priority low
+ */
+function reverseArabicSentences() {
+ var this$1 = this;
+
+ var ranges = this.tokenizer.getContextRanges('arabicSentence');
+ ranges.forEach(function (range) {
+ var rangeTokens = this$1.tokenizer.getRangeTokens(range);
+ this$1.tokenizer.replaceRange(
+ range.startIndex,
+ range.endOffset,
+ rangeTokens.reverse()
+ );
+ });
+}
+
+/**
+ * Register supported features tags
+ * @param {script} script script tag
+ * @param {Array} tags features tags list
+ */
+Bidi.prototype.registerFeatures = function (script, tags) {
+ var this$1 = this;
+
+ var supportedTags = tags.filter(
+ function (tag) { return this$1.query.supports({script: script, tag: tag}); }
+ );
+ if (!this.featuresTags.hasOwnProperty(script)) {
+ this.featuresTags[script] = supportedTags;
+ } else {
+ this.featuresTags[script] =
+ this.featuresTags[script].concat(supportedTags);
+ }
+};
+
+/**
+ * Apply GSUB features
+ * @param {Array} tagsList a list of features tags
+ * @param {string} script a script tag
+ * @param {Font} font opentype font instance
+ */
+Bidi.prototype.applyFeatures = function (font, features) {
+ if (!font) { throw new Error(
+ 'No valid font was provided to apply features'
+ ); }
+ if (!this.query) { this.query = new FeatureQuery(font); }
+ for (var f = 0; f < features.length; f++) {
+ var feature = features[f];
+ if (!this.query.supports({script: feature.script})) { continue; }
+ this.registerFeatures(feature.script, feature.tags);
+ }
+};
+
+/**
+ * Register a state modifier
+ * @param {string} modifierId state modifier id
+ * @param {function} condition a predicate function that returns true or false
+ * @param {function} modifier a modifier function to set token state
+ */
+Bidi.prototype.registerModifier = function (modifierId, condition, modifier) {
+ this.tokenizer.registerModifier(modifierId, condition, modifier);
+};
+
+/**
+ * Check if 'glyphIndex' is registered
+ */
+function checkGlyphIndexStatus() {
+ if (this.tokenizer.registeredModifiers.indexOf('glyphIndex') === -1) {
+ throw new Error(
+ 'glyphIndex modifier is required to apply ' +
+ 'arabic presentation features.'
+ );
+ }
+}
+
+/**
+ * Apply arabic presentation forms features
+ */
+function applyArabicPresentationForms() {
+ var this$1 = this;
+
+ var script = 'arab';
+ if (!this.featuresTags.hasOwnProperty(script)) { return; }
+ checkGlyphIndexStatus.call(this);
+ var ranges = this.tokenizer.getContextRanges('arabicWord');
+ ranges.forEach(function (range) {
+ arabicPresentationForms.call(this$1, range);
+ });
+}
+
+/**
+ * Apply required arabic ligatures
+ */
+function applyArabicRequireLigatures() {
+ var this$1 = this;
+
+ var script = 'arab';
+ if (!this.featuresTags.hasOwnProperty(script)) { return; }
+ var tags = this.featuresTags[script];
+ if (tags.indexOf('rlig') === -1) { return; }
+ checkGlyphIndexStatus.call(this);
+ var ranges = this.tokenizer.getContextRanges('arabicWord');
+ ranges.forEach(function (range) {
+ arabicRequiredLigatures.call(this$1, range);
+ });
+}
+
+/**
+ * Apply required arabic ligatures
+ */
+function applyLatinLigatures() {
+ var this$1 = this;
+
+ var script = 'latn';
+ if (!this.featuresTags.hasOwnProperty(script)) { return; }
+ var tags = this.featuresTags[script];
+ if (tags.indexOf('liga') === -1) { return; }
+ checkGlyphIndexStatus.call(this);
+ var ranges = this.tokenizer.getContextRanges('latinWord');
+ ranges.forEach(function (range) {
+ latinLigature.call(this$1, range);
+ });
+}
+
+/**
+ * Check if a context is registered
+ * @param {string} contextId context id
+ */
+Bidi.prototype.checkContextReady = function (contextId) {
+ return !!this.tokenizer.getContext(contextId);
+};
+
+/**
+ * Apply features to registered contexts
+ */
+Bidi.prototype.applyFeaturesToContexts = function () {
+ if (this.checkContextReady('arabicWord')) {
+ applyArabicPresentationForms.call(this);
+ applyArabicRequireLigatures.call(this);
+ }
+ if (this.checkContextReady('latinWord')) {
+ applyLatinLigatures.call(this);
+ }
+ if (this.checkContextReady('arabicSentence')) {
+ reverseArabicSentences.call(this);
+ }
+};
+
+/**
+ * process text input
+ * @param {string} text an input text
+ */
+Bidi.prototype.processText = function(text) {
+ if (!this.text || this.text !== text) {
+ this.setText(text);
+ tokenizeText.call(this);
+ this.applyFeaturesToContexts();
+ }
+};
+
+/**
+ * Process a string of text to identify and adjust
+ * bidirectional text entities.
+ * @param {string} text input text
+ */
+Bidi.prototype.getBidiText = function (text) {
+ this.processText(text);
+ return this.tokenizer.getText();
+};
+
+/**
+ * Get the current state index of each token
+ * @param {text} text an input text
+ */
+Bidi.prototype.getTextGlyphs = function (text) {
+ this.processText(text);
+ var indexes = [];
+ for (var i = 0; i < this.tokenizer.tokens.length; i++) {
+ var token = this.tokenizer.tokens[i];
+ if (token.state.deleted) { continue; }
+ var index = token.activeState.value;
+ indexes.push(Array.isArray(index) ? index[0] : index);
+ }
+ return indexes;
+};
+
+// The Font object
+
+/**
+ * @typedef FontOptions
+ * @type Object
+ * @property {Boolean} empty - whether to create a new empty font
+ * @property {string} familyName
+ * @property {string} styleName
+ * @property {string=} fullName
+ * @property {string=} postScriptName
+ * @property {string=} designer
+ * @property {string=} designerURL
+ * @property {string=} manufacturer
+ * @property {string=} manufacturerURL
+ * @property {string=} license
+ * @property {string=} licenseURL
+ * @property {string=} version
+ * @property {string=} description
+ * @property {string=} copyright
+ * @property {string=} trademark
+ * @property {Number} unitsPerEm
+ * @property {Number} ascender
+ * @property {Number} descender
+ * @property {Number} createdTimestamp
+ * @property {string=} weightClass
+ * @property {string=} widthClass
+ * @property {string=} fsSelection
+ */
+
+/**
+ * A Font represents a loaded OpenType font file.
+ * It contains a set of glyphs and methods to draw text on a drawing context,
+ * or to get a path representing the text.
+ * @exports opentype.Font
+ * @class
+ * @param {FontOptions}
+ * @constructor
+ */
+function Font(options) {
+ options = options || {};
+ options.tables = options.tables || {};
+
+ if (!options.empty) {
+ // Check that we've provided the minimum set of names.
+ checkArgument(options.familyName, 'When creating a new Font object, familyName is required.');
+ checkArgument(options.styleName, 'When creating a new Font object, styleName is required.');
+ checkArgument(options.unitsPerEm, 'When creating a new Font object, unitsPerEm is required.');
+ checkArgument(options.ascender, 'When creating a new Font object, ascender is required.');
+ checkArgument(options.descender <= 0, 'When creating a new Font object, negative descender value is required.');
+
+ // OS X will complain if the names are empty, so we put a single space everywhere by default.
+ this.names = {
+ fontFamily: {en: options.familyName || ' '},
+ fontSubfamily: {en: options.styleName || ' '},
+ fullName: {en: options.fullName || options.familyName + ' ' + options.styleName},
+ // postScriptName may not contain any whitespace
+ postScriptName: {en: options.postScriptName || (options.familyName + options.styleName).replace(/\s/g, '')},
+ designer: {en: options.designer || ' '},
+ designerURL: {en: options.designerURL || ' '},
+ manufacturer: {en: options.manufacturer || ' '},
+ manufacturerURL: {en: options.manufacturerURL || ' '},
+ license: {en: options.license || ' '},
+ licenseURL: {en: options.licenseURL || ' '},
+ version: {en: options.version || 'Version 0.1'},
+ description: {en: options.description || ' '},
+ copyright: {en: options.copyright || ' '},
+ trademark: {en: options.trademark || ' '}
+ };
+ this.unitsPerEm = options.unitsPerEm || 1000;
+ this.ascender = options.ascender;
+ this.descender = options.descender;
+ this.createdTimestamp = options.createdTimestamp;
+ this.tables = Object.assign(options.tables, {
+ os2: Object.assign({
+ usWeightClass: options.weightClass || this.usWeightClasses.MEDIUM,
+ usWidthClass: options.widthClass || this.usWidthClasses.MEDIUM,
+ fsSelection: options.fsSelection || this.fsSelectionValues.REGULAR,
+ }, options.tables.os2)
+ });
+ }
+
+ this.supported = true; // Deprecated: parseBuffer will throw an error if font is not supported.
+ this.glyphs = new glyphset.GlyphSet(this, options.glyphs || []);
+ this.encoding = new DefaultEncoding(this);
+ this.position = new Position(this);
+ this.substitution = new Substitution(this);
+ this.tables = this.tables || {};
+
+ // needed for low memory mode only.
+ this._push = null;
+ this._hmtxTableData = {};
+
+ Object.defineProperty(this, 'hinting', {
+ get: function() {
+ if (this._hinting) { return this._hinting; }
+ if (this.outlinesFormat === 'truetype') {
+ return (this._hinting = new Hinting(this));
+ }
+ }
+ });
+}
+
+/**
+ * Check if the font has a glyph for the given character.
+ * @param {string}
+ * @return {Boolean}
+ */
+Font.prototype.hasChar = function(c) {
+ return this.encoding.charToGlyphIndex(c) !== null;
+};
+
+/**
+ * Convert the given character to a single glyph index.
+ * Note that this function assumes that there is a one-to-one mapping between
+ * the given character and a glyph; for complex scripts this might not be the case.
+ * @param {string}
+ * @return {Number}
+ */
+Font.prototype.charToGlyphIndex = function(s) {
+ return this.encoding.charToGlyphIndex(s);
+};
+
+/**
+ * Convert the given character to a single Glyph object.
+ * Note that this function assumes that there is a one-to-one mapping between
+ * the given character and a glyph; for complex scripts this might not be the case.
+ * @param {string}
+ * @return {opentype.Glyph}
+ */
+Font.prototype.charToGlyph = function(c) {
+ var glyphIndex = this.charToGlyphIndex(c);
+ var glyph = this.glyphs.get(glyphIndex);
+ if (!glyph) {
+ // .notdef
+ glyph = this.glyphs.get(0);
+ }
+
+ return glyph;
+};
+
+/**
+ * Update features
+ * @param {any} options features options
+ */
+Font.prototype.updateFeatures = function (options) {
+ // TODO: update all features options not only 'latn'.
+ return this.defaultRenderOptions.features.map(function (feature) {
+ if (feature.script === 'latn') {
+ return {
+ script: 'latn',
+ tags: feature.tags.filter(function (tag) { return options[tag]; })
+ };
+ } else {
+ return feature;
+ }
+ });
+};
+
+/**
+ * Convert the given text to a list of Glyph objects.
+ * Note that there is no strict one-to-one mapping between characters and
+ * glyphs, so the list of returned glyphs can be larger or smaller than the
+ * length of the given string.
+ * @param {string}
+ * @param {GlyphRenderOptions} [options]
+ * @return {opentype.Glyph[]}
+ */
+Font.prototype.stringToGlyphs = function(s, options) {
+ var this$1 = this;
+
+
+ var bidi = new Bidi();
+
+ // Create and register 'glyphIndex' state modifier
+ var charToGlyphIndexMod = function (token) { return this$1.charToGlyphIndex(token.char); };
+ bidi.registerModifier('glyphIndex', null, charToGlyphIndexMod);
+
+ // roll-back to default features
+ var features = options ?
+ this.updateFeatures(options.features) :
+ this.defaultRenderOptions.features;
+
+ bidi.applyFeatures(this, features);
+
+ var indexes = bidi.getTextGlyphs(s);
+
+ var length = indexes.length;
+
+ // convert glyph indexes to glyph objects
+ var glyphs = new Array(length);
+ var notdef = this.glyphs.get(0);
+ for (var i = 0; i < length; i += 1) {
+ glyphs[i] = this.glyphs.get(indexes[i]) || notdef;
+ }
+ return glyphs;
+};
+
+/**
+ * @param {string}
+ * @return {Number}
+ */
+Font.prototype.nameToGlyphIndex = function(name) {
+ return this.glyphNames.nameToGlyphIndex(name);
+};
+
+/**
+ * @param {string}
+ * @return {opentype.Glyph}
+ */
+Font.prototype.nameToGlyph = function(name) {
+ var glyphIndex = this.nameToGlyphIndex(name);
+ var glyph = this.glyphs.get(glyphIndex);
+ if (!glyph) {
+ // .notdef
+ glyph = this.glyphs.get(0);
+ }
+
+ return glyph;
+};
+
+/**
+ * @param {Number}
+ * @return {String}
+ */
+Font.prototype.glyphIndexToName = function(gid) {
+ if (!this.glyphNames.glyphIndexToName) {
+ return '';
+ }
+
+ return this.glyphNames.glyphIndexToName(gid);
+};
+
+/**
+ * Retrieve the value of the kerning pair between the left glyph (or its index)
+ * and the right glyph (or its index). If no kerning pair is found, return 0.
+ * The kerning value gets added to the advance width when calculating the spacing
+ * between glyphs.
+ * For GPOS kerning, this method uses the default script and language, which covers
+ * most use cases. To have greater control, use font.position.getKerningValue .
+ * @param {opentype.Glyph} leftGlyph
+ * @param {opentype.Glyph} rightGlyph
+ * @return {Number}
+ */
+Font.prototype.getKerningValue = function(leftGlyph, rightGlyph) {
+ leftGlyph = leftGlyph.index || leftGlyph;
+ rightGlyph = rightGlyph.index || rightGlyph;
+ var gposKerning = this.position.defaultKerningTables;
+ if (gposKerning) {
+ return this.position.getKerningValue(gposKerning, leftGlyph, rightGlyph);
+ }
+ // "kern" table
+ return this.kerningPairs[leftGlyph + ',' + rightGlyph] || 0;
+};
+
+/**
+ * @typedef GlyphRenderOptions
+ * @type Object
+ * @property {string} [script] - script used to determine which features to apply. By default, 'DFLT' or 'latn' is used.
+ * See https://www.microsoft.com/typography/otspec/scripttags.htm
+ * @property {string} [language='dflt'] - language system used to determine which features to apply.
+ * See https://www.microsoft.com/typography/developers/opentype/languagetags.aspx
+ * @property {boolean} [kerning=true] - whether to include kerning values
+ * @property {object} [features] - OpenType Layout feature tags. Used to enable or disable the features of the given script/language system.
+ * See https://www.microsoft.com/typography/otspec/featuretags.htm
+ */
+Font.prototype.defaultRenderOptions = {
+ kerning: true,
+ features: [
+ /**
+ * these 4 features are required to render Arabic text properly
+ * and shouldn't be turned off when rendering arabic text.
+ */
+ { script: 'arab', tags: ['init', 'medi', 'fina', 'rlig'] },
+ { script: 'latn', tags: ['liga', 'rlig'] }
+ ]
+};
+
+/**
+ * Helper function that invokes the given callback for each glyph in the given text.
+ * The callback gets `(glyph, x, y, fontSize, options)`.* @param {string} text
+ * @param {string} text - The text to apply.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {GlyphRenderOptions=} options
+ * @param {Function} callback
+ */
+Font.prototype.forEachGlyph = function(text, x, y, fontSize, options, callback) {
+ x = x !== undefined ? x : 0;
+ y = y !== undefined ? y : 0;
+ fontSize = fontSize !== undefined ? fontSize : 72;
+ options = Object.assign({}, this.defaultRenderOptions, options);
+ var fontScale = 1 / this.unitsPerEm * fontSize;
+ var glyphs = this.stringToGlyphs(text, options);
+ var kerningLookups;
+ if (options.kerning) {
+ var script = options.script || this.position.getDefaultScriptName();
+ kerningLookups = this.position.getKerningTables(script, options.language);
+ }
+ for (var i = 0; i < glyphs.length; i += 1) {
+ var glyph = glyphs[i];
+ callback.call(this, glyph, x, y, fontSize, options);
+ if (glyph.advanceWidth) {
+ x += glyph.advanceWidth * fontScale;
+ }
+
+ if (options.kerning && i < glyphs.length - 1) {
+ // We should apply position adjustment lookups in a more generic way.
+ // Here we only use the xAdvance value.
+ var kerningValue = kerningLookups ?
+ this.position.getKerningValue(kerningLookups, glyph.index, glyphs[i + 1].index) :
+ this.getKerningValue(glyph, glyphs[i + 1]);
+ x += kerningValue * fontScale;
+ }
+
+ if (options.letterSpacing) {
+ x += options.letterSpacing * fontSize;
+ } else if (options.tracking) {
+ x += (options.tracking / 1000) * fontSize;
+ }
+ }
+ return x;
+};
+
+/**
+ * Create a Path object that represents the given text.
+ * @param {string} text - The text to create.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {GlyphRenderOptions=} options
+ * @return {opentype.Path}
+ */
+Font.prototype.getPath = function(text, x, y, fontSize, options) {
+ var fullPath = new Path();
+ this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) {
+ var glyphPath = glyph.getPath(gX, gY, gFontSize, options, this);
+ fullPath.extend(glyphPath);
+ });
+ return fullPath;
+};
+
+/**
+ * Create an array of Path objects that represent the glyphs of a given text.
+ * @param {string} text - The text to create.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {GlyphRenderOptions=} options
+ * @return {opentype.Path[]}
+ */
+Font.prototype.getPaths = function(text, x, y, fontSize, options) {
+ var glyphPaths = [];
+ this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) {
+ var glyphPath = glyph.getPath(gX, gY, gFontSize, options, this);
+ glyphPaths.push(glyphPath);
+ });
+
+ return glyphPaths;
+};
+
+/**
+ * Returns the advance width of a text.
+ *
+ * This is something different than Path.getBoundingBox() as for example a
+ * suffixed whitespace increases the advanceWidth but not the bounding box
+ * or an overhanging letter like a calligraphic 'f' might have a quite larger
+ * bounding box than its advance width.
+ *
+ * This corresponds to canvas2dContext.measureText(text).width
+ *
+ * @param {string} text - The text to create.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {GlyphRenderOptions=} options
+ * @return advance width
+ */
+Font.prototype.getAdvanceWidth = function(text, fontSize, options) {
+ return this.forEachGlyph(text, 0, 0, fontSize, options, function() {});
+};
+
+/**
+ * Draw the text on the given drawing context.
+ * @param {CanvasRenderingContext2D} ctx - A 2D drawing context, like Canvas.
+ * @param {string} text - The text to create.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {GlyphRenderOptions=} options
+ */
+Font.prototype.draw = function(ctx, text, x, y, fontSize, options) {
+ this.getPath(text, x, y, fontSize, options).draw(ctx);
+};
+
+/**
+ * Draw the points of all glyphs in the text.
+ * On-curve points will be drawn in blue, off-curve points will be drawn in red.
+ * @param {CanvasRenderingContext2D} ctx - A 2D drawing context, like Canvas.
+ * @param {string} text - The text to create.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {GlyphRenderOptions=} options
+ */
+Font.prototype.drawPoints = function(ctx, text, x, y, fontSize, options) {
+ this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) {
+ glyph.drawPoints(ctx, gX, gY, gFontSize);
+ });
+};
+
+/**
+ * Draw lines indicating important font measurements for all glyphs in the text.
+ * Black lines indicate the origin of the coordinate system (point 0,0).
+ * Blue lines indicate the glyph bounding box.
+ * Green line indicates the advance width of the glyph.
+ * @param {CanvasRenderingContext2D} ctx - A 2D drawing context, like Canvas.
+ * @param {string} text - The text to create.
+ * @param {number} [x=0] - Horizontal position of the beginning of the text.
+ * @param {number} [y=0] - Vertical position of the *baseline* of the text.
+ * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`.
+ * @param {GlyphRenderOptions=} options
+ */
+Font.prototype.drawMetrics = function(ctx, text, x, y, fontSize, options) {
+ this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) {
+ glyph.drawMetrics(ctx, gX, gY, gFontSize);
+ });
+};
+
+/**
+ * @param {string}
+ * @return {string}
+ */
+Font.prototype.getEnglishName = function(name) {
+ var translations = this.names[name];
+ if (translations) {
+ return translations.en;
+ }
+};
+
+/**
+ * Validate
+ */
+Font.prototype.validate = function() {
+ var _this = this;
+
+ function assert(predicate, message) {
+ }
+
+ function assertNamePresent(name) {
+ var englishName = _this.getEnglishName(name);
+ assert(englishName && englishName.trim().length > 0);
+ }
+
+ // Identification information
+ assertNamePresent('fontFamily');
+ assertNamePresent('weightName');
+ assertNamePresent('manufacturer');
+ assertNamePresent('copyright');
+ assertNamePresent('version');
+
+ // Dimension information
+ assert(this.unitsPerEm > 0);
+};
+
+/**
+ * Convert the font object to a SFNT data structure.
+ * This structure contains all the necessary tables and metadata to create a binary OTF file.
+ * @return {opentype.Table}
+ */
+Font.prototype.toTables = function() {
+ return sfnt.fontToTable(this);
+};
+/**
+ * @deprecated Font.toBuffer is deprecated. Use Font.toArrayBuffer instead.
+ */
+Font.prototype.toBuffer = function() {
+ console.warn('Font.toBuffer is deprecated. Use Font.toArrayBuffer instead.');
+ return this.toArrayBuffer();
+};
+/**
+ * Converts a `opentype.Font` into an `ArrayBuffer`
+ * @return {ArrayBuffer}
+ */
+Font.prototype.toArrayBuffer = function() {
+ var sfntTable = this.toTables();
+ var bytes = sfntTable.encode();
+ var buffer = new ArrayBuffer(bytes.length);
+ var intArray = new Uint8Array(buffer);
+ for (var i = 0; i < bytes.length; i++) {
+ intArray[i] = bytes[i];
+ }
+
+ return buffer;
+};
+
+/**
+ * Initiate a download of the OpenType font.
+ */
+Font.prototype.download = function(fileName) {
+ var familyName = this.getEnglishName('fontFamily');
+ var styleName = this.getEnglishName('fontSubfamily');
+ fileName = fileName || familyName.replace(/\s/g, '') + '-' + styleName + '.otf';
+ var arrayBuffer = this.toArrayBuffer();
+
+ if (isBrowser()) {
+ window.URL = window.URL || window.webkitURL;
+
+ if (window.URL) {
+ var dataView = new DataView(arrayBuffer);
+ var blob = new Blob([dataView], {type: 'font/opentype'});
+
+ var link = document.createElement('a');
+ link.href = window.URL.createObjectURL(blob);
+ link.download = fileName;
+
+ var event = document.createEvent('MouseEvents');
+ event.initEvent('click', true, false);
+ link.dispatchEvent(event);
+ } else {
+ console.warn('Font file could not be downloaded. Try using a different browser.');
+ }
+ } else {
+ var fs = require('fs');
+ var buffer = arrayBufferToNodeBuffer(arrayBuffer);
+ fs.writeFileSync(fileName, buffer);
+ }
+};
+/**
+ * @private
+ */
+Font.prototype.fsSelectionValues = {
+ ITALIC: 0x001, //1
+ UNDERSCORE: 0x002, //2
+ NEGATIVE: 0x004, //4
+ OUTLINED: 0x008, //8
+ STRIKEOUT: 0x010, //16
+ BOLD: 0x020, //32
+ REGULAR: 0x040, //64
+ USER_TYPO_METRICS: 0x080, //128
+ WWS: 0x100, //256
+ OBLIQUE: 0x200 //512
+};
+
+/**
+ * @private
+ */
+Font.prototype.usWidthClasses = {
+ ULTRA_CONDENSED: 1,
+ EXTRA_CONDENSED: 2,
+ CONDENSED: 3,
+ SEMI_CONDENSED: 4,
+ MEDIUM: 5,
+ SEMI_EXPANDED: 6,
+ EXPANDED: 7,
+ EXTRA_EXPANDED: 8,
+ ULTRA_EXPANDED: 9
+};
+
+/**
+ * @private
+ */
+Font.prototype.usWeightClasses = {
+ THIN: 100,
+ EXTRA_LIGHT: 200,
+ LIGHT: 300,
+ NORMAL: 400,
+ MEDIUM: 500,
+ SEMI_BOLD: 600,
+ BOLD: 700,
+ EXTRA_BOLD: 800,
+ BLACK: 900
+};
+
+// The `fvar` table stores font variation axes and instances.
+
+function addName(name, names) {
+ var nameString = JSON.stringify(name);
+ var nameID = 256;
+ for (var nameKey in names) {
+ var n = parseInt(nameKey);
+ if (!n || n < 256) {
+ continue;
+ }
+
+ if (JSON.stringify(names[nameKey]) === nameString) {
+ return n;
+ }
+
+ if (nameID <= n) {
+ nameID = n + 1;
+ }
+ }
+
+ names[nameID] = name;
+ return nameID;
+}
+
+function makeFvarAxis(n, axis, names) {
+ var nameID = addName(axis.name, names);
+ return [
+ {name: 'tag_' + n, type: 'TAG', value: axis.tag},
+ {name: 'minValue_' + n, type: 'FIXED', value: axis.minValue << 16},
+ {name: 'defaultValue_' + n, type: 'FIXED', value: axis.defaultValue << 16},
+ {name: 'maxValue_' + n, type: 'FIXED', value: axis.maxValue << 16},
+ {name: 'flags_' + n, type: 'USHORT', value: 0},
+ {name: 'nameID_' + n, type: 'USHORT', value: nameID}
+ ];
+}
+
+function parseFvarAxis(data, start, names) {
+ var axis = {};
+ var p = new parse.Parser(data, start);
+ axis.tag = p.parseTag();
+ axis.minValue = p.parseFixed();
+ axis.defaultValue = p.parseFixed();
+ axis.maxValue = p.parseFixed();
+ p.skip('uShort', 1); // reserved for flags; no values defined
+ axis.name = names[p.parseUShort()] || {};
+ return axis;
+}
+
+function makeFvarInstance(n, inst, axes, names) {
+ var nameID = addName(inst.name, names);
+ var fields = [
+ {name: 'nameID_' + n, type: 'USHORT', value: nameID},
+ {name: 'flags_' + n, type: 'USHORT', value: 0}
+ ];
+
+ for (var i = 0; i < axes.length; ++i) {
+ var axisTag = axes[i].tag;
+ fields.push({
+ name: 'axis_' + n + ' ' + axisTag,
+ type: 'FIXED',
+ value: inst.coordinates[axisTag] << 16
+ });
+ }
+
+ return fields;
+}
+
+function parseFvarInstance(data, start, axes, names) {
+ var inst = {};
+ var p = new parse.Parser(data, start);
+ inst.name = names[p.parseUShort()] || {};
+ p.skip('uShort', 1); // reserved for flags; no values defined
+
+ inst.coordinates = {};
+ for (var i = 0; i < axes.length; ++i) {
+ inst.coordinates[axes[i].tag] = p.parseFixed();
+ }
+
+ return inst;
+}
+
+function makeFvarTable(fvar, names) {
+ var result = new table.Table('fvar', [
+ {name: 'version', type: 'ULONG', value: 0x10000},
+ {name: 'offsetToData', type: 'USHORT', value: 0},
+ {name: 'countSizePairs', type: 'USHORT', value: 2},
+ {name: 'axisCount', type: 'USHORT', value: fvar.axes.length},
+ {name: 'axisSize', type: 'USHORT', value: 20},
+ {name: 'instanceCount', type: 'USHORT', value: fvar.instances.length},
+ {name: 'instanceSize', type: 'USHORT', value: 4 + fvar.axes.length * 4}
+ ]);
+ result.offsetToData = result.sizeOf();
+
+ for (var i = 0; i < fvar.axes.length; i++) {
+ result.fields = result.fields.concat(makeFvarAxis(i, fvar.axes[i], names));
+ }
+
+ for (var j = 0; j < fvar.instances.length; j++) {
+ result.fields = result.fields.concat(makeFvarInstance(j, fvar.instances[j], fvar.axes, names));
+ }
+
+ return result;
+}
+
+function parseFvarTable(data, start, names) {
+ var p = new parse.Parser(data, start);
+ var tableVersion = p.parseULong();
+ check.argument(tableVersion === 0x00010000, 'Unsupported fvar table version.');
+ var offsetToData = p.parseOffset16();
+ // Skip countSizePairs.
+ p.skip('uShort', 1);
+ var axisCount = p.parseUShort();
+ var axisSize = p.parseUShort();
+ var instanceCount = p.parseUShort();
+ var instanceSize = p.parseUShort();
+
+ var axes = [];
+ for (var i = 0; i < axisCount; i++) {
+ axes.push(parseFvarAxis(data, start + offsetToData + i * axisSize, names));
+ }
+
+ var instances = [];
+ var instanceStart = start + offsetToData + axisCount * axisSize;
+ for (var j = 0; j < instanceCount; j++) {
+ instances.push(parseFvarInstance(data, instanceStart + j * instanceSize, axes, names));
+ }
+
+ return {axes: axes, instances: instances};
+}
+
+var fvar = { make: makeFvarTable, parse: parseFvarTable };
+
+// The `GDEF` table contains various glyph properties
+
+var attachList = function() {
+ return {
+ coverage: this.parsePointer(Parser.coverage),
+ attachPoints: this.parseList(Parser.pointer(Parser.uShortList))
+ };
+};
+
+var caretValue = function() {
+ var format = this.parseUShort();
+ check.argument(format === 1 || format === 2 || format === 3,
+ 'Unsupported CaretValue table version.');
+ if (format === 1) {
+ return { coordinate: this.parseShort() };
+ } else if (format === 2) {
+ return { pointindex: this.parseShort() };
+ } else if (format === 3) {
+ // Device / Variation Index tables unsupported
+ return { coordinate: this.parseShort() };
+ }
+};
+
+var ligGlyph = function() {
+ return this.parseList(Parser.pointer(caretValue));
+};
+
+var ligCaretList = function() {
+ return {
+ coverage: this.parsePointer(Parser.coverage),
+ ligGlyphs: this.parseList(Parser.pointer(ligGlyph))
+ };
+};
+
+var markGlyphSets = function() {
+ this.parseUShort(); // Version
+ return this.parseList(Parser.pointer(Parser.coverage));
+};
+
+function parseGDEFTable(data, start) {
+ start = start || 0;
+ var p = new Parser(data, start);
+ var tableVersion = p.parseVersion(1);
+ check.argument(tableVersion === 1 || tableVersion === 1.2 || tableVersion === 1.3,
+ 'Unsupported GDEF table version.');
+ var gdef = {
+ version: tableVersion,
+ classDef: p.parsePointer(Parser.classDef),
+ attachList: p.parsePointer(attachList),
+ ligCaretList: p.parsePointer(ligCaretList),
+ markAttachClassDef: p.parsePointer(Parser.classDef)
+ };
+ if (tableVersion >= 1.2) {
+ gdef.markGlyphSets = p.parsePointer(markGlyphSets);
+ }
+ return gdef;
+}
+var gdef = { parse: parseGDEFTable };
+
+// The `GPOS` table contains kerning pairs, among other things.
+
+var subtableParsers$1 = new Array(10); // subtableParsers[0] is unused
+
+// https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-1-single-adjustment-positioning-subtable
+// this = Parser instance
+subtableParsers$1[1] = function parseLookup1() {
+ var start = this.offset + this.relativeOffset;
+ var posformat = this.parseUShort();
+ if (posformat === 1) {
+ return {
+ posFormat: 1,
+ coverage: this.parsePointer(Parser.coverage),
+ value: this.parseValueRecord()
+ };
+ } else if (posformat === 2) {
+ return {
+ posFormat: 2,
+ coverage: this.parsePointer(Parser.coverage),
+ values: this.parseValueRecordList()
+ };
+ }
+ check.assert(false, '0x' + start.toString(16) + ': GPOS lookup type 1 format must be 1 or 2.');
+};
+
+// https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-2-pair-adjustment-positioning-subtable
+subtableParsers$1[2] = function parseLookup2() {
+ var start = this.offset + this.relativeOffset;
+ var posFormat = this.parseUShort();
+ check.assert(posFormat === 1 || posFormat === 2, '0x' + start.toString(16) + ': GPOS lookup type 2 format must be 1 or 2.');
+ var coverage = this.parsePointer(Parser.coverage);
+ var valueFormat1 = this.parseUShort();
+ var valueFormat2 = this.parseUShort();
+ if (posFormat === 1) {
+ // Adjustments for Glyph Pairs
+ return {
+ posFormat: posFormat,
+ coverage: coverage,
+ valueFormat1: valueFormat1,
+ valueFormat2: valueFormat2,
+ pairSets: this.parseList(Parser.pointer(Parser.list(function() {
+ return { // pairValueRecord
+ secondGlyph: this.parseUShort(),
+ value1: this.parseValueRecord(valueFormat1),
+ value2: this.parseValueRecord(valueFormat2)
+ };
+ })))
+ };
+ } else if (posFormat === 2) {
+ var classDef1 = this.parsePointer(Parser.classDef);
+ var classDef2 = this.parsePointer(Parser.classDef);
+ var class1Count = this.parseUShort();
+ var class2Count = this.parseUShort();
+ return {
+ // Class Pair Adjustment
+ posFormat: posFormat,
+ coverage: coverage,
+ valueFormat1: valueFormat1,
+ valueFormat2: valueFormat2,
+ classDef1: classDef1,
+ classDef2: classDef2,
+ class1Count: class1Count,
+ class2Count: class2Count,
+ classRecords: this.parseList(class1Count, Parser.list(class2Count, function() {
+ return {
+ value1: this.parseValueRecord(valueFormat1),
+ value2: this.parseValueRecord(valueFormat2)
+ };
+ }))
+ };
+ }
+};
+
+subtableParsers$1[3] = function parseLookup3() { return { error: 'GPOS Lookup 3 not supported' }; };
+subtableParsers$1[4] = function parseLookup4() { return { error: 'GPOS Lookup 4 not supported' }; };
+subtableParsers$1[5] = function parseLookup5() { return { error: 'GPOS Lookup 5 not supported' }; };
+subtableParsers$1[6] = function parseLookup6() { return { error: 'GPOS Lookup 6 not supported' }; };
+subtableParsers$1[7] = function parseLookup7() { return { error: 'GPOS Lookup 7 not supported' }; };
+subtableParsers$1[8] = function parseLookup8() { return { error: 'GPOS Lookup 8 not supported' }; };
+subtableParsers$1[9] = function parseLookup9() { return { error: 'GPOS Lookup 9 not supported' }; };
+
+// https://docs.microsoft.com/en-us/typography/opentype/spec/gpos
+function parseGposTable(data, start) {
+ start = start || 0;
+ var p = new Parser(data, start);
+ var tableVersion = p.parseVersion(1);
+ check.argument(tableVersion === 1 || tableVersion === 1.1, 'Unsupported GPOS table version ' + tableVersion);
+
+ if (tableVersion === 1) {
+ return {
+ version: tableVersion,
+ scripts: p.parseScriptList(),
+ features: p.parseFeatureList(),
+ lookups: p.parseLookupList(subtableParsers$1)
+ };
+ } else {
+ return {
+ version: tableVersion,
+ scripts: p.parseScriptList(),
+ features: p.parseFeatureList(),
+ lookups: p.parseLookupList(subtableParsers$1),
+ variations: p.parseFeatureVariationsList()
+ };
+ }
+
+}
+
+// GPOS Writing //////////////////////////////////////////////
+// NOT SUPPORTED
+var subtableMakers$1 = new Array(10);
+
+function makeGposTable(gpos) {
+ return new table.Table('GPOS', [
+ {name: 'version', type: 'ULONG', value: 0x10000},
+ {name: 'scripts', type: 'TABLE', value: new table.ScriptList(gpos.scripts)},
+ {name: 'features', type: 'TABLE', value: new table.FeatureList(gpos.features)},
+ {name: 'lookups', type: 'TABLE', value: new table.LookupList(gpos.lookups, subtableMakers$1)}
+ ]);
+}
+
+var gpos = { parse: parseGposTable, make: makeGposTable };
+
+// The `kern` table contains kerning pairs.
+
+function parseWindowsKernTable(p) {
+ var pairs = {};
+ // Skip nTables.
+ p.skip('uShort');
+ var subtableVersion = p.parseUShort();
+ check.argument(subtableVersion === 0, 'Unsupported kern sub-table version.');
+ // Skip subtableLength, subtableCoverage
+ p.skip('uShort', 2);
+ var nPairs = p.parseUShort();
+ // Skip searchRange, entrySelector, rangeShift.
+ p.skip('uShort', 3);
+ for (var i = 0; i < nPairs; i += 1) {
+ var leftIndex = p.parseUShort();
+ var rightIndex = p.parseUShort();
+ var value = p.parseShort();
+ pairs[leftIndex + ',' + rightIndex] = value;
+ }
+ return pairs;
+}
+
+function parseMacKernTable(p) {
+ var pairs = {};
+ // The Mac kern table stores the version as a fixed (32 bits) but we only loaded the first 16 bits.
+ // Skip the rest.
+ p.skip('uShort');
+ var nTables = p.parseULong();
+ //check.argument(nTables === 1, 'Only 1 subtable is supported (got ' + nTables + ').');
+ if (nTables > 1) {
+ console.warn('Only the first kern subtable is supported.');
+ }
+ p.skip('uLong');
+ var coverage = p.parseUShort();
+ var subtableVersion = coverage & 0xFF;
+ p.skip('uShort');
+ if (subtableVersion === 0) {
+ var nPairs = p.parseUShort();
+ // Skip searchRange, entrySelector, rangeShift.
+ p.skip('uShort', 3);
+ for (var i = 0; i < nPairs; i += 1) {
+ var leftIndex = p.parseUShort();
+ var rightIndex = p.parseUShort();
+ var value = p.parseShort();
+ pairs[leftIndex + ',' + rightIndex] = value;
+ }
+ }
+ return pairs;
+}
+
+// Parse the `kern` table which contains kerning pairs.
+function parseKernTable(data, start) {
+ var p = new parse.Parser(data, start);
+ var tableVersion = p.parseUShort();
+ if (tableVersion === 0) {
+ return parseWindowsKernTable(p);
+ } else if (tableVersion === 1) {
+ return parseMacKernTable(p);
+ } else {
+ throw new Error('Unsupported kern table version (' + tableVersion + ').');
+ }
+}
+
+var kern = { parse: parseKernTable };
+
+// The `loca` table stores the offsets to the locations of the glyphs in the font.
+
+// Parse the `loca` table. This table stores the offsets to the locations of the glyphs in the font,
+// relative to the beginning of the glyphData table.
+// The number of glyphs stored in the `loca` table is specified in the `maxp` table (under numGlyphs)
+// The loca table has two versions: a short version where offsets are stored as uShorts, and a long
+// version where offsets are stored as uLongs. The `head` table specifies which version to use
+// (under indexToLocFormat).
+function parseLocaTable(data, start, numGlyphs, shortVersion) {
+ var p = new parse.Parser(data, start);
+ var parseFn = shortVersion ? p.parseUShort : p.parseULong;
+ // There is an extra entry after the last index element to compute the length of the last glyph.
+ // That's why we use numGlyphs + 1.
+ var glyphOffsets = [];
+ for (var i = 0; i < numGlyphs + 1; i += 1) {
+ var glyphOffset = parseFn.call(p);
+ if (shortVersion) {
+ // The short table version stores the actual offset divided by 2.
+ glyphOffset *= 2;
+ }
+
+ glyphOffsets.push(glyphOffset);
+ }
+
+ return glyphOffsets;
+}
+
+var loca = { parse: parseLocaTable };
+
+// opentype.js
+
+/**
+ * The opentype library.
+ * @namespace opentype
+ */
+
+// File loaders /////////////////////////////////////////////////////////
+/**
+ * Loads a font from a file. The callback throws an error message as the first parameter if it fails
+ * and the font as an ArrayBuffer in the second parameter if it succeeds.
+ * @param {string} path - The path of the file
+ * @param {Function} callback - The function to call when the font load completes
+ */
+function loadFromFile(path, callback) {
+ var fs = require('fs');
+ fs.readFile(path, function(err, buffer) {
+ if (err) {
+ return callback(err.message);
+ }
+
+ callback(null, nodeBufferToArrayBuffer(buffer));
+ });
+}
+/**
+ * Loads a font from a URL. The callback throws an error message as the first parameter if it fails
+ * and the font as an ArrayBuffer in the second parameter if it succeeds.
+ * @param {string} url - The URL of the font file.
+ * @param {Function} callback - The function to call when the font load completes
+ */
+function loadFromUrl(url, callback) {
+ var request = new XMLHttpRequest();
+ request.open('get', url, true);
+ request.responseType = 'arraybuffer';
+ request.onload = function() {
+ if (request.response) {
+ return callback(null, request.response);
+ } else {
+ return callback('Font could not be loaded: ' + request.statusText);
+ }
+ };
+
+ request.onerror = function () {
+ callback('Font could not be loaded');
+ };
+
+ request.send();
+}
+
+// Table Directory Entries //////////////////////////////////////////////
+/**
+ * Parses OpenType table entries.
+ * @param {DataView}
+ * @param {Number}
+ * @return {Object[]}
+ */
+function parseOpenTypeTableEntries(data, numTables) {
+ var tableEntries = [];
+ var p = 12;
+ for (var i = 0; i < numTables; i += 1) {
+ var tag = parse.getTag(data, p);
+ var checksum = parse.getULong(data, p + 4);
+ var offset = parse.getULong(data, p + 8);
+ var length = parse.getULong(data, p + 12);
+ tableEntries.push({tag: tag, checksum: checksum, offset: offset, length: length, compression: false});
+ p += 16;
+ }
+
+ return tableEntries;
+}
+
+/**
+ * Parses WOFF table entries.
+ * @param {DataView}
+ * @param {Number}
+ * @return {Object[]}
+ */
+function parseWOFFTableEntries(data, numTables) {
+ var tableEntries = [];
+ var p = 44; // offset to the first table directory entry.
+ for (var i = 0; i < numTables; i += 1) {
+ var tag = parse.getTag(data, p);
+ var offset = parse.getULong(data, p + 4);
+ var compLength = parse.getULong(data, p + 8);
+ var origLength = parse.getULong(data, p + 12);
+ var compression = (void 0);
+ if (compLength < origLength) {
+ compression = 'WOFF';
+ } else {
+ compression = false;
+ }
+
+ tableEntries.push({tag: tag, offset: offset, compression: compression,
+ compressedLength: compLength, length: origLength});
+ p += 20;
+ }
+
+ return tableEntries;
+}
+
+/**
+ * @typedef TableData
+ * @type Object
+ * @property {DataView} data - The DataView
+ * @property {number} offset - The data offset.
+ */
+
+/**
+ * @param {DataView}
+ * @param {Object}
+ * @return {TableData}
+ */
+function uncompressTable(data, tableEntry) {
+ if (tableEntry.compression === 'WOFF') {
+ var inBuffer = new Uint8Array(data.buffer, tableEntry.offset + 2, tableEntry.compressedLength - 2);
+ var outBuffer = new Uint8Array(tableEntry.length);
+ tinyInflate(inBuffer, outBuffer);
+ if (outBuffer.byteLength !== tableEntry.length) {
+ throw new Error('Decompression error: ' + tableEntry.tag + ' decompressed length doesn\'t match recorded length');
+ }
+
+ var view = new DataView(outBuffer.buffer, 0);
+ return {data: view, offset: 0};
+ } else {
+ return {data: data, offset: tableEntry.offset};
+ }
+}
+
+// Public API ///////////////////////////////////////////////////////////
+
+/**
+ * Parse the OpenType file data (as an ArrayBuffer) and return a Font object.
+ * Throws an error if the font could not be parsed.
+ * @param {ArrayBuffer}
+ * @param {Object} opt - options for parsing
+ * @return {opentype.Font}
+ */
+function parseBuffer(buffer, opt) {
+ opt = (opt === undefined || opt === null) ? {} : opt;
+
+ var indexToLocFormat;
+ var ltagTable;
+
+ // Since the constructor can also be called to create new fonts from scratch, we indicate this
+ // should be an empty font that we'll fill with our own data.
+ var font = new Font({empty: true});
+
+ // OpenType fonts use big endian byte ordering.
+ // We can't rely on typed array view types, because they operate with the endianness of the host computer.
+ // Instead we use DataViews where we can specify endianness.
+ var data = new DataView(buffer, 0);
+ var numTables;
+ var tableEntries = [];
+ var signature = parse.getTag(data, 0);
+ if (signature === String.fromCharCode(0, 1, 0, 0) || signature === 'true' || signature === 'typ1') {
+ font.outlinesFormat = 'truetype';
+ numTables = parse.getUShort(data, 4);
+ tableEntries = parseOpenTypeTableEntries(data, numTables);
+ } else if (signature === 'OTTO') {
+ font.outlinesFormat = 'cff';
+ numTables = parse.getUShort(data, 4);
+ tableEntries = parseOpenTypeTableEntries(data, numTables);
+ } else if (signature === 'wOFF') {
+ var flavor = parse.getTag(data, 4);
+ if (flavor === String.fromCharCode(0, 1, 0, 0)) {
+ font.outlinesFormat = 'truetype';
+ } else if (flavor === 'OTTO') {
+ font.outlinesFormat = 'cff';
+ } else {
+ throw new Error('Unsupported OpenType flavor ' + signature);
+ }
+
+ numTables = parse.getUShort(data, 12);
+ tableEntries = parseWOFFTableEntries(data, numTables);
+ } else {
+ throw new Error('Unsupported OpenType signature ' + signature);
+ }
+
+ var cffTableEntry;
+ var fvarTableEntry;
+ var glyfTableEntry;
+ var gdefTableEntry;
+ var gposTableEntry;
+ var gsubTableEntry;
+ var hmtxTableEntry;
+ var kernTableEntry;
+ var locaTableEntry;
+ var nameTableEntry;
+ var metaTableEntry;
+ var p;
+
+ for (var i = 0; i < numTables; i += 1) {
+ var tableEntry = tableEntries[i];
+ var table = (void 0);
+ switch (tableEntry.tag) {
+ case 'cmap':
+ table = uncompressTable(data, tableEntry);
+ font.tables.cmap = cmap.parse(table.data, table.offset);
+ font.encoding = new CmapEncoding(font.tables.cmap);
+ break;
+ case 'cvt ' :
+ table = uncompressTable(data, tableEntry);
+ p = new parse.Parser(table.data, table.offset);
+ font.tables.cvt = p.parseShortList(tableEntry.length / 2);
+ break;
+ case 'fvar':
+ fvarTableEntry = tableEntry;
+ break;
+ case 'fpgm' :
+ table = uncompressTable(data, tableEntry);
+ p = new parse.Parser(table.data, table.offset);
+ font.tables.fpgm = p.parseByteList(tableEntry.length);
+ break;
+ case 'head':
+ table = uncompressTable(data, tableEntry);
+ font.tables.head = head.parse(table.data, table.offset);
+ font.unitsPerEm = font.tables.head.unitsPerEm;
+ indexToLocFormat = font.tables.head.indexToLocFormat;
+ break;
+ case 'hhea':
+ table = uncompressTable(data, tableEntry);
+ font.tables.hhea = hhea.parse(table.data, table.offset);
+ font.ascender = font.tables.hhea.ascender;
+ font.descender = font.tables.hhea.descender;
+ font.numberOfHMetrics = font.tables.hhea.numberOfHMetrics;
+ break;
+ case 'hmtx':
+ hmtxTableEntry = tableEntry;
+ break;
+ case 'ltag':
+ table = uncompressTable(data, tableEntry);
+ ltagTable = ltag.parse(table.data, table.offset);
+ break;
+ case 'maxp':
+ table = uncompressTable(data, tableEntry);
+ font.tables.maxp = maxp.parse(table.data, table.offset);
+ font.numGlyphs = font.tables.maxp.numGlyphs;
+ break;
+ case 'name':
+ nameTableEntry = tableEntry;
+ break;
+ case 'OS/2':
+ table = uncompressTable(data, tableEntry);
+ font.tables.os2 = os2.parse(table.data, table.offset);
+ break;
+ case 'post':
+ table = uncompressTable(data, tableEntry);
+ font.tables.post = post.parse(table.data, table.offset);
+ font.glyphNames = new GlyphNames(font.tables.post);
+ break;
+ case 'prep' :
+ table = uncompressTable(data, tableEntry);
+ p = new parse.Parser(table.data, table.offset);
+ font.tables.prep = p.parseByteList(tableEntry.length);
+ break;
+ case 'glyf':
+ glyfTableEntry = tableEntry;
+ break;
+ case 'loca':
+ locaTableEntry = tableEntry;
+ break;
+ case 'CFF ':
+ cffTableEntry = tableEntry;
+ break;
+ case 'kern':
+ kernTableEntry = tableEntry;
+ break;
+ case 'GDEF':
+ gdefTableEntry = tableEntry;
+ break;
+ case 'GPOS':
+ gposTableEntry = tableEntry;
+ break;
+ case 'GSUB':
+ gsubTableEntry = tableEntry;
+ break;
+ case 'meta':
+ metaTableEntry = tableEntry;
+ break;
+ }
+ }
+
+ var nameTable = uncompressTable(data, nameTableEntry);
+ font.tables.name = _name.parse(nameTable.data, nameTable.offset, ltagTable);
+ font.names = font.tables.name;
+
+ if (glyfTableEntry && locaTableEntry) {
+ var shortVersion = indexToLocFormat === 0;
+ var locaTable = uncompressTable(data, locaTableEntry);
+ var locaOffsets = loca.parse(locaTable.data, locaTable.offset, font.numGlyphs, shortVersion);
+ var glyfTable = uncompressTable(data, glyfTableEntry);
+ font.glyphs = glyf.parse(glyfTable.data, glyfTable.offset, locaOffsets, font, opt);
+ } else if (cffTableEntry) {
+ var cffTable = uncompressTable(data, cffTableEntry);
+ cff.parse(cffTable.data, cffTable.offset, font, opt);
+ } else {
+ throw new Error('Font doesn\'t contain TrueType or CFF outlines.');
+ }
+
+ var hmtxTable = uncompressTable(data, hmtxTableEntry);
+ hmtx.parse(font, hmtxTable.data, hmtxTable.offset, font.numberOfHMetrics, font.numGlyphs, font.glyphs, opt);
+ addGlyphNames(font, opt);
+
+ if (kernTableEntry) {
+ var kernTable = uncompressTable(data, kernTableEntry);
+ font.kerningPairs = kern.parse(kernTable.data, kernTable.offset);
+ } else {
+ font.kerningPairs = {};
+ }
+
+ if (gdefTableEntry) {
+ var gdefTable = uncompressTable(data, gdefTableEntry);
+ font.tables.gdef = gdef.parse(gdefTable.data, gdefTable.offset);
+ }
+
+ if (gposTableEntry) {
+ var gposTable = uncompressTable(data, gposTableEntry);
+ font.tables.gpos = gpos.parse(gposTable.data, gposTable.offset);
+ font.position.init();
+ }
+
+ if (gsubTableEntry) {
+ var gsubTable = uncompressTable(data, gsubTableEntry);
+ font.tables.gsub = gsub.parse(gsubTable.data, gsubTable.offset);
+ }
+
+ if (fvarTableEntry) {
+ var fvarTable = uncompressTable(data, fvarTableEntry);
+ font.tables.fvar = fvar.parse(fvarTable.data, fvarTable.offset, font.names);
+ }
+
+ if (metaTableEntry) {
+ var metaTable = uncompressTable(data, metaTableEntry);
+ font.tables.meta = meta.parse(metaTable.data, metaTable.offset);
+ font.metas = font.tables.meta;
+ }
+
+ return font;
+}
+
+/**
+ * Asynchronously load the font from a URL or a filesystem. When done, call the callback
+ * with two arguments `(err, font)`. The `err` will be null on success,
+ * the `font` is a Font object.
+ * We use the node.js callback convention so that
+ * opentype.js can integrate with frameworks like async.js.
+ * @alias opentype.load
+ * @param {string} url - The URL of the font to load.
+ * @param {Function} callback - The callback.
+ */
+function load(url, callback, opt) {
+ opt = (opt === undefined || opt === null) ? {} : opt;
+ var isNode = typeof window === 'undefined';
+ var loadFn = isNode && !opt.isUrl ? loadFromFile : loadFromUrl;
+
+ return new Promise(function (resolve, reject) {
+ loadFn(url, function(err, arrayBuffer) {
+ if (err) {
+ if (callback) {
+ return callback(err);
+ } else {
+ reject(err);
+ }
+ }
+ var font;
+ try {
+ font = parseBuffer(arrayBuffer, opt);
+ } catch (e) {
+ if (callback) {
+ return callback(e, null);
+ } else {
+ reject(e);
+ }
+ }
+ if (callback) {
+ return callback(null, font);
+ } else {
+ resolve(font);
+ }
+ });
+ });
+}
+
+/**
+ * Synchronously load the font from a URL or file.
+ * When done, returns the font object or throws an error.
+ * @alias opentype.loadSync
+ * @param {string} url - The URL of the font to load.
+ * @param {Object} opt - opt.lowMemory
+ * @return {opentype.Font}
+ */
+function loadSync(url, opt) {
+ var fs = require('fs');
+ var buffer = fs.readFileSync(url);
+ return parseBuffer(nodeBufferToArrayBuffer(buffer), opt);
+}
+
+var opentype = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ Font: Font,
+ Glyph: Glyph,
+ Path: Path,
+ BoundingBox: BoundingBox,
+ _parse: parse,
+ parse: parseBuffer,
+ load: load,
+ loadSync: loadSync
+});
+
+export default opentype;
+export { BoundingBox, Font, Glyph, Path, parse as _parse, load, loadSync, parseBuffer as parse };
+//# sourceMappingURL=opentype.module.js.map
diff --git a/bluebey-studio/vendor/lib/opentype.module.js.LICENSE b/bluebey-studio/vendor/lib/opentype.module.js.LICENSE
new file mode 100644
index 0000000..023e618
--- /dev/null
+++ b/bluebey-studio/vendor/lib/opentype.module.js.LICENSE
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Frederik De Bleser
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/bluebey-studio/vendor/lib/opentype.module.js.VERSION b/bluebey-studio/vendor/lib/opentype.module.js.VERSION
new file mode 100644
index 0000000..a0023af
--- /dev/null
+++ b/bluebey-studio/vendor/lib/opentype.module.js.VERSION
@@ -0,0 +1 @@
+opentype.js 1.3.4
diff --git a/bluebey-studio/vendor/three/LICENSE b/bluebey-studio/vendor/three/LICENSE
new file mode 100644
index 0000000..8ada2a5
--- /dev/null
+++ b/bluebey-studio/vendor/three/LICENSE
@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright © 2010-2026 three.js authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/bluebey-studio/vendor/three/VERSION b/bluebey-studio/vendor/three/VERSION
new file mode 100644
index 0000000..3ec66c8
--- /dev/null
+++ b/bluebey-studio/vendor/three/VERSION
@@ -0,0 +1 @@
+0.186.0
diff --git a/bluebey-studio/vendor/three/build/three.core.js b/bluebey-studio/vendor/three/build/three.core.js
new file mode 100644
index 0000000..0a1d6cb
--- /dev/null
+++ b/bluebey-studio/vendor/three/build/three.core.js
@@ -0,0 +1,60586 @@
+/**
+ * @license
+ * Copyright 2010-2026 Three.js Authors
+ * SPDX-License-Identifier: MIT
+ */
+const REVISION = '186';
+
+/**
+ * Represents mouse buttons and interaction types in context of controls.
+ *
+ * @type {ConstantsMouse}
+ * @constant
+ */
+const MOUSE = { LEFT: 0, MIDDLE: 1, RIGHT: 2, ROTATE: 0, DOLLY: 1, PAN: 2 };
+
+/**
+ * Represents touch interaction types in context of controls.
+ *
+ * @type {ConstantsTouch}
+ * @constant
+ */
+const TOUCH = { ROTATE: 0, PAN: 1, DOLLY_PAN: 2, DOLLY_ROTATE: 3 };
+
+/**
+ * Disables face culling.
+ *
+ * @type {number}
+ * @constant
+ */
+const CullFaceNone = 0;
+
+/**
+ * Culls back faces.
+ *
+ * @type {number}
+ * @constant
+ */
+const CullFaceBack = 1;
+
+/**
+ * Culls front faces.
+ *
+ * @type {number}
+ * @constant
+ */
+const CullFaceFront = 2;
+
+/**
+ * Culls both front and back faces.
+ *
+ * @type {number}
+ * @constant
+ */
+const CullFaceFrontBack = 3;
+
+/**
+ * Gives unfiltered shadow maps - fastest, but lowest quality.
+ *
+ * @type {number}
+ * @constant
+ */
+const BasicShadowMap = 0;
+
+/**
+ * Filters shadow maps using the Percentage-Closer Filtering (PCF) algorithm.
+ *
+ * @type {number}
+ * @constant
+ */
+const PCFShadowMap = 1;
+
+/**
+ * Filters shadow maps using the Percentage-Closer Filtering (PCF) algorithm with
+ * better soft shadows especially when using low-resolution shadow maps.
+ *
+ * @type {number}
+ * @constant
+ * @deprecated since r186. Use `PCFShadowMap` instead.
+ */
+const PCFSoftShadowMap = 2;
+
+/**
+ * Filters shadow maps using the Variance Shadow Map (VSM) algorithm.
+ * When using VSMShadowMap all shadow receivers will also cast shadows.
+ *
+ * @type {number}
+ * @constant
+ */
+const VSMShadowMap = 3;
+
+/**
+ * Only front faces are rendered.
+ *
+ * @type {number}
+ * @constant
+ */
+const FrontSide = 0;
+
+/**
+ * Only back faces are rendered.
+ *
+ * @type {number}
+ * @constant
+ */
+const BackSide = 1;
+
+/**
+ * Both front and back faces are rendered.
+ *
+ * @type {number}
+ * @constant
+ */
+const DoubleSide = 2;
+
+/**
+ * No blending is performed which effectively disables
+ * alpha transparency.
+ *
+ * @type {number}
+ * @constant
+ */
+const NoBlending = 0;
+
+/**
+ * The default blending.
+ *
+ * @type {number}
+ * @constant
+ */
+const NormalBlending = 1;
+
+/**
+ * Represents additive blending.
+ *
+ * @type {number}
+ * @constant
+ */
+const AdditiveBlending = 2;
+
+/**
+ * Represents subtractive blending.
+ *
+ * @type {number}
+ * @constant
+ */
+const SubtractiveBlending = 3;
+
+/**
+ * Represents multiply blending.
+ *
+ * @type {number}
+ * @constant
+ */
+const MultiplyBlending = 4;
+
+/**
+ * Represents custom blending.
+ *
+ * @type {number}
+ * @constant
+ */
+const CustomBlending = 5;
+
+/**
+ * Represents material blending.
+ *
+ * @type {number}
+ * @constant
+ */
+const MaterialBlending = 6;
+
+/**
+ * A `source + destination` blending equation.
+ *
+ * @type {number}
+ * @constant
+ */
+const AddEquation = 100;
+
+/**
+ * A `source - destination` blending equation.
+ *
+ * @type {number}
+ * @constant
+ */
+const SubtractEquation = 101;
+
+/**
+ * A `destination - source` blending equation.
+ *
+ * @type {number}
+ * @constant
+ */
+const ReverseSubtractEquation = 102;
+
+/**
+ * A blend equation that uses the minimum of source and destination.
+ *
+ * @type {number}
+ * @constant
+ */
+const MinEquation = 103;
+
+/**
+ * A blend equation that uses the maximum of source and destination.
+ *
+ * @type {number}
+ * @constant
+ */
+const MaxEquation = 104;
+
+/**
+ * Multiplies all colors by `0`.
+ *
+ * @type {number}
+ * @constant
+ */
+const ZeroFactor = 200;
+
+/**
+ * Multiplies all colors by `1`.
+ *
+ * @type {number}
+ * @constant
+ */
+const OneFactor = 201;
+
+/**
+ * Multiplies all colors by the source colors.
+ *
+ * @type {number}
+ * @constant
+ */
+const SrcColorFactor = 202;
+
+/**
+ * Multiplies all colors by `1` minus each source color.
+ *
+ * @type {number}
+ * @constant
+ */
+const OneMinusSrcColorFactor = 203;
+
+/**
+ * Multiplies all colors by the source alpha value.
+ *
+ * @type {number}
+ * @constant
+ */
+const SrcAlphaFactor = 204;
+
+/**
+ * Multiplies all colors by 1 minus the source alpha value.
+ *
+ * @type {number}
+ * @constant
+ */
+const OneMinusSrcAlphaFactor = 205;
+
+/**
+ * Multiplies all colors by the destination alpha value.
+ *
+ * @type {number}
+ * @constant
+ */
+const DstAlphaFactor = 206;
+
+/**
+ * Multiplies all colors by `1` minus the destination alpha value.
+ *
+ * @type {number}
+ * @constant
+ */
+const OneMinusDstAlphaFactor = 207;
+
+/**
+ * Multiplies all colors by the destination color.
+ *
+ * @type {number}
+ * @constant
+ */
+const DstColorFactor = 208;
+
+/**
+ * Multiplies all colors by `1` minus each destination color.
+ *
+ * @type {number}
+ * @constant
+ */
+const OneMinusDstColorFactor = 209;
+
+/**
+ * Multiplies the RGB colors by the smaller of either the source alpha
+ * value or the value of `1` minus the destination alpha value. The alpha
+ * value is multiplied by `1`.
+ *
+ * @type {number}
+ * @constant
+ */
+const SrcAlphaSaturateFactor = 210;
+
+/**
+ * Multiplies all colors by a constant color.
+ *
+ * @type {number}
+ * @constant
+ */
+const ConstantColorFactor = 211;
+
+/**
+ * Multiplies all colors by `1` minus a constant color.
+ *
+ * @type {number}
+ * @constant
+ */
+const OneMinusConstantColorFactor = 212;
+
+/**
+ * Multiplies all colors by a constant alpha value.
+ *
+ * @type {number}
+ * @constant
+ */
+const ConstantAlphaFactor = 213;
+
+/**
+ * Multiplies all colors by 1 minus a constant alpha value.
+ *
+ * @type {number}
+ * @constant
+ */
+const OneMinusConstantAlphaFactor = 214;
+
+/**
+ * Never pass.
+ *
+ * @type {number}
+ * @constant
+ */
+const NeverDepth = 0;
+
+/**
+ * Always pass.
+ *
+ * @type {number}
+ * @constant
+ */
+const AlwaysDepth = 1;
+
+/**
+ * Pass if the incoming value is less than the depth buffer value.
+ *
+ * @type {number}
+ * @constant
+ */
+const LessDepth = 2;
+
+/**
+ * Pass if the incoming value is less than or equal to the depth buffer value.
+ *
+ * @type {number}
+ * @constant
+ */
+const LessEqualDepth = 3;
+
+/**
+ * Pass if the incoming value equals the depth buffer value.
+ *
+ * @type {number}
+ * @constant
+ */
+const EqualDepth = 4;
+
+/**
+ * Pass if the incoming value is greater than or equal to the depth buffer value.
+ *
+ * @type {number}
+ * @constant
+ */
+const GreaterEqualDepth = 5;
+
+/**
+ * Pass if the incoming value is greater than the depth buffer value.
+ *
+ * @type {number}
+ * @constant
+ */
+const GreaterDepth = 6;
+
+/**
+ * Pass if the incoming value is not equal to the depth buffer value.
+ *
+ * @type {number}
+ * @constant
+ */
+const NotEqualDepth = 7;
+
+/**
+ * Multiplies the environment map color with the surface color.
+ *
+ * @type {number}
+ * @constant
+ */
+const MultiplyOperation = 0;
+
+/**
+ * Uses reflectivity to blend between the two colors.
+ *
+ * @type {number}
+ * @constant
+ */
+const MixOperation = 1;
+
+/**
+ * Adds the two colors.
+ *
+ * @type {number}
+ * @constant
+ */
+const AddOperation = 2;
+
+/**
+ * No tone mapping is applied.
+ *
+ * @type {number}
+ * @constant
+ */
+const NoToneMapping = 0;
+
+/**
+ * Linear tone mapping.
+ *
+ * @type {number}
+ * @constant
+ */
+const LinearToneMapping = 1;
+
+/**
+ * Reinhard tone mapping.
+ *
+ * @type {number}
+ * @constant
+ */
+const ReinhardToneMapping = 2;
+
+/**
+ * Cineon tone mapping.
+ *
+ * @type {number}
+ * @constant
+ */
+const CineonToneMapping = 3;
+
+/**
+ * ACES Filmic tone mapping.
+ *
+ * @type {number}
+ * @constant
+ */
+const ACESFilmicToneMapping = 4;
+
+/**
+ * Custom tone mapping.
+ *
+ * Expects a custom implementation by modifying shader code of the material's fragment shader.
+ *
+ * @type {number}
+ * @constant
+ */
+const CustomToneMapping = 5;
+
+/**
+ * AgX tone mapping.
+ *
+ * @type {number}
+ * @constant
+ */
+const AgXToneMapping = 6;
+
+/**
+ * Neutral tone mapping.
+ *
+ * Implementation based on the Khronos 3D Commerce Group standard tone mapping.
+ *
+ * @type {number}
+ * @constant
+ */
+const NeutralToneMapping = 7;
+
+/**
+ * The skinned mesh shares the same world space as the skeleton.
+ *
+ * @type {string}
+ * @constant
+ */
+const AttachedBindMode = 'attached';
+
+/**
+ * The skinned mesh does not share the same world space as the skeleton.
+ * This is useful when a skeleton is shared across multiple skinned meshes.
+ *
+ * @type {string}
+ * @constant
+ */
+const DetachedBindMode = 'detached';
+
+/**
+ * Maps textures using the geometry's UV coordinates.
+ *
+ * @type {number}
+ * @constant
+ */
+const UVMapping = 300;
+
+/**
+ * Reflection mapping for cube textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const CubeReflectionMapping = 301;
+
+/**
+ * Refraction mapping for cube textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const CubeRefractionMapping = 302;
+
+/**
+ * Reflection mapping for equirectangular textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const EquirectangularReflectionMapping = 303;
+
+/**
+ * Refraction mapping for equirectangular textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const EquirectangularRefractionMapping = 304;
+
+/**
+ * Reflection mapping for PMREM textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const CubeUVReflectionMapping = 306;
+
+/**
+ * The texture will simply repeat to infinity.
+ *
+ * @type {number}
+ * @constant
+ */
+const RepeatWrapping = 1000;
+
+/**
+ * The last pixel of the texture stretches to the edge of the mesh.
+ *
+ * @type {number}
+ * @constant
+ */
+const ClampToEdgeWrapping = 1001;
+
+/**
+ * The texture will repeats to infinity, mirroring on each repeat.
+ *
+ * @type {number}
+ * @constant
+ */
+const MirroredRepeatWrapping = 1002;
+
+/**
+ * Returns the value of the texture element that is nearest (in Manhattan distance)
+ * to the specified texture coordinates.
+ *
+ * @type {number}
+ * @constant
+ */
+const NearestFilter = 1003;
+
+/**
+ * Chooses the mipmap that most closely matches the size of the pixel being textured
+ * and uses the `NearestFilter` criterion (the texel nearest to the center of the pixel)
+ * to produce a texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const NearestMipmapNearestFilter = 1004;
+const NearestMipMapNearestFilter = 1004; // legacy
+
+/**
+ * Chooses the two mipmaps that most closely match the size of the pixel being textured and
+ * uses the `NearestFilter` criterion to produce a texture value from each mipmap.
+ * The final texture value is a weighted average of those two values.
+ *
+ * @type {number}
+ * @constant
+ */
+const NearestMipmapLinearFilter = 1005;
+const NearestMipMapLinearFilter = 1005; // legacy
+
+/**
+ * Returns the weighted average of the four texture elements that are closest to the specified
+ * texture coordinates, and can include items wrapped or repeated from other parts of a texture,
+ * depending on the values of `wrapS` and `wrapT`, and on the exact mapping.
+ *
+ * @type {number}
+ * @constant
+ */
+const LinearFilter = 1006;
+
+/**
+ * Chooses the mipmap that most closely matches the size of the pixel being textured and uses
+ * the `LinearFilter` criterion (a weighted average of the four texels that are closest to the
+ * center of the pixel) to produce a texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const LinearMipmapNearestFilter = 1007;
+const LinearMipMapNearestFilter = 1007; // legacy
+
+/**
+ * Chooses the two mipmaps that most closely match the size of the pixel being textured and uses
+ * the `LinearFilter` criterion to produce a texture value from each mipmap. The final texture value
+ * is a weighted average of those two values.
+ *
+ * @type {number}
+ * @constant
+ */
+const LinearMipmapLinearFilter = 1008;
+const LinearMipMapLinearFilter = 1008; // legacy
+
+/**
+ * An unsigned byte data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedByteType = 1009;
+
+/**
+ * A byte data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const ByteType = 1010;
+
+/**
+ * A short data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const ShortType = 1011;
+
+/**
+ * An unsigned short data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedShortType = 1012;
+
+/**
+ * An int data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const IntType = 1013;
+
+/**
+ * An unsigned int data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedIntType = 1014;
+
+/**
+ * A float data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const FloatType = 1015;
+
+/**
+ * A half float data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const HalfFloatType = 1016;
+
+/**
+ * An unsigned short 4_4_4_4 (packed) data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedShort4444Type = 1017;
+
+/**
+ * An unsigned short 5_5_5_1 (packed) data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedShort5551Type = 1018;
+
+/**
+ * An unsigned int 24_8 data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedInt248Type = 1020;
+
+/**
+ * An unsigned int 5_9_9_9 (packed) data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedInt5999Type = 35902;
+
+/**
+ * An unsigned int 10_11_11 (packed) data type for textures.
+ *
+ * @type {number}
+ * @constant
+ */
+const UnsignedInt101111Type = 35899;
+
+/**
+ * Discards the red, green and blue components and reads just the alpha component.
+ *
+ * @type {number}
+ * @constant
+ */
+const AlphaFormat = 1021;
+
+/**
+ * Discards the alpha component and reads the red, green and blue component.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBFormat = 1022;
+
+/**
+ * Reads the red, green, blue and alpha components.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBAFormat = 1023;
+
+/**
+ * Reads each element as a single depth value, converts it to floating point, and clamps to the range `[0,1]`.
+ *
+ * @type {number}
+ * @constant
+ */
+const DepthFormat = 1026;
+
+/**
+ * Reads each element is a pair of depth and stencil values. The depth component of the pair is interpreted as
+ * in `DepthFormat`. The stencil component is interpreted based on the depth + stencil internal format.
+ *
+ * @type {number}
+ * @constant
+ */
+const DepthStencilFormat = 1027;
+
+/**
+ * Discards the green, blue and alpha components and reads just the red component.
+ *
+ * @type {number}
+ * @constant
+ */
+const RedFormat = 1028;
+
+/**
+ * Discards the green, blue and alpha components and reads just the red component. The texels are read as integers instead of floating point.
+ *
+ * @type {number}
+ * @constant
+ */
+const RedIntegerFormat = 1029;
+
+/**
+ * Discards the alpha, and blue components and reads the red, and green components.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGFormat = 1030;
+
+/**
+ * Discards the alpha, and blue components and reads the red, and green components. The texels are read as integers instead of floating point.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGIntegerFormat = 1031;
+
+/**
+ * Discards the alpha component and reads the red, green and blue component. The texels are read as integers instead of floating point.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBIntegerFormat = 1032;
+
+/**
+ * Reads the red, green, blue and alpha components. The texels are read as integers instead of floating point.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBAIntegerFormat = 1033;
+
+/**
+ * A DXT1-compressed image in an RGB image format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGB_S3TC_DXT1_Format = 33776;
+
+/**
+ * A DXT1-compressed image in an RGB image format with a simple on/off alpha value.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_S3TC_DXT1_Format = 33777;
+
+/**
+ * A DXT3-compressed image in an RGBA image format. Compared to a 32-bit RGBA texture, it offers 4:1 compression.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_S3TC_DXT3_Format = 33778;
+
+/**
+ * A DXT5-compressed image in an RGBA image format. It also provides a 4:1 compression, but differs to the DXT3
+ * compression in how the alpha compression is done.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_S3TC_DXT5_Format = 33779;
+
+/**
+ * PVRTC RGB compression in 4-bit mode. One block for each 4×4 pixels.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGB_PVRTC_4BPPV1_Format = 35840;
+
+/**
+ * PVRTC RGB compression in 2-bit mode. One block for each 8×4 pixels.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGB_PVRTC_2BPPV1_Format = 35841;
+
+/**
+ * PVRTC RGBA compression in 4-bit mode. One block for each 4×4 pixels.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_PVRTC_4BPPV1_Format = 35842;
+
+/**
+ * PVRTC RGBA compression in 2-bit mode. One block for each 8×4 pixels.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_PVRTC_2BPPV1_Format = 35843;
+
+/**
+ * ETC1 RGB format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGB_ETC1_Format = 36196;
+
+/**
+ * ETC2 RGB format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGB_ETC2_Format = 37492;
+
+/**
+ * ETC2 RGBA format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ETC2_EAC_Format = 37496;
+
+/**
+ * EAC R11 UNORM format.
+ *
+ * @type {number}
+ * @constant
+ */
+const R11_EAC_Format = 37488; // 0x9270
+
+/**
+ * EAC R11 SNORM format.
+ *
+ * @type {number}
+ * @constant
+ */
+const SIGNED_R11_EAC_Format = 37489; // 0x9271
+
+/**
+ * EAC RG11 UNORM format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RG11_EAC_Format = 37490; // 0x9272
+
+/**
+ * EAC RG11 SNORM format.
+ *
+ * @type {number}
+ * @constant
+ */
+const SIGNED_RG11_EAC_Format = 37491; // 0x9273
+
+/**
+ * ASTC RGBA 4x4 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_4x4_Format = 37808;
+
+/**
+ * ASTC RGBA 5x4 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_5x4_Format = 37809;
+
+/**
+ * ASTC RGBA 5x5 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_5x5_Format = 37810;
+
+/**
+ * ASTC RGBA 6x5 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_6x5_Format = 37811;
+
+/**
+ * ASTC RGBA 6x6 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_6x6_Format = 37812;
+
+/**
+ * ASTC RGBA 8x5 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_8x5_Format = 37813;
+
+/**
+ * ASTC RGBA 8x6 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_8x6_Format = 37814;
+
+/**
+ * ASTC RGBA 8x8 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_8x8_Format = 37815;
+
+/**
+ * ASTC RGBA 10x5 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_10x5_Format = 37816;
+
+/**
+ * ASTC RGBA 10x6 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_10x6_Format = 37817;
+
+/**
+ * ASTC RGBA 10x8 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_10x8_Format = 37818;
+
+/**
+ * ASTC RGBA 10x10 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_10x10_Format = 37819;
+
+/**
+ * ASTC RGBA 12x10 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_12x10_Format = 37820;
+
+/**
+ * ASTC RGBA 12x12 format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_ASTC_12x12_Format = 37821;
+
+/**
+ * BPTC RGBA format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBA_BPTC_Format = 36492;
+
+/**
+ * BPTC Signed RGB format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGB_BPTC_SIGNED_Format = 36494;
+
+/**
+ * BPTC Unsigned RGB format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGB_BPTC_UNSIGNED_Format = 36495;
+
+/**
+ * RGTC1 Red format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RED_RGTC1_Format = 36283;
+
+/**
+ * RGTC1 Signed Red format.
+ *
+ * @type {number}
+ * @constant
+ */
+const SIGNED_RED_RGTC1_Format = 36284;
+
+/**
+ * RGTC2 Red Green format.
+ *
+ * @type {number}
+ * @constant
+ */
+const RED_GREEN_RGTC2_Format = 36285;
+
+/**
+ * RGTC2 Signed Red Green format.
+ *
+ * @type {number}
+ * @constant
+ */
+const SIGNED_RED_GREEN_RGTC2_Format = 36286;
+
+/**
+ * Animations are played once.
+ *
+ * @type {number}
+ * @constant
+ */
+const LoopOnce = 2200;
+
+/**
+ * Animations are played with a chosen number of repetitions, each time jumping from
+ * the end of the clip directly to its beginning.
+ *
+ * @type {number}
+ * @constant
+ */
+const LoopRepeat = 2201;
+
+/**
+ * Animations are played with a chosen number of repetitions, alternately playing forward
+ * and backward.
+ *
+ * @type {number}
+ * @constant
+ */
+const LoopPingPong = 2202;
+
+/**
+ * Discrete interpolation mode for keyframe tracks.
+ *
+ * @type {number}
+ * @constant
+ */
+const InterpolateDiscrete = 2300;
+
+/**
+ * Linear interpolation mode for keyframe tracks.
+ *
+ * @type {number}
+ * @constant
+ */
+const InterpolateLinear = 2301;
+
+/**
+ * Smooth interpolation mode for keyframe tracks.
+ *
+ * @type {number}
+ * @constant
+ */
+const InterpolateSmooth = 2302;
+
+/**
+ * Bezier interpolation mode for keyframe tracks.
+ *
+ * Uses cubic Bezier curves with explicit 2D control points.
+ * Requires tangent data to be set on the track.
+ *
+ * @type {number}
+ * @constant
+ */
+const InterpolateBezier = 2303;
+
+/**
+ * Zero curvature ending for animations.
+ *
+ * @type {number}
+ * @constant
+ */
+const ZeroCurvatureEnding = 2400;
+
+/**
+ * Zero slope ending for animations.
+ *
+ * @type {number}
+ * @constant
+ */
+const ZeroSlopeEnding = 2401;
+
+/**
+ * Wrap around ending for animations.
+ *
+ * @type {number}
+ * @constant
+ */
+const WrapAroundEnding = 2402;
+
+/**
+ * Default animation blend mode.
+ *
+ * @type {number}
+ * @constant
+ */
+const NormalAnimationBlendMode = 2500;
+
+/**
+ * Additive animation blend mode. Can be used to layer motions on top of
+ * each other to build complex performances from smaller re-usable assets.
+ *
+ * @type {number}
+ * @constant
+ */
+const AdditiveAnimationBlendMode = 2501;
+
+/**
+ * For every three vertices draw a single triangle.
+ *
+ * @type {number}
+ * @constant
+ */
+const TrianglesDrawMode = 0;
+
+/**
+ * For each vertex draw a triangle from the last three vertices.
+ *
+ * @type {number}
+ * @constant
+ */
+const TriangleStripDrawMode = 1;
+
+/**
+ * For each vertex draw a triangle from the first vertex and the last two vertices.
+ *
+ * @type {number}
+ * @constant
+ */
+const TriangleFanDrawMode = 2;
+
+/**
+ * The depth value is inverted (1.0 - z) for visualization purposes.
+ *
+ * @type {number}
+ * @constant
+ */
+const BasicDepthPacking = 3200;
+
+/**
+ * The depth value is packed into 32 bit RGBA.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBADepthPacking = 3201;
+
+/**
+ * The depth value is packed into 24 bit RGB.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGBDepthPacking = 3202;
+
+/**
+ * The depth value is packed into 16 bit RG.
+ *
+ * @type {number}
+ * @constant
+ */
+const RGDepthPacking = 3203;
+
+/**
+ * Normal information is relative to the underlying surface.
+ *
+ * @type {number}
+ * @constant
+ */
+const TangentSpaceNormalMap = 0;
+
+/**
+ * Normal information is relative to the object orientation.
+ *
+ * @type {number}
+ * @constant
+ */
+const ObjectSpaceNormalMap = 1;
+
+// Color space string identifiers, matching CSS Color Module Level 4 and WebGPU names where available.
+
+/**
+ * No color space.
+ *
+ * @type {string}
+ * @constant
+ */
+const NoColorSpace = '';
+
+/**
+ * sRGB color space.
+ *
+ * @type {string}
+ * @constant
+ */
+const SRGBColorSpace = 'srgb';
+
+/**
+ * sRGB-linear color space.
+ *
+ * @type {string}
+ * @constant
+ */
+const LinearSRGBColorSpace = 'srgb-linear';
+
+/**
+ * Linear transfer function.
+ *
+ * @type {string}
+ * @constant
+ */
+const LinearTransfer = 'linear';
+
+/**
+ * sRGB transfer function.
+ *
+ * @type {string}
+ * @constant
+ */
+const SRGBTransfer = 'srgb';
+
+/**
+ * No normal map packing.
+ *
+ * @type {string}
+ * @constant
+ */
+const NoNormalPacking = '';
+
+/**
+ * Normal RG packing.
+ *
+ * @type {string}
+ * @constant
+ */
+const NormalRGPacking = 'rg';
+
+/**
+ * Normal GA packing.
+ *
+ * @type {string}
+ * @constant
+ */
+const NormalGAPacking = 'ga';
+
+/**
+ * Sets the stencil buffer value to `0`.
+ *
+ * @type {number}
+ * @constant
+ */
+const ZeroStencilOp = 0;
+
+/**
+ * Keeps the current value.
+ *
+ * @type {number}
+ * @constant
+ */
+const KeepStencilOp = 7680;
+
+/**
+ * Sets the stencil buffer value to the specified reference value.
+ *
+ * @type {number}
+ * @constant
+ */
+const ReplaceStencilOp = 7681;
+
+/**
+ * Increments the current stencil buffer value. Clamps to the maximum representable unsigned value.
+ *
+ * @type {number}
+ * @constant
+ */
+const IncrementStencilOp = 7682;
+
+/**
+ * Decrements the current stencil buffer value. Clamps to `0`.
+ *
+ * @type {number}
+ * @constant
+ */
+const DecrementStencilOp = 7683;
+
+/**
+ * Increments the current stencil buffer value. Wraps stencil buffer value to zero when incrementing
+ * the maximum representable unsigned value.
+ *
+ * @type {number}
+ * @constant
+ */
+const IncrementWrapStencilOp = 34055;
+
+/**
+ * Decrements the current stencil buffer value. Wraps stencil buffer value to the maximum representable
+ * unsigned value when decrementing a stencil buffer value of `0`.
+ *
+ * @type {number}
+ * @constant
+ */
+const DecrementWrapStencilOp = 34056;
+
+/**
+ * Inverts the current stencil buffer value bitwise.
+ *
+ * @type {number}
+ * @constant
+ */
+const InvertStencilOp = 5386;
+
+/**
+ * Will never return true.
+ *
+ * @type {number}
+ * @constant
+ */
+const NeverStencilFunc = 512;
+
+/**
+ * Will return true if the stencil reference value is less than the current stencil value.
+ *
+ * @type {number}
+ * @constant
+ */
+const LessStencilFunc = 513;
+
+/**
+ * Will return true if the stencil reference value is equal to the current stencil value.
+ *
+ * @type {number}
+ * @constant
+ */
+const EqualStencilFunc = 514;
+
+/**
+ * Will return true if the stencil reference value is less than or equal to the current stencil value.
+ *
+ * @type {number}
+ * @constant
+ */
+const LessEqualStencilFunc = 515;
+
+/**
+ * Will return true if the stencil reference value is greater than the current stencil value.
+ *
+ * @type {number}
+ * @constant
+ */
+const GreaterStencilFunc = 516;
+
+/**
+ * Will return true if the stencil reference value is not equal to the current stencil value.
+ *
+ * @type {number}
+ * @constant
+ */
+const NotEqualStencilFunc = 517;
+
+/**
+ * Will return true if the stencil reference value is greater than or equal to the current stencil value.
+ *
+ * @type {number}
+ * @constant
+ */
+const GreaterEqualStencilFunc = 518;
+
+/**
+ * Will always return true.
+ *
+ * @type {number}
+ * @constant
+ */
+const AlwaysStencilFunc = 519;
+
+/**
+ * Never pass.
+ *
+ * @type {number}
+ * @constant
+ */
+const NeverCompare = 512;
+
+/**
+ * Pass if the incoming value is less than the texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const LessCompare = 513;
+
+/**
+ * Pass if the incoming value equals the texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const EqualCompare = 514;
+
+/**
+ * Pass if the incoming value is less than or equal to the texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const LessEqualCompare = 515;
+
+/**
+ * Pass if the incoming value is greater than the texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const GreaterCompare = 516;
+
+/**
+ * Pass if the incoming value is not equal to the texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const NotEqualCompare = 517;
+
+/**
+ * Pass if the incoming value is greater than or equal to the texture value.
+ *
+ * @type {number}
+ * @constant
+ */
+const GreaterEqualCompare = 518;
+
+/**
+ * Always pass.
+ *
+ * @type {number}
+ * @constant
+ */
+const AlwaysCompare = 519;
+
+/**
+ * The contents are intended to be specified once by the application, and used many
+ * times as the source for drawing and image specification commands.
+ *
+ * @type {number}
+ * @constant
+ */
+const StaticDrawUsage = 35044;
+
+/**
+ * The contents are intended to be respecified repeatedly by the application, and
+ * used many times as the source for drawing and image specification commands.
+ *
+ * @type {number}
+ * @constant
+ */
+const DynamicDrawUsage = 35048;
+
+/**
+ * The contents are intended to be specified once by the application, and used at most
+ * a few times as the source for drawing and image specification commands.
+ *
+ * @type {number}
+ * @constant
+ */
+const StreamDrawUsage = 35040;
+
+/**
+ * The contents are intended to be specified once by reading data from the 3D API, and queried
+ * many times by the application.
+ *
+ * @type {number}
+ * @constant
+ */
+const StaticReadUsage = 35045;
+
+/**
+ * The contents are intended to be respecified repeatedly by reading data from the 3D API, and queried
+ * many times by the application.
+ *
+ * @type {number}
+ * @constant
+ */
+const DynamicReadUsage = 35049;
+
+/**
+ * The contents are intended to be specified once by reading data from the 3D API, and queried at most
+ * a few times by the application
+ *
+ * @type {number}
+ * @constant
+ */
+const StreamReadUsage = 35041;
+
+/**
+ * The contents are intended to be specified once by reading data from the 3D API, and used many times as
+ * the source for WebGL drawing and image specification commands.
+ *
+ * @type {number}
+ * @constant
+ */
+const StaticCopyUsage = 35046;
+
+/**
+ * The contents are intended to be respecified repeatedly by reading data from the 3D API, and used many times
+ * as the source for WebGL drawing and image specification commands.
+ *
+ * @type {number}
+ * @constant
+ */
+const DynamicCopyUsage = 35050;
+
+/**
+ * The contents are intended to be specified once by reading data from the 3D API, and used at most a few times
+ * as the source for WebGL drawing and image specification commands.
+ *
+ * @type {number}
+ * @constant
+ */
+const StreamCopyUsage = 35042;
+
+/**
+ * GLSL 1 shader code.
+ *
+ * @type {string}
+ * @constant
+ */
+const GLSL1 = '100';
+
+/**
+ * GLSL 3 shader code.
+ *
+ * @type {string}
+ * @constant
+ */
+const GLSL3 = '300 es';
+
+/**
+ * WebGL coordinate system.
+ *
+ * @type {number}
+ * @constant
+ */
+const WebGLCoordinateSystem = 2000;
+
+/**
+ * WebGPU coordinate system.
+ *
+ * @type {number}
+ * @constant
+ */
+const WebGPUCoordinateSystem = 2001;
+
+/**
+ * Represents the different timestamp query types.
+ *
+ * @type {ConstantsTimestampQuery}
+ * @constant
+ */
+const TimestampQuery = {
+ COMPUTE: 'compute',
+ RENDER: 'render'
+};
+
+/**
+ * Represents mouse buttons and interaction types in context of controls.
+ *
+ * @type {ConstantsInterpolationSamplingType}
+ * @constant
+ */
+const InterpolationSamplingType = {
+ PERSPECTIVE: 'perspective',
+ LINEAR: 'linear',
+ FLAT: 'flat'
+};
+
+/**
+ * Represents the different interpolation sampling modes.
+ *
+ * @type {ConstantsInterpolationSamplingMode}
+ * @constant
+ */
+const InterpolationSamplingMode = {
+ NORMAL: 'normal',
+ CENTROID: 'centroid',
+ SAMPLE: 'sample',
+ FIRST: 'first',
+ EITHER: 'either'
+};
+
+/**
+ * Compatibility flags for features that may not be supported across all platforms.
+ *
+ * @type {Object}
+ * @constant
+ */
+const Compatibility = {
+ TEXTURE_COMPARE: 'depthTextureCompare'
+};
+
+/**
+ * Represents the refresh types of render objects.
+ *
+ * @type {ConstantsRenderObjectRefreshType}
+ * @constant
+ */
+const RenderObjectRefreshType = {
+ NONE: 0,
+ SHARED: 1,
+ FULL: 2
+};
+
+/**
+ * This type represents mouse buttons and interaction types in context of controls.
+ *
+ * @typedef {Object} ConstantsMouse
+ * @property {number} MIDDLE - The left mouse button.
+ * @property {number} LEFT - The middle mouse button.
+ * @property {number} RIGHT - The right mouse button.
+ * @property {number} ROTATE - A rotate interaction.
+ * @property {number} DOLLY - A dolly interaction.
+ * @property {number} PAN - A pan interaction.
+ **/
+
+/**
+ * This type represents touch interaction types in context of controls.
+ *
+ * @typedef {Object} ConstantsTouch
+ * @property {number} ROTATE - A rotate interaction.
+ * @property {number} PAN - A pan interaction.
+ * @property {number} DOLLY_PAN - The dolly-pan interaction.
+ * @property {number} DOLLY_ROTATE - A dolly-rotate interaction.
+ **/
+
+/**
+ * This type represents the different timestamp query types.
+ *
+ * @typedef {Object} ConstantsTimestampQuery
+ * @property {string} COMPUTE - A `compute` timestamp query.
+ * @property {string} RENDER - A `render` timestamp query.
+ **/
+
+/**
+ * Represents the different interpolation sampling types.
+ *
+ * @typedef {Object} ConstantsInterpolationSamplingType
+ * @property {string} PERSPECTIVE - Perspective-correct interpolation.
+ * @property {string} LINEAR - Linear interpolation.
+ * @property {string} FLAT - Flat interpolation.
+ */
+
+/**
+ * Represents the different interpolation sampling modes.
+ *
+ * @typedef {Object} ConstantsInterpolationSamplingMode
+ * @property {string} NORMAL - Normal sampling mode.
+ * @property {string} CENTROID - Centroid sampling mode.
+ * @property {string} SAMPLE - Sample-specific sampling mode.
+ * @property {string} FIRST - Flat interpolation using the first vertex.
+ * @property {string} EITHER - Flat interpolation using either vertex.
+ */
+
+/**
+ * Represents the refresh types of render objects.
+ *
+ * @typedef {Object} ConstantsRenderObjectRefreshType
+ * @property {number} NONE - No refresh required.
+ * @property {number} SHARED - Only shared uniform buffers require an update.
+ * @property {number} FULL - The render object requires a full refresh.
+ */
+
+/**
+ * Checks if an array contains values that require Uint32 representation.
+ *
+ * This function determines whether the array contains any values >= 65535,
+ * which would require a Uint32Array rather than a Uint16Array for proper storage.
+ * The function iterates from the end of the array, assuming larger values are
+ * typically located at the end.
+ *
+ * @private
+ * @param {Array} array - The array to check.
+ * @return {boolean} True if the array contains values >= 65535, false otherwise.
+ */
+function arrayNeedsUint32( array ) {
+
+ // assumes larger values usually on last
+
+ for ( let i = array.length - 1; i >= 0; -- i ) {
+
+ if ( array[ i ] >= 65535 ) return true; // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565
+
+ }
+
+ return false;
+
+}
+
+/**
+ * Map of typed array constructor names to their constructors.
+ * This mapping enables dynamic creation of typed arrays based on string type names.
+ *
+ * @private
+ * @constant
+ * @type {Object}
+ */
+const TYPED_ARRAYS = {
+ Int8Array: Int8Array,
+ Uint8Array: Uint8Array,
+ Uint8ClampedArray: Uint8ClampedArray,
+ Int16Array: Int16Array,
+ Uint16Array: Uint16Array,
+ Int32Array: Int32Array,
+ Uint32Array: Uint32Array,
+ Float32Array: Float32Array,
+ Float64Array: Float64Array
+};
+
+/**
+ * Creates a typed array of the specified type from the given buffer.
+ *
+ * @private
+ * @param {string} type - The name of the typed array type (e.g., 'Float32Array', 'Uint16Array').
+ * @param {ArrayBuffer} buffer - The buffer to create the typed array from.
+ * @return {TypedArray} A new typed array of the specified type.
+ */
+function getTypedArray( type, buffer ) {
+
+ return new TYPED_ARRAYS[ type ]( buffer );
+
+}
+
+/**
+ * Returns `true` if the given object is a typed array.
+ *
+ * @param {any} array - The object to check.
+ * @return {boolean} Whether the given object is a typed array.
+ */
+function isTypedArray( array ) {
+
+ return ArrayBuffer.isView( array ) && ! ( array instanceof DataView );
+
+}
+
+/**
+ * Creates an XHTML element with the specified tag name.
+ *
+ * This function uses the XHTML namespace to create DOM elements,
+ * ensuring proper element creation in XML-based contexts.
+ *
+ * @private
+ * @param {string} name - The tag name of the element to create (e.g., 'canvas', 'div').
+ * @return {HTMLElement} The created XHTML element.
+ */
+function createElementNS( name ) {
+
+ return document.createElementNS( 'http://www.w3.org/1999/xhtml', name );
+
+}
+
+/**
+ * Creates a canvas element configured for block display.
+ *
+ * This is a convenience function that creates a canvas element with
+ * display style set to 'block', which is commonly used in three.js
+ * rendering contexts to avoid inline element spacing issues.
+ *
+ * @return {HTMLCanvasElement} A canvas element with display set to 'block'.
+ */
+function createCanvasElement() {
+
+ const canvas = createElementNS( 'canvas' );
+ canvas.style.display = 'block';
+ return canvas;
+
+}
+
+/**
+ * Internal cache for tracking warning messages to prevent duplicate warnings.
+ *
+ * @private
+ * @type {Object}
+ */
+const _cache = {};
+
+/**
+ * Custom console function handler for intercepting log, warn, and error calls.
+ *
+ * @private
+ * @type {Function|null}
+ */
+let _setConsoleFunction = null;
+
+/**
+ * Sets a custom function to handle console output.
+ *
+ * This allows external code to intercept and handle console.log, console.warn,
+ * and console.error calls made by three.js, which is useful for custom logging,
+ * testing, or debugging workflows.
+ *
+ * @param {Function} fn - The function to handle console output. Should accept
+ * (type, message, ...params) where type is 'log', 'warn', or 'error'.
+ */
+function setConsoleFunction( fn ) {
+
+ _setConsoleFunction = fn;
+
+}
+
+/**
+ * Gets the currently set custom console function.
+ *
+ * @return {Function|null} The custom console function, or null if not set.
+ */
+function getConsoleFunction() {
+
+ return _setConsoleFunction;
+
+}
+
+/**
+ * Logs an informational message with the 'THREE.' prefix.
+ *
+ * If a custom console function is set via setConsoleFunction(), it will be used
+ * instead of the native console.log. The first parameter is treated as the
+ * method name and is automatically prefixed with 'THREE.'.
+ *
+ * @param {...any} params - The message components. The first param is used as
+ * the method name and prefixed with 'THREE.'.
+ */
+function log( ...params ) {
+
+ const message = 'THREE.' + params.shift();
+
+ if ( _setConsoleFunction ) {
+
+ _setConsoleFunction( 'log', message, ...params );
+
+ } else {
+
+ console.log( message, ...params );
+
+ }
+
+}
+
+/**
+ * Enhances log/warn/error messages related to TSL.
+ *
+ * @param {Array} params - The original message parameters.
+ * @returns {Array} The filtered and enhanced message parameters.
+ */
+function enhanceLogMessage( params ) {
+
+ const message = params[ 0 ];
+
+ if ( typeof message === 'string' && message.startsWith( 'TSL:' ) ) {
+
+ const stackTrace = params[ 1 ];
+
+ if ( stackTrace && stackTrace.isStackTrace ) {
+
+ params[ 0 ] += ' ' + stackTrace.getLocation();
+
+ } else {
+
+ params[ 1 ] = 'Stack trace not available. Enable "THREE.Node.captureStackTrace" to capture stack traces.';
+
+ }
+
+ }
+
+ return params;
+
+}
+
+/**
+ * Logs a warning message with the 'THREE.' prefix.
+ *
+ * If a custom console function is set via setConsoleFunction(), it will be used
+ * instead of the native console.warn. The first parameter is treated as the
+ * method name and is automatically prefixed with 'THREE.'.
+ *
+ * @param {...any} params - The message components. The first param is used as
+ * the method name and prefixed with 'THREE.'.
+ */
+function warn( ...params ) {
+
+ params = enhanceLogMessage( params );
+
+ const message = 'THREE.' + params.shift();
+
+ if ( _setConsoleFunction ) {
+
+ _setConsoleFunction( 'warn', message, ...params );
+
+ } else {
+
+ const stackTrace = params[ 0 ];
+
+ if ( stackTrace && stackTrace.isStackTrace ) {
+
+ console.warn( stackTrace.getError( message ) );
+
+ } else {
+
+ console.warn( message, ...params );
+
+ }
+
+ }
+
+}
+
+/**
+ * Logs an error message with the 'THREE.' prefix.
+ *
+ * If a custom console function is set via setConsoleFunction(), it will be used
+ * instead of the native console.error. The first parameter is treated as the
+ * method name and is automatically prefixed with 'THREE.'.
+ *
+ * @param {...any} params - The message components. The first param is used as
+ * the method name and prefixed with 'THREE.'.
+ */
+function error( ...params ) {
+
+ params = enhanceLogMessage( params );
+
+ const message = 'THREE.' + params.shift();
+
+ if ( _setConsoleFunction ) {
+
+ _setConsoleFunction( 'error', message, ...params );
+
+ } else {
+
+ const stackTrace = params[ 0 ];
+
+ if ( stackTrace && stackTrace.isStackTrace ) {
+
+ console.error( stackTrace.getError( message ) );
+
+ } else {
+
+ console.error( message, ...params );
+
+ }
+
+ }
+
+}
+
+/**
+ * Logs a warning message only once, preventing duplicate warnings.
+ *
+ * This function maintains an internal cache of warning messages and will only
+ * output each unique warning message once. Useful for warnings that may be
+ * triggered repeatedly but should only be shown to the user once.
+ *
+ * @param {...any} params - The warning message components.
+ */
+function warnOnce( ...params ) {
+
+ const message = params.join( ' ' );
+
+ if ( message in _cache ) return;
+
+ _cache[ message ] = true;
+
+ warn( ...params );
+
+}
+
+/**
+ * Yields execution to the main thread to allow rendering and other tasks.
+ * Uses scheduler.yield() when available (Chrome 115+), falls back to requestAnimationFrame.
+ *
+ * @return {Promise}
+ */
+function yieldToMain() {
+
+ if ( typeof self !== 'undefined' && typeof self.scheduler !== 'undefined' && typeof self.scheduler.yield !== 'undefined' ) {
+
+ return self.scheduler.yield();
+
+ }
+
+ return new Promise( resolve => {
+
+ requestAnimationFrame( resolve );
+
+ } );
+
+}
+
+/**
+ * Asynchronously probes for WebGL sync object completion.
+ *
+ * This function creates a promise that resolves when the WebGL sync object
+ * signals completion or rejects if the sync operation fails. It uses polling
+ * at the specified interval to check the sync status without blocking the
+ * main thread. This is useful for GPU-CPU synchronization in WebGL contexts.
+ *
+ * @private
+ * @param {WebGL2RenderingContext} gl - The WebGL rendering context.
+ * @param {WebGLSync} sync - The WebGL sync object to wait for.
+ * @param {number} interval - The polling interval in milliseconds.
+ * @return {Promise} A promise that resolves when the sync completes or rejects if it fails.
+ */
+function probeAsync( gl, sync, interval ) {
+
+ return new Promise( function ( resolve, reject ) {
+
+ function probe() {
+
+ switch ( gl.clientWaitSync( sync, gl.SYNC_FLUSH_COMMANDS_BIT, 0 ) ) {
+
+ case gl.WAIT_FAILED:
+ reject();
+ break;
+
+ case gl.TIMEOUT_EXPIRED:
+ setTimeout( probe, interval );
+ break;
+
+ default:
+ resolve();
+
+ }
+
+ }
+
+ setTimeout( probe, interval );
+
+ } );
+
+}
+
+/**
+ * Used to select the correct depth functions
+ * when reversed depth buffer is used.
+ *
+ * @private
+ * @type {Object}
+ */
+const ReversedDepthFuncs = {
+ [ NeverDepth ]: AlwaysDepth,
+ [ LessDepth ]: GreaterDepth,
+ [ EqualDepth ]: NotEqualDepth,
+ [ LessEqualDepth ]: GreaterEqualDepth,
+
+ [ AlwaysDepth ]: NeverDepth,
+ [ GreaterDepth ]: LessDepth,
+ [ NotEqualDepth ]: EqualDepth,
+ [ GreaterEqualDepth ]: LessEqualDepth,
+};
+
+/**
+ * This modules allows to dispatch event objects on custom JavaScript objects.
+ *
+ * Main repository: [eventdispatcher.js](https://github.com/mrdoob/eventdispatcher.js/)
+ *
+ * Code Example:
+ * ```js
+ * class Car extends EventDispatcher {
+ * start() {
+ * this.dispatchEvent( { type: 'start', message: 'vroom vroom!' } );
+ * }
+ *};
+ *
+ * // Using events with the custom object
+ * const car = new Car();
+ * car.addEventListener( 'start', function ( event ) {
+ * alert( event.message );
+ * } );
+ *
+ * car.start();
+ * ```
+ */
+class EventDispatcher {
+
+ /**
+ * Adds the given event listener to the given event type.
+ *
+ * @param {string} type - The type of event to listen to.
+ * @param {Function} listener - The function that gets called when the event is fired.
+ */
+ addEventListener( type, listener ) {
+
+ if ( this._listeners === undefined ) this._listeners = {};
+
+ const listeners = this._listeners;
+
+ if ( listeners[ type ] === undefined ) {
+
+ listeners[ type ] = [];
+
+ }
+
+ if ( listeners[ type ].indexOf( listener ) === -1 ) {
+
+ listeners[ type ].push( listener );
+
+ }
+
+ }
+
+ /**
+ * Returns `true` if the given event listener has been added to the given event type.
+ *
+ * @param {string} type - The type of event.
+ * @param {Function} listener - The listener to check.
+ * @return {boolean} Whether the given event listener has been added to the given event type.
+ */
+ hasEventListener( type, listener ) {
+
+ const listeners = this._listeners;
+
+ if ( listeners === undefined ) return false;
+
+ return listeners[ type ] !== undefined && listeners[ type ].indexOf( listener ) !== -1;
+
+ }
+
+ /**
+ * Removes the given event listener from the given event type.
+ *
+ * @param {string} type - The type of event.
+ * @param {Function} listener - The listener to remove.
+ */
+ removeEventListener( type, listener ) {
+
+ const listeners = this._listeners;
+
+ if ( listeners === undefined ) return;
+
+ const listenerArray = listeners[ type ];
+
+ if ( listenerArray !== undefined ) {
+
+ const index = listenerArray.indexOf( listener );
+
+ if ( index !== -1 ) {
+
+ listenerArray.splice( index, 1 );
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Dispatches an event object.
+ *
+ * @param {Object} event - The event that gets fired.
+ */
+ dispatchEvent( event ) {
+
+ const listeners = this._listeners;
+
+ if ( listeners === undefined ) return;
+
+ const listenerArray = listeners[ event.type ];
+
+ if ( listenerArray !== undefined ) {
+
+ event.target = this;
+
+ // Make a copy, in case listeners are removed while iterating.
+ const array = listenerArray.slice( 0 );
+
+ for ( let i = 0, l = array.length; i < l; i ++ ) {
+
+ array[ i ].call( this, event );
+
+ }
+
+ event.target = null;
+
+ }
+
+ }
+
+}
+
+const _lut = [ '00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0a', '0b', '0c', '0d', '0e', '0f', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '1a', '1b', '1c', '1d', '1e', '1f', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '2a', '2b', '2c', '2d', '2e', '2f', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3a', '3b', '3c', '3d', '3e', '3f', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '4a', '4b', '4c', '4d', '4e', '4f', '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '5a', '5b', '5c', '5d', '5e', '5f', '60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '7a', '7b', '7c', '7d', '7e', '7f', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '8a', '8b', '8c', '8d', '8e', '8f', '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '9a', '9b', '9c', '9d', '9e', '9f', 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'aa', 'ab', 'ac', 'ad', 'ae', 'af', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7', 'b8', 'b9', 'ba', 'bb', 'bc', 'bd', 'be', 'bf', 'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'ca', 'cb', 'cc', 'cd', 'ce', 'cf', 'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7', 'd8', 'd9', 'da', 'db', 'dc', 'dd', 'de', 'df', 'e0', 'e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', 'e8', 'e9', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef', 'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff' ];
+
+let _seed = 1234567;
+
+
+const DEG2RAD = Math.PI / 180;
+const RAD2DEG = 180 / Math.PI;
+
+/**
+ * Generate a [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
+ * (universally unique identifier).
+ *
+ * @return {string} The UUID.
+ */
+function generateUUID() {
+
+ // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/21963136#21963136
+
+ const d0 = Math.random() * 0xffffffff | 0;
+ const d1 = Math.random() * 0xffffffff | 0;
+ const d2 = Math.random() * 0xffffffff | 0;
+ const d3 = Math.random() * 0xffffffff | 0;
+ const uuid = _lut[ d0 & 0xff ] + _lut[ d0 >> 8 & 0xff ] + _lut[ d0 >> 16 & 0xff ] + _lut[ d0 >> 24 & 0xff ] + '-' +
+ _lut[ d1 & 0xff ] + _lut[ d1 >> 8 & 0xff ] + '-' + _lut[ d1 >> 16 & 0x0f | 0x40 ] + _lut[ d1 >> 24 & 0xff ] + '-' +
+ _lut[ d2 & 0x3f | 0x80 ] + _lut[ d2 >> 8 & 0xff ] + '-' + _lut[ d2 >> 16 & 0xff ] + _lut[ d2 >> 24 & 0xff ] +
+ _lut[ d3 & 0xff ] + _lut[ d3 >> 8 & 0xff ] + _lut[ d3 >> 16 & 0xff ] + _lut[ d3 >> 24 & 0xff ];
+
+ // .toLowerCase() here flattens concatenated strings to save heap memory space.
+ return uuid.toLowerCase();
+
+}
+
+/**
+ * Clamps the given value between min and max.
+ *
+ * @param {number} value - The value to clamp.
+ * @param {number} min - The min value.
+ * @param {number} max - The max value.
+ * @return {number} The clamped value.
+ */
+function clamp( value, min, max ) {
+
+ return Math.max( min, Math.min( max, value ) );
+
+}
+
+/**
+ * Computes the Euclidean modulo of the given parameters that
+ * is `( ( n % m ) + m ) % m`.
+ *
+ * @param {number} n - The first parameter.
+ * @param {number} m - The second parameter.
+ * @return {number} The Euclidean modulo.
+ */
+function euclideanModulo( n, m ) {
+
+ // https://en.wikipedia.org/wiki/Modulo_operation
+
+ return ( ( n % m ) + m ) % m;
+
+}
+
+/**
+ * Performs a linear mapping from range `` to range ``
+ * for the given value. `a2` must be greater than `a1`.
+ *
+ * @param {number} x - The value to be mapped.
+ * @param {number} a1 - Minimum value for range A.
+ * @param {number} a2 - Maximum value for range A.
+ * @param {number} b1 - Minimum value for range B.
+ * @param {number} b2 - Maximum value for range B.
+ * @return {number} The mapped value.
+ */
+function mapLinear( x, a1, a2, b1, b2 ) {
+
+ return b1 + ( x - a1 ) * ( b2 - b1 ) / ( a2 - a1 );
+
+}
+
+/**
+ * Returns the percentage in the closed interval `[0, 1]` of the given value
+ * between the start and end point.
+ *
+ * @param {number} x - The start point
+ * @param {number} y - The end point.
+ * @param {number} value - A value between start and end.
+ * @return {number} The interpolation factor.
+ */
+function inverseLerp( x, y, value ) {
+
+ // https://www.gamedev.net/tutorials/programming/general-and-gameplay-programming/inverse-lerp-a-super-useful-yet-often-overlooked-function-r5230/
+
+ if ( x !== y ) {
+
+ return ( value - x ) / ( y - x );
+
+ } else {
+
+ return 0;
+
+ }
+
+}
+
+/**
+ * Returns a value linearly interpolated from two known points based on the given interval -
+ * `t = 0` will return `x` and `t = 1` will return `y`.
+ *
+ * @param {number} x - The start point
+ * @param {number} y - The end point.
+ * @param {number} t - The interpolation factor in the closed interval `[0, 1]`.
+ * @return {number} The interpolated value.
+ */
+function lerp( x, y, t ) {
+
+ return ( 1 - t ) * x + t * y;
+
+}
+
+/**
+ * Smoothly interpolate a number from `x` to `y` in a spring-like manner using a delta
+ * time to maintain frame rate independent movement. For details, see
+ * [Frame rate independent damping using lerp](http://www.rorydriscoll.com/2016/03/07/frame-rate-independent-damping-using-lerp/).
+ *
+ * @param {number} x - The current point.
+ * @param {number} y - The target point.
+ * @param {number} lambda - A higher lambda value will make the movement more sudden,
+ * and a lower value will make the movement more gradual.
+ * @param {number} dt - Delta time in seconds.
+ * @return {number} The interpolated value.
+ */
+function damp( x, y, lambda, dt ) {
+
+ return lerp( x, y, 1 - Math.exp( - lambda * dt ) );
+
+}
+
+/**
+ * Returns a value that alternates between `0` and the given `length` parameter.
+ *
+ * @param {number} x - The value to pingpong.
+ * @param {number} [length=1] - The positive value the function will pingpong to.
+ * @return {number} The alternated value.
+ */
+function pingpong( x, length = 1 ) {
+
+ // https://www.desmos.com/calculator/vcsjnyz7x4
+
+ return length - Math.abs( euclideanModulo( x, length * 2 ) - length );
+
+}
+
+/**
+ * Returns a value in the range `[0,1]` that represents the percentage that `x` has
+ * moved between `min` and `max`, but smoothed or slowed down the closer `x` is to
+ * the `min` and `max`.
+ *
+ * See [Smoothstep](http://en.wikipedia.org/wiki/Smoothstep) for more details.
+ *
+ * @param {number} x - The value to evaluate based on its position between `min` and `max`.
+ * @param {number} min - The min value. Any `x` value below `min` will be `0`. `min` must be lower than `max`.
+ * @param {number} max - The max value. Any `x` value above `max` will be `1`. `max` must be greater than `min`.
+ * @return {number} The alternated value.
+ */
+function smoothstep( x, min, max ) {
+
+ if ( x <= min ) return 0;
+ if ( x >= max ) return 1;
+
+ x = ( x - min ) / ( max - min );
+
+ return x * x * ( 3 - 2 * x );
+
+}
+
+/**
+ * A [variation on smoothstep](https://en.wikipedia.org/wiki/Smoothstep#Variations)
+ * that has zero 1st and 2nd order derivatives at `x=0` and `x=1`.
+ *
+ * @param {number} x - The value to evaluate based on its position between `min` and `max`.
+ * @param {number} min - The min value. Any `x` value below `min` will be `0`. `min` must be lower than `max`.
+ * @param {number} max - The max value. Any `x` value above `max` will be `1`. `max` must be greater than `min`.
+ * @return {number} The alternated value.
+ */
+function smootherstep( x, min, max ) {
+
+ if ( x <= min ) return 0;
+ if ( x >= max ) return 1;
+
+ x = ( x - min ) / ( max - min );
+
+ return x * x * x * ( x * ( x * 6 - 15 ) + 10 );
+
+}
+
+/**
+ * Returns a random integer from `` interval.
+ *
+ * @param {number} low - The lower value boundary.
+ * @param {number} high - The upper value boundary
+ * @return {number} A random integer.
+ */
+function randInt( low, high ) {
+
+ return low + Math.floor( Math.random() * ( high - low + 1 ) );
+
+}
+
+/**
+ * Returns a random float from `` interval.
+ *
+ * @param {number} low - The lower value boundary.
+ * @param {number} high - The upper value boundary
+ * @return {number} A random float.
+ */
+function randFloat( low, high ) {
+
+ return low + Math.random() * ( high - low );
+
+}
+
+/**
+ * Returns a random integer from `<-range/2, range/2>` interval.
+ *
+ * @param {number} range - Defines the value range.
+ * @return {number} A random float.
+ */
+function randFloatSpread( range ) {
+
+ return range * ( 0.5 - Math.random() );
+
+}
+
+/**
+ * Returns a deterministic pseudo-random float in the interval `[0, 1]`.
+ *
+ * @param {number} [s] - The integer seed.
+ * @return {number} A random float.
+ */
+function seededRandom( s ) {
+
+ if ( s !== undefined ) _seed = s;
+
+ // Mulberry32 generator
+
+ let t = _seed += 0x6D2B79F5;
+
+ t = Math.imul( t ^ t >>> 15, t | 1 );
+
+ t ^= t + Math.imul( t ^ t >>> 7, t | 61 );
+
+ return ( ( t ^ t >>> 14 ) >>> 0 ) / 4294967296;
+
+}
+
+/**
+ * Converts degrees to radians.
+ *
+ * @param {number} degrees - A value in degrees.
+ * @return {number} The converted value in radians.
+ */
+function degToRad( degrees ) {
+
+ return degrees * DEG2RAD;
+
+}
+
+/**
+ * Converts radians to degrees.
+ *
+ * @param {number} radians - A value in radians.
+ * @return {number} The converted value in degrees.
+ */
+function radToDeg( radians ) {
+
+ return radians * RAD2DEG;
+
+}
+
+/**
+ * Returns `true` if the given integer is a power of two.
+ *
+ * @param {number} value - The value to check.
+ * @return {boolean} Whether the given integer is a power of two or not.
+ */
+function isPowerOfTwo( value ) {
+
+ return value > 0 && Number.isInteger( value ) && 2 ** Math.round( Math.log2( value ) ) === value;
+
+}
+
+/**
+ * Returns the smallest power of two that is greater than or equal to the given number.
+ *
+ * @param {number} value - The value to find a POT for. Must be greater than `0`.
+ * @return {number} The smallest power of two that is greater than or equal to the given number.
+ */
+function ceilPowerOfTwo( value ) {
+
+ return Math.pow( 2, Math.ceil( Math.log( value ) / Math.LN2 ) );
+
+}
+
+/**
+ * Returns the largest power of two that is less than or equal to the given number.
+ *
+ * @param {number} value - The value to find a POT for. Must be greater than `0`.
+ * @return {number} The largest power of two that is less than or equal to the given number.
+ */
+function floorPowerOfTwo( value ) {
+
+ return Math.pow( 2, Math.floor( Math.log( value ) / Math.LN2 ) );
+
+}
+
+/**
+ * Sets the given quaternion from the [Intrinsic Proper Euler Angles](https://en.wikipedia.org/wiki/Euler_angles)
+ * defined by the given angles and order.
+ *
+ * Rotations are applied to the axes in the order specified by order:
+ * rotation by angle `a` is applied first, then by angle `b`, then by angle `c`.
+ *
+ * @param {Quaternion} q - The quaternion to set.
+ * @param {number} a - The rotation applied to the first axis, in radians.
+ * @param {number} b - The rotation applied to the second axis, in radians.
+ * @param {number} c - The rotation applied to the third axis, in radians.
+ * @param {('XYX'|'XZX'|'YXY'|'YZY'|'ZXZ'|'ZYZ')} order - A string specifying the axes order.
+ */
+function setQuaternionFromProperEuler( q, a, b, c, order ) {
+
+ const cos = Math.cos;
+ const sin = Math.sin;
+
+ const c2 = cos( b / 2 );
+ const s2 = sin( b / 2 );
+
+ const c13 = cos( ( a + c ) / 2 );
+ const s13 = sin( ( a + c ) / 2 );
+
+ const c1_3 = cos( ( a - c ) / 2 );
+ const s1_3 = sin( ( a - c ) / 2 );
+
+ const c3_1 = cos( ( c - a ) / 2 );
+ const s3_1 = sin( ( c - a ) / 2 );
+
+ switch ( order ) {
+
+ case 'XYX':
+ q.set( c2 * s13, s2 * c1_3, s2 * s1_3, c2 * c13 );
+ break;
+
+ case 'YZY':
+ q.set( s2 * s1_3, c2 * s13, s2 * c1_3, c2 * c13 );
+ break;
+
+ case 'ZXZ':
+ q.set( s2 * c1_3, s2 * s1_3, c2 * s13, c2 * c13 );
+ break;
+
+ case 'XZX':
+ q.set( c2 * s13, s2 * s3_1, s2 * c3_1, c2 * c13 );
+ break;
+
+ case 'YXY':
+ q.set( s2 * c3_1, c2 * s13, s2 * s3_1, c2 * c13 );
+ break;
+
+ case 'ZYZ':
+ q.set( s2 * s3_1, s2 * c3_1, c2 * s13, c2 * c13 );
+ break;
+
+ default:
+ warn( 'MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: ' + order );
+
+ }
+
+}
+
+/**
+ * Denormalizes the given value according to the given typed array.
+ *
+ * @param {number} value - The value to denormalize.
+ * @param {TypedArray} array - The typed array that defines the data type of the value.
+ * @return {number} The denormalize (float) value in the range `[0,1]`.
+ */
+function denormalize( value, array ) {
+
+ switch ( array.constructor ) {
+
+ case Float32Array:
+
+ return value;
+
+ case Uint32Array:
+
+ return value / 4294967295.0;
+
+ case Uint16Array:
+
+ return value / 65535.0;
+
+ case Uint8Array:
+ case Uint8ClampedArray:
+
+ return value / 255.0;
+
+ case Int32Array:
+
+ return Math.max( value / 2147483647.0, -1 );
+
+ case Int16Array:
+
+ return Math.max( value / 32767.0, -1 );
+
+ case Int8Array:
+
+ return Math.max( value / 127.0, -1 );
+
+ default:
+
+ throw new Error( 'THREE.MathUtils: Invalid component type.' );
+
+ }
+
+}
+
+/**
+ * Normalizes the given value according to the given typed array.
+ *
+ * @param {number} value - The float value in the range `[0,1]` to normalize.
+ * @param {TypedArray} array - The typed array that defines the data type of the value.
+ * @return {number} The normalize value.
+ */
+function normalize( value, array ) {
+
+ switch ( array.constructor ) {
+
+ case Float32Array:
+
+ return value;
+
+ case Uint32Array:
+
+ return Math.round( value * 4294967295.0 );
+
+ case Uint16Array:
+
+ return Math.round( value * 65535.0 );
+
+ case Uint8Array:
+ case Uint8ClampedArray:
+
+ return Math.round( value * 255.0 );
+
+ case Int32Array:
+
+ return Math.round( value * 2147483647.0 );
+
+ case Int16Array:
+
+ return Math.round( value * 32767.0 );
+
+ case Int8Array:
+
+ return Math.round( value * 127.0 );
+
+ default:
+
+ throw new Error( 'THREE.MathUtils: Invalid component type.' );
+
+ }
+
+}
+
+/**
+ * @class
+ * @classdesc A collection of math utility functions.
+ * @hideconstructor
+ */
+const MathUtils = {
+ DEG2RAD: DEG2RAD,
+ RAD2DEG: RAD2DEG,
+ /**
+ * Generate a [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
+ * (universally unique identifier).
+ *
+ * @static
+ * @method
+ * @return {string} The UUID.
+ */
+ generateUUID: generateUUID,
+ /**
+ * Clamps the given value between min and max.
+ *
+ * @static
+ * @method
+ * @param {number} value - The value to clamp.
+ * @param {number} min - The min value.
+ * @param {number} max - The max value.
+ * @return {number} The clamped value.
+ */
+ clamp: clamp,
+ /**
+ * Computes the Euclidean modulo of the given parameters that
+ * is `( ( n % m ) + m ) % m`.
+ *
+ * @static
+ * @method
+ * @param {number} n - The first parameter.
+ * @param {number} m - The second parameter.
+ * @return {number} The Euclidean modulo.
+ */
+ euclideanModulo: euclideanModulo,
+ /**
+ * Performs a linear mapping from range `` to range ``
+ * for the given value.
+ *
+ * @static
+ * @method
+ * @param {number} x - The value to be mapped.
+ * @param {number} a1 - Minimum value for range A.
+ * @param {number} a2 - Maximum value for range A.
+ * @param {number} b1 - Minimum value for range B.
+ * @param {number} b2 - Maximum value for range B.
+ * @return {number} The mapped value.
+ */
+ mapLinear: mapLinear,
+ /**
+ * Returns the percentage in the closed interval `[0, 1]` of the given value
+ * between the start and end point.
+ *
+ * @static
+ * @method
+ * @param {number} x - The start point
+ * @param {number} y - The end point.
+ * @param {number} value - A value between start and end.
+ * @return {number} The interpolation factor.
+ */
+ inverseLerp: inverseLerp,
+ /**
+ * Returns a value linearly interpolated from two known points based on the given interval -
+ * `t = 0` will return `x` and `t = 1` will return `y`.
+ *
+ * @static
+ * @method
+ * @param {number} x - The start point
+ * @param {number} y - The end point.
+ * @param {number} t - The interpolation factor in the closed interval `[0, 1]`.
+ * @return {number} The interpolated value.
+ */
+ lerp: lerp,
+ /**
+ * Smoothly interpolate a number from `x` to `y` in a spring-like manner using a delta
+ * time to maintain frame rate independent movement. For details, see
+ * [Frame rate independent damping using lerp](http://www.rorydriscoll.com/2016/03/07/frame-rate-independent-damping-using-lerp/).
+ *
+ * @static
+ * @method
+ * @param {number} x - The current point.
+ * @param {number} y - The target point.
+ * @param {number} lambda - A higher lambda value will make the movement more sudden,
+ * and a lower value will make the movement more gradual.
+ * @param {number} dt - Delta time in seconds.
+ * @return {number} The interpolated value.
+ */
+ damp: damp,
+ /**
+ * Returns a value that alternates between `0` and the given `length` parameter.
+ *
+ * @static
+ * @method
+ * @param {number} x - The value to pingpong.
+ * @param {number} [length=1] - The positive value the function will pingpong to.
+ * @return {number} The alternated value.
+ */
+ pingpong: pingpong,
+ /**
+ * Returns a value in the range `[0,1]` that represents the percentage that `x` has
+ * moved between `min` and `max`, but smoothed or slowed down the closer `x` is to
+ * the `min` and `max`.
+ *
+ * See [Smoothstep](http://en.wikipedia.org/wiki/Smoothstep) for more details.
+ *
+ * @static
+ * @method
+ * @param {number} x - The value to evaluate based on its position between min and max.
+ * @param {number} min - The min value. Any x value below min will be `0`.
+ * @param {number} max - The max value. Any x value above max will be `1`.
+ * @return {number} The alternated value.
+ */
+ smoothstep: smoothstep,
+ /**
+ * A [variation on smoothstep](https://en.wikipedia.org/wiki/Smoothstep#Variations)
+ * that has zero 1st and 2nd order derivatives at x=0 and x=1.
+ *
+ * @static
+ * @method
+ * @param {number} x - The value to evaluate based on its position between min and max.
+ * @param {number} min - The min value. Any x value below min will be `0`.
+ * @param {number} max - The max value. Any x value above max will be `1`.
+ * @return {number} The alternated value.
+ */
+ smootherstep: smootherstep,
+ /**
+ * Returns a random integer from `` interval.
+ *
+ * @static
+ * @method
+ * @param {number} low - The lower value boundary.
+ * @param {number} high - The upper value boundary
+ * @return {number} A random integer.
+ */
+ randInt: randInt,
+ /**
+ * Returns a random float from `` interval.
+ *
+ * @static
+ * @method
+ * @param {number} low - The lower value boundary.
+ * @param {number} high - The upper value boundary
+ * @return {number} A random float.
+ */
+ randFloat: randFloat,
+ /**
+ * Returns a random integer from `<-range/2, range/2>` interval.
+ *
+ * @static
+ * @method
+ * @param {number} range - Defines the value range.
+ * @return {number} A random float.
+ */
+ randFloatSpread: randFloatSpread,
+ /**
+ * Returns a deterministic pseudo-random float in the interval `[0, 1]`.
+ *
+ * @static
+ * @method
+ * @param {number} [s] - The integer seed.
+ * @return {number} A random float.
+ */
+ seededRandom: seededRandom,
+ /**
+ * Converts degrees to radians.
+ *
+ * @static
+ * @method
+ * @param {number} degrees - A value in degrees.
+ * @return {number} The converted value in radians.
+ */
+ degToRad: degToRad,
+ /**
+ * Converts radians to degrees.
+ *
+ * @static
+ * @method
+ * @param {number} radians - A value in radians.
+ * @return {number} The converted value in degrees.
+ */
+ radToDeg: radToDeg,
+ /**
+ * Returns `true` if the given number is a power of two.
+ *
+ * @static
+ * @method
+ * @param {number} value - The value to check.
+ * @return {boolean} Whether the given number is a power of two or not.
+ */
+ isPowerOfTwo: isPowerOfTwo,
+ /**
+ * Returns the smallest power of two that is greater than or equal to the given number.
+ *
+ * @static
+ * @method
+ * @param {number} value - The value to find a POT for.
+ * @return {number} The smallest power of two that is greater than or equal to the given number.
+ */
+ ceilPowerOfTwo: ceilPowerOfTwo,
+ /**
+ * Returns the largest power of two that is less than or equal to the given number.
+ *
+ * @static
+ * @method
+ * @param {number} value - The value to find a POT for.
+ * @return {number} The largest power of two that is less than or equal to the given number.
+ */
+ floorPowerOfTwo: floorPowerOfTwo,
+ /**
+ * Sets the given quaternion from the [Intrinsic Proper Euler Angles](https://en.wikipedia.org/wiki/Euler_angles)
+ * defined by the given angles and order.
+ *
+ * Rotations are applied to the axes in the order specified by order:
+ * rotation by angle `a` is applied first, then by angle `b`, then by angle `c`.
+ *
+ * @static
+ * @method
+ * @param {Quaternion} q - The quaternion to set.
+ * @param {number} a - The rotation applied to the first axis, in radians.
+ * @param {number} b - The rotation applied to the second axis, in radians.
+ * @param {number} c - The rotation applied to the third axis, in radians.
+ * @param {('XYX'|'XZX'|'YXY'|'YZY'|'ZXZ'|'ZYZ')} order - A string specifying the axes order.
+ */
+ setQuaternionFromProperEuler: setQuaternionFromProperEuler,
+ /**
+ * Normalizes the given value according to the given typed array.
+ *
+ * @static
+ * @method
+ * @param {number} value - The float value in the range `[0,1]` to normalize.
+ * @param {TypedArray} array - The typed array that defines the data type of the value.
+ * @return {number} The normalize value.
+ */
+ normalize: normalize,
+ /**
+ * Denormalizes the given value according to the given typed array.
+ *
+ * @static
+ * @method
+ * @param {number} value - The value to denormalize.
+ * @param {TypedArray} array - The typed array that defines the data type of the value.
+ * @return {number} The denormalize (float) value in the range `[0,1]`.
+ */
+ denormalize: denormalize
+};
+
+/**
+ * Class representing a 2D vector. A 2D vector is an ordered pair of numbers
+ * (labeled x and y), which can be used to represent a number of things, such as:
+ *
+ * - A point in 2D space (i.e. a position on a plane).
+ * - A direction and length across a plane. In three.js the length will
+ * always be the Euclidean distance(straight-line distance) from `(0, 0)` to `(x, y)`
+ * and the direction is also measured from `(0, 0)` towards `(x, y)`.
+ * - Any arbitrary ordered pair of numbers.
+ *
+ * There are other things a 2D vector can be used to represent, such as
+ * momentum vectors, complex numbers and so on, however these are the most
+ * common uses in three.js.
+ *
+ * Iterating through a vector instance will yield its components `(x, y)` in
+ * the corresponding order.
+ * ```js
+ * const a = new THREE.Vector2( 0, 1 );
+ *
+ * //no arguments; will be initialised to (0, 0)
+ * const b = new THREE.Vector2( );
+ *
+ * const d = a.distanceTo( b );
+ * ```
+ */
+class Vector2 {
+
+ static {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ Vector2.prototype.isVector2 = true;
+
+ }
+
+ /**
+ * Constructs a new 2D vector.
+ *
+ * @param {number} [x=0] - The x value of this vector.
+ * @param {number} [y=0] - The y value of this vector.
+ */
+ constructor( x = 0, y = 0 ) {
+
+ /**
+ * The x value of this vector.
+ *
+ * @type {number}
+ */
+ this.x = x;
+
+ /**
+ * The y value of this vector.
+ *
+ * @type {number}
+ */
+ this.y = y;
+
+ }
+
+ /**
+ * Alias for {@link Vector2#x}.
+ *
+ * @type {number}
+ */
+ get width() {
+
+ return this.x;
+
+ }
+
+ set width( value ) {
+
+ this.x = value;
+
+ }
+
+ /**
+ * Alias for {@link Vector2#y}.
+ *
+ * @type {number}
+ */
+ get height() {
+
+ return this.y;
+
+ }
+
+ set height( value ) {
+
+ this.y = value;
+
+ }
+
+ /**
+ * Sets the vector components.
+ *
+ * @param {number} x - The value of the x component.
+ * @param {number} y - The value of the y component.
+ * @return {Vector2} A reference to this vector.
+ */
+ set( x, y ) {
+
+ this.x = x;
+ this.y = y;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components to the same value.
+ *
+ * @param {number} scalar - The value to set for all vector components.
+ * @return {Vector2} A reference to this vector.
+ */
+ setScalar( scalar ) {
+
+ this.x = scalar;
+ this.y = scalar;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's x component to the given value
+ *
+ * @param {number} x - The value to set.
+ * @return {Vector2} A reference to this vector.
+ */
+ setX( x ) {
+
+ this.x = x;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's y component to the given value
+ *
+ * @param {number} y - The value to set.
+ * @return {Vector2} A reference to this vector.
+ */
+ setY( y ) {
+
+ this.y = y;
+
+ return this;
+
+ }
+
+ /**
+ * Allows to set a vector component with an index.
+ *
+ * @param {number} index - The component index. `0` equals to x, `1` equals to y.
+ * @param {number} value - The value to set.
+ * @return {Vector2} A reference to this vector.
+ */
+ setComponent( index, value ) {
+
+ switch ( index ) {
+
+ case 0: this.x = value; break;
+ case 1: this.y = value; break;
+ default: throw new Error( 'THREE.Vector2: index is out of range: ' + index );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the value of the vector component which matches the given index.
+ *
+ * @param {number} index - The component index. `0` equals to x, `1` equals to y.
+ * @return {number} A vector component value.
+ */
+ getComponent( index ) {
+
+ switch ( index ) {
+
+ case 0: return this.x;
+ case 1: return this.y;
+ default: throw new Error( 'THREE.Vector2: index is out of range: ' + index );
+
+ }
+
+ }
+
+ /**
+ * Returns a new vector with copied values from this instance.
+ *
+ * @return {Vector2} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor( this.x, this.y );
+
+ }
+
+ /**
+ * Copies the values of the given vector to this instance.
+ *
+ * @param {Vector2} v - The vector to copy.
+ * @return {Vector2} A reference to this vector.
+ */
+ copy( v ) {
+
+ this.x = v.x;
+ this.y = v.y;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vector to this instance.
+ *
+ * @param {Vector2} v - The vector to add.
+ * @return {Vector2} A reference to this vector.
+ */
+ add( v ) {
+
+ this.x += v.x;
+ this.y += v.y;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given scalar value to all components of this instance.
+ *
+ * @param {number} s - The scalar to add.
+ * @return {Vector2} A reference to this vector.
+ */
+ addScalar( s ) {
+
+ this.x += s;
+ this.y += s;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vectors and stores the result in this instance.
+ *
+ * @param {Vector2} a - The first vector.
+ * @param {Vector2} b - The second vector.
+ * @return {Vector2} A reference to this vector.
+ */
+ addVectors( a, b ) {
+
+ this.x = a.x + b.x;
+ this.y = a.y + b.y;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vector scaled by the given factor to this instance.
+ *
+ * @param {Vector2} v - The vector.
+ * @param {number} s - The factor that scales `v`.
+ * @return {Vector2} A reference to this vector.
+ */
+ addScaledVector( v, s ) {
+
+ this.x += v.x * s;
+ this.y += v.y * s;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given vector from this instance.
+ *
+ * @param {Vector2} v - The vector to subtract.
+ * @return {Vector2} A reference to this vector.
+ */
+ sub( v ) {
+
+ this.x -= v.x;
+ this.y -= v.y;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given scalar value from all components of this instance.
+ *
+ * @param {number} s - The scalar to subtract.
+ * @return {Vector2} A reference to this vector.
+ */
+ subScalar( s ) {
+
+ this.x -= s;
+ this.y -= s;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given vectors and stores the result in this instance.
+ *
+ * @param {Vector2} a - The first vector.
+ * @param {Vector2} b - The second vector.
+ * @return {Vector2} A reference to this vector.
+ */
+ subVectors( a, b ) {
+
+ this.x = a.x - b.x;
+ this.y = a.y - b.y;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given vector with this instance.
+ *
+ * @param {Vector2} v - The vector to multiply.
+ * @return {Vector2} A reference to this vector.
+ */
+ multiply( v ) {
+
+ this.x *= v.x;
+ this.y *= v.y;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given scalar value with all components of this instance.
+ *
+ * @param {number} scalar - The scalar to multiply.
+ * @return {Vector2} A reference to this vector.
+ */
+ multiplyScalar( scalar ) {
+
+ this.x *= scalar;
+ this.y *= scalar;
+
+ return this;
+
+ }
+
+ /**
+ * Divides this instance by the given vector.
+ *
+ * @param {Vector2} v - The vector to divide.
+ * @return {Vector2} A reference to this vector.
+ */
+ divide( v ) {
+
+ this.x /= v.x;
+ this.y /= v.y;
+
+ return this;
+
+ }
+
+ /**
+ * Divides this vector by the given scalar.
+ *
+ * @param {number} scalar - The scalar to divide.
+ * @return {Vector2} A reference to this vector.
+ */
+ divideScalar( scalar ) {
+
+ return this.multiplyScalar( 1 / scalar );
+
+ }
+
+ /**
+ * Multiplies this vector (with an implicit 1 as the 3rd component) by
+ * the given 3x3 matrix.
+ *
+ * @param {Matrix3} m - The matrix to apply.
+ * @return {Vector2} A reference to this vector.
+ */
+ applyMatrix3( m ) {
+
+ const x = this.x, y = this.y;
+ const e = m.elements;
+
+ this.x = e[ 0 ] * x + e[ 3 ] * y + e[ 6 ];
+ this.y = e[ 1 ] * x + e[ 4 ] * y + e[ 7 ];
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x or y value is greater than the given vector's x or y
+ * value, replace that value with the corresponding min value.
+ *
+ * @param {Vector2} v - The vector.
+ * @return {Vector2} A reference to this vector.
+ */
+ min( v ) {
+
+ this.x = Math.min( this.x, v.x );
+ this.y = Math.min( this.y, v.y );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x or y value is less than the given vector's x or y
+ * value, replace that value with the corresponding max value.
+ *
+ * @param {Vector2} v - The vector.
+ * @return {Vector2} A reference to this vector.
+ */
+ max( v ) {
+
+ this.x = Math.max( this.x, v.x );
+ this.y = Math.max( this.y, v.y );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x or y value is greater than the max vector's x or y
+ * value, it is replaced by the corresponding value.
+ * If this vector's x or y value is less than the min vector's x or y value,
+ * it is replaced by the corresponding value.
+ *
+ * @param {Vector2} min - The minimum x and y values.
+ * @param {Vector2} max - The maximum x and y values in the desired range.
+ * @return {Vector2} A reference to this vector.
+ */
+ clamp( min, max ) {
+
+ // assumes min < max, componentwise
+
+ this.x = clamp( this.x, min.x, max.x );
+ this.y = clamp( this.y, min.y, max.y );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x or y values are greater than the max value, they are
+ * replaced by the max value.
+ * If this vector's x or y values are less than the min value, they are
+ * replaced by the min value.
+ *
+ * @param {number} minVal - The minimum value the components will be clamped to.
+ * @param {number} maxVal - The maximum value the components will be clamped to.
+ * @return {Vector2} A reference to this vector.
+ */
+ clampScalar( minVal, maxVal ) {
+
+ this.x = clamp( this.x, minVal, maxVal );
+ this.y = clamp( this.y, minVal, maxVal );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's length is greater than the max value, it is replaced by
+ * the max value.
+ * If this vector's length is less than the min value, it is replaced by the
+ * min value.
+ *
+ * @param {number} min - The minimum value the vector length will be clamped to.
+ * @param {number} max - The maximum value the vector length will be clamped to.
+ * @return {Vector2} A reference to this vector.
+ */
+ clampLength( min, max ) {
+
+ const length = this.length();
+
+ return this.divideScalar( length || 1 ).multiplyScalar( clamp( length, min, max ) );
+
+ }
+
+ /**
+ * The components of this vector are rounded down to the nearest integer value.
+ *
+ * @return {Vector2} A reference to this vector.
+ */
+ floor() {
+
+ this.x = Math.floor( this.x );
+ this.y = Math.floor( this.y );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded up to the nearest integer value.
+ *
+ * @return {Vector2} A reference to this vector.
+ */
+ ceil() {
+
+ this.x = Math.ceil( this.x );
+ this.y = Math.ceil( this.y );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded to the nearest integer value
+ *
+ * @return {Vector2} A reference to this vector.
+ */
+ round() {
+
+ this.x = Math.round( this.x );
+ this.y = Math.round( this.y );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded towards zero (up if negative,
+ * down if positive) to an integer value.
+ *
+ * @return {Vector2} A reference to this vector.
+ */
+ roundToZero() {
+
+ this.x = Math.trunc( this.x );
+ this.y = Math.trunc( this.y );
+
+ return this;
+
+ }
+
+ /**
+ * Inverts this vector - i.e. sets x = -x and y = -y.
+ *
+ * @return {Vector2} A reference to this vector.
+ */
+ negate() {
+
+ this.x = - this.x;
+ this.y = - this.y;
+
+ return this;
+
+ }
+
+ /**
+ * Calculates the dot product of the given vector with this instance.
+ *
+ * @param {Vector2} v - The vector to compute the dot product with.
+ * @return {number} The result of the dot product.
+ */
+ dot( v ) {
+
+ return this.x * v.x + this.y * v.y;
+
+ }
+
+ /**
+ * Calculates the cross product of the given vector with this instance.
+ *
+ * @param {Vector2} v - The vector to compute the cross product with.
+ * @return {number} The result of the cross product.
+ */
+ cross( v ) {
+
+ return this.x * v.y - this.y * v.x;
+
+ }
+
+ /**
+ * Computes the square of the Euclidean length (straight-line length) from
+ * (0, 0) to (x, y). If you are comparing the lengths of vectors, you should
+ * compare the length squared instead as it is slightly more efficient to calculate.
+ *
+ * @return {number} The square length of this vector.
+ */
+ lengthSq() {
+
+ return this.x * this.x + this.y * this.y;
+
+ }
+
+ /**
+ * Computes the Euclidean length (straight-line length) from (0, 0) to (x, y).
+ *
+ * @return {number} The length of this vector.
+ */
+ length() {
+
+ return Math.sqrt( this.x * this.x + this.y * this.y );
+
+ }
+
+ /**
+ * Computes the Manhattan length of this vector.
+ *
+ * @return {number} The length of this vector.
+ */
+ manhattanLength() {
+
+ return Math.abs( this.x ) + Math.abs( this.y );
+
+ }
+
+ /**
+ * Converts this vector to a unit vector - that is, sets it equal to a vector
+ * with the same direction as this one, but with a vector length of `1`.
+ *
+ * @return {Vector2} A reference to this vector.
+ */
+ normalize() {
+
+ return this.divideScalar( this.length() || 1 );
+
+ }
+
+ /**
+ * Computes the angle in radians of this vector with respect to the positive x-axis.
+ *
+ * @return {number} The angle in radians.
+ */
+ angle() {
+
+ const angle = Math.atan2( - this.y, - this.x ) + Math.PI;
+
+ return angle;
+
+ }
+
+ /**
+ * Returns the angle between the given vector and this instance in radians.
+ *
+ * @param {Vector2} v - The vector to compute the angle with.
+ * @return {number} The angle in radians.
+ */
+ angleTo( v ) {
+
+ const denominator = Math.sqrt( this.lengthSq() * v.lengthSq() );
+
+ if ( denominator === 0 ) return Math.PI / 2;
+
+ const theta = this.dot( v ) / denominator;
+
+ // clamp, to handle numerical problems
+
+ return Math.acos( clamp( theta, -1, 1 ) );
+
+ }
+
+ /**
+ * Computes the distance from the given vector to this instance.
+ *
+ * @param {Vector2} v - The vector to compute the distance to.
+ * @return {number} The distance.
+ */
+ distanceTo( v ) {
+
+ return Math.sqrt( this.distanceToSquared( v ) );
+
+ }
+
+ /**
+ * Computes the squared distance from the given vector to this instance.
+ * If you are just comparing the distance with another distance, you should compare
+ * the distance squared instead as it is slightly more efficient to calculate.
+ *
+ * @param {Vector2} v - The vector to compute the squared distance to.
+ * @return {number} The squared distance.
+ */
+ distanceToSquared( v ) {
+
+ const dx = this.x - v.x, dy = this.y - v.y;
+ return dx * dx + dy * dy;
+
+ }
+
+ /**
+ * Computes the Manhattan distance from the given vector to this instance.
+ *
+ * @param {Vector2} v - The vector to compute the Manhattan distance to.
+ * @return {number} The Manhattan distance.
+ */
+ manhattanDistanceTo( v ) {
+
+ return Math.abs( this.x - v.x ) + Math.abs( this.y - v.y );
+
+ }
+
+ /**
+ * Sets this vector to a vector with the same direction as this one, but
+ * with the specified length.
+ *
+ * @param {number} length - The new length of this vector.
+ * @return {Vector2} A reference to this vector.
+ */
+ setLength( length ) {
+
+ return this.normalize().multiplyScalar( length );
+
+ }
+
+ /**
+ * Linearly interpolates between the given vector and this instance, where
+ * alpha is the percent distance along the line - alpha = 0 will be this
+ * vector, and alpha = 1 will be the given one.
+ *
+ * @param {Vector2} v - The vector to interpolate towards.
+ * @param {number} alpha - The interpolation factor, typically in the closed interval `[0, 1]`.
+ * @return {Vector2} A reference to this vector.
+ */
+ lerp( v, alpha ) {
+
+ this.x += ( v.x - this.x ) * alpha;
+ this.y += ( v.y - this.y ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Linearly interpolates between the given vectors, where alpha is the percent
+ * distance along the line - alpha = 0 will be first vector, and alpha = 1 will
+ * be the second one. The result is stored in this instance.
+ *
+ * @param {Vector2} v1 - The first vector.
+ * @param {Vector2} v2 - The second vector.
+ * @param {number} alpha - The interpolation factor, typically in the closed interval `[0, 1]`.
+ * @return {Vector2} A reference to this vector.
+ */
+ lerpVectors( v1, v2, alpha ) {
+
+ this.x = v1.x + ( v2.x - v1.x ) * alpha;
+ this.y = v1.y + ( v2.y - v1.y ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this vector is equal with the given one.
+ *
+ * @param {Vector2} v - The vector to test for equality.
+ * @return {boolean} Whether this vector is equal with the given one.
+ */
+ equals( v ) {
+
+ return ( ( v.x === this.x ) && ( v.y === this.y ) );
+
+ }
+
+ /**
+ * Sets this vector's x value to be `array[ offset ]` and y
+ * value to be `array[ offset + 1 ]`.
+ *
+ * @param {Array} array - An array holding the vector component values.
+ * @param {number} [offset=0] - The offset into the array.
+ * @return {Vector2} A reference to this vector.
+ */
+ fromArray( array, offset = 0 ) {
+
+ this.x = array[ offset ];
+ this.y = array[ offset + 1 ];
+
+ return this;
+
+ }
+
+ /**
+ * Writes the components of this vector to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the vector components.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The vector components.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ array[ offset ] = this.x;
+ array[ offset + 1 ] = this.y;
+
+ return array;
+
+ }
+
+ /**
+ * Sets the components of this vector from the given buffer attribute.
+ *
+ * @param {BufferAttribute} attribute - The buffer attribute holding vector data.
+ * @param {number} index - The index into the attribute.
+ * @return {Vector2} A reference to this vector.
+ */
+ fromBufferAttribute( attribute, index ) {
+
+ this.x = attribute.getX( index );
+ this.y = attribute.getY( index );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates this vector around the given center by the given angle.
+ *
+ * @param {Vector2} center - The point around which to rotate.
+ * @param {number} angle - The angle to rotate, in radians.
+ * @return {Vector2} A reference to this vector.
+ */
+ rotateAround( center, angle ) {
+
+ const c = Math.cos( angle ), s = Math.sin( angle );
+
+ const x = this.x - center.x;
+ const y = this.y - center.y;
+
+ this.x = x * c - y * s + center.x;
+ this.y = x * s + y * c + center.y;
+
+ return this;
+
+ }
+
+ /**
+ * Sets each component of this vector to a pseudo-random value between `0` and
+ * `1`, excluding `1`.
+ *
+ * @return {Vector2} A reference to this vector.
+ */
+ random() {
+
+ this.x = Math.random();
+ this.y = Math.random();
+
+ return this;
+
+ }
+
+ *[ Symbol.iterator ]() {
+
+ yield this.x;
+ yield this.y;
+
+ }
+
+}
+
+/**
+ * Class for representing a Quaternion. Quaternions are used in three.js to represent rotations.
+ *
+ * Iterating through a vector instance will yield its components `(x, y, z, w)` in
+ * the corresponding order.
+ *
+ * Note that three.js expects Quaternions to be normalized.
+ * ```js
+ * const quaternion = new THREE.Quaternion();
+ * quaternion.setFromAxisAngle( new THREE.Vector3( 0, 1, 0 ), Math.PI / 2 );
+ *
+ * const vector = new THREE.Vector3( 1, 0, 0 );
+ * vector.applyQuaternion( quaternion );
+ * ```
+ */
+class Quaternion {
+
+ /**
+ * Constructs a new quaternion.
+ *
+ * @param {number} [x=0] - The x value of this quaternion.
+ * @param {number} [y=0] - The y value of this quaternion.
+ * @param {number} [z=0] - The z value of this quaternion.
+ * @param {number} [w=1] - The w value of this quaternion.
+ */
+ constructor( x = 0, y = 0, z = 0, w = 1 ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isQuaternion = true;
+
+ this._x = x;
+ this._y = y;
+ this._z = z;
+ this._w = w;
+
+ }
+
+ /**
+ * Interpolates between two quaternions via SLERP. This implementation assumes the
+ * quaternion data are managed in flat arrays.
+ *
+ * @param {Array} dst - The destination array.
+ * @param {number} dstOffset - An offset into the destination array.
+ * @param {Array} src0 - The source array of the first quaternion.
+ * @param {number} srcOffset0 - An offset into the first source array.
+ * @param {Array} src1 - The source array of the second quaternion.
+ * @param {number} srcOffset1 - An offset into the second source array.
+ * @param {number} t - The interpolation factor. A value in the range `[0,1]` will interpolate. A value outside the range `[0,1]` will extrapolate.
+ * @see {@link Quaternion#slerp}
+ */
+ static slerpFlat( dst, dstOffset, src0, srcOffset0, src1, srcOffset1, t ) {
+
+ let x0 = src0[ srcOffset0 + 0 ],
+ y0 = src0[ srcOffset0 + 1 ],
+ z0 = src0[ srcOffset0 + 2 ],
+ w0 = src0[ srcOffset0 + 3 ];
+
+ let x1 = src1[ srcOffset1 + 0 ],
+ y1 = src1[ srcOffset1 + 1 ],
+ z1 = src1[ srcOffset1 + 2 ],
+ w1 = src1[ srcOffset1 + 3 ];
+
+ if ( w0 !== w1 || x0 !== x1 || y0 !== y1 || z0 !== z1 ) {
+
+ let dot = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1;
+
+ if ( dot < 0 ) {
+
+ x1 = - x1;
+ y1 = - y1;
+ z1 = - z1;
+ w1 = - w1;
+
+ dot = - dot;
+
+ }
+
+ let s = 1 - t;
+
+ if ( dot < 0.9995 ) {
+
+ // slerp
+
+ const theta = Math.acos( dot );
+ const sin = Math.sin( theta );
+
+ s = Math.sin( s * theta ) / sin;
+ t = Math.sin( t * theta ) / sin;
+
+ x0 = x0 * s + x1 * t;
+ y0 = y0 * s + y1 * t;
+ z0 = z0 * s + z1 * t;
+ w0 = w0 * s + w1 * t;
+
+ } else {
+
+ // for small angles, lerp then normalize
+
+ x0 = x0 * s + x1 * t;
+ y0 = y0 * s + y1 * t;
+ z0 = z0 * s + z1 * t;
+ w0 = w0 * s + w1 * t;
+
+ const f = 1 / Math.sqrt( x0 * x0 + y0 * y0 + z0 * z0 + w0 * w0 );
+
+ x0 *= f;
+ y0 *= f;
+ z0 *= f;
+ w0 *= f;
+
+ }
+
+ }
+
+ dst[ dstOffset ] = x0;
+ dst[ dstOffset + 1 ] = y0;
+ dst[ dstOffset + 2 ] = z0;
+ dst[ dstOffset + 3 ] = w0;
+
+ }
+
+ /**
+ * Multiplies two quaternions. This implementation assumes the quaternion data are managed
+ * in flat arrays.
+ *
+ * @param {Array} dst - The destination array.
+ * @param {number} dstOffset - An offset into the destination array.
+ * @param {Array} src0 - The source array of the first quaternion.
+ * @param {number} srcOffset0 - An offset into the first source array.
+ * @param {Array} src1 - The source array of the second quaternion.
+ * @param {number} srcOffset1 - An offset into the second source array.
+ * @return {Array} The destination array.
+ * @see {@link Quaternion#multiplyQuaternions}.
+ */
+ static multiplyQuaternionsFlat( dst, dstOffset, src0, srcOffset0, src1, srcOffset1 ) {
+
+ const x0 = src0[ srcOffset0 ];
+ const y0 = src0[ srcOffset0 + 1 ];
+ const z0 = src0[ srcOffset0 + 2 ];
+ const w0 = src0[ srcOffset0 + 3 ];
+
+ const x1 = src1[ srcOffset1 ];
+ const y1 = src1[ srcOffset1 + 1 ];
+ const z1 = src1[ srcOffset1 + 2 ];
+ const w1 = src1[ srcOffset1 + 3 ];
+
+ dst[ dstOffset ] = x0 * w1 + w0 * x1 + y0 * z1 - z0 * y1;
+ dst[ dstOffset + 1 ] = y0 * w1 + w0 * y1 + z0 * x1 - x0 * z1;
+ dst[ dstOffset + 2 ] = z0 * w1 + w0 * z1 + x0 * y1 - y0 * x1;
+ dst[ dstOffset + 3 ] = w0 * w1 - x0 * x1 - y0 * y1 - z0 * z1;
+
+ return dst;
+
+ }
+
+ /**
+ * The x value of this quaternion.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get x() {
+
+ return this._x;
+
+ }
+
+ set x( value ) {
+
+ this._x = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * The y value of this quaternion.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get y() {
+
+ return this._y;
+
+ }
+
+ set y( value ) {
+
+ this._y = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * The z value of this quaternion.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get z() {
+
+ return this._z;
+
+ }
+
+ set z( value ) {
+
+ this._z = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * The w value of this quaternion.
+ *
+ * @type {number}
+ * @default 1
+ */
+ get w() {
+
+ return this._w;
+
+ }
+
+ set w( value ) {
+
+ this._w = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * Sets the quaternion components.
+ *
+ * @param {number} x - The x value of this quaternion.
+ * @param {number} y - The y value of this quaternion.
+ * @param {number} z - The z value of this quaternion.
+ * @param {number} w - The w value of this quaternion.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ set( x, y, z, w ) {
+
+ this._x = x;
+ this._y = y;
+ this._z = z;
+ this._w = w;
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new quaternion with copied values from this instance.
+ *
+ * @return {Quaternion} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor( this._x, this._y, this._z, this._w );
+
+ }
+
+ /**
+ * Copies the values of the given quaternion to this instance.
+ *
+ * @param {Quaternion} quaternion - The quaternion to copy.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ copy( quaternion ) {
+
+ this._x = quaternion.x;
+ this._y = quaternion.y;
+ this._z = quaternion.z;
+ this._w = quaternion.w;
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Sets this quaternion from the rotation specified by the given
+ * Euler angles.
+ *
+ * @param {Euler} euler - The Euler angles.
+ * @param {boolean} [update=true] - Whether the internal `onChange` callback should be executed or not.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ setFromEuler( euler, update = true ) {
+
+ const x = euler._x, y = euler._y, z = euler._z, order = euler._order;
+
+ const cos = Math.cos;
+ const sin = Math.sin;
+
+ const c1 = cos( x / 2 );
+ const c2 = cos( y / 2 );
+ const c3 = cos( z / 2 );
+
+ const s1 = sin( x / 2 );
+ const s2 = sin( y / 2 );
+ const s3 = sin( z / 2 );
+
+ switch ( order ) {
+
+ case 'XYZ':
+ this._x = s1 * c2 * c3 + c1 * s2 * s3;
+ this._y = c1 * s2 * c3 - s1 * c2 * s3;
+ this._z = c1 * c2 * s3 + s1 * s2 * c3;
+ this._w = c1 * c2 * c3 - s1 * s2 * s3;
+ break;
+
+ case 'YXZ':
+ this._x = s1 * c2 * c3 + c1 * s2 * s3;
+ this._y = c1 * s2 * c3 - s1 * c2 * s3;
+ this._z = c1 * c2 * s3 - s1 * s2 * c3;
+ this._w = c1 * c2 * c3 + s1 * s2 * s3;
+ break;
+
+ case 'ZXY':
+ this._x = s1 * c2 * c3 - c1 * s2 * s3;
+ this._y = c1 * s2 * c3 + s1 * c2 * s3;
+ this._z = c1 * c2 * s3 + s1 * s2 * c3;
+ this._w = c1 * c2 * c3 - s1 * s2 * s3;
+ break;
+
+ case 'ZYX':
+ this._x = s1 * c2 * c3 - c1 * s2 * s3;
+ this._y = c1 * s2 * c3 + s1 * c2 * s3;
+ this._z = c1 * c2 * s3 - s1 * s2 * c3;
+ this._w = c1 * c2 * c3 + s1 * s2 * s3;
+ break;
+
+ case 'YZX':
+ this._x = s1 * c2 * c3 + c1 * s2 * s3;
+ this._y = c1 * s2 * c3 + s1 * c2 * s3;
+ this._z = c1 * c2 * s3 - s1 * s2 * c3;
+ this._w = c1 * c2 * c3 - s1 * s2 * s3;
+ break;
+
+ case 'XZY':
+ this._x = s1 * c2 * c3 - c1 * s2 * s3;
+ this._y = c1 * s2 * c3 - s1 * c2 * s3;
+ this._z = c1 * c2 * s3 + s1 * s2 * c3;
+ this._w = c1 * c2 * c3 + s1 * s2 * s3;
+ break;
+
+ default:
+ warn( 'Quaternion: .setFromEuler() encountered an unknown order: ' + order );
+
+ }
+
+ if ( update === true ) this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Sets this quaternion from the given axis and angle.
+ *
+ * @param {Vector3} axis - The normalized axis.
+ * @param {number} angle - The angle in radians.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ setFromAxisAngle( axis, angle ) {
+
+ const halfAngle = angle / 2, s = Math.sin( halfAngle );
+
+ this._x = axis.x * s;
+ this._y = axis.y * s;
+ this._z = axis.z * s;
+ this._w = Math.cos( halfAngle );
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Sets this quaternion from the given rotation matrix.
+ *
+ * @param {Matrix4} m - A 4x4 matrix of which the upper 3x3 of matrix is a pure rotation matrix (i.e. unscaled).
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ setFromRotationMatrix( m ) {
+
+ // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled)
+
+ const te = m.elements,
+
+ m11 = te[ 0 ], m12 = te[ 4 ], m13 = te[ 8 ],
+ m21 = te[ 1 ], m22 = te[ 5 ], m23 = te[ 9 ],
+ m31 = te[ 2 ], m32 = te[ 6 ], m33 = te[ 10 ],
+
+ trace = m11 + m22 + m33;
+
+ if ( trace > 0 ) {
+
+ const s = 0.5 / Math.sqrt( trace + 1.0 );
+
+ this._w = 0.25 / s;
+ this._x = ( m32 - m23 ) * s;
+ this._y = ( m13 - m31 ) * s;
+ this._z = ( m21 - m12 ) * s;
+
+ } else if ( m11 > m22 && m11 > m33 ) {
+
+ const s = 2.0 * Math.sqrt( 1.0 + m11 - m22 - m33 );
+
+ this._w = ( m32 - m23 ) / s;
+ this._x = 0.25 * s;
+ this._y = ( m12 + m21 ) / s;
+ this._z = ( m13 + m31 ) / s;
+
+ } else if ( m22 > m33 ) {
+
+ const s = 2.0 * Math.sqrt( 1.0 + m22 - m11 - m33 );
+
+ this._w = ( m13 - m31 ) / s;
+ this._x = ( m12 + m21 ) / s;
+ this._y = 0.25 * s;
+ this._z = ( m23 + m32 ) / s;
+
+ } else {
+
+ const s = 2.0 * Math.sqrt( 1.0 + m33 - m11 - m22 );
+
+ this._w = ( m21 - m12 ) / s;
+ this._x = ( m13 + m31 ) / s;
+ this._y = ( m23 + m32 ) / s;
+ this._z = 0.25 * s;
+
+ }
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Sets this quaternion to the rotation required to rotate the direction vector
+ * `vFrom` to the direction vector `vTo`.
+ *
+ * @param {Vector3} vFrom - The first (normalized) direction vector.
+ * @param {Vector3} vTo - The second (normalized) direction vector.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ setFromUnitVectors( vFrom, vTo ) {
+
+ // assumes direction vectors vFrom and vTo are normalized
+
+ let r = vFrom.dot( vTo ) + 1;
+
+ if ( r < 1e-8 ) { // the epsilon value has been discussed in #31286
+
+ // vFrom and vTo point in opposite directions
+
+ r = 0;
+
+ if ( Math.abs( vFrom.x ) > Math.abs( vFrom.z ) ) {
+
+ this._x = - vFrom.y;
+ this._y = vFrom.x;
+ this._z = 0;
+ this._w = r;
+
+ } else {
+
+ this._x = 0;
+ this._y = - vFrom.z;
+ this._z = vFrom.y;
+ this._w = r;
+
+ }
+
+ } else {
+
+ // crossVectors( vFrom, vTo ); // inlined to avoid cyclic dependency on Vector3
+
+ this._x = vFrom.y * vTo.z - vFrom.z * vTo.y;
+ this._y = vFrom.z * vTo.x - vFrom.x * vTo.z;
+ this._z = vFrom.x * vTo.y - vFrom.y * vTo.x;
+ this._w = r;
+
+ }
+
+ return this.normalize();
+
+ }
+
+ /**
+ * Returns the angle between this quaternion and the given one in radians.
+ *
+ * @param {Quaternion} q - The quaternion to compute the angle with.
+ * @return {number} The angle in radians.
+ */
+ angleTo( q ) {
+
+ return 2 * Math.acos( Math.abs( clamp( this.dot( q ), -1, 1 ) ) );
+
+ }
+
+ /**
+ * Rotates this quaternion by a given angular step to the given quaternion.
+ * The method ensures that the final quaternion will not overshoot `q`.
+ *
+ * @param {Quaternion} q - The target quaternion.
+ * @param {number} step - The angular step in radians.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ rotateTowards( q, step ) {
+
+ const angle = this.angleTo( q );
+
+ if ( angle === 0 ) return this;
+
+ const t = Math.min( 1, step / angle );
+
+ this.slerp( q, t );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this quaternion to the identity quaternion; that is, to the
+ * quaternion that represents "no rotation".
+ *
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ identity() {
+
+ return this.set( 0, 0, 0, 1 );
+
+ }
+
+ /**
+ * Inverts this quaternion via {@link Quaternion#conjugate}. The
+ * quaternion is assumed to have unit length.
+ *
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ invert() {
+
+ return this.conjugate();
+
+ }
+
+ /**
+ * Returns the rotational conjugate of this quaternion. The conjugate of a
+ * quaternion represents the same rotation in the opposite direction about
+ * the rotational axis.
+ *
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ conjugate() {
+
+ this._x *= -1;
+ this._y *= -1;
+ this._z *= -1;
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Calculates the dot product of this quaternion and the given one.
+ *
+ * @param {Quaternion} v - The quaternion to compute the dot product with.
+ * @return {number} The result of the dot product.
+ */
+ dot( v ) {
+
+ return this._x * v._x + this._y * v._y + this._z * v._z + this._w * v._w;
+
+ }
+
+ /**
+ * Computes the squared Euclidean length (straight-line length) of this quaternion,
+ * considered as a 4 dimensional vector. This can be useful if you are comparing the
+ * lengths of two quaternions, as this is a slightly more efficient calculation than
+ * {@link Quaternion#length}.
+ *
+ * @return {number} The squared Euclidean length.
+ */
+ lengthSq() {
+
+ return this._x * this._x + this._y * this._y + this._z * this._z + this._w * this._w;
+
+ }
+
+ /**
+ * Computes the Euclidean length (straight-line length) of this quaternion,
+ * considered as a 4 dimensional vector.
+ *
+ * @return {number} The Euclidean length.
+ */
+ length() {
+
+ return Math.sqrt( this._x * this._x + this._y * this._y + this._z * this._z + this._w * this._w );
+
+ }
+
+ /**
+ * Normalizes this quaternion - that is, calculated the quaternion that performs
+ * the same rotation as this one, but has a length equal to `1`.
+ *
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ normalize() {
+
+ let l = this.length();
+
+ if ( l === 0 ) {
+
+ this._x = 0;
+ this._y = 0;
+ this._z = 0;
+ this._w = 1;
+
+ } else {
+
+ l = 1 / l;
+
+ this._x = this._x * l;
+ this._y = this._y * l;
+ this._z = this._z * l;
+ this._w = this._w * l;
+
+ }
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies this quaternion by the given one.
+ *
+ * @param {Quaternion} q - The quaternion.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ multiply( q ) {
+
+ return this.multiplyQuaternions( this, q );
+
+ }
+
+ /**
+ * Pre-multiplies this quaternion by the given one.
+ *
+ * @param {Quaternion} q - The quaternion.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ premultiply( q ) {
+
+ return this.multiplyQuaternions( q, this );
+
+ }
+
+ /**
+ * Multiplies the given quaternions and stores the result in this instance.
+ *
+ * @param {Quaternion} a - The first quaternion.
+ * @param {Quaternion} b - The second quaternion.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ multiplyQuaternions( a, b ) {
+
+ const qax = a._x, qay = a._y, qaz = a._z, qaw = a._w;
+ const qbx = b._x, qby = b._y, qbz = b._z, qbw = b._w;
+
+ this._x = qax * qbw + qaw * qbx + qay * qbz - qaz * qby;
+ this._y = qay * qbw + qaw * qby + qaz * qbx - qax * qbz;
+ this._z = qaz * qbw + qaw * qbz + qax * qby - qay * qbx;
+ this._w = qaw * qbw - qax * qbx - qay * qby - qaz * qbz;
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Performs a spherical linear interpolation between this quaternion and the target quaternion.
+ *
+ * @param {Quaternion} qb - The target quaternion.
+ * @param {number} t - The interpolation factor. A value in the range `[0,1]` will interpolate. A value outside the range `[0,1]` will extrapolate.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ slerp( qb, t ) {
+
+ let x = qb._x, y = qb._y, z = qb._z, w = qb._w;
+
+ let dot = this.dot( qb );
+
+ if ( dot < 0 ) {
+
+ x = - x;
+ y = - y;
+ z = - z;
+ w = - w;
+
+ dot = - dot;
+
+ }
+
+ let s = 1 - t;
+
+ if ( dot < 0.9995 ) {
+
+ // slerp
+
+ const theta = Math.acos( dot );
+ const sin = Math.sin( theta );
+
+ s = Math.sin( s * theta ) / sin;
+ t = Math.sin( t * theta ) / sin;
+
+ this._x = this._x * s + x * t;
+ this._y = this._y * s + y * t;
+ this._z = this._z * s + z * t;
+ this._w = this._w * s + w * t;
+
+ this._onChangeCallback();
+
+ } else {
+
+ // for small angles, lerp then normalize
+
+ this._x = this._x * s + x * t;
+ this._y = this._y * s + y * t;
+ this._z = this._z * s + z * t;
+ this._w = this._w * s + w * t;
+
+ this.normalize(); // normalize calls _onChangeCallback()
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Performs a spherical linear interpolation between the given quaternions
+ * and stores the result in this quaternion.
+ *
+ * @param {Quaternion} qa - The source quaternion.
+ * @param {Quaternion} qb - The target quaternion.
+ * @param {number} t - The interpolation factor in the closed interval `[0, 1]`.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ slerpQuaternions( qa, qb, t ) {
+
+ return this.copy( qa ).slerp( qb, t );
+
+ }
+
+ /**
+ * Sets this quaternion to a uniformly random, normalized quaternion.
+ *
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ random() {
+
+ // Ken Shoemake
+ // Uniform random rotations
+ // D. Kirk, editor, Graphics Gems III, pages 124-132. Academic Press, New York, 1992.
+
+ const theta1 = 2 * Math.PI * Math.random();
+ const theta2 = 2 * Math.PI * Math.random();
+
+ const x0 = Math.random();
+ const r1 = Math.sqrt( 1 - x0 );
+ const r2 = Math.sqrt( x0 );
+
+ return this.set(
+ r1 * Math.sin( theta1 ),
+ r1 * Math.cos( theta1 ),
+ r2 * Math.sin( theta2 ),
+ r2 * Math.cos( theta2 ),
+ );
+
+ }
+
+ /**
+ * Returns `true` if this quaternion is equal with the given one.
+ *
+ * @param {Quaternion} quaternion - The quaternion to test for equality.
+ * @return {boolean} Whether this quaternion is equal with the given one.
+ */
+ equals( quaternion ) {
+
+ return ( quaternion._x === this._x ) && ( quaternion._y === this._y ) && ( quaternion._z === this._z ) && ( quaternion._w === this._w );
+
+ }
+
+ /**
+ * Sets this quaternion's components from the given array.
+ *
+ * @param {Array} array - An array holding the quaternion component values.
+ * @param {number} [offset=0] - The offset into the array.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ fromArray( array, offset = 0 ) {
+
+ this._x = array[ offset ];
+ this._y = array[ offset + 1 ];
+ this._z = array[ offset + 2 ];
+ this._w = array[ offset + 3 ];
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Writes the components of this quaternion to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the quaternion components.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The quaternion components.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ array[ offset ] = this._x;
+ array[ offset + 1 ] = this._y;
+ array[ offset + 2 ] = this._z;
+ array[ offset + 3 ] = this._w;
+
+ return array;
+
+ }
+
+ /**
+ * Sets the components of this quaternion from the given buffer attribute.
+ *
+ * @param {BufferAttribute} attribute - The buffer attribute holding quaternion data.
+ * @param {number} index - The index into the attribute.
+ * @return {Quaternion} A reference to this quaternion.
+ */
+ fromBufferAttribute( attribute, index ) {
+
+ this._x = attribute.getX( index );
+ this._y = attribute.getY( index );
+ this._z = attribute.getZ( index );
+ this._w = attribute.getW( index );
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * This methods defines the serialization result of this class. Returns the
+ * numerical elements of this quaternion in an array of format `[x, y, z, w]`.
+ *
+ * @return {Array} The serialized quaternion.
+ */
+ toJSON() {
+
+ return this.toArray();
+
+ }
+
+ _onChange( callback ) {
+
+ this._onChangeCallback = callback;
+
+ return this;
+
+ }
+
+ _onChangeCallback() {}
+
+ *[ Symbol.iterator ]() {
+
+ yield this._x;
+ yield this._y;
+ yield this._z;
+ yield this._w;
+
+ }
+
+}
+
+/**
+ * Class representing a 3D vector. A 3D vector is an ordered triplet of numbers
+ * (labeled x, y and z), which can be used to represent a number of things, such as:
+ *
+ * - A point in 3D space.
+ * - A direction and length in 3D space. In three.js the length will
+ * always be the Euclidean distance(straight-line distance) from `(0, 0, 0)` to `(x, y, z)`
+ * and the direction is also measured from `(0, 0, 0)` towards `(x, y, z)`.
+ * - Any arbitrary ordered triplet of numbers.
+ *
+ * There are other things a 3D vector can be used to represent, such as
+ * momentum vectors and so on, however these are the most
+ * common uses in three.js.
+ *
+ * Iterating through a vector instance will yield its components `(x, y, z)` in
+ * the corresponding order.
+ * ```js
+ * const a = new THREE.Vector3( 0, 1, 0 );
+ *
+ * //no arguments; will be initialised to (0, 0, 0)
+ * const b = new THREE.Vector3( );
+ *
+ * const d = a.distanceTo( b );
+ * ```
+ */
+class Vector3 {
+
+ static {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ Vector3.prototype.isVector3 = true;
+
+ }
+
+ /**
+ * Constructs a new 3D vector.
+ *
+ * @param {number} [x=0] - The x value of this vector.
+ * @param {number} [y=0] - The y value of this vector.
+ * @param {number} [z=0] - The z value of this vector.
+ */
+ constructor( x = 0, y = 0, z = 0 ) {
+
+ /**
+ * The x value of this vector.
+ *
+ * @type {number}
+ */
+ this.x = x;
+
+ /**
+ * The y value of this vector.
+ *
+ * @type {number}
+ */
+ this.y = y;
+
+ /**
+ * The z value of this vector.
+ *
+ * @type {number}
+ */
+ this.z = z;
+
+ }
+
+ /**
+ * Sets the vector components.
+ *
+ * @param {number} x - The value of the x component.
+ * @param {number} y - The value of the y component.
+ * @param {number} z - The value of the z component.
+ * @return {Vector3} A reference to this vector.
+ */
+ set( x, y, z ) {
+
+ if ( z === undefined ) z = this.z; // sprite.scale.set(x,y)
+
+ this.x = x;
+ this.y = y;
+ this.z = z;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components to the same value.
+ *
+ * @param {number} scalar - The value to set for all vector components.
+ * @return {Vector3} A reference to this vector.
+ */
+ setScalar( scalar ) {
+
+ this.x = scalar;
+ this.y = scalar;
+ this.z = scalar;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's x component to the given value.
+ *
+ * @param {number} x - The value to set.
+ * @return {Vector3} A reference to this vector.
+ */
+ setX( x ) {
+
+ this.x = x;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's y component to the given value.
+ *
+ * @param {number} y - The value to set.
+ * @return {Vector3} A reference to this vector.
+ */
+ setY( y ) {
+
+ this.y = y;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's z component to the given value.
+ *
+ * @param {number} z - The value to set.
+ * @return {Vector3} A reference to this vector.
+ */
+ setZ( z ) {
+
+ this.z = z;
+
+ return this;
+
+ }
+
+ /**
+ * Allows to set a vector component with an index.
+ *
+ * @param {number} index - The component index. `0` equals to x, `1` equals to y, `2` equals to z.
+ * @param {number} value - The value to set.
+ * @return {Vector3} A reference to this vector.
+ */
+ setComponent( index, value ) {
+
+ switch ( index ) {
+
+ case 0: this.x = value; break;
+ case 1: this.y = value; break;
+ case 2: this.z = value; break;
+ default: throw new Error( 'THREE.Vector3: index is out of range: ' + index );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the value of the vector component which matches the given index.
+ *
+ * @param {number} index - The component index. `0` equals to x, `1` equals to y, `2` equals to z.
+ * @return {number} A vector component value.
+ */
+ getComponent( index ) {
+
+ switch ( index ) {
+
+ case 0: return this.x;
+ case 1: return this.y;
+ case 2: return this.z;
+ default: throw new Error( 'THREE.Vector3: index is out of range: ' + index );
+
+ }
+
+ }
+
+ /**
+ * Returns a new vector with copied values from this instance.
+ *
+ * @return {Vector3} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor( this.x, this.y, this.z );
+
+ }
+
+ /**
+ * Copies the values of the given vector to this instance.
+ *
+ * @param {Vector3} v - The vector to copy.
+ * @return {Vector3} A reference to this vector.
+ */
+ copy( v ) {
+
+ this.x = v.x;
+ this.y = v.y;
+ this.z = v.z;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vector to this instance.
+ *
+ * @param {Vector3} v - The vector to add.
+ * @return {Vector3} A reference to this vector.
+ */
+ add( v ) {
+
+ this.x += v.x;
+ this.y += v.y;
+ this.z += v.z;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given scalar value to all components of this instance.
+ *
+ * @param {number} s - The scalar to add.
+ * @return {Vector3} A reference to this vector.
+ */
+ addScalar( s ) {
+
+ this.x += s;
+ this.y += s;
+ this.z += s;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vectors and stores the result in this instance.
+ *
+ * @param {Vector3} a - The first vector.
+ * @param {Vector3} b - The second vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ addVectors( a, b ) {
+
+ this.x = a.x + b.x;
+ this.y = a.y + b.y;
+ this.z = a.z + b.z;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vector scaled by the given factor to this instance.
+ *
+ * @param {Vector3|Vector4} v - The vector.
+ * @param {number} s - The factor that scales `v`.
+ * @return {Vector3} A reference to this vector.
+ */
+ addScaledVector( v, s ) {
+
+ this.x += v.x * s;
+ this.y += v.y * s;
+ this.z += v.z * s;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given vector from this instance.
+ *
+ * @param {Vector3} v - The vector to subtract.
+ * @return {Vector3} A reference to this vector.
+ */
+ sub( v ) {
+
+ this.x -= v.x;
+ this.y -= v.y;
+ this.z -= v.z;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given scalar value from all components of this instance.
+ *
+ * @param {number} s - The scalar to subtract.
+ * @return {Vector3} A reference to this vector.
+ */
+ subScalar( s ) {
+
+ this.x -= s;
+ this.y -= s;
+ this.z -= s;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given vectors and stores the result in this instance.
+ *
+ * @param {Vector3} a - The first vector.
+ * @param {Vector3} b - The second vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ subVectors( a, b ) {
+
+ this.x = a.x - b.x;
+ this.y = a.y - b.y;
+ this.z = a.z - b.z;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given vector with this instance.
+ *
+ * @param {Vector3} v - The vector to multiply.
+ * @return {Vector3} A reference to this vector.
+ */
+ multiply( v ) {
+
+ this.x *= v.x;
+ this.y *= v.y;
+ this.z *= v.z;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given scalar value with all components of this instance.
+ *
+ * @param {number} scalar - The scalar to multiply.
+ * @return {Vector3} A reference to this vector.
+ */
+ multiplyScalar( scalar ) {
+
+ this.x *= scalar;
+ this.y *= scalar;
+ this.z *= scalar;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given vectors and stores the result in this instance.
+ *
+ * @param {Vector3} a - The first vector.
+ * @param {Vector3} b - The second vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ multiplyVectors( a, b ) {
+
+ this.x = a.x * b.x;
+ this.y = a.y * b.y;
+ this.z = a.z * b.z;
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given Euler rotation to this vector.
+ *
+ * @param {Euler} euler - The Euler angles.
+ * @return {Vector3} A reference to this vector.
+ */
+ applyEuler( euler ) {
+
+ return this.applyQuaternion( _quaternion$5.setFromEuler( euler ) );
+
+ }
+
+ /**
+ * Applies a rotation specified by an axis and an angle to this vector.
+ *
+ * @param {Vector3} axis - A normalized vector representing the rotation axis.
+ * @param {number} angle - The angle in radians.
+ * @return {Vector3} A reference to this vector.
+ */
+ applyAxisAngle( axis, angle ) {
+
+ return this.applyQuaternion( _quaternion$5.setFromAxisAngle( axis, angle ) );
+
+ }
+
+ /**
+ * Multiplies this vector with the given 3x3 matrix.
+ *
+ * @param {Matrix3} m - The 3x3 matrix.
+ * @return {Vector3} A reference to this vector.
+ */
+ applyMatrix3( m ) {
+
+ const x = this.x, y = this.y, z = this.z;
+ const e = m.elements;
+
+ this.x = e[ 0 ] * x + e[ 3 ] * y + e[ 6 ] * z;
+ this.y = e[ 1 ] * x + e[ 4 ] * y + e[ 7 ] * z;
+ this.z = e[ 2 ] * x + e[ 5 ] * y + e[ 8 ] * z;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies this vector by the given normal matrix and normalizes
+ * the result.
+ *
+ * @param {Matrix3} m - The normal matrix.
+ * @return {Vector3} A reference to this vector.
+ */
+ applyNormalMatrix( m ) {
+
+ return this.applyMatrix3( m ).normalize();
+
+ }
+
+ /**
+ * Multiplies this vector (with an implicit 1 in the 4th dimension) by m, and
+ * divides by perspective.
+ *
+ * @param {Matrix4} m - The matrix to apply.
+ * @return {Vector3} A reference to this vector.
+ */
+ applyMatrix4( m ) {
+
+ const x = this.x, y = this.y, z = this.z;
+ const e = m.elements;
+
+ const w = 1 / ( e[ 3 ] * x + e[ 7 ] * y + e[ 11 ] * z + e[ 15 ] );
+
+ this.x = ( e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z + e[ 12 ] ) * w;
+ this.y = ( e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z + e[ 13 ] ) * w;
+ this.z = ( e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z + e[ 14 ] ) * w;
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given Quaternion to this vector.
+ *
+ * @param {Quaternion} q - The Quaternion.
+ * @return {Vector3} A reference to this vector.
+ */
+ applyQuaternion( q ) {
+
+ // quaternion q is assumed to have unit length
+
+ const vx = this.x, vy = this.y, vz = this.z;
+ const qx = q.x, qy = q.y, qz = q.z, qw = q.w;
+
+ // t = 2 * cross( q.xyz, v );
+ const tx = 2 * ( qy * vz - qz * vy );
+ const ty = 2 * ( qz * vx - qx * vz );
+ const tz = 2 * ( qx * vy - qy * vx );
+
+ // v + q.w * t + cross( q.xyz, t );
+ this.x = vx + qw * tx + qy * tz - qz * ty;
+ this.y = vy + qw * ty + qz * tx - qx * tz;
+ this.z = vz + qw * tz + qx * ty - qy * tx;
+
+ return this;
+
+ }
+
+ /**
+ * Projects this vector from world space into the camera's normalized
+ * device coordinate (NDC) space.
+ *
+ * @param {Camera} camera - The camera.
+ * @return {Vector3} A reference to this vector.
+ */
+ project( camera ) {
+
+ return this.applyMatrix4( camera.matrixWorldInverse ).applyMatrix4( camera.projectionMatrix );
+
+ }
+
+ /**
+ * Unprojects this vector from the camera's normalized device coordinate (NDC)
+ * space into world space.
+ *
+ * @param {Camera} camera - The camera.
+ * @return {Vector3} A reference to this vector.
+ */
+ unproject( camera ) {
+
+ return this.applyMatrix4( camera.projectionMatrixInverse ).applyMatrix4( camera.matrixWorld );
+
+ }
+
+ /**
+ * Transforms this vector by the upper left 3x3 sub-matrix of the given 4x4 matrix,
+ * and normalizes the result.
+ *
+ * @param {Matrix4} m - The matrix.
+ * @return {Vector3} A reference to this vector.
+ */
+ transformDirection( m ) {
+
+ // input: THREE.Matrix4 affine matrix
+ // vector interpreted as a direction
+
+ const x = this.x, y = this.y, z = this.z;
+ const e = m.elements;
+
+ this.x = e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z;
+ this.y = e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z;
+ this.z = e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z;
+
+ return this.normalize();
+
+ }
+
+ /**
+ * Divides this instance by the given vector.
+ *
+ * @param {Vector3} v - The vector to divide.
+ * @return {Vector3} A reference to this vector.
+ */
+ divide( v ) {
+
+ this.x /= v.x;
+ this.y /= v.y;
+ this.z /= v.z;
+
+ return this;
+
+ }
+
+ /**
+ * Divides this vector by the given scalar.
+ *
+ * @param {number} scalar - The scalar to divide.
+ * @return {Vector3} A reference to this vector.
+ */
+ divideScalar( scalar ) {
+
+ return this.multiplyScalar( 1 / scalar );
+
+ }
+
+ /**
+ * If this vector's x, y or z value is greater than the given vector's x, y or z
+ * value, replace that value with the corresponding min value.
+ *
+ * @param {Vector3} v - The vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ min( v ) {
+
+ this.x = Math.min( this.x, v.x );
+ this.y = Math.min( this.y, v.y );
+ this.z = Math.min( this.z, v.z );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x, y or z value is less than the given vector's x, y or z
+ * value, replace that value with the corresponding max value.
+ *
+ * @param {Vector3} v - The vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ max( v ) {
+
+ this.x = Math.max( this.x, v.x );
+ this.y = Math.max( this.y, v.y );
+ this.z = Math.max( this.z, v.z );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x, y or z value is greater than the max vector's x, y or z
+ * value, it is replaced by the corresponding value.
+ * If this vector's x, y or z value is less than the min vector's x, y or z value,
+ * it is replaced by the corresponding value.
+ *
+ * @param {Vector3} min - The minimum x, y and z values.
+ * @param {Vector3} max - The maximum x, y and z values in the desired range.
+ * @return {Vector3} A reference to this vector.
+ */
+ clamp( min, max ) {
+
+ // assumes min < max, componentwise
+
+ this.x = clamp( this.x, min.x, max.x );
+ this.y = clamp( this.y, min.y, max.y );
+ this.z = clamp( this.z, min.z, max.z );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x, y or z values are greater than the max value, they are
+ * replaced by the max value.
+ * If this vector's x, y or z values are less than the min value, they are
+ * replaced by the min value.
+ *
+ * @param {number} minVal - The minimum value the components will be clamped to.
+ * @param {number} maxVal - The maximum value the components will be clamped to.
+ * @return {Vector3} A reference to this vector.
+ */
+ clampScalar( minVal, maxVal ) {
+
+ this.x = clamp( this.x, minVal, maxVal );
+ this.y = clamp( this.y, minVal, maxVal );
+ this.z = clamp( this.z, minVal, maxVal );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's length is greater than the max value, it is replaced by
+ * the max value.
+ * If this vector's length is less than the min value, it is replaced by the
+ * min value.
+ *
+ * @param {number} min - The minimum value the vector length will be clamped to.
+ * @param {number} max - The maximum value the vector length will be clamped to.
+ * @return {Vector3} A reference to this vector.
+ */
+ clampLength( min, max ) {
+
+ const length = this.length();
+
+ return this.divideScalar( length || 1 ).multiplyScalar( clamp( length, min, max ) );
+
+ }
+
+ /**
+ * The components of this vector are rounded down to the nearest integer value.
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ floor() {
+
+ this.x = Math.floor( this.x );
+ this.y = Math.floor( this.y );
+ this.z = Math.floor( this.z );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded up to the nearest integer value.
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ ceil() {
+
+ this.x = Math.ceil( this.x );
+ this.y = Math.ceil( this.y );
+ this.z = Math.ceil( this.z );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded to the nearest integer value
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ round() {
+
+ this.x = Math.round( this.x );
+ this.y = Math.round( this.y );
+ this.z = Math.round( this.z );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded towards zero (up if negative,
+ * down if positive) to an integer value.
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ roundToZero() {
+
+ this.x = Math.trunc( this.x );
+ this.y = Math.trunc( this.y );
+ this.z = Math.trunc( this.z );
+
+ return this;
+
+ }
+
+ /**
+ * Inverts this vector - i.e. sets x = -x, y = -y and z = -z.
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ negate() {
+
+ this.x = - this.x;
+ this.y = - this.y;
+ this.z = - this.z;
+
+ return this;
+
+ }
+
+ /**
+ * Calculates the dot product of the given vector with this instance.
+ *
+ * @param {Vector3} v - The vector to compute the dot product with.
+ * @return {number} The result of the dot product.
+ */
+ dot( v ) {
+
+ return this.x * v.x + this.y * v.y + this.z * v.z;
+
+ }
+
+ /**
+ * Computes the square of the Euclidean length (straight-line length) from
+ * (0, 0, 0) to (x, y, z). If you are comparing the lengths of vectors, you should
+ * compare the length squared instead as it is slightly more efficient to calculate.
+ *
+ * @return {number} The square length of this vector.
+ */
+ lengthSq() {
+
+ return this.x * this.x + this.y * this.y + this.z * this.z;
+
+ }
+
+ /**
+ * Computes the Euclidean length (straight-line length) from (0, 0, 0) to (x, y, z).
+ *
+ * @return {number} The length of this vector.
+ */
+ length() {
+
+ return Math.sqrt( this.x * this.x + this.y * this.y + this.z * this.z );
+
+ }
+
+ /**
+ * Computes the Manhattan length of this vector.
+ *
+ * @return {number} The length of this vector.
+ */
+ manhattanLength() {
+
+ return Math.abs( this.x ) + Math.abs( this.y ) + Math.abs( this.z );
+
+ }
+
+ /**
+ * Converts this vector to a unit vector - that is, sets it equal to a vector
+ * with the same direction as this one, but with a vector length of `1`.
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ normalize() {
+
+ return this.divideScalar( this.length() || 1 );
+
+ }
+
+ /**
+ * Sets this vector to a vector with the same direction as this one, but
+ * with the specified length.
+ *
+ * @param {number} length - The new length of this vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ setLength( length ) {
+
+ return this.normalize().multiplyScalar( length );
+
+ }
+
+ /**
+ * Linearly interpolates between the given vector and this instance, where
+ * alpha is the percent distance along the line - alpha = 0 will be this
+ * vector, and alpha = 1 will be the given one.
+ *
+ * @param {Vector3} v - The vector to interpolate towards.
+ * @param {number} alpha - The interpolation factor, typically in the closed interval `[0, 1]`.
+ * @return {Vector3} A reference to this vector.
+ */
+ lerp( v, alpha ) {
+
+ this.x += ( v.x - this.x ) * alpha;
+ this.y += ( v.y - this.y ) * alpha;
+ this.z += ( v.z - this.z ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Linearly interpolates between the given vectors, where alpha is the percent
+ * distance along the line - alpha = 0 will be first vector, and alpha = 1 will
+ * be the second one. The result is stored in this instance.
+ *
+ * @param {Vector3} v1 - The first vector.
+ * @param {Vector3} v2 - The second vector.
+ * @param {number} alpha - The interpolation factor, typically in the closed interval `[0, 1]`.
+ * @return {Vector3} A reference to this vector.
+ */
+ lerpVectors( v1, v2, alpha ) {
+
+ this.x = v1.x + ( v2.x - v1.x ) * alpha;
+ this.y = v1.y + ( v2.y - v1.y ) * alpha;
+ this.z = v1.z + ( v2.z - v1.z ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Calculates the cross product of the given vector with this instance.
+ *
+ * @param {Vector3} v - The vector to compute the cross product with.
+ * @return {Vector3} The result of the cross product.
+ */
+ cross( v ) {
+
+ return this.crossVectors( this, v );
+
+ }
+
+ /**
+ * Calculates the cross product of the given vectors and stores the result
+ * in this instance.
+ *
+ * @param {Vector3} a - The first vector.
+ * @param {Vector3} b - The second vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ crossVectors( a, b ) {
+
+ const ax = a.x, ay = a.y, az = a.z;
+ const bx = b.x, by = b.y, bz = b.z;
+
+ this.x = ay * bz - az * by;
+ this.y = az * bx - ax * bz;
+ this.z = ax * by - ay * bx;
+
+ return this;
+
+ }
+
+ /**
+ * Projects this vector onto the given one.
+ *
+ * @param {Vector3} v - The vector to project to.
+ * @return {Vector3} A reference to this vector.
+ */
+ projectOnVector( v ) {
+
+ const denominator = v.lengthSq();
+
+ if ( denominator === 0 ) return this.set( 0, 0, 0 );
+
+ const scalar = v.dot( this ) / denominator;
+
+ return this.copy( v ).multiplyScalar( scalar );
+
+ }
+
+ /**
+ * Projects this vector onto a plane by subtracting this
+ * vector projected onto the plane's normal from this vector.
+ *
+ * @param {Vector3} planeNormal - The plane normal.
+ * @return {Vector3} A reference to this vector.
+ */
+ projectOnPlane( planeNormal ) {
+
+ _vector$c.copy( this ).projectOnVector( planeNormal );
+
+ return this.sub( _vector$c );
+
+ }
+
+ /**
+ * Reflects this vector off a plane orthogonal to the given normal vector.
+ *
+ * @param {Vector3} normal - The (normalized) normal vector.
+ * @return {Vector3} A reference to this vector.
+ */
+ reflect( normal ) {
+
+ return this.sub( _vector$c.copy( normal ).multiplyScalar( 2 * this.dot( normal ) ) );
+
+ }
+ /**
+ * Returns the angle between the given vector and this instance in radians.
+ *
+ * @param {Vector3} v - The vector to compute the angle with.
+ * @return {number} The angle in radians.
+ */
+ angleTo( v ) {
+
+ const denominator = Math.sqrt( this.lengthSq() * v.lengthSq() );
+
+ if ( denominator === 0 ) return Math.PI / 2;
+
+ const theta = this.dot( v ) / denominator;
+
+ // clamp, to handle numerical problems
+
+ return Math.acos( clamp( theta, -1, 1 ) );
+
+ }
+
+ /**
+ * Computes the distance from the given vector to this instance.
+ *
+ * @param {Vector3} v - The vector to compute the distance to.
+ * @return {number} The distance.
+ */
+ distanceTo( v ) {
+
+ return Math.sqrt( this.distanceToSquared( v ) );
+
+ }
+
+ /**
+ * Computes the squared distance from the given vector to this instance.
+ * If you are just comparing the distance with another distance, you should compare
+ * the distance squared instead as it is slightly more efficient to calculate.
+ *
+ * @param {Vector3} v - The vector to compute the squared distance to.
+ * @return {number} The squared distance.
+ */
+ distanceToSquared( v ) {
+
+ const dx = this.x - v.x, dy = this.y - v.y, dz = this.z - v.z;
+
+ return dx * dx + dy * dy + dz * dz;
+
+ }
+
+ /**
+ * Computes the Manhattan distance from the given vector to this instance.
+ *
+ * @param {Vector3} v - The vector to compute the Manhattan distance to.
+ * @return {number} The Manhattan distance.
+ */
+ manhattanDistanceTo( v ) {
+
+ return Math.abs( this.x - v.x ) + Math.abs( this.y - v.y ) + Math.abs( this.z - v.z );
+
+ }
+
+ /**
+ * Sets the vector components from the given spherical coordinates.
+ *
+ * @param {Spherical} s - The spherical coordinates.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromSpherical( s ) {
+
+ return this.setFromSphericalCoords( s.radius, s.phi, s.theta );
+
+ }
+
+ /**
+ * Sets the vector components from the given spherical coordinates.
+ *
+ * @param {number} radius - The radius.
+ * @param {number} phi - The phi angle in radians.
+ * @param {number} theta - The theta angle in radians.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromSphericalCoords( radius, phi, theta ) {
+
+ const sinPhiRadius = Math.sin( phi ) * radius;
+
+ this.x = sinPhiRadius * Math.sin( theta );
+ this.y = Math.cos( phi ) * radius;
+ this.z = sinPhiRadius * Math.cos( theta );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components from the given cylindrical coordinates.
+ *
+ * @param {Cylindrical} c - The cylindrical coordinates.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromCylindrical( c ) {
+
+ return this.setFromCylindricalCoords( c.radius, c.theta, c.y );
+
+ }
+
+ /**
+ * Sets the vector components from the given cylindrical coordinates.
+ *
+ * @param {number} radius - The radius.
+ * @param {number} theta - The theta angle in radians.
+ * @param {number} y - The y value.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromCylindricalCoords( radius, theta, y ) {
+
+ this.x = radius * Math.sin( theta );
+ this.y = y;
+ this.z = radius * Math.cos( theta );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components to the position elements of the
+ * given transformation matrix.
+ *
+ * @param {Matrix4} m - The 4x4 matrix.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromMatrixPosition( m ) {
+
+ const e = m.elements;
+
+ this.x = e[ 12 ];
+ this.y = e[ 13 ];
+ this.z = e[ 14 ];
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components to the scale elements of the
+ * given transformation matrix.
+ *
+ * @param {Matrix4} m - The 4x4 matrix.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromMatrixScale( m ) {
+
+ const sx = this.setFromMatrixColumn( m, 0 ).length();
+ const sy = this.setFromMatrixColumn( m, 1 ).length();
+ const sz = this.setFromMatrixColumn( m, 2 ).length();
+
+ this.x = sx;
+ this.y = sy;
+ this.z = sz;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components from the specified matrix column.
+ *
+ * @param {Matrix4} m - The 4x4 matrix.
+ * @param {number} index - The column index.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromMatrixColumn( m, index ) {
+
+ return this.fromArray( m.elements, index * 4 );
+
+ }
+
+ /**
+ * Sets the vector components from the specified matrix column.
+ *
+ * @param {Matrix3} m - The 3x3 matrix.
+ * @param {number} index - The column index.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromMatrix3Column( m, index ) {
+
+ return this.fromArray( m.elements, index * 3 );
+
+ }
+
+ /**
+ * Sets the vector components from the given Euler angles.
+ *
+ * @param {Euler} e - The Euler angles to set.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromEuler( e ) {
+
+ this.x = e._x;
+ this.y = e._y;
+ this.z = e._z;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components from the RGB components of the
+ * given color.
+ *
+ * @param {Color} c - The color to set.
+ * @return {Vector3} A reference to this vector.
+ */
+ setFromColor( c ) {
+
+ this.x = c.r;
+ this.y = c.g;
+ this.z = c.b;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this vector is equal with the given one.
+ *
+ * @param {Vector3} v - The vector to test for equality.
+ * @return {boolean} Whether this vector is equal with the given one.
+ */
+ equals( v ) {
+
+ return ( ( v.x === this.x ) && ( v.y === this.y ) && ( v.z === this.z ) );
+
+ }
+
+ /**
+ * Sets this vector's x value to be `array[ offset ]`, y value to be `array[ offset + 1 ]`
+ * and z value to be `array[ offset + 2 ]`.
+ *
+ * @param {Array} array - An array holding the vector component values.
+ * @param {number} [offset=0] - The offset into the array.
+ * @return {Vector3} A reference to this vector.
+ */
+ fromArray( array, offset = 0 ) {
+
+ this.x = array[ offset ];
+ this.y = array[ offset + 1 ];
+ this.z = array[ offset + 2 ];
+
+ return this;
+
+ }
+
+ /**
+ * Writes the components of this vector to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the vector components.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The vector components.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ array[ offset ] = this.x;
+ array[ offset + 1 ] = this.y;
+ array[ offset + 2 ] = this.z;
+
+ return array;
+
+ }
+
+ /**
+ * Sets the components of this vector from the given buffer attribute.
+ *
+ * @param {BufferAttribute} attribute - The buffer attribute holding vector data.
+ * @param {number} index - The index into the attribute.
+ * @return {Vector3} A reference to this vector.
+ */
+ fromBufferAttribute( attribute, index ) {
+
+ this.x = attribute.getX( index );
+ this.y = attribute.getY( index );
+ this.z = attribute.getZ( index );
+
+ return this;
+
+ }
+
+ /**
+ * Sets each component of this vector to a pseudo-random value between `0` and
+ * `1`, excluding `1`.
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ random() {
+
+ this.x = Math.random();
+ this.y = Math.random();
+ this.z = Math.random();
+
+ return this;
+
+ }
+
+ /**
+ * Sets this vector to a uniformly random point on a unit sphere.
+ *
+ * @return {Vector3} A reference to this vector.
+ */
+ randomDirection() {
+
+ // https://mathworld.wolfram.com/SpherePointPicking.html
+
+ const theta = Math.random() * Math.PI * 2;
+ const u = Math.random() * 2 - 1;
+ const c = Math.sqrt( 1 - u * u );
+
+ this.x = c * Math.cos( theta );
+ this.y = u;
+ this.z = c * Math.sin( theta );
+
+ return this;
+
+ }
+
+ *[ Symbol.iterator ]() {
+
+ yield this.x;
+ yield this.y;
+ yield this.z;
+
+ }
+
+}
+
+const _vector$c = /*@__PURE__*/ new Vector3();
+const _quaternion$5 = /*@__PURE__*/ new Quaternion();
+
+/**
+ * Represents a 3x3 matrix.
+ *
+ * A Note on Row-Major and Column-Major Ordering:
+ *
+ * The constructor and {@link Matrix3#set} method take arguments in
+ * [row-major](https://en.wikipedia.org/wiki/Row-_and_column-major_order#Column-major_order)
+ * order, while internally they are stored in the {@link Matrix3#elements} array in column-major order.
+ * This means that calling:
+ * ```js
+ * const m = new THREE.Matrix();
+ * m.set( 11, 12, 13,
+ * 21, 22, 23,
+ * 31, 32, 33 );
+ * ```
+ * will result in the elements array containing:
+ * ```js
+ * m.elements = [ 11, 21, 31,
+ * 12, 22, 32,
+ * 13, 23, 33 ];
+ * ```
+ * and internally all calculations are performed using column-major ordering.
+ * However, as the actual ordering makes no difference mathematically and
+ * most people are used to thinking about matrices in row-major order, the
+ * three.js documentation shows matrices in row-major order. Just bear in
+ * mind that if you are reading the source code, you'll have to take the
+ * transpose of any matrices outlined here to make sense of the calculations.
+ */
+class Matrix3 {
+
+ static {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ Matrix3.prototype.isMatrix3 = true;
+
+ }
+
+ /**
+ * Constructs a new 3x3 matrix. The arguments are supposed to be
+ * in row-major order. If no arguments are provided, the constructor
+ * initializes the matrix as an identity matrix.
+ *
+ * @param {number} [n11] - 1-1 matrix element.
+ * @param {number} [n12] - 1-2 matrix element.
+ * @param {number} [n13] - 1-3 matrix element.
+ * @param {number} [n21] - 2-1 matrix element.
+ * @param {number} [n22] - 2-2 matrix element.
+ * @param {number} [n23] - 2-3 matrix element.
+ * @param {number} [n31] - 3-1 matrix element.
+ * @param {number} [n32] - 3-2 matrix element.
+ * @param {number} [n33] - 3-3 matrix element.
+ */
+ constructor( n11, n12, n13, n21, n22, n23, n31, n32, n33 ) {
+
+ /**
+ * A column-major list of matrix values.
+ *
+ * @type {Array}
+ */
+ this.elements = [
+
+ 1, 0, 0,
+ 0, 1, 0,
+ 0, 0, 1
+
+ ];
+
+ if ( n11 !== undefined ) {
+
+ this.set( n11, n12, n13, n21, n22, n23, n31, n32, n33 );
+
+ }
+
+ }
+
+ /**
+ * Sets the elements of the matrix.The arguments are supposed to be
+ * in row-major order.
+ *
+ * @param {number} [n11] - 1-1 matrix element.
+ * @param {number} [n12] - 1-2 matrix element.
+ * @param {number} [n13] - 1-3 matrix element.
+ * @param {number} [n21] - 2-1 matrix element.
+ * @param {number} [n22] - 2-2 matrix element.
+ * @param {number} [n23] - 2-3 matrix element.
+ * @param {number} [n31] - 3-1 matrix element.
+ * @param {number} [n32] - 3-2 matrix element.
+ * @param {number} [n33] - 3-3 matrix element.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ set( n11, n12, n13, n21, n22, n23, n31, n32, n33 ) {
+
+ const te = this.elements;
+
+ te[ 0 ] = n11; te[ 1 ] = n21; te[ 2 ] = n31;
+ te[ 3 ] = n12; te[ 4 ] = n22; te[ 5 ] = n32;
+ te[ 6 ] = n13; te[ 7 ] = n23; te[ 8 ] = n33;
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix to the 3x3 identity matrix.
+ *
+ * @return {Matrix3} A reference to this matrix.
+ */
+ identity() {
+
+ this.set(
+
+ 1, 0, 0,
+ 0, 1, 0,
+ 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Copies the values of the given matrix to this instance.
+ *
+ * @param {Matrix3} m - The matrix to copy.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ copy( m ) {
+
+ const te = this.elements;
+ const me = m.elements;
+
+ te[ 0 ] = me[ 0 ]; te[ 1 ] = me[ 1 ]; te[ 2 ] = me[ 2 ];
+ te[ 3 ] = me[ 3 ]; te[ 4 ] = me[ 4 ]; te[ 5 ] = me[ 5 ];
+ te[ 6 ] = me[ 6 ]; te[ 7 ] = me[ 7 ]; te[ 8 ] = me[ 8 ];
+
+ return this;
+
+ }
+
+ /**
+ * Extracts the basis of this matrix into the three axis vectors provided.
+ *
+ * @param {Vector3} xAxis - The basis's x axis.
+ * @param {Vector3} yAxis - The basis's y axis.
+ * @param {Vector3} zAxis - The basis's z axis.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ extractBasis( xAxis, yAxis, zAxis ) {
+
+ xAxis.setFromMatrix3Column( this, 0 );
+ yAxis.setFromMatrix3Column( this, 1 );
+ zAxis.setFromMatrix3Column( this, 2 );
+
+ return this;
+
+ }
+
+ /**
+ * Set this matrix to the upper 3x3 matrix of the given 4x4 matrix.
+ *
+ * @param {Matrix4} m - The 4x4 matrix.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ setFromMatrix4( m ) {
+
+ const me = m.elements;
+
+ this.set(
+
+ me[ 0 ], me[ 4 ], me[ 8 ],
+ me[ 1 ], me[ 5 ], me[ 9 ],
+ me[ 2 ], me[ 6 ], me[ 10 ]
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Post-multiplies this matrix by the given 3x3 matrix.
+ *
+ * @param {Matrix3} m - The matrix to multiply with.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ multiply( m ) {
+
+ return this.multiplyMatrices( this, m );
+
+ }
+
+ /**
+ * Pre-multiplies this matrix by the given 3x3 matrix.
+ *
+ * @param {Matrix3} m - The matrix to multiply with.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ premultiply( m ) {
+
+ return this.multiplyMatrices( m, this );
+
+ }
+
+ /**
+ * Multiples the given 3x3 matrices and stores the result
+ * in this matrix.
+ *
+ * @param {Matrix3} a - The first matrix.
+ * @param {Matrix3} b - The second matrix.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ multiplyMatrices( a, b ) {
+
+ const ae = a.elements;
+ const be = b.elements;
+ const te = this.elements;
+
+ const a11 = ae[ 0 ], a12 = ae[ 3 ], a13 = ae[ 6 ];
+ const a21 = ae[ 1 ], a22 = ae[ 4 ], a23 = ae[ 7 ];
+ const a31 = ae[ 2 ], a32 = ae[ 5 ], a33 = ae[ 8 ];
+
+ const b11 = be[ 0 ], b12 = be[ 3 ], b13 = be[ 6 ];
+ const b21 = be[ 1 ], b22 = be[ 4 ], b23 = be[ 7 ];
+ const b31 = be[ 2 ], b32 = be[ 5 ], b33 = be[ 8 ];
+
+ te[ 0 ] = a11 * b11 + a12 * b21 + a13 * b31;
+ te[ 3 ] = a11 * b12 + a12 * b22 + a13 * b32;
+ te[ 6 ] = a11 * b13 + a12 * b23 + a13 * b33;
+
+ te[ 1 ] = a21 * b11 + a22 * b21 + a23 * b31;
+ te[ 4 ] = a21 * b12 + a22 * b22 + a23 * b32;
+ te[ 7 ] = a21 * b13 + a22 * b23 + a23 * b33;
+
+ te[ 2 ] = a31 * b11 + a32 * b21 + a33 * b31;
+ te[ 5 ] = a31 * b12 + a32 * b22 + a33 * b32;
+ te[ 8 ] = a31 * b13 + a32 * b23 + a33 * b33;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies every component of the matrix by the given scalar.
+ *
+ * @param {number} s - The scalar.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ multiplyScalar( s ) {
+
+ const te = this.elements;
+
+ te[ 0 ] *= s; te[ 3 ] *= s; te[ 6 ] *= s;
+ te[ 1 ] *= s; te[ 4 ] *= s; te[ 7 ] *= s;
+ te[ 2 ] *= s; te[ 5 ] *= s; te[ 8 ] *= s;
+
+ return this;
+
+ }
+
+ /**
+ * Computes and returns the determinant of this matrix.
+ *
+ * @return {number} The determinant.
+ */
+ determinant() {
+
+ const te = this.elements;
+
+ const a = te[ 0 ], b = te[ 1 ], c = te[ 2 ],
+ d = te[ 3 ], e = te[ 4 ], f = te[ 5 ],
+ g = te[ 6 ], h = te[ 7 ], i = te[ 8 ];
+
+ return a * e * i - a * f * h - b * d * i + b * f * g + c * d * h - c * e * g;
+
+ }
+
+ /**
+ * Inverts this matrix, using the [analytic method](https://en.wikipedia.org/wiki/Invertible_matrix#Analytic_solution).
+ * You can not invert with a determinant of zero. If you attempt this, the method produces
+ * a zero matrix instead.
+ *
+ * @return {Matrix3} A reference to this matrix.
+ */
+ invert() {
+
+ const te = this.elements,
+
+ n11 = te[ 0 ], n21 = te[ 1 ], n31 = te[ 2 ],
+ n12 = te[ 3 ], n22 = te[ 4 ], n32 = te[ 5 ],
+ n13 = te[ 6 ], n23 = te[ 7 ], n33 = te[ 8 ],
+
+ t11 = n33 * n22 - n32 * n23,
+ t12 = n32 * n13 - n33 * n12,
+ t13 = n23 * n12 - n22 * n13,
+
+ det = n11 * t11 + n21 * t12 + n31 * t13;
+
+ if ( det === 0 ) return this.set( 0, 0, 0, 0, 0, 0, 0, 0, 0 );
+
+ const detInv = 1 / det;
+
+ te[ 0 ] = t11 * detInv;
+ te[ 1 ] = ( n31 * n23 - n33 * n21 ) * detInv;
+ te[ 2 ] = ( n32 * n21 - n31 * n22 ) * detInv;
+
+ te[ 3 ] = t12 * detInv;
+ te[ 4 ] = ( n33 * n11 - n31 * n13 ) * detInv;
+ te[ 5 ] = ( n31 * n12 - n32 * n11 ) * detInv;
+
+ te[ 6 ] = t13 * detInv;
+ te[ 7 ] = ( n21 * n13 - n23 * n11 ) * detInv;
+ te[ 8 ] = ( n22 * n11 - n21 * n12 ) * detInv;
+
+ return this;
+
+ }
+
+ /**
+ * Transposes this matrix in place.
+ *
+ * @return {Matrix3} A reference to this matrix.
+ */
+ transpose() {
+
+ let tmp;
+ const m = this.elements;
+
+ tmp = m[ 1 ]; m[ 1 ] = m[ 3 ]; m[ 3 ] = tmp;
+ tmp = m[ 2 ]; m[ 2 ] = m[ 6 ]; m[ 6 ] = tmp;
+ tmp = m[ 5 ]; m[ 5 ] = m[ 7 ]; m[ 7 ] = tmp;
+
+ return this;
+
+ }
+
+ /**
+ * Computes the normal matrix which is the inverse transpose of the upper
+ * left 3x3 portion of the given 4x4 matrix.
+ *
+ * @param {Matrix4} matrix4 - The 4x4 matrix.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ getNormalMatrix( matrix4 ) {
+
+ return this.setFromMatrix4( matrix4 ).invert().transpose();
+
+ }
+
+ /**
+ * Transposes this matrix into the supplied array, and returns itself unchanged.
+ *
+ * @param {Array} r - An array to store the transposed matrix elements.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ transposeIntoArray( r ) {
+
+ const m = this.elements;
+
+ r[ 0 ] = m[ 0 ];
+ r[ 1 ] = m[ 3 ];
+ r[ 2 ] = m[ 6 ];
+ r[ 3 ] = m[ 1 ];
+ r[ 4 ] = m[ 4 ];
+ r[ 5 ] = m[ 7 ];
+ r[ 6 ] = m[ 2 ];
+ r[ 7 ] = m[ 5 ];
+ r[ 8 ] = m[ 8 ];
+
+ return this;
+
+ }
+
+ /**
+ * Sets the UV transform matrix from offset, repeat, rotation, and center.
+ *
+ * @param {number} tx - Offset x.
+ * @param {number} ty - Offset y.
+ * @param {number} sx - Repeat x.
+ * @param {number} sy - Repeat y.
+ * @param {number} rotation - Rotation, in radians. Positive values rotate counterclockwise.
+ * @param {number} cx - Center x of rotation.
+ * @param {number} cy - Center y of rotation
+ * @return {Matrix3} A reference to this matrix.
+ */
+ setUvTransform( tx, ty, sx, sy, rotation, cx, cy ) {
+
+ const c = Math.cos( rotation );
+ const s = Math.sin( rotation );
+
+ this.set(
+ sx * c, sx * s, - sx * ( c * cx + s * cy ) + cx + tx,
+ - sy * s, sy * c, - sy * ( - s * cx + c * cy ) + cy + ty,
+ 0, 0, 1
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Scales this matrix with the given scalar values.
+ *
+ * @deprecated
+ * @param {number} sx - The amount to scale in the X axis.
+ * @param {number} sy - The amount to scale in the Y axis.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ scale( sx, sy ) {
+
+ warnOnce( 'Matrix3: .scale() is deprecated. Use .makeScale() instead.' ); // @deprecated r185
+
+ this.premultiply( _m3.makeScale( sx, sy ) );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates this matrix by the given angle.
+ *
+ * @deprecated
+ * @param {number} theta - The rotation in radians.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ rotate( theta ) {
+
+ warnOnce( 'Matrix3: .rotate() is deprecated. Use .makeRotation() instead.' ); // @deprecated r185
+
+ this.premultiply( _m3.makeRotation( - theta ) );
+
+ return this;
+
+ }
+
+ /**
+ * Translates this matrix by the given scalar values.
+ *
+ * @deprecated
+ * @param {number} tx - The amount to translate in the X axis.
+ * @param {number} ty - The amount to translate in the Y axis.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ translate( tx, ty ) {
+
+ warnOnce( 'Matrix3: .translate() is deprecated. Use .makeTranslation() instead.' ); // @deprecated r185
+
+ this.premultiply( _m3.makeTranslation( tx, ty ) );
+
+ return this;
+
+ }
+
+ // for 2D Transforms
+
+ /**
+ * Sets this matrix as a 2D translation transform.
+ *
+ * @param {number|Vector2} x - The amount to translate in the X axis or alternatively a translation vector.
+ * @param {number} y - The amount to translate in the Y axis.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ makeTranslation( x, y ) {
+
+ if ( x.isVector2 ) {
+
+ this.set(
+
+ 1, 0, x.x,
+ 0, 1, x.y,
+ 0, 0, 1
+
+ );
+
+ } else {
+
+ this.set(
+
+ 1, 0, x,
+ 0, 1, y,
+ 0, 0, 1
+
+ );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a 2D rotational transformation.
+ *
+ * @param {number} theta - The rotation in radians.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ makeRotation( theta ) {
+
+ // counterclockwise
+
+ const c = Math.cos( theta );
+ const s = Math.sin( theta );
+
+ this.set(
+
+ c, - s, 0,
+ s, c, 0,
+ 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a 2D scale transform.
+ *
+ * @param {number} x - The amount to scale in the X axis.
+ * @param {number} y - The amount to scale in the Y axis.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ makeScale( x, y ) {
+
+ this.set(
+
+ x, 0, 0,
+ 0, y, 0,
+ 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this matrix is equal with the given one.
+ *
+ * @param {Matrix3} matrix - The matrix to test for equality.
+ * @return {boolean} Whether this matrix is equal with the given one.
+ */
+ equals( matrix ) {
+
+ const te = this.elements;
+ const me = matrix.elements;
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ if ( te[ i ] !== me[ i ] ) return false;
+
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Sets the elements of the matrix from the given array.
+ *
+ * @param {Array} array - The matrix elements in column-major order.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Matrix3} A reference to this matrix.
+ */
+ fromArray( array, offset = 0 ) {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.elements[ i ] = array[ i + offset ];
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Writes the elements of this matrix to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the matrix elements in column-major order.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The matrix elements in column-major order.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ const te = this.elements;
+
+ array[ offset ] = te[ 0 ];
+ array[ offset + 1 ] = te[ 1 ];
+ array[ offset + 2 ] = te[ 2 ];
+
+ array[ offset + 3 ] = te[ 3 ];
+ array[ offset + 4 ] = te[ 4 ];
+ array[ offset + 5 ] = te[ 5 ];
+
+ array[ offset + 6 ] = te[ 6 ];
+ array[ offset + 7 ] = te[ 7 ];
+ array[ offset + 8 ] = te[ 8 ];
+
+ return array;
+
+ }
+
+ /**
+ * Returns a matrix with copied values from this instance.
+ *
+ * @return {Matrix3} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().fromArray( this.elements );
+
+ }
+
+}
+
+const _m3 = /*@__PURE__*/ new Matrix3();
+
+const LINEAR_REC709_TO_XYZ = /*@__PURE__*/ new Matrix3().set(
+ 0.4123908, 0.3575843, 0.1804808,
+ 0.2126390, 0.7151687, 0.0721923,
+ 0.0193308, 0.1191948, 0.9505322
+);
+
+const XYZ_TO_LINEAR_REC709 = /*@__PURE__*/ new Matrix3().set(
+ 3.2409699, -1.5373832, -0.4986108,
+ -0.9692436, 1.8759675, 0.0415551,
+ 0.0556301, -0.203977, 1.0569715
+);
+
+function createColorManagement() {
+
+ const ColorManagement = {
+
+ enabled: true,
+
+ workingColorSpace: LinearSRGBColorSpace,
+
+ /**
+ * Implementations of supported color spaces.
+ *
+ * Required:
+ * - primaries: chromaticity coordinates [ rx ry gx gy bx by ]
+ * - whitePoint: reference white [ x y ]
+ * - transfer: transfer function (pre-defined)
+ * - toXYZ: Matrix3 RGB to XYZ transform
+ * - fromXYZ: Matrix3 XYZ to RGB transform
+ * - luminanceCoefficients: RGB luminance coefficients
+ *
+ * Optional:
+ * - outputColorSpaceConfig: { drawingBufferColorSpace: ColorSpace, toneMappingMode: 'extended' | 'standard' }
+ * - workingColorSpaceConfig: { unpackColorSpace: ColorSpace }
+ *
+ * Reference:
+ * - https://www.russellcottrell.com/photo/matrixCalculator.htm
+ */
+ spaces: {},
+
+ convert: function ( color, sourceColorSpace, targetColorSpace ) {
+
+ if ( this.enabled === false || sourceColorSpace === targetColorSpace || ! sourceColorSpace || ! targetColorSpace ) {
+
+ return color;
+
+ }
+
+ if ( this.spaces[ sourceColorSpace ].transfer === SRGBTransfer ) {
+
+ color.r = SRGBToLinear( color.r );
+ color.g = SRGBToLinear( color.g );
+ color.b = SRGBToLinear( color.b );
+
+ }
+
+ if ( this.spaces[ sourceColorSpace ].primaries !== this.spaces[ targetColorSpace ].primaries ) {
+
+ color.applyMatrix3( this.spaces[ sourceColorSpace ].toXYZ );
+ color.applyMatrix3( this.spaces[ targetColorSpace ].fromXYZ );
+
+ }
+
+ if ( this.spaces[ targetColorSpace ].transfer === SRGBTransfer ) {
+
+ color.r = LinearToSRGB( color.r );
+ color.g = LinearToSRGB( color.g );
+ color.b = LinearToSRGB( color.b );
+
+ }
+
+ return color;
+
+ },
+
+ workingToColorSpace: function ( color, targetColorSpace ) {
+
+ return this.convert( color, this.workingColorSpace, targetColorSpace );
+
+ },
+
+ colorSpaceToWorking: function ( color, sourceColorSpace ) {
+
+ return this.convert( color, sourceColorSpace, this.workingColorSpace );
+
+ },
+
+ getPrimaries: function ( colorSpace ) {
+
+ return this.spaces[ colorSpace ].primaries;
+
+ },
+
+ getTransfer: function ( colorSpace ) {
+
+ if ( colorSpace === NoColorSpace ) return LinearTransfer;
+
+ return this.spaces[ colorSpace ].transfer;
+
+ },
+
+ getToneMappingMode: function ( colorSpace ) {
+
+ return this.spaces[ colorSpace ].outputColorSpaceConfig.toneMappingMode || 'standard';
+
+ },
+
+ getLuminanceCoefficients: function ( target, colorSpace = this.workingColorSpace ) {
+
+ return target.fromArray( this.spaces[ colorSpace ].luminanceCoefficients );
+
+ },
+
+ define: function ( colorSpaces ) {
+
+ Object.assign( this.spaces, colorSpaces );
+
+ },
+
+ // Internal APIs
+
+ _getMatrix: function ( targetMatrix, sourceColorSpace, targetColorSpace ) {
+
+ return targetMatrix
+ .copy( this.spaces[ sourceColorSpace ].toXYZ )
+ .multiply( this.spaces[ targetColorSpace ].fromXYZ );
+
+ },
+
+ _getDrawingBufferColorSpace: function ( colorSpace ) {
+
+ return this.spaces[ colorSpace ].outputColorSpaceConfig.drawingBufferColorSpace;
+
+ },
+
+ _getUnpackColorSpace: function ( colorSpace = this.workingColorSpace ) {
+
+ return this.spaces[ colorSpace ].workingColorSpaceConfig.unpackColorSpace;
+
+ },
+
+ // Deprecated
+
+ fromWorkingColorSpace: function ( color, targetColorSpace ) {
+
+ warnOnce( 'ColorManagement: .fromWorkingColorSpace() has been renamed to .workingToColorSpace().' ); // @deprecated, r177
+
+ return ColorManagement.workingToColorSpace( color, targetColorSpace );
+
+ },
+
+ toWorkingColorSpace: function ( color, sourceColorSpace ) {
+
+ warnOnce( 'ColorManagement: .toWorkingColorSpace() has been renamed to .colorSpaceToWorking().' ); // @deprecated, r177
+
+ return ColorManagement.colorSpaceToWorking( color, sourceColorSpace );
+
+ },
+
+ };
+
+ /******************************************************************************
+ * sRGB definitions
+ */
+
+ const REC709_PRIMARIES = [ 0.640, 0.330, 0.300, 0.600, 0.150, 0.060 ];
+ const REC709_LUMINANCE_COEFFICIENTS = [ 0.2126, 0.7152, 0.0722 ];
+ const D65 = [ 0.3127, 0.3290 ];
+
+ ColorManagement.define( {
+
+ [ LinearSRGBColorSpace ]: {
+ primaries: REC709_PRIMARIES,
+ whitePoint: D65,
+ transfer: LinearTransfer,
+ toXYZ: LINEAR_REC709_TO_XYZ,
+ fromXYZ: XYZ_TO_LINEAR_REC709,
+ luminanceCoefficients: REC709_LUMINANCE_COEFFICIENTS,
+ workingColorSpaceConfig: { unpackColorSpace: SRGBColorSpace },
+ outputColorSpaceConfig: { drawingBufferColorSpace: SRGBColorSpace }
+ },
+
+ [ SRGBColorSpace ]: {
+ primaries: REC709_PRIMARIES,
+ whitePoint: D65,
+ transfer: SRGBTransfer,
+ toXYZ: LINEAR_REC709_TO_XYZ,
+ fromXYZ: XYZ_TO_LINEAR_REC709,
+ luminanceCoefficients: REC709_LUMINANCE_COEFFICIENTS,
+ outputColorSpaceConfig: { drawingBufferColorSpace: SRGBColorSpace }
+ },
+
+ } );
+
+ return ColorManagement;
+
+}
+
+const ColorManagement = /*@__PURE__*/ createColorManagement();
+
+function SRGBToLinear( c ) {
+
+ return ( c < 0.04045 ) ? c * 0.0773993808 : Math.pow( c * 0.9478672986 + 0.0521327014, 2.4 );
+
+}
+
+function LinearToSRGB( c ) {
+
+ return ( c < 0.0031308 ) ? c * 12.92 : 1.055 * ( Math.pow( c, 0.41666 ) ) - 0.055;
+
+}
+
+let _canvas;
+
+/**
+ * A class containing utility functions for images.
+ *
+ * @hideconstructor
+ */
+class ImageUtils {
+
+ /**
+ * Returns a data URI containing a representation of the given image.
+ *
+ * @param {(HTMLImageElement|HTMLCanvasElement)} image - The image object.
+ * @param {string} [type='image/png'] - Indicates the image format.
+ * @return {string} The data URI.
+ */
+ static getDataURL( image, type = 'image/png' ) {
+
+ if ( /^data:/i.test( image.src ) ) {
+
+ return image.src;
+
+ }
+
+ if ( typeof HTMLCanvasElement === 'undefined' ) {
+
+ return image.src;
+
+ }
+
+ let canvas;
+
+ if ( image instanceof HTMLCanvasElement ) {
+
+ canvas = image;
+
+ } else {
+
+ if ( _canvas === undefined ) _canvas = createElementNS( 'canvas' );
+
+ _canvas.width = image.width;
+ _canvas.height = image.height;
+
+ const context = _canvas.getContext( '2d' );
+
+ if ( image instanceof ImageData ) {
+
+ context.putImageData( image, 0, 0 );
+
+ } else {
+
+ context.drawImage( image, 0, 0, image.width, image.height );
+
+ }
+
+ canvas = _canvas;
+
+ }
+
+ return canvas.toDataURL( type );
+
+ }
+
+ /**
+ * Converts the given sRGB image data to linear color space.
+ *
+ * @param {(HTMLImageElement|HTMLCanvasElement|ImageBitmap|Object)} image - The image object.
+ * @return {HTMLCanvasElement|Object} The converted image.
+ */
+ static sRGBToLinear( image ) {
+
+ if ( ( typeof HTMLImageElement !== 'undefined' && image instanceof HTMLImageElement ) ||
+ ( typeof HTMLCanvasElement !== 'undefined' && image instanceof HTMLCanvasElement ) ||
+ ( typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap ) ) {
+
+ const canvas = createElementNS( 'canvas' );
+
+ canvas.width = image.width;
+ canvas.height = image.height;
+
+ const context = canvas.getContext( '2d' );
+ context.drawImage( image, 0, 0, image.width, image.height );
+
+ const imageData = context.getImageData( 0, 0, image.width, image.height );
+ const data = imageData.data;
+
+ for ( let i = 0; i < data.length; i ++ ) {
+
+ data[ i ] = SRGBToLinear( data[ i ] / 255 ) * 255;
+
+ }
+
+ context.putImageData( imageData, 0, 0 );
+
+ return canvas;
+
+ } else if ( image.data ) {
+
+ const data = image.data.slice( 0 );
+
+ for ( let i = 0; i < data.length; i ++ ) {
+
+ if ( data instanceof Uint8Array || data instanceof Uint8ClampedArray ) {
+
+ data[ i ] = Math.floor( SRGBToLinear( data[ i ] / 255 ) * 255 );
+
+ } else {
+
+ // assuming float
+
+ data[ i ] = SRGBToLinear( data[ i ] );
+
+ }
+
+ }
+
+ return {
+ data: data,
+ width: image.width,
+ height: image.height
+ };
+
+ } else {
+
+ warn( 'ImageUtils.sRGBToLinear(): Unsupported image type. No color space conversion applied.' );
+ return image;
+
+ }
+
+ }
+
+}
+
+let _sourceId = 0;
+
+/**
+ * Represents the data source of a texture.
+ *
+ * The main purpose of this class is to decouple the data definition from the texture
+ * definition so the same data can be used with multiple texture instances.
+ */
+class TextureSource {
+
+ /**
+ * Constructs a new texture source.
+ *
+ * @param {any} [data=null] - The data definition of a texture.
+ */
+ constructor( data = null ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isTextureSource = true;
+
+ /**
+ * The ID of the source.
+ *
+ * @name TextureSource#id
+ * @type {number}
+ * @readonly
+ */
+ Object.defineProperty( this, 'id', { value: _sourceId ++ } );
+
+ /**
+ * The UUID of the source.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ /**
+ * The data definition of a texture.
+ *
+ * @type {any}
+ */
+ this.data = data;
+
+ /**
+ * This property is only relevant when {@link TextureSource#needsUpdate} is set to `true` and
+ * provides more control on how texture data should be processed. When `dataReady` is set
+ * to `false`, the engine performs the memory allocation (if necessary) but does not transfer
+ * the data into the GPU memory.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.dataReady = true;
+
+ /**
+ * This starts at `0` and counts how many times {@link TextureSource#needsUpdate} is set to `true`.
+ *
+ * @type {number}
+ * @readonly
+ * @default 0
+ */
+ this.version = 0;
+
+ }
+
+ /**
+ * Returns the dimensions of the source into the given target vector.
+ *
+ * @param {(Vector2|Vector3)} target - The target object the result is written into.
+ * @return {(Vector2|Vector3)} The dimensions of the source.
+ */
+ getSize( target ) {
+
+ const data = this.data;
+
+ if ( ( typeof HTMLVideoElement !== 'undefined' ) && ( data instanceof HTMLVideoElement ) ) {
+
+ target.set( data.videoWidth, data.videoHeight, 0 );
+
+ } else if ( ( typeof VideoFrame !== 'undefined' ) && ( data instanceof VideoFrame ) ) {
+
+ target.set( data.displayWidth, data.displayHeight, 0 );
+
+ } else if ( data !== null ) {
+
+ target.set( data.width, data.height, data.depth || 0 );
+
+ } else {
+
+ target.set( 0, 0, 0 );
+
+ }
+
+ return target;
+
+ }
+
+ /**
+ * When the property is set to `true`, the engine allocates the memory
+ * for the texture (if necessary) and triggers the actual texture upload
+ * to the GPU next time the source is used.
+ *
+ * @type {boolean}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsUpdate( value ) {
+
+ if ( value === true ) this.version ++;
+
+ }
+
+ /**
+ * Serializes the source into JSON.
+ *
+ * @param {?(Object|string)} meta - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized source.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON( meta ) {
+
+ const isRootObject = ( meta === undefined || typeof meta === 'string' );
+
+ if ( ! isRootObject && meta.images[ this.uuid ] !== undefined ) {
+
+ return meta.images[ this.uuid ];
+
+ }
+
+ const output = {
+ uuid: this.uuid,
+ url: ''
+ };
+
+ const data = this.data;
+
+ if ( data !== null ) {
+
+ let url;
+
+ if ( Array.isArray( data ) ) {
+
+ // cube texture
+
+ url = [];
+
+ for ( let i = 0, l = data.length; i < l; i ++ ) {
+
+ if ( data[ i ].isDataTexture ) {
+
+ url.push( serializeImage( data[ i ].image ) );
+
+ } else {
+
+ url.push( serializeImage( data[ i ] ) );
+
+ }
+
+ }
+
+ } else {
+
+ // texture
+
+ url = serializeImage( data );
+
+ }
+
+ output.url = url;
+
+ }
+
+ if ( ! isRootObject ) {
+
+ meta.images[ this.uuid ] = output;
+
+ }
+
+ return output;
+
+ }
+
+}
+
+function serializeImage( image ) {
+
+ if ( ( typeof HTMLImageElement !== 'undefined' && image instanceof HTMLImageElement ) ||
+ ( typeof HTMLCanvasElement !== 'undefined' && image instanceof HTMLCanvasElement ) ||
+ ( typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap ) ) {
+
+ // default images
+
+ return ImageUtils.getDataURL( image );
+
+ } else {
+
+ if ( image.data ) {
+
+ // images of DataTexture
+
+ return {
+ data: Array.from( image.data ),
+ width: image.width,
+ height: image.height,
+ type: image.data.constructor.name
+ };
+
+ } else {
+
+ warn( 'Texture: Unable to serialize Texture.' );
+ return {};
+
+ }
+
+ }
+
+}
+
+/**
+ * @deprecated since r186. Use {@link TextureSource} instead. `Source` has been renamed to `TextureSource`.
+ */
+class Source extends TextureSource {
+
+ /**
+ * Constructs a new texture source.
+ *
+ * @param {any} [data=null] - The data definition of a texture.
+ * @deprecated since r186. Use {@link TextureSource} instead.
+ */
+ constructor( data = null ) {
+
+ warnOnce( 'Source: "Source" has been renamed to "TextureSource". Please update your code to use "THREE.TextureSource" instead.' ); // @deprecated, r186
+
+ super( data );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @deprecated since r186. Use {@link TextureSource#isTextureSource} instead.
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSource = true;
+
+ }
+
+}
+
+let _textureId = 0;
+
+const _tempVec3 = /*@__PURE__*/ new Vector3();
+
+/**
+ * Base class for all textures.
+ *
+ * Note: After the initial use of a texture, its dimensions, format, and type
+ * cannot be changed. Instead, call {@link Texture#dispose} on the texture and instantiate a new one.
+ *
+ * @augments EventDispatcher
+ */
+class Texture extends EventDispatcher {
+
+ /**
+ * Constructs a new texture.
+ *
+ * @param {?Object} [image=Texture.DEFAULT_IMAGE] - The image holding the texture data.
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearMipmapLinearFilter] - The min filter value.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ * @param {string} [colorSpace=NoColorSpace] - The color space.
+ */
+ constructor( image = Texture.DEFAULT_IMAGE, mapping = Texture.DEFAULT_MAPPING, wrapS = ClampToEdgeWrapping, wrapT = ClampToEdgeWrapping, magFilter = LinearFilter, minFilter = LinearMipmapLinearFilter, format = RGBAFormat, type = UnsignedByteType, anisotropy = Texture.DEFAULT_ANISOTROPY, colorSpace = NoColorSpace ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isTexture = true;
+
+ /**
+ * The ID of the texture.
+ *
+ * @name Texture#id
+ * @type {number}
+ * @readonly
+ */
+ Object.defineProperty( this, 'id', { value: _textureId ++ } );
+
+ /**
+ * The UUID of the texture.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ /**
+ * The name of the texture.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The data definition of a texture. A reference to the data source can be
+ * shared across textures. This is often useful in context of spritesheets
+ * where multiple textures render the same data but with different texture
+ * transformations.
+ *
+ * @type {TextureSource}
+ */
+ this.source = new TextureSource( image );
+
+ /**
+ * An array holding user-defined mipmaps.
+ *
+ * @type {Array}
+ */
+ this.mipmaps = [];
+
+ /**
+ * How the texture is applied to the object. The value `UVMapping`
+ * is the default, where texture or uv coordinates are used to apply the map.
+ *
+ * @type {(UVMapping|CubeReflectionMapping|CubeRefractionMapping|EquirectangularReflectionMapping|EquirectangularRefractionMapping|CubeUVReflectionMapping)}
+ * @default UVMapping
+ */
+ this.mapping = mapping;
+
+ /**
+ * Lets you select the uv attribute to map the texture to. `0` for `uv`,
+ * `1` for `uv1`, `2` for `uv2` and `3` for `uv3`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.channel = 0;
+
+ /**
+ * This defines how the texture is wrapped horizontally and corresponds to
+ * *U* in UV mapping.
+ *
+ * @type {(RepeatWrapping|ClampToEdgeWrapping|MirroredRepeatWrapping)}
+ * @default ClampToEdgeWrapping
+ */
+ this.wrapS = wrapS;
+
+ /**
+ * This defines how the texture is wrapped horizontally and corresponds to
+ * *V* in UV mapping.
+ *
+ * @type {(RepeatWrapping|ClampToEdgeWrapping|MirroredRepeatWrapping)}
+ * @default ClampToEdgeWrapping
+ */
+ this.wrapT = wrapT;
+
+ /**
+ * How the texture is sampled when a texel covers more than one pixel.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default LinearFilter
+ */
+ this.magFilter = magFilter;
+
+ /**
+ * How the texture is sampled when a texel covers less than one pixel.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default LinearMipmapLinearFilter
+ */
+ this.minFilter = minFilter;
+
+ /**
+ * The number of samples taken along the axis through the pixel that has the
+ * highest density of texels. By default, this value is `1`. A higher value
+ * gives a less blurry result than a basic mipmap, at the cost of more
+ * texture samples being used.
+ *
+ * @type {number}
+ * @default Texture.DEFAULT_ANISOTROPY
+ */
+ this.anisotropy = anisotropy;
+
+ /**
+ * The format of the texture.
+ *
+ * @type {number}
+ * @default RGBAFormat
+ */
+ this.format = format;
+
+ /**
+ * The default internal format is derived from {@link Texture#format} and {@link Texture#type} and
+ * defines how the texture data is going to be stored on the GPU.
+ *
+ * This property allows to overwrite the default format.
+ *
+ * @type {?string}
+ * @default null
+ */
+ this.internalFormat = null;
+
+ /**
+ * The data type of the texture.
+ *
+ * @type {number}
+ * @default UnsignedByteType
+ */
+ this.type = type;
+
+ /**
+ * How much a single repetition of the texture is offset from the beginning,
+ * in each direction U and V. Typical range is `0.0` to `1.0`.
+ *
+ * @type {Vector2}
+ * @default (0,0)
+ */
+ this.offset = new Vector2( 0, 0 );
+
+ /**
+ * How many times the texture is repeated across the surface, in each
+ * direction U and V. If repeat is set greater than `1` in either direction,
+ * the corresponding wrap parameter should also be set to `RepeatWrapping`
+ * or `MirroredRepeatWrapping` to achieve the desired tiling effect.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.repeat = new Vector2( 1, 1 );
+
+ /**
+ * The point around which rotation occurs. A value of `(0.5, 0.5)` corresponds
+ * to the center of the texture. Default is `(0, 0)`, the lower left.
+ *
+ * @type {Vector2}
+ * @default (0,0)
+ */
+ this.center = new Vector2( 0, 0 );
+
+ /**
+ * How much the texture is rotated around the center point, in radians.
+ * Positive values are counter-clockwise.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.rotation = 0;
+
+ /**
+ * Whether to update the texture's uv-transformation {@link Texture#matrix}
+ * from the properties {@link Texture#offset}, {@link Texture#repeat},
+ * {@link Texture#rotation}, and {@link Texture#center}.
+ *
+ * Set this to `false` if you are specifying the uv-transform matrix directly.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.matrixAutoUpdate = true;
+
+ /**
+ * The uv-transformation matrix of the texture.
+ *
+ * @type {Matrix3}
+ */
+ this.matrix = new Matrix3();
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Set this to `false` if you are creating mipmaps manually.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.generateMipmaps = true;
+
+ /**
+ * If set to `true`, the alpha channel, if present, is multiplied into the
+ * color channels when the texture is uploaded to the GPU.
+ *
+ * Note that this property has no effect when using `ImageBitmap`. You need to
+ * configure premultiply alpha on bitmap creation instead.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.premultiplyAlpha = false;
+
+ /**
+ * If set to `true`, the texture is flipped along the vertical axis when
+ * uploaded to the GPU.
+ *
+ * Note that this property has no effect when using `ImageBitmap`. You need to
+ * configure the flip on bitmap creation instead.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.flipY = true;
+
+ /**
+ * Specifies the alignment requirements for the start of each pixel row in memory.
+ * The allowable values are `1` (byte-alignment), `2` (rows aligned to even-numbered bytes),
+ * `4` (word-alignment), and `8` (rows start on double-word boundaries).
+ *
+ * @type {number}
+ * @default 4
+ */
+ this.unpackAlignment = 4; // valid values: 1, 2, 4, 8 (see http://www.khronos.org/opengles/sdk/docs/man/xhtml/glPixelStorei.xml)
+
+ /**
+ * Textures containing color data should be annotated with `SRGBColorSpace` or `LinearSRGBColorSpace`.
+ *
+ * @type {string}
+ * @default NoColorSpace
+ */
+ this.colorSpace = colorSpace;
+
+ /**
+ * An object that can be used to store custom data about the texture. It
+ * should not hold references to functions as these will not be cloned.
+ *
+ * @type {Object}
+ */
+ this.userData = {};
+
+ /**
+ * This can be used to only update a subregion or specific rows of the texture (for example, just the
+ * first 3 rows). Use the `addUpdateRange()` function to add ranges to this array.
+ *
+ * @type {Array}
+ */
+ this.updateRanges = [];
+
+ /**
+ * This starts at `0` and counts how many times {@link Texture#needsUpdate} is set to `true`.
+ *
+ * @type {number}
+ * @readonly
+ * @default 0
+ */
+ this.version = 0;
+
+ /**
+ * A callback function, called when the texture is updated (e.g., when
+ * {@link Texture#needsUpdate} has been set to true and then the texture is used).
+ *
+ * @type {?Function}
+ * @default null
+ */
+ this.onUpdate = null;
+
+ /**
+ * An optional back reference to the textures render target.
+ *
+ * @type {?(RenderTarget|WebGLRenderTarget)}
+ * @default null
+ */
+ this.renderTarget = null;
+
+ /**
+ * Indicates whether a texture belongs to a render target or not.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default false
+ */
+ this.isRenderTargetTexture = false;
+
+ /**
+ * Indicates if a texture should be handled like a texture array.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default false
+ */
+ this.isArrayTexture = image && image.depth && image.depth > 1 ? true : false;
+
+ /**
+ * Indicates whether this texture should be processed by `PMREMGenerator` or not
+ * (only relevant for render target textures).
+ *
+ * @type {number}
+ * @readonly
+ * @default 0
+ */
+ this.pmremVersion = 0;
+
+ /**
+ * Whether the texture should use one of the 16 bit integer formats which are normalized
+ * to [0, 1] or [-1, 1] (depending on signed/unsigned) when sampled.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.normalized = false;
+
+ }
+
+ /**
+ * The width of the texture in pixels.
+ */
+ get width() {
+
+ return this.source.getSize( _tempVec3 ).x;
+
+ }
+
+ /**
+ * The height of the texture in pixels.
+ */
+ get height() {
+
+ return this.source.getSize( _tempVec3 ).y;
+
+ }
+
+ /**
+ * The depth of the texture in pixels.
+ */
+ get depth() {
+
+ return this.source.getSize( _tempVec3 ).z;
+
+ }
+
+ /**
+ * The image object holding the texture data.
+ *
+ * @type {?Object}
+ */
+ get image() {
+
+ return this.source.data;
+
+ }
+
+ set image( value ) {
+
+ this.source.data = value;
+
+ }
+
+ /**
+ * Updates the texture transformation matrix from the properties {@link Texture#offset},
+ * {@link Texture#repeat}, {@link Texture#rotation}, and {@link Texture#center}.
+ */
+ updateMatrix() {
+
+ this.matrix.setUvTransform( this.offset.x, this.offset.y, this.repeat.x, this.repeat.y, this.rotation, this.center.x, this.center.y );
+
+ }
+
+ /**
+ * Adds a range of data in the data texture to be updated on the GPU.
+ *
+ * @param {number} start - Position at which to start update.
+ * @param {number} count - The number of components to update.
+ */
+ addUpdateRange( start, count ) {
+
+ this.updateRanges.push( { start, count } );
+
+ }
+
+ /**
+ * Clears the update ranges.
+ */
+ clearUpdateRanges() {
+
+ this.updateRanges.length = 0;
+
+ }
+
+ /**
+ * Returns a new texture with copied values from this instance.
+ *
+ * @return {Texture} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Copies the values of the given texture to this instance.
+ *
+ * @param {Texture} source - The texture to copy.
+ * @return {Texture} A reference to this instance.
+ */
+ copy( source ) {
+
+ this.name = source.name;
+
+ this.source = source.source;
+ this.mipmaps = source.mipmaps.slice( 0 );
+
+ this.mapping = source.mapping;
+ this.channel = source.channel;
+
+ this.wrapS = source.wrapS;
+ this.wrapT = source.wrapT;
+
+ this.magFilter = source.magFilter;
+ this.minFilter = source.minFilter;
+
+ this.anisotropy = source.anisotropy;
+
+ this.format = source.format;
+ this.internalFormat = source.internalFormat;
+ this.type = source.type;
+ this.normalized = source.normalized;
+
+ this.offset.copy( source.offset );
+ this.repeat.copy( source.repeat );
+ this.center.copy( source.center );
+ this.rotation = source.rotation;
+
+ this.matrixAutoUpdate = source.matrixAutoUpdate;
+ this.matrix.copy( source.matrix );
+
+ this.generateMipmaps = source.generateMipmaps;
+ this.premultiplyAlpha = source.premultiplyAlpha;
+ this.flipY = source.flipY;
+ this.unpackAlignment = source.unpackAlignment;
+ this.colorSpace = source.colorSpace;
+
+ this.renderTarget = source.renderTarget;
+ this.isRenderTargetTexture = source.isRenderTargetTexture;
+ this.isArrayTexture = source.isArrayTexture;
+
+ this.userData = JSON.parse( JSON.stringify( source.userData ) );
+
+ this.needsUpdate = true;
+
+ return this;
+
+ }
+
+ /**
+ * Sets this texture's properties based on `values`.
+ * @param {Object} values - A container with texture parameters.
+ */
+ setValues( values ) {
+
+ for ( const key in values ) {
+
+ const newValue = values[ key ];
+
+ if ( newValue === undefined ) {
+
+ warn( `Texture.setValues(): parameter '${ key }' has value of undefined.` );
+ continue;
+
+ }
+
+ const currentValue = this[ key ];
+
+ if ( currentValue === undefined ) {
+
+ warn( `Texture.setValues(): property '${ key }' does not exist.` );
+ continue;
+
+ }
+
+ if ( ( currentValue && newValue ) && ( currentValue.isVector2 && newValue.isVector2 ) ) {
+
+ currentValue.copy( newValue );
+
+ } else if ( ( currentValue && newValue ) && ( currentValue.isVector3 && newValue.isVector3 ) ) {
+
+ currentValue.copy( newValue );
+
+ } else if ( ( currentValue && newValue ) && ( currentValue.isMatrix3 && newValue.isMatrix3 ) ) {
+
+ currentValue.copy( newValue );
+
+ } else {
+
+ this[ key ] = newValue;
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Serializes the texture into JSON.
+ *
+ * @param {?(Object|string)} meta - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized texture.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON( meta ) {
+
+ const isRootObject = ( meta === undefined || typeof meta === 'string' );
+
+ if ( ! isRootObject && meta.textures[ this.uuid ] !== undefined ) {
+
+ return meta.textures[ this.uuid ];
+
+ }
+
+ const output = {
+
+ metadata: {
+ version: 4.7,
+ type: 'Texture',
+ generator: 'Texture.toJSON'
+ },
+
+ uuid: this.uuid,
+ name: this.name,
+
+ image: this.source.toJSON( meta ).uuid,
+
+ mapping: this.mapping,
+ channel: this.channel,
+
+ repeat: [ this.repeat.x, this.repeat.y ],
+ offset: [ this.offset.x, this.offset.y ],
+ center: [ this.center.x, this.center.y ],
+ rotation: this.rotation,
+
+ wrap: [ this.wrapS, this.wrapT ],
+
+ format: this.format,
+ internalFormat: this.internalFormat,
+ type: this.type,
+ normalized: this.normalized,
+ colorSpace: this.colorSpace,
+
+ minFilter: this.minFilter,
+ magFilter: this.magFilter,
+ anisotropy: this.anisotropy,
+
+ flipY: this.flipY,
+
+ generateMipmaps: this.generateMipmaps,
+ premultiplyAlpha: this.premultiplyAlpha,
+ unpackAlignment: this.unpackAlignment
+
+ };
+
+ if ( Object.keys( this.userData ).length > 0 ) output.userData = this.userData;
+
+ if ( ! isRootObject ) {
+
+ meta.textures[ this.uuid ] = output;
+
+ }
+
+ return output;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ *
+ * @fires Texture#dispose
+ */
+ dispose() {
+
+ /**
+ * Fires when the texture has been disposed of.
+ *
+ * @event Texture#dispose
+ * @type {Object}
+ */
+ this.dispatchEvent( { type: 'dispose' } );
+
+ }
+
+ /**
+ * Transforms the given uv vector with the textures uv transformation matrix.
+ *
+ * @param {Vector2} uv - The uv vector.
+ * @return {Vector2} The transformed uv vector.
+ */
+ transformUv( uv ) {
+
+ if ( this.mapping !== UVMapping ) return uv;
+
+ uv.applyMatrix3( this.matrix );
+
+ if ( uv.x < 0 || uv.x > 1 ) {
+
+ switch ( this.wrapS ) {
+
+ case RepeatWrapping:
+
+ uv.x = uv.x - Math.floor( uv.x );
+ break;
+
+ case ClampToEdgeWrapping:
+
+ uv.x = uv.x < 0 ? 0 : 1;
+ break;
+
+ case MirroredRepeatWrapping:
+
+ if ( Math.abs( Math.floor( uv.x ) % 2 ) === 1 ) {
+
+ uv.x = Math.ceil( uv.x ) - uv.x;
+
+ } else {
+
+ uv.x = uv.x - Math.floor( uv.x );
+
+ }
+
+ break;
+
+ }
+
+ }
+
+ if ( uv.y < 0 || uv.y > 1 ) {
+
+ switch ( this.wrapT ) {
+
+ case RepeatWrapping:
+
+ uv.y = uv.y - Math.floor( uv.y );
+ break;
+
+ case ClampToEdgeWrapping:
+
+ uv.y = uv.y < 0 ? 0 : 1;
+ break;
+
+ case MirroredRepeatWrapping:
+
+ if ( Math.abs( Math.floor( uv.y ) % 2 ) === 1 ) {
+
+ uv.y = Math.ceil( uv.y ) - uv.y;
+
+ } else {
+
+ uv.y = uv.y - Math.floor( uv.y );
+
+ }
+
+ break;
+
+ }
+
+ }
+
+ if ( this.flipY ) {
+
+ uv.y = 1 - uv.y;
+
+ }
+
+ return uv;
+
+ }
+
+ /**
+ * Setting this property to `true` indicates the engine the texture
+ * must be updated in the next render. This triggers a texture upload
+ * to the GPU and ensures correct texture parameter configuration.
+ *
+ * @type {boolean}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsUpdate( value ) {
+
+ if ( value === true ) {
+
+ this.version ++;
+ this.source.needsUpdate = true;
+
+ }
+
+ }
+
+ /**
+ * Setting this property to `true` indicates the engine the PMREM
+ * must be regenerated.
+ *
+ * @type {boolean}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsPMREMUpdate( value ) {
+
+ if ( value === true ) {
+
+ this.pmremVersion ++;
+
+ }
+
+ }
+
+}
+
+/**
+ * The default image for all textures.
+ *
+ * @static
+ * @type {?Image}
+ * @default null
+ */
+Texture.DEFAULT_IMAGE = null;
+
+/**
+ * The default mapping for all textures.
+ *
+ * @static
+ * @type {number}
+ * @default UVMapping
+ */
+Texture.DEFAULT_MAPPING = UVMapping;
+
+/**
+ * The default anisotropy value for all textures.
+ *
+ * @static
+ * @type {number}
+ * @default 1
+ */
+Texture.DEFAULT_ANISOTROPY = 1;
+
+/**
+ * Class representing a 4D vector. A 4D vector is an ordered quadruplet of numbers
+ * (labeled x, y, z and w), which can be used to represent a number of things, such as:
+ *
+ * - A point in 4D space.
+ * - A direction and length in 4D space. In three.js the length will
+ * always be the Euclidean distance(straight-line distance) from `(0, 0, 0, 0)` to `(x, y, z, w)`
+ * and the direction is also measured from `(0, 0, 0, 0)` towards `(x, y, z, w)`.
+ * - Any arbitrary ordered quadruplet of numbers.
+ *
+ * There are other things a 4D vector can be used to represent, however these
+ * are the most common uses in *three.js*.
+ *
+ * Iterating through a vector instance will yield its components `(x, y, z, w)` in
+ * the corresponding order.
+ * ```js
+ * const a = new THREE.Vector4( 0, 1, 0, 0 );
+ *
+ * //no arguments; will be initialised to (0, 0, 0, 1)
+ * const b = new THREE.Vector4( );
+ *
+ * const d = a.dot( b );
+ * ```
+ */
+class Vector4 {
+
+ static {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ Vector4.prototype.isVector4 = true;
+
+ }
+
+ /**
+ * Constructs a new 4D vector.
+ *
+ * @param {number} [x=0] - The x value of this vector.
+ * @param {number} [y=0] - The y value of this vector.
+ * @param {number} [z=0] - The z value of this vector.
+ * @param {number} [w=1] - The w value of this vector.
+ */
+ constructor( x = 0, y = 0, z = 0, w = 1 ) {
+
+ /**
+ * The x value of this vector.
+ *
+ * @type {number}
+ */
+ this.x = x;
+
+ /**
+ * The y value of this vector.
+ *
+ * @type {number}
+ */
+ this.y = y;
+
+ /**
+ * The z value of this vector.
+ *
+ * @type {number}
+ */
+ this.z = z;
+
+ /**
+ * The w value of this vector.
+ *
+ * @type {number}
+ */
+ this.w = w;
+
+ }
+
+ /**
+ * Alias for {@link Vector4#z}.
+ *
+ * @type {number}
+ */
+ get width() {
+
+ return this.z;
+
+ }
+
+ set width( value ) {
+
+ this.z = value;
+
+ }
+
+ /**
+ * Alias for {@link Vector4#w}.
+ *
+ * @type {number}
+ */
+ get height() {
+
+ return this.w;
+
+ }
+
+ set height( value ) {
+
+ this.w = value;
+
+ }
+
+ /**
+ * Sets the vector components.
+ *
+ * @param {number} x - The value of the x component.
+ * @param {number} y - The value of the y component.
+ * @param {number} z - The value of the z component.
+ * @param {number} w - The value of the w component.
+ * @return {Vector4} A reference to this vector.
+ */
+ set( x, y, z, w ) {
+
+ this.x = x;
+ this.y = y;
+ this.z = z;
+ this.w = w;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components to the same value.
+ *
+ * @param {number} scalar - The value to set for all vector components.
+ * @return {Vector4} A reference to this vector.
+ */
+ setScalar( scalar ) {
+
+ this.x = scalar;
+ this.y = scalar;
+ this.z = scalar;
+ this.w = scalar;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's x component to the given value
+ *
+ * @param {number} x - The value to set.
+ * @return {Vector4} A reference to this vector.
+ */
+ setX( x ) {
+
+ this.x = x;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's y component to the given value
+ *
+ * @param {number} y - The value to set.
+ * @return {Vector4} A reference to this vector.
+ */
+ setY( y ) {
+
+ this.y = y;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's z component to the given value
+ *
+ * @param {number} z - The value to set.
+ * @return {Vector4} A reference to this vector.
+ */
+ setZ( z ) {
+
+ this.z = z;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector's w component to the given value
+ *
+ * @param {number} w - The value to set.
+ * @return {Vector4} A reference to this vector.
+ */
+ setW( w ) {
+
+ this.w = w;
+
+ return this;
+
+ }
+
+ /**
+ * Allows to set a vector component with an index.
+ *
+ * @param {number} index - The component index. `0` equals to x, `1` equals to y,
+ * `2` equals to z, `3` equals to w.
+ * @param {number} value - The value to set.
+ * @return {Vector4} A reference to this vector.
+ */
+ setComponent( index, value ) {
+
+ switch ( index ) {
+
+ case 0: this.x = value; break;
+ case 1: this.y = value; break;
+ case 2: this.z = value; break;
+ case 3: this.w = value; break;
+ default: throw new Error( 'THREE.Vector4: index is out of range: ' + index );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the value of the vector component which matches the given index.
+ *
+ * @param {number} index - The component index. `0` equals to x, `1` equals to y,
+ * `2` equals to z, `3` equals to w.
+ * @return {number} A vector component value.
+ */
+ getComponent( index ) {
+
+ switch ( index ) {
+
+ case 0: return this.x;
+ case 1: return this.y;
+ case 2: return this.z;
+ case 3: return this.w;
+ default: throw new Error( 'THREE.Vector4: index is out of range: ' + index );
+
+ }
+
+ }
+
+ /**
+ * Returns a new vector with copied values from this instance.
+ *
+ * @return {Vector4} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor( this.x, this.y, this.z, this.w );
+
+ }
+
+ /**
+ * Copies the values of the given vector to this instance.
+ *
+ * @param {Vector3|Vector4} v - The vector to copy.
+ * @return {Vector4} A reference to this vector.
+ */
+ copy( v ) {
+
+ this.x = v.x;
+ this.y = v.y;
+ this.z = v.z;
+ this.w = ( v.w !== undefined ) ? v.w : 1;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vector to this instance.
+ *
+ * @param {Vector4} v - The vector to add.
+ * @return {Vector4} A reference to this vector.
+ */
+ add( v ) {
+
+ this.x += v.x;
+ this.y += v.y;
+ this.z += v.z;
+ this.w += v.w;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given scalar value to all components of this instance.
+ *
+ * @param {number} s - The scalar to add.
+ * @return {Vector4} A reference to this vector.
+ */
+ addScalar( s ) {
+
+ this.x += s;
+ this.y += s;
+ this.z += s;
+ this.w += s;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vectors and stores the result in this instance.
+ *
+ * @param {Vector4} a - The first vector.
+ * @param {Vector4} b - The second vector.
+ * @return {Vector4} A reference to this vector.
+ */
+ addVectors( a, b ) {
+
+ this.x = a.x + b.x;
+ this.y = a.y + b.y;
+ this.z = a.z + b.z;
+ this.w = a.w + b.w;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given vector scaled by the given factor to this instance.
+ *
+ * @param {Vector4} v - The vector.
+ * @param {number} s - The factor that scales `v`.
+ * @return {Vector4} A reference to this vector.
+ */
+ addScaledVector( v, s ) {
+
+ this.x += v.x * s;
+ this.y += v.y * s;
+ this.z += v.z * s;
+ this.w += v.w * s;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given vector from this instance.
+ *
+ * @param {Vector4} v - The vector to subtract.
+ * @return {Vector4} A reference to this vector.
+ */
+ sub( v ) {
+
+ this.x -= v.x;
+ this.y -= v.y;
+ this.z -= v.z;
+ this.w -= v.w;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given scalar value from all components of this instance.
+ *
+ * @param {number} s - The scalar to subtract.
+ * @return {Vector4} A reference to this vector.
+ */
+ subScalar( s ) {
+
+ this.x -= s;
+ this.y -= s;
+ this.z -= s;
+ this.w -= s;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the given vectors and stores the result in this instance.
+ *
+ * @param {Vector4} a - The first vector.
+ * @param {Vector4} b - The second vector.
+ * @return {Vector4} A reference to this vector.
+ */
+ subVectors( a, b ) {
+
+ this.x = a.x - b.x;
+ this.y = a.y - b.y;
+ this.z = a.z - b.z;
+ this.w = a.w - b.w;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given vector with this instance.
+ *
+ * @param {Vector4} v - The vector to multiply.
+ * @return {Vector4} A reference to this vector.
+ */
+ multiply( v ) {
+
+ this.x *= v.x;
+ this.y *= v.y;
+ this.z *= v.z;
+ this.w *= v.w;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given scalar value with all components of this instance.
+ *
+ * @param {number} scalar - The scalar to multiply.
+ * @return {Vector4} A reference to this vector.
+ */
+ multiplyScalar( scalar ) {
+
+ this.x *= scalar;
+ this.y *= scalar;
+ this.z *= scalar;
+ this.w *= scalar;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies this vector with the given 4x4 matrix.
+ *
+ * @param {Matrix4} m - The 4x4 matrix.
+ * @return {Vector4} A reference to this vector.
+ */
+ applyMatrix4( m ) {
+
+ const x = this.x, y = this.y, z = this.z, w = this.w;
+ const e = m.elements;
+
+ this.x = e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z + e[ 12 ] * w;
+ this.y = e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z + e[ 13 ] * w;
+ this.z = e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z + e[ 14 ] * w;
+ this.w = e[ 3 ] * x + e[ 7 ] * y + e[ 11 ] * z + e[ 15 ] * w;
+
+ return this;
+
+ }
+
+ /**
+ * Divides this instance by the given vector.
+ *
+ * @param {Vector4} v - The vector to divide.
+ * @return {Vector4} A reference to this vector.
+ */
+ divide( v ) {
+
+ this.x /= v.x;
+ this.y /= v.y;
+ this.z /= v.z;
+ this.w /= v.w;
+
+ return this;
+
+ }
+
+ /**
+ * Divides this vector by the given scalar.
+ *
+ * @param {number} scalar - The scalar to divide.
+ * @return {Vector4} A reference to this vector.
+ */
+ divideScalar( scalar ) {
+
+ return this.multiplyScalar( 1 / scalar );
+
+ }
+
+ /**
+ * Sets the x, y and z components of this
+ * vector to the quaternion's axis and w to the angle.
+ *
+ * @param {Quaternion} q - The Quaternion to set.
+ * @return {Vector4} A reference to this vector.
+ */
+ setAxisAngleFromQuaternion( q ) {
+
+ // q is assumed to be normalized
+
+ this.w = 2 * Math.acos( q.w );
+
+ const s = Math.sqrt( 1 - q.w * q.w );
+
+ if ( s < 0.0001 ) {
+
+ this.x = 1;
+ this.y = 0;
+ this.z = 0;
+
+ } else {
+
+ this.x = q.x / s;
+ this.y = q.y / s;
+ this.z = q.z / s;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the x, y and z components of this
+ * vector to the axis of rotation and w to the angle.
+ *
+ * @param {Matrix4} m - A 4x4 matrix of which the upper left 3x3 matrix is a pure rotation matrix.
+ * @return {Vector4} A reference to this vector.
+ */
+ setAxisAngleFromRotationMatrix( m ) {
+
+ // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled)
+
+ let angle, x, y, z; // variables for result
+ const epsilon = 0.01, // margin to allow for rounding errors
+ epsilon2 = 0.1, // margin to distinguish between 0 and 180 degrees
+
+ te = m.elements,
+
+ m11 = te[ 0 ], m12 = te[ 4 ], m13 = te[ 8 ],
+ m21 = te[ 1 ], m22 = te[ 5 ], m23 = te[ 9 ],
+ m31 = te[ 2 ], m32 = te[ 6 ], m33 = te[ 10 ];
+
+ if ( ( Math.abs( m12 - m21 ) < epsilon ) &&
+ ( Math.abs( m13 - m31 ) < epsilon ) &&
+ ( Math.abs( m23 - m32 ) < epsilon ) ) {
+
+ // singularity found
+ // first check for identity matrix which must have +1 for all terms
+ // in leading diagonal and zero in other terms
+
+ if ( ( Math.abs( m12 + m21 ) < epsilon2 ) &&
+ ( Math.abs( m13 + m31 ) < epsilon2 ) &&
+ ( Math.abs( m23 + m32 ) < epsilon2 ) &&
+ ( Math.abs( m11 + m22 + m33 - 3 ) < epsilon2 ) ) {
+
+ // this singularity is identity matrix so angle = 0
+
+ this.set( 1, 0, 0, 0 );
+
+ return this; // zero angle, arbitrary axis
+
+ }
+
+ // otherwise this singularity is angle = 180
+
+ angle = Math.PI;
+
+ const xx = ( m11 + 1 ) / 2;
+ const yy = ( m22 + 1 ) / 2;
+ const zz = ( m33 + 1 ) / 2;
+ const xy = ( m12 + m21 ) / 4;
+ const xz = ( m13 + m31 ) / 4;
+ const yz = ( m23 + m32 ) / 4;
+
+ if ( ( xx > yy ) && ( xx > zz ) ) {
+
+ // m11 is the largest diagonal term
+
+ if ( xx < epsilon ) {
+
+ x = 0;
+ y = 0.707106781;
+ z = 0.707106781;
+
+ } else {
+
+ x = Math.sqrt( xx );
+ y = xy / x;
+ z = xz / x;
+
+ }
+
+ } else if ( yy > zz ) {
+
+ // m22 is the largest diagonal term
+
+ if ( yy < epsilon ) {
+
+ x = 0.707106781;
+ y = 0;
+ z = 0.707106781;
+
+ } else {
+
+ y = Math.sqrt( yy );
+ x = xy / y;
+ z = yz / y;
+
+ }
+
+ } else {
+
+ // m33 is the largest diagonal term so base result on this
+
+ if ( zz < epsilon ) {
+
+ x = 0.707106781;
+ y = 0.707106781;
+ z = 0;
+
+ } else {
+
+ z = Math.sqrt( zz );
+ x = xz / z;
+ y = yz / z;
+
+ }
+
+ }
+
+ this.set( x, y, z, angle );
+
+ return this; // return 180 deg rotation
+
+ }
+
+ // as we have reached here there are no singularities so we can handle normally
+
+ let s = Math.sqrt( ( m32 - m23 ) * ( m32 - m23 ) +
+ ( m13 - m31 ) * ( m13 - m31 ) +
+ ( m21 - m12 ) * ( m21 - m12 ) ); // used to normalize
+
+ if ( Math.abs( s ) < 0.001 ) s = 1;
+
+ // prevent divide by zero, should not happen if matrix is orthogonal and should be
+ // caught by singularity test above, but I've left it in just in case
+
+ this.x = ( m32 - m23 ) / s;
+ this.y = ( m13 - m31 ) / s;
+ this.z = ( m21 - m12 ) / s;
+ this.w = Math.acos( ( m11 + m22 + m33 - 1 ) / 2 );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the vector components to the position elements of the
+ * given transformation matrix.
+ *
+ * @param {Matrix4} m - The 4x4 matrix.
+ * @return {Vector4} A reference to this vector.
+ */
+ setFromMatrixPosition( m ) {
+
+ const e = m.elements;
+
+ this.x = e[ 12 ];
+ this.y = e[ 13 ];
+ this.z = e[ 14 ];
+ this.w = e[ 15 ];
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x, y, z or w value is greater than the given vector's x, y, z or w
+ * value, replace that value with the corresponding min value.
+ *
+ * @param {Vector4} v - The vector.
+ * @return {Vector4} A reference to this vector.
+ */
+ min( v ) {
+
+ this.x = Math.min( this.x, v.x );
+ this.y = Math.min( this.y, v.y );
+ this.z = Math.min( this.z, v.z );
+ this.w = Math.min( this.w, v.w );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x, y, z or w value is less than the given vector's x, y, z or w
+ * value, replace that value with the corresponding max value.
+ *
+ * @param {Vector4} v - The vector.
+ * @return {Vector4} A reference to this vector.
+ */
+ max( v ) {
+
+ this.x = Math.max( this.x, v.x );
+ this.y = Math.max( this.y, v.y );
+ this.z = Math.max( this.z, v.z );
+ this.w = Math.max( this.w, v.w );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x, y, z or w value is greater than the max vector's x, y, z or w
+ * value, it is replaced by the corresponding value.
+ * If this vector's x, y, z or w value is less than the min vector's x, y, z or w value,
+ * it is replaced by the corresponding value.
+ *
+ * @param {Vector4} min - The minimum x, y and z values.
+ * @param {Vector4} max - The maximum x, y and z values in the desired range.
+ * @return {Vector4} A reference to this vector.
+ */
+ clamp( min, max ) {
+
+ // assumes min < max, componentwise
+
+ this.x = clamp( this.x, min.x, max.x );
+ this.y = clamp( this.y, min.y, max.y );
+ this.z = clamp( this.z, min.z, max.z );
+ this.w = clamp( this.w, min.w, max.w );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's x, y, z or w values are greater than the max value, they are
+ * replaced by the max value.
+ * If this vector's x, y, z or w values are less than the min value, they are
+ * replaced by the min value.
+ *
+ * @param {number} minVal - The minimum value the components will be clamped to.
+ * @param {number} maxVal - The maximum value the components will be clamped to.
+ * @return {Vector4} A reference to this vector.
+ */
+ clampScalar( minVal, maxVal ) {
+
+ this.x = clamp( this.x, minVal, maxVal );
+ this.y = clamp( this.y, minVal, maxVal );
+ this.z = clamp( this.z, minVal, maxVal );
+ this.w = clamp( this.w, minVal, maxVal );
+
+ return this;
+
+ }
+
+ /**
+ * If this vector's length is greater than the max value, it is replaced by
+ * the max value.
+ * If this vector's length is less than the min value, it is replaced by the
+ * min value.
+ *
+ * @param {number} min - The minimum value the vector length will be clamped to.
+ * @param {number} max - The maximum value the vector length will be clamped to.
+ * @return {Vector4} A reference to this vector.
+ */
+ clampLength( min, max ) {
+
+ const length = this.length();
+
+ return this.divideScalar( length || 1 ).multiplyScalar( clamp( length, min, max ) );
+
+ }
+
+ /**
+ * The components of this vector are rounded down to the nearest integer value.
+ *
+ * @return {Vector4} A reference to this vector.
+ */
+ floor() {
+
+ this.x = Math.floor( this.x );
+ this.y = Math.floor( this.y );
+ this.z = Math.floor( this.z );
+ this.w = Math.floor( this.w );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded up to the nearest integer value.
+ *
+ * @return {Vector4} A reference to this vector.
+ */
+ ceil() {
+
+ this.x = Math.ceil( this.x );
+ this.y = Math.ceil( this.y );
+ this.z = Math.ceil( this.z );
+ this.w = Math.ceil( this.w );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded to the nearest integer value
+ *
+ * @return {Vector4} A reference to this vector.
+ */
+ round() {
+
+ this.x = Math.round( this.x );
+ this.y = Math.round( this.y );
+ this.z = Math.round( this.z );
+ this.w = Math.round( this.w );
+
+ return this;
+
+ }
+
+ /**
+ * The components of this vector are rounded towards zero (up if negative,
+ * down if positive) to an integer value.
+ *
+ * @return {Vector4} A reference to this vector.
+ */
+ roundToZero() {
+
+ this.x = Math.trunc( this.x );
+ this.y = Math.trunc( this.y );
+ this.z = Math.trunc( this.z );
+ this.w = Math.trunc( this.w );
+
+ return this;
+
+ }
+
+ /**
+ * Inverts this vector - i.e. sets x = -x, y = -y, z = -z, w = -w.
+ *
+ * @return {Vector4} A reference to this vector.
+ */
+ negate() {
+
+ this.x = - this.x;
+ this.y = - this.y;
+ this.z = - this.z;
+ this.w = - this.w;
+
+ return this;
+
+ }
+
+ /**
+ * Calculates the dot product of the given vector with this instance.
+ *
+ * @param {Vector4} v - The vector to compute the dot product with.
+ * @return {number} The result of the dot product.
+ */
+ dot( v ) {
+
+ return this.x * v.x + this.y * v.y + this.z * v.z + this.w * v.w;
+
+ }
+
+ /**
+ * Computes the square of the Euclidean length (straight-line length) from
+ * (0, 0, 0, 0) to (x, y, z, w). If you are comparing the lengths of vectors, you should
+ * compare the length squared instead as it is slightly more efficient to calculate.
+ *
+ * @return {number} The square length of this vector.
+ */
+ lengthSq() {
+
+ return this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w;
+
+ }
+
+ /**
+ * Computes the Euclidean length (straight-line length) from (0, 0, 0, 0) to (x, y, z, w).
+ *
+ * @return {number} The length of this vector.
+ */
+ length() {
+
+ return Math.sqrt( this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w );
+
+ }
+
+ /**
+ * Computes the Manhattan length of this vector.
+ *
+ * @return {number} The length of this vector.
+ */
+ manhattanLength() {
+
+ return Math.abs( this.x ) + Math.abs( this.y ) + Math.abs( this.z ) + Math.abs( this.w );
+
+ }
+
+ /**
+ * Converts this vector to a unit vector - that is, sets it equal to a vector
+ * with the same direction as this one, but with a vector length of `1`.
+ *
+ * @return {Vector4} A reference to this vector.
+ */
+ normalize() {
+
+ return this.divideScalar( this.length() || 1 );
+
+ }
+
+ /**
+ * Sets this vector to a vector with the same direction as this one, but
+ * with the specified length.
+ *
+ * @param {number} length - The new length of this vector.
+ * @return {Vector4} A reference to this vector.
+ */
+ setLength( length ) {
+
+ return this.normalize().multiplyScalar( length );
+
+ }
+
+ /**
+ * Linearly interpolates between the given vector and this instance, where
+ * alpha is the percent distance along the line - alpha = 0 will be this
+ * vector, and alpha = 1 will be the given one.
+ *
+ * @param {Vector4} v - The vector to interpolate towards.
+ * @param {number} alpha - The interpolation factor, typically in the closed interval `[0, 1]`.
+ * @return {Vector4} A reference to this vector.
+ */
+ lerp( v, alpha ) {
+
+ this.x += ( v.x - this.x ) * alpha;
+ this.y += ( v.y - this.y ) * alpha;
+ this.z += ( v.z - this.z ) * alpha;
+ this.w += ( v.w - this.w ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Linearly interpolates between the given vectors, where alpha is the percent
+ * distance along the line - alpha = 0 will be first vector, and alpha = 1 will
+ * be the second one. The result is stored in this instance.
+ *
+ * @param {Vector4} v1 - The first vector.
+ * @param {Vector4} v2 - The second vector.
+ * @param {number} alpha - The interpolation factor, typically in the closed interval `[0, 1]`.
+ * @return {Vector4} A reference to this vector.
+ */
+ lerpVectors( v1, v2, alpha ) {
+
+ this.x = v1.x + ( v2.x - v1.x ) * alpha;
+ this.y = v1.y + ( v2.y - v1.y ) * alpha;
+ this.z = v1.z + ( v2.z - v1.z ) * alpha;
+ this.w = v1.w + ( v2.w - v1.w ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this vector is equal with the given one.
+ *
+ * @param {Vector4} v - The vector to test for equality.
+ * @return {boolean} Whether this vector is equal with the given one.
+ */
+ equals( v ) {
+
+ return ( ( v.x === this.x ) && ( v.y === this.y ) && ( v.z === this.z ) && ( v.w === this.w ) );
+
+ }
+
+ /**
+ * Sets this vector's x value to be `array[ offset ]`, y value to be `array[ offset + 1 ]`,
+ * z value to be `array[ offset + 2 ]`, w value to be `array[ offset + 3 ]`.
+ *
+ * @param {Array} array - An array holding the vector component values.
+ * @param {number} [offset=0] - The offset into the array.
+ * @return {Vector4} A reference to this vector.
+ */
+ fromArray( array, offset = 0 ) {
+
+ this.x = array[ offset ];
+ this.y = array[ offset + 1 ];
+ this.z = array[ offset + 2 ];
+ this.w = array[ offset + 3 ];
+
+ return this;
+
+ }
+
+ /**
+ * Writes the components of this vector to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the vector components.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The vector components.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ array[ offset ] = this.x;
+ array[ offset + 1 ] = this.y;
+ array[ offset + 2 ] = this.z;
+ array[ offset + 3 ] = this.w;
+
+ return array;
+
+ }
+
+ /**
+ * Sets the components of this vector from the given buffer attribute.
+ *
+ * @param {BufferAttribute} attribute - The buffer attribute holding vector data.
+ * @param {number} index - The index into the attribute.
+ * @return {Vector4} A reference to this vector.
+ */
+ fromBufferAttribute( attribute, index ) {
+
+ this.x = attribute.getX( index );
+ this.y = attribute.getY( index );
+ this.z = attribute.getZ( index );
+ this.w = attribute.getW( index );
+
+ return this;
+
+ }
+
+ /**
+ * Sets each component of this vector to a pseudo-random value between `0` and
+ * `1`, excluding `1`.
+ *
+ * @return {Vector4} A reference to this vector.
+ */
+ random() {
+
+ this.x = Math.random();
+ this.y = Math.random();
+ this.z = Math.random();
+ this.w = Math.random();
+
+ return this;
+
+ }
+
+ *[ Symbol.iterator ]() {
+
+ yield this.x;
+ yield this.y;
+ yield this.z;
+ yield this.w;
+
+ }
+
+}
+
+/**
+ * A render target is a buffer where the video card draws pixels for a scene
+ * that is being rendered in the background. It is used in different effects,
+ * such as applying postprocessing to a rendered image before displaying it
+ * on the screen.
+ *
+ * @augments EventDispatcher
+ */
+class RenderTarget extends EventDispatcher {
+
+ /**
+ * Render target options.
+ *
+ * @typedef {Object} RenderTarget~Options
+ * @property {boolean} [generateMipmaps=false] - Whether to generate mipmaps or not.
+ * @property {number} [magFilter=LinearFilter] - The mag filter.
+ * @property {number} [minFilter=LinearFilter] - The min filter.
+ * @property {number} [format=RGBAFormat] - The texture format.
+ * @property {number} [type=UnsignedByteType] - The texture type.
+ * @property {?string} [internalFormat=null] - The texture's internal format.
+ * @property {number} [wrapS=ClampToEdgeWrapping] - The texture's uv wrapping mode.
+ * @property {number} [wrapT=ClampToEdgeWrapping] - The texture's uv wrapping mode.
+ * @property {number} [anisotropy=1] - The texture's anisotropy value.
+ * @property {string} [colorSpace=NoColorSpace] - The texture's color space.
+ * @property {boolean} [depthBuffer=true] - Whether to allocate a depth buffer or not.
+ * @property {boolean} [stencilBuffer=false] - Whether to allocate a stencil buffer or not.
+ * @property {boolean} [resolveColorBuffer=true] - Whether to resolve the color buffer or not. Only relevant for multisampled render targets.
+ * @property {boolean} [resolveDepthBuffer=true] - Whether to resolve the depth buffer or not. Only relevant for multisampled render targets.
+ * @property {boolean} [resolveStencilBuffer=true] - Whether to resolve the stencil buffer or not. Only relevant for multisampled render targets.
+ * @property {boolean} [storeMultisampledColorBuffer=true] - Whether to store the multisampled color buffer or not. Setting to `false` saves memory bandwidth when the multisampled data are not needed after a render pass.
+ * @property {boolean} [storeMultisampledDepthBuffer=true] - Whether to store the multisampled depth buffer or not. Setting to `false` saves memory bandwidth when the multisampled data are not needed after a render pass.
+ * @property {boolean} [storeMultisampledStencilBuffer=true] - Whether to store the multisampled stencil buffer or not. Setting to `false` saves memory bandwidth when the multisampled data are not needed after a render pass.
+ * @property {?Texture} [depthTexture=null] - Reference to a depth texture.
+ * @property {number} [samples=0] - The MSAA samples count.
+ * @property {number} [count=1] - Defines the number of color attachments . Must be at least `1`.
+ * @property {number} [depth=1] - The texture depth.
+ * @property {boolean} [multiview=false] - Whether this target is used for multiview rendering (WebGL OVR_multiview2 extension).
+ * @property {boolean} [useArrayDepthTexture=false] - Whether to create the depth texture as an array texture for per-layer depth testing. This is separate from multiview so layered render targets can use array depth without the multiview extension.
+ */
+
+ /**
+ * Constructs a new render target.
+ *
+ * @param {number} [width=1] - The width of the render target.
+ * @param {number} [height=1] - The height of the render target.
+ * @param {RenderTarget~Options} [options] - The configuration object.
+ */
+ constructor( width = 1, height = 1, options = {} ) {
+
+ super();
+
+ options = Object.assign( {
+ generateMipmaps: false,
+ internalFormat: null,
+ minFilter: LinearFilter,
+ depthBuffer: true,
+ stencilBuffer: false,
+ resolveColorBuffer: true,
+ resolveDepthBuffer: true,
+ resolveStencilBuffer: true,
+ storeMultisampledColorBuffer: true,
+ storeMultisampledDepthBuffer: true,
+ storeMultisampledStencilBuffer: true,
+ depthTexture: null,
+ samples: 0,
+ count: 1,
+ depth: 1,
+ multiview: false,
+ useArrayDepthTexture: false
+ }, options );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isRenderTarget = true;
+
+ /**
+ * The width of the render target.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.width = width;
+
+ /**
+ * The height of the render target.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.height = height;
+
+ /**
+ * The depth of the render target.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.depth = options.depth;
+
+ /**
+ * A rectangular area inside the render target's viewport. Fragments that are
+ * outside the area will be discarded.
+ *
+ * @type {Vector4}
+ * @default (0,0,width,height)
+ */
+ this.scissor = new Vector4( 0, 0, width, height );
+
+ /**
+ * Indicates whether the scissor test should be enabled when rendering into
+ * this render target or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.scissorTest = false;
+
+ /**
+ * A rectangular area representing the render target's viewport.
+ *
+ * @type {Vector4}
+ * @default (0,0,width,height)
+ */
+ this.viewport = new Vector4( 0, 0, width, height );
+
+ /**
+ * An array of textures. Each color attachment is represented as a separate texture.
+ * Has at least a single entry for the default color attachment.
+ *
+ * @type {Array}
+ */
+ this.textures = [];
+
+ const image = { width: width, height: height, depth: options.depth };
+ const texture = new Texture( image );
+
+ const count = options.count;
+ for ( let i = 0; i < count; i ++ ) {
+
+ this.textures[ i ] = texture.clone();
+ this.textures[ i ].isRenderTargetTexture = true;
+ this.textures[ i ].renderTarget = this;
+
+ }
+
+ this._setTextureOptions( options );
+
+ /**
+ * Whether to allocate a depth buffer or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.depthBuffer = options.depthBuffer;
+
+ /**
+ * Whether to allocate a stencil buffer or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.stencilBuffer = options.stencilBuffer;
+
+ /**
+ * Whether to resolve the color buffer or not. When set to `false`, the color
+ * attachments do not receive the resolved (single-sampled) output of a render
+ * pass and the render target's textures are left untouched. The rendered
+ * content is then only accessible within the render pass itself.
+ *
+ * Only relevant for multisampled render targets.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.resolveColorBuffer = options.resolveColorBuffer;
+
+ /**
+ * Whether to resolve the depth buffer or not. When set to `false`, the depth
+ * texture does not receive the resolved depth output of a render pass which
+ * saves memory bandwidth. Use this setting when the depth data of a render
+ * pass are not required afterwards.
+ *
+ * Only relevant for multisampled render targets in WebGL. WebGPU does not
+ * support depth resolves; sampling the depth texture of a multisampled render
+ * target accesses the multisampled data directly, see
+ * {@link RenderTarget#storeMultisampledDepthBuffer}.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.resolveDepthBuffer = options.resolveDepthBuffer;
+
+ /**
+ * Whether to resolve the stencil buffer or not. Analogous to
+ * {@link RenderTarget#resolveDepthBuffer} but for the stencil aspect.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.resolveStencilBuffer = options.resolveStencilBuffer;
+
+ /**
+ * Whether to store the multisampled color buffer or not. When set to `false`,
+ * the multisampled data are discarded at the end of a render pass, right after
+ * they have been resolved. This saves memory bandwidth, especially on tile-based
+ * GPUs, and is the recommended setting for render targets that are fully redrawn
+ * each frame and whose output is only accessed via the resolved textures (e.g.
+ * scene passes in post-processing chains).
+ *
+ * Must be kept `true` when the multisampled data are needed after the render
+ * pass ends, e.g. when rendering into the target without clearing or when the
+ * scene contains transmissive objects which require a mid-pass framebuffer copy.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.storeMultisampledColorBuffer = options.storeMultisampledColorBuffer;
+
+ /**
+ * Whether to store the multisampled depth buffer or not. When set to `false`,
+ * the multisampled depth data are discarded at the end of a render pass which
+ * saves memory bandwidth.
+ *
+ * Must be kept `true` in WebGPU when the depth texture of a multisampled render
+ * target is sampled (e.g. by depth-based post-processing effects) since depth
+ * is read directly from the multisampled data.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.storeMultisampledDepthBuffer = options.storeMultisampledDepthBuffer;
+
+ /**
+ * Whether to store the multisampled stencil buffer or not. Analogous to
+ * {@link RenderTarget#storeMultisampledDepthBuffer} but for the stencil aspect.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.storeMultisampledStencilBuffer = options.storeMultisampledStencilBuffer;
+
+ this._depthTexture = null;
+ this.depthTexture = options.depthTexture;
+
+ /**
+ * The number of MSAA samples.
+ *
+ * A value of `0` disables MSAA.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.samples = options.samples;
+
+ /**
+ * Whether to this target is used in multiview rendering.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.multiview = options.multiview;
+
+ /**
+ * Whether to create the depth texture as an array texture for per-layer depth testing.
+ * This is separate from multiview so layered render targets can use array depth without
+ * the multiview extension.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.useArrayDepthTexture = options.useArrayDepthTexture;
+
+ }
+
+ _setTextureOptions( options = {} ) {
+
+ const values = {
+ minFilter: LinearFilter,
+ generateMipmaps: false,
+ flipY: false,
+ internalFormat: null
+ };
+
+ if ( options.mapping !== undefined ) values.mapping = options.mapping;
+ if ( options.wrapS !== undefined ) values.wrapS = options.wrapS;
+ if ( options.wrapT !== undefined ) values.wrapT = options.wrapT;
+ if ( options.wrapR !== undefined ) values.wrapR = options.wrapR;
+ if ( options.magFilter !== undefined ) values.magFilter = options.magFilter;
+ if ( options.minFilter !== undefined ) values.minFilter = options.minFilter;
+ if ( options.format !== undefined ) values.format = options.format;
+ if ( options.type !== undefined ) values.type = options.type;
+ if ( options.anisotropy !== undefined ) values.anisotropy = options.anisotropy;
+ if ( options.colorSpace !== undefined ) values.colorSpace = options.colorSpace;
+ if ( options.flipY !== undefined ) values.flipY = options.flipY;
+ if ( options.generateMipmaps !== undefined ) values.generateMipmaps = options.generateMipmaps;
+ if ( options.internalFormat !== undefined ) values.internalFormat = options.internalFormat;
+
+ for ( let i = 0; i < this.textures.length; i ++ ) {
+
+ const texture = this.textures[ i ];
+ texture.setValues( values );
+
+ }
+
+ }
+
+ /**
+ * The texture representing the default color attachment.
+ *
+ * @type {Texture}
+ */
+ get texture() {
+
+ return this.textures[ 0 ];
+
+ }
+
+ set texture( value ) {
+
+ this.textures[ 0 ] = value;
+
+ }
+
+ set depthTexture( current ) {
+
+ if ( this._depthTexture !== null && this._depthTexture.renderTarget === this ) this._depthTexture.renderTarget = null;
+ if ( current !== null && current.renderTarget === null ) current.renderTarget = this;
+
+ this._depthTexture = current;
+
+ }
+
+ /**
+ * Instead of saving the depth in a renderbuffer, a texture
+ * can be used instead which is useful for further processing
+ * e.g. in context of post-processing.
+ *
+ * @type {?DepthTexture}
+ * @default null
+ */
+ get depthTexture() {
+
+ return this._depthTexture;
+
+ }
+
+ /**
+ * Sets the size of this render target.
+ *
+ * @param {number} width - The width.
+ * @param {number} height - The height.
+ * @param {number} [depth=1] - The depth.
+ */
+ setSize( width, height, depth = 1 ) {
+
+ if ( this.width !== width || this.height !== height || this.depth !== depth ) {
+
+ this.width = width;
+ this.height = height;
+ this.depth = depth;
+
+ for ( let i = 0, il = this.textures.length; i < il; i ++ ) {
+
+ this.textures[ i ].image.width = width;
+ this.textures[ i ].image.height = height;
+ this.textures[ i ].image.depth = depth;
+
+ if ( this.textures[ i ].isData3DTexture !== true ) { // Fix for #31693
+
+ // TODO: Reconsider setting isArrayTexture flag here and in the ctor of Texture.
+ // Maybe a method `isArrayTexture()` or just a getter could replace a flag since
+ // both are evaluated on each call?
+
+ this.textures[ i ].isArrayTexture = this.textures[ i ].image.depth > 1;
+
+ }
+
+ }
+
+ this.dispose();
+
+ }
+
+ this.viewport.set( 0, 0, width, height );
+ this.scissor.set( 0, 0, width, height );
+
+ }
+
+ /**
+ * Returns a new render target with copied values from this instance.
+ *
+ * @return {RenderTarget} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Copies the settings of the given render target. This is a structural copy so
+ * no resources are shared between render targets after the copy. That includes
+ * all MRT textures and the depth texture.
+ *
+ * @param {RenderTarget} source - The render target to copy.
+ * @return {RenderTarget} A reference to this instance.
+ */
+ copy( source ) {
+
+ this.width = source.width;
+ this.height = source.height;
+ this.depth = source.depth;
+
+ this.scissor.copy( source.scissor );
+ this.scissorTest = source.scissorTest;
+
+ this.viewport.copy( source.viewport );
+
+ this.textures.length = 0;
+
+ for ( let i = 0, il = source.textures.length; i < il; i ++ ) {
+
+ this.textures[ i ] = source.textures[ i ].clone();
+ this.textures[ i ].isRenderTargetTexture = true;
+ this.textures[ i ].renderTarget = this;
+
+ // ensure image object is not shared, see #20328
+
+ const image = Object.assign( {}, source.textures[ i ].image );
+ this.textures[ i ].source = new TextureSource( image );
+
+ }
+
+ this.depthBuffer = source.depthBuffer;
+ this.stencilBuffer = source.stencilBuffer;
+
+ this.resolveColorBuffer = source.resolveColorBuffer;
+ this.resolveDepthBuffer = source.resolveDepthBuffer;
+ this.resolveStencilBuffer = source.resolveStencilBuffer;
+
+ this.storeMultisampledColorBuffer = source.storeMultisampledColorBuffer;
+ this.storeMultisampledDepthBuffer = source.storeMultisampledDepthBuffer;
+ this.storeMultisampledStencilBuffer = source.storeMultisampledStencilBuffer;
+
+ if ( source.depthTexture !== null ) {
+
+ if ( source.depthTexture.renderTarget === source ) {
+
+ const depthTexture = source.depthTexture.clone();
+ depthTexture.renderTarget = null;
+
+ this.depthTexture = depthTexture;
+
+ } else {
+
+ this.depthTexture = source.depthTexture;
+
+ }
+
+ }
+
+ this.samples = source.samples;
+ this.multiview = source.multiview;
+ this.useArrayDepthTexture = source.useArrayDepthTexture;
+
+ return this;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ *
+ * @fires RenderTarget#dispose
+ */
+ dispose() {
+
+ this.dispatchEvent( { type: 'dispose' } );
+
+ }
+
+}
+
+/**
+ * A render target used in context of {@link WebGLRenderer}.
+ *
+ * @augments RenderTarget
+ */
+class WebGLRenderTarget extends RenderTarget {
+
+ /**
+ * Constructs a new 3D render target.
+ *
+ * @param {number} [width=1] - The width of the render target.
+ * @param {number} [height=1] - The height of the render target.
+ * @param {RenderTarget~Options} [options] - The configuration object.
+ */
+ constructor( width = 1, height = 1, options = {} ) {
+
+ super( width, height, options );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isWebGLRenderTarget = true;
+
+ }
+
+}
+
+/**
+ * Creates an array of textures directly from raw buffer data.
+ *
+ * @augments Texture
+ */
+class DataArrayTexture extends Texture {
+
+ /**
+ * Constructs a new data array texture.
+ *
+ * @param {?TypedArray} [data=null] - The buffer data.
+ * @param {number} [width=1] - The width of the texture.
+ * @param {number} [height=1] - The height of the texture.
+ * @param {number} [depth=1] - The depth of the texture.
+ */
+ constructor( data = null, width = 1, height = 1, depth = 1 ) {
+
+ super( null );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isDataArrayTexture = true;
+
+ /**
+ * The image definition of a data texture.
+ *
+ * @type {{data:TypedArray,width:number,height:number,depth:number}}
+ */
+ this.image = { data, width, height, depth };
+
+ /**
+ * How the texture is sampled when a texel covers more than one pixel.
+ *
+ * Overwritten and set to `NearestFilter` by default.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default NearestFilter
+ */
+ this.magFilter = NearestFilter;
+
+ /**
+ * How the texture is sampled when a texel covers less than one pixel.
+ *
+ * Overwritten and set to `NearestFilter` by default.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default NearestFilter
+ */
+ this.minFilter = NearestFilter;
+
+ /**
+ * This defines how the texture is wrapped in the depth and corresponds to
+ * *W* in UVW mapping.
+ *
+ * @type {(RepeatWrapping|ClampToEdgeWrapping|MirroredRepeatWrapping)}
+ * @default ClampToEdgeWrapping
+ */
+ this.wrapR = ClampToEdgeWrapping;
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.generateMipmaps = false;
+
+ /**
+ * If set to `true`, the texture is flipped along the vertical axis when
+ * uploaded to the GPU.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flipY = false;
+
+ /**
+ * Specifies the alignment requirements for the start of each pixel row in memory.
+ *
+ * Overwritten and set to `1` by default.
+ *
+ * @type {boolean}
+ * @default 1
+ */
+ this.unpackAlignment = 1;
+
+ /**
+ * A set of all layers which need to be updated in the texture.
+ *
+ * @type {Set}
+ */
+ this.layerUpdates = new Set();
+
+ }
+
+ /**
+ * Copies the values of the given texture to this instance.
+ *
+ * @param {DataArrayTexture} source - The texture to copy.
+ * @return {DataArrayTexture} A reference to this instance.
+ */
+ copy( source ) {
+
+ super.copy( source );
+
+ this.wrapR = source.wrapR;
+
+ return this;
+
+ }
+
+ /**
+ * Describes that a specific layer of the texture needs to be updated.
+ * Normally when {@link Texture#needsUpdate} is set to `true`, the
+ * entire data texture array is sent to the GPU. Marking specific
+ * layers will only transmit subsets of all mipmaps associated with a
+ * specific depth in the array which is often much more performant.
+ *
+ * @param {number} layerIndex - The layer index that should be updated.
+ */
+ addLayerUpdate( layerIndex ) {
+
+ this.layerUpdates.add( layerIndex );
+
+ }
+
+ /**
+ * Resets the layer updates registry.
+ */
+ clearLayerUpdates() {
+
+ this.layerUpdates.clear();
+
+ }
+
+}
+
+/**
+ * An array render target used in context of {@link WebGLRenderer}.
+ *
+ * @augments WebGLRenderTarget
+ */
+class WebGLArrayRenderTarget extends WebGLRenderTarget {
+
+ /**
+ * Constructs a new array render target.
+ *
+ * @param {number} [width=1] - The width of the render target.
+ * @param {number} [height=1] - The height of the render target.
+ * @param {number} [depth=1] - The height of the render target.
+ * @param {RenderTarget~Options} [options] - The configuration object.
+ */
+ constructor( width = 1, height = 1, depth = 1, options = {} ) {
+
+ super( width, height, options );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isWebGLArrayRenderTarget = true;
+
+ this.depth = depth;
+
+ /**
+ * Overwritten with a different texture type.
+ *
+ * @type {DataArrayTexture}
+ */
+ this.texture = new DataArrayTexture( null, width, height, depth );
+ this._setTextureOptions( options );
+
+ this.texture.isRenderTargetTexture = true;
+
+ }
+
+}
+
+/**
+ * Creates a three-dimensional texture from raw data, with parameters to
+ * divide it into width, height, and depth.
+ *
+ * @augments Texture
+ */
+class Data3DTexture extends Texture {
+
+ /**
+ * Constructs a new data array texture.
+ *
+ * @param {?TypedArray} [data=null] - The buffer data.
+ * @param {number} [width=1] - The width of the texture.
+ * @param {number} [height=1] - The height of the texture.
+ * @param {number} [depth=1] - The depth of the texture.
+ */
+ constructor( data = null, width = 1, height = 1, depth = 1 ) {
+
+ // We're going to add .setXXX() methods for setting properties later.
+ // Users can still set in Data3DTexture directly.
+ //
+ // const texture = new THREE.Data3DTexture( data, width, height, depth );
+ // texture.anisotropy = 16;
+ //
+ // See #14839
+
+ super( null );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isData3DTexture = true;
+
+ /**
+ * The image definition of a data texture.
+ *
+ * @type {{data:TypedArray,width:number,height:number,depth:number}}
+ */
+ this.image = { data, width, height, depth };
+
+ /**
+ * How the texture is sampled when a texel covers more than one pixel.
+ *
+ * Overwritten and set to `NearestFilter` by default.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default NearestFilter
+ */
+ this.magFilter = NearestFilter;
+
+ /**
+ * How the texture is sampled when a texel covers less than one pixel.
+ *
+ * Overwritten and set to `NearestFilter` by default.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default NearestFilter
+ */
+ this.minFilter = NearestFilter;
+
+ /**
+ * This defines how the texture is wrapped in the depth and corresponds to
+ * *W* in UVW mapping.
+ *
+ * @type {(RepeatWrapping|ClampToEdgeWrapping|MirroredRepeatWrapping)}
+ * @default ClampToEdgeWrapping
+ */
+ this.wrapR = ClampToEdgeWrapping;
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.generateMipmaps = false;
+
+ /**
+ * If set to `true`, the texture is flipped along the vertical axis when
+ * uploaded to the GPU.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flipY = false;
+
+ /**
+ * Specifies the alignment requirements for the start of each pixel row in memory.
+ *
+ * Overwritten and set to `1` by default.
+ *
+ * @type {boolean}
+ * @default 1
+ */
+ this.unpackAlignment = 1;
+
+ }
+
+ /**
+ * Copies the values of the given texture to this instance.
+ *
+ * @param {Data3DTexture} source - The texture to copy.
+ * @return {Data3DTexture} A reference to this instance.
+ */
+ copy( source ) {
+
+ super.copy( source );
+
+ this.wrapR = source.wrapR;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A 3D render target used in context of {@link WebGLRenderer}.
+ *
+ * @augments WebGLRenderTarget
+ */
+class WebGL3DRenderTarget extends WebGLRenderTarget {
+
+ /**
+ * Constructs a new 3D render target.
+ *
+ * @param {number} [width=1] - The width of the render target.
+ * @param {number} [height=1] - The height of the render target.
+ * @param {number} [depth=1] - The height of the render target.
+ * @param {RenderTarget~Options} [options] - The configuration object.
+ */
+ constructor( width = 1, height = 1, depth = 1, options = {} ) {
+
+ super( width, height, options );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isWebGL3DRenderTarget = true;
+
+ this.depth = depth;
+
+ /**
+ * Overwritten with a different texture type.
+ *
+ * @type {Data3DTexture}
+ */
+ this.texture = new Data3DTexture( null, width, height, depth );
+ this._setTextureOptions( options );
+
+ this.texture.isRenderTargetTexture = true;
+
+ }
+
+}
+
+/**
+ * Represents a 4x4 matrix.
+ *
+ * The most common use of a 4x4 matrix in 3D computer graphics is as a transformation matrix.
+ * For an introduction to transformation matrices as used in WebGL, check out [this tutorial](https://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices)
+ *
+ * This allows a 3D vector representing a point in 3D space to undergo
+ * transformations such as translation, rotation, shear, scale, reflection,
+ * orthogonal or perspective projection and so on, by being multiplied by the
+ * matrix. This is known as `applying` the matrix to the vector.
+ *
+ * A Note on Row-Major and Column-Major Ordering:
+ *
+ * The constructor and {@link Matrix3#set} method take arguments in
+ * [row-major](https://en.wikipedia.org/wiki/Row-_and_column-major_order#Column-major_order)
+ * order, while internally they are stored in the {@link Matrix3#elements} array in column-major order.
+ * This means that calling:
+ * ```js
+ * const m = new THREE.Matrix4();
+ * m.set( 11, 12, 13, 14,
+ * 21, 22, 23, 24,
+ * 31, 32, 33, 34,
+ * 41, 42, 43, 44 );
+ * ```
+ * will result in the elements array containing:
+ * ```js
+ * m.elements = [ 11, 21, 31, 41,
+ * 12, 22, 32, 42,
+ * 13, 23, 33, 43,
+ * 14, 24, 34, 44 ];
+ * ```
+ * and internally all calculations are performed using column-major ordering.
+ * However, as the actual ordering makes no difference mathematically and
+ * most people are used to thinking about matrices in row-major order, the
+ * three.js documentation shows matrices in row-major order. Just bear in
+ * mind that if you are reading the source code, you'll have to take the
+ * transpose of any matrices outlined here to make sense of the calculations.
+ */
+class Matrix4 {
+
+ static {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ Matrix4.prototype.isMatrix4 = true;
+
+ }
+
+ /**
+ * Constructs a new 4x4 matrix. The arguments are supposed to be
+ * in row-major order. If no arguments are provided, the constructor
+ * initializes the matrix as an identity matrix.
+ *
+ * @param {number} [n11] - 1-1 matrix element.
+ * @param {number} [n12] - 1-2 matrix element.
+ * @param {number} [n13] - 1-3 matrix element.
+ * @param {number} [n14] - 1-4 matrix element.
+ * @param {number} [n21] - 2-1 matrix element.
+ * @param {number} [n22] - 2-2 matrix element.
+ * @param {number} [n23] - 2-3 matrix element.
+ * @param {number} [n24] - 2-4 matrix element.
+ * @param {number} [n31] - 3-1 matrix element.
+ * @param {number} [n32] - 3-2 matrix element.
+ * @param {number} [n33] - 3-3 matrix element.
+ * @param {number} [n34] - 3-4 matrix element.
+ * @param {number} [n41] - 4-1 matrix element.
+ * @param {number} [n42] - 4-2 matrix element.
+ * @param {number} [n43] - 4-3 matrix element.
+ * @param {number} [n44] - 4-4 matrix element.
+ */
+ constructor( n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44 ) {
+
+ /**
+ * A column-major list of matrix values.
+ *
+ * @type {Array}
+ */
+ this.elements = [
+
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+
+ ];
+
+ if ( n11 !== undefined ) {
+
+ this.set( n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44 );
+
+ }
+
+ }
+
+ /**
+ * Sets the elements of the matrix.The arguments are supposed to be
+ * in row-major order.
+ *
+ * @param {number} [n11] - 1-1 matrix element.
+ * @param {number} [n12] - 1-2 matrix element.
+ * @param {number} [n13] - 1-3 matrix element.
+ * @param {number} [n14] - 1-4 matrix element.
+ * @param {number} [n21] - 2-1 matrix element.
+ * @param {number} [n22] - 2-2 matrix element.
+ * @param {number} [n23] - 2-3 matrix element.
+ * @param {number} [n24] - 2-4 matrix element.
+ * @param {number} [n31] - 3-1 matrix element.
+ * @param {number} [n32] - 3-2 matrix element.
+ * @param {number} [n33] - 3-3 matrix element.
+ * @param {number} [n34] - 3-4 matrix element.
+ * @param {number} [n41] - 4-1 matrix element.
+ * @param {number} [n42] - 4-2 matrix element.
+ * @param {number} [n43] - 4-3 matrix element.
+ * @param {number} [n44] - 4-4 matrix element.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ set( n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44 ) {
+
+ const te = this.elements;
+
+ te[ 0 ] = n11; te[ 4 ] = n12; te[ 8 ] = n13; te[ 12 ] = n14;
+ te[ 1 ] = n21; te[ 5 ] = n22; te[ 9 ] = n23; te[ 13 ] = n24;
+ te[ 2 ] = n31; te[ 6 ] = n32; te[ 10 ] = n33; te[ 14 ] = n34;
+ te[ 3 ] = n41; te[ 7 ] = n42; te[ 11 ] = n43; te[ 15 ] = n44;
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix to the 4x4 identity matrix.
+ *
+ * @return {Matrix4} A reference to this matrix.
+ */
+ identity() {
+
+ this.set(
+
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Returns a matrix with copied values from this instance.
+ *
+ * @return {Matrix4} A clone of this instance.
+ */
+ clone() {
+
+ return new Matrix4().fromArray( this.elements );
+
+ }
+
+ /**
+ * Copies the values of the given matrix to this instance.
+ *
+ * @param {Matrix4} m - The matrix to copy.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ copy( m ) {
+
+ const te = this.elements;
+ const me = m.elements;
+
+ te[ 0 ] = me[ 0 ]; te[ 1 ] = me[ 1 ]; te[ 2 ] = me[ 2 ]; te[ 3 ] = me[ 3 ];
+ te[ 4 ] = me[ 4 ]; te[ 5 ] = me[ 5 ]; te[ 6 ] = me[ 6 ]; te[ 7 ] = me[ 7 ];
+ te[ 8 ] = me[ 8 ]; te[ 9 ] = me[ 9 ]; te[ 10 ] = me[ 10 ]; te[ 11 ] = me[ 11 ];
+ te[ 12 ] = me[ 12 ]; te[ 13 ] = me[ 13 ]; te[ 14 ] = me[ 14 ]; te[ 15 ] = me[ 15 ];
+
+ return this;
+
+ }
+
+ /**
+ * Copies the translation component of the given matrix
+ * into this matrix's translation component.
+ *
+ * @param {Matrix4} m - The matrix to copy the translation component.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ copyPosition( m ) {
+
+ const te = this.elements, me = m.elements;
+
+ te[ 12 ] = me[ 12 ];
+ te[ 13 ] = me[ 13 ];
+ te[ 14 ] = me[ 14 ];
+
+ return this;
+
+ }
+
+ /**
+ * Set the upper 3x3 elements of this matrix to the values of given 3x3 matrix.
+ *
+ * @param {Matrix3} m - The 3x3 matrix.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ setFromMatrix3( m ) {
+
+ const me = m.elements;
+
+ this.set(
+
+ me[ 0 ], me[ 3 ], me[ 6 ], 0,
+ me[ 1 ], me[ 4 ], me[ 7 ], 0,
+ me[ 2 ], me[ 5 ], me[ 8 ], 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Extracts the basis vectors of this matrix into the three vectors provided.
+ *
+ * @param {Vector3} xAxis - The basis's x axis.
+ * @param {Vector3} yAxis - The basis's y axis.
+ * @param {Vector3} zAxis - The basis's z axis.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ extractBasis( xAxis, yAxis, zAxis ) {
+
+ if ( this.determinantAffine() === 0 ) {
+
+ xAxis.set( 1, 0, 0 );
+ yAxis.set( 0, 1, 0 );
+ zAxis.set( 0, 0, 1 );
+
+ return this;
+
+ }
+
+ xAxis.setFromMatrixColumn( this, 0 );
+ yAxis.setFromMatrixColumn( this, 1 );
+ zAxis.setFromMatrixColumn( this, 2 );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given basis vectors to this matrix.
+ *
+ * @param {Vector3} xAxis - The basis's x axis.
+ * @param {Vector3} yAxis - The basis's y axis.
+ * @param {Vector3} zAxis - The basis's z axis.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeBasis( xAxis, yAxis, zAxis ) {
+
+ this.set(
+ xAxis.x, yAxis.x, zAxis.x, 0,
+ xAxis.y, yAxis.y, zAxis.y, 0,
+ xAxis.z, yAxis.z, zAxis.z, 0,
+ 0, 0, 0, 1
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Extracts the rotation component of the given matrix
+ * into this matrix's rotation component.
+ *
+ * Note: This method does not support reflection matrices.
+ *
+ * @param {Matrix4} m - The matrix.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ extractRotation( m ) {
+
+ if ( m.determinantAffine() === 0 ) {
+
+ return this.identity();
+
+ }
+
+ const te = this.elements;
+ const me = m.elements;
+
+ const scaleX = 1 / _v1$7.setFromMatrixColumn( m, 0 ).length();
+ const scaleY = 1 / _v1$7.setFromMatrixColumn( m, 1 ).length();
+ const scaleZ = 1 / _v1$7.setFromMatrixColumn( m, 2 ).length();
+
+ te[ 0 ] = me[ 0 ] * scaleX;
+ te[ 1 ] = me[ 1 ] * scaleX;
+ te[ 2 ] = me[ 2 ] * scaleX;
+ te[ 3 ] = 0;
+
+ te[ 4 ] = me[ 4 ] * scaleY;
+ te[ 5 ] = me[ 5 ] * scaleY;
+ te[ 6 ] = me[ 6 ] * scaleY;
+ te[ 7 ] = 0;
+
+ te[ 8 ] = me[ 8 ] * scaleZ;
+ te[ 9 ] = me[ 9 ] * scaleZ;
+ te[ 10 ] = me[ 10 ] * scaleZ;
+ te[ 11 ] = 0;
+
+ te[ 12 ] = 0;
+ te[ 13 ] = 0;
+ te[ 14 ] = 0;
+ te[ 15 ] = 1;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the rotation component (the upper left 3x3 matrix) of this matrix to
+ * the rotation specified by the given Euler angles. The rest of
+ * the matrix is set to the identity. Depending on the {@link Euler#order},
+ * there are six possible outcomes. See [this page](https://en.wikipedia.org/wiki/Euler_angles#Rotation_matrix)
+ * for a complete list.
+ *
+ * @param {Euler} euler - The Euler angles.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeRotationFromEuler( euler ) {
+
+ const te = this.elements;
+
+ const x = euler.x, y = euler.y, z = euler.z;
+ const a = Math.cos( x ), b = Math.sin( x );
+ const c = Math.cos( y ), d = Math.sin( y );
+ const e = Math.cos( z ), f = Math.sin( z );
+
+ if ( euler.order === 'XYZ' ) {
+
+ const ae = a * e, af = a * f, be = b * e, bf = b * f;
+
+ te[ 0 ] = c * e;
+ te[ 4 ] = - c * f;
+ te[ 8 ] = d;
+
+ te[ 1 ] = af + be * d;
+ te[ 5 ] = ae - bf * d;
+ te[ 9 ] = - b * c;
+
+ te[ 2 ] = bf - ae * d;
+ te[ 6 ] = be + af * d;
+ te[ 10 ] = a * c;
+
+ } else if ( euler.order === 'YXZ' ) {
+
+ const ce = c * e, cf = c * f, de = d * e, df = d * f;
+
+ te[ 0 ] = ce + df * b;
+ te[ 4 ] = de * b - cf;
+ te[ 8 ] = a * d;
+
+ te[ 1 ] = a * f;
+ te[ 5 ] = a * e;
+ te[ 9 ] = - b;
+
+ te[ 2 ] = cf * b - de;
+ te[ 6 ] = df + ce * b;
+ te[ 10 ] = a * c;
+
+ } else if ( euler.order === 'ZXY' ) {
+
+ const ce = c * e, cf = c * f, de = d * e, df = d * f;
+
+ te[ 0 ] = ce - df * b;
+ te[ 4 ] = - a * f;
+ te[ 8 ] = de + cf * b;
+
+ te[ 1 ] = cf + de * b;
+ te[ 5 ] = a * e;
+ te[ 9 ] = df - ce * b;
+
+ te[ 2 ] = - a * d;
+ te[ 6 ] = b;
+ te[ 10 ] = a * c;
+
+ } else if ( euler.order === 'ZYX' ) {
+
+ const ae = a * e, af = a * f, be = b * e, bf = b * f;
+
+ te[ 0 ] = c * e;
+ te[ 4 ] = be * d - af;
+ te[ 8 ] = ae * d + bf;
+
+ te[ 1 ] = c * f;
+ te[ 5 ] = bf * d + ae;
+ te[ 9 ] = af * d - be;
+
+ te[ 2 ] = - d;
+ te[ 6 ] = b * c;
+ te[ 10 ] = a * c;
+
+ } else if ( euler.order === 'YZX' ) {
+
+ const ac = a * c, ad = a * d, bc = b * c, bd = b * d;
+
+ te[ 0 ] = c * e;
+ te[ 4 ] = bd - ac * f;
+ te[ 8 ] = bc * f + ad;
+
+ te[ 1 ] = f;
+ te[ 5 ] = a * e;
+ te[ 9 ] = - b * e;
+
+ te[ 2 ] = - d * e;
+ te[ 6 ] = ad * f + bc;
+ te[ 10 ] = ac - bd * f;
+
+ } else if ( euler.order === 'XZY' ) {
+
+ const ac = a * c, ad = a * d, bc = b * c, bd = b * d;
+
+ te[ 0 ] = c * e;
+ te[ 4 ] = - f;
+ te[ 8 ] = d * e;
+
+ te[ 1 ] = ac * f + bd;
+ te[ 5 ] = a * e;
+ te[ 9 ] = ad * f - bc;
+
+ te[ 2 ] = bc * f - ad;
+ te[ 6 ] = b * e;
+ te[ 10 ] = bd * f + ac;
+
+ }
+
+ // bottom row
+ te[ 3 ] = 0;
+ te[ 7 ] = 0;
+ te[ 11 ] = 0;
+
+ // last column
+ te[ 12 ] = 0;
+ te[ 13 ] = 0;
+ te[ 14 ] = 0;
+ te[ 15 ] = 1;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the rotation component of this matrix to the rotation specified by
+ * the given Quaternion as outlined [here](https://en.wikipedia.org/wiki/Rotation_matrix#Quaternion)
+ * The rest of the matrix is set to the identity.
+ *
+ * @param {Quaternion} q - The Quaternion.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeRotationFromQuaternion( q ) {
+
+ return this.compose( _zero, q, _one );
+
+ }
+
+ /**
+ * Sets the rotation component of the transformation matrix, looking from `eye` towards
+ * `target`, and oriented by the up-direction.
+ *
+ * @param {Vector3} eye - The eye vector.
+ * @param {Vector3} target - The target vector.
+ * @param {Vector3} up - The up vector.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ lookAt( eye, target, up ) {
+
+ const te = this.elements;
+
+ _z.subVectors( eye, target );
+
+ if ( _z.lengthSq() === 0 ) {
+
+ // eye and target are in the same position
+
+ _z.z = 1;
+
+ }
+
+ _z.normalize();
+ _x.crossVectors( up, _z );
+
+ if ( _x.lengthSq() === 0 ) {
+
+ // up and z are parallel
+
+ if ( Math.abs( up.z ) === 1 ) {
+
+ _z.x += 0.0001;
+
+ } else {
+
+ _z.z += 0.0001;
+
+ }
+
+ _z.normalize();
+ _x.crossVectors( up, _z );
+
+ }
+
+ _x.normalize();
+ _y.crossVectors( _z, _x );
+
+ te[ 0 ] = _x.x; te[ 4 ] = _y.x; te[ 8 ] = _z.x;
+ te[ 1 ] = _x.y; te[ 5 ] = _y.y; te[ 9 ] = _z.y;
+ te[ 2 ] = _x.z; te[ 6 ] = _y.z; te[ 10 ] = _z.z;
+
+ return this;
+
+ }
+
+ /**
+ * Post-multiplies this matrix by the given 4x4 matrix.
+ *
+ * @param {Matrix4} m - The matrix to multiply with.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ multiply( m ) {
+
+ return this.multiplyMatrices( this, m );
+
+ }
+
+ /**
+ * Pre-multiplies this matrix by the given 4x4 matrix.
+ *
+ * @param {Matrix4} m - The matrix to multiply with.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ premultiply( m ) {
+
+ return this.multiplyMatrices( m, this );
+
+ }
+
+ /**
+ * Multiples the given 4x4 matrices and stores the result
+ * in this matrix.
+ *
+ * @param {Matrix4} a - The first matrix.
+ * @param {Matrix4} b - The second matrix.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ multiplyMatrices( a, b ) {
+
+ const ae = a.elements;
+ const be = b.elements;
+ const te = this.elements;
+
+ const a11 = ae[ 0 ], a12 = ae[ 4 ], a13 = ae[ 8 ], a14 = ae[ 12 ];
+ const a21 = ae[ 1 ], a22 = ae[ 5 ], a23 = ae[ 9 ], a24 = ae[ 13 ];
+ const a31 = ae[ 2 ], a32 = ae[ 6 ], a33 = ae[ 10 ], a34 = ae[ 14 ];
+ const a41 = ae[ 3 ], a42 = ae[ 7 ], a43 = ae[ 11 ], a44 = ae[ 15 ];
+
+ const b11 = be[ 0 ], b12 = be[ 4 ], b13 = be[ 8 ], b14 = be[ 12 ];
+ const b21 = be[ 1 ], b22 = be[ 5 ], b23 = be[ 9 ], b24 = be[ 13 ];
+ const b31 = be[ 2 ], b32 = be[ 6 ], b33 = be[ 10 ], b34 = be[ 14 ];
+ const b41 = be[ 3 ], b42 = be[ 7 ], b43 = be[ 11 ], b44 = be[ 15 ];
+
+ te[ 0 ] = a11 * b11 + a12 * b21 + a13 * b31 + a14 * b41;
+ te[ 4 ] = a11 * b12 + a12 * b22 + a13 * b32 + a14 * b42;
+ te[ 8 ] = a11 * b13 + a12 * b23 + a13 * b33 + a14 * b43;
+ te[ 12 ] = a11 * b14 + a12 * b24 + a13 * b34 + a14 * b44;
+
+ te[ 1 ] = a21 * b11 + a22 * b21 + a23 * b31 + a24 * b41;
+ te[ 5 ] = a21 * b12 + a22 * b22 + a23 * b32 + a24 * b42;
+ te[ 9 ] = a21 * b13 + a22 * b23 + a23 * b33 + a24 * b43;
+ te[ 13 ] = a21 * b14 + a22 * b24 + a23 * b34 + a24 * b44;
+
+ te[ 2 ] = a31 * b11 + a32 * b21 + a33 * b31 + a34 * b41;
+ te[ 6 ] = a31 * b12 + a32 * b22 + a33 * b32 + a34 * b42;
+ te[ 10 ] = a31 * b13 + a32 * b23 + a33 * b33 + a34 * b43;
+ te[ 14 ] = a31 * b14 + a32 * b24 + a33 * b34 + a34 * b44;
+
+ te[ 3 ] = a41 * b11 + a42 * b21 + a43 * b31 + a44 * b41;
+ te[ 7 ] = a41 * b12 + a42 * b22 + a43 * b32 + a44 * b42;
+ te[ 11 ] = a41 * b13 + a42 * b23 + a43 * b33 + a44 * b43;
+ te[ 15 ] = a41 * b14 + a42 * b24 + a43 * b34 + a44 * b44;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies every component of the matrix by the given scalar.
+ *
+ * @param {number} s - The scalar.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ multiplyScalar( s ) {
+
+ const te = this.elements;
+
+ te[ 0 ] *= s; te[ 4 ] *= s; te[ 8 ] *= s; te[ 12 ] *= s;
+ te[ 1 ] *= s; te[ 5 ] *= s; te[ 9 ] *= s; te[ 13 ] *= s;
+ te[ 2 ] *= s; te[ 6 ] *= s; te[ 10 ] *= s; te[ 14 ] *= s;
+ te[ 3 ] *= s; te[ 7 ] *= s; te[ 11 ] *= s; te[ 15 ] *= s;
+
+ return this;
+
+ }
+
+ /**
+ * Computes and returns the determinant of this matrix.
+ *
+ * @return {number} The determinant.
+ */
+ determinant() {
+
+ const te = this.elements;
+
+ const n11 = te[ 0 ], n12 = te[ 4 ], n13 = te[ 8 ], n14 = te[ 12 ];
+ const n21 = te[ 1 ], n22 = te[ 5 ], n23 = te[ 9 ], n24 = te[ 13 ];
+ const n31 = te[ 2 ], n32 = te[ 6 ], n33 = te[ 10 ], n34 = te[ 14 ];
+ const n41 = te[ 3 ], n42 = te[ 7 ], n43 = te[ 11 ], n44 = te[ 15 ];
+
+ const t11 = n23 * n34 - n24 * n33;
+ const t12 = n22 * n34 - n24 * n32;
+ const t13 = n22 * n33 - n23 * n32;
+
+ const t21 = n21 * n34 - n24 * n31;
+ const t22 = n21 * n33 - n23 * n31;
+ const t23 = n21 * n32 - n22 * n31;
+
+ return n11 * ( n42 * t11 - n43 * t12 + n44 * t13 ) -
+ n12 * ( n41 * t11 - n43 * t21 + n44 * t22 ) +
+ n13 * ( n41 * t12 - n42 * t21 + n44 * t23 ) -
+ n14 * ( n41 * t13 - n42 * t22 + n43 * t23 );
+
+ }
+
+ /**
+ * Computes and returns the determinant of the 4x4 matrix, but assumes the
+ * matrix is affine, saving some computations.
+ *
+ * For affine matrices (like an object's world matrix), this value equals the
+ * full 4x4 {@link Matrix4#determinant} but is cheaper to compute.
+ *
+ * Assumes the bottom row is [0, 0, 0, 1].
+ *
+ * @return {number} The determinant of the matrix.
+ */
+ determinantAffine() {
+
+ const te = this.elements;
+
+ const n11 = te[ 0 ], n12 = te[ 4 ], n13 = te[ 8 ];
+ const n21 = te[ 1 ], n22 = te[ 5 ], n23 = te[ 9 ];
+ const n31 = te[ 2 ], n32 = te[ 6 ], n33 = te[ 10 ];
+
+ return n11 * ( n22 * n33 - n23 * n32 ) -
+ n12 * ( n21 * n33 - n23 * n31 ) +
+ n13 * ( n21 * n32 - n22 * n31 );
+
+ }
+
+ /**
+ * Transposes this matrix in place.
+ *
+ * @return {Matrix4} A reference to this matrix.
+ */
+ transpose() {
+
+ const te = this.elements;
+ let tmp;
+
+ tmp = te[ 1 ]; te[ 1 ] = te[ 4 ]; te[ 4 ] = tmp;
+ tmp = te[ 2 ]; te[ 2 ] = te[ 8 ]; te[ 8 ] = tmp;
+ tmp = te[ 6 ]; te[ 6 ] = te[ 9 ]; te[ 9 ] = tmp;
+
+ tmp = te[ 3 ]; te[ 3 ] = te[ 12 ]; te[ 12 ] = tmp;
+ tmp = te[ 7 ]; te[ 7 ] = te[ 13 ]; te[ 13 ] = tmp;
+ tmp = te[ 11 ]; te[ 11 ] = te[ 14 ]; te[ 14 ] = tmp;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the position component for this matrix from the given vector,
+ * without affecting the rest of the matrix.
+ *
+ * @param {number|Vector3} x - The x component of the vector or alternatively the vector object.
+ * @param {number} y - The y component of the vector.
+ * @param {number} z - The z component of the vector.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ setPosition( x, y, z ) {
+
+ const te = this.elements;
+
+ if ( x.isVector3 ) {
+
+ te[ 12 ] = x.x;
+ te[ 13 ] = x.y;
+ te[ 14 ] = x.z;
+
+ } else {
+
+ te[ 12 ] = x;
+ te[ 13 ] = y;
+ te[ 14 ] = z;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Inverts this matrix, using the [analytic method](https://en.wikipedia.org/wiki/Invertible_matrix#Analytic_solution).
+ * You can not invert with a determinant of zero. If you attempt this, the method produces
+ * a zero matrix instead.
+ *
+ * @return {Matrix4} A reference to this matrix.
+ */
+ invert() {
+
+ // based on https://github.com/toji/gl-matrix
+ const te = this.elements,
+
+ n11 = te[ 0 ], n21 = te[ 1 ], n31 = te[ 2 ], n41 = te[ 3 ],
+ n12 = te[ 4 ], n22 = te[ 5 ], n32 = te[ 6 ], n42 = te[ 7 ],
+ n13 = te[ 8 ], n23 = te[ 9 ], n33 = te[ 10 ], n43 = te[ 11 ],
+ n14 = te[ 12 ], n24 = te[ 13 ], n34 = te[ 14 ], n44 = te[ 15 ],
+
+ t1 = n11 * n22 - n21 * n12,
+ t2 = n11 * n32 - n31 * n12,
+ t3 = n11 * n42 - n41 * n12,
+ t4 = n21 * n32 - n31 * n22,
+ t5 = n21 * n42 - n41 * n22,
+ t6 = n31 * n42 - n41 * n32,
+ t7 = n13 * n24 - n23 * n14,
+ t8 = n13 * n34 - n33 * n14,
+ t9 = n13 * n44 - n43 * n14,
+ t10 = n23 * n34 - n33 * n24,
+ t11 = n23 * n44 - n43 * n24,
+ t12 = n33 * n44 - n43 * n34;
+
+ const det = t1 * t12 - t2 * t11 + t3 * t10 + t4 * t9 - t5 * t8 + t6 * t7;
+
+ if ( det === 0 ) return this.set( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 );
+
+ const detInv = 1 / det;
+
+ te[ 0 ] = ( n22 * t12 - n32 * t11 + n42 * t10 ) * detInv;
+ te[ 1 ] = ( n31 * t11 - n21 * t12 - n41 * t10 ) * detInv;
+ te[ 2 ] = ( n24 * t6 - n34 * t5 + n44 * t4 ) * detInv;
+ te[ 3 ] = ( n33 * t5 - n23 * t6 - n43 * t4 ) * detInv;
+
+ te[ 4 ] = ( n32 * t9 - n12 * t12 - n42 * t8 ) * detInv;
+ te[ 5 ] = ( n11 * t12 - n31 * t9 + n41 * t8 ) * detInv;
+ te[ 6 ] = ( n34 * t3 - n14 * t6 - n44 * t2 ) * detInv;
+ te[ 7 ] = ( n13 * t6 - n33 * t3 + n43 * t2 ) * detInv;
+
+ te[ 8 ] = ( n12 * t11 - n22 * t9 + n42 * t7 ) * detInv;
+ te[ 9 ] = ( n21 * t9 - n11 * t11 - n41 * t7 ) * detInv;
+ te[ 10 ] = ( n14 * t5 - n24 * t3 + n44 * t1 ) * detInv;
+ te[ 11 ] = ( n23 * t3 - n13 * t5 - n43 * t1 ) * detInv;
+
+ te[ 12 ] = ( n22 * t8 - n12 * t10 - n32 * t7 ) * detInv;
+ te[ 13 ] = ( n11 * t10 - n21 * t8 + n31 * t7 ) * detInv;
+ te[ 14 ] = ( n24 * t2 - n14 * t4 - n34 * t1 ) * detInv;
+ te[ 15 ] = ( n13 * t4 - n23 * t2 + n33 * t1 ) * detInv;
+
+ return this;
+
+ }
+
+ /**
+ * Scales each of the first three columns of this matrix by the corresponding component of the given vector.
+ *
+ * @param {Vector3} v - The scale vector.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ scale( v ) {
+
+ const te = this.elements;
+ const x = v.x, y = v.y, z = v.z;
+
+ te[ 0 ] *= x; te[ 4 ] *= y; te[ 8 ] *= z;
+ te[ 1 ] *= x; te[ 5 ] *= y; te[ 9 ] *= z;
+ te[ 2 ] *= x; te[ 6 ] *= y; te[ 10 ] *= z;
+ te[ 3 ] *= x; te[ 7 ] *= y; te[ 11 ] *= z;
+
+ return this;
+
+ }
+
+ /**
+ * Gets the maximum scale value of the three axes.
+ *
+ * @return {number} The maximum scale.
+ */
+ getMaxScaleOnAxis() {
+
+ const te = this.elements;
+
+ const scaleXSq = te[ 0 ] * te[ 0 ] + te[ 1 ] * te[ 1 ] + te[ 2 ] * te[ 2 ];
+ const scaleYSq = te[ 4 ] * te[ 4 ] + te[ 5 ] * te[ 5 ] + te[ 6 ] * te[ 6 ];
+ const scaleZSq = te[ 8 ] * te[ 8 ] + te[ 9 ] * te[ 9 ] + te[ 10 ] * te[ 10 ];
+
+ return Math.sqrt( Math.max( scaleXSq, scaleYSq, scaleZSq ) );
+
+ }
+
+ /**
+ * Sets this matrix as a translation transform from the given vector.
+ *
+ * @param {number|Vector3} x - The amount to translate in the X axis or alternatively a translation vector.
+ * @param {number} y - The amount to translate in the Y axis.
+ * @param {number} z - The amount to translate in the z axis.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeTranslation( x, y, z ) {
+
+ if ( x.isVector3 ) {
+
+ this.set(
+
+ 1, 0, 0, x.x,
+ 0, 1, 0, x.y,
+ 0, 0, 1, x.z,
+ 0, 0, 0, 1
+
+ );
+
+ } else {
+
+ this.set(
+
+ 1, 0, 0, x,
+ 0, 1, 0, y,
+ 0, 0, 1, z,
+ 0, 0, 0, 1
+
+ );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a rotational transformation around the X axis by
+ * the given angle.
+ *
+ * @param {number} theta - The rotation in radians.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeRotationX( theta ) {
+
+ const c = Math.cos( theta ), s = Math.sin( theta );
+
+ this.set(
+
+ 1, 0, 0, 0,
+ 0, c, - s, 0,
+ 0, s, c, 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a rotational transformation around the Y axis by
+ * the given angle.
+ *
+ * @param {number} theta - The rotation in radians.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeRotationY( theta ) {
+
+ const c = Math.cos( theta ), s = Math.sin( theta );
+
+ this.set(
+
+ c, 0, s, 0,
+ 0, 1, 0, 0,
+ - s, 0, c, 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a rotational transformation around the Z axis by
+ * the given angle.
+ *
+ * @param {number} theta - The rotation in radians.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeRotationZ( theta ) {
+
+ const c = Math.cos( theta ), s = Math.sin( theta );
+
+ this.set(
+
+ c, - s, 0, 0,
+ s, c, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a rotational transformation around the given axis by
+ * the given angle.
+ *
+ * @param {Vector3} axis - The normalized rotation axis.
+ * @param {number} angle - The rotation in radians.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeRotationAxis( axis, angle ) {
+
+ const c = Math.cos( angle );
+ const s = Math.sin( angle );
+ const t = 1 - c;
+ const x = axis.x, y = axis.y, z = axis.z;
+ const tx = t * x, ty = t * y;
+
+ this.set(
+
+ tx * x + c, tx * y - s * z, tx * z + s * y, 0,
+ tx * y + s * z, ty * y + c, ty * z - s * x, 0,
+ tx * z - s * y, ty * z + s * x, t * z * z + c, 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a scale transformation.
+ *
+ * @param {number} x - The amount to scale in the X axis.
+ * @param {number} y - The amount to scale in the Y axis.
+ * @param {number} z - The amount to scale in the Z axis.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeScale( x, y, z ) {
+
+ this.set(
+
+ x, 0, 0, 0,
+ 0, y, 0, 0,
+ 0, 0, z, 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix as a shear transformation.
+ *
+ * @param {number} xy - The amount to shear X by Y.
+ * @param {number} xz - The amount to shear X by Z.
+ * @param {number} yx - The amount to shear Y by X.
+ * @param {number} yz - The amount to shear Y by Z.
+ * @param {number} zx - The amount to shear Z by X.
+ * @param {number} zy - The amount to shear Z by Y.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeShear( xy, xz, yx, yz, zx, zy ) {
+
+ this.set(
+
+ 1, yx, zx, 0,
+ xy, 1, zy, 0,
+ xz, yz, 1, 0,
+ 0, 0, 0, 1
+
+ );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this matrix to the transformation composed of the given position,
+ * rotation (Quaternion) and scale.
+ *
+ * @param {Vector3} position - The position vector.
+ * @param {Quaternion} quaternion - The rotation as a Quaternion.
+ * @param {Vector3} scale - The scale vector.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ compose( position, quaternion, scale ) {
+
+ const te = this.elements;
+
+ const x = quaternion._x, y = quaternion._y, z = quaternion._z, w = quaternion._w;
+ const x2 = x + x, y2 = y + y, z2 = z + z;
+ const xx = x * x2, xy = x * y2, xz = x * z2;
+ const yy = y * y2, yz = y * z2, zz = z * z2;
+ const wx = w * x2, wy = w * y2, wz = w * z2;
+
+ const sx = scale.x, sy = scale.y, sz = scale.z;
+
+ te[ 0 ] = ( 1 - ( yy + zz ) ) * sx;
+ te[ 1 ] = ( xy + wz ) * sx;
+ te[ 2 ] = ( xz - wy ) * sx;
+ te[ 3 ] = 0;
+
+ te[ 4 ] = ( xy - wz ) * sy;
+ te[ 5 ] = ( 1 - ( xx + zz ) ) * sy;
+ te[ 6 ] = ( yz + wx ) * sy;
+ te[ 7 ] = 0;
+
+ te[ 8 ] = ( xz + wy ) * sz;
+ te[ 9 ] = ( yz - wx ) * sz;
+ te[ 10 ] = ( 1 - ( xx + yy ) ) * sz;
+ te[ 11 ] = 0;
+
+ te[ 12 ] = position.x;
+ te[ 13 ] = position.y;
+ te[ 14 ] = position.z;
+ te[ 15 ] = 1;
+
+ return this;
+
+ }
+
+ /**
+ * Decomposes this matrix into its position, rotation and scale components
+ * and provides the result in the given objects.
+ *
+ * Note: Not all matrices are decomposable in this way. For example, if an
+ * object has a non-uniformly scaled parent, then the object's world matrix
+ * may not be decomposable, and this method may not be appropriate.
+ *
+ * @param {Vector3} position - The position vector.
+ * @param {Quaternion} quaternion - The rotation as a Quaternion.
+ * @param {Vector3} scale - The scale vector.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ decompose( position, quaternion, scale ) {
+
+ const te = this.elements;
+
+ position.x = te[ 12 ];
+ position.y = te[ 13 ];
+ position.z = te[ 14 ];
+
+ const det = this.determinantAffine();
+
+ if ( det === 0 ) {
+
+ scale.set( 1, 1, 1 );
+ quaternion.identity();
+
+ return this;
+
+ }
+
+ let sx = _v1$7.set( te[ 0 ], te[ 1 ], te[ 2 ] ).length();
+ const sy = _v1$7.set( te[ 4 ], te[ 5 ], te[ 6 ] ).length();
+ const sz = _v1$7.set( te[ 8 ], te[ 9 ], te[ 10 ] ).length();
+
+ // if determinant is negative, we need to invert one scale
+ if ( det < 0 ) sx = - sx;
+
+ // scale the rotation part
+ _m1$2.copy( this );
+
+ const invSX = 1 / sx;
+ const invSY = 1 / sy;
+ const invSZ = 1 / sz;
+
+ _m1$2.elements[ 0 ] *= invSX;
+ _m1$2.elements[ 1 ] *= invSX;
+ _m1$2.elements[ 2 ] *= invSX;
+
+ _m1$2.elements[ 4 ] *= invSY;
+ _m1$2.elements[ 5 ] *= invSY;
+ _m1$2.elements[ 6 ] *= invSY;
+
+ _m1$2.elements[ 8 ] *= invSZ;
+ _m1$2.elements[ 9 ] *= invSZ;
+ _m1$2.elements[ 10 ] *= invSZ;
+
+ quaternion.setFromRotationMatrix( _m1$2 );
+
+ scale.x = sx;
+ scale.y = sy;
+ scale.z = sz;
+
+ return this;
+
+ }
+
+ /**
+ * Creates a perspective projection matrix. This is used internally by
+ * {@link PerspectiveCamera#updateProjectionMatrix}.
+
+ * @param {number} left - Left boundary of the viewing frustum at the near plane.
+ * @param {number} right - Right boundary of the viewing frustum at the near plane.
+ * @param {number} top - Top boundary of the viewing frustum at the near plane.
+ * @param {number} bottom - Bottom boundary of the viewing frustum at the near plane.
+ * @param {number} near - The distance from the camera to the near plane.
+ * @param {number} far - The distance from the camera to the far plane.
+ * @param {(WebGLCoordinateSystem|WebGPUCoordinateSystem)} [coordinateSystem=WebGLCoordinateSystem] - The coordinate system.
+ * @param {boolean} [reversedDepth=false] - Whether to use a reversed depth.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makePerspective( left, right, top, bottom, near, far, coordinateSystem = WebGLCoordinateSystem, reversedDepth = false ) {
+
+ const te = this.elements;
+
+ const x = 2 * near / ( right - left );
+ const y = 2 * near / ( top - bottom );
+
+ const a = ( right + left ) / ( right - left );
+ const b = ( top + bottom ) / ( top - bottom );
+
+ let c, d;
+
+ if ( reversedDepth ) {
+
+ c = near / ( far - near );
+ d = ( far * near ) / ( far - near );
+
+ } else {
+
+ if ( coordinateSystem === WebGLCoordinateSystem ) {
+
+ c = - ( far + near ) / ( far - near );
+ d = ( -2 * far * near ) / ( far - near );
+
+ } else if ( coordinateSystem === WebGPUCoordinateSystem ) {
+
+ c = - far / ( far - near );
+ d = ( - far * near ) / ( far - near );
+
+ } else {
+
+ throw new Error( 'THREE.Matrix4.makePerspective(): Invalid coordinate system: ' + coordinateSystem );
+
+ }
+
+ }
+
+ te[ 0 ] = x; te[ 4 ] = 0; te[ 8 ] = a; te[ 12 ] = 0;
+ te[ 1 ] = 0; te[ 5 ] = y; te[ 9 ] = b; te[ 13 ] = 0;
+ te[ 2 ] = 0; te[ 6 ] = 0; te[ 10 ] = c; te[ 14 ] = d;
+ te[ 3 ] = 0; te[ 7 ] = 0; te[ 11 ] = -1; te[ 15 ] = 0;
+
+ return this;
+
+ }
+
+ /**
+ * Creates a orthographic projection matrix. This is used internally by
+ * {@link OrthographicCamera#updateProjectionMatrix}.
+
+ * @param {number} left - Left boundary of the viewing frustum at the near plane.
+ * @param {number} right - Right boundary of the viewing frustum at the near plane.
+ * @param {number} top - Top boundary of the viewing frustum at the near plane.
+ * @param {number} bottom - Bottom boundary of the viewing frustum at the near plane.
+ * @param {number} near - The distance from the camera to the near plane.
+ * @param {number} far - The distance from the camera to the far plane.
+ * @param {(WebGLCoordinateSystem|WebGPUCoordinateSystem)} [coordinateSystem=WebGLCoordinateSystem] - The coordinate system.
+ * @param {boolean} [reversedDepth=false] - Whether to use a reversed depth.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ makeOrthographic( left, right, top, bottom, near, far, coordinateSystem = WebGLCoordinateSystem, reversedDepth = false ) {
+
+ const te = this.elements;
+
+ const x = 2 / ( right - left );
+ const y = 2 / ( top - bottom );
+
+ const a = - ( right + left ) / ( right - left );
+ const b = - ( top + bottom ) / ( top - bottom );
+
+ let c, d;
+
+ if ( reversedDepth ) {
+
+ c = 1 / ( far - near );
+ d = far / ( far - near );
+
+ } else {
+
+ if ( coordinateSystem === WebGLCoordinateSystem ) {
+
+ c = -2 / ( far - near );
+ d = - ( far + near ) / ( far - near );
+
+ } else if ( coordinateSystem === WebGPUCoordinateSystem ) {
+
+ c = -1 / ( far - near );
+ d = - near / ( far - near );
+
+ } else {
+
+ throw new Error( 'THREE.Matrix4.makeOrthographic(): Invalid coordinate system: ' + coordinateSystem );
+
+ }
+
+ }
+
+ te[ 0 ] = x; te[ 4 ] = 0; te[ 8 ] = 0; te[ 12 ] = a;
+ te[ 1 ] = 0; te[ 5 ] = y; te[ 9 ] = 0; te[ 13 ] = b;
+ te[ 2 ] = 0; te[ 6 ] = 0; te[ 10 ] = c; te[ 14 ] = d;
+ te[ 3 ] = 0; te[ 7 ] = 0; te[ 11 ] = 0; te[ 15 ] = 1;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this matrix is equal with the given one.
+ *
+ * @param {Matrix4} matrix - The matrix to test for equality.
+ * @return {boolean} Whether this matrix is equal with the given one.
+ */
+ equals( matrix ) {
+
+ const te = this.elements;
+ const me = matrix.elements;
+
+ for ( let i = 0; i < 16; i ++ ) {
+
+ if ( te[ i ] !== me[ i ] ) return false;
+
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Sets the elements of the matrix from the given array.
+ *
+ * @param {Array} array - The matrix elements in column-major order.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Matrix4} A reference to this matrix.
+ */
+ fromArray( array, offset = 0 ) {
+
+ for ( let i = 0; i < 16; i ++ ) {
+
+ this.elements[ i ] = array[ i + offset ];
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Writes the elements of this matrix to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the matrix elements in column-major order.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The matrix elements in column-major order.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ const te = this.elements;
+
+ array[ offset ] = te[ 0 ];
+ array[ offset + 1 ] = te[ 1 ];
+ array[ offset + 2 ] = te[ 2 ];
+ array[ offset + 3 ] = te[ 3 ];
+
+ array[ offset + 4 ] = te[ 4 ];
+ array[ offset + 5 ] = te[ 5 ];
+ array[ offset + 6 ] = te[ 6 ];
+ array[ offset + 7 ] = te[ 7 ];
+
+ array[ offset + 8 ] = te[ 8 ];
+ array[ offset + 9 ] = te[ 9 ];
+ array[ offset + 10 ] = te[ 10 ];
+ array[ offset + 11 ] = te[ 11 ];
+
+ array[ offset + 12 ] = te[ 12 ];
+ array[ offset + 13 ] = te[ 13 ];
+ array[ offset + 14 ] = te[ 14 ];
+ array[ offset + 15 ] = te[ 15 ];
+
+ return array;
+
+ }
+
+}
+
+const _v1$7 = /*@__PURE__*/ new Vector3();
+const _m1$2 = /*@__PURE__*/ new Matrix4();
+const _zero = /*@__PURE__*/ new Vector3( 0, 0, 0 );
+const _one = /*@__PURE__*/ new Vector3( 1, 1, 1 );
+const _x = /*@__PURE__*/ new Vector3();
+const _y = /*@__PURE__*/ new Vector3();
+const _z = /*@__PURE__*/ new Vector3();
+
+const _matrix$2 = /*@__PURE__*/ new Matrix4();
+const _quaternion$4 = /*@__PURE__*/ new Quaternion();
+
+/**
+ * A class representing Euler angles.
+ *
+ * Euler angles describe a rotational transformation by rotating an object on
+ * its various axes in specified amounts per axis, and a specified axis
+ * order.
+ *
+ * Iterating through an instance will yield its components (x, y, z,
+ * order) in the corresponding order.
+ *
+ * ```js
+ * const a = new THREE.Euler( 0, 1, 1.57, 'XYZ' );
+ * const b = new THREE.Vector3( 1, 0, 1 );
+ * b.applyEuler(a);
+ * ```
+ */
+class Euler {
+
+ /**
+ * Constructs a new euler instance.
+ *
+ * @param {number} [x=0] - The angle of the x axis in radians.
+ * @param {number} [y=0] - The angle of the y axis in radians.
+ * @param {number} [z=0] - The angle of the z axis in radians.
+ * @param {string} [order=Euler.DEFAULT_ORDER] - A string representing the order that the rotations are applied.
+ */
+ constructor( x = 0, y = 0, z = 0, order = Euler.DEFAULT_ORDER ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isEuler = true;
+
+ this._x = x;
+ this._y = y;
+ this._z = z;
+ this._order = order;
+
+ }
+
+ /**
+ * The angle of the x axis in radians.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get x() {
+
+ return this._x;
+
+ }
+
+ set x( value ) {
+
+ this._x = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * The angle of the y axis in radians.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get y() {
+
+ return this._y;
+
+ }
+
+ set y( value ) {
+
+ this._y = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * The angle of the z axis in radians.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get z() {
+
+ return this._z;
+
+ }
+
+ set z( value ) {
+
+ this._z = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * A string representing the order that the rotations are applied.
+ *
+ * @type {string}
+ * @default 'XYZ'
+ */
+ get order() {
+
+ return this._order;
+
+ }
+
+ set order( value ) {
+
+ this._order = value;
+ this._onChangeCallback();
+
+ }
+
+ /**
+ * Sets the Euler components.
+ *
+ * @param {number} x - The angle of the x axis in radians.
+ * @param {number} y - The angle of the y axis in radians.
+ * @param {number} z - The angle of the z axis in radians.
+ * @param {string} [order] - A string representing the order that the rotations are applied.
+ * @return {Euler} A reference to this Euler instance.
+ */
+ set( x, y, z, order = this._order ) {
+
+ this._x = x;
+ this._y = y;
+ this._z = z;
+ this._order = order;
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new Euler instance with copied values from this instance.
+ *
+ * @return {Euler} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor( this._x, this._y, this._z, this._order );
+
+ }
+
+ /**
+ * Copies the values of the given Euler instance to this instance.
+ *
+ * @param {Euler} euler - The Euler instance to copy.
+ * @return {Euler} A reference to this Euler instance.
+ */
+ copy( euler ) {
+
+ this._x = euler._x;
+ this._y = euler._y;
+ this._z = euler._z;
+ this._order = euler._order;
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Sets the angles of this Euler instance from a pure rotation matrix.
+ *
+ * @param {Matrix4} m - A 4x4 matrix of which the upper 3x3 of matrix is a pure rotation matrix (i.e. unscaled).
+ * @param {string} [order] - A string representing the order that the rotations are applied.
+ * @param {boolean} [update=true] - Whether the internal `onChange` callback should be executed or not.
+ * @return {Euler} A reference to this Euler instance.
+ */
+ setFromRotationMatrix( m, order = this._order, update = true ) {
+
+ const te = m.elements;
+ const m11 = te[ 0 ], m12 = te[ 4 ], m13 = te[ 8 ];
+ const m21 = te[ 1 ], m22 = te[ 5 ], m23 = te[ 9 ];
+ const m31 = te[ 2 ], m32 = te[ 6 ], m33 = te[ 10 ];
+
+ switch ( order ) {
+
+ case 'XYZ':
+
+ this._y = Math.asin( clamp( m13, -1, 1 ) );
+
+ if ( Math.abs( m13 ) < 0.9999999 ) {
+
+ this._x = Math.atan2( - m23, m33 );
+ this._z = Math.atan2( - m12, m11 );
+
+ } else {
+
+ this._x = Math.atan2( m32, m22 );
+ this._z = 0;
+
+ }
+
+ break;
+
+ case 'YXZ':
+
+ this._x = Math.asin( - clamp( m23, -1, 1 ) );
+
+ if ( Math.abs( m23 ) < 0.9999999 ) {
+
+ this._y = Math.atan2( m13, m33 );
+ this._z = Math.atan2( m21, m22 );
+
+ } else {
+
+ this._y = Math.atan2( - m31, m11 );
+ this._z = 0;
+
+ }
+
+ break;
+
+ case 'ZXY':
+
+ this._x = Math.asin( clamp( m32, -1, 1 ) );
+
+ if ( Math.abs( m32 ) < 0.9999999 ) {
+
+ this._y = Math.atan2( - m31, m33 );
+ this._z = Math.atan2( - m12, m22 );
+
+ } else {
+
+ this._y = 0;
+ this._z = Math.atan2( m21, m11 );
+
+ }
+
+ break;
+
+ case 'ZYX':
+
+ this._y = Math.asin( - clamp( m31, -1, 1 ) );
+
+ if ( Math.abs( m31 ) < 0.9999999 ) {
+
+ this._x = Math.atan2( m32, m33 );
+ this._z = Math.atan2( m21, m11 );
+
+ } else {
+
+ this._x = 0;
+ this._z = Math.atan2( - m12, m22 );
+
+ }
+
+ break;
+
+ case 'YZX':
+
+ this._z = Math.asin( clamp( m21, -1, 1 ) );
+
+ if ( Math.abs( m21 ) < 0.9999999 ) {
+
+ this._x = Math.atan2( - m23, m22 );
+ this._y = Math.atan2( - m31, m11 );
+
+ } else {
+
+ this._x = 0;
+ this._y = Math.atan2( m13, m33 );
+
+ }
+
+ break;
+
+ case 'XZY':
+
+ this._z = Math.asin( - clamp( m12, -1, 1 ) );
+
+ if ( Math.abs( m12 ) < 0.9999999 ) {
+
+ this._x = Math.atan2( m32, m22 );
+ this._y = Math.atan2( m13, m11 );
+
+ } else {
+
+ this._x = Math.atan2( - m23, m33 );
+ this._y = 0;
+
+ }
+
+ break;
+
+ default:
+
+ warn( 'Euler: .setFromRotationMatrix() encountered an unknown order: ' + order );
+
+ }
+
+ this._order = order;
+
+ if ( update === true ) this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Sets the angles of this Euler instance from a normalized quaternion.
+ *
+ * @param {Quaternion} q - A normalized Quaternion.
+ * @param {string} [order] - A string representing the order that the rotations are applied.
+ * @param {boolean} [update=true] - Whether the internal `onChange` callback should be executed or not.
+ * @return {Euler} A reference to this Euler instance.
+ */
+ setFromQuaternion( q, order, update ) {
+
+ _matrix$2.makeRotationFromQuaternion( q );
+
+ return this.setFromRotationMatrix( _matrix$2, order, update );
+
+ }
+
+ /**
+ * Sets the angles of this Euler instance from the given vector.
+ *
+ * @param {Vector3} v - The vector.
+ * @param {string} [order] - A string representing the order that the rotations are applied.
+ * @return {Euler} A reference to this Euler instance.
+ */
+ setFromVector3( v, order = this._order ) {
+
+ return this.set( v.x, v.y, v.z, order );
+
+ }
+
+ /**
+ * Resets the euler angle with a new order by creating a quaternion from this
+ * euler angle and then setting this euler angle with the quaternion and the
+ * new order.
+ *
+ * Warning: This discards revolution information.
+ *
+ * @param {string} [newOrder] - A string representing the new order that the rotations are applied.
+ * @return {Euler} A reference to this Euler instance.
+ */
+ reorder( newOrder ) {
+
+ _quaternion$4.setFromEuler( this );
+
+ return this.setFromQuaternion( _quaternion$4, newOrder );
+
+ }
+
+ /**
+ * Returns `true` if this Euler instance is equal with the given one.
+ *
+ * @param {Euler} euler - The Euler instance to test for equality.
+ * @return {boolean} Whether this Euler instance is equal with the given one.
+ */
+ equals( euler ) {
+
+ return ( euler._x === this._x ) && ( euler._y === this._y ) && ( euler._z === this._z ) && ( euler._order === this._order );
+
+ }
+
+ /**
+ * Sets this Euler instance's components to values from the given array. The first three
+ * entries of the array are assign to the x,y and z components. An optional fourth entry
+ * defines the Euler order.
+ *
+ * @param {Array} array - An array holding the Euler component values.
+ * @return {Euler} A reference to this Euler instance.
+ */
+ fromArray( array ) {
+
+ this._x = array[ 0 ];
+ this._y = array[ 1 ];
+ this._z = array[ 2 ];
+ if ( array[ 3 ] !== undefined ) this._order = array[ 3 ];
+
+ this._onChangeCallback();
+
+ return this;
+
+ }
+
+ /**
+ * Writes the components of this Euler instance to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the Euler components.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The Euler components.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ array[ offset ] = this._x;
+ array[ offset + 1 ] = this._y;
+ array[ offset + 2 ] = this._z;
+ array[ offset + 3 ] = this._order;
+
+ return array;
+
+ }
+
+ _onChange( callback ) {
+
+ this._onChangeCallback = callback;
+
+ return this;
+
+ }
+
+ _onChangeCallback() {}
+
+ *[ Symbol.iterator ]() {
+
+ yield this._x;
+ yield this._y;
+ yield this._z;
+ yield this._order;
+
+ }
+
+}
+
+/**
+ * The default Euler angle order.
+ *
+ * @static
+ * @type {string}
+ * @default 'XYZ'
+ */
+Euler.DEFAULT_ORDER = 'XYZ';
+
+/**
+ * A layers object assigns an 3D object to 1 or more of 32
+ * layers numbered `0` to `31` - internally the layers are stored as a
+ * bit mask], and by default all 3D objects are a member of layer `0`.
+ *
+ * This can be used to control visibility - an object must share a layer with
+ * a camera to be visible when that camera's view is
+ * rendered.
+ *
+ * All classes that inherit from {@link Object3D} have an `layers` property which
+ * is an instance of this class.
+ */
+class Layers {
+
+ /**
+ * Constructs a new layers instance, with membership
+ * initially set to layer `0`.
+ */
+ constructor() {
+
+ /**
+ * A bit mask storing which of the 32 layers this layers object is currently
+ * a member of.
+ *
+ * @type {number}
+ */
+ this.mask = 1 | 0;
+
+ }
+
+ /**
+ * Sets membership to the given layer, and remove membership all other layers.
+ *
+ * @param {number} layer - The layer to set.
+ */
+ set( layer ) {
+
+ this.mask = ( 1 << layer | 0 ) >>> 0;
+
+ }
+
+ /**
+ * Adds membership of the given layer.
+ *
+ * @param {number} layer - The layer to enable.
+ */
+ enable( layer ) {
+
+ this.mask |= 1 << layer | 0;
+
+ }
+
+ /**
+ * Adds membership to all layers.
+ */
+ enableAll() {
+
+ this.mask = 0xffffffff | 0;
+
+ }
+
+ /**
+ * Toggles the membership of the given layer.
+ *
+ * @param {number} layer - The layer to toggle.
+ */
+ toggle( layer ) {
+
+ this.mask ^= 1 << layer | 0;
+
+ }
+
+ /**
+ * Removes membership of the given layer.
+ *
+ * @param {number} layer - The layer to enable.
+ */
+ disable( layer ) {
+
+ this.mask &= ~ ( 1 << layer | 0 );
+
+ }
+
+ /**
+ * Removes the membership from all layers.
+ */
+ disableAll() {
+
+ this.mask = 0;
+
+ }
+
+ /**
+ * Returns `true` if this and the given layers object have at least one
+ * layer in common.
+ *
+ * @param {Layers} layers - The layers to test.
+ * @return {boolean } Whether this and the given layers object have at least one layer in common or not.
+ */
+ test( layers ) {
+
+ return ( this.mask & layers.mask ) !== 0;
+
+ }
+
+ /**
+ * Returns `true` if the given layer is enabled.
+ *
+ * @param {number} layer - The layer to test.
+ * @return {boolean } Whether the given layer is enabled or not.
+ */
+ isEnabled( layer ) {
+
+ return ( this.mask & ( 1 << layer | 0 ) ) !== 0;
+
+ }
+
+}
+
+let _object3DId = 0;
+
+const _v1$6 = /*@__PURE__*/ new Vector3();
+const _q1 = /*@__PURE__*/ new Quaternion();
+const _m1$1 = /*@__PURE__*/ new Matrix4();
+const _target = /*@__PURE__*/ new Vector3();
+
+const _position$4 = /*@__PURE__*/ new Vector3();
+const _scale$3 = /*@__PURE__*/ new Vector3();
+const _quaternion$3 = /*@__PURE__*/ new Quaternion();
+
+const _xAxis = /*@__PURE__*/ new Vector3( 1, 0, 0 );
+const _yAxis = /*@__PURE__*/ new Vector3( 0, 1, 0 );
+const _zAxis = /*@__PURE__*/ new Vector3( 0, 0, 1 );
+
+/**
+ * Fires when the object has been added to its parent object.
+ *
+ * @event Object3D#added
+ * @type {Object}
+ */
+const _addedEvent = { type: 'added' };
+
+/**
+ * Fires when the object has been removed from its parent object.
+ *
+ * @event Object3D#removed
+ * @type {Object}
+ */
+const _removedEvent = { type: 'removed' };
+
+/**
+ * Fires when a new child object has been added.
+ *
+ * @event Object3D#childadded
+ * @type {Object}
+ */
+const _childaddedEvent = { type: 'childadded', child: null };
+
+/**
+ * Fires when a child object has been removed.
+ *
+ * @event Object3D#childremoved
+ * @type {Object}
+ */
+const _childremovedEvent = { type: 'childremoved', child: null };
+
+/**
+ * This is the base class for most objects in three.js and provides a set of
+ * properties and methods for manipulating objects in 3D space.
+ *
+ * @augments EventDispatcher
+ */
+class Object3D extends EventDispatcher {
+
+ /**
+ * Constructs a new 3D object.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isObject3D = true;
+
+ /**
+ * The ID of the 3D object.
+ *
+ * @name Object3D#id
+ * @type {number}
+ * @readonly
+ */
+ Object.defineProperty( this, 'id', { value: _object3DId ++ } );
+
+ /**
+ * The UUID of the 3D object.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ /**
+ * The name of the 3D object.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The type property is used for detecting the object type
+ * in context of serialization/deserialization.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.type = 'Object3D';
+
+ /**
+ * A reference to the parent object.
+ *
+ * @type {?Object3D}
+ * @default null
+ */
+ this.parent = null;
+
+ /**
+ * An array holding the child 3D objects of this instance.
+ *
+ * @type {Array}
+ */
+ this.children = [];
+
+ /**
+ * Defines the `up` direction of the 3D object which influences
+ * the orientation via methods like {@link Object3D#lookAt}.
+ *
+ * The default values for all 3D objects is defined by `Object3D.DEFAULT_UP`.
+ *
+ * @type {Vector3}
+ */
+ this.up = Object3D.DEFAULT_UP.clone();
+
+ const position = new Vector3();
+ const rotation = new Euler();
+ const quaternion = new Quaternion();
+ const scale = new Vector3( 1, 1, 1 );
+
+ function onRotationChange() {
+
+ quaternion.setFromEuler( rotation, false );
+
+ }
+
+ function onQuaternionChange() {
+
+ rotation.setFromQuaternion( quaternion, undefined, false );
+
+ }
+
+ rotation._onChange( onRotationChange );
+ quaternion._onChange( onQuaternionChange );
+
+ Object.defineProperties( this, {
+ /**
+ * Represents the object's local position.
+ *
+ * @name Object3D#position
+ * @type {Vector3}
+ * @default (0,0,0)
+ */
+ position: {
+ configurable: true,
+ enumerable: true,
+ value: position
+ },
+ /**
+ * Represents the object's local rotation as Euler angles, in radians.
+ *
+ * @name Object3D#rotation
+ * @type {Euler}
+ * @default (0,0,0)
+ */
+ rotation: {
+ configurable: true,
+ enumerable: true,
+ value: rotation
+ },
+ /**
+ * Represents the object's local rotation as Quaternions.
+ *
+ * @name Object3D#quaternion
+ * @type {Quaternion}
+ */
+ quaternion: {
+ configurable: true,
+ enumerable: true,
+ value: quaternion
+ },
+ /**
+ * Represents the object's local scale.
+ *
+ * @name Object3D#scale
+ * @type {Vector3}
+ * @default (1,1,1)
+ */
+ scale: {
+ configurable: true,
+ enumerable: true,
+ value: scale
+ },
+ /**
+ * Represents the object's model-view matrix.
+ *
+ * @name Object3D#modelViewMatrix
+ * @type {Matrix4}
+ */
+ modelViewMatrix: {
+ value: new Matrix4()
+ },
+ /**
+ * Represents the object's normal matrix.
+ *
+ * @name Object3D#normalMatrix
+ * @type {Matrix3}
+ */
+ normalMatrix: {
+ value: new Matrix3()
+ }
+ } );
+
+ /**
+ * Represents the object's transformation matrix in local space.
+ *
+ * @type {Matrix4}
+ */
+ this.matrix = new Matrix4();
+
+ /**
+ * Represents the object's transformation matrix in world space.
+ * If the 3D object has no parent, then it's identical to the local transformation matrix
+ *
+ * @type {Matrix4}
+ */
+ this.matrixWorld = new Matrix4();
+
+ /**
+ * When set to `true`, the engine automatically computes the local matrix from position,
+ * rotation and scale every frame. If set to `false`, the app is responsible for recomputing
+ * the local matrix by calling `updateMatrix()`.
+ *
+ * The default values for all 3D objects is defined by `Object3D.DEFAULT_MATRIX_AUTO_UPDATE`.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.matrixAutoUpdate = Object3D.DEFAULT_MATRIX_AUTO_UPDATE;
+
+ /**
+ * When set to `true`, the engine automatically computes the world matrix from the current local
+ * matrix and the object's transformation hierarchy. If set to `false`, the app is responsible for
+ * recomputing the world matrix by directly updating the `matrixWorld` property.
+ *
+ * The default values for all 3D objects is defined by `Object3D.DEFAULT_MATRIX_WORLD_AUTO_UPDATE`.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.matrixWorldAutoUpdate = Object3D.DEFAULT_MATRIX_WORLD_AUTO_UPDATE; // checked by the renderer
+
+ /**
+ * When set to `true`, it calculates the world matrix in that frame and resets this property
+ * to `false`.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.matrixWorldNeedsUpdate = false;
+
+ /**
+ * The layer membership of the 3D object. The 3D object is only visible if it has
+ * at least one layer in common with the camera in use. This property can also be
+ * used to filter out unwanted objects in ray-intersection tests when using {@link Raycaster}.
+ *
+ * @type {Layers}
+ */
+ this.layers = new Layers();
+
+ /**
+ * When set to `true`, the 3D object gets rendered.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.visible = true;
+
+ /**
+ * When set to `true`, the 3D object gets rendered into shadow maps.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.castShadow = false;
+
+ /**
+ * When set to `true`, the 3D object is affected by shadows in the scene.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.receiveShadow = false;
+
+ /**
+ * When set to `true`, the 3D object is honored by view frustum culling.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.frustumCulled = true;
+
+ /**
+ * This value allows the default rendering order of scene graph objects to be
+ * overridden although opaque and transparent objects remain sorted independently.
+ * When this property is set for an instance of {@link Group},all descendants
+ * objects will be sorted and rendered together. Sorting is from lowest to highest
+ * render order.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.renderOrder = 0;
+
+ /**
+ * An array holding the animation clips of the 3D object.
+ *
+ * @type {Array}
+ */
+ this.animations = [];
+
+ /**
+ * Custom depth material to be used when rendering to the depth map. Can only be used
+ * in context of meshes. When shadow-casting with a {@link DirectionalLight} or {@link SpotLight},
+ * if you are modifying vertex positions in the vertex shader you must specify a custom depth
+ * material for proper shadows.
+ *
+ * Only relevant in context of {@link WebGLRenderer}.
+ *
+ * @type {(Material|undefined)}
+ * @default undefined
+ */
+ this.customDepthMaterial = undefined;
+
+ /**
+ * Same as {@link Object3D#customDepthMaterial}, but used with {@link PointLight}.
+ *
+ * Only relevant in context of {@link WebGLRenderer}.
+ *
+ * @type {(Material|undefined)}
+ * @default undefined
+ */
+ this.customDistanceMaterial = undefined;
+
+ /**
+ * Whether the 3D object is supposed to be static or not. If set to `true`, it means
+ * the 3D object is not going to be changed after the initial renderer. This includes
+ * geometry and material settings. A static 3D object can be processed by the renderer
+ * slightly faster since certain state checks can be bypassed.
+ *
+ * Only relevant in context of {@link WebGPURenderer}.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.static = false;
+
+ /**
+ * An object that can be used to store custom data about the 3D object. It
+ * should not hold references to functions as these will not be cloned.
+ *
+ * @type {Object}
+ */
+ this.userData = {};
+
+ /**
+ * The pivot point for rotation and scale transformations.
+ * When set, rotation and scale are applied around this point
+ * instead of the object's origin.
+ *
+ * @type {?Vector3}
+ * @default null
+ */
+ this.pivot = null;
+
+ }
+
+ /**
+ * A callback that is executed immediately before a 3D object is rendered to a shadow map.
+ *
+ * @param {Renderer|WebGLRenderer} renderer - The renderer.
+ * @param {Object3D} object - The 3D object.
+ * @param {Camera} camera - The camera that is used to render the scene.
+ * @param {Camera} shadowCamera - The shadow camera.
+ * @param {BufferGeometry} geometry - The 3D object's geometry.
+ * @param {Material} depthMaterial - The depth material.
+ * @param {Object} group - The geometry group data.
+ */
+ onBeforeShadow( /* renderer, object, camera, shadowCamera, geometry, depthMaterial, group */ ) {}
+
+ /**
+ * A callback that is executed immediately after a 3D object is rendered to a shadow map.
+ *
+ * @param {Renderer|WebGLRenderer} renderer - The renderer.
+ * @param {Object3D} object - The 3D object.
+ * @param {Camera} camera - The camera that is used to render the scene.
+ * @param {Camera} shadowCamera - The shadow camera.
+ * @param {BufferGeometry} geometry - The 3D object's geometry.
+ * @param {Material} depthMaterial - The depth material.
+ * @param {Object} group - The geometry group data.
+ */
+ onAfterShadow( /* renderer, object, camera, shadowCamera, geometry, depthMaterial, group */ ) {}
+
+ /**
+ * A callback that is executed immediately before a 3D object is rendered.
+ *
+ * @param {Renderer|WebGLRenderer} renderer - The renderer.
+ * @param {Object3D} object - The 3D object.
+ * @param {Camera} camera - The camera that is used to render the scene.
+ * @param {BufferGeometry} geometry - The 3D object's geometry.
+ * @param {Material} material - The 3D object's material.
+ * @param {Object} group - The geometry group data.
+ */
+ onBeforeRender( /* renderer, scene, camera, geometry, material, group */ ) {}
+
+ /**
+ * A callback that is executed immediately after a 3D object is rendered.
+ *
+ * @param {Renderer|WebGLRenderer} renderer - The renderer.
+ * @param {Object3D} object - The 3D object.
+ * @param {Camera} camera - The camera that is used to render the scene.
+ * @param {BufferGeometry} geometry - The 3D object's geometry.
+ * @param {Material} material - The 3D object's material.
+ * @param {Object} group - The geometry group data.
+ */
+ onAfterRender( /* renderer, scene, camera, geometry, material, group */ ) {}
+
+ /**
+ * Applies the given transformation matrix to the object and updates the object's position,
+ * rotation and scale.
+ *
+ * @param {Matrix4} matrix - The transformation matrix.
+ */
+ applyMatrix4( matrix ) {
+
+ if ( this.matrixAutoUpdate ) this.updateMatrix();
+
+ this.matrix.premultiply( matrix );
+
+ this.matrix.decompose( this.position, this.quaternion, this.scale );
+
+ }
+
+ /**
+ * Applies a rotation represented by given the quaternion to the 3D object.
+ *
+ * @param {Quaternion} q - The quaternion.
+ * @return {Object3D} A reference to this instance.
+ */
+ applyQuaternion( q ) {
+
+ this.quaternion.premultiply( q );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given rotation represented as an axis/angle couple to the 3D object.
+ *
+ * @param {Vector3} axis - The (normalized) axis vector.
+ * @param {number} angle - The angle in radians.
+ */
+ setRotationFromAxisAngle( axis, angle ) {
+
+ // assumes axis is normalized
+
+ this.quaternion.setFromAxisAngle( axis, angle );
+
+ }
+
+ /**
+ * Sets the given rotation represented as Euler angles to the 3D object.
+ *
+ * @param {Euler} euler - The Euler angles.
+ */
+ setRotationFromEuler( euler ) {
+
+ this.quaternion.setFromEuler( euler, true );
+
+ }
+
+ /**
+ * Sets the given rotation represented as rotation matrix to the 3D object.
+ *
+ * @param {Matrix4} m - Although a 4x4 matrix is expected, the upper 3x3 portion must be
+ * a pure rotation matrix (i.e, unscaled).
+ */
+ setRotationFromMatrix( m ) {
+
+ // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled)
+
+ this.quaternion.setFromRotationMatrix( m );
+
+ }
+
+ /**
+ * Sets the given rotation represented as a Quaternion to the 3D object.
+ *
+ * @param {Quaternion} q - The Quaternion
+ */
+ setRotationFromQuaternion( q ) {
+
+ // assumes q is normalized
+
+ this.quaternion.copy( q );
+
+ }
+
+ /**
+ * Rotates the 3D object along an axis in local space.
+ *
+ * @param {Vector3} axis - The (normalized) axis vector.
+ * @param {number} angle - The angle in radians.
+ * @return {Object3D} A reference to this instance.
+ */
+ rotateOnAxis( axis, angle ) {
+
+ // rotate object on axis in object space
+ // axis is assumed to be normalized
+
+ _q1.setFromAxisAngle( axis, angle );
+
+ this.quaternion.multiply( _q1 );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates the 3D object along an axis in world space.
+ *
+ * @param {Vector3} axis - The (normalized) axis vector.
+ * @param {number} angle - The angle in radians.
+ * @return {Object3D} A reference to this instance.
+ */
+ rotateOnWorldAxis( axis, angle ) {
+
+ // rotate object on axis in world space
+ // axis is assumed to be normalized
+ // method assumes no rotated parent
+
+ _q1.setFromAxisAngle( axis, angle );
+
+ this.quaternion.premultiply( _q1 );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates the 3D object around its X axis in local space.
+ *
+ * @param {number} angle - The angle in radians.
+ * @return {Object3D} A reference to this instance.
+ */
+ rotateX( angle ) {
+
+ return this.rotateOnAxis( _xAxis, angle );
+
+ }
+
+ /**
+ * Rotates the 3D object around its Y axis in local space.
+ *
+ * @param {number} angle - The angle in radians.
+ * @return {Object3D} A reference to this instance.
+ */
+ rotateY( angle ) {
+
+ return this.rotateOnAxis( _yAxis, angle );
+
+ }
+
+ /**
+ * Rotates the 3D object around its Z axis in local space.
+ *
+ * @param {number} angle - The angle in radians.
+ * @return {Object3D} A reference to this instance.
+ */
+ rotateZ( angle ) {
+
+ return this.rotateOnAxis( _zAxis, angle );
+
+ }
+
+ /**
+ * Translate the 3D object by a distance along the given axis in local space.
+ *
+ * @param {Vector3} axis - The (normalized) axis vector.
+ * @param {number} distance - The distance in world units.
+ * @return {Object3D} A reference to this instance.
+ */
+ translateOnAxis( axis, distance ) {
+
+ // translate object by distance along axis in object space
+ // axis is assumed to be normalized
+
+ _v1$6.copy( axis ).applyQuaternion( this.quaternion );
+
+ this.position.add( _v1$6.multiplyScalar( distance ) );
+
+ return this;
+
+ }
+
+ /**
+ * Translate the 3D object by a distance along its X-axis in local space.
+ *
+ * @param {number} distance - The distance in world units.
+ * @return {Object3D} A reference to this instance.
+ */
+ translateX( distance ) {
+
+ return this.translateOnAxis( _xAxis, distance );
+
+ }
+
+ /**
+ * Translate the 3D object by a distance along its Y-axis in local space.
+ *
+ * @param {number} distance - The distance in world units.
+ * @return {Object3D} A reference to this instance.
+ */
+ translateY( distance ) {
+
+ return this.translateOnAxis( _yAxis, distance );
+
+ }
+
+ /**
+ * Translate the 3D object by a distance along its Z-axis in local space.
+ *
+ * @param {number} distance - The distance in world units.
+ * @return {Object3D} A reference to this instance.
+ */
+ translateZ( distance ) {
+
+ return this.translateOnAxis( _zAxis, distance );
+
+ }
+
+ /**
+ * Converts the given vector from this 3D object's local space to world space.
+ *
+ * @param {Vector3} vector - The vector to convert.
+ * @return {Vector3} The converted vector.
+ */
+ localToWorld( vector ) {
+
+ this.updateWorldMatrix( true, false );
+
+ return vector.applyMatrix4( this.matrixWorld );
+
+ }
+
+ /**
+ * Converts the given vector from this 3D object's world space to local space.
+ *
+ * @param {Vector3} vector - The vector to convert.
+ * @return {Vector3} The converted vector.
+ */
+ worldToLocal( vector ) {
+
+ this.updateWorldMatrix( true, false );
+
+ return vector.applyMatrix4( _m1$1.copy( this.matrixWorld ).invert() );
+
+ }
+
+ /**
+ * Rotates the object to face a point in world space.
+ *
+ * This method does not support objects having non-uniformly-scaled parent(s).
+ *
+ * @param {number|Vector3} x - The x coordinate in world space. Alternatively, a vector representing a position in world space
+ * @param {number} [y] - The y coordinate in world space.
+ * @param {number} [z] - The z coordinate in world space.
+ */
+ lookAt( x, y, z ) {
+
+ // This method does not support objects having non-uniformly-scaled parent(s)
+
+ if ( x.isVector3 ) {
+
+ _target.copy( x );
+
+ } else {
+
+ _target.set( x, y, z );
+
+ }
+
+ const parent = this.parent;
+
+ this.updateWorldMatrix( true, false );
+
+ _position$4.setFromMatrixPosition( this.matrixWorld );
+
+ if ( this.isCamera || this.isLight ) {
+
+ _m1$1.lookAt( _position$4, _target, this.up );
+
+ } else {
+
+ _m1$1.lookAt( _target, _position$4, this.up );
+
+ }
+
+ this.quaternion.setFromRotationMatrix( _m1$1 );
+
+ if ( parent ) {
+
+ _m1$1.extractRotation( parent.matrixWorld );
+ _q1.setFromRotationMatrix( _m1$1 );
+ this.quaternion.premultiply( _q1.invert() );
+
+ }
+
+ }
+
+ /**
+ * Adds the given 3D object as a child to this 3D object. An arbitrary number of
+ * objects may be added. Any current parent on an object passed in here will be
+ * removed, since an object can have at most one parent.
+ *
+ * @fires Object3D#added
+ * @fires Object3D#childadded
+ * @param {Object3D} object - The 3D object to add.
+ * @return {Object3D} A reference to this instance.
+ */
+ add( object ) {
+
+ if ( arguments.length > 1 ) {
+
+ for ( let i = 0; i < arguments.length; i ++ ) {
+
+ this.add( arguments[ i ] );
+
+ }
+
+ return this;
+
+ }
+
+ if ( object === this ) {
+
+ error( 'Object3D.add: object can\'t be added as a child of itself.', object );
+ return this;
+
+ }
+
+ if ( object && object.isObject3D ) {
+
+ object.removeFromParent();
+ object.parent = this;
+ this.children.push( object );
+
+ object.dispatchEvent( _addedEvent );
+
+ _childaddedEvent.child = object;
+ this.dispatchEvent( _childaddedEvent );
+ _childaddedEvent.child = null;
+
+ } else {
+
+ error( 'Object3D.add: object not an instance of THREE.Object3D.', object );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Removes the given 3D object as child from this 3D object.
+ * An arbitrary number of objects may be removed.
+ *
+ * @fires Object3D#removed
+ * @fires Object3D#childremoved
+ * @param {Object3D} object - The 3D object to remove.
+ * @return {Object3D} A reference to this instance.
+ */
+ remove( object ) {
+
+ if ( arguments.length > 1 ) {
+
+ for ( let i = 0; i < arguments.length; i ++ ) {
+
+ this.remove( arguments[ i ] );
+
+ }
+
+ return this;
+
+ }
+
+ const index = this.children.indexOf( object );
+
+ if ( index !== -1 ) {
+
+ object.parent = null;
+ this.children.splice( index, 1 );
+
+ object.dispatchEvent( _removedEvent );
+
+ _childremovedEvent.child = object;
+ this.dispatchEvent( _childremovedEvent );
+ _childremovedEvent.child = null;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Removes this 3D object from its current parent.
+ *
+ * @fires Object3D#removed
+ * @fires Object3D#childremoved
+ * @return {Object3D} A reference to this instance.
+ */
+ removeFromParent() {
+
+ const parent = this.parent;
+
+ if ( parent !== null ) {
+
+ parent.remove( this );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Removes all child objects.
+ *
+ * @fires Object3D#removed
+ * @fires Object3D#childremoved
+ * @return {Object3D} A reference to this instance.
+ */
+ clear() {
+
+ return this.remove( ... this.children );
+
+ }
+
+ /**
+ * Adds the given 3D object as a child of this 3D object, while maintaining the object's world
+ * transform. This method does not support scene graphs having non-uniformly-scaled nodes(s).
+ *
+ * @fires Object3D#added
+ * @fires Object3D#childadded
+ * @param {Object3D} object - The 3D object to attach.
+ * @return {Object3D} A reference to this instance.
+ */
+ attach( object ) {
+
+ // adds object as a child of this, while maintaining the object's world transform
+
+ // Note: This method does not support scene graphs having non-uniformly-scaled nodes(s)
+
+ this.updateWorldMatrix( true, false );
+
+ _m1$1.copy( this.matrixWorld ).invert();
+
+ if ( object.parent !== null ) {
+
+ object.parent.updateWorldMatrix( true, false );
+
+ _m1$1.multiply( object.parent.matrixWorld );
+
+ }
+
+ object.applyMatrix4( _m1$1 );
+
+ object.removeFromParent();
+ object.parent = this;
+ this.children.push( object );
+
+ object.updateWorldMatrix( false, true );
+
+ object.dispatchEvent( _addedEvent );
+
+ _childaddedEvent.child = object;
+ this.dispatchEvent( _childaddedEvent );
+ _childaddedEvent.child = null;
+
+ return this;
+
+ }
+
+ /**
+ * Searches through the 3D object and its children, starting with the 3D object
+ * itself, and returns the first with a matching ID.
+ *
+ * @param {number} id - The id.
+ * @return {Object3D|undefined} The found 3D object. Returns `undefined` if no 3D object has been found.
+ */
+ getObjectById( id ) {
+
+ return this.getObjectByProperty( 'id', id );
+
+ }
+
+ /**
+ * Searches through the 3D object and its children, starting with the 3D object
+ * itself, and returns the first with a matching name.
+ *
+ * @param {string} name - The name.
+ * @return {Object3D|undefined} The found 3D object. Returns `undefined` if no 3D object has been found.
+ */
+ getObjectByName( name ) {
+
+ return this.getObjectByProperty( 'name', name );
+
+ }
+
+ /**
+ * Searches through the 3D object and its children, starting with the 3D object
+ * itself, and returns the first with a matching property value.
+ *
+ * @param {string} name - The name of the property.
+ * @param {any} value - The value.
+ * @return {Object3D|undefined} The found 3D object. Returns `undefined` if no 3D object has been found.
+ */
+ getObjectByProperty( name, value ) {
+
+ if ( this[ name ] === value ) return this;
+
+ for ( let i = 0, l = this.children.length; i < l; i ++ ) {
+
+ const child = this.children[ i ];
+ const object = child.getObjectByProperty( name, value );
+
+ if ( object !== undefined ) {
+
+ return object;
+
+ }
+
+ }
+
+ return undefined;
+
+ }
+
+ /**
+ * Searches through the 3D object and its children, starting with the 3D object
+ * itself, and returns all 3D objects with a matching property value.
+ *
+ * @param {string} name - The name of the property.
+ * @param {any} value - The value.
+ * @param {Array} result - The method stores the result in this array.
+ * @return {Array} The found 3D objects.
+ */
+ getObjectsByProperty( name, value, result = [] ) {
+
+ if ( this[ name ] === value ) result.push( this );
+
+ const children = this.children;
+
+ for ( let i = 0, l = children.length; i < l; i ++ ) {
+
+ children[ i ].getObjectsByProperty( name, value, result );
+
+ }
+
+ return result;
+
+ }
+
+ /**
+ * Returns a vector representing the position of the 3D object in world space.
+ *
+ * @param {Vector3} target - The target vector the result is stored to.
+ * @return {Vector3} The 3D object's position in world space.
+ */
+ getWorldPosition( target ) {
+
+ this.updateWorldMatrix( true, false );
+
+ return target.setFromMatrixPosition( this.matrixWorld );
+
+ }
+
+ /**
+ * Returns a Quaternion representing the position of the 3D object in world space.
+ *
+ * @param {Quaternion} target - The target Quaternion the result is stored to.
+ * @return {Quaternion} The 3D object's rotation in world space.
+ */
+ getWorldQuaternion( target ) {
+
+ this.updateWorldMatrix( true, false );
+
+ this.matrixWorld.decompose( _position$4, target, _scale$3 );
+
+ return target;
+
+ }
+
+ /**
+ * Returns a vector representing the scale of the 3D object in world space.
+ *
+ * @param {Vector3} target - The target vector the result is stored to.
+ * @return {Vector3} The 3D object's scale in world space.
+ */
+ getWorldScale( target ) {
+
+ this.updateWorldMatrix( true, false );
+
+ this.matrixWorld.decompose( _position$4, _quaternion$3, target );
+
+ return target;
+
+ }
+
+ /**
+ * Returns a vector representing the ("look") direction of the 3D object in world space.
+ *
+ * @param {Vector3} target - The target vector the result is stored to.
+ * @return {Vector3} The 3D object's direction in world space.
+ */
+ getWorldDirection( target ) {
+
+ this.updateWorldMatrix( true, false );
+
+ const e = this.matrixWorld.elements;
+
+ return target.set( e[ 8 ], e[ 9 ], e[ 10 ] ).normalize();
+
+ }
+
+ /**
+ * Abstract method to get intersections between a casted ray and this
+ * 3D object. Renderable 3D objects such as {@link Mesh}, {@link Line} or {@link Points}
+ * implement this method in order to use raycasting.
+ *
+ * @abstract
+ * @param {Raycaster} raycaster - The raycaster.
+ * @param {Array} intersects - An array holding the result of the method.
+ */
+ raycast( /* raycaster, intersects */ ) {}
+
+ /**
+ * Abstract method to test whether this 3D object intersects the given frustum.
+ * Renderable 3D objects such as {@link Mesh}, {@link Line} or {@link Points}
+ * implement this method in order to use frustum culling.
+ *
+ * @abstract
+ * @param {Frustum|FrustumArray} frustum - The frustum to test.
+ * @return {boolean|undefined} Whether this 3D object intersects the given frustum or not.
+ */
+ intersectsFrustum( /* frustum */ ) {}
+
+ /**
+ * Executes the callback on this 3D object and all descendants.
+ *
+ * Note: Modifying the scene graph inside the callback is discouraged.
+ *
+ * @param {Function} callback - A callback function that allows to process the current 3D object.
+ */
+ traverse( callback ) {
+
+ callback( this );
+
+ const children = this.children;
+
+ for ( let i = 0, l = children.length; i < l; i ++ ) {
+
+ children[ i ].traverse( callback );
+
+ }
+
+ }
+
+ /**
+ * Like {@link Object3D#traverse}, but the callback will only be executed for visible 3D objects.
+ * Descendants of invisible 3D objects are not traversed.
+ *
+ * Note: Modifying the scene graph inside the callback is discouraged.
+ *
+ * @param {Function} callback - A callback function that allows to process the current 3D object.
+ */
+ traverseVisible( callback ) {
+
+ if ( this.visible === false ) return;
+
+ callback( this );
+
+ const children = this.children;
+
+ for ( let i = 0, l = children.length; i < l; i ++ ) {
+
+ children[ i ].traverseVisible( callback );
+
+ }
+
+ }
+
+ /**
+ * Like {@link Object3D#traverse}, but the callback will only be executed for all ancestors.
+ *
+ * Note: Modifying the scene graph inside the callback is discouraged.
+ *
+ * @param {Function} callback - A callback function that allows to process the current 3D object.
+ */
+ traverseAncestors( callback ) {
+
+ const parent = this.parent;
+
+ if ( parent !== null ) {
+
+ callback( parent );
+
+ parent.traverseAncestors( callback );
+
+ }
+
+ }
+
+ /**
+ * Updates the transformation matrix in local space by computing it from the current
+ * position, rotation and scale values.
+ */
+ updateMatrix() {
+
+ this.matrix.compose( this.position, this.quaternion, this.scale );
+
+ const pivot = this.pivot;
+
+ if ( pivot !== null ) {
+
+ const px = pivot.x, py = pivot.y, pz = pivot.z;
+ const te = this.matrix.elements;
+
+ te[ 12 ] += px - te[ 0 ] * px - te[ 4 ] * py - te[ 8 ] * pz;
+ te[ 13 ] += py - te[ 1 ] * px - te[ 5 ] * py - te[ 9 ] * pz;
+ te[ 14 ] += pz - te[ 2 ] * px - te[ 6 ] * py - te[ 10 ] * pz;
+
+ }
+
+ this.matrixWorldNeedsUpdate = true;
+
+ }
+
+ /**
+ * Updates the transformation matrix in world space of this 3D objects and its descendants.
+ *
+ * To ensure correct results, this method also recomputes the 3D object's transformation matrix in
+ * local space. The computation of the local and world matrix can be controlled with the
+ * {@link Object3D#matrixAutoUpdate} and {@link Object3D#matrixWorldAutoUpdate} flags which are both
+ * `true` by default. Set these flags to `false` if you need more control over the update matrix process.
+ *
+ * @param {boolean} [force=false] - When set to `true`, a recomputation of world matrices is forced even
+ * when {@link Object3D#matrixWorldNeedsUpdate} is `false`.
+ */
+ updateMatrixWorld( force ) {
+
+ if ( this.matrixAutoUpdate ) this.updateMatrix();
+
+ if ( this.matrixWorldNeedsUpdate || force ) {
+
+ if ( this.matrixWorldAutoUpdate === true ) {
+
+ if ( this.parent === null ) {
+
+ this.matrixWorld.copy( this.matrix );
+
+ } else {
+
+ this.matrixWorld.multiplyMatrices( this.parent.matrixWorld, this.matrix );
+
+ }
+
+ }
+
+ this.matrixWorldNeedsUpdate = false;
+
+ force = true;
+
+ }
+
+ // make sure descendants are updated if required
+
+ const children = this.children;
+
+ for ( let i = 0, l = children.length; i < l; i ++ ) {
+
+ const child = children[ i ];
+
+ child.updateMatrixWorld( force );
+
+ }
+
+ }
+
+ /**
+ * An alternative version of {@link Object3D#updateMatrixWorld} with more control over the
+ * update of ancestor and descendant nodes.
+ *
+ * @param {boolean} [updateParents=false] Whether ancestor nodes should be updated or not.
+ * @param {boolean} [updateChildren=false] Whether descendant nodes should be updated or not.
+ * @param {boolean} [force=false] - When set to `true`, a recomputation of world matrices is forced even
+ * when {@link Object3D#matrixWorldNeedsUpdate} is `false`.
+ */
+ updateWorldMatrix( updateParents, updateChildren, force = false ) {
+
+ const parent = this.parent;
+
+ if ( updateParents === true && parent !== null ) {
+
+ parent.updateWorldMatrix( true, false );
+
+ }
+
+ if ( this.matrixAutoUpdate ) this.updateMatrix();
+
+ if ( this.matrixWorldNeedsUpdate || force ) {
+
+ if ( this.matrixWorldAutoUpdate === true ) {
+
+ if ( this.parent === null ) {
+
+ this.matrixWorld.copy( this.matrix );
+
+ } else {
+
+ this.matrixWorld.multiplyMatrices( this.parent.matrixWorld, this.matrix );
+
+ }
+
+ }
+
+ this.matrixWorldNeedsUpdate = false;
+
+ force = true;
+
+ }
+
+ // make sure descendants are updated
+
+ if ( updateChildren === true ) {
+
+ const children = this.children;
+
+ for ( let i = 0, l = children.length; i < l; i ++ ) {
+
+ const child = children[ i ];
+
+ child.updateWorldMatrix( false, true, force );
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Serializes the 3D object into JSON.
+ *
+ * @param {?(Object|string)} meta - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized 3D object.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON( meta ) {
+
+ // meta is a string when called from JSON.stringify
+ const isRootObject = ( meta === undefined || typeof meta === 'string' );
+
+ const output = {};
+
+ // meta is a hash used to collect geometries, materials.
+ // not providing it implies that this is the root object
+ // being serialized.
+ if ( isRootObject ) {
+
+ // initialize meta obj
+ meta = {
+ geometries: {},
+ materials: {},
+ textures: {},
+ images: {},
+ shapes: {},
+ skeletons: {},
+ animations: {},
+ nodes: {}
+ };
+
+ output.metadata = {
+ version: 4.7,
+ type: 'Object',
+ generator: 'Object3D.toJSON'
+ };
+
+ }
+
+ // standard Object3D serialization
+
+ const object = {};
+
+ object.uuid = this.uuid;
+ object.type = this.type;
+
+ object.name = this.name;
+ object.castShadow = this.castShadow;
+ object.receiveShadow = this.receiveShadow;
+ object.visible = this.visible;
+ object.frustumCulled = this.frustumCulled;
+ object.renderOrder = this.renderOrder;
+ object.static = this.static;
+ object.matrixAutoUpdate = this.matrixAutoUpdate;
+
+ if ( Object.keys( this.userData ).length > 0 ) object.userData = this.userData;
+
+ object.layers = this.layers.mask;
+ object.matrix = this.matrix.toArray();
+ object.up = this.up.toArray();
+
+ if ( this.pivot !== null ) object.pivot = this.pivot.toArray();
+
+ if ( this.morphTargetDictionary !== undefined ) object.morphTargetDictionary = Object.assign( {}, this.morphTargetDictionary );
+ if ( this.morphTargetInfluences !== undefined ) object.morphTargetInfluences = this.morphTargetInfluences.slice();
+
+ // object specific properties
+
+ if ( this.isInstancedMesh ) {
+
+ object.type = 'InstancedMesh';
+ object.count = this.count;
+ object.instanceMatrix = this.instanceMatrix.toJSON();
+ if ( this.instanceColor !== null ) object.instanceColor = this.instanceColor.toJSON();
+
+ }
+
+ if ( this.isBatchedMesh ) {
+
+ object.type = 'BatchedMesh';
+ object.perObjectFrustumCulled = this.perObjectFrustumCulled;
+ object.sortObjects = this.sortObjects;
+
+ object.drawRanges = this._drawRanges;
+ object.reservedRanges = this._reservedRanges;
+
+ object.geometryInfo = this._geometryInfo.map( info => ( {
+ ...info,
+ boundingBox: info.boundingBox ? info.boundingBox.toJSON() : undefined,
+ boundingSphere: info.boundingSphere ? info.boundingSphere.toJSON() : undefined
+ } ) );
+ object.instanceInfo = this._instanceInfo.map( info => ( { ...info } ) );
+
+ object.availableInstanceIds = this._availableInstanceIds.slice();
+ object.availableGeometryIds = this._availableGeometryIds.slice();
+
+ object.nextIndexStart = this._nextIndexStart;
+ object.nextVertexStart = this._nextVertexStart;
+ object.geometryCount = this._geometryCount;
+
+ object.maxInstanceCount = this._maxInstanceCount;
+ object.maxVertexCount = this._maxVertexCount;
+ object.maxIndexCount = this._maxIndexCount;
+
+ object.geometryInitialized = this._geometryInitialized;
+
+ object.matricesTexture = this._matricesTexture.toJSON( meta );
+
+ object.indirectTexture = this._indirectTexture.toJSON( meta );
+
+ if ( this._colorsTexture !== null ) {
+
+ object.colorsTexture = this._colorsTexture.toJSON( meta );
+
+ }
+
+ if ( this.boundingSphere !== null ) {
+
+ object.boundingSphere = this.boundingSphere.toJSON();
+
+ }
+
+ if ( this.boundingBox !== null ) {
+
+ object.boundingBox = this.boundingBox.toJSON();
+
+ }
+
+ }
+
+ //
+
+ function serialize( library, element ) {
+
+ if ( library[ element.uuid ] === undefined ) {
+
+ library[ element.uuid ] = element.toJSON( meta );
+
+ }
+
+ return element.uuid;
+
+ }
+
+ if ( this.isScene ) {
+
+ if ( this.background ) {
+
+ if ( this.background.isColor ) {
+
+ object.background = this.background.toJSON();
+
+ } else if ( this.background.isTexture ) {
+
+ object.background = this.background.toJSON( meta ).uuid;
+
+ }
+
+ }
+
+ if ( this.environment && this.environment.isTexture && this.environment.isRenderTargetTexture !== true ) {
+
+ object.environment = this.environment.toJSON( meta ).uuid;
+
+ }
+
+ } else if ( this.isMesh || this.isLine || this.isPoints ) {
+
+ object.geometry = serialize( meta.geometries, this.geometry );
+
+ const parameters = this.geometry.parameters;
+
+ if ( parameters !== undefined && parameters.shapes !== undefined ) {
+
+ const shapes = parameters.shapes;
+
+ if ( Array.isArray( shapes ) ) {
+
+ for ( let i = 0, l = shapes.length; i < l; i ++ ) {
+
+ const shape = shapes[ i ];
+
+ serialize( meta.shapes, shape );
+
+ }
+
+ } else {
+
+ serialize( meta.shapes, shapes );
+
+ }
+
+ }
+
+ }
+
+ if ( this.isSkinnedMesh ) {
+
+ object.bindMode = this.bindMode;
+ object.bindMatrix = this.bindMatrix.toArray();
+
+ if ( this.skeleton !== undefined ) {
+
+ serialize( meta.skeletons, this.skeleton );
+
+ object.skeleton = this.skeleton.uuid;
+
+ }
+
+ }
+
+ if ( this.material !== undefined ) {
+
+ if ( Array.isArray( this.material ) ) {
+
+ const uuids = [];
+
+ for ( let i = 0, l = this.material.length; i < l; i ++ ) {
+
+ uuids.push( serialize( meta.materials, this.material[ i ] ) );
+
+ }
+
+ object.material = uuids;
+
+ } else {
+
+ object.material = serialize( meta.materials, this.material );
+
+ }
+
+ }
+
+ //
+
+ if ( this.children.length > 0 ) {
+
+ object.children = [];
+
+ for ( let i = 0; i < this.children.length; i ++ ) {
+
+ object.children.push( this.children[ i ].toJSON( meta ).object );
+
+ }
+
+ }
+
+ //
+
+ if ( this.animations.length > 0 ) {
+
+ object.animations = [];
+
+ for ( let i = 0; i < this.animations.length; i ++ ) {
+
+ const animation = this.animations[ i ];
+
+ object.animations.push( serialize( meta.animations, animation ) );
+
+ }
+
+ }
+
+ if ( isRootObject ) {
+
+ const geometries = extractFromCache( meta.geometries );
+ const materials = extractFromCache( meta.materials );
+ const textures = extractFromCache( meta.textures );
+ const images = extractFromCache( meta.images );
+ const shapes = extractFromCache( meta.shapes );
+ const skeletons = extractFromCache( meta.skeletons );
+ const animations = extractFromCache( meta.animations );
+ const nodes = extractFromCache( meta.nodes );
+
+ if ( geometries.length > 0 ) output.geometries = geometries;
+ if ( materials.length > 0 ) output.materials = materials;
+ if ( textures.length > 0 ) output.textures = textures;
+ if ( images.length > 0 ) output.images = images;
+ if ( shapes.length > 0 ) output.shapes = shapes;
+ if ( skeletons.length > 0 ) output.skeletons = skeletons;
+ if ( animations.length > 0 ) output.animations = animations;
+ if ( nodes.length > 0 ) output.nodes = nodes;
+
+ }
+
+ output.object = object;
+
+ return output;
+
+ // extract data from the cache hash
+ // remove metadata on each item
+ // and return as array
+ function extractFromCache( cache ) {
+
+ const values = [];
+ for ( const key in cache ) {
+
+ const data = cache[ key ];
+ delete data.metadata;
+ values.push( data );
+
+ }
+
+ return values;
+
+ }
+
+ }
+
+ /**
+ * Returns a new 3D object with copied values from this instance.
+ *
+ * @param {boolean} [recursive=true] - When set to `true`, descendants of the 3D object are also cloned.
+ * @return {Object3D} A clone of this instance.
+ */
+ clone( recursive ) {
+
+ return new this.constructor().copy( this, recursive );
+
+ }
+
+ /**
+ * Copies the values of the given 3D object to this instance.
+ *
+ * @param {Object3D} source - The 3D object to copy.
+ * @param {boolean} [recursive=true] - When set to `true`, descendants of the 3D object are cloned.
+ * @return {Object3D} A reference to this instance.
+ */
+ copy( source, recursive = true ) {
+
+ this.name = source.name;
+
+ this.up.copy( source.up );
+
+ this.position.copy( source.position );
+ this.rotation.order = source.rotation.order;
+ this.quaternion.copy( source.quaternion );
+ this.scale.copy( source.scale );
+
+ this.pivot = ( source.pivot !== null ) ? source.pivot.clone() : null;
+
+ this.matrix.copy( source.matrix );
+ this.matrixWorld.copy( source.matrixWorld );
+
+ this.matrixAutoUpdate = source.matrixAutoUpdate;
+
+ this.matrixWorldAutoUpdate = source.matrixWorldAutoUpdate;
+ this.matrixWorldNeedsUpdate = source.matrixWorldNeedsUpdate;
+
+ this.layers.mask = source.layers.mask;
+ this.visible = source.visible;
+
+ this.castShadow = source.castShadow;
+ this.receiveShadow = source.receiveShadow;
+
+ this.frustumCulled = source.frustumCulled;
+ this.renderOrder = source.renderOrder;
+
+ this.static = source.static;
+
+ this.animations = source.animations.slice();
+
+ this.userData = JSON.parse( JSON.stringify( source.userData ) );
+
+ if ( recursive === true ) {
+
+ for ( let i = 0; i < source.children.length; i ++ ) {
+
+ const child = source.children[ i ];
+ this.add( child.clone() );
+
+ }
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ *
+ * Geometries, materials and textures are potentially shared with other
+ * 3D objects and must be disposed of separately.
+ *
+ * @fires Object3D#dispose
+ */
+ dispose() {
+
+ /**
+ * Fires when the 3D object has been disposed of.
+ *
+ * @event Object3D#dispose
+ * @type {Object}
+ */
+ this.dispatchEvent( { type: 'dispose' } );
+
+ }
+
+}
+
+/**
+ * The default up direction for objects, also used as the default
+ * position for {@link DirectionalLight} and {@link HemisphereLight}.
+ *
+ * @static
+ * @type {Vector3}
+ * @default (0,1,0)
+ */
+Object3D.DEFAULT_UP = /*@__PURE__*/ new Vector3( 0, 1, 0 );
+
+/**
+ * The default setting for {@link Object3D#matrixAutoUpdate} for
+ * newly created 3D objects.
+ *
+ * @static
+ * @type {boolean}
+ * @default true
+ */
+Object3D.DEFAULT_MATRIX_AUTO_UPDATE = true;
+
+/**
+ * The default setting for {@link Object3D#matrixWorldAutoUpdate} for
+ * newly created 3D objects.
+ *
+ * @static
+ * @type {boolean}
+ * @default true
+ */
+Object3D.DEFAULT_MATRIX_WORLD_AUTO_UPDATE = true;
+
+/**
+ * This is almost identical to an {@link Object3D}. Its purpose is to
+ * make working with groups of objects syntactically clearer.
+ *
+ * ```js
+ * // Create a group and add the two cubes.
+ * // These cubes can now be rotated / scaled etc as a group.
+ * const group = new THREE.Group();
+ *
+ * group.add( meshA );
+ * group.add( meshB );
+ *
+ * scene.add( group );
+ * ```
+ *
+ * @augments Object3D
+ */
+class Group extends Object3D {
+
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isGroup = true;
+
+ this.type = 'Group';
+
+ }
+
+}
+
+const _moveEvent = { type: 'move' };
+
+/**
+ * Class for representing a XR controller with its
+ * different coordinate systems.
+ *
+ * @private
+ */
+class WebXRController {
+
+ /**
+ * Constructs a new XR controller.
+ */
+ constructor() {
+
+ /**
+ * A group representing the target ray space
+ * of the XR controller.
+ *
+ * @private
+ * @type {?Group}
+ * @default null
+ */
+ this._targetRay = null;
+
+ /**
+ * A group representing the grip space
+ * of the XR controller.
+ *
+ * @private
+ * @type {?Group}
+ * @default null
+ */
+ this._grip = null;
+
+ /**
+ * A group representing the hand space
+ * of the XR controller.
+ *
+ * @private
+ * @type {?Group}
+ * @default null
+ */
+ this._hand = null;
+
+ }
+
+ /**
+ * Returns a group representing the hand space of the XR controller.
+ *
+ * @return {Group} A group representing the hand space of the XR controller.
+ */
+ getHandSpace() {
+
+ if ( this._hand === null ) {
+
+ this._hand = new Group();
+ this._hand.matrixAutoUpdate = false;
+ this._hand.visible = false;
+
+ this._hand.joints = {};
+ this._hand.inputState = { pinching: false };
+
+ }
+
+ return this._hand;
+
+ }
+
+ /**
+ * Returns a group representing the target ray space of the XR controller.
+ *
+ * @return {Group} A group representing the target ray space of the XR controller.
+ */
+ getTargetRaySpace() {
+
+ if ( this._targetRay === null ) {
+
+ this._targetRay = new Group();
+ this._targetRay.matrixAutoUpdate = false;
+ this._targetRay.visible = false;
+ this._targetRay.hasLinearVelocity = false;
+ this._targetRay.linearVelocity = new Vector3();
+ this._targetRay.hasAngularVelocity = false;
+ this._targetRay.angularVelocity = new Vector3();
+
+ }
+
+ return this._targetRay;
+
+ }
+
+ /**
+ * Returns a group representing the grip space of the XR controller.
+ *
+ * @return {Group} A group representing the grip space of the XR controller.
+ */
+ getGripSpace() {
+
+ if ( this._grip === null ) {
+
+ this._grip = new Group();
+ this._grip.matrixAutoUpdate = false;
+ this._grip.visible = false;
+ this._grip.hasLinearVelocity = false;
+ this._grip.linearVelocity = new Vector3();
+ this._grip.hasAngularVelocity = false;
+ this._grip.angularVelocity = new Vector3();
+ this._grip.eventsEnabled = false;
+
+ }
+
+ return this._grip;
+
+ }
+
+ /**
+ * Dispatches the given event to the groups representing
+ * the different coordinate spaces of the XR controller.
+ *
+ * @param {Object} event - The event to dispatch.
+ * @return {WebXRController} A reference to this instance.
+ */
+ dispatchEvent( event ) {
+
+ if ( this._targetRay !== null ) {
+
+ this._targetRay.dispatchEvent( event );
+
+ }
+
+ if ( this._grip !== null ) {
+
+ this._grip.dispatchEvent( event );
+
+ }
+
+ if ( this._hand !== null ) {
+
+ this._hand.dispatchEvent( event );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Connects the controller with the given XR input source.
+ *
+ * @param {XRInputSource} inputSource - The input source.
+ * @return {WebXRController} A reference to this instance.
+ */
+ connect( inputSource ) {
+
+ if ( inputSource && inputSource.hand ) {
+
+ const hand = this._hand;
+
+ if ( hand ) {
+
+ for ( const inputjoint of inputSource.hand.values() ) {
+
+ // Initialize hand with joints when connected
+ this._getHandJoint( hand, inputjoint );
+
+ }
+
+ }
+
+ }
+
+ this.dispatchEvent( { type: 'connected', data: inputSource } );
+
+ return this;
+
+ }
+
+ /**
+ * Disconnects the controller from the given XR input source.
+ *
+ * @param {XRInputSource} inputSource - The input source.
+ * @return {WebXRController} A reference to this instance.
+ */
+ disconnect( inputSource ) {
+
+ this.dispatchEvent( { type: 'disconnected', data: inputSource } );
+
+ if ( this._targetRay !== null ) {
+
+ this._targetRay.visible = false;
+
+ }
+
+ if ( this._grip !== null ) {
+
+ this._grip.visible = false;
+
+ }
+
+ if ( this._hand !== null ) {
+
+ this._hand.visible = false;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Updates the controller with the given input source, XR frame and reference space.
+ * This updates the transformations of the groups that represent the different
+ * coordinate systems of the controller.
+ *
+ * @param {XRInputSource} inputSource - The input source.
+ * @param {XRFrame} frame - The XR frame.
+ * @param {XRReferenceSpace} referenceSpace - The reference space.
+ * @return {WebXRController} A reference to this instance.
+ */
+ update( inputSource, frame, referenceSpace ) {
+
+ let inputPose = null;
+ let gripPose = null;
+ let handPose = null;
+
+ const targetRay = this._targetRay;
+ const grip = this._grip;
+ const hand = this._hand;
+
+ if ( inputSource && frame.session.visibilityState !== 'visible-blurred' ) {
+
+ if ( hand && inputSource.hand ) {
+
+ handPose = true;
+
+ for ( const inputjoint of inputSource.hand.values() ) {
+
+ // Update the joints groups with the XRJoint poses
+ const jointPose = frame.getJointPose( inputjoint, referenceSpace );
+
+ // The transform of this joint will be updated with the joint pose on each frame
+ const joint = this._getHandJoint( hand, inputjoint );
+
+ if ( jointPose !== null ) {
+
+ joint.matrix.fromArray( jointPose.transform.matrix );
+ joint.matrix.decompose( joint.position, joint.rotation, joint.scale );
+ joint.matrixWorldNeedsUpdate = true;
+ joint.jointRadius = jointPose.radius;
+
+ }
+
+ joint.visible = jointPose !== null;
+
+ }
+
+ // Custom events
+
+ // Check pinchz
+ const indexTip = hand.joints[ 'index-finger-tip' ];
+ const thumbTip = hand.joints[ 'thumb-tip' ];
+ const distance = indexTip.position.distanceTo( thumbTip.position );
+
+ const distanceToPinch = 0.02;
+ const threshold = 0.005;
+
+ if ( hand.inputState.pinching && distance > distanceToPinch + threshold ) {
+
+ hand.inputState.pinching = false;
+ this.dispatchEvent( {
+ type: 'pinchend',
+ handedness: inputSource.handedness,
+ target: this
+ } );
+
+ } else if ( ! hand.inputState.pinching && distance <= distanceToPinch - threshold ) {
+
+ hand.inputState.pinching = true;
+ this.dispatchEvent( {
+ type: 'pinchstart',
+ handedness: inputSource.handedness,
+ target: this
+ } );
+
+ }
+
+ } else {
+
+ if ( grip !== null && inputSource.gripSpace ) {
+
+ gripPose = frame.getPose( inputSource.gripSpace, referenceSpace );
+
+ if ( gripPose !== null ) {
+
+ grip.matrix.fromArray( gripPose.transform.matrix );
+ grip.matrix.decompose( grip.position, grip.rotation, grip.scale );
+ grip.matrixWorldNeedsUpdate = true;
+
+ if ( gripPose.linearVelocity ) {
+
+ grip.hasLinearVelocity = true;
+ grip.linearVelocity.copy( gripPose.linearVelocity );
+
+ } else {
+
+ grip.hasLinearVelocity = false;
+
+ }
+
+ if ( gripPose.angularVelocity ) {
+
+ grip.hasAngularVelocity = true;
+ grip.angularVelocity.copy( gripPose.angularVelocity );
+
+ } else {
+
+ grip.hasAngularVelocity = false;
+
+ }
+
+ // grip update event if enabled
+ if ( grip.eventsEnabled ) {
+
+ grip.dispatchEvent( {
+ type: 'gripUpdated',
+ data: inputSource,
+ target: this
+ } );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ if ( targetRay !== null ) {
+
+ inputPose = frame.getPose( inputSource.targetRaySpace, referenceSpace );
+
+ // Some runtimes (namely Vive Cosmos with Vive OpenXR Runtime) have only grip space and ray space is equal to it
+ if ( inputPose === null && gripPose !== null ) {
+
+ inputPose = gripPose;
+
+ }
+
+ if ( inputPose !== null ) {
+
+ targetRay.matrix.fromArray( inputPose.transform.matrix );
+ targetRay.matrix.decompose( targetRay.position, targetRay.rotation, targetRay.scale );
+ targetRay.matrixWorldNeedsUpdate = true;
+
+ if ( inputPose.linearVelocity ) {
+
+ targetRay.hasLinearVelocity = true;
+ targetRay.linearVelocity.copy( inputPose.linearVelocity );
+
+ } else {
+
+ targetRay.hasLinearVelocity = false;
+
+ }
+
+ if ( inputPose.angularVelocity ) {
+
+ targetRay.hasAngularVelocity = true;
+ targetRay.angularVelocity.copy( inputPose.angularVelocity );
+
+ } else {
+
+ targetRay.hasAngularVelocity = false;
+
+ }
+
+ this.dispatchEvent( _moveEvent );
+
+ }
+
+ }
+
+
+ }
+
+ if ( targetRay !== null ) {
+
+ targetRay.visible = ( inputPose !== null );
+
+ }
+
+ if ( grip !== null ) {
+
+ grip.visible = ( gripPose !== null );
+
+ }
+
+ if ( hand !== null ) {
+
+ hand.visible = ( handPose !== null );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns a group representing the hand joint for the given input joint.
+ *
+ * @private
+ * @param {Group} hand - The group representing the hand space.
+ * @param {XRJointSpace} inputjoint - The hand joint data.
+ * @return {Group} A group representing the hand joint for the given input joint.
+ */
+ _getHandJoint( hand, inputjoint ) {
+
+ if ( hand.joints[ inputjoint.jointName ] === undefined ) {
+
+ const joint = new Group();
+ joint.matrixAutoUpdate = false;
+ joint.visible = false;
+ hand.joints[ inputjoint.jointName ] = joint;
+
+ hand.add( joint );
+
+ }
+
+ return hand.joints[ inputjoint.jointName ];
+
+ }
+
+}
+
+const _colorKeywords = { 'aliceblue': 0xF0F8FF, 'antiquewhite': 0xFAEBD7, 'aqua': 0x00FFFF, 'aquamarine': 0x7FFFD4, 'azure': 0xF0FFFF,
+ 'beige': 0xF5F5DC, 'bisque': 0xFFE4C4, 'black': 0x000000, 'blanchedalmond': 0xFFEBCD, 'blue': 0x0000FF, 'blueviolet': 0x8A2BE2,
+ 'brown': 0xA52A2A, 'burlywood': 0xDEB887, 'cadetblue': 0x5F9EA0, 'chartreuse': 0x7FFF00, 'chocolate': 0xD2691E, 'coral': 0xFF7F50,
+ 'cornflowerblue': 0x6495ED, 'cornsilk': 0xFFF8DC, 'crimson': 0xDC143C, 'cyan': 0x00FFFF, 'darkblue': 0x00008B, 'darkcyan': 0x008B8B,
+ 'darkgoldenrod': 0xB8860B, 'darkgray': 0xA9A9A9, 'darkgreen': 0x006400, 'darkgrey': 0xA9A9A9, 'darkkhaki': 0xBDB76B, 'darkmagenta': 0x8B008B,
+ 'darkolivegreen': 0x556B2F, 'darkorange': 0xFF8C00, 'darkorchid': 0x9932CC, 'darkred': 0x8B0000, 'darksalmon': 0xE9967A, 'darkseagreen': 0x8FBC8F,
+ 'darkslateblue': 0x483D8B, 'darkslategray': 0x2F4F4F, 'darkslategrey': 0x2F4F4F, 'darkturquoise': 0x00CED1, 'darkviolet': 0x9400D3,
+ 'deeppink': 0xFF1493, 'deepskyblue': 0x00BFFF, 'dimgray': 0x696969, 'dimgrey': 0x696969, 'dodgerblue': 0x1E90FF, 'firebrick': 0xB22222,
+ 'floralwhite': 0xFFFAF0, 'forestgreen': 0x228B22, 'fuchsia': 0xFF00FF, 'gainsboro': 0xDCDCDC, 'ghostwhite': 0xF8F8FF, 'gold': 0xFFD700,
+ 'goldenrod': 0xDAA520, 'gray': 0x808080, 'green': 0x008000, 'greenyellow': 0xADFF2F, 'grey': 0x808080, 'honeydew': 0xF0FFF0, 'hotpink': 0xFF69B4,
+ 'indianred': 0xCD5C5C, 'indigo': 0x4B0082, 'ivory': 0xFFFFF0, 'khaki': 0xF0E68C, 'lavender': 0xE6E6FA, 'lavenderblush': 0xFFF0F5, 'lawngreen': 0x7CFC00,
+ 'lemonchiffon': 0xFFFACD, 'lightblue': 0xADD8E6, 'lightcoral': 0xF08080, 'lightcyan': 0xE0FFFF, 'lightgoldenrodyellow': 0xFAFAD2, 'lightgray': 0xD3D3D3,
+ 'lightgreen': 0x90EE90, 'lightgrey': 0xD3D3D3, 'lightpink': 0xFFB6C1, 'lightsalmon': 0xFFA07A, 'lightseagreen': 0x20B2AA, 'lightskyblue': 0x87CEFA,
+ 'lightslategray': 0x778899, 'lightslategrey': 0x778899, 'lightsteelblue': 0xB0C4DE, 'lightyellow': 0xFFFFE0, 'lime': 0x00FF00, 'limegreen': 0x32CD32,
+ 'linen': 0xFAF0E6, 'magenta': 0xFF00FF, 'maroon': 0x800000, 'mediumaquamarine': 0x66CDAA, 'mediumblue': 0x0000CD, 'mediumorchid': 0xBA55D3,
+ 'mediumpurple': 0x9370DB, 'mediumseagreen': 0x3CB371, 'mediumslateblue': 0x7B68EE, 'mediumspringgreen': 0x00FA9A, 'mediumturquoise': 0x48D1CC,
+ 'mediumvioletred': 0xC71585, 'midnightblue': 0x191970, 'mintcream': 0xF5FFFA, 'mistyrose': 0xFFE4E1, 'moccasin': 0xFFE4B5, 'navajowhite': 0xFFDEAD,
+ 'navy': 0x000080, 'oldlace': 0xFDF5E6, 'olive': 0x808000, 'olivedrab': 0x6B8E23, 'orange': 0xFFA500, 'orangered': 0xFF4500, 'orchid': 0xDA70D6,
+ 'palegoldenrod': 0xEEE8AA, 'palegreen': 0x98FB98, 'paleturquoise': 0xAFEEEE, 'palevioletred': 0xDB7093, 'papayawhip': 0xFFEFD5, 'peachpuff': 0xFFDAB9,
+ 'peru': 0xCD853F, 'pink': 0xFFC0CB, 'plum': 0xDDA0DD, 'powderblue': 0xB0E0E6, 'purple': 0x800080, 'rebeccapurple': 0x663399, 'red': 0xFF0000, 'rosybrown': 0xBC8F8F,
+ 'royalblue': 0x4169E1, 'saddlebrown': 0x8B4513, 'salmon': 0xFA8072, 'sandybrown': 0xF4A460, 'seagreen': 0x2E8B57, 'seashell': 0xFFF5EE,
+ 'sienna': 0xA0522D, 'silver': 0xC0C0C0, 'skyblue': 0x87CEEB, 'slateblue': 0x6A5ACD, 'slategray': 0x708090, 'slategrey': 0x708090, 'snow': 0xFFFAFA,
+ 'springgreen': 0x00FF7F, 'steelblue': 0x4682B4, 'tan': 0xD2B48C, 'teal': 0x008080, 'thistle': 0xD8BFD8, 'tomato': 0xFF6347, 'turquoise': 0x40E0D0,
+ 'violet': 0xEE82EE, 'wheat': 0xF5DEB3, 'white': 0xFFFFFF, 'whitesmoke': 0xF5F5F5, 'yellow': 0xFFFF00, 'yellowgreen': 0x9ACD32 };
+
+const _hslA = { h: 0, s: 0, l: 0 };
+const _hslB = { h: 0, s: 0, l: 0 };
+
+function hue2rgb( p, q, t ) {
+
+ if ( t < 0 ) t += 1;
+ if ( t > 1 ) t -= 1;
+ if ( t < 1 / 6 ) return p + ( q - p ) * 6 * t;
+ if ( t < 1 / 2 ) return q;
+ if ( t < 2 / 3 ) return p + ( q - p ) * 6 * ( 2 / 3 - t );
+ return p;
+
+}
+
+/**
+ * A Color instance is represented by RGB components in the linear working
+ * color space , which defaults to `LinearSRGBColorSpace`. Inputs
+ * conventionally using `SRGBColorSpace` (such as hexadecimals and CSS
+ * strings) are converted to the working color space automatically.
+ *
+ * ```js
+ * // converted automatically from SRGBColorSpace to LinearSRGBColorSpace
+ * const color = new THREE.Color().setHex( 0x112233 );
+ * ```
+ * Source color spaces may be specified explicitly, to ensure correct conversions.
+ * ```js
+ * // assumed already LinearSRGBColorSpace; no conversion
+ * const color = new THREE.Color().setRGB( 0.5, 0.5, 0.5 );
+ *
+ * // converted explicitly from SRGBColorSpace to LinearSRGBColorSpace
+ * const color = new THREE.Color().setRGB( 0.5, 0.5, 0.5, SRGBColorSpace );
+ * ```
+ * If THREE.ColorManagement is disabled, no conversions occur. For details,
+ * see Color management . Iterating through a Color instance will yield
+ * its components (r, g, b) in the corresponding order. A Color can be initialised
+ * in any of the following ways:
+ * ```js
+ * //empty constructor - will default white
+ * const color1 = new THREE.Color();
+ *
+ * //Hexadecimal color (recommended)
+ * const color2 = new THREE.Color( 0xff0000 );
+ *
+ * //RGB string
+ * const color3 = new THREE.Color("rgb(255, 0, 0)");
+ * const color4 = new THREE.Color("rgb(100%, 0%, 0%)");
+ *
+ * //X11 color name - all 140 color names are supported.
+ * //Note the lack of CamelCase in the name
+ * const color5 = new THREE.Color( 'skyblue' );
+ * //HSL string
+ * const color6 = new THREE.Color("hsl(0, 100%, 50%)");
+ *
+ * //Separate RGB values between 0 and 1
+ * const color7 = new THREE.Color( 1, 0, 0 );
+ * ```
+ */
+class Color {
+
+ /**
+ * Constructs a new color.
+ *
+ * Note that standard method of specifying color in three.js is with a hexadecimal triplet,
+ * and that method is used throughout the rest of the documentation.
+ *
+ * @param {(number|string|Color)} [r] - The red component of the color. If `g` and `b` are
+ * not provided, it can be hexadecimal triplet, a CSS-style string or another `Color` instance.
+ * @param {number} [g] - The green component.
+ * @param {number} [b] - The blue component.
+ */
+ constructor( r, g, b ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isColor = true;
+
+ /**
+ * The red component.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.r = 1;
+
+ /**
+ * The green component.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.g = 1;
+
+ /**
+ * The blue component.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.b = 1;
+
+ return this.set( r, g, b );
+
+ }
+
+ /**
+ * Sets the colors's components from the given values.
+ *
+ * @param {(number|string|Color)} [r] - The red component of the color. If `g` and `b` are
+ * not provided, it can be hexadecimal triplet, a CSS-style string or another `Color` instance.
+ * @param {number} [g] - The green component.
+ * @param {number} [b] - The blue component.
+ * @return {Color} A reference to this color.
+ */
+ set( r, g, b ) {
+
+ if ( g === undefined && b === undefined ) {
+
+ // r is THREE.Color, hex or string
+
+ const value = r;
+
+ if ( value && value.isColor ) {
+
+ this.copy( value );
+
+ } else if ( typeof value === 'number' ) {
+
+ this.setHex( value );
+
+ } else if ( typeof value === 'string' ) {
+
+ this.setStyle( value );
+
+ }
+
+ } else {
+
+ this.setRGB( r, g, b );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the colors's components to the given scalar value.
+ *
+ * @param {number} scalar - The scalar value.
+ * @return {Color} A reference to this color.
+ */
+ setScalar( scalar ) {
+
+ this.r = scalar;
+ this.g = scalar;
+ this.b = scalar;
+
+ return this;
+
+ }
+
+ /**
+ * Sets this color from a hexadecimal value.
+ *
+ * @param {number} hex - The hexadecimal value.
+ * @param {string} [colorSpace=SRGBColorSpace] - The color space.
+ * @return {Color} A reference to this color.
+ */
+ setHex( hex, colorSpace = SRGBColorSpace ) {
+
+ hex = Math.floor( hex );
+
+ this.r = ( hex >> 16 & 255 ) / 255;
+ this.g = ( hex >> 8 & 255 ) / 255;
+ this.b = ( hex & 255 ) / 255;
+
+ ColorManagement.colorSpaceToWorking( this, colorSpace );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this color from RGB values.
+ *
+ * @param {number} r - Red channel value between `0.0` and `1.0`.
+ * @param {number} g - Green channel value between `0.0` and `1.0`.
+ * @param {number} b - Blue channel value between `0.0` and `1.0`.
+ * @param {string} [colorSpace=ColorManagement.workingColorSpace] - The color space.
+ * @return {Color} A reference to this color.
+ */
+ setRGB( r, g, b, colorSpace = ColorManagement.workingColorSpace ) {
+
+ this.r = r;
+ this.g = g;
+ this.b = b;
+
+ ColorManagement.colorSpaceToWorking( this, colorSpace );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this color from RGB values.
+ *
+ * @param {number} h - Hue value between `0.0` and `1.0`.
+ * @param {number} s - Saturation value between `0.0` and `1.0`.
+ * @param {number} l - Lightness value between `0.0` and `1.0`.
+ * @param {string} [colorSpace=ColorManagement.workingColorSpace] - The color space.
+ * @return {Color} A reference to this color.
+ */
+ setHSL( h, s, l, colorSpace = ColorManagement.workingColorSpace ) {
+
+ // h,s,l ranges are in 0.0 - 1.0
+ h = euclideanModulo( h, 1 );
+ s = clamp( s, 0, 1 );
+ l = clamp( l, 0, 1 );
+
+ if ( s === 0 ) {
+
+ this.r = this.g = this.b = l;
+
+ } else {
+
+ const p = l <= 0.5 ? l * ( 1 + s ) : l + s - ( l * s );
+ const q = ( 2 * l ) - p;
+
+ this.r = hue2rgb( q, p, h + 1 / 3 );
+ this.g = hue2rgb( q, p, h );
+ this.b = hue2rgb( q, p, h - 1 / 3 );
+
+ }
+
+ ColorManagement.colorSpaceToWorking( this, colorSpace );
+
+ return this;
+
+ }
+
+ /**
+ * Sets this color from a CSS-style string. For example, `rgb(250, 0,0)`,
+ * `rgb(100%, 0%, 0%)`, `hsl(0, 100%, 50%)`, `#ff0000`, `#f00`, or `red` ( or
+ * any [X11 color name](https://en.wikipedia.org/wiki/X11_color_names#Color_name_chart) -
+ * all 140 color names are supported).
+ *
+ * @param {string} style - Color as a CSS-style string.
+ * @param {string} [colorSpace=SRGBColorSpace] - The color space.
+ * @return {Color} A reference to this color.
+ */
+ setStyle( style, colorSpace = SRGBColorSpace ) {
+
+ function handleAlpha( string ) {
+
+ if ( string === undefined ) return;
+
+ if ( parseFloat( string ) < 1 ) {
+
+ warn( 'Color: Alpha component of ' + style + ' will be ignored.' );
+
+ }
+
+ }
+
+
+ let m;
+
+ if ( m = /^(\w+)\(([^\)]*)\)/.exec( style ) ) {
+
+ // rgb / hsl
+
+ let color;
+ const name = m[ 1 ];
+ const components = m[ 2 ];
+
+ switch ( name ) {
+
+ case 'rgb':
+ case 'rgba':
+
+ if ( color = /^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec( components ) ) {
+
+ // rgb(255,0,0) rgba(255,0,0,0.5)
+
+ handleAlpha( color[ 4 ] );
+
+ return this.setRGB(
+ Math.min( 255, parseInt( color[ 1 ], 10 ) ) / 255,
+ Math.min( 255, parseInt( color[ 2 ], 10 ) ) / 255,
+ Math.min( 255, parseInt( color[ 3 ], 10 ) ) / 255,
+ colorSpace
+ );
+
+ }
+
+ if ( color = /^\s*(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec( components ) ) {
+
+ // rgb(100%,0%,0%) rgba(100%,0%,0%,0.5)
+
+ handleAlpha( color[ 4 ] );
+
+ return this.setRGB(
+ Math.min( 100, parseInt( color[ 1 ], 10 ) ) / 100,
+ Math.min( 100, parseInt( color[ 2 ], 10 ) ) / 100,
+ Math.min( 100, parseInt( color[ 3 ], 10 ) ) / 100,
+ colorSpace
+ );
+
+ }
+
+ break;
+
+ case 'hsl':
+ case 'hsla':
+
+ if ( color = /^\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\%\s*,\s*(\d*\.?\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec( components ) ) {
+
+ // hsl(120,50%,50%) hsla(120,50%,50%,0.5)
+
+ handleAlpha( color[ 4 ] );
+
+ return this.setHSL(
+ parseFloat( color[ 1 ] ) / 360,
+ parseFloat( color[ 2 ] ) / 100,
+ parseFloat( color[ 3 ] ) / 100,
+ colorSpace
+ );
+
+ }
+
+ break;
+
+ default:
+
+ warn( 'Color: Unknown color model ' + style );
+
+ }
+
+ } else if ( m = /^\#([A-Fa-f\d]+)$/.exec( style ) ) {
+
+ // hex color
+
+ const hex = m[ 1 ];
+ const size = hex.length;
+
+ if ( size === 3 ) {
+
+ // #ff0
+ return this.setRGB(
+ parseInt( hex.charAt( 0 ), 16 ) / 15,
+ parseInt( hex.charAt( 1 ), 16 ) / 15,
+ parseInt( hex.charAt( 2 ), 16 ) / 15,
+ colorSpace
+ );
+
+ } else if ( size === 6 ) {
+
+ // #ff0000
+ return this.setHex( parseInt( hex, 16 ), colorSpace );
+
+ } else {
+
+ warn( 'Color: Invalid hex color ' + style );
+
+ }
+
+ } else if ( style && style.length > 0 ) {
+
+ return this.setColorName( style, colorSpace );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets this color from a color name. Faster than {@link Color#setStyle} if
+ * you don't need the other CSS-style formats.
+ *
+ * For convenience, the list of names is exposed in `Color.NAMES` as a hash.
+ * ```js
+ * Color.NAMES.aliceblue // returns 0xF0F8FF
+ * ```
+ *
+ * @param {string} style - The color name.
+ * @param {string} [colorSpace=SRGBColorSpace] - The color space.
+ * @return {Color} A reference to this color.
+ */
+ setColorName( style, colorSpace = SRGBColorSpace ) {
+
+ // color keywords
+ const hex = _colorKeywords[ style.toLowerCase() ];
+
+ if ( hex !== undefined ) {
+
+ // red
+ this.setHex( hex, colorSpace );
+
+ } else {
+
+ // unknown color
+ warn( 'Color: Unknown color ' + style );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new color with copied values from this instance.
+ *
+ * @return {Color} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor( this.r, this.g, this.b );
+
+ }
+
+ /**
+ * Copies the values of the given color to this instance.
+ *
+ * @param {Color} color - The color to copy.
+ * @return {Color} A reference to this color.
+ */
+ copy( color ) {
+
+ this.r = color.r;
+ this.g = color.g;
+ this.b = color.b;
+
+ return this;
+
+ }
+
+ /**
+ * Copies the given color into this color, and then converts this color from
+ * `SRGBColorSpace` to `LinearSRGBColorSpace`.
+ *
+ * @param {Color} color - The color to copy/convert.
+ * @return {Color} A reference to this color.
+ */
+ copySRGBToLinear( color ) {
+
+ this.r = SRGBToLinear( color.r );
+ this.g = SRGBToLinear( color.g );
+ this.b = SRGBToLinear( color.b );
+
+ return this;
+
+ }
+
+ /**
+ * Copies the given color into this color, and then converts this color from
+ * `LinearSRGBColorSpace` to `SRGBColorSpace`.
+ *
+ * @param {Color} color - The color to copy/convert.
+ * @return {Color} A reference to this color.
+ */
+ copyLinearToSRGB( color ) {
+
+ this.r = LinearToSRGB( color.r );
+ this.g = LinearToSRGB( color.g );
+ this.b = LinearToSRGB( color.b );
+
+ return this;
+
+ }
+
+ /**
+ * Converts this color from `SRGBColorSpace` to `LinearSRGBColorSpace`.
+ *
+ * @return {Color} A reference to this color.
+ */
+ convertSRGBToLinear() {
+
+ this.copySRGBToLinear( this );
+
+ return this;
+
+ }
+
+ /**
+ * Converts this color from `LinearSRGBColorSpace` to `SRGBColorSpace`.
+ *
+ * @return {Color} A reference to this color.
+ */
+ convertLinearToSRGB() {
+
+ this.copyLinearToSRGB( this );
+
+ return this;
+
+ }
+
+ /**
+ * Returns the hexadecimal value of this color.
+ *
+ * @param {string} [colorSpace=SRGBColorSpace] - The color space.
+ * @return {number} The hexadecimal value.
+ */
+ getHex( colorSpace = SRGBColorSpace ) {
+
+ ColorManagement.workingToColorSpace( _color.copy( this ), colorSpace );
+
+ return Math.round( clamp( _color.r * 255, 0, 255 ) ) * 65536 + Math.round( clamp( _color.g * 255, 0, 255 ) ) * 256 + Math.round( clamp( _color.b * 255, 0, 255 ) );
+
+ }
+
+ /**
+ * Returns the hexadecimal value of this color as a string (for example, 'FFFFFF').
+ *
+ * @param {string} [colorSpace=SRGBColorSpace] - The color space.
+ * @return {string} The hexadecimal value as a string.
+ */
+ getHexString( colorSpace = SRGBColorSpace ) {
+
+ return ( '000000' + this.getHex( colorSpace ).toString( 16 ) ).slice( -6 );
+
+ }
+
+ /**
+ * Converts the colors RGB values into the HSL format and stores them into the
+ * given target object.
+ *
+ * @param {{h:number,s:number,l:number}} target - The target object that is used to store the method's result.
+ * @param {string} [colorSpace=ColorManagement.workingColorSpace] - The color space.
+ * @return {{h:number,s:number,l:number}} The HSL representation of this color.
+ */
+ getHSL( target, colorSpace = ColorManagement.workingColorSpace ) {
+
+ // h,s,l ranges are in 0.0 - 1.0
+
+ ColorManagement.workingToColorSpace( _color.copy( this ), colorSpace );
+
+ const r = _color.r, g = _color.g, b = _color.b;
+
+ const max = Math.max( r, g, b );
+ const min = Math.min( r, g, b );
+
+ let hue, saturation;
+ const lightness = ( min + max ) / 2.0;
+
+ if ( min === max ) {
+
+ hue = 0;
+ saturation = 0;
+
+ } else {
+
+ const delta = max - min;
+
+ saturation = lightness <= 0.5 ? delta / ( max + min ) : delta / ( 2 - max - min );
+
+ switch ( max ) {
+
+ case r: hue = ( g - b ) / delta + ( g < b ? 6 : 0 ); break;
+ case g: hue = ( b - r ) / delta + 2; break;
+ case b: hue = ( r - g ) / delta + 4; break;
+
+ }
+
+ hue /= 6;
+
+ }
+
+ target.h = hue;
+ target.s = saturation;
+ target.l = lightness;
+
+ return target;
+
+ }
+
+ /**
+ * Returns the RGB values of this color and stores them into the given target object.
+ *
+ * @param {Color} target - The target color that is used to store the method's result.
+ * @param {string} [colorSpace=ColorManagement.workingColorSpace] - The color space.
+ * @return {Color} The RGB representation of this color.
+ */
+ getRGB( target, colorSpace = ColorManagement.workingColorSpace ) {
+
+ ColorManagement.workingToColorSpace( _color.copy( this ), colorSpace );
+
+ target.r = _color.r;
+ target.g = _color.g;
+ target.b = _color.b;
+
+ return target;
+
+ }
+
+ /**
+ * Returns the value of this color as a CSS style string. Example: `rgb(255,0,0)`.
+ *
+ * @param {string} [colorSpace=SRGBColorSpace] - The color space.
+ * @return {string} The CSS representation of this color.
+ */
+ getStyle( colorSpace = SRGBColorSpace ) {
+
+ ColorManagement.workingToColorSpace( _color.copy( this ), colorSpace );
+
+ const r = _color.r, g = _color.g, b = _color.b;
+
+ if ( colorSpace !== SRGBColorSpace ) {
+
+ // Requires CSS Color Module Level 4 (https://www.w3.org/TR/css-color-4/).
+ return `color(${ colorSpace } ${ r.toFixed( 3 ) } ${ g.toFixed( 3 ) } ${ b.toFixed( 3 ) })`;
+
+ }
+
+ return `rgb(${ Math.round( r * 255 ) },${ Math.round( g * 255 ) },${ Math.round( b * 255 ) })`;
+
+ }
+
+ /**
+ * Adds the given HSL values to this color's values.
+ * Internally, this converts the color's RGB values to HSL, adds HSL
+ * and then converts the color back to RGB.
+ *
+ * @param {number} h - Hue value between `0.0` and `1.0`.
+ * @param {number} s - Saturation value between `0.0` and `1.0`.
+ * @param {number} l - Lightness value between `0.0` and `1.0`.
+ * @return {Color} A reference to this color.
+ */
+ offsetHSL( h, s, l ) {
+
+ this.getHSL( _hslA );
+
+ return this.setHSL( _hslA.h + h, _hslA.s + s, _hslA.l + l );
+
+ }
+
+ /**
+ * Adds the RGB values of the given color to the RGB values of this color.
+ *
+ * @param {Color} color - The color to add.
+ * @return {Color} A reference to this color.
+ */
+ add( color ) {
+
+ this.r += color.r;
+ this.g += color.g;
+ this.b += color.b;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the RGB values of the given colors and stores the result in this instance.
+ *
+ * @param {Color} color1 - The first color.
+ * @param {Color} color2 - The second color.
+ * @return {Color} A reference to this color.
+ */
+ addColors( color1, color2 ) {
+
+ this.r = color1.r + color2.r;
+ this.g = color1.g + color2.g;
+ this.b = color1.b + color2.b;
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given scalar value to the RGB values of this color.
+ *
+ * @param {number} s - The scalar to add.
+ * @return {Color} A reference to this color.
+ */
+ addScalar( s ) {
+
+ this.r += s;
+ this.g += s;
+ this.b += s;
+
+ return this;
+
+ }
+
+ /**
+ * Subtracts the RGB values of the given color from the RGB values of this color.
+ *
+ * @param {Color} color - The color to subtract.
+ * @return {Color} A reference to this color.
+ */
+ sub( color ) {
+
+ this.r = Math.max( 0, this.r - color.r );
+ this.g = Math.max( 0, this.g - color.g );
+ this.b = Math.max( 0, this.b - color.b );
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the RGB values of the given color with the RGB values of this color.
+ *
+ * @param {Color} color - The color to multiply.
+ * @return {Color} A reference to this color.
+ */
+ multiply( color ) {
+
+ this.r *= color.r;
+ this.g *= color.g;
+ this.b *= color.b;
+
+ return this;
+
+ }
+
+ /**
+ * Multiplies the given scalar value with the RGB values of this color.
+ *
+ * @param {number} s - The scalar to multiply.
+ * @return {Color} A reference to this color.
+ */
+ multiplyScalar( s ) {
+
+ this.r *= s;
+ this.g *= s;
+ this.b *= s;
+
+ return this;
+
+ }
+
+ /**
+ * Linearly interpolates this color's RGB values toward the RGB values of the
+ * given color. The alpha argument can be thought of as the ratio between
+ * the two colors, where `0.0` is this color and `1.0` is the first argument.
+ *
+ * @param {Color} color - The color to converge on.
+ * @param {number} alpha - The interpolation factor in the closed interval `[0,1]`.
+ * @return {Color} A reference to this color.
+ */
+ lerp( color, alpha ) {
+
+ this.r += ( color.r - this.r ) * alpha;
+ this.g += ( color.g - this.g ) * alpha;
+ this.b += ( color.b - this.b ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Linearly interpolates between the given colors and stores the result in this instance.
+ * The alpha argument can be thought of as the ratio between the two colors, where `0.0`
+ * is the first and `1.0` is the second color.
+ *
+ * @param {Color} color1 - The first color.
+ * @param {Color} color2 - The second color.
+ * @param {number} alpha - The interpolation factor in the closed interval `[0,1]`.
+ * @return {Color} A reference to this color.
+ */
+ lerpColors( color1, color2, alpha ) {
+
+ this.r = color1.r + ( color2.r - color1.r ) * alpha;
+ this.g = color1.g + ( color2.g - color1.g ) * alpha;
+ this.b = color1.b + ( color2.b - color1.b ) * alpha;
+
+ return this;
+
+ }
+
+ /**
+ * Linearly interpolates this color's HSL values toward the HSL values of the
+ * given color. It differs from {@link Color#lerp} by not interpolating straight
+ * from one color to the other, but instead going through all the hues in between
+ * those two colors. The alpha argument can be thought of as the ratio between
+ * the two colors, where 0.0 is this color and 1.0 is the first argument.
+ *
+ * @param {Color} color - The color to converge on.
+ * @param {number} alpha - The interpolation factor in the closed interval `[0,1]`.
+ * @return {Color} A reference to this color.
+ */
+ lerpHSL( color, alpha ) {
+
+ this.getHSL( _hslA );
+ color.getHSL( _hslB );
+
+ const h = lerp( _hslA.h, _hslB.h, alpha );
+ const s = lerp( _hslA.s, _hslB.s, alpha );
+ const l = lerp( _hslA.l, _hslB.l, alpha );
+
+ this.setHSL( h, s, l );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the color's RGB components from the given 3D vector.
+ *
+ * @param {Vector3} v - The vector to set.
+ * @return {Color} A reference to this color.
+ */
+ setFromVector3( v ) {
+
+ this.r = v.x;
+ this.g = v.y;
+ this.b = v.z;
+
+ return this;
+
+ }
+
+ /**
+ * Transforms this color with the given 3x3 matrix.
+ *
+ * @param {Matrix3} m - The matrix.
+ * @return {Color} A reference to this color.
+ */
+ applyMatrix3( m ) {
+
+ const r = this.r, g = this.g, b = this.b;
+ const e = m.elements;
+
+ this.r = e[ 0 ] * r + e[ 3 ] * g + e[ 6 ] * b;
+ this.g = e[ 1 ] * r + e[ 4 ] * g + e[ 7 ] * b;
+ this.b = e[ 2 ] * r + e[ 5 ] * g + e[ 8 ] * b;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this color is equal with the given one.
+ *
+ * @param {Color} c - The color to test for equality.
+ * @return {boolean} Whether this bounding color is equal with the given one.
+ */
+ equals( c ) {
+
+ return ( c.r === this.r ) && ( c.g === this.g ) && ( c.b === this.b );
+
+ }
+
+ /**
+ * Sets this color's RGB components from the given array.
+ *
+ * @param {Array} array - An array holding the RGB values.
+ * @param {number} [offset=0] - The offset into the array.
+ * @return {Color} A reference to this color.
+ */
+ fromArray( array, offset = 0 ) {
+
+ this.r = array[ offset ];
+ this.g = array[ offset + 1 ];
+ this.b = array[ offset + 2 ];
+
+ return this;
+
+ }
+
+ /**
+ * Writes the RGB components of this color to the given array. If no array is provided,
+ * the method returns a new instance.
+ *
+ * @param {Array} [array=[]] - The target array holding the color components.
+ * @param {number} [offset=0] - Index of the first element in the array.
+ * @return {Array} The color components.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ array[ offset ] = this.r;
+ array[ offset + 1 ] = this.g;
+ array[ offset + 2 ] = this.b;
+
+ return array;
+
+ }
+
+ /**
+ * Sets the components of this color from the given buffer attribute.
+ *
+ * @param {BufferAttribute} attribute - The buffer attribute holding color data.
+ * @param {number} index - The index into the attribute.
+ * @return {Color} A reference to this color.
+ */
+ fromBufferAttribute( attribute, index ) {
+
+ this.r = attribute.getX( index );
+ this.g = attribute.getY( index );
+ this.b = attribute.getZ( index );
+
+ return this;
+
+ }
+
+ /**
+ * This methods defines the serialization result of this class. Returns the color
+ * as a hexadecimal value.
+ *
+ * @return {number} The hexadecimal value.
+ */
+ toJSON() {
+
+ return this.getHex();
+
+ }
+
+ *[ Symbol.iterator ]() {
+
+ yield this.r;
+ yield this.g;
+ yield this.b;
+
+ }
+
+}
+
+const _color = /*@__PURE__*/ new Color();
+
+/**
+ * A dictionary with X11 color names.
+ *
+ * Note that multiple words such as Dark Orange become the string 'darkorange'.
+ *
+ * @static
+ * @type {Object}
+ */
+Color.NAMES = _colorKeywords;
+
+/**
+ * This class can be used to define an exponential squared fog,
+ * which gives a clear view near the camera and a faster than exponentially
+ * densening fog farther from the camera.
+ *
+ * ```js
+ * const scene = new THREE.Scene();
+ * scene.fog = new THREE.FogExp2( 0xcccccc, 0.002 );
+ * ```
+ */
+class FogExp2 {
+
+ /**
+ * Constructs a new fog.
+ *
+ * @param {number|Color} color - The fog's color.
+ * @param {number} [density=0.00025] - Defines how fast the fog will grow dense.
+ */
+ constructor( color, density = 0.00025 ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isFogExp2 = true;
+
+ /**
+ * The name of the fog.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The fog's color.
+ *
+ * @type {Color}
+ */
+ this.color = new Color( color );
+
+ /**
+ * Defines how fast the fog will grow dense.
+ *
+ * @type {number}
+ * @default 0.00025
+ */
+ this.density = density;
+
+ }
+
+ /**
+ * Returns a new fog with copied values from this instance.
+ *
+ * @return {FogExp2} A clone of this instance.
+ */
+ clone() {
+
+ return new FogExp2( this.color, this.density );
+
+ }
+
+ /**
+ * Serializes the fog into JSON.
+ *
+ * @param {?(Object|string)} meta - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized fog
+ */
+ toJSON( /* meta */ ) {
+
+ return {
+ type: 'FogExp2',
+ name: this.name,
+ color: this.color.getHex(),
+ density: this.density
+ };
+
+ }
+
+}
+
+/**
+ * This class can be used to define a linear fog that grows linearly denser
+ * with the distance.
+ *
+ * ```js
+ * const scene = new THREE.Scene();
+ * scene.fog = new THREE.Fog( 0xcccccc, 10, 15 );
+ * ```
+ */
+class Fog {
+
+ /**
+ * Constructs a new fog.
+ *
+ * @param {number|Color} color - The fog's color.
+ * @param {number} [near=1] - The minimum distance to start applying fog.
+ * @param {number} [far=1000] - The maximum distance at which fog stops being calculated and applied.
+ */
+ constructor( color, near = 1, far = 1000 ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isFog = true;
+
+ /**
+ * The name of the fog.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The fog's color.
+ *
+ * @type {Color}
+ */
+ this.color = new Color( color );
+
+ /**
+ * The minimum distance to start applying fog. Objects that are less than
+ * `near` units from the active camera won't be affected by fog.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.near = near;
+
+ /**
+ * The maximum distance at which fog stops being calculated and applied.
+ * Objects that are more than `far` units away from the active camera won't
+ * be affected by fog.
+ *
+ * @type {number}
+ * @default 1000
+ */
+ this.far = far;
+
+ }
+
+ /**
+ * Returns a new fog with copied values from this instance.
+ *
+ * @return {Fog} A clone of this instance.
+ */
+ clone() {
+
+ return new Fog( this.color, this.near, this.far );
+
+ }
+
+ /**
+ * Serializes the fog into JSON.
+ *
+ * @param {?(Object|string)} meta - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized fog
+ */
+ toJSON( /* meta */ ) {
+
+ return {
+ type: 'Fog',
+ name: this.name,
+ color: this.color.getHex(),
+ near: this.near,
+ far: this.far
+ };
+
+ }
+
+}
+
+/**
+ * Scenes allow you to set up what is to be rendered and where by three.js.
+ * This is where you place 3D objects like meshes, lines or lights.
+ *
+ * @augments Object3D
+ */
+class Scene extends Object3D {
+
+ /**
+ * Constructs a new scene.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isScene = true;
+
+ this.type = 'Scene';
+
+ /**
+ * Defines the background of the scene. Valid inputs are:
+ *
+ * - A color for defining a uniform colored background.
+ * - A texture for defining a (flat) textured background.
+ * - Cube textures or equirectangular textures for defining a skybox.
+ *
+ * @type {?(Color|Texture)}
+ * @default null
+ */
+ this.background = null;
+
+ /**
+ * Sets the environment map for all physical materials in the scene. However,
+ * it's not possible to overwrite an existing texture assigned to the `envMap`
+ * material property.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.environment = null;
+
+ /**
+ * A fog instance defining the type of fog that affects everything
+ * rendered in the scene.
+ *
+ * @type {?(Fog|FogExp2)}
+ * @default null
+ */
+ this.fog = null;
+
+ /**
+ * Sets the blurriness of the background. Only influences environment maps
+ * assigned to {@link Scene#background}. Valid input is a float between `0`
+ * and `1`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.backgroundBlurriness = 0;
+
+ /**
+ * Attenuates the color of the background. Only applies to background textures.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.backgroundIntensity = 1;
+
+ /**
+ * The rotation of the background in radians. Only influences environment maps
+ * assigned to {@link Scene#background}.
+ *
+ * @type {Euler}
+ * @default (0,0,0)
+ */
+ this.backgroundRotation = new Euler();
+
+ /**
+ * Attenuates the color of the environment. Only influences environment maps
+ * assigned to {@link Scene#environment}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.environmentIntensity = 1;
+
+ /**
+ * The rotation of the environment map in radians. Only influences physical materials
+ * in the scene when {@link Scene#environment} is used.
+ *
+ * @type {Euler}
+ * @default (0,0,0)
+ */
+ this.environmentRotation = new Euler();
+
+ /**
+ * Forces everything in the scene to be rendered with the defined material. It is possible
+ * to exclude materials from override by setting {@link Material#allowOverride} to `false`.
+ *
+ * @type {?Material}
+ * @default null
+ */
+ this.overrideMaterial = null;
+
+ if ( typeof __THREE_DEVTOOLS__ !== 'undefined' ) {
+
+ __THREE_DEVTOOLS__.dispatchEvent( new CustomEvent( 'observe', { detail: this } ) );
+
+ }
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ if ( source.background !== null ) this.background = source.background.clone();
+ if ( source.environment !== null ) this.environment = source.environment.clone();
+ if ( source.fog !== null ) this.fog = source.fog.clone();
+
+ this.backgroundBlurriness = source.backgroundBlurriness;
+ this.backgroundIntensity = source.backgroundIntensity;
+ this.backgroundRotation.copy( source.backgroundRotation );
+
+ this.environmentIntensity = source.environmentIntensity;
+ this.environmentRotation.copy( source.environmentRotation );
+
+ if ( source.overrideMaterial !== null ) this.overrideMaterial = source.overrideMaterial.clone();
+
+ this.matrixAutoUpdate = source.matrixAutoUpdate;
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ if ( this.fog !== null ) data.object.fog = this.fog.toJSON();
+
+ data.object.backgroundBlurriness = this.backgroundBlurriness;
+ data.object.backgroundIntensity = this.backgroundIntensity;
+ data.object.backgroundRotation = this.backgroundRotation.toArray();
+
+ data.object.environmentIntensity = this.environmentIntensity;
+ data.object.environmentRotation = this.environmentRotation.toArray();
+
+ return data;
+
+ }
+
+}
+
+const _v0$2 = /*@__PURE__*/ new Vector3();
+const _v1$5 = /*@__PURE__*/ new Vector3();
+const _v2$4 = /*@__PURE__*/ new Vector3();
+const _v3$2 = /*@__PURE__*/ new Vector3();
+
+const _vab = /*@__PURE__*/ new Vector3();
+const _vac = /*@__PURE__*/ new Vector3();
+const _vbc = /*@__PURE__*/ new Vector3();
+const _vap = /*@__PURE__*/ new Vector3();
+const _vbp = /*@__PURE__*/ new Vector3();
+const _vcp = /*@__PURE__*/ new Vector3();
+
+const _v40 = /*@__PURE__*/ new Vector4();
+const _v41 = /*@__PURE__*/ new Vector4();
+const _v42 = /*@__PURE__*/ new Vector4();
+
+/**
+ * A geometric triangle as defined by three vectors representing its three corners.
+ */
+class Triangle {
+
+ /**
+ * Constructs a new triangle.
+ *
+ * @param {Vector3} [a=(0,0,0)] - The first corner of the triangle.
+ * @param {Vector3} [b=(0,0,0)] - The second corner of the triangle.
+ * @param {Vector3} [c=(0,0,0)] - The third corner of the triangle.
+ */
+ constructor( a = new Vector3(), b = new Vector3(), c = new Vector3() ) {
+
+ /**
+ * The first corner of the triangle.
+ *
+ * @type {Vector3}
+ */
+ this.a = a;
+
+ /**
+ * The second corner of the triangle.
+ *
+ * @type {Vector3}
+ */
+ this.b = b;
+
+ /**
+ * The third corner of the triangle.
+ *
+ * @type {Vector3}
+ */
+ this.c = c;
+
+ }
+
+ /**
+ * Computes the normal vector of a triangle.
+ *
+ * @param {Vector3} a - The first corner of the triangle.
+ * @param {Vector3} b - The second corner of the triangle.
+ * @param {Vector3} c - The third corner of the triangle.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The triangle's normal.
+ */
+ static getNormal( a, b, c, target ) {
+
+ target.subVectors( c, b );
+ _v0$2.subVectors( a, b );
+ target.cross( _v0$2 );
+
+ const targetLengthSq = target.lengthSq();
+ if ( targetLengthSq > 0 ) {
+
+ return target.multiplyScalar( 1 / Math.sqrt( targetLengthSq ) );
+
+ }
+
+ return target.set( 0, 0, 0 );
+
+ }
+
+ /**
+ * Computes a barycentric coordinates from the given vector.
+ * Returns `null` if the triangle is degenerate.
+ *
+ * @param {Vector3} point - A point in 3D space.
+ * @param {Vector3} a - The first corner of the triangle.
+ * @param {Vector3} b - The second corner of the triangle.
+ * @param {Vector3} c - The third corner of the triangle.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The barycentric coordinates for the given point
+ */
+ static getBarycoord( point, a, b, c, target ) {
+
+ _v0$2.subVectors( c, a );
+ _v1$5.subVectors( b, a );
+ _v2$4.subVectors( point, a );
+
+ const dot00 = _v0$2.dot( _v0$2 );
+ const dot01 = _v0$2.dot( _v1$5 );
+ const dot02 = _v0$2.dot( _v2$4 );
+ const dot11 = _v1$5.dot( _v1$5 );
+ const dot12 = _v1$5.dot( _v2$4 );
+
+ const denom = ( dot00 * dot11 - dot01 * dot01 );
+
+ // collinear or singular triangle
+ if ( denom === 0 ) {
+
+ target.set( 0, 0, 0 );
+ return null;
+
+ }
+
+ const invDenom = 1 / denom;
+ const u = ( dot11 * dot02 - dot01 * dot12 ) * invDenom;
+ const v = ( dot00 * dot12 - dot01 * dot02 ) * invDenom;
+
+ // barycentric coordinates must always sum to 1
+ return target.set( 1 - u - v, v, u );
+
+ }
+
+ /**
+ * Returns `true` if the given point, when projected onto the plane of the
+ * triangle, lies within the triangle.
+ *
+ * @param {Vector3} point - The point in 3D space to test.
+ * @param {Vector3} a - The first corner of the triangle.
+ * @param {Vector3} b - The second corner of the triangle.
+ * @param {Vector3} c - The third corner of the triangle.
+ * @return {boolean} Whether the given point, when projected onto the plane of the
+ * triangle, lies within the triangle or not.
+ */
+ static containsPoint( point, a, b, c ) {
+
+ // if the triangle is degenerate then we can't contain a point
+ if ( this.getBarycoord( point, a, b, c, _v3$2 ) === null ) {
+
+ return false;
+
+ }
+
+ return ( _v3$2.x >= 0 ) && ( _v3$2.y >= 0 ) && ( ( _v3$2.x + _v3$2.y ) <= 1 );
+
+ }
+
+ /**
+ * Computes the value barycentrically interpolated for the given point on the
+ * triangle. Returns `null` if the triangle is degenerate.
+ *
+ * @param {Vector3} point - Position of interpolated point.
+ * @param {Vector3} p1 - The first corner of the triangle.
+ * @param {Vector3} p2 - The second corner of the triangle.
+ * @param {Vector3} p3 - The third corner of the triangle.
+ * @param {Vector3} v1 - Value to interpolate of first vertex.
+ * @param {Vector3} v2 - Value to interpolate of second vertex.
+ * @param {Vector3} v3 - Value to interpolate of third vertex.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The interpolated value.
+ */
+ static getInterpolation( point, p1, p2, p3, v1, v2, v3, target ) {
+
+ if ( this.getBarycoord( point, p1, p2, p3, _v3$2 ) === null ) {
+
+ target.x = 0;
+ target.y = 0;
+ if ( 'z' in target ) target.z = 0;
+ if ( 'w' in target ) target.w = 0;
+ return null;
+
+ }
+
+ target.setScalar( 0 );
+ target.addScaledVector( v1, _v3$2.x );
+ target.addScaledVector( v2, _v3$2.y );
+ target.addScaledVector( v3, _v3$2.z );
+
+ return target;
+
+ }
+
+ /**
+ * Computes the value barycentrically interpolated for the given attribute and indices.
+ *
+ * @param {BufferAttribute} attr - The attribute to interpolate.
+ * @param {number} i1 - Index of first vertex.
+ * @param {number} i2 - Index of second vertex.
+ * @param {number} i3 - Index of third vertex.
+ * @param {Vector3} barycoord - The barycoordinate value to use to interpolate.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The interpolated attribute value.
+ */
+ static getInterpolatedAttribute( attr, i1, i2, i3, barycoord, target ) {
+
+ _v40.setScalar( 0 );
+ _v41.setScalar( 0 );
+ _v42.setScalar( 0 );
+
+ _v40.fromBufferAttribute( attr, i1 );
+ _v41.fromBufferAttribute( attr, i2 );
+ _v42.fromBufferAttribute( attr, i3 );
+
+ target.setScalar( 0 );
+ target.addScaledVector( _v40, barycoord.x );
+ target.addScaledVector( _v41, barycoord.y );
+ target.addScaledVector( _v42, barycoord.z );
+
+ return target;
+
+ }
+
+ /**
+ * Returns `true` if the triangle is oriented towards the given direction.
+ *
+ * @param {Vector3} a - The first corner of the triangle.
+ * @param {Vector3} b - The second corner of the triangle.
+ * @param {Vector3} c - The third corner of the triangle.
+ * @param {Vector3} direction - The (normalized) direction vector.
+ * @return {boolean} Whether the triangle is oriented towards the given direction or not.
+ */
+ static isFrontFacing( a, b, c, direction ) {
+
+ _v0$2.subVectors( c, b );
+ _v1$5.subVectors( a, b );
+
+ // strictly front facing
+ return _v0$2.cross( _v1$5 ).dot( direction ) < 0;
+
+ }
+
+ /**
+ * Sets the triangle's vertices by copying the given values.
+ *
+ * @param {Vector3} a - The first corner of the triangle.
+ * @param {Vector3} b - The second corner of the triangle.
+ * @param {Vector3} c - The third corner of the triangle.
+ * @return {Triangle} A reference to this triangle.
+ */
+ set( a, b, c ) {
+
+ this.a.copy( a );
+ this.b.copy( b );
+ this.c.copy( c );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the triangle's vertices by copying the given array values.
+ *
+ * @param {Array} points - An array with 3D points.
+ * @param {number} i0 - The array index representing the first corner of the triangle.
+ * @param {number} i1 - The array index representing the second corner of the triangle.
+ * @param {number} i2 - The array index representing the third corner of the triangle.
+ * @return {Triangle} A reference to this triangle.
+ */
+ setFromPointsAndIndices( points, i0, i1, i2 ) {
+
+ this.a.copy( points[ i0 ] );
+ this.b.copy( points[ i1 ] );
+ this.c.copy( points[ i2 ] );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the triangle's vertices by copying the given attribute values.
+ *
+ * @param {BufferAttribute} attribute - A buffer attribute with 3D points data.
+ * @param {number} i0 - The attribute index representing the first corner of the triangle.
+ * @param {number} i1 - The attribute index representing the second corner of the triangle.
+ * @param {number} i2 - The attribute index representing the third corner of the triangle.
+ * @return {Triangle} A reference to this triangle.
+ */
+ setFromAttributeAndIndices( attribute, i0, i1, i2 ) {
+
+ this.a.fromBufferAttribute( attribute, i0 );
+ this.b.fromBufferAttribute( attribute, i1 );
+ this.c.fromBufferAttribute( attribute, i2 );
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new triangle with copied values from this instance.
+ *
+ * @return {Triangle} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Copies the values of the given triangle to this instance.
+ *
+ * @param {Triangle} triangle - The triangle to copy.
+ * @return {Triangle} A reference to this triangle.
+ */
+ copy( triangle ) {
+
+ this.a.copy( triangle.a );
+ this.b.copy( triangle.b );
+ this.c.copy( triangle.c );
+
+ return this;
+
+ }
+
+ /**
+ * Computes the area of the triangle.
+ *
+ * @return {number} The triangle's area.
+ */
+ getArea() {
+
+ _v0$2.subVectors( this.c, this.b );
+ _v1$5.subVectors( this.a, this.b );
+
+ return _v0$2.cross( _v1$5 ).length() * 0.5;
+
+ }
+
+ /**
+ * Computes the midpoint of the triangle.
+ *
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The triangle's midpoint.
+ */
+ getMidpoint( target ) {
+
+ return target.addVectors( this.a, this.b ).add( this.c ).multiplyScalar( 1 / 3 );
+
+ }
+
+ /**
+ * Computes the normal of the triangle.
+ *
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The triangle's normal.
+ */
+ getNormal( target ) {
+
+ return Triangle.getNormal( this.a, this.b, this.c, target );
+
+ }
+
+ /**
+ * Computes a plane the triangle lies within.
+ *
+ * @param {Plane} target - The target vector that is used to store the method's result.
+ * @return {Plane} The plane the triangle lies within.
+ */
+ getPlane( target ) {
+
+ return target.setFromCoplanarPoints( this.a, this.b, this.c );
+
+ }
+
+ /**
+ * Computes a barycentric coordinates from the given vector.
+ * Returns `null` if the triangle is degenerate.
+ *
+ * @param {Vector3} point - A point in 3D space.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The barycentric coordinates for the given point
+ */
+ getBarycoord( point, target ) {
+
+ return Triangle.getBarycoord( point, this.a, this.b, this.c, target );
+
+ }
+
+ /**
+ * Computes the value barycentrically interpolated for the given point on the
+ * triangle. Returns `null` if the triangle is degenerate.
+ *
+ * @param {Vector3} point - Position of interpolated point.
+ * @param {Vector3} v1 - Value to interpolate of first vertex.
+ * @param {Vector3} v2 - Value to interpolate of second vertex.
+ * @param {Vector3} v3 - Value to interpolate of third vertex.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The interpolated value.
+ */
+ getInterpolation( point, v1, v2, v3, target ) {
+
+ return Triangle.getInterpolation( point, this.a, this.b, this.c, v1, v2, v3, target );
+
+ }
+
+ /**
+ * Returns `true` if the given point, when projected onto the plane of the
+ * triangle, lies within the triangle.
+ *
+ * @param {Vector3} point - The point in 3D space to test.
+ * @return {boolean} Whether the given point, when projected onto the plane of the
+ * triangle, lies within the triangle or not.
+ */
+ containsPoint( point ) {
+
+ return Triangle.containsPoint( point, this.a, this.b, this.c );
+
+ }
+
+ /**
+ * Returns `true` if the triangle is oriented towards the given direction.
+ *
+ * @param {Vector3} direction - The (normalized) direction vector.
+ * @return {boolean} Whether the triangle is oriented towards the given direction or not.
+ */
+ isFrontFacing( direction ) {
+
+ return Triangle.isFrontFacing( this.a, this.b, this.c, direction );
+
+ }
+
+ /**
+ * Returns `true` if this triangle intersects with the given box.
+ *
+ * @param {Box3} box - The box to intersect.
+ * @return {boolean} Whether this triangle intersects with the given box or not.
+ */
+ intersectsBox( box ) {
+
+ return box.intersectsTriangle( this );
+
+ }
+
+ /**
+ * Returns the closest point on the triangle to the given point.
+ *
+ * @param {Vector3} p - The point to compute the closest point for.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The closest point on the triangle.
+ */
+ closestPointToPoint( p, target ) {
+
+ const a = this.a, b = this.b, c = this.c;
+ let v, w;
+
+ // algorithm thanks to Real-Time Collision Detection by Christer Ericson,
+ // published by Morgan Kaufmann Publishers, (c) 2005 Elsevier Inc.,
+ // under the accompanying license; see chapter 5.1.5 for detailed explanation.
+ // basically, we're distinguishing which of the voronoi regions of the triangle
+ // the point lies in with the minimum amount of redundant computation.
+
+ _vab.subVectors( b, a );
+ _vac.subVectors( c, a );
+ _vap.subVectors( p, a );
+ const d1 = _vab.dot( _vap );
+ const d2 = _vac.dot( _vap );
+ if ( d1 <= 0 && d2 <= 0 ) {
+
+ // vertex region of A; barycentric coords (1, 0, 0)
+ return target.copy( a );
+
+ }
+
+ _vbp.subVectors( p, b );
+ const d3 = _vab.dot( _vbp );
+ const d4 = _vac.dot( _vbp );
+ if ( d3 >= 0 && d4 <= d3 ) {
+
+ // vertex region of B; barycentric coords (0, 1, 0)
+ return target.copy( b );
+
+ }
+
+ const vc = d1 * d4 - d3 * d2;
+ if ( vc <= 0 && d1 >= 0 && d3 <= 0 ) {
+
+ v = d1 / ( d1 - d3 );
+ // edge region of AB; barycentric coords (1-v, v, 0)
+ return target.copy( a ).addScaledVector( _vab, v );
+
+ }
+
+ _vcp.subVectors( p, c );
+ const d5 = _vab.dot( _vcp );
+ const d6 = _vac.dot( _vcp );
+ if ( d6 >= 0 && d5 <= d6 ) {
+
+ // vertex region of C; barycentric coords (0, 0, 1)
+ return target.copy( c );
+
+ }
+
+ const vb = d5 * d2 - d1 * d6;
+ if ( vb <= 0 && d2 >= 0 && d6 <= 0 ) {
+
+ w = d2 / ( d2 - d6 );
+ // edge region of AC; barycentric coords (1-w, 0, w)
+ return target.copy( a ).addScaledVector( _vac, w );
+
+ }
+
+ const va = d3 * d6 - d5 * d4;
+ if ( va <= 0 && ( d4 - d3 ) >= 0 && ( d5 - d6 ) >= 0 ) {
+
+ _vbc.subVectors( c, b );
+ w = ( d4 - d3 ) / ( ( d4 - d3 ) + ( d5 - d6 ) );
+ // edge region of BC; barycentric coords (0, 1-w, w)
+ return target.copy( b ).addScaledVector( _vbc, w ); // edge region of BC
+
+ }
+
+ // face region
+ const denom = 1 / ( va + vb + vc );
+ // u = va * denom
+ v = vb * denom;
+ w = vc * denom;
+
+ return target.copy( a ).addScaledVector( _vab, v ).addScaledVector( _vac, w );
+
+ }
+
+ /**
+ * Returns `true` if this triangle is equal with the given one.
+ *
+ * @param {Triangle} triangle - The triangle to test for equality.
+ * @return {boolean} Whether this triangle is equal with the given one.
+ */
+ equals( triangle ) {
+
+ return triangle.a.equals( this.a ) && triangle.b.equals( this.b ) && triangle.c.equals( this.c );
+
+ }
+
+}
+
+/**
+ * Represents an axis-aligned bounding box (AABB) in 3D space.
+ */
+class Box3 {
+
+ /**
+ * Constructs a new bounding box.
+ *
+ * @param {Vector3} [min=(Infinity,Infinity,Infinity)] - A vector representing the lower boundary of the box.
+ * @param {Vector3} [max=(-Infinity,-Infinity,-Infinity)] - A vector representing the upper boundary of the box.
+ */
+ constructor( min = new Vector3( + Infinity, + Infinity, + Infinity ), max = new Vector3( - Infinity, - Infinity, - Infinity ) ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isBox3 = true;
+
+ /**
+ * The lower boundary of the box.
+ *
+ * @type {Vector3}
+ */
+ this.min = min;
+
+ /**
+ * The upper boundary of the box.
+ *
+ * @type {Vector3}
+ */
+ this.max = max;
+
+ }
+
+ /**
+ * Sets the lower and upper boundaries of this box.
+ * Please note that this method only copies the values from the given objects.
+ *
+ * @param {Vector3} min - The lower boundary of the box.
+ * @param {Vector3} max - The upper boundary of the box.
+ * @return {Box3} A reference to this bounding box.
+ */
+ set( min, max ) {
+
+ this.min.copy( min );
+ this.max.copy( max );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the upper and lower bounds of this box so it encloses the position data
+ * in the given array.
+ *
+ * @param {Array} array - An array holding 3D position data.
+ * @return {Box3} A reference to this bounding box.
+ */
+ setFromArray( array ) {
+
+ this.makeEmpty();
+
+ for ( let i = 0, il = array.length; i < il; i += 3 ) {
+
+ this.expandByPoint( _vector$b.fromArray( array, i ) );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the upper and lower bounds of this box so it encloses the position data
+ * in the given buffer attribute.
+ *
+ * @param {BufferAttribute} attribute - A buffer attribute holding 3D position data.
+ * @return {Box3} A reference to this bounding box.
+ */
+ setFromBufferAttribute( attribute ) {
+
+ this.makeEmpty();
+
+ for ( let i = 0, il = attribute.count; i < il; i ++ ) {
+
+ this.expandByPoint( _vector$b.fromBufferAttribute( attribute, i ) );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the upper and lower bounds of this box so it encloses the position data
+ * in the given array.
+ *
+ * @param {Array} points - An array holding 3D position data as instances of {@link Vector3}.
+ * @return {Box3} A reference to this bounding box.
+ */
+ setFromPoints( points ) {
+
+ this.makeEmpty();
+
+ for ( let i = 0, il = points.length; i < il; i ++ ) {
+
+ this.expandByPoint( points[ i ] );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Centers this box on the given center vector and sets this box's width, height and
+ * depth to the given size values.
+ *
+ * @param {Vector3} center - The center of the box.
+ * @param {Vector3} size - The x, y and z dimensions of the box.
+ * @return {Box3} A reference to this bounding box.
+ */
+ setFromCenterAndSize( center, size ) {
+
+ const halfSize = _vector$b.copy( size ).multiplyScalar( 0.5 );
+
+ this.min.copy( center ).sub( halfSize );
+ this.max.copy( center ).add( halfSize );
+
+ return this;
+
+ }
+
+ /**
+ * Computes the world-axis-aligned bounding box for the given 3D object
+ * (including its children), accounting for the object's, and children's,
+ * world transforms. The function may result in a larger box than strictly necessary.
+ *
+ * Note: To compute the correct bounding box, make sure the given 3D object
+ * has an up-to-date world matrix that reflects the current transformation of its
+ * ancestor nodes. Call `object.updateWorldMatrix( true, false )` beforehand if
+ * you're unsure.
+ *
+ * @param {Object3D} object - The 3D object to compute the bounding box for.
+ * @param {boolean} [precise=false] - If set to `true`, the method computes the smallest
+ * world-axis-aligned bounding box at the expense of more computation.
+ * @return {Box3} A reference to this bounding box.
+ */
+ setFromObject( object, precise = false ) {
+
+ this.makeEmpty();
+
+ return this.expandByObject( object, precise );
+
+ }
+
+ /**
+ * Returns a new box with copied values from this instance.
+ *
+ * @return {Box3} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Copies the values of the given box to this instance.
+ *
+ * @param {Box3} box - The box to copy.
+ * @return {Box3} A reference to this bounding box.
+ */
+ copy( box ) {
+
+ this.min.copy( box.min );
+ this.max.copy( box.max );
+
+ return this;
+
+ }
+
+ /**
+ * Makes this box empty which means in encloses a zero space in 3D.
+ *
+ * @return {Box3} A reference to this bounding box.
+ */
+ makeEmpty() {
+
+ this.min.x = this.min.y = this.min.z = + Infinity;
+ this.max.x = this.max.y = this.max.z = - Infinity;
+
+ return this;
+
+ }
+
+ /**
+ * Returns true if this box includes zero points within its bounds.
+ * Note that a box with equal lower and upper bounds still includes one
+ * point, the one both bounds share.
+ *
+ * @return {boolean} Whether this box is empty or not.
+ */
+ isEmpty() {
+
+ // this is a more robust check for empty than ( volume <= 0 ) because volume can get positive with two negative axes
+
+ return ( this.max.x < this.min.x ) || ( this.max.y < this.min.y ) || ( this.max.z < this.min.z );
+
+ }
+
+ /**
+ * Returns the center point of this box.
+ *
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The center point.
+ */
+ getCenter( target ) {
+
+ return this.isEmpty() ? target.set( 0, 0, 0 ) : target.addVectors( this.min, this.max ).multiplyScalar( 0.5 );
+
+ }
+
+ /**
+ * Returns the dimensions of this box.
+ *
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The size.
+ */
+ getSize( target ) {
+
+ return this.isEmpty() ? target.set( 0, 0, 0 ) : target.subVectors( this.max, this.min );
+
+ }
+
+ /**
+ * Expands the boundaries of this box to include the given point.
+ *
+ * @param {Vector3} point - The point that should be included by the bounding box.
+ * @return {Box3} A reference to this bounding box.
+ */
+ expandByPoint( point ) {
+
+ this.min.min( point );
+ this.max.max( point );
+
+ return this;
+
+ }
+
+ /**
+ * Expands this box equilaterally by the given vector. The width of this
+ * box will be expanded by the x component of the vector in both
+ * directions. The height of this box will be expanded by the y component of
+ * the vector in both directions. The depth of this box will be
+ * expanded by the z component of the vector in both directions.
+ *
+ * @param {Vector3} vector - The vector that should expand the bounding box.
+ * @return {Box3} A reference to this bounding box.
+ */
+ expandByVector( vector ) {
+
+ this.min.sub( vector );
+ this.max.add( vector );
+
+ return this;
+
+ }
+
+ /**
+ * Expands each dimension of the box by the given scalar. If negative, the
+ * dimensions of the box will be contracted.
+ *
+ * @param {number} scalar - The scalar value that should expand the bounding box.
+ * @return {Box3} A reference to this bounding box.
+ */
+ expandByScalar( scalar ) {
+
+ this.min.addScalar( - scalar );
+ this.max.addScalar( scalar );
+
+ return this;
+
+ }
+
+ /**
+ * Expands the boundaries of this box to include the given 3D object and
+ * its children, accounting for the object's, and children's, world
+ * transforms. The function may result in a larger box than strictly
+ * necessary (unless the precise parameter is set to true).
+ *
+ * @param {Object3D} object - The 3D object that should expand the bounding box.
+ * @param {boolean} precise - If set to `true`, the method expands the bounding box
+ * as little as necessary at the expense of more computation.
+ * @return {Box3} A reference to this bounding box.
+ */
+ expandByObject( object, precise = false ) {
+
+ // Computes the world-axis-aligned bounding box of an object (including its children),
+ // accounting for both the object's, and children's, world transforms
+
+ object.updateWorldMatrix( false, false );
+
+ const geometry = object.geometry;
+
+ if ( geometry !== undefined ) {
+
+ const positionAttribute = geometry.getAttribute( 'position' );
+
+ // precise AABB computation based on vertex data requires at least a position attribute.
+ // instancing isn't supported so far and uses the normal (conservative) code path.
+
+ if ( precise === true && positionAttribute !== undefined && object.isInstancedMesh !== true ) {
+
+ for ( let i = 0, l = positionAttribute.count; i < l; i ++ ) {
+
+ if ( object.isMesh === true ) {
+
+ object.getVertexPosition( i, _vector$b );
+
+ } else {
+
+ _vector$b.fromBufferAttribute( positionAttribute, i );
+
+ }
+
+ _vector$b.applyMatrix4( object.matrixWorld );
+ this.expandByPoint( _vector$b );
+
+ }
+
+ } else {
+
+ if ( object.boundingBox !== undefined ) {
+
+ // object-level bounding box
+
+ if ( object.boundingBox === null ) {
+
+ object.computeBoundingBox();
+
+ }
+
+ _box$4.copy( object.boundingBox );
+
+
+ } else {
+
+ // geometry-level bounding box
+
+ if ( geometry.boundingBox === null ) {
+
+ geometry.computeBoundingBox();
+
+ }
+
+ _box$4.copy( geometry.boundingBox );
+
+ }
+
+ _box$4.applyMatrix4( object.matrixWorld );
+
+ this.union( _box$4 );
+
+ }
+
+ }
+
+ const children = object.children;
+
+ for ( let i = 0, l = children.length; i < l; i ++ ) {
+
+ this.expandByObject( children[ i ], precise );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if the given point lies within or on the boundaries of this box.
+ *
+ * @param {Vector3} point - The point to test.
+ * @return {boolean} Whether the bounding box contains the given point or not.
+ */
+ containsPoint( point ) {
+
+ return point.x >= this.min.x && point.x <= this.max.x &&
+ point.y >= this.min.y && point.y <= this.max.y &&
+ point.z >= this.min.z && point.z <= this.max.z;
+
+ }
+
+ /**
+ * Returns `true` if this bounding box includes the entirety of the given bounding box.
+ * If this box and the given one are identical, this function also returns `true`.
+ *
+ * @param {Box3} box - The bounding box to test.
+ * @return {boolean} Whether the bounding box contains the given bounding box or not.
+ */
+ containsBox( box ) {
+
+ return this.min.x <= box.min.x && box.max.x <= this.max.x &&
+ this.min.y <= box.min.y && box.max.y <= this.max.y &&
+ this.min.z <= box.min.z && box.max.z <= this.max.z;
+
+ }
+
+ /**
+ * Returns a point as a proportion of this box's width, height and depth.
+ *
+ * @param {Vector3} point - A point in 3D space.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} A point as a proportion of this box's width, height and depth.
+ */
+ getParameter( point, target ) {
+
+ // This can potentially have a divide by zero if the box
+ // has a size dimension of 0.
+
+ return target.set(
+ ( point.x - this.min.x ) / ( this.max.x - this.min.x ),
+ ( point.y - this.min.y ) / ( this.max.y - this.min.y ),
+ ( point.z - this.min.z ) / ( this.max.z - this.min.z )
+ );
+
+ }
+
+ /**
+ * Returns `true` if the given bounding box intersects with this bounding box.
+ *
+ * @param {Box3} box - The bounding box to test.
+ * @return {boolean} Whether the given bounding box intersects with this bounding box.
+ */
+ intersectsBox( box ) {
+
+ // using 6 splitting planes to rule out intersections.
+ return box.max.x >= this.min.x && box.min.x <= this.max.x &&
+ box.max.y >= this.min.y && box.min.y <= this.max.y &&
+ box.max.z >= this.min.z && box.min.z <= this.max.z;
+
+ }
+
+ /**
+ * Returns `true` if the given bounding sphere intersects with this bounding box.
+ *
+ * @param {Sphere} sphere - The bounding sphere to test.
+ * @return {boolean} Whether the given bounding sphere intersects with this bounding box.
+ */
+ intersectsSphere( sphere ) {
+
+ // Find the point on the AABB closest to the sphere center.
+ this.clampPoint( sphere.center, _vector$b );
+
+ // If that point is inside the sphere, the AABB and sphere intersect.
+ return _vector$b.distanceToSquared( sphere.center ) <= ( sphere.radius * sphere.radius );
+
+ }
+
+ /**
+ * Returns `true` if the given plane intersects with this bounding box.
+ *
+ * @param {Plane} plane - The plane to test.
+ * @return {boolean} Whether the given plane intersects with this bounding box.
+ */
+ intersectsPlane( plane ) {
+
+ // We compute the minimum and maximum dot product values. If those values
+ // are on the same side (back or front) of the plane, then there is no intersection.
+
+ let min, max;
+
+ if ( plane.normal.x > 0 ) {
+
+ min = plane.normal.x * this.min.x;
+ max = plane.normal.x * this.max.x;
+
+ } else {
+
+ min = plane.normal.x * this.max.x;
+ max = plane.normal.x * this.min.x;
+
+ }
+
+ if ( plane.normal.y > 0 ) {
+
+ min += plane.normal.y * this.min.y;
+ max += plane.normal.y * this.max.y;
+
+ } else {
+
+ min += plane.normal.y * this.max.y;
+ max += plane.normal.y * this.min.y;
+
+ }
+
+ if ( plane.normal.z > 0 ) {
+
+ min += plane.normal.z * this.min.z;
+ max += plane.normal.z * this.max.z;
+
+ } else {
+
+ min += plane.normal.z * this.max.z;
+ max += plane.normal.z * this.min.z;
+
+ }
+
+ return ( min <= - plane.constant && max >= - plane.constant );
+
+ }
+
+ /**
+ * Returns `true` if the given triangle intersects with this bounding box.
+ *
+ * @param {Triangle} triangle - The triangle to test.
+ * @return {boolean} Whether the given triangle intersects with this bounding box.
+ */
+ intersectsTriangle( triangle ) {
+
+ if ( this.isEmpty() ) {
+
+ return false;
+
+ }
+
+ // compute box center and extents
+ this.getCenter( _center );
+ _extents.subVectors( this.max, _center );
+
+ // translate triangle to aabb origin
+ _v0$1.subVectors( triangle.a, _center );
+ _v1$4.subVectors( triangle.b, _center );
+ _v2$3.subVectors( triangle.c, _center );
+
+ // compute edge vectors for triangle
+ _f0.subVectors( _v1$4, _v0$1 );
+ _f1.subVectors( _v2$3, _v1$4 );
+ _f2.subVectors( _v0$1, _v2$3 );
+
+ // test against axes that are given by cross product combinations of the edges of the triangle and the edges of the aabb
+ // make an axis testing of each of the 3 sides of the aabb against each of the 3 sides of the triangle = 9 axis of separation
+ // axis_ij = u_i x f_j (u0, u1, u2 = face normals of aabb = x,y,z axes vectors since aabb is axis aligned)
+ let axes = [
+ 0, - _f0.z, _f0.y, 0, - _f1.z, _f1.y, 0, - _f2.z, _f2.y,
+ _f0.z, 0, - _f0.x, _f1.z, 0, - _f1.x, _f2.z, 0, - _f2.x,
+ - _f0.y, _f0.x, 0, - _f1.y, _f1.x, 0, - _f2.y, _f2.x, 0
+ ];
+ if ( ! satForAxes( axes, _v0$1, _v1$4, _v2$3, _extents ) ) {
+
+ return false;
+
+ }
+
+ // test 3 face normals from the aabb
+ axes = [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ];
+ if ( ! satForAxes( axes, _v0$1, _v1$4, _v2$3, _extents ) ) {
+
+ return false;
+
+ }
+
+ // finally testing the face normal of the triangle
+ // use already existing triangle edge vectors here
+ _triangleNormal.crossVectors( _f0, _f1 );
+ axes = [ _triangleNormal.x, _triangleNormal.y, _triangleNormal.z ];
+
+ return satForAxes( axes, _v0$1, _v1$4, _v2$3, _extents );
+
+ }
+
+ /**
+ * Clamps the given point within the bounds of this box.
+ *
+ * @param {Vector3} point - The point to clamp.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The clamped point.
+ */
+ clampPoint( point, target ) {
+
+ return target.copy( point ).clamp( this.min, this.max );
+
+ }
+
+ /**
+ * Returns the euclidean distance from any edge of this box to the specified point. If
+ * the given point lies inside of this box, the distance will be `0`.
+ *
+ * @param {Vector3} point - The point to compute the distance to.
+ * @return {number} The euclidean distance.
+ */
+ distanceToPoint( point ) {
+
+ return this.clampPoint( point, _vector$b ).distanceTo( point );
+
+ }
+
+ /**
+ * Returns a bounding sphere that encloses this bounding box.
+ *
+ * @param {Sphere} target - The target sphere that is used to store the method's result.
+ * @return {Sphere} The bounding sphere that encloses this bounding box.
+ */
+ getBoundingSphere( target ) {
+
+ if ( this.isEmpty() ) {
+
+ target.makeEmpty();
+
+ } else {
+
+ this.getCenter( target.center );
+
+ target.radius = this.getSize( _vector$b ).length() * 0.5;
+
+ }
+
+ return target;
+
+ }
+
+ /**
+ * Computes the intersection of this bounding box and the given one, setting the upper
+ * bound of this box to the lesser of the two boxes' upper bounds and the
+ * lower bound of this box to the greater of the two boxes' lower bounds. If
+ * there's no overlap, makes this box empty.
+ *
+ * @param {Box3} box - The bounding box to intersect with.
+ * @return {Box3} A reference to this bounding box.
+ */
+ intersect( box ) {
+
+ this.min.max( box.min );
+ this.max.min( box.max );
+
+ // ensure that if there is no overlap, the result is fully empty, not slightly empty with non-inf/+inf values that will cause subsequence intersects to erroneously return valid values.
+ if ( this.isEmpty() ) this.makeEmpty();
+
+ return this;
+
+ }
+
+ /**
+ * Computes the union of this box and another and the given one, setting the upper
+ * bound of this box to the greater of the two boxes' upper bounds and the
+ * lower bound of this box to the lesser of the two boxes' lower bounds.
+ *
+ * @param {Box3} box - The bounding box that will be unioned with this instance.
+ * @return {Box3} A reference to this bounding box.
+ */
+ union( box ) {
+
+ this.min.min( box.min );
+ this.max.max( box.max );
+
+ return this;
+
+ }
+
+ /**
+ * Transforms this bounding box by the given 4x4 transformation matrix.
+ *
+ * @param {Matrix4} matrix - The transformation matrix.
+ * @return {Box3} A reference to this bounding box.
+ */
+ applyMatrix4( matrix ) {
+
+ // transform of empty box is an empty box.
+ if ( this.isEmpty() ) return this;
+
+ // NOTE: I am using a binary pattern to specify all 2^3 combinations below
+ _points[ 0 ].set( this.min.x, this.min.y, this.min.z ).applyMatrix4( matrix ); // 000
+ _points[ 1 ].set( this.min.x, this.min.y, this.max.z ).applyMatrix4( matrix ); // 001
+ _points[ 2 ].set( this.min.x, this.max.y, this.min.z ).applyMatrix4( matrix ); // 010
+ _points[ 3 ].set( this.min.x, this.max.y, this.max.z ).applyMatrix4( matrix ); // 011
+ _points[ 4 ].set( this.max.x, this.min.y, this.min.z ).applyMatrix4( matrix ); // 100
+ _points[ 5 ].set( this.max.x, this.min.y, this.max.z ).applyMatrix4( matrix ); // 101
+ _points[ 6 ].set( this.max.x, this.max.y, this.min.z ).applyMatrix4( matrix ); // 110
+ _points[ 7 ].set( this.max.x, this.max.y, this.max.z ).applyMatrix4( matrix ); // 111
+
+ this.setFromPoints( _points );
+
+ return this;
+
+ }
+
+ /**
+ * Adds the given offset to both the upper and lower bounds of this bounding box,
+ * effectively moving it in 3D space.
+ *
+ * @param {Vector3} offset - The offset that should be used to translate the bounding box.
+ * @return {Box3} A reference to this bounding box.
+ */
+ translate( offset ) {
+
+ this.min.add( offset );
+ this.max.add( offset );
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this bounding box is equal with the given one.
+ *
+ * @param {Box3} box - The box to test for equality.
+ * @return {boolean} Whether this bounding box is equal with the given one.
+ */
+ equals( box ) {
+
+ return box.min.equals( this.min ) && box.max.equals( this.max );
+
+ }
+
+ /**
+ * Returns a serialized structure of the bounding box.
+ *
+ * @return {Object} Serialized structure with fields representing the object state.
+ */
+ toJSON() {
+
+ return {
+ min: this.min.toArray(),
+ max: this.max.toArray()
+ };
+
+ }
+
+ /**
+ * Returns a serialized structure of the bounding box.
+ *
+ * @param {Object} json - The serialized json to set the box from.
+ * @return {Box3} A reference to this bounding box.
+ */
+ fromJSON( json ) {
+
+ this.min.fromArray( json.min );
+ this.max.fromArray( json.max );
+ return this;
+
+ }
+
+}
+
+const _points = [
+ /*@__PURE__*/ new Vector3(),
+ /*@__PURE__*/ new Vector3(),
+ /*@__PURE__*/ new Vector3(),
+ /*@__PURE__*/ new Vector3(),
+ /*@__PURE__*/ new Vector3(),
+ /*@__PURE__*/ new Vector3(),
+ /*@__PURE__*/ new Vector3(),
+ /*@__PURE__*/ new Vector3()
+];
+
+const _vector$b = /*@__PURE__*/ new Vector3();
+
+const _box$4 = /*@__PURE__*/ new Box3();
+
+// triangle centered vertices
+
+const _v0$1 = /*@__PURE__*/ new Vector3();
+const _v1$4 = /*@__PURE__*/ new Vector3();
+const _v2$3 = /*@__PURE__*/ new Vector3();
+
+// triangle edge vectors
+
+const _f0 = /*@__PURE__*/ new Vector3();
+const _f1 = /*@__PURE__*/ new Vector3();
+const _f2 = /*@__PURE__*/ new Vector3();
+
+const _center = /*@__PURE__*/ new Vector3();
+const _extents = /*@__PURE__*/ new Vector3();
+const _triangleNormal = /*@__PURE__*/ new Vector3();
+const _testAxis = /*@__PURE__*/ new Vector3();
+
+function satForAxes( axes, v0, v1, v2, extents ) {
+
+ for ( let i = 0, j = axes.length - 3; i <= j; i += 3 ) {
+
+ _testAxis.fromArray( axes, i );
+ // project the aabb onto the separating axis
+ const r = extents.x * Math.abs( _testAxis.x ) + extents.y * Math.abs( _testAxis.y ) + extents.z * Math.abs( _testAxis.z );
+ // project all 3 vertices of the triangle onto the separating axis
+ const p0 = v0.dot( _testAxis );
+ const p1 = v1.dot( _testAxis );
+ const p2 = v2.dot( _testAxis );
+ // actual test, basically see if either of the most extreme of the triangle points intersects r
+ if ( Math.max( - Math.max( p0, p1, p2 ), Math.min( p0, p1, p2 ) ) > r ) {
+
+ // points of the projected triangle are outside the projected half-length of the aabb
+ // the axis is separating and we can exit
+ return false;
+
+ }
+
+ }
+
+ return true;
+
+}
+
+// Fast Half Float Conversions, http://www.fox-toolkit.org/ftp/fasthalffloatconversion.pdf
+
+const _tables = /*@__PURE__*/ _generateTables();
+
+function _generateTables() {
+
+ // float32 to float16 helpers
+
+ const buffer = new ArrayBuffer( 4 );
+ const floatView = new Float32Array( buffer );
+ const uint32View = new Uint32Array( buffer );
+
+ const baseTable = new Uint32Array( 512 );
+ const shiftTable = new Uint32Array( 512 );
+
+ for ( let i = 0; i < 256; ++ i ) {
+
+ const e = i - 127;
+
+ // very small number (0, -0)
+
+ if ( e < -27 ) {
+
+ baseTable[ i ] = 0x0000;
+ baseTable[ i | 0x100 ] = 0x8000;
+ shiftTable[ i ] = 24;
+ shiftTable[ i | 0x100 ] = 24;
+
+ // small number (denorm)
+
+ } else if ( e < -14 ) {
+
+ baseTable[ i ] = 0x0400 >> ( - e - 14 );
+ baseTable[ i | 0x100 ] = ( 0x0400 >> ( - e - 14 ) ) | 0x8000;
+ shiftTable[ i ] = - e - 1;
+ shiftTable[ i | 0x100 ] = - e - 1;
+
+ // normal number
+
+ } else if ( e <= 15 ) {
+
+ baseTable[ i ] = ( e + 15 ) << 10;
+ baseTable[ i | 0x100 ] = ( ( e + 15 ) << 10 ) | 0x8000;
+ shiftTable[ i ] = 13;
+ shiftTable[ i | 0x100 ] = 13;
+
+ // large number (Infinity, -Infinity)
+
+ } else if ( e < 128 ) {
+
+ baseTable[ i ] = 0x7c00;
+ baseTable[ i | 0x100 ] = 0xfc00;
+ shiftTable[ i ] = 24;
+ shiftTable[ i | 0x100 ] = 24;
+
+ // stay (NaN, Infinity, -Infinity)
+
+ } else {
+
+ baseTable[ i ] = 0x7c00;
+ baseTable[ i | 0x100 ] = 0xfc00;
+ shiftTable[ i ] = 13;
+ shiftTable[ i | 0x100 ] = 13;
+
+ }
+
+ }
+
+ // float16 to float32 helpers
+
+ const mantissaTable = new Uint32Array( 2048 );
+ const exponentTable = new Uint32Array( 64 );
+ const offsetTable = new Uint32Array( 64 );
+
+ for ( let i = 1; i < 1024; ++ i ) {
+
+ let m = i << 13; // zero pad mantissa bits
+ let e = 0; // zero exponent
+
+ // normalized
+ while ( ( m & 0x00800000 ) === 0 ) {
+
+ m <<= 1;
+ e -= 0x00800000; // decrement exponent
+
+ }
+
+ m &= -8388609; // clear leading 1 bit
+ e += 0x38800000; // adjust bias
+
+ mantissaTable[ i ] = m | e;
+
+ }
+
+ for ( let i = 1024; i < 2048; ++ i ) {
+
+ mantissaTable[ i ] = 0x38000000 + ( ( i - 1024 ) << 13 );
+
+ }
+
+ for ( let i = 1; i < 31; ++ i ) {
+
+ exponentTable[ i ] = i << 23;
+
+ }
+
+ exponentTable[ 31 ] = 0x47800000;
+ exponentTable[ 32 ] = 0x80000000;
+
+ for ( let i = 33; i < 63; ++ i ) {
+
+ exponentTable[ i ] = 0x80000000 + ( ( i - 32 ) << 23 );
+
+ }
+
+ exponentTable[ 63 ] = 0xc7800000;
+
+ for ( let i = 1; i < 64; ++ i ) {
+
+ if ( i !== 32 ) {
+
+ offsetTable[ i ] = 1024;
+
+ }
+
+ }
+
+ return {
+ floatView: floatView,
+ uint32View: uint32View,
+ baseTable: baseTable,
+ shiftTable: shiftTable,
+ mantissaTable: mantissaTable,
+ exponentTable: exponentTable,
+ offsetTable: offsetTable
+ };
+
+}
+
+/**
+ * Returns a half precision floating point value (FP16) from the given single
+ * precision floating point value (FP32).
+ *
+ * @param {number} val - A single precision floating point value.
+ * @return {number} The FP16 value.
+ */
+function toHalfFloat( val ) {
+
+ if ( Math.abs( val ) > 65504 ) warn( 'DataUtils.toHalfFloat(): Value out of range.' );
+
+ val = clamp( val, -65504, 65504 );
+
+ _tables.floatView[ 0 ] = val;
+ const f = _tables.uint32View[ 0 ];
+ const e = ( f >> 23 ) & 0x1ff;
+ return _tables.baseTable[ e ] + ( ( f & 0x007fffff ) >> _tables.shiftTable[ e ] );
+
+}
+
+/**
+ * Returns a single precision floating point value (FP32) from the given half
+ * precision floating point value (FP16).
+ *
+ * @param {number} val - A half precision floating point value.
+ * @return {number} The FP32 value.
+ */
+function fromHalfFloat( val ) {
+
+ const m = val >> 10;
+ _tables.uint32View[ 0 ] = _tables.mantissaTable[ _tables.offsetTable[ m ] + ( val & 0x3ff ) ] + _tables.exponentTable[ m ];
+ return _tables.floatView[ 0 ];
+
+}
+
+/**
+ * A class containing utility functions for data.
+ *
+ * @hideconstructor
+ */
+class DataUtils {
+
+ /**
+ * Returns a half precision floating point value (FP16) from the given single
+ * precision floating point value (FP32).
+ *
+ * @param {number} val - A single precision floating point value.
+ * @return {number} The FP16 value.
+ */
+ static toHalfFloat( val ) {
+
+ return toHalfFloat( val );
+
+ }
+
+ /**
+ * Returns a single precision floating point value (FP32) from the given half
+ * precision floating point value (FP16).
+ *
+ * @param {number} val - A half precision floating point value.
+ * @return {number} The FP32 value.
+ */
+ static fromHalfFloat( val ) {
+
+ return fromHalfFloat( val );
+
+ }
+
+}
+
+const _vector$a = /*@__PURE__*/ new Vector3();
+const _vector2$1 = /*@__PURE__*/ new Vector2();
+
+let _id$2 = 0;
+
+/**
+ * This class stores data for an attribute (such as vertex positions, face
+ * indices, normals, colors, UVs, and any custom attributes ) associated with
+ * a geometry, which allows for more efficient passing of data to the GPU.
+ *
+ * When working with vector-like data, the `fromBufferAttribute( attribute, index )`
+ * helper methods on vector and color class might be helpful. E.g. {@link Vector3#fromBufferAttribute}.
+ */
+class BufferAttribute extends EventDispatcher {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {TypedArray} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized = false ) {
+
+ super();
+
+ if ( Array.isArray( array ) ) {
+
+ throw new TypeError( 'THREE.BufferAttribute: array should be a Typed Array.' );
+
+ }
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isBufferAttribute = true;
+
+ /**
+ * The ID of the buffer attribute.
+ *
+ * @name BufferAttribute#id
+ * @type {number}
+ * @readonly
+ */
+ Object.defineProperty( this, 'id', { value: _id$2 ++ } );
+
+ /**
+ * The name of the buffer attribute.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The array holding the attribute data. It should have `itemSize * numVertices`
+ * elements, where `numVertices` is the number of vertices in the associated geometry.
+ *
+ * @type {TypedArray}
+ */
+ this.array = array;
+
+ /**
+ * The number of values of the array that should be associated with a particular vertex.
+ * For instance, if this attribute is storing a 3-component vector (such as a position,
+ * normal, or color), then the value should be `3`.
+ *
+ * @type {number}
+ */
+ this.itemSize = itemSize;
+
+ /**
+ * Represents the number of items this buffer attribute stores. It is internally computed
+ * by dividing the `array` length by the `itemSize`.
+ *
+ * @type {number}
+ * @readonly
+ */
+ this.count = array !== undefined ? array.length / itemSize : 0;
+
+ /**
+ * Applies to integer data only. Indicates how the underlying data in the buffer maps to
+ * the values in the GLSL code. For instance, if `array` is an instance of `UInt16Array`,
+ * and `normalized` is `true`, the values `0 - +65535` in the array data will be mapped to
+ * `0.0f - +1.0f` in the GLSL attribute. If `normalized` is `false`, the values will be converted
+ * to floats unmodified, i.e. `65535` becomes `65535.0f`.
+ *
+ * @type {boolean}
+ */
+ this.normalized = normalized;
+
+ /**
+ * Defines the intended usage pattern of the data store for optimization purposes.
+ *
+ * Note: After the initial use of a buffer, its usage cannot be changed. Instead,
+ * instantiate a new one and set the desired usage before the next render.
+ *
+ * @type {(StaticDrawUsage|DynamicDrawUsage|StreamDrawUsage|StaticReadUsage|DynamicReadUsage|StreamReadUsage|StaticCopyUsage|DynamicCopyUsage|StreamCopyUsage)}
+ * @default StaticDrawUsage
+ */
+ this.usage = StaticDrawUsage;
+
+ /**
+ * This can be used to only update some components of stored vectors (for example, just the
+ * component related to color). Use the `addUpdateRange()` function to add ranges to this array.
+ *
+ * @type {Array}
+ */
+ this.updateRanges = [];
+
+ /**
+ * Configures the bound GPU type for use in shaders.
+ *
+ * Note: this only has an effect for integer arrays and is not configurable for float arrays.
+ * For lower precision float types, use `Float16BufferAttribute`.
+ *
+ * @type {(FloatType|IntType)}
+ * @default FloatType
+ */
+ this.gpuType = FloatType;
+
+ /**
+ * A version number, incremented every time the `needsUpdate` is set to `true`.
+ *
+ * @type {number}
+ */
+ this.version = 0;
+
+ }
+
+ /**
+ * A callback function that is executed after the renderer has transferred the attribute
+ * array data to the GPU.
+ */
+ onUploadCallback() {}
+
+ /**
+ * Flag to indicate that this attribute has changed and should be re-sent to
+ * the GPU. Set this to `true` when you modify the value of the array.
+ *
+ * @type {number}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsUpdate( value ) {
+
+ if ( value === true ) this.version ++;
+
+ }
+
+ /**
+ * Sets the usage of this buffer attribute.
+ *
+ * @param {(StaticDrawUsage|DynamicDrawUsage|StreamDrawUsage|StaticReadUsage|DynamicReadUsage|StreamReadUsage|StaticCopyUsage|DynamicCopyUsage|StreamCopyUsage)} value - The usage to set.
+ * @return {BufferAttribute} A reference to this buffer attribute.
+ */
+ setUsage( value ) {
+
+ this.usage = value;
+
+ return this;
+
+ }
+
+ /**
+ * Adds a range of data in the data array to be updated on the GPU.
+ *
+ * @param {number} start - Position at which to start update.
+ * @param {number} count - The number of components to update.
+ */
+ addUpdateRange( start, count ) {
+
+ this.updateRanges.push( { start, count } );
+
+ }
+
+ /**
+ * Clears the update ranges.
+ */
+ clearUpdateRanges() {
+
+ this.updateRanges.length = 0;
+
+ }
+
+ /**
+ * Copies the values of the given buffer attribute to this instance.
+ *
+ * @param {BufferAttribute} source - The buffer attribute to copy.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ copy( source ) {
+
+ this.name = source.name;
+ this.array = new source.array.constructor( source.array );
+ this.itemSize = source.itemSize;
+ this.count = source.count;
+ this.normalized = source.normalized;
+
+ this.usage = source.usage;
+ this.gpuType = source.gpuType;
+
+ return this;
+
+ }
+
+ /**
+ * Copies a vector from the given buffer attribute to this one. The start
+ * and destination position in the attribute buffers are represented by the
+ * given indices.
+ *
+ * @param {number} index1 - The destination index into this buffer attribute.
+ * @param {BufferAttribute} attribute - The buffer attribute to copy from.
+ * @param {number} index2 - The source index into the given buffer attribute.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ copyAt( index1, attribute, index2 ) {
+
+ index1 *= this.itemSize;
+ index2 *= attribute.itemSize;
+
+ for ( let i = 0, l = this.itemSize; i < l; i ++ ) {
+
+ this.array[ index1 + i ] = attribute.array[ index2 + i ];
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Copies the given array data into this buffer attribute.
+ *
+ * @param {(TypedArray|Array)} array - The array to copy.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ copyArray( array ) {
+
+ this.array.set( array );
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given 3x3 matrix to the given attribute. Works with
+ * item size `2` and `3`.
+ *
+ * @param {Matrix3} m - The matrix to apply.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ applyMatrix3( m ) {
+
+ if ( this.itemSize === 2 ) {
+
+ for ( let i = 0, l = this.count; i < l; i ++ ) {
+
+ _vector2$1.fromBufferAttribute( this, i );
+ _vector2$1.applyMatrix3( m );
+
+ this.setXY( i, _vector2$1.x, _vector2$1.y );
+
+ }
+
+ } else if ( this.itemSize === 3 ) {
+
+ for ( let i = 0, l = this.count; i < l; i ++ ) {
+
+ _vector$a.fromBufferAttribute( this, i );
+ _vector$a.applyMatrix3( m );
+
+ this.setXYZ( i, _vector$a.x, _vector$a.y, _vector$a.z );
+
+ }
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given 4x4 matrix to the given attribute. Only works with
+ * item size `3`.
+ *
+ * @param {Matrix4} m - The matrix to apply.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ applyMatrix4( m ) {
+
+ for ( let i = 0, l = this.count; i < l; i ++ ) {
+
+ _vector$a.fromBufferAttribute( this, i );
+
+ _vector$a.applyMatrix4( m );
+
+ this.setXYZ( i, _vector$a.x, _vector$a.y, _vector$a.z );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given 3x3 normal matrix to the given attribute. Only works with
+ * item size `3`.
+ *
+ * @param {Matrix3} m - The normal matrix to apply.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ applyNormalMatrix( m ) {
+
+ for ( let i = 0, l = this.count; i < l; i ++ ) {
+
+ _vector$a.fromBufferAttribute( this, i );
+
+ _vector$a.applyNormalMatrix( m );
+
+ this.setXYZ( i, _vector$a.x, _vector$a.y, _vector$a.z );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given 4x4 matrix to the given attribute. Only works with
+ * item size `3` and with direction vectors.
+ *
+ * @param {Matrix4} m - The matrix to apply.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ transformDirection( m ) {
+
+ for ( let i = 0, l = this.count; i < l; i ++ ) {
+
+ _vector$a.fromBufferAttribute( this, i );
+
+ _vector$a.transformDirection( m );
+
+ this.setXYZ( i, _vector$a.x, _vector$a.y, _vector$a.z );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given array data in the buffer attribute.
+ *
+ * @param {(TypedArray|Array)} value - The array data to set.
+ * @param {number} [offset=0] - The offset in this buffer attribute's array.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ set( value, offset = 0 ) {
+
+ // Matching BufferAttribute constructor, do not normalize the array.
+ this.array.set( value, offset );
+
+ return this;
+
+ }
+
+ /**
+ * Returns the given component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} component - The component index.
+ * @return {number} The returned value.
+ */
+ getComponent( index, component ) {
+
+ let value = this.array[ index * this.itemSize + component ];
+
+ if ( this.normalized ) value = denormalize( value, this.array );
+
+ return value;
+
+ }
+
+ /**
+ * Sets the given value to the given component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} component - The component index.
+ * @param {number} value - The value to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setComponent( index, component, value ) {
+
+ if ( this.normalized ) value = normalize( value, this.array );
+
+ this.array[ index * this.itemSize + component ] = value;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the x component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The x component.
+ */
+ getX( index ) {
+
+ let x = this.array[ index * this.itemSize ];
+
+ if ( this.normalized ) x = denormalize( x, this.array );
+
+ return x;
+
+ }
+
+ /**
+ * Sets the x component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setX( index, x ) {
+
+ if ( this.normalized ) x = normalize( x, this.array );
+
+ this.array[ index * this.itemSize ] = x;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the y component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The y component.
+ */
+ getY( index ) {
+
+ let y = this.array[ index * this.itemSize + 1 ];
+
+ if ( this.normalized ) y = denormalize( y, this.array );
+
+ return y;
+
+ }
+
+ /**
+ * Sets the y component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} y - The value to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setY( index, y ) {
+
+ if ( this.normalized ) y = normalize( y, this.array );
+
+ this.array[ index * this.itemSize + 1 ] = y;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the z component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The z component.
+ */
+ getZ( index ) {
+
+ let z = this.array[ index * this.itemSize + 2 ];
+
+ if ( this.normalized ) z = denormalize( z, this.array );
+
+ return z;
+
+ }
+
+ /**
+ * Sets the z component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} z - The value to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setZ( index, z ) {
+
+ if ( this.normalized ) z = normalize( z, this.array );
+
+ this.array[ index * this.itemSize + 2 ] = z;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the w component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The w component.
+ */
+ getW( index ) {
+
+ let w = this.array[ index * this.itemSize + 3 ];
+
+ if ( this.normalized ) w = denormalize( w, this.array );
+
+ return w;
+
+ }
+
+ /**
+ * Sets the w component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} w - The value to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setW( index, w ) {
+
+ if ( this.normalized ) w = normalize( w, this.array );
+
+ this.array[ index * this.itemSize + 3 ] = w;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the x and y component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value for the x component to set.
+ * @param {number} y - The value for the y component to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setXY( index, x, y ) {
+
+ index *= this.itemSize;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+
+ }
+
+ this.array[ index + 0 ] = x;
+ this.array[ index + 1 ] = y;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the x, y and z component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value for the x component to set.
+ * @param {number} y - The value for the y component to set.
+ * @param {number} z - The value for the z component to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setXYZ( index, x, y, z ) {
+
+ index *= this.itemSize;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+ z = normalize( z, this.array );
+
+ }
+
+ this.array[ index + 0 ] = x;
+ this.array[ index + 1 ] = y;
+ this.array[ index + 2 ] = z;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the x, y, z and w component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value for the x component to set.
+ * @param {number} y - The value for the y component to set.
+ * @param {number} z - The value for the z component to set.
+ * @param {number} w - The value for the w component to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setXYZW( index, x, y, z, w ) {
+
+ index *= this.itemSize;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+ z = normalize( z, this.array );
+ w = normalize( w, this.array );
+
+ }
+
+ this.array[ index + 0 ] = x;
+ this.array[ index + 1 ] = y;
+ this.array[ index + 2 ] = z;
+ this.array[ index + 3 ] = w;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given callback function that is executed after the Renderer has transferred
+ * the attribute array data to the GPU. Can be used to perform clean-up operations after
+ * the upload when attribute data are not needed anymore on the CPU side.
+ *
+ * @param {Function} callback - The `onUpload()` callback.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ onUpload( callback ) {
+
+ this.onUploadCallback = callback;
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new buffer attribute with copied values from this instance.
+ *
+ * @return {BufferAttribute} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor( this.array, this.itemSize ).copy( this );
+
+ }
+
+ /**
+ * Serializes the buffer attribute into JSON.
+ *
+ * @return {Object} A JSON object representing the serialized buffer attribute.
+ */
+ toJSON() {
+
+ const data = {
+ itemSize: this.itemSize,
+ type: this.array.constructor.name,
+ array: Array.from( this.array ),
+ normalized: this.normalized
+ };
+
+ data.name = this.name;
+ data.usage = this.usage;
+ data.gpuType = this.gpuType;
+
+ return data;
+
+ }
+
+ /**
+ * Disposes of the buffer attribute. Available only in {@link WebGPURenderer}.
+ */
+ dispose() {
+
+ this.dispatchEvent( { type: 'dispose' } );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `Int8` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Int8BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Int8Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Int8Array( array ), itemSize, normalized );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `UInt8` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Uint8BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Uint8Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Uint8Array( array ), itemSize, normalized );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `UInt8Clamped` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Uint8ClampedBufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Uint8ClampedArray)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Uint8ClampedArray( array ), itemSize, normalized );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `Int16` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Int16BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Int16Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Int16Array( array ), itemSize, normalized );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `UInt16` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Uint16BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Uint16Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Uint16Array( array ), itemSize, normalized );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `Int32` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Int32BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Int32Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Int32Array( array ), itemSize, normalized );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `UInt32` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Uint32BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Uint32Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Uint32Array( array ), itemSize, normalized );
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `Float16` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * This class automatically converts to and from FP16 via `Uint16Array` since `Float16Array`
+ * browser support is still problematic.
+ *
+ * @augments BufferAttribute
+ */
+class Float16BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Uint16Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Uint16Array( array ), itemSize, normalized );
+
+ this.isFloat16BufferAttribute = true;
+
+ }
+
+ getX( index ) {
+
+ let x = fromHalfFloat( this.array[ index * this.itemSize ] );
+
+ if ( this.normalized ) x = denormalize( x, this.array );
+
+ return x;
+
+ }
+
+ setX( index, x ) {
+
+ if ( this.normalized ) x = normalize( x, this.array );
+
+ this.array[ index * this.itemSize ] = toHalfFloat( x );
+
+ return this;
+
+ }
+
+ getY( index ) {
+
+ let y = fromHalfFloat( this.array[ index * this.itemSize + 1 ] );
+
+ if ( this.normalized ) y = denormalize( y, this.array );
+
+ return y;
+
+ }
+
+ setY( index, y ) {
+
+ if ( this.normalized ) y = normalize( y, this.array );
+
+ this.array[ index * this.itemSize + 1 ] = toHalfFloat( y );
+
+ return this;
+
+ }
+
+ getZ( index ) {
+
+ let z = fromHalfFloat( this.array[ index * this.itemSize + 2 ] );
+
+ if ( this.normalized ) z = denormalize( z, this.array );
+
+ return z;
+
+ }
+
+ setZ( index, z ) {
+
+ if ( this.normalized ) z = normalize( z, this.array );
+
+ this.array[ index * this.itemSize + 2 ] = toHalfFloat( z );
+
+ return this;
+
+ }
+
+ getW( index ) {
+
+ let w = fromHalfFloat( this.array[ index * this.itemSize + 3 ] );
+
+ if ( this.normalized ) w = denormalize( w, this.array );
+
+ return w;
+
+ }
+
+ setW( index, w ) {
+
+ if ( this.normalized ) w = normalize( w, this.array );
+
+ this.array[ index * this.itemSize + 3 ] = toHalfFloat( w );
+
+ return this;
+
+ }
+
+ setXY( index, x, y ) {
+
+ index *= this.itemSize;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+
+ }
+
+ this.array[ index + 0 ] = toHalfFloat( x );
+ this.array[ index + 1 ] = toHalfFloat( y );
+
+ return this;
+
+ }
+
+ setXYZ( index, x, y, z ) {
+
+ index *= this.itemSize;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+ z = normalize( z, this.array );
+
+ }
+
+ this.array[ index + 0 ] = toHalfFloat( x );
+ this.array[ index + 1 ] = toHalfFloat( y );
+ this.array[ index + 2 ] = toHalfFloat( z );
+
+ return this;
+
+ }
+
+ setXYZW( index, x, y, z, w ) {
+
+ index *= this.itemSize;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+ z = normalize( z, this.array );
+ w = normalize( w, this.array );
+
+ }
+
+ this.array[ index + 0 ] = toHalfFloat( x );
+ this.array[ index + 1 ] = toHalfFloat( y );
+ this.array[ index + 2 ] = toHalfFloat( z );
+ this.array[ index + 3 ] = toHalfFloat( w );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * Convenient class that can be used when creating a `Float32` buffer attribute with
+ * a plain `Array` instance.
+ *
+ * @augments BufferAttribute
+ */
+class Float32BufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new buffer attribute.
+ *
+ * @param {(Array|Float32Array)} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( array, itemSize, normalized ) {
+
+ super( new Float32Array( array ), itemSize, normalized );
+
+ }
+
+}
+
+const _box$3 = /*@__PURE__*/ new Box3();
+const _v1$3 = /*@__PURE__*/ new Vector3();
+const _v2$2 = /*@__PURE__*/ new Vector3();
+
+/**
+ * An analytical 3D sphere defined by a center and radius. This class is mainly
+ * used as a Bounding Sphere for 3D objects.
+ */
+class Sphere {
+
+ /**
+ * Constructs a new sphere.
+ *
+ * @param {Vector3} [center=(0,0,0)] - The center of the sphere
+ * @param {number} [radius=-1] - The radius of the sphere.
+ */
+ constructor( center = new Vector3(), radius = -1 ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSphere = true;
+
+ /**
+ * The center of the sphere
+ *
+ * @type {Vector3}
+ */
+ this.center = center;
+
+ /**
+ * The radius of the sphere.
+ *
+ * @type {number}
+ */
+ this.radius = radius;
+
+ }
+
+ /**
+ * Sets the sphere's components by copying the given values.
+ *
+ * @param {Vector3} center - The center.
+ * @param {number} radius - The radius.
+ * @return {Sphere} A reference to this sphere.
+ */
+ set( center, radius ) {
+
+ this.center.copy( center );
+ this.radius = radius;
+
+ return this;
+
+ }
+
+ /**
+ * Computes the minimum bounding sphere for list of points.
+ * If the optional center point is given, it is used as the sphere's
+ * center. Otherwise, the center of the axis-aligned bounding box
+ * encompassing the points is calculated.
+ *
+ * @param {Array} points - A list of points in 3D space.
+ * @param {Vector3} [optionalCenter] - The center of the sphere.
+ * @return {Sphere} A reference to this sphere.
+ */
+ setFromPoints( points, optionalCenter ) {
+
+ const center = this.center;
+
+ if ( optionalCenter !== undefined ) {
+
+ center.copy( optionalCenter );
+
+ } else {
+
+ _box$3.setFromPoints( points ).getCenter( center );
+
+ }
+
+ let maxRadiusSq = 0;
+
+ for ( let i = 0, il = points.length; i < il; i ++ ) {
+
+ maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( points[ i ] ) );
+
+ }
+
+ this.radius = Math.sqrt( maxRadiusSq );
+
+ return this;
+
+ }
+
+ /**
+ * Copies the values of the given sphere to this instance.
+ *
+ * @param {Sphere} sphere - The sphere to copy.
+ * @return {Sphere} A reference to this sphere.
+ */
+ copy( sphere ) {
+
+ this.center.copy( sphere.center );
+ this.radius = sphere.radius;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if the sphere is empty (the radius set to a negative number).
+ *
+ * Spheres with a radius of `0` contain only their center point and are not
+ * considered to be empty.
+ *
+ * @return {boolean} Whether this sphere is empty or not.
+ */
+ isEmpty() {
+
+ return ( this.radius < 0 );
+
+ }
+
+ /**
+ * Makes this sphere empty which means in encloses a zero space in 3D.
+ *
+ * @return {Sphere} A reference to this sphere.
+ */
+ makeEmpty() {
+
+ this.center.set( 0, 0, 0 );
+ this.radius = -1;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this sphere contains the given point inclusive of
+ * the surface of the sphere.
+ *
+ * @param {Vector3} point - The point to check.
+ * @return {boolean} Whether this sphere contains the given point or not.
+ */
+ containsPoint( point ) {
+
+ return ( point.distanceToSquared( this.center ) <= ( this.radius * this.radius ) );
+
+ }
+
+ /**
+ * Returns the closest distance from the boundary of the sphere to the
+ * given point. If the sphere contains the point, the distance will
+ * be negative.
+ *
+ * @param {Vector3} point - The point to compute the distance to.
+ * @return {number} The distance to the point.
+ */
+ distanceToPoint( point ) {
+
+ return ( point.distanceTo( this.center ) - this.radius );
+
+ }
+
+ /**
+ * Returns `true` if this sphere intersects with the given one.
+ *
+ * @param {Sphere} sphere - The sphere to test.
+ * @return {boolean} Whether this sphere intersects with the given one or not.
+ */
+ intersectsSphere( sphere ) {
+
+ const radiusSum = this.radius + sphere.radius;
+
+ return sphere.center.distanceToSquared( this.center ) <= ( radiusSum * radiusSum );
+
+ }
+
+ /**
+ * Returns `true` if this sphere intersects with the given box.
+ *
+ * @param {Box3} box - The box to test.
+ * @return {boolean} Whether this sphere intersects with the given box or not.
+ */
+ intersectsBox( box ) {
+
+ return box.intersectsSphere( this );
+
+ }
+
+ /**
+ * Returns `true` if this sphere intersects with the given plane.
+ *
+ * @param {Plane} plane - The plane to test.
+ * @return {boolean} Whether this sphere intersects with the given plane or not.
+ */
+ intersectsPlane( plane ) {
+
+ return Math.abs( plane.distanceToPoint( this.center ) ) <= this.radius;
+
+ }
+
+ /**
+ * Clamps a point within the sphere. If the point is outside the sphere, it
+ * will clamp it to the closest point on the edge of the sphere. Points
+ * already inside the sphere will not be affected.
+ *
+ * @param {Vector3} point - The plane to clamp.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The clamped point.
+ */
+ clampPoint( point, target ) {
+
+ const deltaLengthSq = this.center.distanceToSquared( point );
+
+ target.copy( point );
+
+ if ( deltaLengthSq > ( this.radius * this.radius ) ) {
+
+ target.sub( this.center ).normalize();
+ target.multiplyScalar( this.radius ).add( this.center );
+
+ }
+
+ return target;
+
+ }
+
+ /**
+ * Returns a bounding box that encloses this sphere.
+ *
+ * @param {Box3} target - The target box that is used to store the method's result.
+ * @return {Box3} The bounding box that encloses this sphere.
+ */
+ getBoundingBox( target ) {
+
+ if ( this.isEmpty() ) {
+
+ // Empty sphere produces empty bounding box
+ target.makeEmpty();
+ return target;
+
+ }
+
+ target.set( this.center, this.center );
+ target.expandByScalar( this.radius );
+
+ return target;
+
+ }
+
+ /**
+ * Transforms this sphere with the given 4x4 transformation matrix.
+ *
+ * @param {Matrix4} matrix - The transformation matrix.
+ * @return {Sphere} A reference to this sphere.
+ */
+ applyMatrix4( matrix ) {
+
+ this.center.applyMatrix4( matrix );
+ this.radius = this.radius * matrix.getMaxScaleOnAxis();
+
+ return this;
+
+ }
+
+ /**
+ * Translates the sphere's center by the given offset.
+ *
+ * @param {Vector3} offset - The offset.
+ * @return {Sphere} A reference to this sphere.
+ */
+ translate( offset ) {
+
+ this.center.add( offset );
+
+ return this;
+
+ }
+
+ /**
+ * Expands the boundaries of this sphere to include the given point.
+ *
+ * @param {Vector3} point - The point to include.
+ * @return {Sphere} A reference to this sphere.
+ */
+ expandByPoint( point ) {
+
+ if ( this.isEmpty() ) {
+
+ this.center.copy( point );
+
+ this.radius = 0;
+
+ return this;
+
+ }
+
+ _v1$3.subVectors( point, this.center );
+
+ const lengthSq = _v1$3.lengthSq();
+
+ if ( lengthSq > ( this.radius * this.radius ) ) {
+
+ // calculate the minimal sphere
+
+ const length = Math.sqrt( lengthSq );
+
+ const delta = ( length - this.radius ) * 0.5;
+
+ this.center.addScaledVector( _v1$3, delta / length );
+
+ this.radius += delta;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Expands this sphere to enclose both the original sphere and the given sphere.
+ *
+ * @param {Sphere} sphere - The sphere to include.
+ * @return {Sphere} A reference to this sphere.
+ */
+ union( sphere ) {
+
+ if ( sphere.isEmpty() ) {
+
+ return this;
+
+ }
+
+ if ( this.isEmpty() ) {
+
+ this.copy( sphere );
+
+ return this;
+
+ }
+
+ if ( this.center.equals( sphere.center ) === true ) {
+
+ this.radius = Math.max( this.radius, sphere.radius );
+
+ } else {
+
+ _v2$2.subVectors( sphere.center, this.center ).setLength( sphere.radius );
+
+ this.expandByPoint( _v1$3.copy( sphere.center ).add( _v2$2 ) );
+
+ this.expandByPoint( _v1$3.copy( sphere.center ).sub( _v2$2 ) );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this sphere is equal with the given one.
+ *
+ * @param {Sphere} sphere - The sphere to test for equality.
+ * @return {boolean} Whether this bounding sphere is equal with the given one.
+ */
+ equals( sphere ) {
+
+ return sphere.center.equals( this.center ) && ( sphere.radius === this.radius );
+
+ }
+
+ /**
+ * Returns a new sphere with copied values from this instance.
+ *
+ * @return {Sphere} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Returns a serialized structure of the bounding sphere.
+ *
+ * @return {Object} Serialized structure with fields representing the object state.
+ */
+ toJSON() {
+
+ return {
+ radius: this.radius,
+ center: this.center.toArray()
+ };
+
+ }
+
+ /**
+ * Returns a serialized structure of the bounding sphere.
+ *
+ * @param {Object} json - The serialized json to set the sphere from.
+ * @return {Sphere} A reference to this bounding sphere.
+ */
+ fromJSON( json ) {
+
+ this.radius = json.radius;
+ this.center.fromArray( json.center );
+ return this;
+
+ }
+
+}
+
+let _id$1 = 0;
+
+const _m1 = /*@__PURE__*/ new Matrix4();
+const _obj = /*@__PURE__*/ new Object3D();
+const _offset = /*@__PURE__*/ new Vector3();
+const _box$2 = /*@__PURE__*/ new Box3();
+const _boxMorphTargets = /*@__PURE__*/ new Box3();
+const _vector$9 = /*@__PURE__*/ new Vector3();
+
+/**
+ * A representation of mesh, line, or point geometry. Includes vertex
+ * positions, face indices, normals, colors, UVs, and custom attributes
+ * within buffers, reducing the cost of passing all this data to the GPU.
+ *
+ * ```js
+ * const geometry = new THREE.BufferGeometry();
+ * // create a simple square shape. We duplicate the top left and bottom right
+ * // vertices because each vertex needs to appear once per triangle.
+ * const vertices = new Float32Array( [
+ * -1.0, -1.0, 1.0, // v0
+ * 1.0, -1.0, 1.0, // v1
+ * 1.0, 1.0, 1.0, // v2
+ *
+ * 1.0, 1.0, 1.0, // v3
+ * -1.0, 1.0, 1.0, // v4
+ * -1.0, -1.0, 1.0 // v5
+ * ] );
+ * // itemSize = 3 because there are 3 values (components) per vertex
+ * geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xff0000 } );
+ * const mesh = new THREE.Mesh( geometry, material );
+ * ```
+ *
+ * @augments EventDispatcher
+ */
+class BufferGeometry extends EventDispatcher {
+
+ /**
+ * Constructs a new geometry.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isBufferGeometry = true;
+
+ /**
+ * The ID of the geometry.
+ *
+ * @name BufferGeometry#id
+ * @type {number}
+ * @readonly
+ */
+ Object.defineProperty( this, 'id', { value: _id$1 ++ } );
+
+ /**
+ * The UUID of the geometry.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ /**
+ * The name of the geometry.
+ *
+ * @type {string}
+ */
+ this.name = '';
+ this.type = 'BufferGeometry';
+
+ /**
+ * Allows for vertices to be re-used across multiple triangles; this is
+ * called using "indexed triangles". Each triangle is associated with the
+ * indices of three vertices. This attribute therefore stores the index of
+ * each vertex for each triangular face. If this attribute is not set, the
+ * renderer assumes that each three contiguous positions represent a single triangle.
+ *
+ * @type {?BufferAttribute}
+ * @default null
+ */
+ this.index = null;
+
+ /**
+ * A (storage) buffer attribute which was generated with a compute shader and
+ * now defines indirect draw calls.
+ *
+ * Can only be used with {@link WebGPURenderer} and a WebGPU backend.
+ *
+ * @type {?BufferAttribute}
+ * @default null
+ */
+ this.indirect = null;
+
+ /**
+ * The offset, in bytes, into the indirect drawing buffer where the value data begins. If an array is provided, multiple indirect draw calls will be made for each offset.
+ *
+ * Can only be used with {@link WebGPURenderer} and a WebGPU backend.
+ *
+ * @type {number|Array}
+ * @default 0
+ */
+ this.indirectOffset = 0;
+
+ /**
+ * This dictionary has as id the name of the attribute to be set and as value
+ * the buffer attribute to set it to. Rather than accessing this property directly,
+ * use `setAttribute()` and `getAttribute()` to access attributes of this geometry.
+ *
+ * @type {Object}
+ */
+ this.attributes = {};
+
+ /**
+ * This dictionary holds the morph targets of the geometry.
+ *
+ * Note: Once the geometry has been rendered, the morph attribute data cannot
+ * be changed. You will have to call `dispose()`, and create a new geometry instance.
+ *
+ * @type {Object}
+ */
+ this.morphAttributes = {};
+
+ /**
+ * Used to control the morph target behavior; when set to `true`, the morph
+ * target data is treated as relative offsets, rather than as absolute
+ * positions/normals.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.morphTargetsRelative = false;
+
+ /**
+ * Split the geometry into groups, each of which will be rendered in a
+ * separate draw call. This allows an array of materials to be used with the geometry.
+ *
+ * Use `addGroup()` and `clearGroups()` to edit groups, rather than modifying this array directly.
+ *
+ * Every vertex and index must belong to exactly one group — groups must not share vertices or
+ * indices, and must not leave vertices or indices unused.
+ *
+ * @type {Array}
+ */
+ this.groups = [];
+
+ /**
+ * Bounding box for the geometry which can be calculated with `computeBoundingBox()`.
+ *
+ * @type {?Box3}
+ * @default null
+ */
+ this.boundingBox = null;
+
+ /**
+ * Bounding sphere for the geometry which can be calculated with `computeBoundingSphere()`.
+ *
+ * @type {?Sphere}
+ * @default null
+ */
+ this.boundingSphere = null;
+
+ /**
+ * Determines the part of the geometry to render. This should not be set directly,
+ * instead use `setDrawRange()`.
+ *
+ * @type {{start:number,count:number}}
+ */
+ this.drawRange = { start: 0, count: Infinity };
+
+ /**
+ * An object that can be used to store custom data about the geometry.
+ * It should not hold references to functions as these will not be cloned.
+ *
+ * @type {Object}
+ */
+ this.userData = {};
+
+ /**
+ * `true` when the geometry has been transformed since construction
+ * (e.g. via {@link BufferGeometry#applyMatrix4}). Only relevant for
+ * geometry generators (subclasses that populate `parameters`): when set,
+ * {@link BufferGeometry#toJSON} omits `parameters` since they no longer
+ * describe the geometry.
+ *
+ * @private
+ * @type {boolean}
+ * @default false
+ */
+ this._transformed = false;
+
+ }
+
+ /**
+ * Returns the index of this geometry.
+ *
+ * @return {?BufferAttribute} The index. Returns `null` if no index is defined.
+ */
+ getIndex() {
+
+ return this.index;
+
+ }
+
+ /**
+ * Sets the given index to this geometry.
+ *
+ * @param {Array|BufferAttribute} index - The index to set.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ setIndex( index ) {
+
+ if ( Array.isArray( index ) ) {
+
+ this.index = new ( arrayNeedsUint32( index ) ? Uint32BufferAttribute : Uint16BufferAttribute )( index, 1 );
+
+ } else {
+
+ this.index = index;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given indirect attribute to this geometry.
+ *
+ * @param {BufferAttribute} indirect - The attribute holding indirect draw calls.
+ * @param {number|Array} [indirectOffset=0] - The offset, in bytes, into the indirect drawing buffer where the value data begins. If an array is provided, multiple indirect draw calls will be made for each offset.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ setIndirect( indirect, indirectOffset = 0 ) {
+
+ this.indirect = indirect;
+ this.indirectOffset = indirectOffset;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the indirect attribute of this geometry.
+ *
+ * @return {?BufferAttribute} The indirect attribute. Returns `null` if no indirect attribute is defined.
+ */
+ getIndirect() {
+
+ return this.indirect;
+
+ }
+
+ /**
+ * Returns the buffer attribute for the given name.
+ *
+ * @param {string} name - The attribute name.
+ * @return {BufferAttribute|InterleavedBufferAttribute|undefined} The buffer attribute.
+ * Returns `undefined` if not attribute has been found.
+ */
+ getAttribute( name ) {
+
+ return this.attributes[ name ];
+
+ }
+
+ /**
+ * Sets the given attribute for the given name.
+ *
+ * @param {string} name - The attribute name.
+ * @param {BufferAttribute|InterleavedBufferAttribute} attribute - The attribute to set.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ setAttribute( name, attribute ) {
+
+ this.attributes[ name ] = attribute;
+
+ return this;
+
+ }
+
+ /**
+ * Deletes the attribute for the given name.
+ *
+ * @param {string} name - The attribute name to delete.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ deleteAttribute( name ) {
+
+ delete this.attributes[ name ];
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this geometry has an attribute for the given name.
+ *
+ * @param {string} name - The attribute name.
+ * @return {boolean} Whether this geometry has an attribute for the given name or not.
+ */
+ hasAttribute( name ) {
+
+ return this.attributes[ name ] !== undefined;
+
+ }
+
+ /**
+ * Adds a group to this geometry.
+ *
+ * @param {number} start - The first element in this draw call. That is the first
+ * vertex for non-indexed geometry, otherwise the first triangle index.
+ * @param {number} count - Specifies how many vertices (or indices) are part of this group.
+ * @param {number} [materialIndex=0] - The material array index to use.
+ */
+ addGroup( start, count, materialIndex = 0 ) {
+
+ this.groups.push( {
+
+ start: start,
+ count: count,
+ materialIndex: materialIndex
+
+ } );
+
+ }
+
+ /**
+ * Clears all groups.
+ */
+ clearGroups() {
+
+ this.groups = [];
+
+ }
+
+ /**
+ * Sets the draw range for this geometry.
+ *
+ * @param {number} start - The first vertex for non-indexed geometry, otherwise the first triangle index.
+ * @param {number} count - For non-indexed BufferGeometry, `count` is the number of vertices to render.
+ * For indexed BufferGeometry, `count` is the number of indices to render.
+ */
+ setDrawRange( start, count ) {
+
+ this.drawRange.start = start;
+ this.drawRange.count = count;
+
+ }
+
+ /**
+ * Applies the given 4x4 transformation matrix to the geometry.
+ *
+ * @param {Matrix4} matrix - The matrix to apply.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ applyMatrix4( matrix ) {
+
+ const position = this.attributes.position;
+
+ if ( position !== undefined ) {
+
+ position.applyMatrix4( matrix );
+
+ position.needsUpdate = true;
+
+ }
+
+ const normal = this.attributes.normal;
+
+ if ( normal !== undefined ) {
+
+ const normalMatrix = new Matrix3().getNormalMatrix( matrix );
+
+ normal.applyNormalMatrix( normalMatrix );
+
+ normal.needsUpdate = true;
+
+ }
+
+ const tangent = this.attributes.tangent;
+
+ if ( tangent !== undefined ) {
+
+ tangent.transformDirection( matrix );
+
+ tangent.needsUpdate = true;
+
+ }
+
+ if ( this.boundingBox !== null ) {
+
+ this.computeBoundingBox();
+
+ }
+
+ if ( this.boundingSphere !== null ) {
+
+ this.computeBoundingSphere();
+
+ }
+
+ this._transformed = true;
+
+ return this;
+
+ }
+
+ /**
+ * Applies the rotation represented by the Quaternion to the geometry.
+ *
+ * @param {Quaternion} q - The Quaternion to apply.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ applyQuaternion( q ) {
+
+ _m1.makeRotationFromQuaternion( q );
+
+ this.applyMatrix4( _m1 );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates the geometry about the X axis. This is typically done as a one time
+ * operation, and not during a loop. Use {@link Object3D#rotation} for typical
+ * real-time mesh rotation.
+ *
+ * @param {number} angle - The angle in radians.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ rotateX( angle ) {
+
+ // rotate geometry around world x-axis
+
+ _m1.makeRotationX( angle );
+
+ this.applyMatrix4( _m1 );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates the geometry about the Y axis. This is typically done as a one time
+ * operation, and not during a loop. Use {@link Object3D#rotation} for typical
+ * real-time mesh rotation.
+ *
+ * @param {number} angle - The angle in radians.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ rotateY( angle ) {
+
+ // rotate geometry around world y-axis
+
+ _m1.makeRotationY( angle );
+
+ this.applyMatrix4( _m1 );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates the geometry about the Z axis. This is typically done as a one time
+ * operation, and not during a loop. Use {@link Object3D#rotation} for typical
+ * real-time mesh rotation.
+ *
+ * @param {number} angle - The angle in radians.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ rotateZ( angle ) {
+
+ // rotate geometry around world z-axis
+
+ _m1.makeRotationZ( angle );
+
+ this.applyMatrix4( _m1 );
+
+ return this;
+
+ }
+
+ /**
+ * Translates the geometry. This is typically done as a one time
+ * operation, and not during a loop. Use {@link Object3D#position} for typical
+ * real-time mesh rotation.
+ *
+ * @param {number} x - The x offset.
+ * @param {number} y - The y offset.
+ * @param {number} z - The z offset.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ translate( x, y, z ) {
+
+ // translate geometry
+
+ _m1.makeTranslation( x, y, z );
+
+ this.applyMatrix4( _m1 );
+
+ return this;
+
+ }
+
+ /**
+ * Scales the geometry. This is typically done as a one time
+ * operation, and not during a loop. Use {@link Object3D#scale} for typical
+ * real-time mesh rotation.
+ *
+ * @param {number} x - The x scale.
+ * @param {number} y - The y scale.
+ * @param {number} z - The z scale.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ scale( x, y, z ) {
+
+ // scale geometry
+
+ _m1.makeScale( x, y, z );
+
+ this.applyMatrix4( _m1 );
+
+ return this;
+
+ }
+
+ /**
+ * Rotates the geometry to face a point in 3D space. This is typically done as a one time
+ * operation, and not during a loop. Use {@link Object3D#lookAt} for typical
+ * real-time mesh rotation.
+ *
+ * @param {Vector3} vector - The target point.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ lookAt( vector ) {
+
+ _obj.lookAt( vector );
+
+ _obj.updateMatrix();
+
+ this.applyMatrix4( _obj.matrix );
+
+ return this;
+
+ }
+
+ /**
+ * Center the geometry based on its bounding box.
+ *
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ center() {
+
+ this.computeBoundingBox();
+
+ this.boundingBox.getCenter( _offset ).negate();
+
+ this.translate( _offset.x, _offset.y, _offset.z );
+
+ return this;
+
+ }
+
+ /**
+ * Defines a geometry by creating a `position` attribute based on the given array of points. The array
+ * can hold 2D or 3D vectors. When using two-dimensional data, the `z` coordinate for all vertices is
+ * set to `0`.
+ *
+ * If the method is used with an existing `position` attribute, the vertex data are overwritten with the
+ * data from the array. The length of the array must match the vertex count.
+ *
+ * @param {Array|Array} points - The points.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ setFromPoints( points ) {
+
+ const positionAttribute = this.getAttribute( 'position' );
+
+ if ( positionAttribute === undefined ) {
+
+ const position = [];
+
+ for ( let i = 0, l = points.length; i < l; i ++ ) {
+
+ const point = points[ i ];
+ position.push( point.x, point.y, point.z || 0 );
+
+ }
+
+ this.setAttribute( 'position', new Float32BufferAttribute( position, 3 ) );
+
+ } else {
+
+ const l = Math.min( points.length, positionAttribute.count ); // make sure data do not exceed buffer size
+
+ for ( let i = 0; i < l; i ++ ) {
+
+ const point = points[ i ];
+ positionAttribute.setXYZ( i, point.x, point.y, point.z || 0 );
+
+ }
+
+ if ( points.length > positionAttribute.count ) {
+
+ warn( 'BufferGeometry: Buffer size too small for points data. Use .dispose() and create a new geometry.' );
+
+ }
+
+ positionAttribute.needsUpdate = true;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Computes the bounding box of the geometry, and updates the `boundingBox` member.
+ * The bounding box is not computed by the engine; it must be computed by your app.
+ * You may need to recompute the bounding box if the geometry vertices are modified.
+ */
+ computeBoundingBox() {
+
+ if ( this.boundingBox === null ) {
+
+ this.boundingBox = new Box3();
+
+ }
+
+ const position = this.attributes.position;
+ const morphAttributesPosition = this.morphAttributes.position;
+
+ if ( position && position.isGLBufferAttribute ) {
+
+ error( 'BufferGeometry.computeBoundingBox(): GLBufferAttribute requires a manual bounding box.', this );
+
+ this.boundingBox.set(
+ new Vector3( - Infinity, - Infinity, - Infinity ),
+ new Vector3( + Infinity, + Infinity, + Infinity )
+ );
+
+ return;
+
+ }
+
+ if ( position !== undefined ) {
+
+ this.boundingBox.setFromBufferAttribute( position );
+
+ // process morph attributes if present
+
+ if ( morphAttributesPosition ) {
+
+ for ( let i = 0, il = morphAttributesPosition.length; i < il; i ++ ) {
+
+ const morphAttribute = morphAttributesPosition[ i ];
+ _box$2.setFromBufferAttribute( morphAttribute );
+
+ if ( this.morphTargetsRelative ) {
+
+ _vector$9.addVectors( this.boundingBox.min, _box$2.min );
+ this.boundingBox.expandByPoint( _vector$9 );
+
+ _vector$9.addVectors( this.boundingBox.max, _box$2.max );
+ this.boundingBox.expandByPoint( _vector$9 );
+
+ } else {
+
+ this.boundingBox.expandByPoint( _box$2.min );
+ this.boundingBox.expandByPoint( _box$2.max );
+
+ }
+
+ }
+
+ }
+
+ } else {
+
+ this.boundingBox.makeEmpty();
+
+ }
+
+ if ( isNaN( this.boundingBox.min.x ) || isNaN( this.boundingBox.min.y ) || isNaN( this.boundingBox.min.z ) ) {
+
+ error( 'BufferGeometry.computeBoundingBox(): Computed min/max have NaN values. The "position" attribute is likely to have NaN values.', this );
+
+ }
+
+ }
+
+ /**
+ * Computes the bounding sphere of the geometry, and updates the `boundingSphere` member.
+ * The engine automatically computes the bounding sphere when it is needed, e.g., for ray casting or view frustum culling.
+ * You may need to recompute the bounding sphere if the geometry vertices are modified.
+ */
+ computeBoundingSphere() {
+
+ if ( this.boundingSphere === null ) {
+
+ this.boundingSphere = new Sphere();
+
+ }
+
+ const position = this.attributes.position;
+ const morphAttributesPosition = this.morphAttributes.position;
+
+ if ( position && position.isGLBufferAttribute ) {
+
+ error( 'BufferGeometry.computeBoundingSphere(): GLBufferAttribute requires a manual bounding sphere.', this );
+
+ this.boundingSphere.set( new Vector3(), Infinity );
+
+ return;
+
+ }
+
+ if ( position ) {
+
+ // first, find the center of the bounding sphere
+
+ const center = this.boundingSphere.center;
+
+ _box$2.setFromBufferAttribute( position );
+
+ // process morph attributes if present
+
+ if ( morphAttributesPosition ) {
+
+ for ( let i = 0, il = morphAttributesPosition.length; i < il; i ++ ) {
+
+ const morphAttribute = morphAttributesPosition[ i ];
+ _boxMorphTargets.setFromBufferAttribute( morphAttribute );
+
+ if ( this.morphTargetsRelative ) {
+
+ _vector$9.addVectors( _box$2.min, _boxMorphTargets.min );
+ _box$2.expandByPoint( _vector$9 );
+
+ _vector$9.addVectors( _box$2.max, _boxMorphTargets.max );
+ _box$2.expandByPoint( _vector$9 );
+
+ } else {
+
+ _box$2.expandByPoint( _boxMorphTargets.min );
+ _box$2.expandByPoint( _boxMorphTargets.max );
+
+ }
+
+ }
+
+ }
+
+ _box$2.getCenter( center );
+
+ // second, try to find a boundingSphere with a radius smaller than the
+ // boundingSphere of the boundingBox: sqrt(3) smaller in the best case
+
+ let maxRadiusSq = 0;
+
+ for ( let i = 0, il = position.count; i < il; i ++ ) {
+
+ _vector$9.fromBufferAttribute( position, i );
+
+ maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( _vector$9 ) );
+
+ }
+
+ // process morph attributes if present
+
+ if ( morphAttributesPosition ) {
+
+ for ( let i = 0, il = morphAttributesPosition.length; i < il; i ++ ) {
+
+ const morphAttribute = morphAttributesPosition[ i ];
+ const morphTargetsRelative = this.morphTargetsRelative;
+
+ for ( let j = 0, jl = morphAttribute.count; j < jl; j ++ ) {
+
+ _vector$9.fromBufferAttribute( morphAttribute, j );
+
+ if ( morphTargetsRelative ) {
+
+ _offset.fromBufferAttribute( position, j );
+ _vector$9.add( _offset );
+
+ }
+
+ maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( _vector$9 ) );
+
+ }
+
+ }
+
+ }
+
+ this.boundingSphere.radius = Math.sqrt( maxRadiusSq );
+
+ if ( isNaN( this.boundingSphere.radius ) ) {
+
+ error( 'BufferGeometry.computeBoundingSphere(): Computed radius is NaN. The "position" attribute is likely to have NaN values.', this );
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Calculates and adds a tangent attribute to this geometry.
+ *
+ * The computation is only supported for indexed geometries and if position, normal, and uv attributes
+ * are defined. When using a tangent space normal map, prefer the MikkTSpace algorithm provided by
+ * {@link BufferGeometryUtils#computeMikkTSpaceTangents} instead.
+ */
+ computeTangents() {
+
+ const index = this.index;
+ const attributes = this.attributes;
+
+ // based on http://www.terathon.com/code/tangent.html
+ // (per vertex tangents)
+
+ if ( index === null ||
+ attributes.position === undefined ||
+ attributes.normal === undefined ||
+ attributes.uv === undefined ) {
+
+ error( 'BufferGeometry: .computeTangents() failed. Missing required attributes (index, position, normal or uv)' );
+ return;
+
+ }
+
+ const positionAttribute = attributes.position;
+ const normalAttribute = attributes.normal;
+ const uvAttribute = attributes.uv;
+
+ let tangentAttribute = this.getAttribute( 'tangent' );
+
+ if ( tangentAttribute === undefined || tangentAttribute.count !== positionAttribute.count ) {
+
+ tangentAttribute = new BufferAttribute( new Float32Array( 4 * positionAttribute.count ), 4 );
+ this.setAttribute( 'tangent', tangentAttribute );
+
+ }
+
+ const tan1 = [], tan2 = [];
+
+ for ( let i = 0; i < positionAttribute.count; i ++ ) {
+
+ tan1[ i ] = new Vector3();
+ tan2[ i ] = new Vector3();
+
+ }
+
+ const vA = new Vector3(),
+ vB = new Vector3(),
+ vC = new Vector3(),
+
+ uvA = new Vector2(),
+ uvB = new Vector2(),
+ uvC = new Vector2(),
+
+ sdir = new Vector3(),
+ tdir = new Vector3();
+
+ function handleTriangle( a, b, c ) {
+
+ vA.fromBufferAttribute( positionAttribute, a );
+ vB.fromBufferAttribute( positionAttribute, b );
+ vC.fromBufferAttribute( positionAttribute, c );
+
+ uvA.fromBufferAttribute( uvAttribute, a );
+ uvB.fromBufferAttribute( uvAttribute, b );
+ uvC.fromBufferAttribute( uvAttribute, c );
+
+ vB.sub( vA );
+ vC.sub( vA );
+
+ uvB.sub( uvA );
+ uvC.sub( uvA );
+
+ const r = 1.0 / ( uvB.x * uvC.y - uvC.x * uvB.y );
+
+ // silently ignore degenerate uv triangles having coincident or colinear vertices
+
+ if ( ! isFinite( r ) ) return;
+
+ sdir.copy( vB ).multiplyScalar( uvC.y ).addScaledVector( vC, - uvB.y ).multiplyScalar( r );
+ tdir.copy( vC ).multiplyScalar( uvB.x ).addScaledVector( vB, - uvC.x ).multiplyScalar( r );
+
+ tan1[ a ].add( sdir );
+ tan1[ b ].add( sdir );
+ tan1[ c ].add( sdir );
+
+ tan2[ a ].add( tdir );
+ tan2[ b ].add( tdir );
+ tan2[ c ].add( tdir );
+
+ }
+
+ let groups = this.groups;
+
+ if ( groups.length === 0 ) {
+
+ groups = [ {
+ start: 0,
+ count: index.count
+ } ];
+
+ }
+
+ for ( let i = 0, il = groups.length; i < il; ++ i ) {
+
+ const group = groups[ i ];
+
+ const start = group.start;
+ const count = group.count;
+
+ for ( let j = start, jl = start + count; j < jl; j += 3 ) {
+
+ handleTriangle(
+ index.getX( j + 0 ),
+ index.getX( j + 1 ),
+ index.getX( j + 2 )
+ );
+
+ }
+
+ }
+
+ const tmp = new Vector3(), tmp2 = new Vector3();
+ const n = new Vector3(), n2 = new Vector3();
+
+ function handleVertex( v ) {
+
+ n.fromBufferAttribute( normalAttribute, v );
+ n2.copy( n );
+
+ const t = tan1[ v ];
+
+ // Gram-Schmidt orthogonalize
+
+ tmp.copy( t );
+ tmp.sub( n.multiplyScalar( n.dot( t ) ) ).normalize();
+
+ // Calculate handedness
+
+ tmp2.crossVectors( n2, t );
+ const test = tmp2.dot( tan2[ v ] );
+ const w = ( test < 0.0 ) ? -1 : 1.0;
+
+ tangentAttribute.setXYZW( v, tmp.x, tmp.y, tmp.z, w );
+
+ }
+
+ for ( let i = 0, il = groups.length; i < il; ++ i ) {
+
+ const group = groups[ i ];
+
+ const start = group.start;
+ const count = group.count;
+
+ for ( let j = start, jl = start + count; j < jl; j += 3 ) {
+
+ handleVertex( index.getX( j + 0 ) );
+ handleVertex( index.getX( j + 1 ) );
+ handleVertex( index.getX( j + 2 ) );
+
+ }
+
+ }
+
+ this._transformed = true;
+
+ }
+
+ /**
+ * Computes vertex normals for the given vertex data. For indexed geometries, the method sets
+ * each vertex normal to be the average of the face normals of the faces that share that vertex.
+ * For non-indexed geometries, vertices are not shared, and the method sets each vertex normal
+ * to be the same as the face normal.
+ */
+ computeVertexNormals() {
+
+ const index = this.index;
+ const positionAttribute = this.getAttribute( 'position' );
+
+ if ( positionAttribute !== undefined ) {
+
+ let normalAttribute = this.getAttribute( 'normal' );
+
+ if ( normalAttribute === undefined || normalAttribute.count !== positionAttribute.count ) {
+
+ normalAttribute = new BufferAttribute( new Float32Array( positionAttribute.count * 3 ), 3 );
+ this.setAttribute( 'normal', normalAttribute );
+
+ } else {
+
+ // reset existing normals to zero
+
+ for ( let i = 0, il = normalAttribute.count; i < il; i ++ ) {
+
+ normalAttribute.setXYZ( i, 0, 0, 0 );
+
+ }
+
+ }
+
+ const pA = new Vector3(), pB = new Vector3(), pC = new Vector3();
+ const nA = new Vector3(), nB = new Vector3(), nC = new Vector3();
+ const cb = new Vector3(), ab = new Vector3();
+
+ // indexed elements
+
+ if ( index ) {
+
+ for ( let i = 0, il = index.count; i < il; i += 3 ) {
+
+ const vA = index.getX( i + 0 );
+ const vB = index.getX( i + 1 );
+ const vC = index.getX( i + 2 );
+
+ pA.fromBufferAttribute( positionAttribute, vA );
+ pB.fromBufferAttribute( positionAttribute, vB );
+ pC.fromBufferAttribute( positionAttribute, vC );
+
+ cb.subVectors( pC, pB );
+ ab.subVectors( pA, pB );
+ cb.cross( ab );
+
+ nA.fromBufferAttribute( normalAttribute, vA );
+ nB.fromBufferAttribute( normalAttribute, vB );
+ nC.fromBufferAttribute( normalAttribute, vC );
+
+ nA.add( cb );
+ nB.add( cb );
+ nC.add( cb );
+
+ normalAttribute.setXYZ( vA, nA.x, nA.y, nA.z );
+ normalAttribute.setXYZ( vB, nB.x, nB.y, nB.z );
+ normalAttribute.setXYZ( vC, nC.x, nC.y, nC.z );
+
+ }
+
+ } else {
+
+ // non-indexed elements (unconnected triangle soup)
+
+ for ( let i = 0, il = positionAttribute.count; i < il; i += 3 ) {
+
+ pA.fromBufferAttribute( positionAttribute, i + 0 );
+ pB.fromBufferAttribute( positionAttribute, i + 1 );
+ pC.fromBufferAttribute( positionAttribute, i + 2 );
+
+ cb.subVectors( pC, pB );
+ ab.subVectors( pA, pB );
+ cb.cross( ab );
+
+ normalAttribute.setXYZ( i + 0, cb.x, cb.y, cb.z );
+ normalAttribute.setXYZ( i + 1, cb.x, cb.y, cb.z );
+ normalAttribute.setXYZ( i + 2, cb.x, cb.y, cb.z );
+
+ }
+
+ }
+
+ this.normalizeNormals();
+
+ normalAttribute.needsUpdate = true;
+
+ }
+
+ }
+
+ /**
+ * Ensures every normal vector in a geometry will have a magnitude of `1`. This will
+ * correct lighting on the geometry surfaces.
+ */
+ normalizeNormals() {
+
+ const normals = this.attributes.normal;
+
+ for ( let i = 0, il = normals.count; i < il; i ++ ) {
+
+ _vector$9.fromBufferAttribute( normals, i );
+
+ _vector$9.normalize();
+
+ normals.setXYZ( i, _vector$9.x, _vector$9.y, _vector$9.z );
+
+ }
+
+ }
+
+ /**
+ * Return a new non-index version of this indexed geometry. If the geometry
+ * is already non-indexed, the method is a NOOP.
+ *
+ * @return {BufferGeometry} The non-indexed version of this indexed geometry.
+ */
+ toNonIndexed() {
+
+ function convertBufferAttribute( attribute, indices ) {
+
+ const array = attribute.array;
+ const itemSize = attribute.itemSize;
+ const normalized = attribute.normalized;
+
+ const array2 = new array.constructor( indices.length * itemSize );
+
+ let index = 0, index2 = 0;
+
+ for ( let i = 0, l = indices.length; i < l; i ++ ) {
+
+ if ( attribute.isInterleavedBufferAttribute ) {
+
+ index = indices[ i ] * attribute.data.stride + attribute.offset;
+
+ } else {
+
+ index = indices[ i ] * itemSize;
+
+ }
+
+ for ( let j = 0; j < itemSize; j ++ ) {
+
+ array2[ index2 ++ ] = array[ index ++ ];
+
+ }
+
+ }
+
+ return new BufferAttribute( array2, itemSize, normalized );
+
+ }
+
+ //
+
+ if ( this.index === null ) {
+
+ warn( 'BufferGeometry.toNonIndexed(): BufferGeometry is already non-indexed.' );
+ return this;
+
+ }
+
+ const geometry2 = new BufferGeometry();
+
+ const indices = this.index.array;
+ const attributes = this.attributes;
+
+ // attributes
+
+ for ( const name in attributes ) {
+
+ const attribute = attributes[ name ];
+
+ const newAttribute = convertBufferAttribute( attribute, indices );
+
+ geometry2.setAttribute( name, newAttribute );
+
+ }
+
+ // morph attributes
+
+ const morphAttributes = this.morphAttributes;
+
+ for ( const name in morphAttributes ) {
+
+ const morphArray = [];
+ const morphAttribute = morphAttributes[ name ]; // morphAttribute: array of Float32BufferAttributes
+
+ for ( let i = 0, il = morphAttribute.length; i < il; i ++ ) {
+
+ const attribute = morphAttribute[ i ];
+
+ const newAttribute = convertBufferAttribute( attribute, indices );
+
+ morphArray.push( newAttribute );
+
+ }
+
+ geometry2.morphAttributes[ name ] = morphArray;
+
+ }
+
+ geometry2.morphTargetsRelative = this.morphTargetsRelative;
+
+ // groups
+
+ const groups = this.groups;
+
+ for ( let i = 0, l = groups.length; i < l; i ++ ) {
+
+ const group = groups[ i ];
+ geometry2.addGroup( group.start, group.count, group.materialIndex );
+
+ }
+
+ return geometry2;
+
+ }
+
+ /**
+ * Serializes the geometry into JSON.
+ *
+ * @return {Object} A JSON object representing the serialized geometry.
+ */
+ toJSON() {
+
+ const data = {
+ metadata: {
+ version: 4.7,
+ type: 'BufferGeometry',
+ generator: 'BufferGeometry.toJSON'
+ }
+ };
+
+ // standard BufferGeometry serialization
+
+ data.uuid = this.uuid;
+ data.type = ( this.parameters !== undefined && this._transformed === true ) ? 'BufferGeometry' : this.type;
+ data.name = this.name;
+ if ( Object.keys( this.userData ).length > 0 ) data.userData = this.userData;
+
+ if ( this.parameters !== undefined && this._transformed !== true ) {
+
+ const parameters = this.parameters;
+
+ for ( const key in parameters ) {
+
+ if ( parameters[ key ] !== undefined ) data[ key ] = parameters[ key ];
+
+ }
+
+ return data;
+
+ }
+
+ // for simplicity the code assumes attributes are not shared across geometries, see #15811
+
+ data.data = { attributes: {} };
+
+ const index = this.index;
+
+ if ( index !== null ) {
+
+ data.data.index = {
+ type: index.array.constructor.name,
+ array: Array.prototype.slice.call( index.array )
+ };
+
+ }
+
+ const attributes = this.attributes;
+
+ for ( const key in attributes ) {
+
+ const attribute = attributes[ key ];
+
+ data.data.attributes[ key ] = attribute.toJSON( data.data );
+
+ }
+
+ const morphAttributes = {};
+ let hasMorphAttributes = false;
+
+ for ( const key in this.morphAttributes ) {
+
+ const attributeArray = this.morphAttributes[ key ];
+
+ const array = [];
+
+ for ( let i = 0, il = attributeArray.length; i < il; i ++ ) {
+
+ const attribute = attributeArray[ i ];
+
+ array.push( attribute.toJSON( data.data ) );
+
+ }
+
+ if ( array.length > 0 ) {
+
+ morphAttributes[ key ] = array;
+
+ hasMorphAttributes = true;
+
+ }
+
+ }
+
+ if ( hasMorphAttributes ) {
+
+ data.data.morphAttributes = morphAttributes;
+ data.data.morphTargetsRelative = this.morphTargetsRelative;
+
+ }
+
+ const groups = this.groups;
+
+ if ( groups.length > 0 ) {
+
+ data.data.groups = JSON.parse( JSON.stringify( groups ) );
+
+ }
+
+ const boundingSphere = this.boundingSphere;
+
+ if ( boundingSphere !== null ) {
+
+ data.data.boundingSphere = boundingSphere.toJSON();
+
+ }
+
+ return data;
+
+ }
+
+ /**
+ * Returns a new geometry with copied values from this instance.
+ *
+ * @return {BufferGeometry} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Copies the values of the given geometry to this instance.
+ *
+ * @param {BufferGeometry} source - The geometry to copy.
+ * @return {BufferGeometry} A reference to this instance.
+ */
+ copy( source ) {
+
+ // reset
+
+ this.index = null;
+ this.attributes = {};
+ this.morphAttributes = {};
+ this.groups = [];
+ this.boundingBox = null;
+ this.boundingSphere = null;
+
+ // used for storing cloned, shared data
+
+ const data = {};
+
+ // name
+
+ this.name = source.name;
+
+ // index
+
+ const index = source.index;
+
+ if ( index !== null ) {
+
+ this.setIndex( index.clone() );
+
+ }
+
+ // attributes
+
+ const attributes = source.attributes;
+
+ for ( const name in attributes ) {
+
+ const attribute = attributes[ name ];
+ this.setAttribute( name, attribute.clone( data ) );
+
+ }
+
+ // morph attributes
+
+ const morphAttributes = source.morphAttributes;
+
+ for ( const name in morphAttributes ) {
+
+ const array = [];
+ const morphAttribute = morphAttributes[ name ]; // morphAttribute: array of Float32BufferAttributes
+
+ for ( let i = 0, l = morphAttribute.length; i < l; i ++ ) {
+
+ array.push( morphAttribute[ i ].clone( data ) );
+
+ }
+
+ this.morphAttributes[ name ] = array;
+
+ }
+
+ this.morphTargetsRelative = source.morphTargetsRelative;
+
+ // groups
+
+ const groups = source.groups;
+
+ for ( let i = 0, l = groups.length; i < l; i ++ ) {
+
+ const group = groups[ i ];
+ this.addGroup( group.start, group.count, group.materialIndex );
+
+ }
+
+ // bounding box
+
+ const boundingBox = source.boundingBox;
+
+ if ( boundingBox !== null ) {
+
+ this.boundingBox = boundingBox.clone();
+
+ }
+
+ // bounding sphere
+
+ const boundingSphere = source.boundingSphere;
+
+ if ( boundingSphere !== null ) {
+
+ this.boundingSphere = boundingSphere.clone();
+
+ }
+
+ // draw range
+
+ this.drawRange.start = source.drawRange.start;
+ this.drawRange.count = source.drawRange.count;
+
+ // user data
+
+ this.userData = source.userData;
+
+ // transformed flag
+
+ this._transformed = source._transformed;
+
+ return this;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ *
+ * @fires BufferGeometry#dispose
+ */
+ dispose() {
+
+ this.dispatchEvent( { type: 'dispose' } );
+
+ }
+
+}
+
+/**
+ * "Interleaved" means that multiple attributes, possibly of different types,
+ * (e.g., position, normal, uv, color) are packed into a single array buffer.
+ *
+ * An introduction into interleaved arrays can be found here: [Interleaved array basics](https://blog.tojicode.com/2011/05/interleaved-array-basics.html)
+ */
+class InterleavedBuffer {
+
+ /**
+ * Constructs a new interleaved buffer.
+ *
+ * @param {TypedArray} array - A typed array with a shared buffer storing attribute data.
+ * @param {number} stride - The number of typed-array elements per vertex.
+ */
+ constructor( array, stride ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isInterleavedBuffer = true;
+
+ /**
+ * A typed array with a shared buffer storing attribute data.
+ *
+ * @type {TypedArray}
+ */
+ this.array = array;
+
+ /**
+ * The number of typed-array elements per vertex.
+ *
+ * @type {number}
+ */
+ this.stride = stride;
+
+ /**
+ * The total number of elements in the array
+ *
+ * @type {number}
+ * @readonly
+ */
+ this.count = array !== undefined ? array.length / stride : 0;
+
+ /**
+ * Defines the intended usage pattern of the data store for optimization purposes.
+ *
+ * Note: After the initial use of a buffer, its usage cannot be changed. Instead,
+ * instantiate a new one and set the desired usage before the next render.
+ *
+ * @type {(StaticDrawUsage|DynamicDrawUsage|StreamDrawUsage|StaticReadUsage|DynamicReadUsage|StreamReadUsage|StaticCopyUsage|DynamicCopyUsage|StreamCopyUsage)}
+ * @default StaticDrawUsage
+ */
+ this.usage = StaticDrawUsage;
+
+ /**
+ * This can be used to only update some components of stored vectors (for example, just the
+ * component related to color). Use the `addUpdateRange()` function to add ranges to this array.
+ *
+ * @type {Array}
+ */
+ this.updateRanges = [];
+
+ /**
+ * A version number, incremented every time the `needsUpdate` is set to `true`.
+ *
+ * @type {number}
+ */
+ this.version = 0;
+
+ /**
+ * The UUID of the interleaved buffer.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ }
+
+ /**
+ * A callback function that is executed after the renderer has transferred the attribute array
+ * data to the GPU.
+ */
+ onUploadCallback() {}
+
+ /**
+ * Flag to indicate that this attribute has changed and should be re-sent to
+ * the GPU. Set this to `true` when you modify the value of the array.
+ *
+ * @type {number}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsUpdate( value ) {
+
+ if ( value === true ) this.version ++;
+
+ }
+
+ /**
+ * Sets the usage of this interleaved buffer.
+ *
+ * @param {(StaticDrawUsage|DynamicDrawUsage|StreamDrawUsage|StaticReadUsage|DynamicReadUsage|StreamReadUsage|StaticCopyUsage|DynamicCopyUsage|StreamCopyUsage)} value - The usage to set.
+ * @return {InterleavedBuffer} A reference to this interleaved buffer.
+ */
+ setUsage( value ) {
+
+ this.usage = value;
+
+ return this;
+
+ }
+
+ /**
+ * Adds a range of data in the data array to be updated on the GPU.
+ *
+ * @param {number} start - Position at which to start update.
+ * @param {number} count - The number of components to update.
+ */
+ addUpdateRange( start, count ) {
+
+ this.updateRanges.push( { start, count } );
+
+ }
+
+ /**
+ * Clears the update ranges.
+ */
+ clearUpdateRanges() {
+
+ this.updateRanges.length = 0;
+
+ }
+
+ /**
+ * Copies the values of the given interleaved buffer to this instance.
+ *
+ * @param {InterleavedBuffer} source - The interleaved buffer to copy.
+ * @return {InterleavedBuffer} A reference to this instance.
+ */
+ copy( source ) {
+
+ this.array = new source.array.constructor( source.array );
+ this.count = source.count;
+ this.stride = source.stride;
+ this.usage = source.usage;
+
+ return this;
+
+ }
+
+ /**
+ * Copies a vector from the given interleaved buffer to this one. The start
+ * and destination position in the attribute buffers are represented by the
+ * given indices.
+ *
+ * @param {number} index1 - The destination index into this interleaved buffer.
+ * @param {InterleavedBuffer} interleavedBuffer - The interleaved buffer to copy from.
+ * @param {number} index2 - The source index into the given interleaved buffer.
+ * @return {InterleavedBuffer} A reference to this instance.
+ */
+ copyAt( index1, interleavedBuffer, index2 ) {
+
+ index1 *= this.stride;
+ index2 *= interleavedBuffer.stride;
+
+ for ( let i = 0, l = this.stride; i < l; i ++ ) {
+
+ this.array[ index1 + i ] = interleavedBuffer.array[ index2 + i ];
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given array data in the interleaved buffer.
+ *
+ * @param {(TypedArray|Array)} value - The array data to set.
+ * @param {number} [offset=0] - The offset in this interleaved buffer's array.
+ * @return {InterleavedBuffer} A reference to this instance.
+ */
+ set( value, offset = 0 ) {
+
+ this.array.set( value, offset );
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new interleaved buffer with copied values from this instance.
+ *
+ * @param {Object} [data] - An object with shared array buffers that allows to retain shared structures.
+ * @return {InterleavedBuffer} A clone of this instance.
+ */
+ clone( data ) {
+
+ if ( data.arrayBuffers === undefined ) {
+
+ data.arrayBuffers = {};
+
+ }
+
+ if ( this.array.buffer._uuid === undefined ) {
+
+ this.array.buffer._uuid = generateUUID();
+
+ }
+
+ if ( data.arrayBuffers[ this.array.buffer._uuid ] === undefined ) {
+
+ data.arrayBuffers[ this.array.buffer._uuid ] = this.array.slice( 0 ).buffer;
+
+ }
+
+ const array = new this.array.constructor( data.arrayBuffers[ this.array.buffer._uuid ] );
+
+ const ib = new this.constructor( array, this.stride );
+ ib.setUsage( this.usage );
+
+ return ib;
+
+ }
+
+ /**
+ * Sets the given callback function that is executed after the Renderer has transferred
+ * the array data to the GPU. Can be used to perform clean-up operations after
+ * the upload when data are not needed anymore on the CPU side.
+ *
+ * @param {Function} callback - The `onUpload()` callback.
+ * @return {InterleavedBuffer} A reference to this instance.
+ */
+ onUpload( callback ) {
+
+ this.onUploadCallback = callback;
+
+ return this;
+
+ }
+
+ /**
+ * Serializes the interleaved buffer into JSON.
+ *
+ * @param {Object} [data] - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized interleaved buffer.
+ */
+ toJSON( data ) {
+
+ if ( data.arrayBuffers === undefined ) {
+
+ data.arrayBuffers = {};
+
+ }
+
+ // generate UUID for array buffer if necessary
+
+ if ( this.array.buffer._uuid === undefined ) {
+
+ this.array.buffer._uuid = generateUUID();
+
+ }
+
+ if ( data.arrayBuffers[ this.array.buffer._uuid ] === undefined ) {
+
+ data.arrayBuffers[ this.array.buffer._uuid ] = Array.from( new Uint32Array( this.array.buffer ) );
+
+ }
+
+ //
+
+ const json = {
+ uuid: this.uuid,
+ buffer: this.array.buffer._uuid,
+ type: this.array.constructor.name,
+ stride: this.stride
+ };
+
+ json.usage = this.usage;
+
+ return json;
+
+ }
+
+}
+
+const _vector$8 = /*@__PURE__*/ new Vector3();
+
+/**
+ * An alternative version of a buffer attribute with interleaved data. Interleaved
+ * attributes share a common interleaved data storage ({@link InterleavedBuffer}) and refer with
+ * different offsets into the buffer.
+ */
+class InterleavedBufferAttribute {
+
+ /**
+ * Constructs a new interleaved buffer attribute.
+ *
+ * @param {InterleavedBuffer} interleavedBuffer - The buffer holding the interleaved data.
+ * @param {number} itemSize - The item size.
+ * @param {number} offset - The attribute offset into the buffer.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( interleavedBuffer, itemSize, offset, normalized = false ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isInterleavedBufferAttribute = true;
+
+ /**
+ * The name of the buffer attribute.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The buffer holding the interleaved data.
+ *
+ * @type {InterleavedBuffer}
+ */
+ this.data = interleavedBuffer;
+
+ /**
+ * The item size, see {@link BufferAttribute#itemSize}.
+ *
+ * @type {number}
+ */
+ this.itemSize = itemSize;
+
+ /**
+ * The attribute offset into the buffer.
+ *
+ * @type {number}
+ */
+ this.offset = offset;
+
+ /**
+ * Whether the data are normalized or not, see {@link BufferAttribute#normalized}
+ *
+ * @type {InterleavedBuffer}
+ */
+ this.normalized = normalized;
+
+ }
+
+ /**
+ * The item count of this buffer attribute.
+ *
+ * @type {number}
+ * @readonly
+ */
+ get count() {
+
+ return this.data.count;
+
+ }
+
+ /**
+ * The array holding the interleaved buffer attribute data.
+ *
+ * @type {TypedArray}
+ */
+ get array() {
+
+ return this.data.array;
+
+ }
+
+ /**
+ * Flag to indicate that this attribute has changed and should be re-sent to
+ * the GPU. Set this to `true` when you modify the value of the array.
+ *
+ * @type {number}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsUpdate( value ) {
+
+ this.data.needsUpdate = value;
+
+ }
+
+ /**
+ * Applies the given 4x4 matrix to the given attribute. Only works with
+ * item size `3`.
+ *
+ * @param {Matrix4} m - The matrix to apply.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ applyMatrix4( m ) {
+
+ for ( let i = 0, l = this.data.count; i < l; i ++ ) {
+
+ _vector$8.fromBufferAttribute( this, i );
+
+ _vector$8.applyMatrix4( m );
+
+ this.setXYZ( i, _vector$8.x, _vector$8.y, _vector$8.z );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given 3x3 normal matrix to the given attribute. Only works with
+ * item size `3`.
+ *
+ * @param {Matrix3} m - The normal matrix to apply.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ applyNormalMatrix( m ) {
+
+ for ( let i = 0, l = this.count; i < l; i ++ ) {
+
+ _vector$8.fromBufferAttribute( this, i );
+
+ _vector$8.applyNormalMatrix( m );
+
+ this.setXYZ( i, _vector$8.x, _vector$8.y, _vector$8.z );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Applies the given 4x4 matrix to the given attribute. Only works with
+ * item size `3` and with direction vectors.
+ *
+ * @param {Matrix4} m - The matrix to apply.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ transformDirection( m ) {
+
+ for ( let i = 0, l = this.count; i < l; i ++ ) {
+
+ _vector$8.fromBufferAttribute( this, i );
+
+ _vector$8.transformDirection( m );
+
+ this.setXYZ( i, _vector$8.x, _vector$8.y, _vector$8.z );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the given component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} component - The component index.
+ * @return {number} The returned value.
+ */
+ getComponent( index, component ) {
+
+ let value = this.array[ index * this.data.stride + this.offset + component ];
+
+ if ( this.normalized ) value = denormalize( value, this.array );
+
+ return value;
+
+ }
+
+ /**
+ * Sets the given value to the given component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} component - The component index.
+ * @param {number} value - The value to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setComponent( index, component, value ) {
+
+ if ( this.normalized ) value = normalize( value, this.array );
+
+ this.data.array[ index * this.data.stride + this.offset + component ] = value;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the x component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setX( index, x ) {
+
+ if ( this.normalized ) x = normalize( x, this.array );
+
+ this.data.array[ index * this.data.stride + this.offset ] = x;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the y component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} y - The value to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setY( index, y ) {
+
+ if ( this.normalized ) y = normalize( y, this.array );
+
+ this.data.array[ index * this.data.stride + this.offset + 1 ] = y;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the z component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} z - The value to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setZ( index, z ) {
+
+ if ( this.normalized ) z = normalize( z, this.array );
+
+ this.data.array[ index * this.data.stride + this.offset + 2 ] = z;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the w component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} w - The value to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setW( index, w ) {
+
+ if ( this.normalized ) w = normalize( w, this.array );
+
+ this.data.array[ index * this.data.stride + this.offset + 3 ] = w;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the x component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The x component.
+ */
+ getX( index ) {
+
+ let x = this.data.array[ index * this.data.stride + this.offset ];
+
+ if ( this.normalized ) x = denormalize( x, this.array );
+
+ return x;
+
+ }
+
+ /**
+ * Returns the y component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The y component.
+ */
+ getY( index ) {
+
+ let y = this.data.array[ index * this.data.stride + this.offset + 1 ];
+
+ if ( this.normalized ) y = denormalize( y, this.array );
+
+ return y;
+
+ }
+
+ /**
+ * Returns the z component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The z component.
+ */
+ getZ( index ) {
+
+ let z = this.data.array[ index * this.data.stride + this.offset + 2 ];
+
+ if ( this.normalized ) z = denormalize( z, this.array );
+
+ return z;
+
+ }
+
+ /**
+ * Returns the w component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @return {number} The w component.
+ */
+ getW( index ) {
+
+ let w = this.data.array[ index * this.data.stride + this.offset + 3 ];
+
+ if ( this.normalized ) w = denormalize( w, this.array );
+
+ return w;
+
+ }
+
+ /**
+ * Sets the x and y component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value for the x component to set.
+ * @param {number} y - The value for the y component to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setXY( index, x, y ) {
+
+ index = index * this.data.stride + this.offset;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+
+ }
+
+ this.data.array[ index + 0 ] = x;
+ this.data.array[ index + 1 ] = y;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the x, y and z component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value for the x component to set.
+ * @param {number} y - The value for the y component to set.
+ * @param {number} z - The value for the z component to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setXYZ( index, x, y, z ) {
+
+ index = index * this.data.stride + this.offset;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+ z = normalize( z, this.array );
+
+ }
+
+ this.data.array[ index + 0 ] = x;
+ this.data.array[ index + 1 ] = y;
+ this.data.array[ index + 2 ] = z;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the x, y, z and w component of the vector at the given index.
+ *
+ * @param {number} index - The index into the buffer attribute.
+ * @param {number} x - The value for the x component to set.
+ * @param {number} y - The value for the y component to set.
+ * @param {number} z - The value for the z component to set.
+ * @param {number} w - The value for the w component to set.
+ * @return {InterleavedBufferAttribute} A reference to this instance.
+ */
+ setXYZW( index, x, y, z, w ) {
+
+ index = index * this.data.stride + this.offset;
+
+ if ( this.normalized ) {
+
+ x = normalize( x, this.array );
+ y = normalize( y, this.array );
+ z = normalize( z, this.array );
+ w = normalize( w, this.array );
+
+ }
+
+ this.data.array[ index + 0 ] = x;
+ this.data.array[ index + 1 ] = y;
+ this.data.array[ index + 2 ] = z;
+ this.data.array[ index + 3 ] = w;
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new buffer attribute with copied values from this instance.
+ *
+ * If no parameter is provided, cloning an interleaved buffer attribute will de-interleave buffer data.
+ *
+ * @param {Object} [data] - An object with interleaved buffers that allows to retain the interleaved property.
+ * @return {BufferAttribute|InterleavedBufferAttribute} A clone of this instance.
+ */
+ clone( data ) {
+
+ if ( data === undefined ) {
+
+ log( 'InterleavedBufferAttribute.clone(): Cloning an interleaved buffer attribute will de-interleave buffer data.' );
+
+ const array = [];
+
+ for ( let i = 0; i < this.count; i ++ ) {
+
+ const index = i * this.data.stride + this.offset;
+
+ for ( let j = 0; j < this.itemSize; j ++ ) {
+
+ array.push( this.data.array[ index + j ] );
+
+ }
+
+ }
+
+ return new BufferAttribute( new this.array.constructor( array ), this.itemSize, this.normalized );
+
+ } else {
+
+ if ( data.interleavedBuffers === undefined ) {
+
+ data.interleavedBuffers = {};
+
+ }
+
+ if ( data.interleavedBuffers[ this.data.uuid ] === undefined ) {
+
+ data.interleavedBuffers[ this.data.uuid ] = this.data.clone( data );
+
+ }
+
+ return new InterleavedBufferAttribute( data.interleavedBuffers[ this.data.uuid ], this.itemSize, this.offset, this.normalized );
+
+ }
+
+ }
+
+ /**
+ * Serializes the buffer attribute into JSON.
+ *
+ * If no parameter is provided, cloning an interleaved buffer attribute will de-interleave buffer data.
+ *
+ * @param {Object} [data] - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized buffer attribute.
+ */
+ toJSON( data ) {
+
+ if ( data === undefined ) {
+
+ log( 'InterleavedBufferAttribute.toJSON(): Serializing an interleaved buffer attribute will de-interleave buffer data.' );
+
+ const array = [];
+
+ for ( let i = 0; i < this.count; i ++ ) {
+
+ const index = i * this.data.stride + this.offset;
+
+ for ( let j = 0; j < this.itemSize; j ++ ) {
+
+ array.push( this.data.array[ index + j ] );
+
+ }
+
+ }
+
+ // de-interleave data and save it as an ordinary buffer attribute for now
+
+ return {
+ itemSize: this.itemSize,
+ type: this.array.constructor.name,
+ array: array,
+ normalized: this.normalized
+ };
+
+ } else {
+
+ // save as true interleaved attribute
+
+ if ( data.interleavedBuffers === undefined ) {
+
+ data.interleavedBuffers = {};
+
+ }
+
+ if ( data.interleavedBuffers[ this.data.uuid ] === undefined ) {
+
+ data.interleavedBuffers[ this.data.uuid ] = this.data.toJSON( data );
+
+ }
+
+ return {
+ isInterleavedBufferAttribute: true,
+ itemSize: this.itemSize,
+ data: this.data.uuid,
+ offset: this.offset,
+ normalized: this.normalized
+ };
+
+ }
+
+ }
+
+}
+
+const _vector1 = /*@__PURE__*/ new Vector3();
+const _vector2 = /*@__PURE__*/ new Vector3();
+const _normalMatrix = /*@__PURE__*/ new Matrix3();
+
+/**
+ * A two dimensional surface that extends infinitely in 3D space, represented
+ * in [Hessian normal form](https://mathworld.wolfram.com/HessianNormalForm.html)
+ * by a unit length normal vector and a constant.
+ */
+class Plane {
+
+ /**
+ * Constructs a new plane.
+ *
+ * @param {Vector3} [normal=(1,0,0)] - A unit length vector defining the normal of the plane.
+ * @param {number} [constant=0] - The signed distance from the origin to the plane.
+ */
+ constructor( normal = new Vector3( 1, 0, 0 ), constant = 0 ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isPlane = true;
+
+ /**
+ * A unit length vector defining the normal of the plane.
+ *
+ * @type {Vector3}
+ */
+ this.normal = normal;
+
+ /**
+ * The signed distance from the origin to the plane.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.constant = constant;
+
+ }
+
+ /**
+ * Sets the plane components by copying the given values.
+ *
+ * @param {Vector3} normal - The normal.
+ * @param {number} constant - The constant.
+ * @return {Plane} A reference to this plane.
+ */
+ set( normal, constant ) {
+
+ this.normal.copy( normal );
+ this.constant = constant;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the plane components by defining `x`, `y`, `z` as the
+ * plane normal and `w` as the constant.
+ *
+ * @param {number} x - The value for the normal's x component.
+ * @param {number} y - The value for the normal's y component.
+ * @param {number} z - The value for the normal's z component.
+ * @param {number} w - The constant value.
+ * @return {Plane} A reference to this plane.
+ */
+ setComponents( x, y, z, w ) {
+
+ this.normal.set( x, y, z );
+ this.constant = w;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the plane from the given normal and coplanar point (that is a point
+ * that lies onto the plane).
+ *
+ * @param {Vector3} normal - The normal.
+ * @param {Vector3} point - A coplanar point.
+ * @return {Plane} A reference to this plane.
+ */
+ setFromNormalAndCoplanarPoint( normal, point ) {
+
+ this.normal.copy( normal );
+ this.constant = - point.dot( this.normal );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the plane from three coplanar points. The winding order is
+ * assumed to be counter-clockwise, and determines the direction of
+ * the plane normal.
+ *
+ * @param {Vector3} a - The first coplanar point.
+ * @param {Vector3} b - The second coplanar point.
+ * @param {Vector3} c - The third coplanar point.
+ * @return {Plane} A reference to this plane.
+ */
+ setFromCoplanarPoints( a, b, c ) {
+
+ const normal = _vector1.subVectors( c, b ).cross( _vector2.subVectors( a, b ) ).normalize();
+
+ // Q: should an error be thrown if normal is zero (e.g. degenerate plane)?
+
+ this.setFromNormalAndCoplanarPoint( normal, a );
+
+ return this;
+
+ }
+
+ /**
+ * Copies the values of the given plane to this instance.
+ *
+ * @param {Plane} plane - The plane to copy.
+ * @return {Plane} A reference to this plane.
+ */
+ copy( plane ) {
+
+ this.normal.copy( plane.normal );
+ this.constant = plane.constant;
+
+ return this;
+
+ }
+
+ /**
+ * Normalizes the plane normal and adjusts the constant accordingly.
+ *
+ * @return {Plane} A reference to this plane.
+ */
+ normalize() {
+
+ // Note: will lead to a divide by zero if the plane is invalid.
+
+ const inverseNormalLength = 1.0 / this.normal.length();
+ this.normal.multiplyScalar( inverseNormalLength );
+ this.constant *= inverseNormalLength;
+
+ return this;
+
+ }
+
+ /**
+ * Negates both the plane normal and the constant.
+ *
+ * @return {Plane} A reference to this plane.
+ */
+ negate() {
+
+ this.constant *= -1;
+ this.normal.negate();
+
+ return this;
+
+ }
+
+ /**
+ * Returns the signed distance from the given point to this plane.
+ *
+ * @param {Vector3} point - The point to compute the distance for.
+ * @return {number} The signed distance.
+ */
+ distanceToPoint( point ) {
+
+ return this.normal.dot( point ) + this.constant;
+
+ }
+
+ /**
+ * Returns the signed distance from the given sphere to this plane.
+ *
+ * @param {Sphere} sphere - The sphere to compute the distance for.
+ * @return {number} The signed distance.
+ */
+ distanceToSphere( sphere ) {
+
+ return this.distanceToPoint( sphere.center ) - sphere.radius;
+
+ }
+
+ /**
+ * Projects a the given point onto the plane.
+ *
+ * @param {Vector3} point - The point to project.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The projected point on the plane.
+ */
+ projectPoint( point, target ) {
+
+ return target.copy( point ).addScaledVector( this.normal, - this.distanceToPoint( point ) );
+
+ }
+
+ /**
+ * Returns the intersection point of the passed line and the plane. Returns
+ * `null` if the line does not intersect. Returns the line's starting point if
+ * the line is coplanar with the plane.
+ *
+ * @param {Line3} line - The line to compute the intersection for.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @param {boolean} [clampToLine=true] - Whether to clamp the intersection to the line segment.
+ * @return {?Vector3} The intersection point. Returns `null` if no intersection is detected.
+ */
+ intersectLine( line, target, clampToLine = true ) {
+
+ const direction = line.delta( _vector1 );
+
+ const denominator = this.normal.dot( direction );
+
+ if ( denominator === 0 ) {
+
+ // line is coplanar, return origin
+ if ( this.distanceToPoint( line.start ) === 0 ) {
+
+ return target.copy( line.start );
+
+ }
+
+ // Unsure if this is the correct method to handle this case.
+ return null;
+
+ }
+
+ const t = - ( line.start.dot( this.normal ) + this.constant ) / denominator;
+
+ if ( ( clampToLine === true ) && ( t < 0 || t > 1 ) ) {
+
+ return null;
+
+ }
+
+ return target.copy( line.start ).addScaledVector( direction, t );
+
+ }
+
+ /**
+ * Returns `true` if the given line segment intersects with (passes through) the plane.
+ *
+ * @param {Line3} line - The line to test.
+ * @return {boolean} Whether the given line segment intersects with the plane or not.
+ */
+ intersectsLine( line ) {
+
+ // Note: this tests if a line intersects the plane, not whether it (or its end-points) are coplanar with it.
+
+ const startSign = this.distanceToPoint( line.start );
+ const endSign = this.distanceToPoint( line.end );
+
+ return ( startSign < 0 && endSign > 0 ) || ( endSign < 0 && startSign > 0 );
+
+ }
+
+ /**
+ * Returns `true` if the given bounding box intersects with the plane.
+ *
+ * @param {Box3} box - The bounding box to test.
+ * @return {boolean} Whether the given bounding box intersects with the plane or not.
+ */
+ intersectsBox( box ) {
+
+ return box.intersectsPlane( this );
+
+ }
+
+ /**
+ * Returns `true` if the given bounding sphere intersects with the plane.
+ *
+ * @param {Sphere} sphere - The bounding sphere to test.
+ * @return {boolean} Whether the given bounding sphere intersects with the plane or not.
+ */
+ intersectsSphere( sphere ) {
+
+ return sphere.intersectsPlane( this );
+
+ }
+
+ /**
+ * Returns a coplanar vector to the plane, by calculating the
+ * projection of the normal at the origin onto the plane.
+ *
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The coplanar point.
+ */
+ coplanarPoint( target ) {
+
+ return target.copy( this.normal ).multiplyScalar( - this.constant );
+
+ }
+
+ /**
+ * Apply a 4x4 matrix to the plane. The matrix must be an affine, homogeneous transform.
+ *
+ * The optional normal matrix can be pre-computed like so:
+ * ```js
+ * const optionalNormalMatrix = new THREE.Matrix3().getNormalMatrix( matrix );
+ * ```
+ *
+ * @param {Matrix4} matrix - The transformation matrix.
+ * @param {Matrix4} [optionalNormalMatrix] - A pre-computed normal matrix.
+ * @return {Plane} A reference to this plane.
+ */
+ applyMatrix4( matrix, optionalNormalMatrix ) {
+
+ const normalMatrix = optionalNormalMatrix || _normalMatrix.getNormalMatrix( matrix );
+
+ const referencePoint = this.coplanarPoint( _vector1 ).applyMatrix4( matrix );
+
+ const normal = this.normal.applyMatrix3( normalMatrix ).normalize();
+
+ this.constant = - referencePoint.dot( normal );
+
+ return this;
+
+ }
+
+ /**
+ * Translates the plane by the distance defined by the given offset vector.
+ * Note that this only affects the plane constant and will not affect the normal vector.
+ *
+ * @param {Vector3} offset - The offset vector.
+ * @return {Plane} A reference to this plane.
+ */
+ translate( offset ) {
+
+ this.constant -= offset.dot( this.normal );
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this plane is equal with the given one.
+ *
+ * @param {Plane} plane - The plane to test for equality.
+ * @return {boolean} Whether this plane is equal with the given one.
+ */
+ equals( plane ) {
+
+ return plane.normal.equals( this.normal ) && ( plane.constant === this.constant );
+
+ }
+
+ /**
+ * Returns a new plane with copied values from this instance.
+ *
+ * @return {Plane} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Returns a serialized structure of the plane.
+ *
+ * @return {Object} Serialized structure with fields representing the object state.
+ */
+ toJSON() {
+
+ return {
+ normal: this.normal.toArray(),
+ constant: this.constant
+ };
+
+ }
+
+ /**
+ * Sets the plane properties from the given JSON.
+ *
+ * @param {Object} json - The serialized json to set the plane from.
+ * @return {Plane} A reference to this plane.
+ */
+ fromJSON( json ) {
+
+ this.normal.fromArray( json.normal );
+ this.constant = json.constant;
+
+ return this;
+
+ }
+
+}
+
+let _materialId = 0;
+
+/**
+ * Abstract base class for materials.
+ *
+ * Materials define the appearance of renderable 3D objects.
+ *
+ * @abstract
+ * @augments EventDispatcher
+ */
+class Material extends EventDispatcher {
+
+ /**
+ * Constructs a new material.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMaterial = true;
+
+ /**
+ * The ID of the material.
+ *
+ * @name Material#id
+ * @type {number}
+ * @readonly
+ */
+ Object.defineProperty( this, 'id', { value: _materialId ++ } );
+
+ /**
+ * The UUID of the material.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ /**
+ * The name of the material.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The type property is used for detecting the object type
+ * in context of serialization/deserialization.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.type = 'Material';
+
+ /**
+ * Defines the blending type of the material.
+ *
+ * It must be set to `CustomBlending` if custom blending properties like
+ * {@link Material#blendSrc}, {@link Material#blendDst} or {@link Material#blendEquation}
+ * should have any effect.
+ *
+ * @type {(NoBlending|NormalBlending|AdditiveBlending|SubtractiveBlending|MultiplyBlending|CustomBlending)}
+ * @default NormalBlending
+ */
+ this.blending = NormalBlending;
+
+ /**
+ * Defines which side of faces will be rendered - front, back or both.
+ *
+ * @type {(FrontSide|BackSide|DoubleSide)}
+ * @default FrontSide
+ */
+ this.side = FrontSide;
+
+ /**
+ * If set to `true`, vertex colors should be used.
+ *
+ * The engine supports RGB and RGBA vertex colors depending on whether a three (RGB) or
+ * four (RGBA) component color buffer attribute is used.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.vertexColors = false;
+
+ /**
+ * Defines how transparent the material is.
+ * A value of `0.0` indicates fully transparent, `1.0` is fully opaque.
+ *
+ * If the {@link Material#transparent} is not set to `true`,
+ * the material will remain fully opaque and this value will only affect its color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.opacity = 1;
+
+ /**
+ * Defines whether this material is transparent. This has an effect on
+ * rendering as transparent objects need special treatment and are rendered
+ * after non-transparent objects.
+ *
+ * When set to true, the extent to which the material is transparent is
+ * controlled by {@link Material#opacity}.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.transparent = false;
+
+ /**
+ * Enables alpha hashed transparency, an alternative to {@link Material#transparent} or
+ * {@link Material#alphaTest}. The material will not be rendered if opacity is lower than
+ * a random threshold. Randomization introduces some grain or noise, but approximates alpha
+ * blending without the associated problems of sorting. Using TAA can reduce the resulting noise.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.alphaHash = false;
+
+ /**
+ * Defines the blending source factor.
+ *
+ * @type {(ZeroFactor|OneFactor|SrcColorFactor|OneMinusSrcColorFactor|SrcAlphaFactor|OneMinusSrcAlphaFactor|DstAlphaFactor|OneMinusDstAlphaFactor|DstColorFactor|OneMinusDstColorFactor|SrcAlphaSaturateFactor|ConstantColorFactor|OneMinusConstantColorFactor|ConstantAlphaFactor|OneMinusConstantAlphaFactor)}
+ * @default SrcAlphaFactor
+ */
+ this.blendSrc = SrcAlphaFactor;
+
+ /**
+ * Defines the blending destination factor.
+ *
+ * @type {(ZeroFactor|OneFactor|SrcColorFactor|OneMinusSrcColorFactor|SrcAlphaFactor|OneMinusSrcAlphaFactor|DstAlphaFactor|OneMinusDstAlphaFactor|DstColorFactor|OneMinusDstColorFactor|SrcAlphaSaturateFactor|ConstantColorFactor|OneMinusConstantColorFactor|ConstantAlphaFactor|OneMinusConstantAlphaFactor)}
+ * @default OneMinusSrcAlphaFactor
+ */
+ this.blendDst = OneMinusSrcAlphaFactor;
+
+ /**
+ * Defines the blending equation.
+ *
+ * @type {(AddEquation|SubtractEquation|ReverseSubtractEquation|MinEquation|MaxEquation)}
+ * @default AddEquation
+ */
+ this.blendEquation = AddEquation;
+
+ /**
+ * Defines the blending source alpha factor.
+ *
+ * @type {?(ZeroFactor|OneFactor|SrcColorFactor|OneMinusSrcColorFactor|SrcAlphaFactor|OneMinusSrcAlphaFactor|DstAlphaFactor|OneMinusDstAlphaFactor|DstColorFactor|OneMinusDstColorFactor|SrcAlphaSaturateFactor|ConstantColorFactor|OneMinusConstantColorFactor|ConstantAlphaFactor|OneMinusConstantAlphaFactor)}
+ * @default null
+ */
+ this.blendSrcAlpha = null;
+
+ /**
+ * Defines the blending destination alpha factor.
+ *
+ * @type {?(ZeroFactor|OneFactor|SrcColorFactor|OneMinusSrcColorFactor|SrcAlphaFactor|OneMinusSrcAlphaFactor|DstAlphaFactor|OneMinusDstAlphaFactor|DstColorFactor|OneMinusDstColorFactor|SrcAlphaSaturateFactor|ConstantColorFactor|OneMinusConstantColorFactor|ConstantAlphaFactor|OneMinusConstantAlphaFactor)}
+ * @default null
+ */
+ this.blendDstAlpha = null;
+
+ /**
+ * Defines the blending equation of the alpha channel.
+ *
+ * @type {?(AddEquation|SubtractEquation|ReverseSubtractEquation|MinEquation|MaxEquation)}
+ * @default null
+ */
+ this.blendEquationAlpha = null;
+
+ /**
+ * Represents the RGB values of the constant blend color.
+ *
+ * This property has only an effect when using custom blending with `ConstantColor` or `OneMinusConstantColor`.
+ *
+ * @type {Color}
+ * @default (0,0,0)
+ */
+ this.blendColor = new Color( 0, 0, 0 );
+
+ /**
+ * Represents the alpha value of the constant blend color.
+ *
+ * This property has only an effect when using custom blending with `ConstantAlpha` or `OneMinusConstantAlpha`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.blendAlpha = 0;
+
+ /**
+ * Defines the depth function.
+ *
+ * @type {(NeverDepth|AlwaysDepth|LessDepth|LessEqualDepth|EqualDepth|GreaterEqualDepth|GreaterDepth|NotEqualDepth)}
+ * @default LessEqualDepth
+ */
+ this.depthFunc = LessEqualDepth;
+
+ /**
+ * Whether to have depth test enabled when rendering this material.
+ * When the depth test is disabled, the depth write will also be implicitly disabled.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.depthTest = true;
+
+ /**
+ * Whether rendering this material has any effect on the depth buffer.
+ *
+ * When drawing 2D overlays it can be useful to disable the depth writing in
+ * order to layer several things together without creating z-index artifacts.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.depthWrite = true;
+
+ /**
+ * The bit mask to use when writing to the stencil buffer.
+ *
+ * @type {number}
+ * @default 0xff
+ */
+ this.stencilWriteMask = 0xff;
+
+ /**
+ * The stencil comparison function to use.
+ *
+ * @type {NeverStencilFunc|LessStencilFunc|EqualStencilFunc|LessEqualStencilFunc|GreaterStencilFunc|NotEqualStencilFunc|GreaterEqualStencilFunc|AlwaysStencilFunc}
+ * @default AlwaysStencilFunc
+ */
+ this.stencilFunc = AlwaysStencilFunc;
+
+ /**
+ * The value to use when performing stencil comparisons or stencil operations.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.stencilRef = 0;
+
+ /**
+ * The bit mask to use when comparing against the stencil buffer.
+ *
+ * @type {number}
+ * @default 0xff
+ */
+ this.stencilFuncMask = 0xff;
+
+ /**
+ * Which stencil operation to perform when the comparison function returns `false`.
+ *
+ * @type {ZeroStencilOp|KeepStencilOp|ReplaceStencilOp|IncrementStencilOp|DecrementStencilOp|IncrementWrapStencilOp|DecrementWrapStencilOp|InvertStencilOp}
+ * @default KeepStencilOp
+ */
+ this.stencilFail = KeepStencilOp;
+
+ /**
+ * Which stencil operation to perform when the comparison function returns
+ * `true` but the depth test fails.
+ *
+ * @type {ZeroStencilOp|KeepStencilOp|ReplaceStencilOp|IncrementStencilOp|DecrementStencilOp|IncrementWrapStencilOp|DecrementWrapStencilOp|InvertStencilOp}
+ * @default KeepStencilOp
+ */
+ this.stencilZFail = KeepStencilOp;
+
+ /**
+ * Which stencil operation to perform when the comparison function returns
+ * `true` and the depth test passes.
+ *
+ * @type {ZeroStencilOp|KeepStencilOp|ReplaceStencilOp|IncrementStencilOp|DecrementStencilOp|IncrementWrapStencilOp|DecrementWrapStencilOp|InvertStencilOp}
+ * @default KeepStencilOp
+ */
+ this.stencilZPass = KeepStencilOp;
+
+ /**
+ * Whether stencil operations are performed against the stencil buffer. In
+ * order to perform writes or comparisons against the stencil buffer this
+ * value must be `true`.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.stencilWrite = false;
+
+ /**
+ * User-defined clipping planes specified as THREE.Plane objects in world
+ * space. These planes apply to the objects this material is attached to.
+ * Points in space whose signed distance to the plane is negative are clipped
+ * (not rendered). This requires {@link WebGLRenderer#localClippingEnabled} to
+ * be `true`.
+ *
+ * @type {?Array}
+ * @default null
+ */
+ this.clippingPlanes = null;
+
+ /**
+ * Changes the behavior of clipping planes so that only their intersection is
+ * clipped, rather than their union.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.clipIntersection = false;
+
+ /**
+ * Defines whether to clip shadows according to the clipping planes specified
+ * on this material.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.clipShadows = false;
+
+ /**
+ * Defines which side of faces cast shadows. If `null`, the side casting shadows
+ * is determined as follows:
+ *
+ * - When {@link Material#side} is set to `FrontSide`, the back side cast shadows.
+ * - When {@link Material#side} is set to `BackSide`, the front side cast shadows.
+ * - When {@link Material#side} is set to `DoubleSide`, both sides cast shadows.
+ *
+ * @type {?(FrontSide|BackSide|DoubleSide)}
+ * @default null
+ */
+ this.shadowSide = null;
+
+ /**
+ * Whether to render the material's color.
+ *
+ * This can be used in conjunction with {@link Object3D#renderOder} to create invisible
+ * objects that occlude other objects.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.colorWrite = true;
+
+ /**
+ * Override the renderer's default precision for this material.
+ *
+ * @type {?('highp'|'mediump'|'lowp')}
+ * @default null
+ */
+ this.precision = null;
+
+ /**
+ * Whether to use polygon offset or not. When enabled, each fragment's depth value will
+ * be offset after it is interpolated from the depth values of the appropriate vertices.
+ * The offset is added before the depth test is performed and before the value is written
+ * into the depth buffer.
+ *
+ * Can be useful for rendering hidden-line images, for applying decals to surfaces, and for
+ * rendering solids with highlighted edges.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.polygonOffset = false;
+
+ /**
+ * Specifies a scale factor that is used to create a variable depth offset for each polygon.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.polygonOffsetFactor = 0;
+
+ /**
+ * Is multiplied by an implementation-specific value to create a constant depth offset.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.polygonOffsetUnits = 0;
+
+ /**
+ * Whether to apply dithering to the color to remove the appearance of banding.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.dithering = false;
+
+ /**
+ * Whether alpha to coverage should be enabled or not. Can only be used with MSAA-enabled contexts
+ * (meaning when the renderer was created with *antialias* parameter set to `true`). Enabling this
+ * will smooth aliasing on clip plane edges and alphaTest-clipped edges.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.alphaToCoverage = false;
+
+ /**
+ * Whether to premultiply the alpha (transparency) value.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.premultipliedAlpha = false;
+
+ /**
+ * Whether double-sided, transparent objects should be rendered with a single pass or not.
+ *
+ * The engine renders double-sided, transparent objects with two draw calls (back faces first,
+ * then front faces) to mitigate transparency artifacts. There are scenarios however where this
+ * approach produces no quality gains but still doubles draw calls e.g. when rendering flat
+ * vegetation like grass sprites. In these cases, set the `forceSinglePass` flag to `true` to
+ * disable the two pass rendering to avoid performance issues.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.forceSinglePass = false;
+
+ /**
+ * Whether it's possible to override the material with {@link Scene#overrideMaterial} or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.allowOverride = true;
+
+ /**
+ * Defines whether 3D objects using this material are visible.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.visible = true;
+
+ /**
+ * Defines whether this material is tone mapped according to the renderer's tone mapping setting.
+ *
+ * It is ignored when rendering to a render target or using post processing or when using
+ * `WebGPURenderer`. In all these cases, all materials are honored by tone mapping.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.toneMapped = true;
+
+ /**
+ * An object that can be used to store custom data about the Material. It
+ * should not hold references to functions as these will not be cloned.
+ *
+ * @type {Object}
+ */
+ this.userData = {};
+
+ /**
+ * This starts at `0` and counts how many times {@link Material#needsUpdate} is set to `true`.
+ *
+ * @type {number}
+ * @readonly
+ * @default 0
+ */
+ this.version = 0;
+
+ this._alphaTest = 0;
+
+ }
+
+ /**
+ * Sets the alpha value to be used when running an alpha test. The material
+ * will not be rendered if the opacity is lower than this value.
+ *
+ * @type {number}
+ * @readonly
+ * @default 0
+ */
+ get alphaTest() {
+
+ return this._alphaTest;
+
+ }
+
+ set alphaTest( value ) {
+
+ if ( this._alphaTest > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._alphaTest = value;
+
+ }
+
+ /**
+ * An optional callback that is executed immediately before the material is used to render a 3D object.
+ *
+ * This method can only be used when rendering with {@link WebGLRenderer}.
+ *
+ * @param {WebGLRenderer} renderer - The renderer.
+ * @param {Scene} scene - The scene.
+ * @param {Camera} camera - The camera that is used to render the scene.
+ * @param {BufferGeometry} geometry - The 3D object's geometry.
+ * @param {Object3D} object - The 3D object.
+ * @param {Object} group - The geometry group data.
+ */
+ onBeforeRender( /* renderer, scene, camera, geometry, object, group */ ) {}
+
+ /**
+ * An optional callback that is executed immediately before the shader
+ * program is compiled. This function is called with the shader source code
+ * as a parameter. Useful for the modification of built-in materials.
+ *
+ * This method can only be used when rendering with {@link WebGLRenderer}. The
+ * recommended approach when customizing materials is to use `WebGPURenderer` with the new
+ * Node Material system and [TSL](https://github.com/mrdoob/three.js/wiki/Three.js-Shading-Language).
+ *
+ * @param {{vertexShader:string,fragmentShader:string,uniforms:Object}} shaderobject - The object holds the uniforms and the vertex and fragment shader source.
+ * @param {WebGLRenderer} renderer - A reference to the renderer.
+ */
+ onBeforeCompile( /* shaderobject, renderer */ ) {}
+
+ /**
+ * In case {@link Material#onBeforeCompile} is used, this callback can be used to identify
+ * values of settings used in `onBeforeCompile()`, so three.js can reuse a cached
+ * shader or recompile the shader for this material as needed.
+ *
+ * This method can only be used when rendering with {@link WebGLRenderer}.
+ *
+ * @return {string} The custom program cache key.
+ */
+ customProgramCacheKey() {
+
+ return this.onBeforeCompile.toString();
+
+ }
+
+ /**
+ * This method can be used to set default values from parameter objects.
+ * It is a generic implementation so it can be used with different types
+ * of materials.
+ *
+ * @param {Object} [values] - The material values to set.
+ */
+ setValues( values ) {
+
+ if ( values === undefined ) return;
+
+ for ( const key in values ) {
+
+ const newValue = values[ key ];
+
+ if ( newValue === undefined ) {
+
+ warn( `Material: parameter '${ key }' has value of undefined.` );
+ continue;
+
+ }
+
+ const currentValue = this[ key ];
+
+ if ( currentValue === undefined ) {
+
+ warn( `Material: '${ key }' is not a property of THREE.${ this.type }.` );
+ continue;
+
+ }
+
+ if ( currentValue && currentValue.isColor ) {
+
+ currentValue.set( newValue );
+
+ } else if (
+ ( ( currentValue && currentValue.isVector2 ) && ( newValue && newValue.isVector2 ) ) ||
+ ( ( currentValue && currentValue.isEuler ) && ( newValue && newValue.isEuler ) ) ||
+ ( ( currentValue && currentValue.isVector3 ) && ( newValue && newValue.isVector3 ) )
+ ) {
+
+ currentValue.copy( newValue );
+
+ } else {
+
+ this[ key ] = newValue;
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Serializes the material into JSON.
+ *
+ * @param {?(Object|string)} meta - An optional value holding meta information about the serialization.
+ * @return {Object} A JSON object representing the serialized material.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON( meta ) {
+
+ const isRootObject = ( meta === undefined || typeof meta === 'string' );
+
+ if ( isRootObject ) {
+
+ meta = {
+ textures: {},
+ images: {}
+ };
+
+ }
+
+ const data = {
+ metadata: {
+ version: 4.7,
+ type: 'Material',
+ generator: 'Material.toJSON'
+ }
+ };
+
+ // standard Material serialization
+
+ data.uuid = this.uuid;
+ data.type = this.type;
+
+ data.blending = this.blending;
+ data.side = this.side;
+ data.shadowSide = this.shadowSide;
+ data.vertexColors = this.vertexColors;
+
+ data.opacity = this.opacity;
+ data.transparent = this.transparent;
+
+ data.blendSrc = this.blendSrc;
+ data.blendDst = this.blendDst;
+ data.blendEquation = this.blendEquation;
+ data.blendSrcAlpha = this.blendSrcAlpha;
+ data.blendDstAlpha = this.blendDstAlpha;
+ data.blendEquationAlpha = this.blendEquationAlpha;
+ data.blendColor = this.blendColor.getHex();
+ data.blendAlpha = this.blendAlpha;
+
+ data.depthFunc = this.depthFunc;
+ data.depthTest = this.depthTest;
+ data.depthWrite = this.depthWrite;
+ data.colorWrite = this.colorWrite;
+
+ data.clipIntersection = this.clipIntersection;
+ data.clipShadows = this.clipShadows;
+
+ data.stencilWriteMask = this.stencilWriteMask;
+ data.stencilFunc = this.stencilFunc;
+ data.stencilRef = this.stencilRef;
+ data.stencilFuncMask = this.stencilFuncMask;
+ data.stencilFail = this.stencilFail;
+ data.stencilZFail = this.stencilZFail;
+ data.stencilZPass = this.stencilZPass;
+ data.stencilWrite = this.stencilWrite;
+
+ data.polygonOffset = this.polygonOffset;
+ data.polygonOffsetFactor = this.polygonOffsetFactor;
+ data.polygonOffsetUnits = this.polygonOffsetUnits;
+
+ data.dithering = this.dithering;
+
+ data.alphaTest = this.alphaTest;
+ data.alphaHash = this.alphaHash;
+ data.alphaToCoverage = this.alphaToCoverage;
+ data.premultipliedAlpha = this.premultipliedAlpha;
+ data.forceSinglePass = this.forceSinglePass;
+ data.allowOverride = this.allowOverride;
+
+ data.visible = this.visible;
+ data.toneMapped = this.toneMapped;
+
+ data.name = this.name;
+
+ if ( this.color && this.color.isColor ) data.color = this.color.getHex();
+
+ if ( this.roughness !== undefined ) data.roughness = this.roughness;
+ if ( this.metalness !== undefined ) data.metalness = this.metalness;
+
+ if ( this.sheen !== undefined ) data.sheen = this.sheen;
+ if ( this.sheenColor && this.sheenColor.isColor ) data.sheenColor = this.sheenColor.getHex();
+ if ( this.sheenRoughness !== undefined ) data.sheenRoughness = this.sheenRoughness;
+ if ( this.emissive && this.emissive.isColor ) data.emissive = this.emissive.getHex();
+ if ( this.emissiveIntensity !== undefined ) data.emissiveIntensity = this.emissiveIntensity;
+
+ if ( this.specular && this.specular.isColor ) data.specular = this.specular.getHex();
+ if ( this.specularIntensity !== undefined ) data.specularIntensity = this.specularIntensity;
+ if ( this.specularColor && this.specularColor.isColor ) data.specularColor = this.specularColor.getHex();
+ if ( this.shininess !== undefined ) data.shininess = this.shininess;
+ if ( this.clearcoat !== undefined ) data.clearcoat = this.clearcoat;
+ if ( this.clearcoatRoughness !== undefined ) data.clearcoatRoughness = this.clearcoatRoughness;
+
+ if ( this.clearcoatMap && this.clearcoatMap.isTexture ) {
+
+ data.clearcoatMap = this.clearcoatMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.clearcoatRoughnessMap && this.clearcoatRoughnessMap.isTexture ) {
+
+ data.clearcoatRoughnessMap = this.clearcoatRoughnessMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.clearcoatNormalMap && this.clearcoatNormalMap.isTexture ) {
+
+ data.clearcoatNormalMap = this.clearcoatNormalMap.toJSON( meta ).uuid;
+ data.clearcoatNormalScale = this.clearcoatNormalScale.toArray();
+
+ }
+
+ if ( this.sheenColorMap && this.sheenColorMap.isTexture ) {
+
+ data.sheenColorMap = this.sheenColorMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.sheenRoughnessMap && this.sheenRoughnessMap.isTexture ) {
+
+ data.sheenRoughnessMap = this.sheenRoughnessMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.dispersion !== undefined ) data.dispersion = this.dispersion;
+ if ( this.retroreflectivity !== undefined ) data.retroreflectivity = this.retroreflectivity;
+
+ if ( this.iridescence !== undefined ) data.iridescence = this.iridescence;
+ if ( this.iridescenceIOR !== undefined ) data.iridescenceIOR = this.iridescenceIOR;
+ if ( this.iridescenceThicknessRange !== undefined ) data.iridescenceThicknessRange = this.iridescenceThicknessRange;
+
+ if ( this.iridescenceMap && this.iridescenceMap.isTexture ) {
+
+ data.iridescenceMap = this.iridescenceMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.iridescenceThicknessMap && this.iridescenceThicknessMap.isTexture ) {
+
+ data.iridescenceThicknessMap = this.iridescenceThicknessMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.anisotropy !== undefined ) data.anisotropy = this.anisotropy;
+ if ( this.anisotropyRotation !== undefined ) data.anisotropyRotation = this.anisotropyRotation;
+
+ if ( this.anisotropyMap && this.anisotropyMap.isTexture ) {
+
+ data.anisotropyMap = this.anisotropyMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.map && this.map.isTexture ) data.map = this.map.toJSON( meta ).uuid;
+ if ( this.matcap && this.matcap.isTexture ) data.matcap = this.matcap.toJSON( meta ).uuid;
+ if ( this.alphaMap && this.alphaMap.isTexture ) data.alphaMap = this.alphaMap.toJSON( meta ).uuid;
+
+ if ( this.lightMap && this.lightMap.isTexture ) {
+
+ data.lightMap = this.lightMap.toJSON( meta ).uuid;
+ data.lightMapIntensity = this.lightMapIntensity;
+
+ }
+
+ if ( this.aoMap && this.aoMap.isTexture ) {
+
+ data.aoMap = this.aoMap.toJSON( meta ).uuid;
+ data.aoMapIntensity = this.aoMapIntensity;
+
+ }
+
+ if ( this.bumpMap && this.bumpMap.isTexture ) {
+
+ data.bumpMap = this.bumpMap.toJSON( meta ).uuid;
+ data.bumpScale = this.bumpScale;
+
+ }
+
+ if ( this.normalMap && this.normalMap.isTexture ) {
+
+ data.normalMap = this.normalMap.toJSON( meta ).uuid;
+ data.normalMapType = this.normalMapType;
+ data.normalScale = this.normalScale.toArray();
+
+ }
+
+ if ( this.displacementMap && this.displacementMap.isTexture ) {
+
+ data.displacementMap = this.displacementMap.toJSON( meta ).uuid;
+ data.displacementScale = this.displacementScale;
+ data.displacementBias = this.displacementBias;
+
+ }
+
+ if ( this.roughnessMap && this.roughnessMap.isTexture ) data.roughnessMap = this.roughnessMap.toJSON( meta ).uuid;
+ if ( this.metalnessMap && this.metalnessMap.isTexture ) data.metalnessMap = this.metalnessMap.toJSON( meta ).uuid;
+
+ if ( this.emissiveMap && this.emissiveMap.isTexture ) data.emissiveMap = this.emissiveMap.toJSON( meta ).uuid;
+ if ( this.specularMap && this.specularMap.isTexture ) data.specularMap = this.specularMap.toJSON( meta ).uuid;
+ if ( this.specularIntensityMap && this.specularIntensityMap.isTexture ) data.specularIntensityMap = this.specularIntensityMap.toJSON( meta ).uuid;
+ if ( this.specularColorMap && this.specularColorMap.isTexture ) data.specularColorMap = this.specularColorMap.toJSON( meta ).uuid;
+
+ if ( this.envMap && this.envMap.isTexture ) {
+
+ data.envMap = this.envMap.toJSON( meta ).uuid;
+
+ if ( this.combine !== undefined ) data.combine = this.combine;
+
+ }
+
+ if ( this.envMapRotation !== undefined ) data.envMapRotation = this.envMapRotation.toArray();
+ if ( this.envMapIntensity !== undefined ) data.envMapIntensity = this.envMapIntensity;
+ if ( this.reflectivity !== undefined ) data.reflectivity = this.reflectivity;
+ if ( this.refractionRatio !== undefined ) data.refractionRatio = this.refractionRatio;
+
+ if ( this.gradientMap && this.gradientMap.isTexture ) {
+
+ data.gradientMap = this.gradientMap.toJSON( meta ).uuid;
+
+ }
+
+ if ( this.transmission !== undefined ) data.transmission = this.transmission;
+ if ( this.transmissionMap && this.transmissionMap.isTexture ) data.transmissionMap = this.transmissionMap.toJSON( meta ).uuid;
+ if ( this.thickness !== undefined ) data.thickness = this.thickness;
+ if ( this.thicknessMap && this.thicknessMap.isTexture ) data.thicknessMap = this.thicknessMap.toJSON( meta ).uuid;
+ if ( this.attenuationDistance !== undefined ) data.attenuationDistance = this.attenuationDistance;
+ if ( this.attenuationColor !== undefined ) data.attenuationColor = this.attenuationColor.getHex();
+
+ if ( this.size !== undefined ) data.size = this.size;
+ if ( this.sizeAttenuation !== undefined ) data.sizeAttenuation = this.sizeAttenuation;
+
+ if ( Array.isArray( this.clippingPlanes ) && this.clippingPlanes.length > 0 ) {
+
+ data.clippingPlanes = this.clippingPlanes.map( plane => plane.toJSON() );
+
+ }
+
+ // rotation (SpriteMaterial)
+ if ( this.rotation !== undefined ) data.rotation = this.rotation;
+
+ // depthPacking (MeshDepthMaterial)
+ if ( this.depthPacking !== undefined ) data.depthPacking = this.depthPacking;
+
+ if ( this.linewidth !== undefined ) data.linewidth = this.linewidth;
+ if ( this.linecap !== undefined ) data.linecap = this.linecap;
+ if ( this.linejoin !== undefined ) data.linejoin = this.linejoin;
+ if ( this.dashSize !== undefined ) data.dashSize = this.dashSize;
+ if ( this.gapSize !== undefined ) data.gapSize = this.gapSize;
+ if ( this.scale !== undefined ) data.scale = this.scale;
+
+ if ( this.wireframe !== undefined ) data.wireframe = this.wireframe;
+ if ( this.wireframeLinewidth !== undefined ) data.wireframeLinewidth = this.wireframeLinewidth;
+ if ( this.wireframeLinecap !== undefined ) data.wireframeLinecap = this.wireframeLinecap;
+ if ( this.wireframeLinejoin !== undefined ) data.wireframeLinejoin = this.wireframeLinejoin;
+
+ if ( this.flatShading !== undefined ) data.flatShading = this.flatShading;
+
+ if ( this.fog !== undefined ) data.fog = this.fog;
+
+ if ( Object.keys( this.userData ).length > 0 ) data.userData = this.userData;
+
+ // TODO: Copied from Object3D.toJSON
+
+ function extractFromCache( cache ) {
+
+ const values = [];
+
+ for ( const key in cache ) {
+
+ const data = cache[ key ];
+ delete data.metadata;
+ values.push( data );
+
+ }
+
+ return values;
+
+ }
+
+ if ( isRootObject ) {
+
+ const textures = extractFromCache( meta.textures );
+ const images = extractFromCache( meta.images );
+
+ if ( textures.length > 0 ) data.textures = textures;
+ if ( images.length > 0 ) data.images = images;
+
+ }
+
+ return data;
+
+ }
+
+ /**
+ * Deserializes the material from the given JSON.
+ *
+ * @param {Object} json - The JSON holding the serialized material.
+ * @param {Object} textures - A dictionary holding textures referenced by the material.
+ * @return {Material} A reference to this material.
+ */
+ fromJSON( json, textures ) {
+
+ if ( json.uuid !== undefined ) this.uuid = json.uuid;
+ if ( json.name !== undefined ) this.name = json.name;
+ if ( json.color !== undefined && this.color !== undefined ) this.color.setHex( json.color );
+ if ( json.roughness !== undefined ) this.roughness = json.roughness;
+ if ( json.metalness !== undefined ) this.metalness = json.metalness;
+ if ( json.sheen !== undefined ) this.sheen = json.sheen;
+ if ( json.sheenColor !== undefined ) this.sheenColor = new Color().setHex( json.sheenColor );
+ if ( json.sheenRoughness !== undefined ) this.sheenRoughness = json.sheenRoughness;
+ if ( json.emissive !== undefined && this.emissive !== undefined ) this.emissive.setHex( json.emissive );
+ if ( json.specular !== undefined && this.specular !== undefined ) this.specular.setHex( json.specular );
+ if ( json.specularIntensity !== undefined ) this.specularIntensity = json.specularIntensity;
+ if ( json.specularColor !== undefined && this.specularColor !== undefined ) this.specularColor.setHex( json.specularColor );
+ if ( json.shininess !== undefined ) this.shininess = json.shininess;
+ if ( json.clearcoat !== undefined ) this.clearcoat = json.clearcoat;
+ if ( json.clearcoatRoughness !== undefined ) this.clearcoatRoughness = json.clearcoatRoughness;
+ if ( json.dispersion !== undefined ) this.dispersion = json.dispersion;
+ if ( json.retroreflectivity !== undefined ) this.retroreflectivity = json.retroreflectivity;
+ if ( json.iridescence !== undefined ) this.iridescence = json.iridescence;
+ if ( json.iridescenceIOR !== undefined ) this.iridescenceIOR = json.iridescenceIOR;
+ if ( json.iridescenceThicknessRange !== undefined ) this.iridescenceThicknessRange = json.iridescenceThicknessRange;
+ if ( json.transmission !== undefined ) this.transmission = json.transmission;
+ if ( json.thickness !== undefined ) this.thickness = json.thickness;
+ if ( json.attenuationDistance !== undefined ) this.attenuationDistance = json.attenuationDistance;
+ if ( json.attenuationColor !== undefined && this.attenuationColor !== undefined ) this.attenuationColor.setHex( json.attenuationColor );
+ if ( json.anisotropy !== undefined ) this.anisotropy = json.anisotropy;
+ if ( json.anisotropyRotation !== undefined ) this.anisotropyRotation = json.anisotropyRotation;
+ if ( json.fog !== undefined ) this.fog = json.fog;
+ if ( json.flatShading !== undefined ) this.flatShading = json.flatShading;
+ if ( json.blending !== undefined ) this.blending = json.blending;
+ if ( json.combine !== undefined ) this.combine = json.combine;
+ if ( json.side !== undefined ) this.side = json.side;
+ if ( json.shadowSide !== undefined ) this.shadowSide = json.shadowSide;
+ if ( json.opacity !== undefined ) this.opacity = json.opacity;
+ if ( json.transparent !== undefined ) this.transparent = json.transparent;
+ if ( json.alphaTest !== undefined ) this.alphaTest = json.alphaTest;
+ if ( json.alphaHash !== undefined ) this.alphaHash = json.alphaHash;
+ if ( json.depthFunc !== undefined ) this.depthFunc = json.depthFunc;
+ if ( json.depthTest !== undefined ) this.depthTest = json.depthTest;
+ if ( json.depthWrite !== undefined ) this.depthWrite = json.depthWrite;
+ if ( json.colorWrite !== undefined ) this.colorWrite = json.colorWrite;
+ if ( json.clippingPlanes !== undefined ) this.clippingPlanes = json.clippingPlanes.map( plane => new Plane().fromJSON( plane ) );
+ if ( json.clipIntersection !== undefined ) this.clipIntersection = json.clipIntersection;
+ if ( json.clipShadows !== undefined ) this.clipShadows = json.clipShadows;
+ if ( json.depthPacking !== undefined ) this.depthPacking = json.depthPacking;
+ if ( json.blendSrc !== undefined ) this.blendSrc = json.blendSrc;
+ if ( json.blendDst !== undefined ) this.blendDst = json.blendDst;
+ if ( json.blendEquation !== undefined ) this.blendEquation = json.blendEquation;
+ if ( json.blendSrcAlpha !== undefined ) this.blendSrcAlpha = json.blendSrcAlpha;
+ if ( json.blendDstAlpha !== undefined ) this.blendDstAlpha = json.blendDstAlpha;
+ if ( json.blendEquationAlpha !== undefined ) this.blendEquationAlpha = json.blendEquationAlpha;
+ if ( json.blendColor !== undefined && this.blendColor !== undefined ) this.blendColor.setHex( json.blendColor );
+ if ( json.blendAlpha !== undefined ) this.blendAlpha = json.blendAlpha;
+ if ( json.stencilWriteMask !== undefined ) this.stencilWriteMask = json.stencilWriteMask;
+ if ( json.stencilFunc !== undefined ) this.stencilFunc = json.stencilFunc;
+ if ( json.stencilRef !== undefined ) this.stencilRef = json.stencilRef;
+ if ( json.stencilFuncMask !== undefined ) this.stencilFuncMask = json.stencilFuncMask;
+ if ( json.stencilFail !== undefined ) this.stencilFail = json.stencilFail;
+ if ( json.stencilZFail !== undefined ) this.stencilZFail = json.stencilZFail;
+ if ( json.stencilZPass !== undefined ) this.stencilZPass = json.stencilZPass;
+ if ( json.stencilWrite !== undefined ) this.stencilWrite = json.stencilWrite;
+
+ if ( json.wireframe !== undefined ) this.wireframe = json.wireframe;
+ if ( json.wireframeLinewidth !== undefined ) this.wireframeLinewidth = json.wireframeLinewidth;
+ if ( json.wireframeLinecap !== undefined ) this.wireframeLinecap = json.wireframeLinecap;
+ if ( json.wireframeLinejoin !== undefined ) this.wireframeLinejoin = json.wireframeLinejoin;
+
+ if ( json.rotation !== undefined ) this.rotation = json.rotation;
+
+ if ( json.linewidth !== undefined ) this.linewidth = json.linewidth;
+ if ( json.linecap !== undefined ) this.linecap = json.linecap;
+ if ( json.linejoin !== undefined ) this.linejoin = json.linejoin;
+ if ( json.dashSize !== undefined ) this.dashSize = json.dashSize;
+ if ( json.gapSize !== undefined ) this.gapSize = json.gapSize;
+ if ( json.scale !== undefined ) this.scale = json.scale;
+
+ if ( json.polygonOffset !== undefined ) this.polygonOffset = json.polygonOffset;
+ if ( json.polygonOffsetFactor !== undefined ) this.polygonOffsetFactor = json.polygonOffsetFactor;
+ if ( json.polygonOffsetUnits !== undefined ) this.polygonOffsetUnits = json.polygonOffsetUnits;
+
+ if ( json.dithering !== undefined ) this.dithering = json.dithering;
+
+ if ( json.alphaToCoverage !== undefined ) this.alphaToCoverage = json.alphaToCoverage;
+ if ( json.premultipliedAlpha !== undefined ) this.premultipliedAlpha = json.premultipliedAlpha;
+ if ( json.forceSinglePass !== undefined ) this.forceSinglePass = json.forceSinglePass;
+ if ( json.allowOverride !== undefined ) this.allowOverride = json.allowOverride;
+
+ if ( json.visible !== undefined ) this.visible = json.visible;
+
+ if ( json.toneMapped !== undefined ) this.toneMapped = json.toneMapped;
+
+ if ( json.userData !== undefined ) this.userData = json.userData;
+
+ if ( json.vertexColors !== undefined ) {
+
+ if ( typeof json.vertexColors === 'number' ) {
+
+ this.vertexColors = json.vertexColors > 0;
+
+ } else {
+
+ this.vertexColors = json.vertexColors;
+
+ }
+
+ }
+
+ // for PointsMaterial
+
+ if ( json.size !== undefined ) this.size = json.size;
+ if ( json.sizeAttenuation !== undefined ) this.sizeAttenuation = json.sizeAttenuation;
+
+ // maps
+
+ if ( json.map !== undefined ) this.map = textures[ json.map ] || null;
+ if ( json.matcap !== undefined ) this.matcap = textures[ json.matcap ] || null;
+
+ if ( json.alphaMap !== undefined ) this.alphaMap = textures[ json.alphaMap ] || null;
+
+ if ( json.bumpMap !== undefined ) this.bumpMap = textures[ json.bumpMap ] || null;
+ if ( json.bumpScale !== undefined ) this.bumpScale = json.bumpScale;
+
+ if ( json.normalMap !== undefined ) this.normalMap = textures[ json.normalMap ] || null;
+ if ( json.normalMapType !== undefined ) this.normalMapType = json.normalMapType;
+ if ( json.normalScale !== undefined ) {
+
+ let normalScale = json.normalScale;
+
+ if ( Array.isArray( normalScale ) === false ) {
+
+ // Blender exporter used to export a scalar. See #7459
+
+ normalScale = [ normalScale, normalScale ];
+
+ }
+
+ this.normalScale = new Vector2().fromArray( normalScale );
+
+ }
+
+ if ( json.displacementMap !== undefined ) this.displacementMap = textures[ json.displacementMap ] || null;
+ if ( json.displacementScale !== undefined ) this.displacementScale = json.displacementScale;
+ if ( json.displacementBias !== undefined ) this.displacementBias = json.displacementBias;
+
+ if ( json.roughnessMap !== undefined ) this.roughnessMap = textures[ json.roughnessMap ] || null;
+ if ( json.metalnessMap !== undefined ) this.metalnessMap = textures[ json.metalnessMap ] || null;
+
+ if ( json.emissiveMap !== undefined ) this.emissiveMap = textures[ json.emissiveMap ] || null;
+ if ( json.emissiveIntensity !== undefined ) this.emissiveIntensity = json.emissiveIntensity;
+
+ if ( json.specularMap !== undefined ) this.specularMap = textures[ json.specularMap ] || null;
+ if ( json.specularIntensityMap !== undefined ) this.specularIntensityMap = textures[ json.specularIntensityMap ] || null;
+ if ( json.specularColorMap !== undefined ) this.specularColorMap = textures[ json.specularColorMap ] || null;
+
+ if ( json.envMap !== undefined ) this.envMap = textures[ json.envMap ] || null;
+ if ( json.envMapRotation !== undefined ) this.envMapRotation.fromArray( json.envMapRotation );
+ if ( json.envMapIntensity !== undefined ) this.envMapIntensity = json.envMapIntensity;
+
+ if ( json.reflectivity !== undefined ) this.reflectivity = json.reflectivity;
+ if ( json.refractionRatio !== undefined ) this.refractionRatio = json.refractionRatio;
+
+ if ( json.lightMap !== undefined ) this.lightMap = textures[ json.lightMap ] || null;
+ if ( json.lightMapIntensity !== undefined ) this.lightMapIntensity = json.lightMapIntensity;
+
+ if ( json.aoMap !== undefined ) this.aoMap = textures[ json.aoMap ] || null;
+ if ( json.aoMapIntensity !== undefined ) this.aoMapIntensity = json.aoMapIntensity;
+
+ if ( json.gradientMap !== undefined ) this.gradientMap = textures[ json.gradientMap ] || null;
+
+ if ( json.clearcoatMap !== undefined ) this.clearcoatMap = textures[ json.clearcoatMap ] || null;
+ if ( json.clearcoatRoughnessMap !== undefined ) this.clearcoatRoughnessMap = textures[ json.clearcoatRoughnessMap ] || null;
+ if ( json.clearcoatNormalMap !== undefined ) this.clearcoatNormalMap = textures[ json.clearcoatNormalMap ] || null;
+ if ( json.clearcoatNormalScale !== undefined ) this.clearcoatNormalScale = new Vector2().fromArray( json.clearcoatNormalScale );
+
+ if ( json.iridescenceMap !== undefined ) this.iridescenceMap = textures[ json.iridescenceMap ] || null;
+ if ( json.iridescenceThicknessMap !== undefined ) this.iridescenceThicknessMap = textures[ json.iridescenceThicknessMap ] || null;
+
+ if ( json.transmissionMap !== undefined ) this.transmissionMap = textures[ json.transmissionMap ] || null;
+ if ( json.thicknessMap !== undefined ) this.thicknessMap = textures[ json.thicknessMap ] || null;
+
+ if ( json.anisotropyMap !== undefined ) this.anisotropyMap = textures[ json.anisotropyMap ] || null;
+
+ if ( json.sheenColorMap !== undefined ) this.sheenColorMap = textures[ json.sheenColorMap ] || null;
+ if ( json.sheenRoughnessMap !== undefined ) this.sheenRoughnessMap = textures[ json.sheenRoughnessMap ] || null;
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new material with copied values from this instance.
+ *
+ * @return {Material} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Copies the values of the given material to this instance.
+ *
+ * @param {Material} source - The material to copy.
+ * @return {Material} A reference to this instance.
+ */
+ copy( source ) {
+
+ this.name = source.name;
+
+ this.blending = source.blending;
+ this.side = source.side;
+ this.vertexColors = source.vertexColors;
+
+ this.opacity = source.opacity;
+ this.transparent = source.transparent;
+
+ this.blendSrc = source.blendSrc;
+ this.blendDst = source.blendDst;
+ this.blendEquation = source.blendEquation;
+ this.blendSrcAlpha = source.blendSrcAlpha;
+ this.blendDstAlpha = source.blendDstAlpha;
+ this.blendEquationAlpha = source.blendEquationAlpha;
+ this.blendColor.copy( source.blendColor );
+ this.blendAlpha = source.blendAlpha;
+
+ this.depthFunc = source.depthFunc;
+ this.depthTest = source.depthTest;
+ this.depthWrite = source.depthWrite;
+
+ this.stencilWriteMask = source.stencilWriteMask;
+ this.stencilFunc = source.stencilFunc;
+ this.stencilRef = source.stencilRef;
+ this.stencilFuncMask = source.stencilFuncMask;
+ this.stencilFail = source.stencilFail;
+ this.stencilZFail = source.stencilZFail;
+ this.stencilZPass = source.stencilZPass;
+ this.stencilWrite = source.stencilWrite;
+
+ const srcPlanes = source.clippingPlanes;
+ let dstPlanes = null;
+
+ if ( srcPlanes !== null ) {
+
+ const n = srcPlanes.length;
+ dstPlanes = new Array( n );
+
+ for ( let i = 0; i !== n; ++ i ) {
+
+ dstPlanes[ i ] = srcPlanes[ i ].clone();
+
+ }
+
+ }
+
+ this.clippingPlanes = dstPlanes;
+ this.clipIntersection = source.clipIntersection;
+ this.clipShadows = source.clipShadows;
+
+ this.shadowSide = source.shadowSide;
+
+ this.colorWrite = source.colorWrite;
+
+ this.precision = source.precision;
+
+ this.polygonOffset = source.polygonOffset;
+ this.polygonOffsetFactor = source.polygonOffsetFactor;
+ this.polygonOffsetUnits = source.polygonOffsetUnits;
+
+ this.dithering = source.dithering;
+
+ this.alphaTest = source.alphaTest;
+ this.alphaHash = source.alphaHash;
+ this.alphaToCoverage = source.alphaToCoverage;
+ this.premultipliedAlpha = source.premultipliedAlpha;
+ this.forceSinglePass = source.forceSinglePass;
+ this.allowOverride = source.allowOverride;
+
+ this.visible = source.visible;
+
+ this.toneMapped = source.toneMapped;
+
+ this.userData = JSON.parse( JSON.stringify( source.userData ) );
+
+ return this;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ *
+ * @fires Material#dispose
+ */
+ dispose() {
+
+ /**
+ * Fires when the material has been disposed of.
+ *
+ * @event Material#dispose
+ * @type {Object}
+ */
+ this.dispatchEvent( { type: 'dispose' } );
+
+ }
+
+ /**
+ * Setting this property to `true` indicates the engine the material
+ * needs to be recompiled.
+ *
+ * @type {boolean}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsUpdate( value ) {
+
+ if ( value === true ) this.version ++;
+
+ }
+
+}
+
+/**
+ * A material for rendering instances of {@link Sprite}.
+ *
+ * ```js
+ * const map = new THREE.TextureLoader().load( 'textures/sprite.png' );
+ * const material = new THREE.SpriteMaterial( { map: map, color: 0xffffff } );
+ *
+ * const sprite = new THREE.Sprite( material );
+ * sprite.scale.set(200, 200, 1)
+ * scene.add( sprite );
+ * ```
+ *
+ * @augments Material
+ */
+class SpriteMaterial extends Material {
+
+ /**
+ * Constructs a new sprite material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSpriteMaterial = true;
+
+ this.type = 'SpriteMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff );
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * The rotation of the sprite in radians.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.rotation = 0;
+
+ /**
+ * Specifies whether size of the sprite is attenuated by the camera depth (perspective camera only).
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.sizeAttenuation = true;
+
+ /**
+ * Overwritten since sprite materials are transparent
+ * by default.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.transparent = true;
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+
+ this.map = source.map;
+
+ this.alphaMap = source.alphaMap;
+
+ this.rotation = source.rotation;
+
+ this.sizeAttenuation = source.sizeAttenuation;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+let _geometry;
+
+const _intersectPoint = /*@__PURE__*/ new Vector3();
+const _worldScale = /*@__PURE__*/ new Vector3();
+const _mvPosition = /*@__PURE__*/ new Vector3();
+
+const _alignedPosition = /*@__PURE__*/ new Vector2();
+const _rotatedPosition = /*@__PURE__*/ new Vector2();
+const _viewWorldMatrix = /*@__PURE__*/ new Matrix4();
+
+const _vA$1 = /*@__PURE__*/ new Vector3();
+const _vB$1 = /*@__PURE__*/ new Vector3();
+const _vC$1 = /*@__PURE__*/ new Vector3();
+
+const _uvA = /*@__PURE__*/ new Vector2();
+const _uvB = /*@__PURE__*/ new Vector2();
+const _uvC = /*@__PURE__*/ new Vector2();
+
+/**
+ * A sprite is a plane that always faces towards the camera, generally with a
+ * partially transparent texture applied.
+ *
+ * Sprites do not cast shadows, setting {@link Object3D#castShadow} to `true` will
+ * have no effect.
+ *
+ * ```js
+ * const map = new THREE.TextureLoader().load( 'sprite.png' );
+ * const material = new THREE.SpriteMaterial( { map: map } );
+ *
+ * const sprite = new THREE.Sprite( material );
+ * scene.add( sprite );
+ * ```
+ *
+ * @augments Object3D
+ */
+class Sprite extends Object3D {
+
+ /**
+ * Constructs a new sprite.
+ *
+ * @param {(SpriteMaterial|SpriteNodeMaterial)} [material] - The sprite material.
+ */
+ constructor( material = new SpriteMaterial() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSprite = true;
+
+ this.type = 'Sprite';
+
+ if ( _geometry === undefined ) {
+
+ _geometry = new BufferGeometry();
+
+ const float32Array = new Float32Array( [
+ -0.5, -0.5, 0, 0, 0,
+ 0.5, -0.5, 0, 1, 0,
+ 0.5, 0.5, 0, 1, 1,
+ -0.5, 0.5, 0, 0, 1
+ ] );
+
+ const interleavedBuffer = new InterleavedBuffer( float32Array, 5 );
+
+ _geometry.setIndex( [ 0, 1, 2, 0, 2, 3 ] );
+ _geometry.setAttribute( 'position', new InterleavedBufferAttribute( interleavedBuffer, 3, 0, false ) );
+ _geometry.setAttribute( 'uv', new InterleavedBufferAttribute( interleavedBuffer, 2, 3, false ) );
+
+ }
+
+ /**
+ * The sprite geometry.
+ *
+ * @type {BufferGeometry}
+ */
+ this.geometry = _geometry;
+
+ /**
+ * The sprite material.
+ *
+ * @type {(SpriteMaterial|SpriteNodeMaterial)}
+ */
+ this.material = material;
+
+ /**
+ * The sprite's anchor point, and the point around which the sprite rotates.
+ * A value of `(0.5, 0.5)` corresponds to the midpoint of the sprite. A value
+ * of `(0, 0)` corresponds to the lower left corner of the sprite.
+ *
+ * @type {Vector2}
+ * @default (0.5,0.5)
+ */
+ this.center = new Vector2( 0.5, 0.5 );
+
+ /**
+ * The number of instances of this sprite.
+ * Can only be used with {@link WebGPURenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.count = 1;
+
+ }
+
+ /**
+ * Returns `true` if this sprite intersects the given frustum.
+ *
+ * @param {Frustum|FrustumArray} frustum - The frustum to test.
+ * @return {boolean} Whether this sprite intersects the given frustum or not.
+ */
+ intersectsFrustum( frustum ) {
+
+ return frustum.intersectsSprite( this );
+
+ }
+
+ /**
+ * Computes intersection points between a casted ray and this sprite.
+ *
+ * @param {Raycaster} raycaster - The raycaster.
+ * @param {Array} intersects - The target array that holds the intersection points.
+ */
+ raycast( raycaster, intersects ) {
+
+ if ( raycaster.camera === null ) {
+
+ error( 'Sprite: "Raycaster.camera" needs to be set in order to raycast against sprites.' );
+
+ }
+
+ _worldScale.setFromMatrixScale( this.matrixWorld );
+
+ _viewWorldMatrix.copy( raycaster.camera.matrixWorld );
+ this.modelViewMatrix.multiplyMatrices( raycaster.camera.matrixWorldInverse, this.matrixWorld );
+
+ _mvPosition.setFromMatrixPosition( this.modelViewMatrix );
+
+ if ( raycaster.camera.isPerspectiveCamera && this.material.sizeAttenuation === false ) {
+
+ _worldScale.multiplyScalar( - _mvPosition.z );
+
+ }
+
+ const rotation = this.material.rotation;
+ let sin, cos;
+
+ if ( rotation !== 0 ) {
+
+ cos = Math.cos( rotation );
+ sin = Math.sin( rotation );
+
+ }
+
+ const center = this.center;
+
+ transformVertex( _vA$1.set( -0.5, -0.5, 0 ), _mvPosition, center, _worldScale, sin, cos );
+ transformVertex( _vB$1.set( 0.5, -0.5, 0 ), _mvPosition, center, _worldScale, sin, cos );
+ transformVertex( _vC$1.set( 0.5, 0.5, 0 ), _mvPosition, center, _worldScale, sin, cos );
+
+ _uvA.set( 0, 0 );
+ _uvB.set( 1, 0 );
+ _uvC.set( 1, 1 );
+
+ // check first triangle
+ let intersect = raycaster.ray.intersectTriangle( _vA$1, _vB$1, _vC$1, false, _intersectPoint );
+
+ if ( intersect === null ) {
+
+ // check second triangle
+ transformVertex( _vB$1.set( -0.5, 0.5, 0 ), _mvPosition, center, _worldScale, sin, cos );
+ _uvB.set( 0, 1 );
+
+ intersect = raycaster.ray.intersectTriangle( _vA$1, _vC$1, _vB$1, false, _intersectPoint );
+ if ( intersect === null ) {
+
+ return;
+
+ }
+
+ }
+
+ const distance = raycaster.ray.origin.distanceTo( _intersectPoint );
+
+ if ( distance < raycaster.near || distance > raycaster.far ) return;
+
+ intersects.push( {
+
+ distance: distance,
+ point: _intersectPoint.clone(),
+ uv: Triangle.getInterpolation( _intersectPoint, _vA$1, _vB$1, _vC$1, _uvA, _uvB, _uvC, new Vector2() ),
+ face: null,
+ object: this
+
+ } );
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ if ( source.center !== undefined ) this.center.copy( source.center );
+
+ this.material = source.material;
+
+ return this;
+
+ }
+
+}
+
+function transformVertex( vertexPosition, mvPosition, center, scale, sin, cos ) {
+
+ // compute position in camera space
+ _alignedPosition.subVectors( vertexPosition, center ).addScalar( 0.5 ).multiply( scale );
+
+ // to check if rotation is not zero
+ if ( sin !== undefined ) {
+
+ _rotatedPosition.x = ( cos * _alignedPosition.x ) - ( sin * _alignedPosition.y );
+ _rotatedPosition.y = ( sin * _alignedPosition.x ) + ( cos * _alignedPosition.y );
+
+ } else {
+
+ _rotatedPosition.copy( _alignedPosition );
+
+ }
+
+
+ vertexPosition.copy( mvPosition );
+ vertexPosition.x += _rotatedPosition.x;
+ vertexPosition.y += _rotatedPosition.y;
+
+ // transform to world space
+ vertexPosition.applyMatrix4( _viewWorldMatrix );
+
+}
+
+const _v1$2 = /*@__PURE__*/ new Vector3();
+const _v2$1 = /*@__PURE__*/ new Vector3();
+
+/**
+ * A component for providing a basic Level of Detail (LOD) mechanism.
+ *
+ * Every LOD level is associated with an object, and rendering can be switched
+ * between them at the distances specified. Typically you would create, say,
+ * three meshes, one for far away (low detail), one for mid range (medium
+ * detail) and one for close up (high detail).
+ *
+ * ```js
+ * const lod = new THREE.LOD();
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ *
+ * //Create spheres with 3 levels of detail and create new LOD levels for them
+ * for( let i = 0; i < 3; i++ ) {
+ *
+ * const geometry = new THREE.IcosahedronGeometry( 10, 3 - i );
+ * const mesh = new THREE.Mesh( geometry, material );
+ * lod.addLevel( mesh, i * 75 );
+ *
+ * }
+ *
+ * scene.add( lod );
+ * ```
+ *
+ * @augments Object3D
+ */
+class LOD extends Object3D {
+
+ /**
+ * Constructs a new LOD.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLOD = true;
+
+ /**
+ * The current LOD index.
+ *
+ * @private
+ * @type {number}
+ * @default 0
+ */
+ this._currentLevel = 0;
+
+ this.type = 'LOD';
+
+ Object.defineProperties( this, {
+ /**
+ * This array holds the LOD levels.
+ *
+ * @name LOD#levels
+ * @type {Array<{object:Object3D,distance:number,hysteresis:number}>}
+ */
+ levels: {
+ enumerable: true,
+ value: []
+ }
+ } );
+
+ /**
+ * Whether the LOD object is updated automatically by the renderer per frame
+ * or not. If set to `false`, you have to call {@link LOD#update} in the
+ * render loop by yourself.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.autoUpdate = true;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source, false );
+
+ const levels = source.levels;
+
+ for ( let i = 0, l = levels.length; i < l; i ++ ) {
+
+ const level = levels[ i ];
+
+ this.addLevel( level.object.clone(), level.distance, level.hysteresis );
+
+ }
+
+ this.autoUpdate = source.autoUpdate;
+
+ return this;
+
+ }
+
+ /**
+ * Adds a mesh that will display at a certain distance and greater. Typically
+ * the further away the distance, the lower the detail on the mesh.
+ *
+ * @param {Object3D} object - The 3D object to display at this level.
+ * @param {number} [distance=0] - The distance at which to display this level of detail.
+ * @param {number} [hysteresis=0] - Threshold used to avoid flickering at LOD boundaries, as a fraction of distance.
+ * @return {LOD} A reference to this instance.
+ */
+ addLevel( object, distance = 0, hysteresis = 0 ) {
+
+ distance = Math.abs( distance );
+
+ const levels = this.levels;
+
+ let l;
+
+ for ( l = 0; l < levels.length; l ++ ) {
+
+ if ( distance < levels[ l ].distance ) {
+
+ break;
+
+ }
+
+ }
+
+ levels.splice( l, 0, { distance: distance, hysteresis: hysteresis, object: object } );
+
+ this.add( object );
+
+ return this;
+
+ }
+
+ /**
+ * Removes an existing level, based on the distance from the camera.
+ * Returns `true` when the level has been removed. Otherwise `false`.
+ *
+ * @param {number} distance - Distance of the level to remove.
+ * @return {boolean} Whether the level has been removed or not.
+ */
+ removeLevel( distance ) {
+
+ const levels = this.levels;
+
+ for ( let i = 0; i < levels.length; i ++ ) {
+
+ if ( levels[ i ].distance === distance ) {
+
+ const removedElements = levels.splice( i, 1 );
+ this.remove( removedElements[ 0 ].object );
+
+ return true;
+
+ }
+
+ }
+
+ return false;
+
+ }
+
+ /**
+ * Returns the currently active LOD level index.
+ *
+ * @return {number} The current active LOD level index.
+ */
+ getCurrentLevel() {
+
+ return this._currentLevel;
+
+ }
+
+ /**
+ * Returns a reference to the first 3D object that is greater than
+ * the given distance.
+ *
+ * @param {number} distance - The LOD distance.
+ * @return {?Object3D} The found 3D object. `null` if no 3D object has been found.
+ */
+ getObjectForDistance( distance ) {
+
+ const levels = this.levels;
+
+ if ( levels.length > 0 ) {
+
+ let i, l;
+
+ for ( i = 1, l = levels.length; i < l; i ++ ) {
+
+ let levelDistance = levels[ i ].distance;
+
+ if ( levels[ i ].object.visible ) {
+
+ levelDistance -= levelDistance * levels[ i ].hysteresis;
+
+ }
+
+ if ( distance < levelDistance ) {
+
+ break;
+
+ }
+
+ }
+
+ return levels[ i - 1 ].object;
+
+ }
+
+ return null;
+
+ }
+
+ /**
+ * Computes intersection points between a casted ray and this LOD.
+ *
+ * @param {Raycaster} raycaster - The raycaster.
+ * @param {Array} intersects - The target array that holds the intersection points.
+ */
+ raycast( raycaster, intersects ) {
+
+ const levels = this.levels;
+
+ if ( levels.length > 0 ) {
+
+ _v1$2.setFromMatrixPosition( this.matrixWorld );
+
+ const distance = raycaster.ray.origin.distanceTo( _v1$2 );
+
+ this.getObjectForDistance( distance ).raycast( raycaster, intersects );
+
+ }
+
+ }
+
+ /**
+ * Updates the LOD by computing which LOD level should be visible according
+ * to the current distance of the given camera.
+ *
+ * @param {Camera} camera - The camera the scene is rendered with.
+ */
+ update( camera ) {
+
+ const levels = this.levels;
+
+ if ( levels.length > 1 ) {
+
+ _v1$2.setFromMatrixPosition( camera.matrixWorld );
+ _v2$1.setFromMatrixPosition( this.matrixWorld );
+
+ const distance = _v1$2.distanceTo( _v2$1 ) / camera.zoom;
+
+ levels[ 0 ].object.visible = true;
+
+ let i, l;
+
+ for ( i = 1, l = levels.length; i < l; i ++ ) {
+
+ let levelDistance = levels[ i ].distance;
+
+ if ( levels[ i ].object.visible ) {
+
+ levelDistance -= levelDistance * levels[ i ].hysteresis;
+
+ }
+
+ if ( distance >= levelDistance ) {
+
+ levels[ i - 1 ].object.visible = false;
+ levels[ i ].object.visible = true;
+
+ } else {
+
+ break;
+
+ }
+
+ }
+
+ this._currentLevel = i - 1;
+
+ for ( ; i < l; i ++ ) {
+
+ levels[ i ].object.visible = false;
+
+ }
+
+ }
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.autoUpdate = this.autoUpdate;
+
+ data.object.levels = [];
+
+ const levels = this.levels;
+
+ for ( let i = 0, l = levels.length; i < l; i ++ ) {
+
+ const level = levels[ i ];
+
+ data.object.levels.push( {
+ object: level.object.uuid,
+ distance: level.distance,
+ hysteresis: level.hysteresis
+ } );
+
+ }
+
+ return data;
+
+ }
+
+}
+
+const _vector$7 = /*@__PURE__*/ new Vector3();
+const _segCenter = /*@__PURE__*/ new Vector3();
+const _segDir = /*@__PURE__*/ new Vector3();
+const _diff = /*@__PURE__*/ new Vector3();
+
+/**
+ * A ray that emits from an origin in a certain direction. The class is used by
+ * {@link Raycaster} to assist with raycasting. Raycasting is used for
+ * mouse picking (working out what objects in the 3D space the mouse is over)
+ * amongst other things.
+ */
+class Ray {
+
+ /**
+ * Constructs a new ray.
+ *
+ * @param {Vector3} [origin=(0,0,0)] - The origin of the ray.
+ * @param {Vector3} [direction=(0,0,-1)] - The (normalized) direction of the ray.
+ */
+ constructor( origin = new Vector3(), direction = new Vector3( 0, 0, -1 ) ) {
+
+ /**
+ * The origin of the ray.
+ *
+ * @type {Vector3}
+ */
+ this.origin = origin;
+
+ /**
+ * The (normalized) direction of the ray.
+ *
+ * @type {Vector3}
+ */
+ this.direction = direction;
+
+ }
+
+ /**
+ * Sets the ray's components by copying the given values.
+ *
+ * @param {Vector3} origin - The origin.
+ * @param {Vector3} direction - The direction.
+ * @return {Ray} A reference to this ray.
+ */
+ set( origin, direction ) {
+
+ this.origin.copy( origin );
+ this.direction.copy( direction );
+
+ return this;
+
+ }
+
+ /**
+ * Copies the values of the given ray to this instance.
+ *
+ * @param {Ray} ray - The ray to copy.
+ * @return {Ray} A reference to this ray.
+ */
+ copy( ray ) {
+
+ this.origin.copy( ray.origin );
+ this.direction.copy( ray.direction );
+
+ return this;
+
+ }
+
+ /**
+ * Returns a vector that is located at a given distance along this ray.
+ *
+ * @param {number} t - The distance along the ray to retrieve a position for.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} A position on the ray.
+ */
+ at( t, target ) {
+
+ return target.copy( this.origin ).addScaledVector( this.direction, t );
+
+ }
+
+ /**
+ * Adjusts the direction of the ray to point at the given vector in world space.
+ *
+ * @param {Vector3} v - The target position.
+ * @return {Ray} A reference to this ray.
+ */
+ lookAt( v ) {
+
+ this.direction.copy( v ).sub( this.origin ).normalize();
+
+ return this;
+
+ }
+
+ /**
+ * Shift the origin of this ray along its direction by the given distance.
+ *
+ * @param {number} t - The distance along the ray to interpolate.
+ * @return {Ray} A reference to this ray.
+ */
+ recast( t ) {
+
+ this.origin.copy( this.at( t, _vector$7 ) );
+
+ return this;
+
+ }
+
+ /**
+ * Returns the point along this ray that is closest to the given point.
+ *
+ * @param {Vector3} point - A point in 3D space to get the closet location on the ray for.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The closest point on this ray.
+ */
+ closestPointToPoint( point, target ) {
+
+ target.subVectors( point, this.origin );
+
+ const directionDistance = target.dot( this.direction );
+
+ if ( directionDistance < 0 ) {
+
+ return target.copy( this.origin );
+
+ }
+
+ return target.copy( this.origin ).addScaledVector( this.direction, directionDistance );
+
+ }
+
+ /**
+ * Returns the distance of the closest approach between this ray and the given point.
+ *
+ * @param {Vector3} point - A point in 3D space to compute the distance to.
+ * @return {number} The distance.
+ */
+ distanceToPoint( point ) {
+
+ return Math.sqrt( this.distanceSqToPoint( point ) );
+
+ }
+
+ /**
+ * Returns the squared distance of the closest approach between this ray and the given point.
+ *
+ * @param {Vector3} point - A point in 3D space to compute the distance to.
+ * @return {number} The squared distance.
+ */
+ distanceSqToPoint( point ) {
+
+ const directionDistance = _vector$7.subVectors( point, this.origin ).dot( this.direction );
+
+ // point behind the ray
+
+ if ( directionDistance < 0 ) {
+
+ return this.origin.distanceToSquared( point );
+
+ }
+
+ _vector$7.copy( this.origin ).addScaledVector( this.direction, directionDistance );
+
+ return _vector$7.distanceToSquared( point );
+
+ }
+
+ /**
+ * Returns the squared distance between this ray and the given line segment.
+ *
+ * @param {Vector3} v0 - The start point of the line segment.
+ * @param {Vector3} v1 - The end point of the line segment.
+ * @param {Vector3} [optionalPointOnRay] - When provided, it receives the point on this ray that is closest to the segment.
+ * @param {Vector3} [optionalPointOnSegment] - When provided, it receives the point on the line segment that is closest to this ray.
+ * @return {number} The squared distance.
+ */
+ distanceSqToSegment( v0, v1, optionalPointOnRay, optionalPointOnSegment ) {
+
+ // from https://github.com/pmjoniak/GeometricTools/blob/master/GTEngine/Include/Mathematics/GteDistRaySegment.h
+ // It returns the min distance between the ray and the segment
+ // defined by v0 and v1
+ // It can also set two optional targets :
+ // - The closest point on the ray
+ // - The closest point on the segment
+
+ _segCenter.copy( v0 ).add( v1 ).multiplyScalar( 0.5 );
+ _segDir.copy( v1 ).sub( v0 ).normalize();
+ _diff.copy( this.origin ).sub( _segCenter );
+
+ const segExtent = v0.distanceTo( v1 ) * 0.5;
+ const a01 = - this.direction.dot( _segDir );
+ const b0 = _diff.dot( this.direction );
+ const b1 = - _diff.dot( _segDir );
+ const c = _diff.lengthSq();
+ const det = Math.abs( 1 - a01 * a01 );
+ let s0, s1, sqrDist, extDet;
+
+ if ( det > 0 ) {
+
+ // The ray and segment are not parallel.
+
+ s0 = a01 * b1 - b0;
+ s1 = a01 * b0 - b1;
+ extDet = segExtent * det;
+
+ if ( s0 >= 0 ) {
+
+ if ( s1 >= - extDet ) {
+
+ if ( s1 <= extDet ) {
+
+ // region 0
+ // Minimum at interior points of ray and segment.
+
+ const invDet = 1 / det;
+ s0 *= invDet;
+ s1 *= invDet;
+ sqrDist = s0 * ( s0 + a01 * s1 + 2 * b0 ) + s1 * ( a01 * s0 + s1 + 2 * b1 ) + c;
+
+ } else {
+
+ // region 1
+
+ s1 = segExtent;
+ s0 = Math.max( 0, - ( a01 * s1 + b0 ) );
+ sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c;
+
+ }
+
+ } else {
+
+ // region 5
+
+ s1 = - segExtent;
+ s0 = Math.max( 0, - ( a01 * s1 + b0 ) );
+ sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c;
+
+ }
+
+ } else {
+
+ if ( s1 <= - extDet ) {
+
+ // region 4
+
+ s0 = Math.max( 0, - ( - a01 * segExtent + b0 ) );
+ s1 = ( s0 > 0 ) ? - segExtent : Math.min( Math.max( - segExtent, - b1 ), segExtent );
+ sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c;
+
+ } else if ( s1 <= extDet ) {
+
+ // region 3
+
+ s0 = 0;
+ s1 = Math.min( Math.max( - segExtent, - b1 ), segExtent );
+ sqrDist = s1 * ( s1 + 2 * b1 ) + c;
+
+ } else {
+
+ // region 2
+
+ s0 = Math.max( 0, - ( a01 * segExtent + b0 ) );
+ s1 = ( s0 > 0 ) ? segExtent : Math.min( Math.max( - segExtent, - b1 ), segExtent );
+ sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c;
+
+ }
+
+ }
+
+ } else {
+
+ // Ray and segment are parallel.
+
+ s1 = ( a01 > 0 ) ? - segExtent : segExtent;
+ s0 = Math.max( 0, - ( a01 * s1 + b0 ) );
+ sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c;
+
+ }
+
+ if ( optionalPointOnRay ) {
+
+ optionalPointOnRay.copy( this.origin ).addScaledVector( this.direction, s0 );
+
+ }
+
+ if ( optionalPointOnSegment ) {
+
+ optionalPointOnSegment.copy( _segCenter ).addScaledVector( _segDir, s1 );
+
+ }
+
+ return sqrDist;
+
+ }
+
+ /**
+ * Intersects this ray with the given sphere, returning the intersection
+ * point or `null` if there is no intersection.
+ *
+ * @param {Sphere} sphere - The sphere to intersect.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The intersection point.
+ */
+ intersectSphere( sphere, target ) {
+
+ if ( sphere.radius < 0 ) return null; // handle empty spheres, see #31187
+
+ _vector$7.subVectors( sphere.center, this.origin );
+ const tca = _vector$7.dot( this.direction );
+ const d2 = _vector$7.dot( _vector$7 ) - tca * tca;
+ const radius2 = sphere.radius * sphere.radius;
+
+ if ( d2 > radius2 ) return null;
+
+ const thc = Math.sqrt( radius2 - d2 );
+
+ // t0 = first intersect point - entrance on front of sphere
+ const t0 = tca - thc;
+
+ // t1 = second intersect point - exit point on back of sphere
+ const t1 = tca + thc;
+
+ // test to see if t1 is behind the ray - if so, return null
+ if ( t1 < 0 ) return null;
+
+ // test to see if t0 is behind the ray:
+ // if it is, the ray is inside the sphere, so return the second exit point scaled by t1,
+ // in order to always return an intersect point that is in front of the ray.
+ if ( t0 < 0 ) return this.at( t1, target );
+
+ // else t0 is in front of the ray, so return the first collision point scaled by t0
+ return this.at( t0, target );
+
+ }
+
+ /**
+ * Returns `true` if this ray intersects with the given sphere.
+ *
+ * @param {Sphere} sphere - The sphere to intersect.
+ * @return {boolean} Whether this ray intersects with the given sphere or not.
+ */
+ intersectsSphere( sphere ) {
+
+ if ( sphere.radius < 0 ) return false; // handle empty spheres, see #31187
+
+ return this.distanceSqToPoint( sphere.center ) <= ( sphere.radius * sphere.radius );
+
+ }
+
+ /**
+ * Computes the distance from the ray's origin to the given plane. Returns `null` if the ray
+ * does not intersect with the plane.
+ *
+ * @param {Plane} plane - The plane to compute the distance to.
+ * @return {?number} Whether this ray intersects with the given sphere or not.
+ */
+ distanceToPlane( plane ) {
+
+ const denominator = plane.normal.dot( this.direction );
+
+ if ( denominator === 0 ) {
+
+ // line is coplanar, return origin
+ if ( plane.distanceToPoint( this.origin ) === 0 ) {
+
+ return 0;
+
+ }
+
+ // Null is preferable to undefined since undefined means.... it is undefined
+
+ return null;
+
+ }
+
+ const t = - ( this.origin.dot( plane.normal ) + plane.constant ) / denominator;
+
+ // Return if the ray never intersects the plane
+
+ return t >= 0 ? t : null;
+
+ }
+
+ /**
+ * Intersects this ray with the given plane, returning the intersection
+ * point or `null` if there is no intersection.
+ *
+ * @param {Plane} plane - The plane to intersect.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The intersection point.
+ */
+ intersectPlane( plane, target ) {
+
+ const t = this.distanceToPlane( plane );
+
+ if ( t === null ) {
+
+ return null;
+
+ }
+
+ return this.at( t, target );
+
+ }
+
+ /**
+ * Returns `true` if this ray intersects with the given plane.
+ *
+ * @param {Plane} plane - The plane to intersect.
+ * @return {boolean} Whether this ray intersects with the given plane or not.
+ */
+ intersectsPlane( plane ) {
+
+ // check if the ray lies on the plane first
+
+ const distToPoint = plane.distanceToPoint( this.origin );
+
+ if ( distToPoint === 0 ) {
+
+ return true;
+
+ }
+
+ const denominator = plane.normal.dot( this.direction );
+
+ if ( denominator * distToPoint < 0 ) {
+
+ return true;
+
+ }
+
+ // ray origin is behind the plane (and is pointing behind it)
+
+ return false;
+
+ }
+
+ /**
+ * Intersects this ray with the given bounding box, returning the intersection
+ * point or `null` if there is no intersection.
+ *
+ * @param {Box3} box - The box to intersect.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The intersection point.
+ */
+ intersectBox( box, target ) {
+
+ let tmin, tmax, tymin, tymax, tzmin, tzmax;
+
+ const invdirx = 1 / this.direction.x,
+ invdiry = 1 / this.direction.y,
+ invdirz = 1 / this.direction.z;
+
+ const origin = this.origin;
+
+ if ( invdirx >= 0 ) {
+
+ tmin = ( box.min.x - origin.x ) * invdirx;
+ tmax = ( box.max.x - origin.x ) * invdirx;
+
+ } else {
+
+ tmin = ( box.max.x - origin.x ) * invdirx;
+ tmax = ( box.min.x - origin.x ) * invdirx;
+
+ }
+
+ if ( invdiry >= 0 ) {
+
+ tymin = ( box.min.y - origin.y ) * invdiry;
+ tymax = ( box.max.y - origin.y ) * invdiry;
+
+ } else {
+
+ tymin = ( box.max.y - origin.y ) * invdiry;
+ tymax = ( box.min.y - origin.y ) * invdiry;
+
+ }
+
+ if ( ( tmin > tymax ) || ( tymin > tmax ) ) return null;
+
+ if ( tymin > tmin || isNaN( tmin ) ) tmin = tymin;
+
+ if ( tymax < tmax || isNaN( tmax ) ) tmax = tymax;
+
+ if ( invdirz >= 0 ) {
+
+ tzmin = ( box.min.z - origin.z ) * invdirz;
+ tzmax = ( box.max.z - origin.z ) * invdirz;
+
+ } else {
+
+ tzmin = ( box.max.z - origin.z ) * invdirz;
+ tzmax = ( box.min.z - origin.z ) * invdirz;
+
+ }
+
+ if ( ( tmin > tzmax ) || ( tzmin > tmax ) ) return null;
+
+ if ( tzmin > tmin || tmin !== tmin ) tmin = tzmin;
+
+ if ( tzmax < tmax || tmax !== tmax ) tmax = tzmax;
+
+ //return point closest to the ray (positive side)
+
+ if ( tmax < 0 ) return null;
+
+ return this.at( tmin >= 0 ? tmin : tmax, target );
+
+ }
+
+ /**
+ * Returns `true` if this ray intersects with the given box.
+ *
+ * @param {Box3} box - The box to intersect.
+ * @return {boolean} Whether this ray intersects with the given box or not.
+ */
+ intersectsBox( box ) {
+
+ return this.intersectBox( box, _vector$7 ) !== null;
+
+ }
+
+ /**
+ * Intersects this ray with the given triangle, returning the intersection
+ * point or `null` if there is no intersection.
+ *
+ * @param {Vector3} a - The first vertex of the triangle.
+ * @param {Vector3} b - The second vertex of the triangle.
+ * @param {Vector3} c - The third vertex of the triangle.
+ * @param {boolean} backfaceCulling - Whether to use backface culling or not.
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {?Vector3} The intersection point.
+ */
+ intersectTriangle( a, b, c, backfaceCulling, target ) {
+
+ // Watertight ray/triangle intersection. Reference: Woop, Benthin, Wald,
+ // "Watertight Ray/Triangle Intersection", JCGT vol. 2 no. 1 (2013), Appendix A.
+ // https://jcgt.org/published/0002/01/05/
+
+ const origin = this.origin;
+ const direction = this.direction;
+
+ const dx = direction.x;
+ const dy = direction.y;
+ const dz = direction.z;
+
+ // triangle vertices relative to the ray origin
+
+ const aox = a.x - origin.x, aoy = a.y - origin.y, aoz = a.z - origin.z;
+ const box = b.x - origin.x, boy = b.y - origin.y, boz = b.z - origin.z;
+ const cox = c.x - origin.x, coy = c.y - origin.y, coz = c.z - origin.z;
+
+ // Use the dimension where the ray direction is maximal as the projection
+ // axis (kz) and read every component already permuted into (kx, ky, kz).
+ // kx and ky are swapped when the direction's kz component is negative, to
+ // preserve the winding order of triangles.
+
+ const adx = Math.abs( dx ), ady = Math.abs( dy ), adz = Math.abs( dz );
+
+ let dkx, dky, dkz;
+ let akx, aky, akz, bkx, bky, bkz, ckx, cky, ckz;
+
+ if ( adx >= ady && adx >= adz ) {
+
+ dkz = dx; akz = aox; bkz = box; ckz = cox;
+
+ if ( dx >= 0 ) {
+
+ dkx = dy; dky = dz;
+ akx = aoy; aky = aoz; bkx = boy; bky = boz; ckx = coy; cky = coz;
+
+ } else {
+
+ dkx = dz; dky = dy;
+ akx = aoz; aky = aoy; bkx = boz; bky = boy; ckx = coz; cky = coy;
+
+ }
+
+ } else if ( ady >= adz ) {
+
+ dkz = dy; akz = aoy; bkz = boy; ckz = coy;
+
+ if ( dy >= 0 ) {
+
+ dkx = dz; dky = dx;
+ akx = aoz; aky = aox; bkx = boz; bky = box; ckx = coz; cky = cox;
+
+ } else {
+
+ dkx = dx; dky = dz;
+ akx = aox; aky = aoz; bkx = box; bky = boz; ckx = cox; cky = coz;
+
+ }
+
+ } else {
+
+ dkz = dz; akz = aoz; bkz = boz; ckz = coz;
+
+ if ( dz >= 0 ) {
+
+ dkx = dx; dky = dy;
+ akx = aox; aky = aoy; bkx = box; bky = boy; ckx = cox; cky = coy;
+
+ } else {
+
+ dkx = dy; dky = dx;
+ akx = aoy; aky = aox; bkx = boy; bky = box; ckx = coy; cky = cox;
+
+ }
+
+ }
+
+ // a zero direction has no maximal axis and cannot intersect
+
+ if ( dkz === 0 ) return null;
+
+ // shear constants that align the ray with the +kz axis
+
+ const sx = dkx / dkz, sy = dky / dkz, sz = 1 / dkz;
+
+ // sheared and scaled vertices
+
+ const ax = akx - sx * akz, ay = aky - sy * akz;
+ const bx = bkx - sx * bkz, by = bky - sy * bkz;
+ const cx = ckx - sx * ckz, cy = cky - sy * ckz;
+
+ // scaled barycentric coordinates (signed edge functions); the shear makes a
+ // shared edge evaluate identically for both adjacent triangles, so the ray
+ // can never fall between them
+
+ const u = cx * by - cy * bx;
+ const v = ax * cy - ay * cx;
+ const w = bx * ay - by * ax;
+
+ if ( backfaceCulling ) {
+
+ if ( u < 0 || v < 0 || w < 0 ) return null;
+
+ } else {
+
+ if ( ( u < 0 || v < 0 || w < 0 ) && ( u > 0 || v > 0 || w > 0 ) ) return null;
+
+ }
+
+ const det = u + v + w;
+
+ // ray is co-planar with the triangle
+
+ if ( det === 0 ) return null;
+
+ // scaled hit distance; t = tScaled / det must lie in front of the origin
+
+ const tScaled = sz * ( u * akz + v * bkz + w * ckz );
+
+ if ( det > 0 ? tScaled < 0 : tScaled > 0 ) return null;
+
+ return this.at( tScaled / det, target );
+
+ }
+
+ /**
+ * Transforms this ray with the given 4x4 transformation matrix.
+ *
+ * @param {Matrix4} matrix4 - The transformation matrix.
+ * @return {Ray} A reference to this ray.
+ */
+ applyMatrix4( matrix4 ) {
+
+ this.origin.applyMatrix4( matrix4 );
+ this.direction.transformDirection( matrix4 );
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this ray is equal with the given one.
+ *
+ * @param {Ray} ray - The ray to test for equality.
+ * @return {boolean} Whether this ray is equal with the given one.
+ */
+ equals( ray ) {
+
+ return ray.origin.equals( this.origin ) && ray.direction.equals( this.direction );
+
+ }
+
+ /**
+ * Returns a new ray with copied values from this instance.
+ *
+ * @return {Ray} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+}
+
+/**
+ * A material for drawing geometries in a simple shaded (flat or wireframe) way.
+ *
+ * This material is not affected by lights.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshBasicMaterial
+ */
+class MeshBasicMaterial extends Material {
+
+ /**
+ * Constructs a new mesh basic material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshBasicMaterial = true;
+
+ this.type = 'MeshBasicMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff ); // diffuse
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The light map. Requires a second set of UVs.
+ *
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `lightMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.lightMap = null;
+
+ /**
+ * Intensity of the baked light.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.lightMapIntensity = 1.0;
+
+ /**
+ * The red channel of this texture is used as the ambient occlusion map.
+ * Requires a second set of UVs.
+ *
+ * `aoMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.aoMap = null;
+
+ /**
+ * Intensity of the ambient occlusion effect. Range is `[0,1]`, where `0`
+ * disables ambient occlusion. Where intensity is `1` and the AO map's
+ * red channel is also `1`, ambient light is fully occluded on a surface.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aoMapIntensity = 1.0;
+
+ /**
+ * Specular map used by the material.
+ *
+ * `specularMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `specularMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.specularMap = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * The environment map.
+ *
+ * `envMap` represents luminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `envMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.envMap = null;
+
+ /**
+ * The rotation of the environment map in radians.
+ *
+ * @type {Euler}
+ * @default (0,0,0)
+ */
+ this.envMapRotation = new Euler();
+
+ /**
+ * How to combine the result of the surface's color with the environment map, if any.
+ *
+ * When set to `MixOperation`, the {@link MeshBasicMaterial#reflectivity} is used to
+ * blend between the two colors.
+ *
+ * @type {(MultiplyOperation|MixOperation|AddOperation)}
+ * @default MultiplyOperation
+ */
+ this.combine = MultiplyOperation;
+
+ /**
+ * How much the environment map affects the surface.
+ * The valid range is between `0` (no reflections) and `1` (full reflections).
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.reflectivity = 1;
+
+ /**
+ * The index of refraction (IOR) of air (approximately 1) divided by the
+ * index of refraction of the material. It is used with environment mapping
+ * modes {@link CubeRefractionMapping} and {@link EquirectangularRefractionMapping}.
+ * The refraction ratio should not exceed `1`.
+ *
+ * @type {number}
+ * @default 0.98
+ */
+ this.refractionRatio = 0.98;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Defines appearance of wireframe ends.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinecap = 'round';
+
+ /**
+ * Defines appearance of wireframe joints.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinejoin = 'round';
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+
+ this.map = source.map;
+
+ this.lightMap = source.lightMap;
+ this.lightMapIntensity = source.lightMapIntensity;
+
+ this.aoMap = source.aoMap;
+ this.aoMapIntensity = source.aoMapIntensity;
+
+ this.specularMap = source.specularMap;
+
+ this.alphaMap = source.alphaMap;
+
+ this.envMap = source.envMap;
+ this.envMapRotation.copy( source.envMapRotation );
+ this.combine = source.combine;
+ this.reflectivity = source.reflectivity;
+ this.refractionRatio = source.refractionRatio;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+ this.wireframeLinecap = source.wireframeLinecap;
+ this.wireframeLinejoin = source.wireframeLinejoin;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+const _inverseMatrix$3 = /*@__PURE__*/ new Matrix4();
+const _ray$3 = /*@__PURE__*/ new Ray();
+const _sphere$6 = /*@__PURE__*/ new Sphere();
+const _sphereHitAt = /*@__PURE__*/ new Vector3();
+
+const _vA = /*@__PURE__*/ new Vector3();
+const _vB = /*@__PURE__*/ new Vector3();
+const _vC = /*@__PURE__*/ new Vector3();
+
+const _tempA = /*@__PURE__*/ new Vector3();
+const _morphA = /*@__PURE__*/ new Vector3();
+
+const _intersectionPoint = /*@__PURE__*/ new Vector3();
+const _intersectionPointWorld = /*@__PURE__*/ new Vector3();
+
+/**
+ * Class representing triangular polygon mesh based objects.
+ *
+ * ```js
+ * const geometry = new THREE.BoxGeometry( 1, 1, 1 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const mesh = new THREE.Mesh( geometry, material );
+ * scene.add( mesh );
+ * ```
+ *
+ * @augments Object3D
+ */
+class Mesh extends Object3D {
+
+ /**
+ * Constructs a new mesh.
+ *
+ * @param {BufferGeometry} [geometry] - The mesh geometry.
+ * @param {Material|Array} [material] - The mesh material.
+ */
+ constructor( geometry = new BufferGeometry(), material = new MeshBasicMaterial() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMesh = true;
+
+ this.type = 'Mesh';
+
+ /**
+ * The mesh geometry.
+ *
+ * @type {BufferGeometry}
+ */
+ this.geometry = geometry;
+
+ /**
+ * The mesh material.
+ *
+ * @type {Material|Array}
+ * @default MeshBasicMaterial
+ */
+ this.material = material;
+
+ /**
+ * A dictionary representing the morph targets in the geometry. The key is the
+ * morph targets name, the value its attribute index. This member is `undefined`
+ * by default and only set when morph targets are detected in the geometry.
+ *
+ * @type {Object|undefined}
+ * @default undefined
+ */
+ this.morphTargetDictionary = undefined;
+
+ /**
+ * An array of weights typically in the range `[0,1]` that specify how much of the morph
+ * is applied. This member is `undefined` by default and only set when morph targets are
+ * detected in the geometry.
+ *
+ * @type {Array|undefined}
+ * @default undefined
+ */
+ this.morphTargetInfluences = undefined;
+
+ /**
+ * The number of instances of this mesh.
+ * Can only be used with {@link WebGPURenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.count = 1;
+
+ this.updateMorphTargets();
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ if ( source.morphTargetInfluences !== undefined ) {
+
+ this.morphTargetInfluences = source.morphTargetInfluences.slice();
+
+ }
+
+ if ( source.morphTargetDictionary !== undefined ) {
+
+ this.morphTargetDictionary = Object.assign( {}, source.morphTargetDictionary );
+
+ }
+
+ this.material = Array.isArray( source.material ) ? source.material.slice() : source.material;
+ this.geometry = source.geometry;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the values of {@link Mesh#morphTargetDictionary} and {@link Mesh#morphTargetInfluences}
+ * to make sure existing morph targets can influence this 3D object.
+ */
+ updateMorphTargets() {
+
+ const geometry = this.geometry;
+
+ const morphAttributes = geometry.morphAttributes;
+ const keys = Object.keys( morphAttributes );
+
+ if ( keys.length > 0 ) {
+
+ const morphAttribute = morphAttributes[ keys[ 0 ] ];
+
+ if ( morphAttribute !== undefined ) {
+
+ this.morphTargetInfluences = [];
+ this.morphTargetDictionary = {};
+
+ for ( let m = 0, ml = morphAttribute.length; m < ml; m ++ ) {
+
+ const name = morphAttribute[ m ].name || String( m );
+
+ this.morphTargetInfluences.push( 0 );
+ this.morphTargetDictionary[ name ] = m;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Returns the local-space position of the vertex at the given index, taking into
+ * account the current animation state of both morph targets and skinning.
+ *
+ * @param {number} index - The vertex index.
+ * @param {Vector3} target - The target object that is used to store the method's result.
+ * @return {Vector3} The vertex position in local space.
+ */
+ getVertexPosition( index, target ) {
+
+ const geometry = this.geometry;
+ const position = geometry.attributes.position;
+ const morphPosition = geometry.morphAttributes.position;
+ const morphTargetsRelative = geometry.morphTargetsRelative;
+
+ target.fromBufferAttribute( position, index );
+
+ const morphInfluences = this.morphTargetInfluences;
+
+ if ( morphPosition && morphInfluences ) {
+
+ _morphA.set( 0, 0, 0 );
+
+ for ( let i = 0, il = morphPosition.length; i < il; i ++ ) {
+
+ const influence = morphInfluences[ i ];
+ const morphAttribute = morphPosition[ i ];
+
+ if ( influence === 0 ) continue;
+
+ _tempA.fromBufferAttribute( morphAttribute, index );
+
+ if ( morphTargetsRelative ) {
+
+ _morphA.addScaledVector( _tempA, influence );
+
+ } else {
+
+ _morphA.addScaledVector( _tempA.sub( target ), influence );
+
+ }
+
+ }
+
+ target.add( _morphA );
+
+ }
+
+ return target;
+
+ }
+
+ /**
+ * Returns `true` if this mesh intersects the given frustum.
+ *
+ * @param {Frustum|FrustumArray} frustum - The frustum to test.
+ * @return {boolean} Whether this mesh intersects the given frustum or not.
+ */
+ intersectsFrustum( frustum ) {
+
+ return frustum.intersectsObject( this );
+
+ }
+
+ /**
+ * Computes intersection points between a casted ray and this line.
+ *
+ * @param {Raycaster} raycaster - The raycaster.
+ * @param {Array} intersects - The target array that holds the intersection points.
+ */
+ raycast( raycaster, intersects ) {
+
+ const geometry = this.geometry;
+ const material = this.material;
+ const matrixWorld = this.matrixWorld;
+
+ if ( material === undefined ) return;
+
+ // test with bounding sphere in world space
+
+ if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();
+
+ _sphere$6.copy( geometry.boundingSphere );
+ _sphere$6.applyMatrix4( matrixWorld );
+
+ // check distance from ray origin to bounding sphere
+
+ _ray$3.copy( raycaster.ray ).recast( raycaster.near );
+
+ if ( _sphere$6.containsPoint( _ray$3.origin ) === false ) {
+
+ if ( _ray$3.intersectSphere( _sphere$6, _sphereHitAt ) === null ) return;
+
+ if ( _ray$3.origin.distanceToSquared( _sphereHitAt ) > ( raycaster.far - raycaster.near ) ** 2 ) return;
+
+ }
+
+ // convert ray to local space of mesh
+
+ _inverseMatrix$3.copy( matrixWorld ).invert();
+ _ray$3.copy( raycaster.ray ).applyMatrix4( _inverseMatrix$3 );
+
+ // test with bounding box in local space
+
+ if ( geometry.boundingBox !== null ) {
+
+ if ( _ray$3.intersectsBox( geometry.boundingBox ) === false ) return;
+
+ }
+
+ // test for intersections with geometry
+
+ this._computeIntersections( raycaster, intersects, _ray$3 );
+
+ }
+
+ _computeIntersections( raycaster, intersects, rayLocalSpace ) {
+
+ let intersection;
+
+ const geometry = this.geometry;
+ const material = this.material;
+
+ const index = geometry.index;
+ const position = geometry.attributes.position;
+ const uv = geometry.attributes.uv;
+ const uv1 = geometry.attributes.uv1;
+ const normal = geometry.attributes.normal;
+ const groups = geometry.groups;
+ const drawRange = geometry.drawRange;
+
+ if ( index !== null ) {
+
+ // indexed buffer geometry
+
+ if ( Array.isArray( material ) ) {
+
+ for ( let i = 0, il = groups.length; i < il; i ++ ) {
+
+ const group = groups[ i ];
+ const groupMaterial = material[ group.materialIndex ];
+
+ const start = Math.max( group.start, drawRange.start );
+ const end = Math.min( index.count, Math.min( ( group.start + group.count ), ( drawRange.start + drawRange.count ) ) );
+
+ for ( let j = start, jl = end; j < jl; j += 3 ) {
+
+ const a = index.getX( j );
+ const b = index.getX( j + 1 );
+ const c = index.getX( j + 2 );
+
+ intersection = checkGeometryIntersection( this, groupMaterial, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c );
+
+ if ( intersection ) {
+
+ intersection.faceIndex = Math.floor( j / 3 ); // triangle number in indexed buffer semantics
+ intersection.face.materialIndex = group.materialIndex;
+ intersects.push( intersection );
+
+ }
+
+ }
+
+ }
+
+ } else {
+
+ const start = Math.max( 0, drawRange.start );
+ const end = Math.min( index.count, ( drawRange.start + drawRange.count ) );
+
+ for ( let i = start, il = end; i < il; i += 3 ) {
+
+ const a = index.getX( i );
+ const b = index.getX( i + 1 );
+ const c = index.getX( i + 2 );
+
+ intersection = checkGeometryIntersection( this, material, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c );
+
+ if ( intersection ) {
+
+ intersection.faceIndex = Math.floor( i / 3 ); // triangle number in indexed buffer semantics
+ intersects.push( intersection );
+
+ }
+
+ }
+
+ }
+
+ } else if ( position !== undefined ) {
+
+ // non-indexed buffer geometry
+
+ if ( Array.isArray( material ) ) {
+
+ for ( let i = 0, il = groups.length; i < il; i ++ ) {
+
+ const group = groups[ i ];
+ const groupMaterial = material[ group.materialIndex ];
+
+ const start = Math.max( group.start, drawRange.start );
+ const end = Math.min( position.count, Math.min( ( group.start + group.count ), ( drawRange.start + drawRange.count ) ) );
+
+ for ( let j = start, jl = end; j < jl; j += 3 ) {
+
+ const a = j;
+ const b = j + 1;
+ const c = j + 2;
+
+ intersection = checkGeometryIntersection( this, groupMaterial, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c );
+
+ if ( intersection ) {
+
+ intersection.faceIndex = Math.floor( j / 3 ); // triangle number in non-indexed buffer semantics
+ intersection.face.materialIndex = group.materialIndex;
+ intersects.push( intersection );
+
+ }
+
+ }
+
+ }
+
+ } else {
+
+ const start = Math.max( 0, drawRange.start );
+ const end = Math.min( position.count, ( drawRange.start + drawRange.count ) );
+
+ for ( let i = start, il = end; i < il; i += 3 ) {
+
+ const a = i;
+ const b = i + 1;
+ const c = i + 2;
+
+ intersection = checkGeometryIntersection( this, material, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c );
+
+ if ( intersection ) {
+
+ intersection.faceIndex = Math.floor( i / 3 ); // triangle number in non-indexed buffer semantics
+ intersects.push( intersection );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
+
+function checkIntersection$1( object, material, raycaster, ray, pA, pB, pC, point ) {
+
+ let intersect;
+
+ if ( material.side === BackSide ) {
+
+ intersect = ray.intersectTriangle( pC, pB, pA, true, point );
+
+ } else {
+
+ intersect = ray.intersectTriangle( pA, pB, pC, ( material.side === FrontSide ), point );
+
+ }
+
+ if ( intersect === null ) return null;
+
+ _intersectionPointWorld.copy( point );
+ _intersectionPointWorld.applyMatrix4( object.matrixWorld );
+
+ const distance = raycaster.ray.origin.distanceTo( _intersectionPointWorld );
+
+ if ( distance < raycaster.near || distance > raycaster.far ) return null;
+
+ return {
+ distance: distance,
+ point: _intersectionPointWorld.clone(),
+ object: object
+ };
+
+}
+
+function checkGeometryIntersection( object, material, raycaster, ray, uv, uv1, normal, a, b, c ) {
+
+ object.getVertexPosition( a, _vA );
+ object.getVertexPosition( b, _vB );
+ object.getVertexPosition( c, _vC );
+
+ const intersection = checkIntersection$1( object, material, raycaster, ray, _vA, _vB, _vC, _intersectionPoint );
+
+ if ( intersection ) {
+
+ const barycoord = new Vector3();
+ Triangle.getBarycoord( _intersectionPoint, _vA, _vB, _vC, barycoord );
+
+ if ( uv ) {
+
+ intersection.uv = Triangle.getInterpolatedAttribute( uv, a, b, c, barycoord, new Vector2() );
+
+ }
+
+ if ( uv1 ) {
+
+ intersection.uv1 = Triangle.getInterpolatedAttribute( uv1, a, b, c, barycoord, new Vector2() );
+
+ }
+
+ if ( normal ) {
+
+ intersection.normal = Triangle.getInterpolatedAttribute( normal, a, b, c, barycoord, new Vector3() );
+
+ if ( intersection.normal.dot( ray.direction ) > 0 ) {
+
+ intersection.normal.multiplyScalar( -1 );
+
+ }
+
+ }
+
+ const face = {
+ a: a,
+ b: b,
+ c: c,
+ normal: new Vector3(),
+ materialIndex: 0
+ };
+
+ Triangle.getNormal( _vA, _vB, _vC, face.normal );
+
+ intersection.face = face;
+ intersection.barycoord = barycoord;
+
+ }
+
+ return intersection;
+
+}
+
+const _baseVector = /*@__PURE__*/ new Vector4();
+
+const _skinIndex = /*@__PURE__*/ new Vector4();
+const _skinWeight = /*@__PURE__*/ new Vector4();
+
+const _vector4 = /*@__PURE__*/ new Vector4();
+const _matrix4 = /*@__PURE__*/ new Matrix4();
+const _vertex = /*@__PURE__*/ new Vector3();
+
+const _sphere$5 = /*@__PURE__*/ new Sphere();
+const _inverseMatrix$2 = /*@__PURE__*/ new Matrix4();
+const _ray$2 = /*@__PURE__*/ new Ray();
+
+/**
+ * A mesh that has a {@link Skeleton} that can then be used to animate the
+ * vertices of the geometry with skinning/skeleton animation.
+ *
+ * Next to a valid skeleton, the skinned mesh requires skin indices and weights
+ * as buffer attributes in its geometry. These attribute define which bones affect a single
+ * vertex to a certain extend.
+ *
+ * Typically skinned meshes are not created manually but loaders like {@link GLTFLoader}
+ * or {@link FBXLoader } import respective models.
+ *
+ * @augments Mesh
+ * @demo scenes/bones-browser.html
+ */
+class SkinnedMesh extends Mesh {
+
+ /**
+ * Constructs a new skinned mesh.
+ *
+ * @param {BufferGeometry} [geometry] - The mesh geometry.
+ * @param {Material|Array} [material] - The mesh material.
+ */
+ constructor( geometry, material ) {
+
+ super( geometry, material );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSkinnedMesh = true;
+
+ this.type = 'SkinnedMesh';
+
+ /**
+ * `AttachedBindMode` means the skinned mesh shares the same world space as the skeleton.
+ * This is not true when using `DetachedBindMode` which is useful when sharing a skeleton
+ * across multiple skinned meshes.
+ *
+ * @type {(AttachedBindMode|DetachedBindMode)}
+ * @default AttachedBindMode
+ */
+ this.bindMode = AttachedBindMode;
+
+ /**
+ * The base matrix that is used for the bound bone transforms.
+ *
+ * @type {Matrix4}
+ */
+ this.bindMatrix = new Matrix4();
+
+ /**
+ * The base matrix that is used for resetting the bound bone transforms.
+ *
+ * @type {Matrix4}
+ */
+ this.bindMatrixInverse = new Matrix4();
+
+ /**
+ * The bounding box of the skinned mesh. Can be computed via {@link SkinnedMesh#computeBoundingBox}.
+ *
+ * @type {?Box3}
+ * @default null
+ */
+ this.boundingBox = null;
+
+ /**
+ * The bounding sphere of the skinned mesh. Can be computed via {@link SkinnedMesh#computeBoundingSphere}.
+ *
+ * @type {?Sphere}
+ * @default null
+ */
+ this.boundingSphere = null;
+
+ }
+
+ /**
+ * Computes the bounding box of the skinned mesh, and updates {@link SkinnedMesh#boundingBox}.
+ * The bounding box is not automatically computed by the engine; this method must be called by your app.
+ * If the skinned mesh is animated, the bounding box should be recomputed per frame in order to reflect
+ * the current animation state.
+ */
+ computeBoundingBox() {
+
+ const geometry = this.geometry;
+
+ if ( this.boundingBox === null ) {
+
+ this.boundingBox = new Box3();
+
+ }
+
+ this.boundingBox.makeEmpty();
+
+ const positionAttribute = geometry.getAttribute( 'position' );
+
+ for ( let i = 0; i < positionAttribute.count; i ++ ) {
+
+ this.getVertexPosition( i, _vertex );
+ this.boundingBox.expandByPoint( _vertex );
+
+ }
+
+ }
+
+ /**
+ * Computes the bounding sphere of the skinned mesh, and updates {@link SkinnedMesh#boundingSphere}.
+ * The bounding sphere is automatically computed by the engine once when it is needed, e.g., for ray casting
+ * and view frustum culling. If the skinned mesh is animated, the bounding sphere should be recomputed
+ * per frame in order to reflect the current animation state.
+ */
+ computeBoundingSphere() {
+
+ const geometry = this.geometry;
+
+ if ( this.boundingSphere === null ) {
+
+ this.boundingSphere = new Sphere();
+
+ }
+
+ this.boundingSphere.makeEmpty();
+
+ const positionAttribute = geometry.getAttribute( 'position' );
+
+ for ( let i = 0; i < positionAttribute.count; i ++ ) {
+
+ this.getVertexPosition( i, _vertex );
+ this.boundingSphere.expandByPoint( _vertex );
+
+ }
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.bindMode = source.bindMode;
+ this.bindMatrix.copy( source.bindMatrix );
+ this.bindMatrixInverse.copy( source.bindMatrixInverse );
+
+ this.skeleton = source.skeleton;
+
+ if ( source.boundingBox !== null ) this.boundingBox = source.boundingBox.clone();
+ if ( source.boundingSphere !== null ) this.boundingSphere = source.boundingSphere.clone();
+
+ return this;
+
+ }
+
+ raycast( raycaster, intersects ) {
+
+ const material = this.material;
+ const matrixWorld = this.matrixWorld;
+
+ if ( material === undefined ) return;
+
+ // test with bounding sphere in world space
+
+ if ( this.boundingSphere === null ) this.computeBoundingSphere();
+
+ _sphere$5.copy( this.boundingSphere );
+ _sphere$5.applyMatrix4( matrixWorld );
+
+ if ( raycaster.ray.intersectsSphere( _sphere$5 ) === false ) return;
+
+ // convert ray to local space of skinned mesh
+
+ _inverseMatrix$2.copy( matrixWorld ).invert();
+ _ray$2.copy( raycaster.ray ).applyMatrix4( _inverseMatrix$2 );
+
+ // test with bounding box in local space
+
+ if ( this.boundingBox !== null ) {
+
+ if ( _ray$2.intersectsBox( this.boundingBox ) === false ) return;
+
+ }
+
+ // test for intersections with geometry
+
+ this._computeIntersections( raycaster, intersects, _ray$2 );
+
+ }
+
+ getVertexPosition( index, target ) {
+
+ super.getVertexPosition( index, target );
+
+ this.applyBoneTransform( index, target );
+
+ return target;
+
+ }
+
+ /**
+ * Binds the given skeleton to the skinned mesh.
+ *
+ * @param {Skeleton} skeleton - The skeleton to bind.
+ * @param {Matrix4} [bindMatrix] - The bind matrix. If no bind matrix is provided,
+ * the skinned mesh's world matrix will be used instead.
+ */
+ bind( skeleton, bindMatrix ) {
+
+ this.skeleton = skeleton;
+
+ if ( bindMatrix === undefined ) {
+
+ this.updateMatrixWorld( true );
+
+ this.skeleton.calculateInverses();
+
+ bindMatrix = this.matrixWorld;
+
+ }
+
+ this.bindMatrix.copy( bindMatrix );
+ this.bindMatrixInverse.copy( bindMatrix ).invert();
+
+ }
+
+ /**
+ * This method sets the skinned mesh in the rest pose).
+ */
+ pose() {
+
+ this.skeleton.pose();
+
+ }
+
+ /**
+ * Normalizes the skin weights which are defined as a buffer attribute
+ * in the skinned mesh's geometry.
+ */
+ normalizeSkinWeights() {
+
+ const vector = new Vector4();
+
+ const skinWeight = this.geometry.attributes.skinWeight;
+
+ for ( let i = 0, l = skinWeight.count; i < l; i ++ ) {
+
+ vector.fromBufferAttribute( skinWeight, i );
+
+ const scale = 1.0 / vector.manhattanLength();
+
+ if ( scale !== Infinity ) {
+
+ vector.multiplyScalar( scale );
+
+ } else {
+
+ vector.set( 1, 0, 0, 0 ); // do something reasonable
+
+ }
+
+ skinWeight.setXYZW( i, vector.x, vector.y, vector.z, vector.w );
+
+ }
+
+ }
+
+ updateMatrixWorld( force ) {
+
+ super.updateMatrixWorld( force );
+
+ if ( this.bindMode === AttachedBindMode ) {
+
+ this.bindMatrixInverse.copy( this.matrixWorld ).invert();
+
+ } else if ( this.bindMode === DetachedBindMode ) {
+
+ this.bindMatrixInverse.copy( this.bindMatrix ).invert();
+
+ } else {
+
+ warn( 'SkinnedMesh: Unrecognized bindMode: ' + this.bindMode );
+
+ }
+
+ }
+
+ /**
+ * Applies the bone transform associated with the given index to the given
+ * vector. Can be used to transform positions or direction vectors by providing
+ * a Vector4 with 1 or 0 in the w component respectively. Returns the updated vector.
+ *
+ * @param {number} index - The vertex index.
+ * @param {Vector3|Vector4} target - The target object that is used to store the method's result.
+ * @return {Vector3|Vector4} The updated vertex attribute data.
+ */
+ applyBoneTransform( index, target ) {
+
+ const skeleton = this.skeleton;
+ const geometry = this.geometry;
+
+ _skinIndex.fromBufferAttribute( geometry.attributes.skinIndex, index );
+ _skinWeight.fromBufferAttribute( geometry.attributes.skinWeight, index );
+
+ if ( target.isVector4 ) {
+
+ _baseVector.copy( target );
+ target.set( 0, 0, 0, 0 );
+
+ } else {
+
+ _baseVector.set( ...target, 1 );
+ target.set( 0, 0, 0 );
+
+ }
+
+ _baseVector.applyMatrix4( this.bindMatrix );
+
+ for ( let i = 0; i < 4; i ++ ) {
+
+ const weight = _skinWeight.getComponent( i );
+
+ if ( weight !== 0 ) {
+
+ const boneIndex = _skinIndex.getComponent( i );
+
+ _matrix4.multiplyMatrices( skeleton.bones[ boneIndex ].matrixWorld, skeleton.boneInverses[ boneIndex ] );
+
+ target.addScaledVector( _vector4.copy( _baseVector ).applyMatrix4( _matrix4 ), weight );
+
+ }
+
+ }
+
+ if ( target.isVector4 ) {
+
+ // ensure the homogenous coordinate remains unchanged after vector operations
+ target.w = _baseVector.w;
+
+ }
+
+ return target.applyMatrix4( this.bindMatrixInverse );
+
+ }
+
+}
+
+/**
+ * A bone which is part of a {@link Skeleton}. The skeleton in turn is used by
+ * the {@link SkinnedMesh}.
+ *
+ * ```js
+ * const root = new THREE.Bone();
+ * const child = new THREE.Bone();
+ *
+ * root.add( child );
+ * child.position.y = 5;
+ * ```
+ *
+ * @augments Object3D
+ */
+class Bone extends Object3D {
+
+ /**
+ * Constructs a new bone.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isBone = true;
+
+ this.type = 'Bone';
+
+ }
+
+}
+
+/**
+ * Creates a texture directly from raw buffer data.
+ *
+ * The interpretation of the data depends on type and format: If the type is
+ * `UnsignedByteType`, a `Uint8Array` will be useful for addressing the
+ * texel data. If the format is `RGBAFormat`, data needs four values for
+ * one texel; Red, Green, Blue and Alpha (typically the opacity).
+ *
+ * @augments Texture
+ */
+class DataTexture extends Texture {
+
+ /**
+ * Constructs a new data texture.
+ *
+ * @param {?TypedArray} [data=null] - The buffer data.
+ * @param {number} [width=1] - The width of the texture.
+ * @param {number} [height=1] - The height of the texture.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=NearestFilter] - The mag filter value.
+ * @param {number} [minFilter=NearestFilter] - The min filter value.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ * @param {string} [colorSpace=NoColorSpace] - The color space.
+ */
+ constructor( data = null, width = 1, height = 1, format, type, mapping, wrapS, wrapT, magFilter = NearestFilter, minFilter = NearestFilter, anisotropy, colorSpace ) {
+
+ super( null, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, colorSpace );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isDataTexture = true;
+
+ /**
+ * The image definition of a data texture.
+ *
+ * @type {{data:TypedArray,width:number,height:number}}
+ */
+ this.image = { data: data, width: width, height: height };
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.generateMipmaps = false;
+
+ /**
+ * If set to `true`, the texture is flipped along the vertical axis when
+ * uploaded to the GPU.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flipY = false;
+
+ /**
+ * Specifies the alignment requirements for the start of each pixel row in memory.
+ *
+ * Overwritten and set to `1` by default.
+ *
+ * @type {boolean}
+ * @default 1
+ */
+ this.unpackAlignment = 1;
+
+ }
+
+}
+
+const _offsetMatrix = /*@__PURE__*/ new Matrix4();
+const _identityMatrix = /*@__PURE__*/ new Matrix4();
+
+/**
+ * Class for representing the armatures in `three.js`. The skeleton
+ * is defined by a hierarchy of bones.
+ *
+ * ```js
+ * const bones = [];
+ *
+ * const shoulder = new THREE.Bone();
+ * const elbow = new THREE.Bone();
+ * const hand = new THREE.Bone();
+ *
+ * shoulder.add( elbow );
+ * elbow.add( hand );
+ *
+ * bones.push( shoulder , elbow, hand);
+ *
+ * shoulder.position.y = -5;
+ * elbow.position.y = 0;
+ * hand.position.y = 5;
+ *
+ * const armSkeleton = new THREE.Skeleton( bones );
+ * ```
+ */
+class Skeleton {
+
+ /**
+ * Constructs a new skeleton.
+ *
+ * @param {Array} [bones] - An array of bones.
+ * @param {Array} [boneInverses] - An array of bone inverse matrices.
+ * If not provided, these matrices will be computed automatically via {@link Skeleton#calculateInverses}.
+ */
+ constructor( bones = [], boneInverses = [] ) {
+
+ this.uuid = generateUUID();
+
+ /**
+ * An array of bones defining the skeleton.
+ *
+ * @type {Array}
+ */
+ this.bones = bones.slice( 0 );
+
+ /**
+ * An array of bone inverse matrices.
+ *
+ * @type {Array}
+ */
+ this.boneInverses = boneInverses;
+
+ /**
+ * An array buffer holding the bone data.
+ * Input data for {@link Skeleton#boneTexture}.
+ *
+ * @type {?Float32Array}
+ * @default null
+ */
+ this.boneMatrices = null;
+
+ /**
+ * A texture holding the bone data for use
+ * in the vertex shader.
+ *
+ * @type {?DataTexture}
+ * @default null
+ */
+ this.boneTexture = null;
+
+ this.init();
+
+ }
+
+ /**
+ * Initializes the skeleton. This method gets automatically called by the constructor
+ * but depending on how the skeleton is created it might be necessary to call this method
+ * manually.
+ */
+ init() {
+
+ const bones = this.bones;
+ const boneInverses = this.boneInverses;
+
+ this.boneMatrices = new Float32Array( bones.length * 16 );
+
+ // calculate inverse bone matrices if necessary
+
+ if ( boneInverses.length === 0 ) {
+
+ this.calculateInverses();
+
+ } else {
+
+ // handle special case
+
+ if ( bones.length !== boneInverses.length ) {
+
+ warn( 'Skeleton: Number of inverse bone matrices does not match amount of bones.' );
+
+ this.boneInverses = [];
+
+ for ( let i = 0, il = this.bones.length; i < il; i ++ ) {
+
+ this.boneInverses.push( new Matrix4() );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Computes the bone inverse matrices. This method resets {@link Skeleton#boneInverses}
+ * and fills it with new matrices.
+ */
+ calculateInverses() {
+
+ this.boneInverses.length = 0;
+
+ for ( let i = 0, il = this.bones.length; i < il; i ++ ) {
+
+ const inverse = new Matrix4();
+
+ if ( this.bones[ i ] ) {
+
+ inverse.copy( this.bones[ i ].matrixWorld ).invert();
+
+ }
+
+ this.boneInverses.push( inverse );
+
+ }
+
+ }
+
+ /**
+ * Resets the skeleton to the base pose.
+ */
+ pose() {
+
+ // recover the bind-time world matrices
+
+ for ( let i = 0, il = this.bones.length; i < il; i ++ ) {
+
+ const bone = this.bones[ i ];
+
+ if ( bone ) {
+
+ bone.matrixWorld.copy( this.boneInverses[ i ] ).invert();
+
+ }
+
+ }
+
+ // compute the local matrices, positions, rotations and scales
+
+ for ( let i = 0, il = this.bones.length; i < il; i ++ ) {
+
+ const bone = this.bones[ i ];
+
+ if ( bone ) {
+
+ if ( bone.parent && bone.parent.isBone ) {
+
+ bone.matrix.copy( bone.parent.matrixWorld ).invert();
+ bone.matrix.multiply( bone.matrixWorld );
+
+ } else {
+
+ bone.matrix.copy( bone.matrixWorld );
+
+ }
+
+ bone.matrix.decompose( bone.position, bone.quaternion, bone.scale );
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Resets the skeleton to the base pose.
+ */
+ update() {
+
+ const bones = this.bones;
+ const boneInverses = this.boneInverses;
+ const boneMatrices = this.boneMatrices;
+ const boneTexture = this.boneTexture;
+
+ // flatten bone matrices to array
+
+ for ( let i = 0, il = bones.length; i < il; i ++ ) {
+
+ // compute the offset between the current and the original transform
+
+ const matrix = bones[ i ] ? bones[ i ].matrixWorld : _identityMatrix;
+
+ _offsetMatrix.multiplyMatrices( matrix, boneInverses[ i ] );
+ _offsetMatrix.toArray( boneMatrices, i * 16 );
+
+ }
+
+ if ( boneTexture !== null ) {
+
+ boneTexture.needsUpdate = true;
+
+ }
+
+ }
+
+ /**
+ * Returns a new skeleton with copied values from this instance.
+ *
+ * @return {Skeleton} A clone of this instance.
+ */
+ clone() {
+
+ return new Skeleton( this.bones, this.boneInverses );
+
+ }
+
+ /**
+ * Computes a data texture for passing bone data to the vertex shader.
+ *
+ * @return {Skeleton} A reference of this instance.
+ */
+ computeBoneTexture() {
+
+ // layout (1 matrix = 4 pixels)
+ // RGBA RGBA RGBA RGBA (=> column1, column2, column3, column4)
+ // with 8x8 pixel texture max 16 bones * 4 pixels = (8 * 8)
+ // 16x16 pixel texture max 64 bones * 4 pixels = (16 * 16)
+ // 32x32 pixel texture max 256 bones * 4 pixels = (32 * 32)
+ // 64x64 pixel texture max 1024 bones * 4 pixels = (64 * 64)
+
+ let size = Math.sqrt( this.bones.length * 4 ); // 4 pixels needed for 1 matrix
+ size = Math.ceil( size / 4 ) * 4;
+ size = Math.max( size, 4 );
+
+ const boneMatrices = new Float32Array( size * size * 4 ); // 4 floats per RGBA pixel
+ boneMatrices.set( this.boneMatrices ); // copy current values
+
+ const boneTexture = new DataTexture( boneMatrices, size, size, RGBAFormat, FloatType );
+ boneTexture.needsUpdate = true;
+
+ this.boneMatrices = boneMatrices;
+ this.boneTexture = boneTexture;
+
+ return this;
+
+ }
+
+ /**
+ * Searches through the skeleton's bone array and returns the first with a
+ * matching name.
+ *
+ * @param {string} name - The name of the bone.
+ * @return {Bone|undefined} The found bone. `undefined` if no bone has been found.
+ */
+ getBoneByName( name ) {
+
+ for ( let i = 0, il = this.bones.length; i < il; i ++ ) {
+
+ const bone = this.bones[ i ];
+
+ if ( bone.name === name ) {
+
+ return bone;
+
+ }
+
+ }
+
+ return undefined;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ */
+ dispose( ) {
+
+ if ( this.boneTexture !== null ) {
+
+ this.boneTexture.dispose();
+
+ this.boneTexture = null;
+
+ }
+
+ }
+
+ /**
+ * Setups the skeleton by the given JSON and bones.
+ *
+ * @param {Object} json - The skeleton as serialized JSON.
+ * @param {Object} bones - An array of bones.
+ * @return {Skeleton} A reference of this instance.
+ */
+ fromJSON( json, bones ) {
+
+ this.uuid = json.uuid;
+
+ for ( let i = 0, l = json.bones.length; i < l; i ++ ) {
+
+ const uuid = json.bones[ i ];
+ let bone = bones[ uuid ];
+
+ if ( bone === undefined ) {
+
+ warn( 'Skeleton: No bone found with UUID:', uuid );
+ bone = new Bone();
+
+ }
+
+ this.bones.push( bone );
+ this.boneInverses.push( new Matrix4().fromArray( json.boneInverses[ i ] ) );
+
+ }
+
+ this.init();
+
+ return this;
+
+ }
+
+ /**
+ * Serializes the skeleton into JSON.
+ *
+ * @return {Object} A JSON object representing the serialized skeleton.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON() {
+
+ const data = {
+ metadata: {
+ version: 4.7,
+ type: 'Skeleton',
+ generator: 'Skeleton.toJSON'
+ },
+ bones: [],
+ boneInverses: []
+ };
+
+ data.uuid = this.uuid;
+
+ const bones = this.bones;
+ const boneInverses = this.boneInverses;
+
+ for ( let i = 0, l = bones.length; i < l; i ++ ) {
+
+ const bone = bones[ i ];
+ data.bones.push( bone.uuid );
+
+ const boneInverse = boneInverses[ i ];
+ data.boneInverses.push( boneInverse.toArray() );
+
+ }
+
+ return data;
+
+ }
+
+}
+
+/**
+ * An instanced version of a buffer attribute.
+ *
+ * @augments BufferAttribute
+ */
+class InstancedBufferAttribute extends BufferAttribute {
+
+ /**
+ * Constructs a new instanced buffer attribute.
+ *
+ * @param {TypedArray} array - The array holding the attribute data.
+ * @param {number} itemSize - The item size.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ * @param {number} [meshPerAttribute=1] - How often a value of this buffer attribute should be repeated.
+ */
+ constructor( array, itemSize, normalized, meshPerAttribute = 1 ) {
+
+ super( array, itemSize, normalized );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isInstancedBufferAttribute = true;
+
+ /**
+ * Defines how often a value of this buffer attribute should be repeated. A
+ * value of one means that each value of the instanced attribute is used for
+ * a single instance. A value of two means that each value is used for two
+ * consecutive instances (and so on).
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.meshPerAttribute = meshPerAttribute;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.meshPerAttribute = source.meshPerAttribute;
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.meshPerAttribute = this.meshPerAttribute;
+
+ data.isInstancedBufferAttribute = true;
+
+ return data;
+
+ }
+
+}
+
+const _instanceLocalMatrix = /*@__PURE__*/ new Matrix4();
+const _instanceWorldMatrix = /*@__PURE__*/ new Matrix4();
+
+const _instanceIntersects = [];
+
+const _box3 = /*@__PURE__*/ new Box3();
+const _identity = /*@__PURE__*/ new Matrix4();
+const _mesh$1 = /*@__PURE__*/ new Mesh();
+const _sphere$4 = /*@__PURE__*/ new Sphere();
+
+/**
+ * A special version of a mesh with instanced rendering support. Use
+ * this class if you have to render a large number of objects with the same
+ * geometry and material(s) but with different world transformations. The usage
+ * of 'InstancedMesh' will help you to reduce the number of draw calls and thus
+ * improve the overall rendering performance in your application.
+ *
+ * @augments Mesh
+ */
+class InstancedMesh extends Mesh {
+
+ /**
+ * Constructs a new instanced mesh.
+ *
+ * @param {BufferGeometry} [geometry] - The mesh geometry.
+ * @param {Material|Array} [material] - The mesh material.
+ * @param {number} count - The number of instances.
+ */
+ constructor( geometry, material, count ) {
+
+ super( geometry, material );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isInstancedMesh = true;
+
+ /**
+ * Represents the local transformation of all instances. You have to set its
+ * {@link BufferAttribute#needsUpdate} flag to true if you modify instanced data
+ * via {@link InstancedMesh#setMatrixAt}.
+ *
+ * @type {InstancedBufferAttribute}
+ */
+ this.instanceMatrix = new InstancedBufferAttribute( new Float32Array( count * 16 ), 16 );
+
+ /**
+ * Represents the color of all instances. You have to set its
+ * {@link BufferAttribute#needsUpdate} flag to true if you modify instanced data
+ * via {@link InstancedMesh#setColorAt}.
+ *
+ * @type {?InstancedBufferAttribute}
+ * @default null
+ */
+ this.instanceColor = null;
+
+ /**
+ * Represents the morph target weights of all instances. You have to set its
+ * {@link Texture#needsUpdate} flag to true if you modify instanced data
+ * via {@link InstancedMesh#setMorphAt}.
+ *
+ * @type {?DataTexture}
+ * @default null
+ */
+ this.morphTexture = null;
+
+ /**
+ * The number of instances.
+ *
+ * @type {number}
+ */
+ this.count = count;
+
+ /**
+ * The bounding box of the instanced mesh. Can be computed via {@link InstancedMesh#computeBoundingBox}.
+ *
+ * @type {?Box3}
+ * @default null
+ */
+ this.boundingBox = null;
+
+ /**
+ * The bounding sphere of the instanced mesh. Can be computed via {@link InstancedMesh#computeBoundingSphere}.
+ *
+ * @type {?Sphere}
+ * @default null
+ */
+ this.boundingSphere = null;
+
+ for ( let i = 0; i < count; i ++ ) {
+
+ this.setMatrixAt( i, _identity );
+
+ }
+
+ }
+
+ /**
+ * Computes the bounding box of the instanced mesh, and updates {@link InstancedMesh#boundingBox}.
+ * The bounding box is not automatically computed by the engine; this method must be called by your app.
+ * You may need to recompute the bounding box if an instance is transformed via {@link InstancedMesh#setMatrixAt}.
+ */
+ computeBoundingBox() {
+
+ const geometry = this.geometry;
+ const count = this.count;
+
+ if ( this.boundingBox === null ) {
+
+ this.boundingBox = new Box3();
+
+ }
+
+ if ( geometry.boundingBox === null ) {
+
+ geometry.computeBoundingBox();
+
+ }
+
+ this.boundingBox.makeEmpty();
+
+ for ( let i = 0; i < count; i ++ ) {
+
+ this.getMatrixAt( i, _instanceLocalMatrix );
+
+ _box3.copy( geometry.boundingBox ).applyMatrix4( _instanceLocalMatrix );
+
+ this.boundingBox.union( _box3 );
+
+ }
+
+ }
+
+ /**
+ * Computes the bounding sphere of the instanced mesh, and updates {@link InstancedMesh#boundingSphere}
+ * The engine automatically computes the bounding sphere when it is needed, e.g., for ray casting or view frustum culling.
+ * You may need to recompute the bounding sphere if an instance is transformed via {@link InstancedMesh#setMatrixAt}.
+ */
+ computeBoundingSphere() {
+
+ const geometry = this.geometry;
+ const count = this.count;
+
+ if ( this.boundingSphere === null ) {
+
+ this.boundingSphere = new Sphere();
+
+ }
+
+ if ( geometry.boundingSphere === null ) {
+
+ geometry.computeBoundingSphere();
+
+ }
+
+ this.boundingSphere.makeEmpty();
+
+ for ( let i = 0; i < count; i ++ ) {
+
+ this.getMatrixAt( i, _instanceLocalMatrix );
+
+ _sphere$4.copy( geometry.boundingSphere ).applyMatrix4( _instanceLocalMatrix );
+
+ this.boundingSphere.union( _sphere$4 );
+
+ }
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.instanceMatrix.copy( source.instanceMatrix );
+
+ if ( source.morphTexture !== null ) this.morphTexture = source.morphTexture.clone();
+ if ( source.instanceColor !== null ) this.instanceColor = source.instanceColor.clone();
+
+ this.count = source.count;
+
+ if ( source.boundingBox !== null ) this.boundingBox = source.boundingBox.clone();
+ if ( source.boundingSphere !== null ) this.boundingSphere = source.boundingSphere.clone();
+
+ return this;
+
+ }
+
+ /**
+ * Gets the color of the defined instance.
+ *
+ * @param {number} index - The instance index.
+ * @param {Color} color - The target object that is used to store the method's result.
+ * @return {Color} A reference to the target color.
+ */
+ getColorAt( index, color ) {
+
+ if ( this.instanceColor === null ) {
+
+ return color.setRGB( 1, 1, 1 );
+
+ } else {
+
+ return color.fromArray( this.instanceColor.array, index * 3 );
+
+ }
+
+ }
+
+ /**
+ * Gets the local transformation matrix of the defined instance.
+ *
+ * @param {number} index - The instance index.
+ * @param {Matrix4} matrix - The target object that is used to store the method's result.
+ * @return {Matrix4} A reference to the target matrix.
+ */
+ getMatrixAt( index, matrix ) {
+
+ return matrix.fromArray( this.instanceMatrix.array, index * 16 );
+
+ }
+
+ /**
+ * Gets the morph target weights of the defined instance.
+ *
+ * @param {number} index - The instance index.
+ * @param {Mesh} object - The target object that is used to store the method's result.
+ */
+ getMorphAt( index, object ) {
+
+ const objectInfluences = object.morphTargetInfluences;
+
+ const array = this.morphTexture.source.data.data;
+
+ const len = objectInfluences.length + 1; // All influences + the baseInfluenceSum
+
+ const dataIndex = index * len + 1; // Skip the baseInfluenceSum at the beginning
+
+ for ( let i = 0; i < objectInfluences.length; i ++ ) {
+
+ objectInfluences[ i ] = array[ dataIndex + i ];
+
+ }
+
+ }
+
+ raycast( raycaster, intersects ) {
+
+ const matrixWorld = this.matrixWorld;
+ const raycastTimes = this.count;
+
+ _mesh$1.geometry = this.geometry;
+ _mesh$1.material = this.material;
+
+ if ( _mesh$1.material === undefined ) return;
+
+ // test with bounding sphere first
+
+ if ( this.boundingSphere === null ) this.computeBoundingSphere();
+
+ _sphere$4.copy( this.boundingSphere );
+ _sphere$4.applyMatrix4( matrixWorld );
+
+ if ( raycaster.ray.intersectsSphere( _sphere$4 ) === false ) return;
+
+ // now test each instance
+
+ for ( let instanceId = 0; instanceId < raycastTimes; instanceId ++ ) {
+
+ // calculate the world matrix for each instance
+
+ this.getMatrixAt( instanceId, _instanceLocalMatrix );
+
+ _instanceWorldMatrix.multiplyMatrices( matrixWorld, _instanceLocalMatrix );
+
+ // the mesh represents this single instance
+
+ _mesh$1.matrixWorld = _instanceWorldMatrix;
+
+ _mesh$1.raycast( raycaster, _instanceIntersects );
+
+ // process the result of raycast
+
+ for ( let i = 0, l = _instanceIntersects.length; i < l; i ++ ) {
+
+ const intersect = _instanceIntersects[ i ];
+ intersect.instanceId = instanceId;
+ intersect.object = this;
+ intersects.push( intersect );
+
+ }
+
+ _instanceIntersects.length = 0;
+
+ }
+
+ }
+
+ /**
+ * Sets the given color to the defined instance. Make sure you set the `needsUpdate` flag of
+ * {@link InstancedMesh#instanceColor} to `true` after updating all the colors.
+ *
+ * @param {number} index - The instance index.
+ * @param {Color} color - The instance color.
+ * @return {InstancedMesh} A reference to this instanced mesh.
+ */
+ setColorAt( index, color ) {
+
+ if ( this.instanceColor === null ) {
+
+ this.instanceColor = new InstancedBufferAttribute( new Float32Array( this.instanceMatrix.count * 3 ).fill( 1 ), 3 );
+
+ }
+
+ color.toArray( this.instanceColor.array, index * 3 );
+ return this;
+
+ }
+
+ /**
+ * Sets the given local transformation matrix to the defined instance. Make sure you set the `needsUpdate` flag of
+ * {@link InstancedMesh#instanceMatrix} to `true` after updating all the matrices.
+ *
+ * @param {number} index - The instance index.
+ * @param {Matrix4} matrix - The local transformation.
+ * @return {InstancedMesh} A reference to this instanced mesh.
+ */
+ setMatrixAt( index, matrix ) {
+
+ matrix.toArray( this.instanceMatrix.array, index * 16 );
+ return this;
+
+ }
+
+ /**
+ * Sets the morph target weights to the defined instance. Make sure you set the `needsUpdate` flag of
+ * {@link InstancedMesh#morphTexture} to `true` after updating all the influences.
+ *
+ * @param {number} index - The instance index.
+ * @param {Mesh} object - A mesh which `morphTargetInfluences` property containing the morph target weights
+ * of a single instance.
+ * @return {InstancedMesh} A reference to this instanced mesh.
+ */
+ setMorphAt( index, object ) {
+
+ const objectInfluences = object.morphTargetInfluences;
+
+ const len = objectInfluences.length + 1; // morphBaseInfluence + all influences
+
+ if ( this.morphTexture === null ) {
+
+ this.morphTexture = new DataTexture( new Float32Array( len * this.count ), len, this.count, RedFormat, FloatType );
+
+ }
+
+ const array = this.morphTexture.source.data.data;
+
+ let morphInfluencesSum = 0;
+
+ for ( let i = 0; i < objectInfluences.length; i ++ ) {
+
+ morphInfluencesSum += objectInfluences[ i ];
+
+ }
+
+ const morphBaseInfluence = this.geometry.morphTargetsRelative ? 1 : 1 - morphInfluencesSum;
+
+ const dataIndex = len * index;
+
+ array[ dataIndex ] = morphBaseInfluence;
+
+ array.set( objectInfluences, dataIndex + 1 );
+ return this;
+
+ }
+
+ updateMorphTargets() {
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ */
+ dispose() {
+
+ super.dispose();
+
+ if ( this.morphTexture !== null ) {
+
+ this.morphTexture.dispose();
+ this.morphTexture = null;
+
+ }
+
+ }
+
+}
+
+const _sphere$3 = /*@__PURE__*/ new Sphere();
+const _defaultSpriteCenter = /*@__PURE__*/ new Vector2( 0.5, 0.5 );
+const _vector$6 = /*@__PURE__*/ new Vector3();
+
+/**
+ * Frustums are used to determine what is inside the camera's field of view.
+ * They help speed up the rendering process - objects which lie outside a camera's
+ * frustum can safely be excluded from rendering.
+ *
+ * This class is mainly intended for use internally by a renderer.
+ */
+class Frustum {
+
+ /**
+ * Constructs a new frustum.
+ *
+ * @param {Plane} [p0] - The first plane that encloses the frustum.
+ * @param {Plane} [p1] - The second plane that encloses the frustum.
+ * @param {Plane} [p2] - The third plane that encloses the frustum.
+ * @param {Plane} [p3] - The fourth plane that encloses the frustum.
+ * @param {Plane} [p4] - The fifth plane that encloses the frustum.
+ * @param {Plane} [p5] - The sixth plane that encloses the frustum.
+ */
+ constructor( p0 = new Plane(), p1 = new Plane(), p2 = new Plane(), p3 = new Plane(), p4 = new Plane(), p5 = new Plane() ) {
+
+ /**
+ * This array holds the planes that enclose the frustum.
+ *
+ * @type {Array}
+ */
+ this.planes = [ p0, p1, p2, p3, p4, p5 ];
+
+ }
+
+ /**
+ * Sets the frustum planes by copying the given planes.
+ *
+ * @param {Plane} [p0] - The first plane that encloses the frustum.
+ * @param {Plane} [p1] - The second plane that encloses the frustum.
+ * @param {Plane} [p2] - The third plane that encloses the frustum.
+ * @param {Plane} [p3] - The fourth plane that encloses the frustum.
+ * @param {Plane} [p4] - The fifth plane that encloses the frustum.
+ * @param {Plane} [p5] - The sixth plane that encloses the frustum.
+ * @return {Frustum} A reference to this frustum.
+ */
+ set( p0, p1, p2, p3, p4, p5 ) {
+
+ const planes = this.planes;
+
+ planes[ 0 ].copy( p0 );
+ planes[ 1 ].copy( p1 );
+ planes[ 2 ].copy( p2 );
+ planes[ 3 ].copy( p3 );
+ planes[ 4 ].copy( p4 );
+ planes[ 5 ].copy( p5 );
+
+ return this;
+
+ }
+
+ /**
+ * Copies the values of the given frustum to this instance.
+ *
+ * @param {Frustum} frustum - The frustum to copy.
+ * @return {Frustum} A reference to this frustum.
+ */
+ copy( frustum ) {
+
+ const planes = this.planes;
+
+ for ( let i = 0; i < 6; i ++ ) {
+
+ planes[ i ].copy( frustum.planes[ i ] );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the frustum planes from the given projection matrix.
+ *
+ * @param {Matrix4} m - The projection matrix.
+ * @param {(WebGLCoordinateSystem|WebGPUCoordinateSystem)} coordinateSystem - The coordinate system.
+ * @param {boolean} [reversedDepth=false] - Whether to use a reversed depth.
+ * @return {Frustum} A reference to this frustum.
+ */
+ setFromProjectionMatrix( m, coordinateSystem = WebGLCoordinateSystem, reversedDepth = false ) {
+
+ const planes = this.planes;
+ const me = m.elements;
+ const me0 = me[ 0 ], me1 = me[ 1 ], me2 = me[ 2 ], me3 = me[ 3 ];
+ const me4 = me[ 4 ], me5 = me[ 5 ], me6 = me[ 6 ], me7 = me[ 7 ];
+ const me8 = me[ 8 ], me9 = me[ 9 ], me10 = me[ 10 ], me11 = me[ 11 ];
+ const me12 = me[ 12 ], me13 = me[ 13 ], me14 = me[ 14 ], me15 = me[ 15 ];
+
+ planes[ 0 ].setComponents( me3 - me0, me7 - me4, me11 - me8, me15 - me12 ).normalize();
+ planes[ 1 ].setComponents( me3 + me0, me7 + me4, me11 + me8, me15 + me12 ).normalize();
+ planes[ 2 ].setComponents( me3 + me1, me7 + me5, me11 + me9, me15 + me13 ).normalize();
+ planes[ 3 ].setComponents( me3 - me1, me7 - me5, me11 - me9, me15 - me13 ).normalize();
+
+ if ( reversedDepth ) {
+
+ planes[ 4 ].setComponents( me2, me6, me10, me14 ).normalize(); // far
+ planes[ 5 ].setComponents( me3 - me2, me7 - me6, me11 - me10, me15 - me14 ).normalize(); // near
+
+ } else {
+
+ planes[ 4 ].setComponents( me3 - me2, me7 - me6, me11 - me10, me15 - me14 ).normalize(); // far
+
+ if ( coordinateSystem === WebGLCoordinateSystem ) {
+
+ planes[ 5 ].setComponents( me3 + me2, me7 + me6, me11 + me10, me15 + me14 ).normalize(); // near
+
+ } else if ( coordinateSystem === WebGPUCoordinateSystem ) {
+
+ planes[ 5 ].setComponents( me2, me6, me10, me14 ).normalize(); // near
+
+ } else {
+
+ throw new Error( 'THREE.Frustum.setFromProjectionMatrix(): Invalid coordinate system: ' + coordinateSystem );
+
+ }
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if the 3D object's bounding sphere is intersecting this frustum.
+ *
+ * Note that the 3D object must have a geometry so that the bounding sphere can be calculated.
+ *
+ * @param {Object3D} object - The 3D object to test.
+ * @return {boolean} Whether the 3D object's bounding sphere is intersecting this frustum or not.
+ */
+ intersectsObject( object ) {
+
+ if ( object.boundingSphere !== undefined ) {
+
+ if ( object.boundingSphere === null ) object.computeBoundingSphere();
+
+ _sphere$3.copy( object.boundingSphere ).applyMatrix4( object.matrixWorld );
+
+ } else {
+
+ const geometry = object.geometry;
+
+ if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();
+
+ _sphere$3.copy( geometry.boundingSphere ).applyMatrix4( object.matrixWorld );
+
+ }
+
+ return this.intersectsSphere( _sphere$3 );
+
+ }
+
+ /**
+ * Returns `true` if the given sprite is intersecting this frustum.
+ *
+ * @param {Sprite} sprite - The sprite to test.
+ * @return {boolean} Whether the sprite is intersecting this frustum or not.
+ */
+ intersectsSprite( sprite ) {
+
+ _sphere$3.center.set( 0, 0, 0 );
+
+ const offset = _defaultSpriteCenter.distanceTo( sprite.center );
+
+ _sphere$3.radius = 0.7071067811865476 + offset;
+ _sphere$3.applyMatrix4( sprite.matrixWorld );
+
+ return this.intersectsSphere( _sphere$3 );
+
+ }
+
+ /**
+ * Returns `true` if the given bounding sphere is intersecting this frustum.
+ *
+ * This is a fast, conservative test that favors performance over precision. It can
+ * report false positives for spheres that lie outside the frustum but are not separated
+ * by a single frustum plane. It never reports false negatives, so it is safe for culling.
+ *
+ * @param {Sphere} sphere - The bounding sphere to test.
+ * @return {boolean} Whether the bounding sphere is intersecting this frustum or not.
+ */
+ intersectsSphere( sphere ) {
+
+ const planes = this.planes;
+ const center = sphere.center;
+ const negRadius = - sphere.radius;
+
+ for ( let i = 0; i < 6; i ++ ) {
+
+ const distance = planes[ i ].distanceToPoint( center );
+
+ if ( distance < negRadius ) {
+
+ return false;
+
+ }
+
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Returns `true` if the given bounding box is intersecting this frustum.
+ *
+ * This is a fast, conservative test that favors performance over precision. It can
+ * report false positives for large boxes that lie outside the frustum but are not
+ * separated by a single frustum plane. It never reports false negatives, so it is
+ * safe for culling.
+ *
+ * @param {Box3} box - The bounding box to test.
+ * @return {boolean} Whether the bounding box is intersecting this frustum or not.
+ */
+ intersectsBox( box ) {
+
+ const planes = this.planes;
+
+ for ( let i = 0; i < 6; i ++ ) {
+
+ const plane = planes[ i ];
+
+ // corner at max distance
+
+ _vector$6.x = plane.normal.x > 0 ? box.max.x : box.min.x;
+ _vector$6.y = plane.normal.y > 0 ? box.max.y : box.min.y;
+ _vector$6.z = plane.normal.z > 0 ? box.max.z : box.min.z;
+
+ if ( plane.distanceToPoint( _vector$6 ) < 0 ) {
+
+ return false;
+
+ }
+
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Returns `true` if the given point lies within the frustum.
+ *
+ * @param {Vector3} point - The point to test.
+ * @return {boolean} Whether the point lies within this frustum or not.
+ */
+ containsPoint( point ) {
+
+ const planes = this.planes;
+
+ for ( let i = 0; i < 6; i ++ ) {
+
+ if ( planes[ i ].distanceToPoint( point ) < 0 ) {
+
+ return false;
+
+ }
+
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Returns a new frustum with copied values from this instance.
+ *
+ * @return {Frustum} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+}
+
+const _projScreenMatrix$1 = /*@__PURE__*/ new Matrix4();
+
+/**
+ * FrustumArray is used to determine if an object is visible in at least one camera
+ * from an array of cameras. This is particularly useful for multi-view renderers.
+*/
+class FrustumArray {
+
+ /**
+ * Constructs a new frustum array.
+ *
+ */
+ constructor() {
+
+ /**
+ * The coordinate system to use.
+ *
+ * @type {WebGLCoordinateSystem|WebGPUCoordinateSystem}
+ * @default WebGLCoordinateSystem
+ */
+ this.coordinateSystem = WebGLCoordinateSystem;
+
+ /**
+ * A pool of frustum instances. It may hold more entries than are
+ * currently in use; surplus instances are kept for reuse to avoid
+ * reallocating when array cameras of different lengths are rendered.
+ *
+ * @private
+ * @type {Array}
+ */
+ this._frustums = [];
+
+ /**
+ * The number of frustums in {@link FrustumArray#_frustums} that are currently
+ * in use.
+ *
+ * @private
+ * @type {number}
+ * @default 0
+ */
+ this._count = 0;
+
+ }
+
+ /**
+ * Computes and caches a frustum for each camera of the given array camera.
+ *
+ * @param {ArrayCamera} cameraArray - The array camera whose sub-cameras define the frustums.
+ * @return {FrustumArray} A reference to this frustum array.
+ */
+ setFromArrayCamera( cameraArray ) {
+
+ const cameras = cameraArray.cameras;
+ const frustums = this._frustums;
+
+ for ( let i = 0; i < cameras.length; i ++ ) {
+
+ const camera = cameras[ i ];
+
+ _projScreenMatrix$1.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse );
+
+ if ( frustums[ i ] === undefined ) frustums[ i ] = new Frustum();
+
+ frustums[ i ].setFromProjectionMatrix( _projScreenMatrix$1, camera.coordinateSystem, camera.reversedDepth );
+
+ }
+
+ this._count = cameras.length;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if the 3D object's bounding sphere is intersecting any cached frustum.
+ *
+ * {@link FrustumArray#setFromArrayCamera} must be called once per render before this method.
+ *
+ * @param {Object3D} object - The 3D object to test.
+ * @return {boolean} Whether the 3D object is visible in any camera.
+ */
+ intersectsObject( object ) {
+
+ const frustums = this._frustums;
+
+ for ( let i = 0; i < this._count; i ++ ) {
+
+ if ( frustums[ i ].intersectsObject( object ) ) return true;
+
+ }
+
+ return false;
+
+ }
+
+ /**
+ * Returns `true` if the given sprite is intersecting any cached frustum.
+ *
+ * {@link FrustumArray#setFromArrayCamera} must be called once per render before this method.
+ *
+ * @param {Sprite} sprite - The sprite to test.
+ * @return {boolean} Whether the sprite is visible in any camera.
+ */
+ intersectsSprite( sprite ) {
+
+ const frustums = this._frustums;
+
+ for ( let i = 0; i < this._count; i ++ ) {
+
+ if ( frustums[ i ].intersectsSprite( sprite ) ) return true;
+
+ }
+
+ return false;
+
+ }
+
+ /**
+ * Returns `true` if the given bounding sphere is intersecting any cached frustum.
+ *
+ * {@link FrustumArray#setFromArrayCamera} must be called once per render before this method.
+ *
+ * @param {Sphere} sphere - The bounding sphere to test.
+ * @return {boolean} Whether the sphere is visible in any camera.
+ */
+ intersectsSphere( sphere ) {
+
+ const frustums = this._frustums;
+
+ for ( let i = 0; i < this._count; i ++ ) {
+
+ if ( frustums[ i ].intersectsSphere( sphere ) ) return true;
+
+ }
+
+ return false;
+
+ }
+
+ /**
+ * Returns `true` if the given bounding box is intersecting any cached frustum.
+ *
+ * {@link FrustumArray#setFromArrayCamera} must be called once per render before this method.
+ *
+ * @param {Box3} box - The bounding box to test.
+ * @return {boolean} Whether the box is visible in any camera.
+ */
+ intersectsBox( box ) {
+
+ const frustums = this._frustums;
+
+ for ( let i = 0; i < this._count; i ++ ) {
+
+ if ( frustums[ i ].intersectsBox( box ) ) return true;
+
+ }
+
+ return false;
+
+ }
+
+ /**
+ * Returns `true` if the given point lies within any cached frustum.
+ *
+ * {@link FrustumArray#setFromArrayCamera} must be called once per render before this method.
+ *
+ * @param {Vector3} point - The point to test.
+ * @return {boolean} Whether the point is visible in any camera.
+ */
+ containsPoint( point ) {
+
+ const frustums = this._frustums;
+
+ for ( let i = 0; i < this._count; i ++ ) {
+
+ if ( frustums[ i ].containsPoint( point ) ) return true;
+
+ }
+
+ return false;
+
+ }
+
+ /**
+ * Copies the values of the given frustum array to this instance.
+ *
+ * @param {FrustumArray} source - The frustum array to copy.
+ * @return {FrustumArray} A reference to this frustum array.
+ */
+ copy( source ) {
+
+ this.coordinateSystem = source.coordinateSystem;
+
+ const frustums = this._frustums;
+ const sourceFrustums = source._frustums;
+
+ for ( let i = 0; i < source._count; i ++ ) {
+
+ if ( frustums[ i ] === undefined ) frustums[ i ] = new Frustum();
+
+ frustums[ i ].copy( sourceFrustums[ i ] );
+
+ }
+
+ this._count = source._count;
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new frustum array with copied values from this instance.
+ *
+ * @return {FrustumArray} A clone of this instance.
+ */
+ clone() {
+
+ return new FrustumArray().copy( this );
+
+ }
+
+}
+
+function ascIdSort( a, b ) {
+
+ return a - b;
+
+}
+
+function sortOpaque( a, b ) {
+
+ return a.z - b.z;
+
+}
+
+function sortTransparent( a, b ) {
+
+ return b.z - a.z;
+
+}
+
+class MultiDrawRenderList {
+
+ constructor() {
+
+ this.index = 0;
+ this.pool = [];
+ this.list = [];
+
+ }
+
+ push( start, count, z, index ) {
+
+ const pool = this.pool;
+ const list = this.list;
+ if ( this.index >= pool.length ) {
+
+ pool.push( {
+
+ start: -1,
+ count: -1,
+ z: -1,
+ index: -1,
+
+ } );
+
+ }
+
+ const item = pool[ this.index ];
+ list.push( item );
+ this.index ++;
+
+ item.start = start;
+ item.count = count;
+ item.z = z;
+ item.index = index;
+
+ }
+
+ reset() {
+
+ this.list.length = 0;
+ this.index = 0;
+
+ }
+
+}
+
+const _matrix$1 = /*@__PURE__*/ new Matrix4();
+const _whiteColor = /*@__PURE__*/ new Color( 1, 1, 1 );
+const _frustum = /*@__PURE__*/ new Frustum();
+const _frustumArray = /*@__PURE__*/ new FrustumArray();
+const _box$1 = /*@__PURE__*/ new Box3();
+const _sphere$2 = /*@__PURE__*/ new Sphere();
+const _vector$5 = /*@__PURE__*/ new Vector3();
+const _forward$1 = /*@__PURE__*/ new Vector3();
+const _temp = /*@__PURE__*/ new Vector3();
+const _renderList = /*@__PURE__*/ new MultiDrawRenderList();
+const _mesh = /*@__PURE__*/ new Mesh();
+const _batchIntersects = [];
+
+// copies data from attribute "src" into "target" starting at "targetOffset"
+function copyAttributeData( src, target, targetOffset = 0 ) {
+
+ const itemSize = target.itemSize;
+ if ( src.isInterleavedBufferAttribute || src.array.constructor !== target.array.constructor ) {
+
+ // use the component getters and setters if the array data cannot
+ // be copied directly
+ const vertexCount = src.count;
+ for ( let i = 0; i < vertexCount; i ++ ) {
+
+ for ( let c = 0; c < itemSize; c ++ ) {
+
+ target.setComponent( i + targetOffset, c, src.getComponent( i, c ) );
+
+ }
+
+ }
+
+ } else {
+
+ // faster copy approach using typed array set function
+ target.array.set( src.array, targetOffset * itemSize );
+
+ }
+
+ target.needsUpdate = true;
+
+}
+
+// safely copies array contents to a potentially smaller array
+function copyArrayContents( src, target ) {
+
+ if ( src.constructor !== target.constructor ) {
+
+ // if arrays are of a different type (eg due to index size increasing) then data must be per-element copied
+ const len = Math.min( src.length, target.length );
+ for ( let i = 0; i < len; i ++ ) {
+
+ target[ i ] = src[ i ];
+
+ }
+
+ } else {
+
+ // if the arrays use the same data layout we can use a fast block copy
+ const len = Math.min( src.length, target.length );
+ target.set( new src.constructor( src.buffer, 0, len ) );
+
+ }
+
+}
+
+/**
+ * A special version of a mesh with multi draw batch rendering support. Use
+ * this class if you have to render a large number of objects with the same
+ * material but with different geometries or world transformations. The usage of
+ * `BatchedMesh` will help you to reduce the number of draw calls and thus improve the overall
+ * rendering performance in your application.
+ *
+ * ```js
+ * const box = new THREE.BoxGeometry( 1, 1, 1 );
+ * const sphere = new THREE.SphereGeometry( 1, 12, 12 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
+ *
+ * // initialize and add geometries into the batched mesh
+ * const batchedMesh = new BatchedMesh( 10, 5000, 10000, material );
+ * const boxGeometryId = batchedMesh.addGeometry( box );
+ * const sphereGeometryId = batchedMesh.addGeometry( sphere );
+ *
+ * // create instances of those geometries
+ * const boxInstancedId1 = batchedMesh.addInstance( boxGeometryId );
+ * const boxInstancedId2 = batchedMesh.addInstance( boxGeometryId );
+ *
+ * const sphereInstancedId1 = batchedMesh.addInstance( sphereGeometryId );
+ * const sphereInstancedId2 = batchedMesh.addInstance( sphereGeometryId );
+ *
+ * // position the geometries
+ * batchedMesh.setMatrixAt( boxInstancedId1, boxMatrix1 );
+ * batchedMesh.setMatrixAt( boxInstancedId2, boxMatrix2 );
+ *
+ * batchedMesh.setMatrixAt( sphereInstancedId1, sphereMatrix1 );
+ * batchedMesh.setMatrixAt( sphereInstancedId2, sphereMatrix2 );
+ *
+ * scene.add( batchedMesh );
+ * ```
+ *
+ * @augments Mesh
+ */
+class BatchedMesh extends Mesh {
+
+ /**
+ * Constructs a new batched mesh.
+ *
+ * @param {number} maxInstanceCount - The maximum number of individual instances planned to be added and rendered.
+ * @param {number} maxVertexCount - The maximum number of vertices to be used by all unique geometries.
+ * @param {number} [maxIndexCount=maxVertexCount*2] - The maximum number of indices to be used by all unique geometries
+ * @param {Material|Array} [material] - The mesh material.
+ */
+ constructor( maxInstanceCount, maxVertexCount, maxIndexCount = maxVertexCount * 2, material ) {
+
+ super( new BufferGeometry(), material );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isBatchedMesh = true;
+
+ /**
+ * When set ot `true`, the individual objects of a batch are frustum culled.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.perObjectFrustumCulled = true;
+
+ /**
+ * When set to `true`, the individual objects of a batch are sorted to improve overdraw-related artifacts.
+ * If the material is marked as "transparent" objects are rendered back to front and if not then they are
+ * rendered front to back.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.sortObjects = true;
+
+ /**
+ * The bounding box of the batched mesh. Can be computed via {@link BatchedMesh#computeBoundingBox}.
+ *
+ * @type {?Box3}
+ * @default null
+ */
+ this.boundingBox = null;
+
+ /**
+ * The bounding sphere of the batched mesh. Can be computed via {@link BatchedMesh#computeBoundingSphere}.
+ *
+ * @type {?Sphere}
+ * @default null
+ */
+ this.boundingSphere = null;
+
+ /**
+ * Takes a sort a function that is run before render. The function takes a list of instances to
+ * sort and a camera. The objects in the list include a "z" field to perform a depth-ordered
+ * sort with.
+ *
+ * @type {?Function}
+ * @default null
+ */
+ this.customSort = null;
+
+ // stores visible, active, and geometry id per instance and reserved buffer ranges for geometries
+ this._instanceInfo = [];
+ this._geometryInfo = [];
+
+ // instance, geometry ids that have been set as inactive, and are available to be overwritten
+ this._availableInstanceIds = [];
+ this._availableGeometryIds = [];
+
+ // used to track where the next point is that geometry should be inserted
+ this._nextIndexStart = 0;
+ this._nextVertexStart = 0;
+ this._geometryCount = 0;
+
+ // flags
+ this._visibilityChanged = true;
+ this._geometryInitialized = false;
+
+ // cached user options
+ this._maxInstanceCount = maxInstanceCount;
+ this._maxVertexCount = maxVertexCount;
+ this._maxIndexCount = maxIndexCount;
+
+ // buffers for multi draw
+ this._multiDrawCounts = new Int32Array( maxInstanceCount );
+ this._multiDrawStarts = new Int32Array( maxInstanceCount );
+ this._multiDrawCount = 0;
+ this._multiDrawBytesPerElement = 1;
+
+ // Local matrix per geometry by using data texture
+ this._matricesTexture = null;
+ this._indirectTexture = null;
+ this._colorsTexture = null;
+
+ this._initMatricesTexture();
+ this._initIndirectTexture();
+
+ }
+
+ /**
+ * The maximum number of individual instances that can be stored in the batch.
+ *
+ * @type {number}
+ * @readonly
+ */
+ get maxInstanceCount() {
+
+ return this._maxInstanceCount;
+
+ }
+
+ /**
+ * The instance count.
+ *
+ * @type {number}
+ * @readonly
+ */
+ get instanceCount() {
+
+ return this._instanceInfo.length - this._availableInstanceIds.length;
+
+ }
+
+ /**
+ * The number of unused vertices.
+ *
+ * @type {number}
+ * @readonly
+ */
+ get unusedVertexCount() {
+
+ return this._maxVertexCount - this._nextVertexStart;
+
+ }
+
+ /**
+ * The number of unused indices.
+ *
+ * @type {number}
+ * @readonly
+ */
+ get unusedIndexCount() {
+
+ return this._maxIndexCount - this._nextIndexStart;
+
+ }
+
+ _initMatricesTexture() {
+
+ // layout (1 matrix = 4 pixels)
+ // RGBA RGBA RGBA RGBA (=> column1, column2, column3, column4)
+ // with 8x8 pixel texture max 16 matrices * 4 pixels = (8 * 8)
+ // 16x16 pixel texture max 64 matrices * 4 pixels = (16 * 16)
+ // 32x32 pixel texture max 256 matrices * 4 pixels = (32 * 32)
+ // 64x64 pixel texture max 1024 matrices * 4 pixels = (64 * 64)
+
+ let size = Math.sqrt( this._maxInstanceCount * 4 ); // 4 pixels needed for 1 matrix
+ size = Math.ceil( size / 4 ) * 4;
+ size = Math.max( size, 4 );
+
+ const matricesArray = new Float32Array( size * size * 4 ); // 4 floats per RGBA pixel
+ const matricesTexture = new DataTexture( matricesArray, size, size, RGBAFormat, FloatType );
+
+ this._matricesTexture = matricesTexture;
+
+ }
+
+ _initIndirectTexture() {
+
+ let size = Math.sqrt( this._maxInstanceCount );
+ size = Math.ceil( size );
+
+ const indirectArray = new Uint32Array( size * size );
+ const indirectTexture = new DataTexture( indirectArray, size, size, RedIntegerFormat, UnsignedIntType );
+
+ this._indirectTexture = indirectTexture;
+
+ }
+
+ _initColorsTexture() {
+
+ let size = Math.sqrt( this._maxInstanceCount );
+ size = Math.ceil( size );
+
+ // 4 floats per RGBA pixel initialized to white
+ const colorsArray = new Float32Array( size * size * 4 ).fill( 1 );
+ const colorsTexture = new DataTexture( colorsArray, size, size, RGBAFormat, FloatType );
+ colorsTexture.colorSpace = ColorManagement.workingColorSpace;
+
+ this._colorsTexture = colorsTexture;
+
+ }
+
+ _initializeGeometry( reference ) {
+
+ const geometry = this.geometry;
+ const maxVertexCount = this._maxVertexCount;
+ const maxIndexCount = this._maxIndexCount;
+ if ( this._geometryInitialized === false ) {
+
+ for ( const attributeName in reference.attributes ) {
+
+ const srcAttribute = reference.getAttribute( attributeName );
+ const { array, itemSize, normalized } = srcAttribute;
+
+ const dstArray = new array.constructor( maxVertexCount * itemSize );
+ const dstAttribute = new BufferAttribute( dstArray, itemSize, normalized );
+
+ geometry.setAttribute( attributeName, dstAttribute );
+
+ }
+
+ if ( reference.getIndex() !== null ) {
+
+ // Reserve last u16 index for primitive restart.
+ const indexArray = maxVertexCount > 65535
+ ? new Uint32Array( maxIndexCount )
+ : new Uint16Array( maxIndexCount );
+
+ geometry.setIndex( new BufferAttribute( indexArray, 1 ) );
+
+ }
+
+ this._geometryInitialized = true;
+
+ }
+
+ }
+
+ // Make sure the geometry is compatible with the existing combined geometry attributes
+ _validateGeometry( geometry ) {
+
+ // check to ensure the geometries are using consistent attributes and indices
+ const batchGeometry = this.geometry;
+ if ( Boolean( geometry.getIndex() ) !== Boolean( batchGeometry.getIndex() ) ) {
+
+ throw new Error( 'THREE.BatchedMesh: All geometries must consistently have "index".' );
+
+ }
+
+ for ( const attributeName in batchGeometry.attributes ) {
+
+ if ( ! geometry.hasAttribute( attributeName ) ) {
+
+ throw new Error( `THREE.BatchedMesh: Added geometry missing "${ attributeName }". All geometries must have consistent attributes.` );
+
+ }
+
+ const srcAttribute = geometry.getAttribute( attributeName );
+ const dstAttribute = batchGeometry.getAttribute( attributeName );
+ if ( srcAttribute.itemSize !== dstAttribute.itemSize || srcAttribute.normalized !== dstAttribute.normalized ) {
+
+ throw new Error( 'THREE.BatchedMesh: All attributes must have a consistent itemSize and normalized value.' );
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Validates the instance defined by the given ID.
+ *
+ * @param {number} instanceId - The instance to validate.
+ */
+ validateInstanceId( instanceId ) {
+
+ const instanceInfo = this._instanceInfo;
+ if ( instanceId < 0 || instanceId >= instanceInfo.length || instanceInfo[ instanceId ].active === false ) {
+
+ throw new Error( `THREE.BatchedMesh: Invalid instanceId ${instanceId}. Instance is either out of range or has been deleted.` );
+
+ }
+
+ }
+
+ /**
+ * Validates the geometry defined by the given ID.
+ *
+ * @param {number} geometryId - The geometry to validate.
+ */
+ validateGeometryId( geometryId ) {
+
+ const geometryInfoList = this._geometryInfo;
+ if ( geometryId < 0 || geometryId >= geometryInfoList.length || geometryInfoList[ geometryId ].active === false ) {
+
+ throw new Error( `THREE.BatchedMesh: Invalid geometryId ${geometryId}. Geometry is either out of range or has been deleted.` );
+
+ }
+
+ }
+
+ /**
+ * Takes a sort a function that is run before render. The function takes a list of instances to
+ * sort and a camera. The objects in the list include a "z" field to perform a depth-ordered sort with.
+ *
+ * @param {Function} func - The custom sort function.
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ setCustomSort( func ) {
+
+ this.customSort = func;
+ return this;
+
+ }
+
+ /**
+ * Computes the bounding box, updating {@link BatchedMesh#boundingBox}.
+ * Bounding boxes aren't computed by default. They need to be explicitly computed,
+ * otherwise they are `null`.
+ */
+ computeBoundingBox() {
+
+ if ( this.boundingBox === null ) {
+
+ this.boundingBox = new Box3();
+
+ }
+
+ const boundingBox = this.boundingBox;
+ const instanceInfo = this._instanceInfo;
+
+ boundingBox.makeEmpty();
+ for ( let i = 0, l = instanceInfo.length; i < l; i ++ ) {
+
+ if ( instanceInfo[ i ].active === false ) continue;
+
+ const geometryId = instanceInfo[ i ].geometryIndex;
+ this.getMatrixAt( i, _matrix$1 );
+ this.getBoundingBoxAt( geometryId, _box$1 ).applyMatrix4( _matrix$1 );
+ boundingBox.union( _box$1 );
+
+ }
+
+ }
+
+ /**
+ * Computes the bounding sphere, updating {@link BatchedMesh#boundingSphere}.
+ * Bounding spheres aren't computed by default. They need to be explicitly computed,
+ * otherwise they are `null`.
+ */
+ computeBoundingSphere() {
+
+ if ( this.boundingSphere === null ) {
+
+ this.boundingSphere = new Sphere();
+
+ }
+
+ const boundingSphere = this.boundingSphere;
+ const instanceInfo = this._instanceInfo;
+
+ boundingSphere.makeEmpty();
+ for ( let i = 0, l = instanceInfo.length; i < l; i ++ ) {
+
+ if ( instanceInfo[ i ].active === false ) continue;
+
+ const geometryId = instanceInfo[ i ].geometryIndex;
+ this.getMatrixAt( i, _matrix$1 );
+ this.getBoundingSphereAt( geometryId, _sphere$2 ).applyMatrix4( _matrix$1 );
+ boundingSphere.union( _sphere$2 );
+
+ }
+
+ }
+
+ /**
+ * Adds a new instance to the batch using the geometry of the given ID and returns
+ * a new id referring to the new instance to be used by other functions.
+ *
+ * @param {number} geometryId - The ID of a previously added geometry via {@link BatchedMesh#addGeometry}.
+ * @return {number} The instance ID.
+ */
+ addInstance( geometryId ) {
+
+ const atCapacity = this._instanceInfo.length >= this.maxInstanceCount;
+
+ // ensure we're not over geometry
+ if ( atCapacity && this._availableInstanceIds.length === 0 ) {
+
+ throw new Error( 'THREE.BatchedMesh: Maximum item count reached.' );
+
+ }
+
+ const instanceInfo = {
+ visible: true,
+ active: true,
+ geometryIndex: geometryId,
+ };
+
+ let drawId = null;
+
+ // Prioritize using previously freed instance ids
+ if ( this._availableInstanceIds.length > 0 ) {
+
+ this._availableInstanceIds.sort( ascIdSort );
+
+ drawId = this._availableInstanceIds.shift();
+ this._instanceInfo[ drawId ] = instanceInfo;
+
+ } else {
+
+ drawId = this._instanceInfo.length;
+ this._instanceInfo.push( instanceInfo );
+
+ }
+
+ const matricesTexture = this._matricesTexture;
+ _matrix$1.identity().toArray( matricesTexture.image.data, drawId * 16 );
+ matricesTexture.needsUpdate = true;
+
+ const colorsTexture = this._colorsTexture;
+ if ( colorsTexture ) {
+
+ _whiteColor.toArray( colorsTexture.image.data, drawId * 4 );
+ colorsTexture.needsUpdate = true;
+
+ }
+
+ this._visibilityChanged = true;
+ return drawId;
+
+ }
+
+ /**
+ * Adds the given geometry to the batch and returns the associated
+ * geometry id referring to it to be used in other functions.
+ *
+ * @param {BufferGeometry} geometry - The geometry to add.
+ * @param {number} [reservedVertexCount=-1] - Optional parameter specifying the amount of
+ * vertex buffer space to reserve for the added geometry. This is necessary if it is planned
+ * to set a new geometry at this index at a later time that is larger than the original geometry.
+ * Defaults to the length of the given geometry vertex buffer.
+ * @param {number} [reservedIndexCount=-1] - Optional parameter specifying the amount of index
+ * buffer space to reserve for the added geometry. This is necessary if it is planned to set a
+ * new geometry at this index at a later time that is larger than the original geometry. Defaults to
+ * the length of the given geometry index buffer.
+ * @return {number} The geometry ID.
+ */
+ addGeometry( geometry, reservedVertexCount = -1, reservedIndexCount = -1 ) {
+
+ this._initializeGeometry( geometry );
+
+ this._validateGeometry( geometry );
+
+ const geometryInfo = {
+ // geometry information
+ vertexStart: -1,
+ vertexCount: -1,
+ reservedVertexCount: -1,
+
+ indexStart: -1,
+ indexCount: -1,
+ reservedIndexCount: -1,
+
+ // draw range information
+ start: -1,
+ count: -1,
+
+ // state
+ boundingBox: null,
+ boundingSphere: null,
+ active: true,
+ };
+
+ const geometryInfoList = this._geometryInfo;
+ geometryInfo.vertexStart = this._nextVertexStart;
+ geometryInfo.reservedVertexCount = reservedVertexCount === -1 ? geometry.getAttribute( 'position' ).count : reservedVertexCount;
+
+ const index = geometry.getIndex();
+ const hasIndex = index !== null;
+ if ( hasIndex ) {
+
+ geometryInfo.indexStart = this._nextIndexStart;
+ geometryInfo.reservedIndexCount = reservedIndexCount === -1 ? index.count : reservedIndexCount;
+
+ }
+
+ if (
+ geometryInfo.indexStart !== -1 &&
+ geometryInfo.indexStart + geometryInfo.reservedIndexCount > this._maxIndexCount ||
+ geometryInfo.vertexStart + geometryInfo.reservedVertexCount > this._maxVertexCount
+ ) {
+
+ throw new Error( 'THREE.BatchedMesh: Reserved space request exceeds the maximum buffer size.' );
+
+ }
+
+ // update id
+ let geometryId;
+ if ( this._availableGeometryIds.length > 0 ) {
+
+ this._availableGeometryIds.sort( ascIdSort );
+
+ geometryId = this._availableGeometryIds.shift();
+ geometryInfoList[ geometryId ] = geometryInfo;
+
+
+ } else {
+
+ geometryId = this._geometryCount;
+ this._geometryCount ++;
+ geometryInfoList.push( geometryInfo );
+
+ }
+
+ // update the geometry
+ this.setGeometryAt( geometryId, geometry );
+
+ // increment the next geometry position
+ this._nextIndexStart = geometryInfo.indexStart + geometryInfo.reservedIndexCount;
+ this._nextVertexStart = geometryInfo.vertexStart + geometryInfo.reservedVertexCount;
+
+ return geometryId;
+
+ }
+
+ /**
+ * Replaces the geometry at the given ID with the provided geometry. Throws an error if there
+ * is not enough space reserved for geometry. Calling this will change all instances that are
+ * rendering that geometry.
+ *
+ * @param {number} geometryId - The ID of the geometry that should be replaced with the given geometry.
+ * @param {BufferGeometry} geometry - The new geometry.
+ * @return {number} The geometry ID.
+ */
+ setGeometryAt( geometryId, geometry ) {
+
+ if ( geometryId >= this._geometryCount ) {
+
+ throw new Error( 'THREE.BatchedMesh: Maximum geometry count reached.' );
+
+ }
+
+ this._validateGeometry( geometry );
+
+ const batchGeometry = this.geometry;
+ const hasIndex = batchGeometry.getIndex() !== null;
+ const dstIndex = batchGeometry.getIndex();
+ const srcIndex = geometry.getIndex();
+ const geometryInfo = this._geometryInfo[ geometryId ];
+ if (
+ hasIndex &&
+ srcIndex.count > geometryInfo.reservedIndexCount ||
+ geometry.attributes.position.count > geometryInfo.reservedVertexCount
+ ) {
+
+ throw new Error( 'THREE.BatchedMesh: Reserved space not large enough for provided geometry.' );
+
+ }
+
+ // copy geometry buffer data over
+ const vertexStart = geometryInfo.vertexStart;
+ const reservedVertexCount = geometryInfo.reservedVertexCount;
+ geometryInfo.vertexCount = geometry.getAttribute( 'position' ).count;
+
+ for ( const attributeName in batchGeometry.attributes ) {
+
+ // copy attribute data
+ const srcAttribute = geometry.getAttribute( attributeName );
+ const dstAttribute = batchGeometry.getAttribute( attributeName );
+ copyAttributeData( srcAttribute, dstAttribute, vertexStart );
+
+ // fill the rest in with zeroes
+ const itemSize = srcAttribute.itemSize;
+ for ( let i = srcAttribute.count, l = reservedVertexCount; i < l; i ++ ) {
+
+ const index = vertexStart + i;
+ for ( let c = 0; c < itemSize; c ++ ) {
+
+ dstAttribute.setComponent( index, c, 0 );
+
+ }
+
+ }
+
+ dstAttribute.needsUpdate = true;
+ dstAttribute.addUpdateRange( vertexStart * itemSize, reservedVertexCount * itemSize );
+
+ }
+
+ // copy index
+ if ( hasIndex ) {
+
+ const indexStart = geometryInfo.indexStart;
+ const reservedIndexCount = geometryInfo.reservedIndexCount;
+ geometryInfo.indexCount = geometry.getIndex().count;
+
+ // copy index data over
+ for ( let i = 0; i < srcIndex.count; i ++ ) {
+
+ dstIndex.setX( indexStart + i, vertexStart + srcIndex.getX( i ) );
+
+ }
+
+ // fill the rest in with zeroes
+ for ( let i = srcIndex.count, l = reservedIndexCount; i < l; i ++ ) {
+
+ dstIndex.setX( indexStart + i, vertexStart );
+
+ }
+
+ dstIndex.needsUpdate = true;
+ dstIndex.addUpdateRange( indexStart, geometryInfo.reservedIndexCount );
+
+ }
+
+ // update the draw range
+ geometryInfo.start = hasIndex ? geometryInfo.indexStart : geometryInfo.vertexStart;
+ geometryInfo.count = hasIndex ? geometryInfo.indexCount : geometryInfo.vertexCount;
+
+ // store the bounding boxes
+ geometryInfo.boundingBox = null;
+ if ( geometry.boundingBox !== null ) {
+
+ geometryInfo.boundingBox = geometry.boundingBox.clone();
+
+ }
+
+ geometryInfo.boundingSphere = null;
+ if ( geometry.boundingSphere !== null ) {
+
+ geometryInfo.boundingSphere = geometry.boundingSphere.clone();
+
+ }
+
+ this._visibilityChanged = true;
+ return geometryId;
+
+ }
+
+ /**
+ * Deletes the geometry defined by the given ID from this batch. Any instances referencing
+ * this geometry will also be removed as a side effect.
+ *
+ * @param {number} geometryId - The ID of the geometry to remove from the batch.
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ deleteGeometry( geometryId ) {
+
+ const geometryInfoList = this._geometryInfo;
+ if ( geometryId >= geometryInfoList.length || geometryInfoList[ geometryId ].active === false ) {
+
+ return this;
+
+ }
+
+ // delete any instances associated with this geometry
+ const instanceInfo = this._instanceInfo;
+ for ( let i = 0, l = instanceInfo.length; i < l; i ++ ) {
+
+ if ( instanceInfo[ i ].active && instanceInfo[ i ].geometryIndex === geometryId ) {
+
+ this.deleteInstance( i );
+
+ }
+
+ }
+
+ geometryInfoList[ geometryId ].active = false;
+ this._availableGeometryIds.push( geometryId );
+ this._visibilityChanged = true;
+
+ return this;
+
+ }
+
+ /**
+ * Deletes an existing instance from the batch using the given ID.
+ *
+ * @param {number} instanceId - The ID of the instance to remove from the batch.
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ deleteInstance( instanceId ) {
+
+ this.validateInstanceId( instanceId );
+
+ this._instanceInfo[ instanceId ].active = false;
+ this._availableInstanceIds.push( instanceId );
+ this._visibilityChanged = true;
+
+ return this;
+
+ }
+
+ /**
+ * Repacks the sub geometries in BatchedMesh to remove any unused space remaining from
+ * previously deleted geometry, freeing up space to add new geometry.
+ *
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ optimize() {
+
+ // track the next indices to copy data to
+ let nextVertexStart = 0;
+ let nextIndexStart = 0;
+
+ // Iterate over all geometry ranges in order sorted from earliest in the geometry buffer to latest
+ // in the geometry buffer. Because draw range objects can be reused there is no guarantee of their order.
+ const geometryInfoList = this._geometryInfo;
+ const indices = geometryInfoList
+ .map( ( e, i ) => i )
+ .sort( ( a, b ) => {
+
+ return geometryInfoList[ a ].vertexStart - geometryInfoList[ b ].vertexStart;
+
+ } );
+
+ const geometry = this.geometry;
+ for ( let i = 0, l = geometryInfoList.length; i < l; i ++ ) {
+
+ // if a geometry range is inactive then don't copy anything
+ const index = indices[ i ];
+ const geometryInfo = geometryInfoList[ index ];
+ if ( geometryInfo.active === false ) {
+
+ continue;
+
+ }
+
+ // if a geometry contains an index buffer then shift it, as well
+ if ( geometry.index !== null ) {
+
+ if ( geometryInfo.indexStart !== nextIndexStart ) {
+
+ const { indexStart, vertexStart, reservedIndexCount } = geometryInfo;
+ const index = geometry.index;
+ const array = index.array;
+
+ // shift the index pointers based on how the vertex data will shift
+ // adjusting the index must happen first so the original vertex start value is available
+ const elementDelta = nextVertexStart - vertexStart;
+ for ( let j = indexStart; j < indexStart + reservedIndexCount; j ++ ) {
+
+ array[ j ] = array[ j ] + elementDelta;
+
+ }
+
+ index.array.copyWithin( nextIndexStart, indexStart, indexStart + reservedIndexCount );
+ index.addUpdateRange( nextIndexStart, reservedIndexCount );
+ index.needsUpdate = true;
+
+ geometryInfo.indexStart = nextIndexStart;
+
+ }
+
+ nextIndexStart += geometryInfo.reservedIndexCount;
+
+ }
+
+ // if a geometry needs to be moved then copy attribute data to overwrite unused space
+ if ( geometryInfo.vertexStart !== nextVertexStart ) {
+
+ const { vertexStart, reservedVertexCount } = geometryInfo;
+ const attributes = geometry.attributes;
+ for ( const key in attributes ) {
+
+ const attribute = attributes[ key ];
+ const { array, itemSize } = attribute;
+ array.copyWithin( nextVertexStart * itemSize, vertexStart * itemSize, ( vertexStart + reservedVertexCount ) * itemSize );
+ attribute.addUpdateRange( nextVertexStart * itemSize, reservedVertexCount * itemSize );
+ attribute.needsUpdate = true;
+
+ }
+
+ geometryInfo.vertexStart = nextVertexStart;
+
+ }
+
+ nextVertexStart += geometryInfo.reservedVertexCount;
+ geometryInfo.start = geometry.index ? geometryInfo.indexStart : geometryInfo.vertexStart;
+
+ }
+
+ this._nextIndexStart = nextIndexStart;
+ this._nextVertexStart = nextVertexStart;
+ this._visibilityChanged = true;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the bounding box for the given geometry.
+ *
+ * @param {number} geometryId - The ID of the geometry to return the bounding box for.
+ * @param {Box3} target - The target object that is used to store the method's result.
+ * @return {?Box3} The geometry's bounding box. Returns `null` if no geometry has been found for the given ID.
+ */
+ getBoundingBoxAt( geometryId, target ) {
+
+ if ( geometryId >= this._geometryCount ) {
+
+ return null;
+
+ }
+
+ // compute bounding box
+ const geometry = this.geometry;
+ const geometryInfo = this._geometryInfo[ geometryId ];
+ if ( geometryInfo.boundingBox === null ) {
+
+ const box = new Box3();
+ const index = geometry.index;
+ const position = geometry.attributes.position;
+ for ( let i = geometryInfo.start, l = geometryInfo.start + geometryInfo.count; i < l; i ++ ) {
+
+ let iv = i;
+ if ( index ) {
+
+ iv = index.getX( iv );
+
+ }
+
+ box.expandByPoint( _vector$5.fromBufferAttribute( position, iv ) );
+
+ }
+
+ geometryInfo.boundingBox = box;
+
+ }
+
+ target.copy( geometryInfo.boundingBox );
+ return target;
+
+ }
+
+ /**
+ * Returns the bounding sphere for the given geometry.
+ *
+ * @param {number} geometryId - The ID of the geometry to return the bounding sphere for.
+ * @param {Sphere} target - The target object that is used to store the method's result.
+ * @return {?Sphere} The geometry's bounding sphere. Returns `null` if no geometry has been found for the given ID.
+ */
+ getBoundingSphereAt( geometryId, target ) {
+
+ if ( geometryId >= this._geometryCount ) {
+
+ return null;
+
+ }
+
+ // compute bounding sphere
+ const geometry = this.geometry;
+ const geometryInfo = this._geometryInfo[ geometryId ];
+ if ( geometryInfo.boundingSphere === null ) {
+
+ const sphere = new Sphere();
+ this.getBoundingBoxAt( geometryId, _box$1 );
+ _box$1.getCenter( sphere.center );
+
+ const index = geometry.index;
+ const position = geometry.attributes.position;
+
+ let maxRadiusSq = 0;
+ for ( let i = geometryInfo.start, l = geometryInfo.start + geometryInfo.count; i < l; i ++ ) {
+
+ let iv = i;
+ if ( index ) {
+
+ iv = index.getX( iv );
+
+ }
+
+ _vector$5.fromBufferAttribute( position, iv );
+ maxRadiusSq = Math.max( maxRadiusSq, sphere.center.distanceToSquared( _vector$5 ) );
+
+ }
+
+ sphere.radius = Math.sqrt( maxRadiusSq );
+ geometryInfo.boundingSphere = sphere;
+
+ }
+
+ target.copy( geometryInfo.boundingSphere );
+ return target;
+
+ }
+
+ /**
+ * Sets the given local transformation matrix to the defined instance.
+ * Negatively scaled matrices are not supported.
+ *
+ * @param {number} instanceId - The ID of an instance to set the matrix of.
+ * @param {Matrix4} matrix - A 4x4 matrix representing the local transformation of a single instance.
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ setMatrixAt( instanceId, matrix ) {
+
+ this.validateInstanceId( instanceId );
+
+ const matricesTexture = this._matricesTexture;
+ const matricesArray = this._matricesTexture.image.data;
+ matrix.toArray( matricesArray, instanceId * 16 );
+ matricesTexture.needsUpdate = true;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the local transformation matrix of the defined instance.
+ *
+ * @param {number} instanceId - The ID of an instance to get the matrix of.
+ * @param {Matrix4} matrix - The target object that is used to store the method's result.
+ * @return {Matrix4} The instance's local transformation matrix.
+ */
+ getMatrixAt( instanceId, matrix ) {
+
+ this.validateInstanceId( instanceId );
+ return matrix.fromArray( this._matricesTexture.image.data, instanceId * 16 );
+
+ }
+
+ /**
+ * Sets the given color to the defined instance.
+ *
+ * @param {number} instanceId - The ID of an instance to set the color of.
+ * @param {Color|Vector4} color - The color to set the instance to. Use a `Vector4` to also define alpha.
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ setColorAt( instanceId, color ) {
+
+ this.validateInstanceId( instanceId );
+
+ if ( this._colorsTexture === null ) {
+
+ this._initColorsTexture();
+
+ }
+
+ color.toArray( this._colorsTexture.image.data, instanceId * 4 );
+ this._colorsTexture.needsUpdate = true;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the color of the defined instance.
+ *
+ * @param {number} instanceId - The ID of an instance to get the color of.
+ * @param {Color|Vector4} color - The target object that is used to store the method's result.
+ * @return {Color|Vector4} The instance's color. Use a `Vector4` to also retrieve alpha.
+ */
+ getColorAt( instanceId, color ) {
+
+ this.validateInstanceId( instanceId );
+ if ( this._colorsTexture === null ) {
+
+ if ( color.isVector4 ) {
+
+ return color.set( 1, 1, 1, 1 );
+
+ } else {
+
+ return color.setRGB( 1, 1, 1 );
+
+ }
+
+ } else {
+
+ return color.fromArray( this._colorsTexture.image.data, instanceId * 4 );
+
+ }
+
+ }
+
+ /**
+ * Sets the visibility of the instance.
+ *
+ * @param {number} instanceId - The id of the instance to set the visibility of.
+ * @param {boolean} visible - Whether the instance is visible or not.
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ setVisibleAt( instanceId, visible ) {
+
+ this.validateInstanceId( instanceId );
+
+ if ( this._instanceInfo[ instanceId ].visible === visible ) {
+
+ return this;
+
+ }
+
+ this._instanceInfo[ instanceId ].visible = visible;
+ this._visibilityChanged = true;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the visibility state of the defined instance.
+ *
+ * @param {number} instanceId - The ID of an instance to get the visibility state of.
+ * @return {boolean} Whether the instance is visible or not.
+ */
+ getVisibleAt( instanceId ) {
+
+ this.validateInstanceId( instanceId );
+
+ return this._instanceInfo[ instanceId ].visible;
+
+ }
+
+ /**
+ * Sets the geometry ID of the instance at the given index.
+ *
+ * @param {number} instanceId - The ID of the instance to set the geometry ID of.
+ * @param {number} geometryId - The geometry ID to be use by the instance.
+ * @return {BatchedMesh} A reference to this batched mesh.
+ */
+ setGeometryIdAt( instanceId, geometryId ) {
+
+ this.validateInstanceId( instanceId );
+ this.validateGeometryId( geometryId );
+
+ this._instanceInfo[ instanceId ].geometryIndex = geometryId;
+ this._visibilityChanged = true;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the geometry ID of the defined instance.
+ *
+ * @param {number} instanceId - The ID of an instance to get the geometry ID of.
+ * @return {number} The instance's geometry ID.
+ */
+ getGeometryIdAt( instanceId ) {
+
+ this.validateInstanceId( instanceId );
+
+ return this._instanceInfo[ instanceId ].geometryIndex;
+
+ }
+
+ /**
+ * Get the range representing the subset of triangles related to the attached geometry,
+ * indicating the starting offset and count, or `null` if invalid.
+ *
+ * @param {number} geometryId - The id of the geometry to get the range of.
+ * @param {Object} [target] - The target object that is used to store the method's result.
+ * @return {{
+ * vertexStart:number,vertexCount:number,reservedVertexCount:number,
+ * indexStart:number,indexCount:number,reservedIndexCount:number,
+ * start:number,count:number
+ * }} The result object with range data.
+ */
+ getGeometryRangeAt( geometryId, target = {} ) {
+
+ this.validateGeometryId( geometryId );
+
+ const geometryInfo = this._geometryInfo[ geometryId ];
+ target.vertexStart = geometryInfo.vertexStart;
+ target.vertexCount = geometryInfo.vertexCount;
+ target.reservedVertexCount = geometryInfo.reservedVertexCount;
+
+ target.indexStart = geometryInfo.indexStart;
+ target.indexCount = geometryInfo.indexCount;
+ target.reservedIndexCount = geometryInfo.reservedIndexCount;
+
+ target.start = geometryInfo.start;
+ target.count = geometryInfo.count;
+
+ return target;
+
+ }
+
+ /**
+ * Resizes the necessary buffers to support the provided number of instances.
+ * If the provided arguments shrink the number of instances but there are not enough
+ * unused Ids at the end of the list then an error is thrown.
+ *
+ * @param {number} maxInstanceCount - The max number of individual instances that can be added and rendered by the batch.
+ */
+ setInstanceCount( maxInstanceCount ) {
+
+ // shrink the available instances as much as possible
+ const availableInstanceIds = this._availableInstanceIds;
+ const instanceInfo = this._instanceInfo;
+ availableInstanceIds.sort( ascIdSort );
+ while ( availableInstanceIds[ availableInstanceIds.length - 1 ] === instanceInfo.length - 1 ) {
+
+ instanceInfo.pop();
+ availableInstanceIds.pop();
+
+ }
+
+ // throw an error if it can't be shrunk to the desired size
+ if ( maxInstanceCount < instanceInfo.length ) {
+
+ throw new Error( `THREE.BatchedMesh: Instance ids outside the range ${ maxInstanceCount } are being used. Cannot shrink instance count.` );
+
+ }
+
+ // copy the multi draw counts
+ const multiDrawCounts = new Int32Array( maxInstanceCount );
+ const multiDrawStarts = new Int32Array( maxInstanceCount );
+ copyArrayContents( this._multiDrawCounts, multiDrawCounts );
+ copyArrayContents( this._multiDrawStarts, multiDrawStarts );
+
+ this._multiDrawCounts = multiDrawCounts;
+ this._multiDrawStarts = multiDrawStarts;
+ this._maxInstanceCount = maxInstanceCount;
+
+ // update texture data for instance sampling
+ const indirectTexture = this._indirectTexture;
+ const matricesTexture = this._matricesTexture;
+ const colorsTexture = this._colorsTexture;
+
+ indirectTexture.dispose();
+ this._initIndirectTexture();
+ copyArrayContents( indirectTexture.image.data, this._indirectTexture.image.data );
+
+ matricesTexture.dispose();
+ this._initMatricesTexture();
+ copyArrayContents( matricesTexture.image.data, this._matricesTexture.image.data );
+
+ if ( colorsTexture ) {
+
+ colorsTexture.dispose();
+ this._initColorsTexture();
+ copyArrayContents( colorsTexture.image.data, this._colorsTexture.image.data );
+
+ }
+
+ }
+
+ /**
+ * Resizes the available space in the batch's vertex and index buffer attributes to the provided sizes.
+ * If the provided arguments shrink the geometry buffers but there is not enough unused space at the
+ * end of the geometry attributes then an error is thrown.
+ *
+ * @param {number} maxVertexCount - The maximum number of vertices to be used by all unique geometries to resize to.
+ * @param {number} maxIndexCount - The maximum number of indices to be used by all unique geometries to resize to.
+ */
+ setGeometrySize( maxVertexCount, maxIndexCount ) {
+
+ // Check if we can shrink to the requested vertex attribute size
+ const validRanges = [ ...this._geometryInfo ].filter( info => info.active );
+ const requiredVertexLength = Math.max( ...validRanges.map( range => range.vertexStart + range.reservedVertexCount ) );
+ if ( requiredVertexLength > maxVertexCount ) {
+
+ throw new Error( `THREE.BatchedMesh: Geometry vertex values are being used outside the range ${ maxIndexCount }. Cannot shrink further.` );
+
+ }
+
+ // Check if we can shrink to the requested index attribute size
+ if ( this.geometry.index ) {
+
+ const requiredIndexLength = Math.max( ...validRanges.map( range => range.indexStart + range.reservedIndexCount ) );
+ if ( requiredIndexLength > maxIndexCount ) {
+
+ throw new Error( `THREE.BatchedMesh: Geometry index values are being used outside the range ${ maxIndexCount }. Cannot shrink further.` );
+
+ }
+
+ }
+
+ //
+
+ // dispose of the previous geometry
+ const oldGeometry = this.geometry;
+ oldGeometry.dispose();
+
+ // recreate the geometry needed based on the previous variant
+ this._maxVertexCount = maxVertexCount;
+ this._maxIndexCount = maxIndexCount;
+
+ if ( this._geometryInitialized ) {
+
+ this._geometryInitialized = false;
+ this.geometry = new BufferGeometry();
+ this._initializeGeometry( oldGeometry );
+
+ }
+
+ // copy data from the previous geometry
+ const geometry = this.geometry;
+ if ( oldGeometry.index ) {
+
+ copyArrayContents( oldGeometry.index.array, geometry.index.array );
+
+ }
+
+ for ( const key in oldGeometry.attributes ) {
+
+ copyArrayContents( oldGeometry.attributes[ key ].array, geometry.attributes[ key ].array );
+
+ }
+
+ }
+
+ raycast( raycaster, intersects ) {
+
+ const instanceInfo = this._instanceInfo;
+ const geometryInfoList = this._geometryInfo;
+ const matrixWorld = this.matrixWorld;
+ const batchGeometry = this.geometry;
+
+ // iterate over each geometry
+ _mesh.material = this.material;
+ _mesh.geometry.index = batchGeometry.index;
+ _mesh.geometry.attributes = batchGeometry.attributes;
+ if ( _mesh.geometry.boundingBox === null ) {
+
+ _mesh.geometry.boundingBox = new Box3();
+
+ }
+
+ if ( _mesh.geometry.boundingSphere === null ) {
+
+ _mesh.geometry.boundingSphere = new Sphere();
+
+ }
+
+ for ( let i = 0, l = instanceInfo.length; i < l; i ++ ) {
+
+ if ( ! instanceInfo[ i ].visible || ! instanceInfo[ i ].active ) {
+
+ continue;
+
+ }
+
+ const geometryId = instanceInfo[ i ].geometryIndex;
+ const geometryInfo = geometryInfoList[ geometryId ];
+ _mesh.geometry.setDrawRange( geometryInfo.start, geometryInfo.count );
+
+ // get the intersects
+ this.getMatrixAt( i, _mesh.matrixWorld ).premultiply( matrixWorld );
+ this.getBoundingBoxAt( geometryId, _mesh.geometry.boundingBox );
+ this.getBoundingSphereAt( geometryId, _mesh.geometry.boundingSphere );
+ _mesh.raycast( raycaster, _batchIntersects );
+
+ // add batch id to the intersects
+ for ( let j = 0, l = _batchIntersects.length; j < l; j ++ ) {
+
+ const intersect = _batchIntersects[ j ];
+ intersect.object = this;
+ intersect.batchId = i;
+ intersects.push( intersect );
+
+ }
+
+ _batchIntersects.length = 0;
+
+ }
+
+ _mesh.material = null;
+ _mesh.geometry.index = null;
+ _mesh.geometry.attributes = {};
+ _mesh.geometry.setDrawRange( 0, Infinity );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.geometry = source.geometry.clone();
+ this.perObjectFrustumCulled = source.perObjectFrustumCulled;
+ this.sortObjects = source.sortObjects;
+ this.boundingBox = source.boundingBox !== null ? source.boundingBox.clone() : null;
+ this.boundingSphere = source.boundingSphere !== null ? source.boundingSphere.clone() : null;
+
+ this._geometryInfo = source._geometryInfo.map( info => ( {
+ ...info,
+
+ boundingBox: info.boundingBox !== null ? info.boundingBox.clone() : null,
+ boundingSphere: info.boundingSphere !== null ? info.boundingSphere.clone() : null,
+ } ) );
+ this._instanceInfo = source._instanceInfo.map( info => ( { ...info } ) );
+
+ this._availableInstanceIds = source._availableInstanceIds.slice();
+ this._availableGeometryIds = source._availableGeometryIds.slice();
+
+ this._nextIndexStart = source._nextIndexStart;
+ this._nextVertexStart = source._nextVertexStart;
+ this._geometryCount = source._geometryCount;
+
+ this._maxInstanceCount = source._maxInstanceCount;
+ this._maxVertexCount = source._maxVertexCount;
+ this._maxIndexCount = source._maxIndexCount;
+
+ this._geometryInitialized = source._geometryInitialized;
+ this._multiDrawCounts = source._multiDrawCounts.slice();
+ this._multiDrawStarts = source._multiDrawStarts.slice();
+ this._multiDrawBytesPerElement = source._multiDrawBytesPerElement;
+
+ this._indirectTexture = source._indirectTexture.clone();
+ this._indirectTexture.image.data = this._indirectTexture.image.data.slice();
+
+ this._matricesTexture = source._matricesTexture.clone();
+ this._matricesTexture.image.data = this._matricesTexture.image.data.slice();
+
+ if ( this._colorsTexture !== null ) {
+
+ this._colorsTexture = source._colorsTexture.clone();
+ this._colorsTexture.image.data = this._colorsTexture.image.data.slice();
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ */
+ dispose() {
+
+ super.dispose();
+
+ // Assuming the geometry is not shared with other meshes
+ this.geometry.dispose();
+
+ this._matricesTexture.dispose();
+ this._matricesTexture = null;
+
+ this._indirectTexture.dispose();
+ this._indirectTexture = null;
+
+ if ( this._colorsTexture !== null ) {
+
+ this._colorsTexture.dispose();
+ this._colorsTexture = null;
+
+ }
+
+ }
+
+ onBeforeRender( renderer, scene, camera, geometry, material/*, _group*/ ) {
+
+ // if visibility has not changed and frustum culling and object sorting is not required
+ // then skip iterating over all items
+ if ( ! this._visibilityChanged && ! this.perObjectFrustumCulled && ! this.sortObjects ) {
+
+ return;
+
+ }
+
+ // the indexed version of the multi draw function requires specifying the start
+ // offset in bytes.
+ const index = geometry.getIndex();
+ let bytesPerElement = index === null ? 1 : index.array.BYTES_PER_ELEMENT;
+
+
+ // the "wireframe" attribute implicitly creates a line attribute in the renderer, which is double
+ // the vertices to draw (3 lines per triangle) so we multiply the draw counts / starts and make
+ // assumptions about the index buffer byte size.
+ let multiDrawMultiplier = 1;
+ if ( material.wireframe ) {
+
+ multiDrawMultiplier = 2;
+ bytesPerElement = geometry.attributes.position.count > 65535 ? 4 : 2;
+
+ }
+
+ const instanceInfo = this._instanceInfo;
+ const multiDrawStarts = this._multiDrawStarts;
+ const multiDrawCounts = this._multiDrawCounts;
+ const geometryInfoList = this._geometryInfo;
+ const perObjectFrustumCulled = this.perObjectFrustumCulled;
+ const indirectTexture = this._indirectTexture;
+ const indirectArray = indirectTexture.image.data;
+
+ const frustum = camera.isArrayCamera ? _frustumArray : _frustum;
+ // prepare the frustum in the local frame
+ if ( perObjectFrustumCulled ) {
+
+ if ( camera.isArrayCamera ) {
+
+ frustum.setFromArrayCamera( camera );
+
+ } else {
+
+ _matrix$1
+ .multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse )
+ .multiply( this.matrixWorld );
+
+ frustum.setFromProjectionMatrix(
+ _matrix$1,
+ camera.coordinateSystem,
+ camera.reversedDepth
+ );
+
+ }
+
+ }
+
+ let multiDrawCount = 0;
+ if ( this.sortObjects ) {
+
+ // get the camera position in the local frame
+ _matrix$1.copy( this.matrixWorld ).invert();
+ _vector$5.setFromMatrixPosition( camera.matrixWorld ).applyMatrix4( _matrix$1 );
+ _forward$1.set( 0, 0, -1 ).transformDirection( camera.matrixWorld ).transformDirection( _matrix$1 );
+
+ for ( let i = 0, l = instanceInfo.length; i < l; i ++ ) {
+
+ if ( instanceInfo[ i ].visible && instanceInfo[ i ].active ) {
+
+ const geometryId = instanceInfo[ i ].geometryIndex;
+
+ // get the bounds in world space
+ this.getMatrixAt( i, _matrix$1 );
+ this.getBoundingSphereAt( geometryId, _sphere$2 ).applyMatrix4( _matrix$1 );
+
+ // determine whether the batched geometry is within the frustum
+ let culled = false;
+ if ( perObjectFrustumCulled ) {
+
+ culled = ! frustum.intersectsSphere( _sphere$2 );
+
+ }
+
+ if ( ! culled ) {
+
+ // get the distance from camera used for sorting
+ const geometryInfo = geometryInfoList[ geometryId ];
+ const z = _temp.subVectors( _sphere$2.center, _vector$5 ).dot( _forward$1 );
+ _renderList.push( geometryInfo.start, geometryInfo.count, z, i );
+
+ }
+
+ }
+
+ }
+
+ // Sort the draw ranges and prep for rendering
+ const list = _renderList.list;
+ const customSort = this.customSort;
+ if ( customSort === null ) {
+
+ list.sort( material.transparent ? sortTransparent : sortOpaque );
+
+ } else {
+
+ customSort.call( this, list, camera );
+
+ }
+
+ for ( let i = 0, l = list.length; i < l; i ++ ) {
+
+ const item = list[ i ];
+ multiDrawStarts[ multiDrawCount ] = item.start * bytesPerElement * multiDrawMultiplier;
+ multiDrawCounts[ multiDrawCount ] = item.count * multiDrawMultiplier;
+ indirectArray[ multiDrawCount ] = item.index;
+ multiDrawCount ++;
+
+ }
+
+ _renderList.reset();
+
+ } else {
+
+ for ( let i = 0, l = instanceInfo.length; i < l; i ++ ) {
+
+ if ( instanceInfo[ i ].visible && instanceInfo[ i ].active ) {
+
+ const geometryId = instanceInfo[ i ].geometryIndex;
+
+ // determine whether the batched geometry is within the frustum
+ let culled = false;
+ if ( perObjectFrustumCulled ) {
+
+ // get the bounds in world space
+ this.getMatrixAt( i, _matrix$1 );
+ this.getBoundingSphereAt( geometryId, _sphere$2 ).applyMatrix4( _matrix$1 );
+ culled = ! frustum.intersectsSphere( _sphere$2 );
+
+ }
+
+ if ( ! culled ) {
+
+ const geometryInfo = geometryInfoList[ geometryId ];
+ multiDrawStarts[ multiDrawCount ] = geometryInfo.start * bytesPerElement * multiDrawMultiplier;
+ multiDrawCounts[ multiDrawCount ] = geometryInfo.count * multiDrawMultiplier;
+ indirectArray[ multiDrawCount ] = i;
+ multiDrawCount ++;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ indirectTexture.needsUpdate = true;
+ this._multiDrawCount = multiDrawCount;
+ this._multiDrawBytesPerElement = bytesPerElement;
+ this._visibilityChanged = false;
+
+ }
+
+ onBeforeShadow( renderer, object, camera, shadowCamera, geometry, depthMaterial/* , group */ ) {
+
+ this.onBeforeRender( renderer, null, shadowCamera, geometry, depthMaterial );
+
+ }
+
+}
+
+/**
+ * A material for rendering line primitives.
+ *
+ * Materials define the appearance of renderable 3D objects.
+ *
+ * ```js
+ * const material = new THREE.LineBasicMaterial( { color: 0xffffff } );
+ * ```
+ *
+ * @augments Material
+ */
+class LineBasicMaterial extends Material {
+
+ /**
+ * Constructs a new line basic material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLineBasicMaterial = true;
+
+ this.type = 'LineBasicMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff );
+
+ /**
+ * Sets the color of the lines using data from a texture. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * Controls line thickness or lines.
+ *
+ * Can only be used with {@link SVGRenderer}. WebGL and WebGPU
+ * ignore this setting and always render line primitives with a
+ * width of one pixel.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.linewidth = 1;
+
+ /**
+ * Defines appearance of line ends.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('butt'|'round'|'square')}
+ * @default 'round'
+ */
+ this.linecap = 'round';
+
+ /**
+ * Defines appearance of line joints.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.linejoin = 'round';
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+
+ this.map = source.map;
+
+ this.linewidth = source.linewidth;
+ this.linecap = source.linecap;
+ this.linejoin = source.linejoin;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+const _vStart = /*@__PURE__*/ new Vector3();
+const _vEnd = /*@__PURE__*/ new Vector3();
+
+const _inverseMatrix$1 = /*@__PURE__*/ new Matrix4();
+const _ray$1 = /*@__PURE__*/ new Ray();
+const _sphere$1 = /*@__PURE__*/ new Sphere();
+
+const _intersectPointOnRay = /*@__PURE__*/ new Vector3();
+const _intersectPointOnSegment = /*@__PURE__*/ new Vector3();
+
+/**
+ * A continuous line. The line are rendered by connecting consecutive
+ * vertices with straight lines.
+ *
+ * ```js
+ * const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );
+ *
+ * const points = [];
+ * points.push( new THREE.Vector3( - 10, 0, 0 ) );
+ * points.push( new THREE.Vector3( 0, 10, 0 ) );
+ * points.push( new THREE.Vector3( 10, 0, 0 ) );
+ *
+ * const geometry = new THREE.BufferGeometry().setFromPoints( points );
+ *
+ * const line = new THREE.Line( geometry, material );
+ * scene.add( line );
+ * ```
+ *
+ * @augments Object3D
+ */
+class Line extends Object3D {
+
+ /**
+ * Constructs a new line.
+ *
+ * @param {BufferGeometry} [geometry] - The line geometry.
+ * @param {Material|Array} [material] - The line material.
+ */
+ constructor( geometry = new BufferGeometry(), material = new LineBasicMaterial() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLine = true;
+
+ this.type = 'Line';
+
+ /**
+ * The line geometry.
+ *
+ * @type {BufferGeometry}
+ */
+ this.geometry = geometry;
+
+ /**
+ * The line material.
+ *
+ * @type {Material|Array}
+ * @default LineBasicMaterial
+ */
+ this.material = material;
+
+ /**
+ * A dictionary representing the morph targets in the geometry. The key is the
+ * morph targets name, the value its attribute index. This member is `undefined`
+ * by default and only set when morph targets are detected in the geometry.
+ *
+ * @type {Object|undefined}
+ * @default undefined
+ */
+ this.morphTargetDictionary = undefined;
+
+ /**
+ * An array of weights typically in the range `[0,1]` that specify how much of the morph
+ * is applied. This member is `undefined` by default and only set when morph targets are
+ * detected in the geometry.
+ *
+ * @type {Array|undefined}
+ * @default undefined
+ */
+ this.morphTargetInfluences = undefined;
+
+ this.updateMorphTargets();
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.material = Array.isArray( source.material ) ? source.material.slice() : source.material;
+ this.geometry = source.geometry;
+
+ return this;
+
+ }
+
+ /**
+ * Computes an array of distance values which are necessary for rendering dashed lines.
+ * For each vertex in the geometry, the method calculates the cumulative length from the
+ * current point to the very beginning of the line.
+ *
+ * @return {Line} A reference to this line.
+ */
+ computeLineDistances() {
+
+ const geometry = this.geometry;
+
+ // we assume non-indexed geometry
+
+ if ( geometry.index === null ) {
+
+ const positionAttribute = geometry.attributes.position;
+ const lineDistances = [ 0 ];
+
+ for ( let i = 1, l = positionAttribute.count; i < l; i ++ ) {
+
+ _vStart.fromBufferAttribute( positionAttribute, i - 1 );
+ _vEnd.fromBufferAttribute( positionAttribute, i );
+
+ lineDistances[ i ] = lineDistances[ i - 1 ];
+ lineDistances[ i ] += _vStart.distanceTo( _vEnd );
+
+ }
+
+ geometry.setAttribute( 'lineDistance', new Float32BufferAttribute( lineDistances, 1 ) );
+
+ } else {
+
+ warn( 'Line.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.' );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this line intersects the given frustum.
+ *
+ * @param {Frustum|FrustumArray} frustum - The frustum to test.
+ * @return {boolean} Whether this line intersects the given frustum or not.
+ */
+ intersectsFrustum( frustum ) {
+
+ return frustum.intersectsObject( this );
+
+ }
+
+ /**
+ * Computes intersection points between a casted ray and this line.
+ *
+ * @param {Raycaster} raycaster - The raycaster.
+ * @param {Array} intersects - The target array that holds the intersection points.
+ */
+ raycast( raycaster, intersects ) {
+
+ const geometry = this.geometry;
+ const matrixWorld = this.matrixWorld;
+ const threshold = raycaster.params.Line.threshold;
+ const drawRange = geometry.drawRange;
+
+ // Checking boundingSphere distance to ray
+
+ if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();
+
+ _sphere$1.copy( geometry.boundingSphere );
+ _sphere$1.applyMatrix4( matrixWorld );
+ _sphere$1.radius += threshold;
+
+ if ( raycaster.ray.intersectsSphere( _sphere$1 ) === false ) return;
+
+ //
+
+ _inverseMatrix$1.copy( matrixWorld ).invert();
+ _ray$1.copy( raycaster.ray ).applyMatrix4( _inverseMatrix$1 );
+
+ const localThreshold = threshold / ( ( this.scale.x + this.scale.y + this.scale.z ) / 3 );
+ const localThresholdSq = localThreshold * localThreshold;
+
+ const step = this.isLineSegments ? 2 : 1;
+
+ const index = geometry.index;
+ const attributes = geometry.attributes;
+ const positionAttribute = attributes.position;
+
+ if ( index !== null ) {
+
+ const start = Math.max( 0, drawRange.start );
+ const end = Math.min( index.count, ( drawRange.start + drawRange.count ) );
+
+ for ( let i = start, l = end - 1; i < l; i += step ) {
+
+ const a = index.getX( i );
+ const b = index.getX( i + 1 );
+
+ const intersect = checkIntersection( this, raycaster, _ray$1, localThresholdSq, a, b, i );
+
+ if ( intersect ) {
+
+ intersects.push( intersect );
+
+ }
+
+ }
+
+ if ( this.isLineLoop ) {
+
+ const a = index.getX( end - 1 );
+ const b = index.getX( start );
+
+ const intersect = checkIntersection( this, raycaster, _ray$1, localThresholdSq, a, b, end - 1 );
+
+ if ( intersect ) {
+
+ intersects.push( intersect );
+
+ }
+
+ }
+
+ } else {
+
+ const start = Math.max( 0, drawRange.start );
+ const end = Math.min( positionAttribute.count, ( drawRange.start + drawRange.count ) );
+
+ for ( let i = start, l = end - 1; i < l; i += step ) {
+
+ const intersect = checkIntersection( this, raycaster, _ray$1, localThresholdSq, i, i + 1, i );
+
+ if ( intersect ) {
+
+ intersects.push( intersect );
+
+ }
+
+ }
+
+ if ( this.isLineLoop ) {
+
+ const intersect = checkIntersection( this, raycaster, _ray$1, localThresholdSq, end - 1, start, end - 1 );
+
+ if ( intersect ) {
+
+ intersects.push( intersect );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Sets the values of {@link Line#morphTargetDictionary} and {@link Line#morphTargetInfluences}
+ * to make sure existing morph targets can influence this 3D object.
+ */
+ updateMorphTargets() {
+
+ const geometry = this.geometry;
+
+ const morphAttributes = geometry.morphAttributes;
+ const keys = Object.keys( morphAttributes );
+
+ if ( keys.length > 0 ) {
+
+ const morphAttribute = morphAttributes[ keys[ 0 ] ];
+
+ if ( morphAttribute !== undefined ) {
+
+ this.morphTargetInfluences = [];
+ this.morphTargetDictionary = {};
+
+ for ( let m = 0, ml = morphAttribute.length; m < ml; m ++ ) {
+
+ const name = morphAttribute[ m ].name || String( m );
+
+ this.morphTargetInfluences.push( 0 );
+ this.morphTargetDictionary[ name ] = m;
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
+
+function checkIntersection( object, raycaster, ray, thresholdSq, a, b, i ) {
+
+ const positionAttribute = object.geometry.attributes.position;
+
+ _vStart.fromBufferAttribute( positionAttribute, a );
+ _vEnd.fromBufferAttribute( positionAttribute, b );
+
+ const distSq = ray.distanceSqToSegment( _vStart, _vEnd, _intersectPointOnRay, _intersectPointOnSegment );
+
+ if ( distSq > thresholdSq ) return;
+
+ _intersectPointOnRay.applyMatrix4( object.matrixWorld ); // Move back to world space for distance calculation
+
+ const distance = raycaster.ray.origin.distanceTo( _intersectPointOnRay );
+
+ if ( distance < raycaster.near || distance > raycaster.far ) return;
+
+ return {
+
+ distance: distance,
+ // What do we want? intersection point on the ray or on the segment??
+ // point: raycaster.ray.at( distance ),
+ point: _intersectPointOnSegment.clone().applyMatrix4( object.matrixWorld ),
+ index: i,
+ face: null,
+ faceIndex: null,
+ barycoord: null,
+ object: object
+
+ };
+
+}
+
+const _start = /*@__PURE__*/ new Vector3();
+const _end = /*@__PURE__*/ new Vector3();
+
+/**
+ * A series of lines drawn between pairs of vertices.
+ *
+ * @augments Line
+ */
+class LineSegments extends Line {
+
+ /**
+ * Constructs a new line segments.
+ *
+ * @param {BufferGeometry} [geometry] - The line geometry.
+ * @param {Material|Array} [material] - The line material.
+ */
+ constructor( geometry, material ) {
+
+ super( geometry, material );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLineSegments = true;
+
+ this.type = 'LineSegments';
+
+ }
+
+ computeLineDistances() {
+
+ const geometry = this.geometry;
+
+ // we assume non-indexed geometry
+
+ if ( geometry.index === null ) {
+
+ const positionAttribute = geometry.attributes.position;
+ const lineDistances = [];
+
+ for ( let i = 0, l = positionAttribute.count; i < l; i += 2 ) {
+
+ _start.fromBufferAttribute( positionAttribute, i );
+ _end.fromBufferAttribute( positionAttribute, i + 1 );
+
+ lineDistances[ i ] = ( i === 0 ) ? 0 : lineDistances[ i - 1 ];
+ lineDistances[ i + 1 ] = lineDistances[ i ] + _start.distanceTo( _end );
+
+ }
+
+ geometry.setAttribute( 'lineDistance', new Float32BufferAttribute( lineDistances, 1 ) );
+
+ } else {
+
+ warn( 'LineSegments.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.' );
+
+ }
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A continuous line. This is nearly the same as {@link Line} the only difference
+ * is that the last vertex is connected with the first vertex in order to close
+ * the line to form a loop.
+ *
+ * @augments Line
+ */
+class LineLoop extends Line {
+
+ /**
+ * Constructs a new line loop.
+ *
+ * @param {BufferGeometry} [geometry] - The line geometry.
+ * @param {Material|Array} [material] - The line material.
+ */
+ constructor( geometry, material ) {
+
+ super( geometry, material );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLineLoop = true;
+
+ this.type = 'LineLoop';
+
+ }
+
+}
+
+/**
+ * A material for rendering point primitives.
+ *
+ * Materials define the appearance of renderable 3D objects.
+ *
+ * ```js
+ * const vertices = [];
+ *
+ * for ( let i = 0; i < 10000; i ++ ) {
+ * const x = THREE.MathUtils.randFloatSpread( 2000 );
+ * const y = THREE.MathUtils.randFloatSpread( 2000 );
+ * const z = THREE.MathUtils.randFloatSpread( 2000 );
+ *
+ * vertices.push( x, y, z );
+ * }
+ *
+ * const geometry = new THREE.BufferGeometry();
+ * geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
+ * const material = new THREE.PointsMaterial( { color: 0x888888 } );
+ * const points = new THREE.Points( geometry, material );
+ * scene.add( points );
+ * ```
+ *
+ * @augments Material
+ */
+class PointsMaterial extends Material {
+
+ /**
+ * Constructs a new points material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isPointsMaterial = true;
+
+ this.type = 'PointsMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff );
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * Defines the size of the points in pixels.
+ *
+ * Might be capped if the value exceeds hardware dependent parameters like [gl.ALIASED_POINT_SIZE_RANGE](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/getParamete).
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.size = 1;
+
+ /**
+ * Specifies whether size of individual points is attenuated by the camera depth (perspective camera only).
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.sizeAttenuation = true;
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+
+ this.map = source.map;
+
+ this.alphaMap = source.alphaMap;
+
+ this.size = source.size;
+ this.sizeAttenuation = source.sizeAttenuation;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+const _inverseMatrix = /*@__PURE__*/ new Matrix4();
+const _ray = /*@__PURE__*/ new Ray();
+const _sphere = /*@__PURE__*/ new Sphere();
+const _position$3 = /*@__PURE__*/ new Vector3();
+
+/**
+ * A class for displaying points or point clouds.
+ *
+ * @augments Object3D
+ */
+class Points extends Object3D {
+
+ /**
+ * Constructs a new point cloud.
+ *
+ * @param {BufferGeometry} [geometry] - The points geometry.
+ * @param {Material|Array} [material] - The points material.
+ */
+ constructor( geometry = new BufferGeometry(), material = new PointsMaterial() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isPoints = true;
+
+ this.type = 'Points';
+
+ /**
+ * The points geometry.
+ *
+ * @type {BufferGeometry}
+ */
+ this.geometry = geometry;
+
+ /**
+ * The line material.
+ *
+ * @type {Material|Array}
+ * @default PointsMaterial
+ */
+ this.material = material;
+
+ /**
+ * A dictionary representing the morph targets in the geometry. The key is the
+ * morph targets name, the value its attribute index. This member is `undefined`
+ * by default and only set when morph targets are detected in the geometry.
+ *
+ * @type {Object|undefined}
+ * @default undefined
+ */
+ this.morphTargetDictionary = undefined;
+
+ /**
+ * An array of weights typically in the range `[0,1]` that specify how much of the morph
+ * is applied. This member is `undefined` by default and only set when morph targets are
+ * detected in the geometry.
+ *
+ * @type {Array|undefined}
+ * @default undefined
+ */
+ this.morphTargetInfluences = undefined;
+
+ this.updateMorphTargets();
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.material = Array.isArray( source.material ) ? source.material.slice() : source.material;
+ this.geometry = source.geometry;
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this point cloud intersects the given frustum.
+ *
+ * @param {Frustum|FrustumArray} frustum - The frustum to test.
+ * @return {boolean} Whether this point cloud intersects the given frustum or not.
+ */
+ intersectsFrustum( frustum ) {
+
+ return frustum.intersectsObject( this );
+
+ }
+
+ /**
+ * Computes intersection points between a casted ray and this point cloud.
+ *
+ * @param {Raycaster} raycaster - The raycaster.
+ * @param {Array} intersects - The target array that holds the intersection points.
+ */
+ raycast( raycaster, intersects ) {
+
+ const geometry = this.geometry;
+ const matrixWorld = this.matrixWorld;
+ const threshold = raycaster.params.Points.threshold;
+ const drawRange = geometry.drawRange;
+
+ // Checking boundingSphere distance to ray
+
+ if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();
+
+ _sphere.copy( geometry.boundingSphere );
+ _sphere.applyMatrix4( matrixWorld );
+ _sphere.radius += threshold;
+
+ if ( raycaster.ray.intersectsSphere( _sphere ) === false ) return;
+
+ //
+
+ _inverseMatrix.copy( matrixWorld ).invert();
+ _ray.copy( raycaster.ray ).applyMatrix4( _inverseMatrix );
+
+ const localThreshold = threshold / ( ( this.scale.x + this.scale.y + this.scale.z ) / 3 );
+ const localThresholdSq = localThreshold * localThreshold;
+
+ const index = geometry.index;
+ const attributes = geometry.attributes;
+ const positionAttribute = attributes.position;
+
+ if ( index !== null ) {
+
+ const start = Math.max( 0, drawRange.start );
+ const end = Math.min( index.count, ( drawRange.start + drawRange.count ) );
+
+ for ( let i = start, il = end; i < il; i ++ ) {
+
+ const a = index.getX( i );
+
+ _position$3.fromBufferAttribute( positionAttribute, a );
+
+ testPoint( _position$3, a, localThresholdSq, matrixWorld, raycaster, intersects, this );
+
+ }
+
+ } else {
+
+ const start = Math.max( 0, drawRange.start );
+ const end = Math.min( positionAttribute.count, ( drawRange.start + drawRange.count ) );
+
+ for ( let i = start, l = end; i < l; i ++ ) {
+
+ _position$3.fromBufferAttribute( positionAttribute, i );
+
+ testPoint( _position$3, i, localThresholdSq, matrixWorld, raycaster, intersects, this );
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Sets the values of {@link Points#morphTargetDictionary} and {@link Points#morphTargetInfluences}
+ * to make sure existing morph targets can influence this 3D object.
+ */
+ updateMorphTargets() {
+
+ const geometry = this.geometry;
+
+ const morphAttributes = geometry.morphAttributes;
+ const keys = Object.keys( morphAttributes );
+
+ if ( keys.length > 0 ) {
+
+ const morphAttribute = morphAttributes[ keys[ 0 ] ];
+
+ if ( morphAttribute !== undefined ) {
+
+ this.morphTargetInfluences = [];
+ this.morphTargetDictionary = {};
+
+ for ( let m = 0, ml = morphAttribute.length; m < ml; m ++ ) {
+
+ const name = morphAttribute[ m ].name || String( m );
+
+ this.morphTargetInfluences.push( 0 );
+ this.morphTargetDictionary[ name ] = m;
+
+ }
+
+ }
+
+ }
+
+ }
+
+}
+
+function testPoint( point, index, localThresholdSq, matrixWorld, raycaster, intersects, object ) {
+
+ const rayPointDistanceSq = _ray.distanceSqToPoint( point );
+
+ if ( rayPointDistanceSq < localThresholdSq ) {
+
+ const intersectPoint = new Vector3();
+
+ _ray.closestPointToPoint( point, intersectPoint );
+ intersectPoint.applyMatrix4( matrixWorld );
+
+ const distance = raycaster.ray.origin.distanceTo( intersectPoint );
+
+ if ( distance < raycaster.near || distance > raycaster.far ) return;
+
+ intersects.push( {
+
+ distance: distance,
+ distanceToRay: Math.sqrt( rayPointDistanceSq ),
+ point: intersectPoint,
+ index: index,
+ face: null,
+ faceIndex: null,
+ barycoord: null,
+ object: object
+
+ } );
+
+ }
+
+}
+
+/**
+ * A texture for use with a video.
+ *
+ * ```js
+ * // assuming you have created a HTML video element with id="video"
+ * const video = document.getElementById( 'video' );
+ * const texture = new THREE.VideoTexture( video );
+ * ```
+ *
+ * Note: When using video textures with {@link WebGPURenderer}, {@link Texture#colorSpace} must be
+ * set to THREE.SRGBColorSpace.
+ *
+ * Note: After the initial use of a texture, its dimensions, format, and type
+ * cannot be changed. Instead, call {@link Texture#dispose} on the texture and instantiate a new one.
+ *
+ * @augments Texture
+ */
+class VideoTexture extends Texture {
+
+ /**
+ * Constructs a new video texture.
+ *
+ * @param {HTMLVideoElement} video - The video element to use as a data source for the texture.
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearFilter] - The min filter value.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ */
+ constructor( video, mapping, wrapS, wrapT, magFilter = LinearFilter, minFilter = LinearFilter, format, type, anisotropy ) {
+
+ super( video, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isVideoTexture = true;
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.generateMipmaps = false;
+
+ /**
+ * The video frame request callback identifier, which is a positive integer.
+ *
+ * Value of 0 represents no scheduled rVFC.
+ *
+ * @private
+ * @type {number}
+ */
+ this._requestVideoFrameCallbackId = 0;
+
+ const scope = this;
+
+ function updateVideo() {
+
+ scope.needsUpdate = true;
+ scope._requestVideoFrameCallbackId = video.requestVideoFrameCallback( updateVideo );
+
+ }
+
+ if ( 'requestVideoFrameCallback' in video ) {
+
+ this._requestVideoFrameCallbackId = video.requestVideoFrameCallback( updateVideo );
+
+ }
+
+ }
+
+ clone() {
+
+ return new this.constructor( this.image ).copy( this );
+
+ }
+
+ /**
+ * This method is called automatically by the renderer and sets {@link Texture#needsUpdate}
+ * to `true` every time a new frame is available.
+ *
+ * Only relevant if `requestVideoFrameCallback` is not supported in the browser.
+ */
+ update() {
+
+ const video = this.image;
+ const hasVideoFrameCallback = 'requestVideoFrameCallback' in video;
+
+ if ( hasVideoFrameCallback === false && video.readyState >= video.HAVE_CURRENT_DATA ) {
+
+ this.needsUpdate = true;
+
+ }
+
+ }
+
+ dispose() {
+
+ if ( this._requestVideoFrameCallbackId !== 0 ) {
+
+ this.source.data.cancelVideoFrameCallback( this._requestVideoFrameCallbackId );
+
+ this._requestVideoFrameCallbackId = 0;
+
+ }
+
+ super.dispose();
+
+ }
+
+}
+
+/**
+ * This class can be used as an alternative way to define video data. Instead of using
+ * an instance of `HTMLVideoElement` like with `VideoTexture`, `VideoFrameTexture` expects each frame is
+ * defined manually via {@link VideoFrameTexture#setFrame}. A typical use case for this module is when
+ * video frames are decoded with the WebCodecs API.
+ *
+ * ```js
+ * const texture = new THREE.VideoFrameTexture();
+ * texture.setFrame( frame );
+ * ```
+ *
+ * @augments VideoTexture
+ */
+class VideoFrameTexture extends VideoTexture {
+
+ /**
+ * Constructs a new video frame texture.
+ *
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearFilter] - The min filter value.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ */
+ constructor( mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ) {
+
+ super( {}, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isVideoFrameTexture = true;
+
+ }
+
+ /**
+ * This method overwritten with an empty implementation since
+ * this type of texture is updated via `setFrame()`.
+ */
+ update() {}
+
+ clone() {
+
+ return new this.constructor().copy( this ); // restoring Texture.clone()
+
+ }
+
+ /**
+ * Sets the current frame of the video. This will automatically update the texture
+ * so the data can be used for rendering.
+ *
+ * @param {VideoFrame} frame - The video frame.
+ */
+ setFrame( frame ) {
+
+ this.image = frame;
+ this.needsUpdate = true;
+
+ }
+
+}
+
+/**
+ * This class can only be used in combination with `copyFramebufferToTexture()` methods
+ * of renderers. It extracts the contents of the current bound framebuffer and provides it
+ * as a texture for further usage.
+ *
+ * ```js
+ * const pixelRatio = window.devicePixelRatio;
+ * const textureSize = 128 * pixelRatio;
+ *
+ * const frameTexture = new FramebufferTexture( textureSize, textureSize );
+ *
+ * // calculate start position for copying part of the frame data
+ * const vector = new Vector2();
+ * vector.x = ( window.innerWidth * pixelRatio / 2 ) - ( textureSize / 2 );
+ * vector.y = ( window.innerHeight * pixelRatio / 2 ) - ( textureSize / 2 );
+ *
+ * renderer.render( scene, camera );
+ *
+ * // copy part of the rendered frame into the framebuffer texture
+ * renderer.copyFramebufferToTexture( frameTexture, vector );
+ * ```
+ *
+ * @augments Texture
+ */
+class FramebufferTexture extends Texture {
+
+ /**
+ * Constructs a new framebuffer texture.
+ *
+ * @param {number} [width] - The width of the texture.
+ * @param {number} [height] - The height of the texture.
+ */
+ constructor( width, height ) {
+
+ super( { width, height } );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isFramebufferTexture = true;
+
+ /**
+ * How the texture is sampled when a texel covers more than one pixel.
+ *
+ * Overwritten and set to `NearestFilter` by default to disable filtering.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default NearestFilter
+ */
+ this.magFilter = NearestFilter;
+
+ /**
+ * How the texture is sampled when a texel covers less than one pixel.
+ *
+ * Overwritten and set to `NearestFilter` by default to disable filtering.
+ *
+ * @type {(NearestFilter|NearestMipmapNearestFilter|NearestMipmapLinearFilter|LinearFilter|LinearMipmapNearestFilter|LinearMipmapLinearFilter)}
+ * @default NearestFilter
+ */
+ this.minFilter = NearestFilter;
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.generateMipmaps = false;
+
+ this.needsUpdate = true;
+
+ }
+
+}
+
+/**
+ * Creates a texture based on data in compressed form.
+ *
+ * These texture are usually loaded with {@link CompressedTextureLoader}.
+ *
+ * @augments Texture
+ */
+class CompressedTexture extends Texture {
+
+ /**
+ * Constructs a new compressed texture.
+ *
+ * @param {Array} mipmaps - This array holds for all mipmaps (including the bases mip)
+ * the data and dimensions.
+ * @param {number} width - The width of the texture.
+ * @param {number} height - The height of the texture.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearMipmapLinearFilter] - The min filter value.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ * @param {string} [colorSpace=NoColorSpace] - The color space.
+ */
+ constructor( mipmaps, width, height, format, type, mapping, wrapS, wrapT, magFilter, minFilter, anisotropy, colorSpace ) {
+
+ super( null, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, colorSpace );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCompressedTexture = true;
+
+ /**
+ * The image property of a compressed texture just defines its dimensions.
+ *
+ * @type {{width:number,height:number}}
+ */
+ this.image = { width: width, height: height };
+
+ /**
+ * This array holds for all mipmaps (including the bases mip) the data and dimensions.
+ *
+ * @type {Array}
+ */
+ this.mipmaps = mipmaps;
+
+ /**
+ * If set to `true`, the texture is flipped along the vertical axis when
+ * uploaded to the GPU.
+ *
+ * Overwritten and set to `false` by default since it is not possible to
+ * flip compressed textures.
+ *
+ * @type {boolean}
+ * @default false
+ * @readonly
+ */
+ this.flipY = false;
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Overwritten and set to `false` by default since it is not
+ * possible to generate mipmaps for compressed data. Mipmaps
+ * must be embedded in the compressed texture file.
+ *
+ * @type {boolean}
+ * @default false
+ * @readonly
+ */
+ this.generateMipmaps = false;
+
+ }
+
+}
+
+/**
+ * Creates a texture 2D array based on data in compressed form.
+ *
+ * These texture are usually loaded with {@link CompressedTextureLoader}.
+ *
+ * @augments CompressedTexture
+ */
+class CompressedArrayTexture extends CompressedTexture {
+
+ /**
+ * Constructs a new compressed array texture.
+ *
+ * @param {Array} mipmaps - This array holds for all mipmaps (including the bases mip)
+ * the data and dimensions.
+ * @param {number} width - The width of the texture.
+ * @param {number} height - The height of the texture.
+ * @param {number} depth - The depth of the texture.
+ * @param {number} [format=RGBAFormat] - The min filter value.
+ * @param {number} [type=UnsignedByteType] - The min filter value.
+ */
+ constructor( mipmaps, width, height, depth, format, type ) {
+
+ super( mipmaps, width, height, format, type );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCompressedArrayTexture = true;
+
+ /**
+ * The image property of a compressed texture just defines its dimensions.
+ *
+ * @name CompressedArrayTexture#image
+ * @type {{width:number,height:number,depth:number}}
+ */
+ this.image.depth = depth;
+
+ /**
+ * This defines how the texture is wrapped in the depth and corresponds to
+ * *W* in UVW mapping.
+ *
+ * @type {(RepeatWrapping|ClampToEdgeWrapping|MirroredRepeatWrapping)}
+ * @default ClampToEdgeWrapping
+ */
+ this.wrapR = ClampToEdgeWrapping;
+
+ /**
+ * A set of all layers which need to be updated in the texture.
+ *
+ * @type {Set}
+ */
+ this.layerUpdates = new Set();
+
+ }
+
+ /**
+ * Copies the values of the given texture to this instance.
+ *
+ * @param {CompressedArrayTexture} source - The texture to copy.
+ * @return {CompressedArrayTexture} A reference to this instance.
+ */
+ copy( source ) {
+
+ super.copy( source );
+
+ this.wrapR = source.wrapR;
+
+ return this;
+
+ }
+
+ /**
+ * Describes that a specific layer of the texture needs to be updated.
+ * Normally when {@link Texture#needsUpdate} is set to `true`, the
+ * entire compressed texture array is sent to the GPU. Marking specific
+ * layers will only transmit subsets of all mipmaps associated with a
+ * specific depth in the array which is often much more performant.
+ *
+ * @param {number} layerIndex - The layer index that should be updated.
+ */
+ addLayerUpdate( layerIndex ) {
+
+ this.layerUpdates.add( layerIndex );
+
+ }
+
+ /**
+ * Resets the layer updates registry.
+ */
+ clearLayerUpdates() {
+
+ this.layerUpdates.clear();
+
+ }
+
+}
+
+/**
+ * Creates a cube texture based on data in compressed form.
+ *
+ * These texture are usually loaded with {@link CompressedTextureLoader}.
+ *
+ * @augments CompressedTexture
+ */
+class CompressedCubeTexture extends CompressedTexture {
+
+ /**
+ * Constructs a new compressed texture.
+ *
+ * @param {Array} images - An array of compressed textures.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ */
+ constructor( images, format, type ) {
+
+ super( undefined, images[ 0 ].width, images[ 0 ].height, format, type, CubeReflectionMapping );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCompressedCubeTexture = true;
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCubeTexture = true;
+
+ this.image = images;
+
+ }
+
+}
+
+/**
+ * Creates a cube texture made up of six images.
+ *
+ * ```js
+ * const loader = new THREE.CubeTextureLoader();
+ * loader.setPath( 'textures/cube/pisa/' );
+ *
+ * const textureCube = loader.load( [
+ * 'px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png'
+ * ] );
+ *
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffffff, envMap: textureCube } );
+ * ```
+ *
+ * @augments Texture
+ */
+class CubeTexture extends Texture {
+
+ /**
+ * Constructs a new cube texture.
+ *
+ * @param {Array} [images=[]] - An array holding a image for each side of a cube.
+ * @param {number} [mapping=CubeReflectionMapping] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearMipmapLinearFilter] - The min filter value.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ * @param {string} [colorSpace=NoColorSpace] - The color space value.
+ */
+ constructor( images = [], mapping = CubeReflectionMapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, colorSpace ) {
+
+ super( images, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, colorSpace );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCubeTexture = true;
+
+ /**
+ * If set to `true`, the texture is flipped along the vertical axis when
+ * uploaded to the GPU.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flipY = false;
+
+ }
+
+ /**
+ * Alias for {@link CubeTexture#image}.
+ *
+ * @type {Array}
+ */
+ get images() {
+
+ return this.image;
+
+ }
+
+ set images( value ) {
+
+ this.image = value;
+
+ }
+
+}
+
+/**
+ * Creates a texture from a canvas element.
+ *
+ * This is almost the same as the base texture class, except that it sets {@link Texture#needsUpdate}
+ * to `true` immediately since a canvas can directly be used for rendering.
+ *
+ * @augments Texture
+ */
+class CanvasTexture extends Texture {
+
+ /**
+ * Constructs a new texture.
+ *
+ * @param {HTMLCanvasElement} [canvas] - The HTML canvas element.
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearMipmapLinearFilter] - The min filter value.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ */
+ constructor( canvas, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ) {
+
+ super( canvas, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCanvasTexture = true;
+
+ this.needsUpdate = true;
+
+ }
+
+}
+
+/**
+ * Creates a texture from an HTML element.
+ *
+ * This is almost the same as the base texture class, except that it sets {@link Texture#needsUpdate}
+ * to `true` immediately and listens for the parent canvas's paint events to trigger updates.
+ *
+ * @augments Texture
+ */
+class HTMLTexture extends Texture {
+
+ /**
+ * Constructs a new texture.
+ *
+ * @param {HTMLElement} [element] - The HTML element.
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearMipmapLinearFilter] - The min filter value.
+ * @param {number} [format=RGBAFormat] - The texture format.
+ * @param {number} [type=UnsignedByteType] - The texture type.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ */
+ constructor( element, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ) {
+
+ super( element, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isHTMLTexture = true;
+ this.generateMipmaps = false;
+
+ this.needsUpdate = true;
+
+ const parent = element ? element.parentNode : null;
+
+ if ( parent !== null && 'requestPaint' in parent ) {
+
+ parent.onpaint = () => {
+
+ this.needsUpdate = true;
+
+ };
+
+ parent.requestPaint();
+
+ }
+
+ }
+
+ dispose() {
+
+ const parent = this.image ? this.image.parentNode : null;
+
+ if ( parent !== null && 'onpaint' in parent ) {
+
+ parent.onpaint = null;
+
+ }
+
+ super.dispose();
+
+ }
+
+}
+
+/**
+ * This class can be used to automatically save the depth information of a
+ * rendering into a texture.
+ *
+ * @augments Texture
+ */
+class DepthTexture extends Texture {
+
+ /**
+ * Constructs a new depth texture.
+ *
+ * @param {number} width - The width of the texture.
+ * @param {number} height - The height of the texture.
+ * @param {number} [type=UnsignedIntType] - The texture type.
+ * @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=LinearFilter] - The mag filter value.
+ * @param {number} [minFilter=LinearFilter] - The min filter value.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ * @param {number} [format=DepthFormat] - The texture format.
+ * @param {number} [depth=1] - The depth of the texture.
+ */
+ constructor( width, height, type = UnsignedIntType, mapping, wrapS, wrapT, magFilter = NearestFilter, minFilter = NearestFilter, anisotropy, format = DepthFormat, depth = 1 ) {
+
+ if ( format !== DepthFormat && format !== DepthStencilFormat ) {
+
+ throw new Error( 'THREE.DepthTexture: format must be either THREE.DepthFormat or THREE.DepthStencilFormat' );
+
+ }
+
+ const image = { width: width, height: height, depth: depth };
+
+ super( image, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isDepthTexture = true;
+
+ /**
+ * If set to `true`, the texture is flipped along the vertical axis when
+ * uploaded to the GPU.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flipY = false;
+
+ /**
+ * Whether to generate mipmaps (if possible) for a texture.
+ *
+ * Overwritten and set to `false` by default.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.generateMipmaps = false;
+
+ /**
+ * Code corresponding to the depth compare function.
+ *
+ * @type {?(NeverCompare|LessCompare|EqualCompare|LessEqualCompare|GreaterCompare|NotEqualCompare|GreaterEqualCompare|AlwaysCompare)}
+ * @default null
+ */
+ this.compareFunction = null;
+
+ }
+
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.source = new TextureSource( Object.assign( {}, source.image ) ); // see #30540
+ this.compareFunction = source.compareFunction;
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.compareFunction = this.compareFunction;
+
+ return data;
+
+ }
+
+}
+
+/**
+ * This class can be used to automatically save the depth information of a
+ * cube rendering into a cube texture with depth format. Used for PointLight shadows.
+ *
+ * @augments DepthTexture
+ */
+class CubeDepthTexture extends DepthTexture {
+
+ /**
+ * Constructs a new cube depth texture.
+ *
+ * @param {number} size - The size (width and height) of each cube face.
+ * @param {number} [type=UnsignedIntType] - The texture type.
+ * @param {number} [mapping=CubeReflectionMapping] - The texture mapping.
+ * @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
+ * @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
+ * @param {number} [magFilter=NearestFilter] - The mag filter value.
+ * @param {number} [minFilter=NearestFilter] - The min filter value.
+ * @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
+ * @param {number} [format=DepthFormat] - The texture format.
+ */
+ constructor( size, type = UnsignedIntType, mapping = CubeReflectionMapping, wrapS, wrapT, magFilter = NearestFilter, minFilter = NearestFilter, anisotropy, format = DepthFormat ) {
+
+ // Create 6 identical image descriptors for the cube faces
+ const image = { width: size, height: size, depth: 1 };
+ const images = [ image, image, image, image, image, image ];
+
+ // Call DepthTexture constructor with width, height
+ super( size, size, type, mapping, wrapS, wrapT, magFilter, minFilter, anisotropy, format );
+
+ // Replace the single image with the array of 6 images
+ this.image = images;
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCubeDepthTexture = true;
+
+ /**
+ * Set to true for cube texture handling in WebGLTextures.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCubeTexture = true;
+
+ }
+
+ /**
+ * Alias for {@link CubeDepthTexture#image}.
+ *
+ * @type {Array}
+ */
+ get images() {
+
+ return this.image;
+
+ }
+
+ set images( value ) {
+
+ this.image = value;
+
+ }
+
+}
+
+/**
+ * Represents a texture created externally with the same renderer context.
+ *
+ * This may be a texture from a protected media stream, device camera feed,
+ * or other data feeds like a depth sensor.
+ *
+ * @augments Texture
+ */
+class ExternalTexture extends Texture {
+
+ /**
+ * Creates a new raw texture.
+ *
+ * @param {?(WebGLTexture|GPUTexture)} [sourceTexture=null] - The external texture.
+ */
+ constructor( sourceTexture = null ) {
+
+ super();
+
+ /**
+ * The external source texture.
+ *
+ * @type {?(WebGLTexture|GPUTexture)}
+ * @default null
+ */
+ this.sourceTexture = sourceTexture;
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isExternalTexture = true;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.sourceTexture = source.sourceTexture;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A geometry class for a rectangular cuboid with a given width, height, and depth.
+ * On creation, the cuboid is centred on the origin, with each edge parallel to one
+ * of the axes.
+ *
+ * ```js
+ * const geometry = new THREE.BoxGeometry( 1, 1, 1 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
+ * const cube = new THREE.Mesh( geometry, material );
+ * scene.add( cube );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#BoxGeometry
+ */
+class BoxGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new box geometry.
+ *
+ * @param {number} [width=1] - The width. That is, the length of the edges parallel to the X axis.
+ * @param {number} [height=1] - The height. That is, the length of the edges parallel to the Y axis.
+ * @param {number} [depth=1] - The depth. That is, the length of the edges parallel to the Z axis.
+ * @param {number} [widthSegments=1] - Number of segmented rectangular faces along the width of the sides.
+ * @param {number} [heightSegments=1] - Number of segmented rectangular faces along the height of the sides.
+ * @param {number} [depthSegments=1] - Number of segmented rectangular faces along the depth of the sides.
+ */
+ constructor( width = 1, height = 1, depth = 1, widthSegments = 1, heightSegments = 1, depthSegments = 1 ) {
+
+ super();
+
+ this.type = 'BoxGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ width: width,
+ height: height,
+ depth: depth,
+ widthSegments: widthSegments,
+ heightSegments: heightSegments,
+ depthSegments: depthSegments
+ };
+
+ const scope = this;
+
+ // segments
+
+ widthSegments = Math.floor( widthSegments );
+ heightSegments = Math.floor( heightSegments );
+ depthSegments = Math.floor( depthSegments );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // helper variables
+
+ let numberOfVertices = 0;
+ let groupStart = 0;
+
+ // build each side of the box geometry
+
+ buildPlane( 'z', 'y', 'x', -1, -1, depth, height, width, depthSegments, heightSegments, 0 ); // px
+ buildPlane( 'z', 'y', 'x', 1, -1, depth, height, - width, depthSegments, heightSegments, 1 ); // nx
+ buildPlane( 'x', 'z', 'y', 1, 1, width, depth, height, widthSegments, depthSegments, 2 ); // py
+ buildPlane( 'x', 'z', 'y', 1, -1, width, depth, - height, widthSegments, depthSegments, 3 ); // ny
+ buildPlane( 'x', 'y', 'z', 1, -1, width, height, depth, widthSegments, heightSegments, 4 ); // pz
+ buildPlane( 'x', 'y', 'z', -1, -1, width, height, - depth, widthSegments, heightSegments, 5 ); // nz
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ function buildPlane( u, v, w, udir, vdir, width, height, depth, gridX, gridY, materialIndex ) {
+
+ const segmentWidth = width / gridX;
+ const segmentHeight = height / gridY;
+
+ const widthHalf = width / 2;
+ const heightHalf = height / 2;
+ const depthHalf = depth / 2;
+
+ const gridX1 = gridX + 1;
+ const gridY1 = gridY + 1;
+
+ let vertexCounter = 0;
+ let groupCount = 0;
+
+ const vector = new Vector3();
+
+ // generate vertices, normals and uvs
+
+ for ( let iy = 0; iy < gridY1; iy ++ ) {
+
+ const y = iy * segmentHeight - heightHalf;
+
+ for ( let ix = 0; ix < gridX1; ix ++ ) {
+
+ const x = ix * segmentWidth - widthHalf;
+
+ // set values to correct vector component
+
+ vector[ u ] = x * udir;
+ vector[ v ] = y * vdir;
+ vector[ w ] = depthHalf;
+
+ // now apply vector to vertex buffer
+
+ vertices.push( vector.x, vector.y, vector.z );
+
+ // set values to correct vector component
+
+ vector[ u ] = 0;
+ vector[ v ] = 0;
+ vector[ w ] = depth > 0 ? 1 : -1;
+
+ // now apply vector to normal buffer
+
+ normals.push( vector.x, vector.y, vector.z );
+
+ // uvs
+
+ uvs.push( ix / gridX );
+ uvs.push( 1 - ( iy / gridY ) );
+
+ // counters
+
+ vertexCounter += 1;
+
+ }
+
+ }
+
+ // indices
+
+ // 1. you need three indices to draw a single face
+ // 2. a single segment consists of two faces
+ // 3. so we need to generate six (2*3) indices per segment
+
+ for ( let iy = 0; iy < gridY; iy ++ ) {
+
+ for ( let ix = 0; ix < gridX; ix ++ ) {
+
+ const a = numberOfVertices + ix + gridX1 * iy;
+ const b = numberOfVertices + ix + gridX1 * ( iy + 1 );
+ const c = numberOfVertices + ( ix + 1 ) + gridX1 * ( iy + 1 );
+ const d = numberOfVertices + ( ix + 1 ) + gridX1 * iy;
+
+ // faces
+
+ indices.push( a, b, d );
+ indices.push( b, c, d );
+
+ // increase counter
+
+ groupCount += 6;
+
+ }
+
+ }
+
+ // add a group to the geometry. this will ensure multi material support
+
+ scope.addGroup( groupStart, groupCount, materialIndex );
+
+ // calculate new start value for groups
+
+ groupStart += groupCount;
+
+ // update total number of vertices
+
+ numberOfVertices += vertexCounter;
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {BoxGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new BoxGeometry( data.width, data.height, data.depth, data.widthSegments, data.heightSegments, data.depthSegments );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing a capsule.
+ *
+ * ```js
+ * const geometry = new THREE.CapsuleGeometry( 1, 1, 4, 8, 1 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
+ * const capsule = new THREE.Mesh( geometry, material );
+ * scene.add( capsule );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#CapsuleGeometry
+ */
+class CapsuleGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new capsule geometry.
+ *
+ * @param {number} [radius=1] - Radius of the capsule.
+ * @param {number} [height=1] - Height of the middle section.
+ * @param {number} [capSegments=4] - Number of curve segments used to build each cap.
+ * @param {number} [radialSegments=8] - Number of segmented faces around the circumference of the capsule. Must be an integer >= 3.
+ * @param {number} [heightSegments=1] - Number of rows of faces along the height of the middle section. Must be an integer >= 1.
+ */
+ constructor( radius = 1, height = 1, capSegments = 4, radialSegments = 8, heightSegments = 1 ) {
+
+ super();
+
+ this.type = 'CapsuleGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ height: height,
+ capSegments: capSegments,
+ radialSegments: radialSegments,
+ heightSegments: heightSegments,
+ };
+
+ height = Math.max( 0, height );
+ capSegments = Math.max( 1, Math.floor( capSegments ) );
+ radialSegments = Math.max( 3, Math.floor( radialSegments ) );
+ heightSegments = Math.max( 1, Math.floor( heightSegments ) );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // helper variables
+
+ const halfHeight = height / 2;
+ const capArcLength = ( Math.PI / 2 ) * radius;
+ const cylinderPartLength = height;
+ const totalArcLength = 2 * capArcLength + cylinderPartLength;
+
+ const numVerticalSegments = capSegments * 2 + heightSegments;
+ const verticesPerRow = radialSegments + 1;
+
+ const normal = new Vector3();
+ const vertex = new Vector3();
+
+ // generate vertices, normals, and uvs
+
+ for ( let iy = 0; iy <= numVerticalSegments; iy ++ ) {
+
+ let currentArcLength = 0;
+ let profileY = 0;
+ let profileRadius = 0;
+ let normalYComponent = 0;
+
+ if ( iy <= capSegments ) {
+
+ // bottom cap
+ const segmentProgress = iy / capSegments;
+ const angle = ( segmentProgress * Math.PI ) / 2;
+ profileY = - halfHeight - radius * Math.cos( angle );
+ profileRadius = radius * Math.sin( angle );
+ normalYComponent = - radius * Math.cos( angle );
+ currentArcLength = segmentProgress * capArcLength;
+
+ } else if ( iy <= capSegments + heightSegments ) {
+
+ // middle section
+ const segmentProgress = ( iy - capSegments ) / heightSegments;
+ profileY = - halfHeight + segmentProgress * height;
+ profileRadius = radius;
+ normalYComponent = 0;
+ currentArcLength = capArcLength + segmentProgress * cylinderPartLength;
+
+ } else {
+
+ // top cap
+ const segmentProgress =
+ ( iy - capSegments - heightSegments ) / capSegments;
+ const angle = ( segmentProgress * Math.PI ) / 2;
+ profileY = halfHeight + radius * Math.sin( angle );
+ profileRadius = radius * Math.cos( angle );
+ normalYComponent = radius * Math.sin( angle );
+ currentArcLength =
+ capArcLength + cylinderPartLength + segmentProgress * capArcLength;
+
+ }
+
+ const v = Math.max( 0, Math.min( 1, currentArcLength / totalArcLength ) );
+
+
+ // special case for the poles
+
+ let uOffset = 0;
+
+ if ( iy === 0 ) {
+
+ uOffset = 0.5 / radialSegments;
+
+ } else if ( iy === numVerticalSegments ) {
+
+ uOffset = -0.5 / radialSegments;
+
+ }
+
+ for ( let ix = 0; ix <= radialSegments; ix ++ ) {
+
+ const u = ix / radialSegments;
+ const theta = u * Math.PI * 2;
+
+ const sinTheta = Math.sin( theta );
+ const cosTheta = Math.cos( theta );
+
+ // vertex
+
+ vertex.x = - profileRadius * cosTheta;
+ vertex.y = profileY;
+ vertex.z = profileRadius * sinTheta;
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal
+
+ normal.set(
+ - profileRadius * cosTheta,
+ normalYComponent,
+ profileRadius * sinTheta
+ );
+ normal.normalize();
+ normals.push( normal.x, normal.y, normal.z );
+
+ // uv
+
+ uvs.push( u + uOffset, v );
+
+ }
+
+ if ( iy > 0 ) {
+
+ const prevIndexRow = ( iy - 1 ) * verticesPerRow;
+ for ( let ix = 0; ix < radialSegments; ix ++ ) {
+
+ const i1 = prevIndexRow + ix;
+ const i2 = prevIndexRow + ix + 1;
+ const i3 = iy * verticesPerRow + ix;
+ const i4 = iy * verticesPerRow + ix + 1;
+
+ indices.push( i1, i2, i3 );
+ indices.push( i2, i4, i3 );
+
+ }
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {CapsuleGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new CapsuleGeometry( data.radius, data.height, data.capSegments, data.radialSegments, data.heightSegments );
+
+ }
+
+}
+
+/**
+ * A simple shape of Euclidean geometry. It is constructed from a
+ * number of triangular segments that are oriented around a central point and
+ * extend as far out as a given radius. It is built counter-clockwise from a
+ * start angle and a given central angle. It can also be used to create
+ * regular polygons, where the number of segments determines the number of
+ * sides.
+ *
+ * ```js
+ * const geometry = new THREE.CircleGeometry( 5, 32 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const circle = new THREE.Mesh( geometry, material );
+ * scene.add( circle )
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#CircleGeometry
+ */
+class CircleGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new circle geometry.
+ *
+ * @param {number} [radius=1] - Radius of the circle.
+ * @param {number} [segments=32] - Number of segments (triangles), minimum = `3`.
+ * @param {number} [thetaStart=0] - Start angle for first segment in radians.
+ * @param {number} [thetaLength=Math.PI*2] - The central angle, often called theta,
+ * of the circular sector in radians. The default value results in a complete circle.
+ */
+ constructor( radius = 1, segments = 32, thetaStart = 0, thetaLength = Math.PI * 2 ) {
+
+ super();
+
+ this.type = 'CircleGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ segments: segments,
+ thetaStart: thetaStart,
+ thetaLength: thetaLength
+ };
+
+ segments = Math.max( 3, segments );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // helper variables
+
+ const vertex = new Vector3();
+ const uv = new Vector2();
+
+ // center point
+
+ vertices.push( 0, 0, 0 );
+ normals.push( 0, 0, 1 );
+ uvs.push( 0.5, 0.5 );
+
+ for ( let s = 0, i = 3; s <= segments; s ++, i += 3 ) {
+
+ const segment = thetaStart + s / segments * thetaLength;
+
+ // vertex
+
+ vertex.x = radius * Math.cos( segment );
+ vertex.y = radius * Math.sin( segment );
+
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal
+
+ normals.push( 0, 0, 1 );
+
+ // uvs
+
+ uv.x = ( vertices[ i ] / radius + 1 ) / 2;
+ uv.y = ( vertices[ i + 1 ] / radius + 1 ) / 2;
+
+ uvs.push( uv.x, uv.y );
+
+ }
+
+ // indices
+
+ for ( let i = 1; i <= segments; i ++ ) {
+
+ indices.push( i, i + 1, 0 );
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {CircleGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new CircleGeometry( data.radius, data.segments, data.thetaStart, data.thetaLength );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing a cylinder.
+ *
+ * ```js
+ * const geometry = new THREE.CylinderGeometry( 5, 5, 20, 32 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const cylinder = new THREE.Mesh( geometry, material );
+ * scene.add( cylinder );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#CylinderGeometry
+ */
+class CylinderGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new cylinder geometry.
+ *
+ * @param {number} [radiusTop=1] - Radius of the cylinder at the top.
+ * @param {number} [radiusBottom=1] - Radius of the cylinder at the bottom.
+ * @param {number} [height=1] - Height of the cylinder.
+ * @param {number} [radialSegments=32] - Number of segmented faces around the circumference of the cylinder.
+ * @param {number} [heightSegments=1] - Number of rows of faces along the height of the cylinder.
+ * @param {boolean} [openEnded=false] - Whether the base of the cylinder is open or capped.
+ * @param {number} [thetaStart=0] - Start angle for first segment, in radians.
+ * @param {number} [thetaLength=Math.PI*2] - The central angle, often called theta, of the circular sector, in radians.
+ * The default value results in a complete cylinder.
+ */
+ constructor( radiusTop = 1, radiusBottom = 1, height = 1, radialSegments = 32, heightSegments = 1, openEnded = false, thetaStart = 0, thetaLength = Math.PI * 2 ) {
+
+ super();
+
+ this.type = 'CylinderGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radiusTop: radiusTop,
+ radiusBottom: radiusBottom,
+ height: height,
+ radialSegments: radialSegments,
+ heightSegments: heightSegments,
+ openEnded: openEnded,
+ thetaStart: thetaStart,
+ thetaLength: thetaLength
+ };
+
+ const scope = this;
+
+ radialSegments = Math.floor( radialSegments );
+ heightSegments = Math.floor( heightSegments );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // helper variables
+
+ let index = 0;
+ const indexArray = [];
+ const halfHeight = height / 2;
+ let groupStart = 0;
+
+ // generate geometry
+
+ generateTorso();
+
+ if ( openEnded === false ) {
+
+ if ( radiusTop > 0 ) generateCap( true );
+ if ( radiusBottom > 0 ) generateCap( false );
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ function generateTorso() {
+
+ const normal = new Vector3();
+ const vertex = new Vector3();
+
+ let groupCount = 0;
+
+ // this will be used to calculate the normal
+ const slope = ( radiusBottom - radiusTop ) / height;
+
+ // generate vertices, normals and uvs
+
+ for ( let y = 0; y <= heightSegments; y ++ ) {
+
+ const indexRow = [];
+
+ const v = y / heightSegments;
+
+ // calculate the radius of the current row
+
+ const radius = v * ( radiusBottom - radiusTop ) + radiusTop;
+
+ for ( let x = 0; x <= radialSegments; x ++ ) {
+
+ const u = x / radialSegments;
+
+ const theta = u * thetaLength + thetaStart;
+
+ const sinTheta = Math.sin( theta );
+ const cosTheta = Math.cos( theta );
+
+ // vertex
+
+ vertex.x = radius * sinTheta;
+ vertex.y = - v * height + halfHeight;
+ vertex.z = radius * cosTheta;
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal
+
+ normal.set( sinTheta, slope, cosTheta ).normalize();
+ normals.push( normal.x, normal.y, normal.z );
+
+ // uv
+
+ uvs.push( u, 1 - v );
+
+ // save index of vertex in respective row
+
+ indexRow.push( index ++ );
+
+ }
+
+ // now save vertices of the row in our index array
+
+ indexArray.push( indexRow );
+
+ }
+
+ // generate indices
+
+ for ( let x = 0; x < radialSegments; x ++ ) {
+
+ for ( let y = 0; y < heightSegments; y ++ ) {
+
+ // we use the index array to access the correct indices
+
+ const a = indexArray[ y ][ x ];
+ const b = indexArray[ y + 1 ][ x ];
+ const c = indexArray[ y + 1 ][ x + 1 ];
+ const d = indexArray[ y ][ x + 1 ];
+
+ // faces
+
+ if ( radiusTop > 0 || y !== 0 ) {
+
+ indices.push( a, b, d );
+ groupCount += 3;
+
+ }
+
+ if ( radiusBottom > 0 || y !== heightSegments - 1 ) {
+
+ indices.push( b, c, d );
+ groupCount += 3;
+
+ }
+
+ }
+
+ }
+
+ // add a group to the geometry. this will ensure multi material support
+
+ scope.addGroup( groupStart, groupCount, 0 );
+
+ // calculate new start value for groups
+
+ groupStart += groupCount;
+
+ }
+
+ function generateCap( top ) {
+
+ // save the index of the first center vertex
+ const centerIndexStart = index;
+
+ const uv = new Vector2();
+ const vertex = new Vector3();
+
+ let groupCount = 0;
+
+ const radius = ( top === true ) ? radiusTop : radiusBottom;
+ const sign = ( top === true ) ? 1 : -1;
+
+ // first we generate the center vertex data of the cap.
+ // because the geometry needs one set of uvs per face,
+ // we must generate a center vertex per face/segment
+
+ for ( let x = 1; x <= radialSegments; x ++ ) {
+
+ // vertex
+
+ vertices.push( 0, halfHeight * sign, 0 );
+
+ // normal
+
+ normals.push( 0, sign, 0 );
+
+ // uv
+
+ uvs.push( 0.5, 0.5 );
+
+ // increase index
+
+ index ++;
+
+ }
+
+ // save the index of the last center vertex
+ const centerIndexEnd = index;
+
+ // now we generate the surrounding vertices, normals and uvs
+
+ for ( let x = 0; x <= radialSegments; x ++ ) {
+
+ const u = x / radialSegments;
+ const theta = u * thetaLength + thetaStart;
+
+ const cosTheta = Math.cos( theta );
+ const sinTheta = Math.sin( theta );
+
+ // vertex
+
+ vertex.x = radius * sinTheta;
+ vertex.y = halfHeight * sign;
+ vertex.z = radius * cosTheta;
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal
+
+ normals.push( 0, sign, 0 );
+
+ // uv
+
+ uv.x = ( cosTheta * 0.5 ) + 0.5;
+ uv.y = ( sinTheta * 0.5 * sign ) + 0.5;
+ uvs.push( uv.x, uv.y );
+
+ // increase index
+
+ index ++;
+
+ }
+
+ // generate indices
+
+ for ( let x = 0; x < radialSegments; x ++ ) {
+
+ const c = centerIndexStart + x;
+ const i = centerIndexEnd + x;
+
+ if ( top === true ) {
+
+ // face top
+
+ indices.push( i, i + 1, c );
+
+ } else {
+
+ // face bottom
+
+ indices.push( i + 1, i, c );
+
+ }
+
+ groupCount += 3;
+
+ }
+
+ // add a group to the geometry. this will ensure multi material support
+
+ scope.addGroup( groupStart, groupCount, top === true ? 1 : 2 );
+
+ // calculate new start value for groups
+
+ groupStart += groupCount;
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {CylinderGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new CylinderGeometry( data.radiusTop, data.radiusBottom, data.height, data.radialSegments, data.heightSegments, data.openEnded, data.thetaStart, data.thetaLength );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing a cone.
+ *
+ * ```js
+ * const geometry = new THREE.ConeGeometry( 5, 20, 32 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const cone = new THREE.Mesh(geometry, material );
+ * scene.add( cone );
+ * ```
+ *
+ * @augments CylinderGeometry
+ * @demo scenes/geometry-browser.html#ConeGeometry
+ */
+class ConeGeometry extends CylinderGeometry {
+
+ /**
+ * Constructs a new cone geometry.
+ *
+ * @param {number} [radius=1] - Radius of the cone base.
+ * @param {number} [height=1] - Height of the cone.
+ * @param {number} [radialSegments=32] - Number of segmented faces around the circumference of the cone.
+ * @param {number} [heightSegments=1] - Number of rows of faces along the height of the cone.
+ * @param {boolean} [openEnded=false] - Whether the base of the cone is open or capped.
+ * @param {number} [thetaStart=0] - Start angle for first segment, in radians.
+ * @param {number} [thetaLength=Math.PI*2] - The central angle, often called theta, of the circular sector, in radians.
+ * The default value results in a complete cone.
+ */
+ constructor( radius = 1, height = 1, radialSegments = 32, heightSegments = 1, openEnded = false, thetaStart = 0, thetaLength = Math.PI * 2 ) {
+
+ super( 0, radius, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength );
+
+ this.type = 'ConeGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ height: height,
+ radialSegments: radialSegments,
+ heightSegments: heightSegments,
+ openEnded: openEnded,
+ thetaStart: thetaStart,
+ thetaLength: thetaLength
+ };
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {ConeGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new ConeGeometry( data.radius, data.height, data.radialSegments, data.heightSegments, data.openEnded, data.thetaStart, data.thetaLength );
+
+ }
+
+}
+
+/**
+ * A polyhedron is a solid in three dimensions with flat faces. This class
+ * will take an array of vertices, project them onto a sphere, and then
+ * divide them up to the desired level of detail.
+ *
+ * @augments BufferGeometry
+ */
+class PolyhedronGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new polyhedron geometry.
+ *
+ * @param {Array} [vertices] - A flat array of vertices describing the base shape.
+ * @param {Array} [indices] - A flat array of indices describing the base shape.
+ * @param {number} [radius=1] - The radius of the shape.
+ * @param {number} [detail=0] - How many levels to subdivide the geometry. The more detail, the smoother the shape.
+ */
+ constructor( vertices = [], indices = [], radius = 1, detail = 0 ) {
+
+ super();
+
+ this.type = 'PolyhedronGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ vertices: vertices,
+ indices: indices,
+ radius: radius,
+ detail: detail
+ };
+
+ // default buffer data
+
+ const vertexBuffer = [];
+ const uvBuffer = [];
+
+ // the subdivision creates the vertex buffer data
+
+ subdivide( detail );
+
+ // all vertices should lie on a conceptual sphere with a given radius
+
+ applyRadius( radius );
+
+ // finally, create the uv data
+
+ generateUVs();
+
+ // build non-indexed geometry
+
+ this.setAttribute( 'position', new Float32BufferAttribute( vertexBuffer, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( vertexBuffer.slice(), 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvBuffer, 2 ) );
+
+ if ( detail === 0 ) {
+
+ this.computeVertexNormals(); // flat normals
+
+ } else {
+
+ this.normalizeNormals(); // smooth normals
+
+ }
+
+ // helper functions
+
+ function subdivide( detail ) {
+
+ const a = new Vector3();
+ const b = new Vector3();
+ const c = new Vector3();
+
+ // iterate over all faces and apply a subdivision with the given detail value
+
+ for ( let i = 0; i < indices.length; i += 3 ) {
+
+ // get the vertices of the face
+
+ getVertexByIndex( indices[ i + 0 ], a );
+ getVertexByIndex( indices[ i + 1 ], b );
+ getVertexByIndex( indices[ i + 2 ], c );
+
+ // perform subdivision
+
+ subdivideFace( a, b, c, detail );
+
+ }
+
+ }
+
+ function subdivideFace( a, b, c, detail ) {
+
+ const cols = detail + 1;
+
+ // we use this multidimensional array as a data structure for creating the subdivision
+
+ const v = [];
+
+ // construct all of the vertices for this subdivision
+
+ for ( let i = 0; i <= cols; i ++ ) {
+
+ v[ i ] = [];
+
+ const aj = a.clone().lerp( c, i / cols );
+ const bj = b.clone().lerp( c, i / cols );
+
+ const rows = cols - i;
+
+ for ( let j = 0; j <= rows; j ++ ) {
+
+ if ( j === 0 && i === cols ) {
+
+ v[ i ][ j ] = aj;
+
+ } else {
+
+ v[ i ][ j ] = aj.clone().lerp( bj, j / rows );
+
+ }
+
+ }
+
+ }
+
+ // construct all of the faces
+
+ for ( let i = 0; i < cols; i ++ ) {
+
+ for ( let j = 0; j < 2 * ( cols - i ) - 1; j ++ ) {
+
+ const k = Math.floor( j / 2 );
+
+ if ( j % 2 === 0 ) {
+
+ pushVertex( v[ i ][ k + 1 ] );
+ pushVertex( v[ i + 1 ][ k ] );
+ pushVertex( v[ i ][ k ] );
+
+ } else {
+
+ pushVertex( v[ i ][ k + 1 ] );
+ pushVertex( v[ i + 1 ][ k + 1 ] );
+ pushVertex( v[ i + 1 ][ k ] );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ function applyRadius( radius ) {
+
+ const vertex = new Vector3();
+
+ // iterate over the entire buffer and apply the radius to each vertex
+
+ for ( let i = 0; i < vertexBuffer.length; i += 3 ) {
+
+ vertex.x = vertexBuffer[ i + 0 ];
+ vertex.y = vertexBuffer[ i + 1 ];
+ vertex.z = vertexBuffer[ i + 2 ];
+
+ vertex.normalize().multiplyScalar( radius );
+
+ vertexBuffer[ i + 0 ] = vertex.x;
+ vertexBuffer[ i + 1 ] = vertex.y;
+ vertexBuffer[ i + 2 ] = vertex.z;
+
+ }
+
+ }
+
+ function generateUVs() {
+
+ const vertex = new Vector3();
+
+ for ( let i = 0; i < vertexBuffer.length; i += 3 ) {
+
+ vertex.x = vertexBuffer[ i + 0 ];
+ vertex.y = vertexBuffer[ i + 1 ];
+ vertex.z = vertexBuffer[ i + 2 ];
+
+ const u = azimuth( vertex ) / 2 / Math.PI + 0.5;
+ const v = inclination( vertex ) / Math.PI + 0.5;
+ uvBuffer.push( u, 1 - v );
+
+ }
+
+ correctUVs();
+
+ correctSeam();
+
+ }
+
+ function correctSeam() {
+
+ // handle case when face straddles the seam, see #3269
+
+ for ( let i = 0; i < uvBuffer.length; i += 6 ) {
+
+ // uv data of a single face
+
+ const x0 = uvBuffer[ i + 0 ];
+ const x1 = uvBuffer[ i + 2 ];
+ const x2 = uvBuffer[ i + 4 ];
+
+ const max = Math.max( x0, x1, x2 );
+ const min = Math.min( x0, x1, x2 );
+
+ // 0.9 is somewhat arbitrary
+
+ if ( max > 0.9 && min < 0.1 ) {
+
+ if ( x0 < 0.2 ) uvBuffer[ i + 0 ] += 1;
+ if ( x1 < 0.2 ) uvBuffer[ i + 2 ] += 1;
+ if ( x2 < 0.2 ) uvBuffer[ i + 4 ] += 1;
+
+ }
+
+ }
+
+ }
+
+ function pushVertex( vertex ) {
+
+ vertexBuffer.push( vertex.x, vertex.y, vertex.z );
+
+ }
+
+ function getVertexByIndex( index, vertex ) {
+
+ const stride = index * 3;
+
+ vertex.x = vertices[ stride + 0 ];
+ vertex.y = vertices[ stride + 1 ];
+ vertex.z = vertices[ stride + 2 ];
+
+ }
+
+ function correctUVs() {
+
+ const a = new Vector3();
+ const b = new Vector3();
+ const c = new Vector3();
+
+ const centroid = new Vector3();
+
+ const uvA = new Vector2();
+ const uvB = new Vector2();
+ const uvC = new Vector2();
+
+ for ( let i = 0, j = 0; i < vertexBuffer.length; i += 9, j += 6 ) {
+
+ a.set( vertexBuffer[ i + 0 ], vertexBuffer[ i + 1 ], vertexBuffer[ i + 2 ] );
+ b.set( vertexBuffer[ i + 3 ], vertexBuffer[ i + 4 ], vertexBuffer[ i + 5 ] );
+ c.set( vertexBuffer[ i + 6 ], vertexBuffer[ i + 7 ], vertexBuffer[ i + 8 ] );
+
+ uvA.set( uvBuffer[ j + 0 ], uvBuffer[ j + 1 ] );
+ uvB.set( uvBuffer[ j + 2 ], uvBuffer[ j + 3 ] );
+ uvC.set( uvBuffer[ j + 4 ], uvBuffer[ j + 5 ] );
+
+ centroid.copy( a ).add( b ).add( c ).divideScalar( 3 );
+
+ const azi = azimuth( centroid );
+
+ correctUV( uvA, j + 0, a, azi );
+ correctUV( uvB, j + 2, b, azi );
+ correctUV( uvC, j + 4, c, azi );
+
+ }
+
+ }
+
+ function correctUV( uv, stride, vector, azimuth ) {
+
+ if ( ( azimuth < 0 ) && ( uv.x === 1 ) ) {
+
+ uvBuffer[ stride ] = uv.x - 1;
+
+ }
+
+ if ( ( vector.x === 0 ) && ( vector.z === 0 ) ) {
+
+ uvBuffer[ stride ] = azimuth / 2 / Math.PI + 0.5;
+
+ }
+
+ }
+
+ // Angle around the Y axis, counter-clockwise when looking from above.
+
+ function azimuth( vector ) {
+
+ return Math.atan2( vector.z, - vector.x );
+
+ }
+
+
+ // Angle above the XZ plane.
+
+ function inclination( vector ) {
+
+ return Math.atan2( - vector.y, Math.sqrt( ( vector.x * vector.x ) + ( vector.z * vector.z ) ) );
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {PolyhedronGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new PolyhedronGeometry( data.vertices, data.indices, data.radius, data.detail );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing a dodecahedron.
+ *
+ * ```js
+ * const geometry = new THREE.DodecahedronGeometry();
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const dodecahedron = new THREE.Mesh( geometry, material );
+ * scene.add( dodecahedron );
+ * ```
+ *
+ * @augments PolyhedronGeometry
+ * @demo scenes/geometry-browser.html#DodecahedronGeometry
+ */
+class DodecahedronGeometry extends PolyhedronGeometry {
+
+ /**
+ * Constructs a new dodecahedron geometry.
+ *
+ * @param {number} [radius=1] - Radius of the dodecahedron.
+ * @param {number} [detail=0] - Setting this to a value greater than `0` adds vertices making it no longer a dodecahedron.
+ */
+ constructor( radius = 1, detail = 0 ) {
+
+ const t = ( 1 + Math.sqrt( 5 ) ) / 2;
+ const r = 1 / t;
+
+ const vertices = [
+
+ // (±1, ±1, ±1)
+ -1, -1, -1, -1, -1, 1,
+ -1, 1, -1, -1, 1, 1,
+ 1, -1, -1, 1, -1, 1,
+ 1, 1, -1, 1, 1, 1,
+
+ // (0, ±1/φ, ±φ)
+ 0, - r, - t, 0, - r, t,
+ 0, r, - t, 0, r, t,
+
+ // (±1/φ, ±φ, 0)
+ - r, - t, 0, - r, t, 0,
+ r, - t, 0, r, t, 0,
+
+ // (±φ, 0, ±1/φ)
+ - t, 0, - r, t, 0, - r,
+ - t, 0, r, t, 0, r
+ ];
+
+ const indices = [
+ 3, 11, 7, 3, 7, 15, 3, 15, 13,
+ 7, 19, 17, 7, 17, 6, 7, 6, 15,
+ 17, 4, 8, 17, 8, 10, 17, 10, 6,
+ 8, 0, 16, 8, 16, 2, 8, 2, 10,
+ 0, 12, 1, 0, 1, 18, 0, 18, 16,
+ 6, 10, 2, 6, 2, 13, 6, 13, 15,
+ 2, 16, 18, 2, 18, 3, 2, 3, 13,
+ 18, 1, 9, 18, 9, 11, 18, 11, 3,
+ 4, 14, 12, 4, 12, 0, 4, 0, 8,
+ 11, 9, 5, 11, 5, 19, 11, 19, 7,
+ 19, 5, 14, 19, 14, 4, 19, 4, 17,
+ 1, 12, 14, 1, 14, 5, 1, 5, 9
+ ];
+
+ super( vertices, indices, radius, detail );
+
+ this.type = 'DodecahedronGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ detail: detail
+ };
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {DodecahedronGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new DodecahedronGeometry( data.radius, data.detail );
+
+ }
+
+}
+
+const _v0 = /*@__PURE__*/ new Vector3();
+const _v1$1 = /*@__PURE__*/ new Vector3();
+const _normal = /*@__PURE__*/ new Vector3();
+const _triangle = /*@__PURE__*/ new Triangle();
+
+/**
+ * Can be used as a helper object to view the edges of a geometry.
+ *
+ * ```js
+ * const geometry = new THREE.BoxGeometry();
+ * const edges = new THREE.EdgesGeometry( geometry );
+ * const line = new THREE.LineSegments( edges );
+ * scene.add( line );
+ * ```
+ *
+ * Note: It is not yet possible to serialize/deserialize instances of this class.
+ *
+ * @augments BufferGeometry
+ */
+class EdgesGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new edges geometry.
+ *
+ * @param {?BufferGeometry} [geometry=null] - The geometry.
+ * @param {number} [thresholdAngle=1] - An edge is only rendered if the angle (in degrees)
+ * between the face normals of the adjoining faces exceeds this value.
+ */
+ constructor( geometry = null, thresholdAngle = 1 ) {
+
+ super();
+
+ this.type = 'EdgesGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ geometry: geometry,
+ thresholdAngle: thresholdAngle
+ };
+
+ if ( geometry !== null ) {
+
+ const precisionPoints = 4;
+ const precision = Math.pow( 10, precisionPoints );
+ const thresholdDot = Math.cos( DEG2RAD * thresholdAngle );
+
+ const indexAttr = geometry.getIndex();
+ const positionAttr = geometry.getAttribute( 'position' );
+ const indexCount = indexAttr ? indexAttr.count : positionAttr.count;
+
+ const indexArr = [ 0, 0, 0 ];
+ const vertKeys = [ 'a', 'b', 'c' ];
+ const hashes = new Array( 3 );
+
+ const edgeData = {};
+ const vertices = [];
+ for ( let i = 0; i < indexCount; i += 3 ) {
+
+ if ( indexAttr ) {
+
+ indexArr[ 0 ] = indexAttr.getX( i );
+ indexArr[ 1 ] = indexAttr.getX( i + 1 );
+ indexArr[ 2 ] = indexAttr.getX( i + 2 );
+
+ } else {
+
+ indexArr[ 0 ] = i;
+ indexArr[ 1 ] = i + 1;
+ indexArr[ 2 ] = i + 2;
+
+ }
+
+ const { a, b, c } = _triangle;
+ a.fromBufferAttribute( positionAttr, indexArr[ 0 ] );
+ b.fromBufferAttribute( positionAttr, indexArr[ 1 ] );
+ c.fromBufferAttribute( positionAttr, indexArr[ 2 ] );
+ _triangle.getNormal( _normal );
+
+ // create hashes for the edge from the vertices
+ hashes[ 0 ] = `${ Math.round( a.x * precision ) },${ Math.round( a.y * precision ) },${ Math.round( a.z * precision ) }`;
+ hashes[ 1 ] = `${ Math.round( b.x * precision ) },${ Math.round( b.y * precision ) },${ Math.round( b.z * precision ) }`;
+ hashes[ 2 ] = `${ Math.round( c.x * precision ) },${ Math.round( c.y * precision ) },${ Math.round( c.z * precision ) }`;
+
+ // skip degenerate triangles
+ if ( hashes[ 0 ] === hashes[ 1 ] || hashes[ 1 ] === hashes[ 2 ] || hashes[ 2 ] === hashes[ 0 ] ) {
+
+ continue;
+
+ }
+
+ // iterate over every edge
+ for ( let j = 0; j < 3; j ++ ) {
+
+ // get the first and next vertex making up the edge
+ const jNext = ( j + 1 ) % 3;
+ const vecHash0 = hashes[ j ];
+ const vecHash1 = hashes[ jNext ];
+ const v0 = _triangle[ vertKeys[ j ] ];
+ const v1 = _triangle[ vertKeys[ jNext ] ];
+
+ const hash = `${ vecHash0 }_${ vecHash1 }`;
+ const reverseHash = `${ vecHash1 }_${ vecHash0 }`;
+
+ if ( reverseHash in edgeData && edgeData[ reverseHash ] ) {
+
+ // if we found a sibling edge add it into the vertex array if
+ // it meets the angle threshold and delete the edge from the map.
+ if ( _normal.dot( edgeData[ reverseHash ].normal ) <= thresholdDot ) {
+
+ vertices.push( v0.x, v0.y, v0.z );
+ vertices.push( v1.x, v1.y, v1.z );
+
+ }
+
+ edgeData[ reverseHash ] = null;
+
+ } else if ( ! ( hash in edgeData ) ) {
+
+ // if we've already got an edge here then skip adding a new one
+ edgeData[ hash ] = {
+
+ index0: indexArr[ j ],
+ index1: indexArr[ jNext ],
+ normal: _normal.clone(),
+
+ };
+
+ }
+
+ }
+
+ }
+
+ // iterate over all remaining, unmatched edges and add them to the vertex array
+ for ( const key in edgeData ) {
+
+ if ( edgeData[ key ] ) {
+
+ const { index0, index1 } = edgeData[ key ];
+ _v0.fromBufferAttribute( positionAttr, index0 );
+ _v1$1.fromBufferAttribute( positionAttr, index1 );
+
+ vertices.push( _v0.x, _v0.y, _v0.z );
+ vertices.push( _v1$1.x, _v1$1.y, _v1$1.z );
+
+ }
+
+ }
+
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * An abstract base class for creating an analytic curve object that contains methods
+ * for interpolation.
+ *
+ * @abstract
+ */
+class Curve {
+
+ /**
+ * Constructs a new curve.
+ */
+ constructor() {
+
+ /**
+ * The type property is used for detecting the object type
+ * in context of serialization/deserialization.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.type = 'Curve';
+
+ /**
+ * This value determines the amount of divisions when calculating the
+ * cumulative segment lengths of a curve via {@link Curve#getLengths}. To ensure
+ * precision when using methods like {@link Curve#getSpacedPoints}, it is
+ * recommended to increase the value of this property if the curve is very large.
+ *
+ * @type {number}
+ * @default 200
+ */
+ this.arcLengthDivisions = 200;
+
+ /**
+ * Must be set to `true` if the curve parameters have changed.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.needsUpdate = false;
+
+ /**
+ * An internal cache that holds precomputed curve length values.
+ *
+ * @private
+ * @type {?Array}
+ * @default null
+ */
+ this.cacheArcLengths = null;
+
+ }
+
+ /**
+ * This method returns a vector in 2D or 3D space (depending on the curve definition)
+ * for the given interpolation factor.
+ *
+ * @abstract
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {(Vector2|Vector3)} [optionalTarget] - The optional target vector the result is written to.
+ * @return {(Vector2|Vector3)} The position on the curve. It can be a 2D or 3D vector depending on the curve definition.
+ */
+ getPoint( /* t, optionalTarget */ ) {
+
+ warn( 'Curve: .getPoint() not implemented.' );
+
+ }
+
+ /**
+ * This method returns a vector in 2D or 3D space (depending on the curve definition)
+ * for the given interpolation factor. Unlike {@link Curve#getPoint}, this method honors the length
+ * of the curve which equidistant samples.
+ *
+ * @param {number} u - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {(Vector2|Vector3)} [optionalTarget] - The optional target vector the result is written to.
+ * @return {(Vector2|Vector3)} The position on the curve. It can be a 2D or 3D vector depending on the curve definition.
+ */
+ getPointAt( u, optionalTarget ) {
+
+ const t = this.getUtoTmapping( u );
+ return this.getPoint( t, optionalTarget );
+
+ }
+
+ /**
+ * This method samples the curve via {@link Curve#getPoint} and returns an array of points representing
+ * the curve shape.
+ *
+ * @param {number} [divisions=5] - The number of divisions.
+ * @return {Array<(Vector2|Vector3)>} An array holding the sampled curve values. The number of points is `divisions + 1`.
+ */
+ getPoints( divisions = 5 ) {
+
+ const points = [];
+
+ for ( let d = 0; d <= divisions; d ++ ) {
+
+ points.push( this.getPoint( d / divisions ) );
+
+ }
+
+ return points;
+
+ }
+
+ // Get sequence of points using getPointAt( u )
+
+ /**
+ * This method samples the curve via {@link Curve#getPointAt} and returns an array of points representing
+ * the curve shape. Unlike {@link Curve#getPoints}, this method returns equi-spaced points across the entire
+ * curve.
+ *
+ * @param {number} [divisions=5] - The number of divisions.
+ * @return {Array<(Vector2|Vector3)>} An array holding the sampled curve values. The number of points is `divisions + 1`.
+ */
+ getSpacedPoints( divisions = 5 ) {
+
+ const points = [];
+
+ for ( let d = 0; d <= divisions; d ++ ) {
+
+ points.push( this.getPointAt( d / divisions ) );
+
+ }
+
+ return points;
+
+ }
+
+ /**
+ * Returns the total arc length of the curve.
+ *
+ * @return {number} The length of the curve.
+ */
+ getLength() {
+
+ const lengths = this.getLengths();
+ return lengths[ lengths.length - 1 ];
+
+ }
+
+ /**
+ * Returns an array of cumulative segment lengths of the curve.
+ *
+ * @param {number} [divisions=this.arcLengthDivisions] - The number of divisions.
+ * @return {Array} An array holding the cumulative segment lengths.
+ */
+ getLengths( divisions = this.arcLengthDivisions ) {
+
+ if ( this.cacheArcLengths &&
+ ( this.cacheArcLengths.length === divisions + 1 ) &&
+ ! this.needsUpdate ) {
+
+ return this.cacheArcLengths;
+
+ }
+
+ this.needsUpdate = false;
+
+ const cache = [];
+ let current, last = this.getPoint( 0 );
+ let sum = 0;
+
+ cache.push( 0 );
+
+ for ( let p = 1; p <= divisions; p ++ ) {
+
+ current = this.getPoint( p / divisions );
+ sum += current.distanceTo( last );
+ cache.push( sum );
+ last = current;
+
+ }
+
+ this.cacheArcLengths = cache;
+
+ return cache; // { sums: cache, sum: sum }; Sum is in the last element.
+
+ }
+
+ /**
+ * Update the cumulative segment distance cache. The method must be called
+ * every time curve parameters are changed. If an updated curve is part of a
+ * composed curve like {@link CurvePath}, this method must be called on the
+ * composed curve, too.
+ */
+ updateArcLengths() {
+
+ this.needsUpdate = true;
+ this.getLengths();
+
+ }
+
+ /**
+ * Given an interpolation factor in the range `[0,1]`, this method returns an updated
+ * interpolation factor in the same range that can be ued to sample equidistant points
+ * from a curve.
+ *
+ * @param {number} u - The interpolation factor.
+ * @param {?number} distance - An optional distance on the curve.
+ * @return {number} The updated interpolation factor.
+ */
+ getUtoTmapping( u, distance = null ) {
+
+ const arcLengths = this.getLengths();
+
+ let i = 0;
+ const il = arcLengths.length;
+
+ let targetArcLength; // The targeted u distance value to get
+
+ if ( distance ) {
+
+ targetArcLength = distance;
+
+ } else {
+
+ targetArcLength = u * arcLengths[ il - 1 ];
+
+ }
+
+ // binary search for the index with largest value smaller than target u distance
+
+ let low = 0, high = il - 1, comparison;
+
+ while ( low <= high ) {
+
+ i = Math.floor( low + ( high - low ) / 2 ); // less likely to overflow, though probably not issue here, JS doesn't really have integers, all numbers are floats
+
+ comparison = arcLengths[ i ] - targetArcLength;
+
+ if ( comparison < 0 ) {
+
+ low = i + 1;
+
+ } else if ( comparison > 0 ) {
+
+ high = i - 1;
+
+ } else {
+
+ high = i;
+ break;
+
+ // DONE
+
+ }
+
+ }
+
+ i = high;
+
+ if ( arcLengths[ i ] === targetArcLength ) {
+
+ return i / ( il - 1 );
+
+ }
+
+ // we could get finer grain at lengths, or use simple interpolation between two points
+
+ const lengthBefore = arcLengths[ i ];
+ const lengthAfter = arcLengths[ i + 1 ];
+
+ const segmentLength = lengthAfter - lengthBefore;
+
+ // determine where we are between the 'before' and 'after' points
+
+ const segmentFraction = ( targetArcLength - lengthBefore ) / segmentLength;
+
+ // add that fractional amount to t
+
+ const t = ( i + segmentFraction ) / ( il - 1 );
+
+ return t;
+
+ }
+
+ /**
+ * Returns a unit vector tangent for the given interpolation factor.
+ * If the derived curve does not implement its tangent derivation,
+ * two points a small delta apart will be used to find its gradient
+ * which seems to give a reasonable approximation.
+ *
+ * @param {number} t - The interpolation factor.
+ * @param {(Vector2|Vector3)} [optionalTarget] - The optional target vector the result is written to.
+ * @return {(Vector2|Vector3)} The tangent vector.
+ */
+ getTangent( t, optionalTarget ) {
+
+ const delta = 0.0001;
+ let t1 = t - delta;
+ let t2 = t + delta;
+
+ // Capping in case of danger
+
+ if ( t1 < 0 ) t1 = 0;
+ if ( t2 > 1 ) t2 = 1;
+
+ const pt1 = this.getPoint( t1 );
+ const pt2 = this.getPoint( t2 );
+
+ const tangent = optionalTarget || ( ( pt1.isVector2 ) ? new Vector2() : new Vector3() );
+
+ tangent.copy( pt2 ).sub( pt1 ).normalize();
+
+ return tangent;
+
+ }
+
+ /**
+ * Same as {@link Curve#getTangent} but with equidistant samples.
+ *
+ * @param {number} u - The interpolation factor.
+ * @param {(Vector2|Vector3)} [optionalTarget] - The optional target vector the result is written to.
+ * @return {(Vector2|Vector3)} The tangent vector.
+ * @see {@link Curve#getPointAt}
+ */
+ getTangentAt( u, optionalTarget ) {
+
+ const t = this.getUtoTmapping( u );
+ return this.getTangent( t, optionalTarget );
+
+ }
+
+ /**
+ * Generates the Frenet Frames. Requires a curve definition in 3D space. Used
+ * in geometries like {@link TubeGeometry} or {@link ExtrudeGeometry}.
+ *
+ * @param {number} segments - The number of segments.
+ * @param {boolean} [closed=false] - Whether the curve is closed or not.
+ * @return {{tangents: Array, normals: Array, binormals: Array}} The Frenet Frames.
+ */
+ computeFrenetFrames( segments, closed = false ) {
+
+ // see http://www.cs.indiana.edu/pub/techreports/TR425.pdf
+
+ const normal = new Vector3();
+
+ const tangents = [];
+ const normals = [];
+ const binormals = [];
+
+ const vec = new Vector3();
+ const mat = new Matrix4();
+
+ // compute the tangent vectors for each segment on the curve
+
+ for ( let i = 0; i <= segments; i ++ ) {
+
+ const u = i / segments;
+
+ tangents[ i ] = this.getTangentAt( u, new Vector3() );
+
+ }
+
+ // select an initial normal vector perpendicular to the first tangent vector,
+ // and in the direction of the minimum tangent xyz component
+
+ normals[ 0 ] = new Vector3();
+ binormals[ 0 ] = new Vector3();
+ let min = Number.MAX_VALUE;
+ const tx = Math.abs( tangents[ 0 ].x );
+ const ty = Math.abs( tangents[ 0 ].y );
+ const tz = Math.abs( tangents[ 0 ].z );
+
+ if ( tx <= min ) {
+
+ min = tx;
+ normal.set( 1, 0, 0 );
+
+ }
+
+ if ( ty <= min ) {
+
+ min = ty;
+ normal.set( 0, 1, 0 );
+
+ }
+
+ if ( tz <= min ) {
+
+ normal.set( 0, 0, 1 );
+
+ }
+
+ vec.crossVectors( tangents[ 0 ], normal ).normalize();
+
+ normals[ 0 ].crossVectors( tangents[ 0 ], vec );
+ binormals[ 0 ].crossVectors( tangents[ 0 ], normals[ 0 ] );
+
+
+ // compute the slowly-varying normal and binormal vectors for each segment on the curve
+
+ for ( let i = 1; i <= segments; i ++ ) {
+
+ normals[ i ] = normals[ i - 1 ].clone();
+
+ binormals[ i ] = binormals[ i - 1 ].clone();
+
+ vec.crossVectors( tangents[ i - 1 ], tangents[ i ] );
+
+ if ( vec.length() > Number.EPSILON ) {
+
+ vec.normalize();
+
+ const theta = Math.acos( clamp( tangents[ i - 1 ].dot( tangents[ i ] ), -1, 1 ) ); // clamp for floating pt errors
+
+ normals[ i ].applyMatrix4( mat.makeRotationAxis( vec, theta ) );
+
+ }
+
+ binormals[ i ].crossVectors( tangents[ i ], normals[ i ] );
+
+ }
+
+ // if the curve is closed, postprocess the vectors so the first and last normal vectors are the same
+
+ if ( closed === true ) {
+
+ let theta = Math.acos( clamp( normals[ 0 ].dot( normals[ segments ] ), -1, 1 ) );
+ theta /= segments;
+
+ if ( tangents[ 0 ].dot( vec.crossVectors( normals[ 0 ], normals[ segments ] ) ) > 0 ) {
+
+ theta = - theta;
+
+ }
+
+ for ( let i = 1; i <= segments; i ++ ) {
+
+ // twist a little...
+ normals[ i ].applyMatrix4( mat.makeRotationAxis( tangents[ i ], theta * i ) );
+ binormals[ i ].crossVectors( tangents[ i ], normals[ i ] );
+
+ }
+
+ }
+
+ return {
+ tangents: tangents,
+ normals: normals,
+ binormals: binormals
+ };
+
+ }
+
+ /**
+ * Returns a new curve with copied values from this instance.
+ *
+ * @return {Curve} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Copies the values of the given curve to this instance.
+ *
+ * @param {Curve} source - The curve to copy.
+ * @return {Curve} A reference to this curve.
+ */
+ copy( source ) {
+
+ this.arcLengthDivisions = source.arcLengthDivisions;
+
+ return this;
+
+ }
+
+ /**
+ * Serializes the curve into JSON.
+ *
+ * @return {Object} A JSON object representing the serialized curve.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON() {
+
+ const data = {
+ metadata: {
+ version: 4.7,
+ type: 'Curve',
+ generator: 'Curve.toJSON'
+ }
+ };
+
+ data.arcLengthDivisions = this.arcLengthDivisions;
+ data.type = this.type;
+
+ return data;
+
+ }
+
+ /**
+ * Deserializes the curve from the given JSON.
+ *
+ * @param {Object} json - The JSON holding the serialized curve.
+ * @return {Curve} A reference to this curve.
+ */
+ fromJSON( json ) {
+
+ this.arcLengthDivisions = json.arcLengthDivisions;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing an ellipse.
+ *
+ * ```js
+ * const curve = new THREE.EllipseCurve(
+ * 0, 0,
+ * 10, 10,
+ * 0, 2 * Math.PI,
+ * false,
+ * 0
+ * );
+ *
+ * const points = curve.getPoints( 50 );
+ * const geometry = new THREE.BufferGeometry().setFromPoints( points );
+ *
+ * const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
+ *
+ * // Create the final object to add to the scene
+ * const ellipse = new THREE.Line( geometry, material );
+ * ```
+ *
+ * @augments Curve
+ */
+class EllipseCurve extends Curve {
+
+ /**
+ * Constructs a new ellipse curve.
+ *
+ * @param {number} [aX=0] - The X center of the ellipse.
+ * @param {number} [aY=0] - The Y center of the ellipse.
+ * @param {number} [xRadius=1] - The radius of the ellipse in the x direction.
+ * @param {number} [yRadius=1] - The radius of the ellipse in the y direction.
+ * @param {number} [aStartAngle=0] - The start angle of the curve in radians starting from the positive X axis.
+ * @param {number} [aEndAngle=Math.PI*2] - The end angle of the curve in radians starting from the positive X axis.
+ * @param {boolean} [aClockwise=false] - Whether the ellipse is drawn clockwise or not.
+ * @param {number} [aRotation=0] - The rotation angle of the ellipse in radians, counterclockwise from the positive X axis.
+ */
+ constructor( aX = 0, aY = 0, xRadius = 1, yRadius = 1, aStartAngle = 0, aEndAngle = Math.PI * 2, aClockwise = false, aRotation = 0 ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isEllipseCurve = true;
+
+ this.type = 'EllipseCurve';
+
+ /**
+ * The X center of the ellipse.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.aX = aX;
+
+ /**
+ * The Y center of the ellipse.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.aY = aY;
+
+ /**
+ * The radius of the ellipse in the x direction.
+ * Setting the this value equal to the {@link EllipseCurve#yRadius} will result in a circle.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.xRadius = xRadius;
+
+ /**
+ * The radius of the ellipse in the y direction.
+ * Setting the this value equal to the {@link EllipseCurve#xRadius} will result in a circle.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.yRadius = yRadius;
+
+ /**
+ * The start angle of the curve in radians starting from the positive X axis.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.aStartAngle = aStartAngle;
+
+ /**
+ * The end angle of the curve in radians starting from the positive X axis.
+ *
+ * @type {number}
+ * @default Math.PI*2
+ */
+ this.aEndAngle = aEndAngle;
+
+ /**
+ * Whether the ellipse is drawn clockwise or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.aClockwise = aClockwise;
+
+ /**
+ * The rotation angle of the ellipse in radians, counterclockwise from the positive X axis.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.aRotation = aRotation;
+
+ }
+
+ /**
+ * Returns a point on the curve.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {Vector2} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector2} The position on the curve.
+ */
+ getPoint( t, optionalTarget = new Vector2() ) {
+
+ const point = optionalTarget;
+
+ const twoPi = Math.PI * 2;
+ let deltaAngle = this.aEndAngle - this.aStartAngle;
+ const samePoints = Math.abs( deltaAngle ) < Number.EPSILON;
+
+ // ensures that deltaAngle is 0 .. 2 PI
+ while ( deltaAngle < 0 ) deltaAngle += twoPi;
+ while ( deltaAngle > twoPi ) deltaAngle -= twoPi;
+
+ if ( deltaAngle < Number.EPSILON ) {
+
+ if ( samePoints ) {
+
+ deltaAngle = 0;
+
+ } else {
+
+ deltaAngle = twoPi;
+
+ }
+
+ }
+
+ if ( this.aClockwise === true && ! samePoints ) {
+
+ if ( deltaAngle === twoPi ) {
+
+ deltaAngle = - twoPi;
+
+ } else {
+
+ deltaAngle = deltaAngle - twoPi;
+
+ }
+
+ }
+
+ const angle = this.aStartAngle + t * deltaAngle;
+ let x = this.aX + this.xRadius * Math.cos( angle );
+ let y = this.aY + this.yRadius * Math.sin( angle );
+
+ if ( this.aRotation !== 0 ) {
+
+ const cos = Math.cos( this.aRotation );
+ const sin = Math.sin( this.aRotation );
+
+ const tx = x - this.aX;
+ const ty = y - this.aY;
+
+ // Rotate the point about the center of the ellipse.
+ x = tx * cos - ty * sin + this.aX;
+ y = tx * sin + ty * cos + this.aY;
+
+ }
+
+ return point.set( x, y );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.aX = source.aX;
+ this.aY = source.aY;
+
+ this.xRadius = source.xRadius;
+ this.yRadius = source.yRadius;
+
+ this.aStartAngle = source.aStartAngle;
+ this.aEndAngle = source.aEndAngle;
+
+ this.aClockwise = source.aClockwise;
+
+ this.aRotation = source.aRotation;
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.aX = this.aX;
+ data.aY = this.aY;
+
+ data.xRadius = this.xRadius;
+ data.yRadius = this.yRadius;
+
+ data.aStartAngle = this.aStartAngle;
+ data.aEndAngle = this.aEndAngle;
+
+ data.aClockwise = this.aClockwise;
+
+ data.aRotation = this.aRotation;
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.aX = json.aX;
+ this.aY = json.aY;
+
+ this.xRadius = json.xRadius;
+ this.yRadius = json.yRadius;
+
+ this.aStartAngle = json.aStartAngle;
+ this.aEndAngle = json.aEndAngle;
+
+ this.aClockwise = json.aClockwise;
+
+ this.aRotation = json.aRotation;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing an arc.
+ *
+ * @augments EllipseCurve
+ */
+class ArcCurve extends EllipseCurve {
+
+ /**
+ * Constructs a new arc curve.
+ *
+ * @param {number} [aX=0] - The X center of the ellipse.
+ * @param {number} [aY=0] - The Y center of the ellipse.
+ * @param {number} [aRadius=1] - The radius of the ellipse in the x direction.
+ * @param {number} [aStartAngle=0] - The start angle of the curve in radians starting from the positive X axis.
+ * @param {number} [aEndAngle=Math.PI*2] - The end angle of the curve in radians starting from the positive X axis.
+ * @param {boolean} [aClockwise=false] - Whether the ellipse is drawn clockwise or not.
+ */
+ constructor( aX, aY, aRadius, aStartAngle, aEndAngle, aClockwise ) {
+
+ super( aX, aY, aRadius, aRadius, aStartAngle, aEndAngle, aClockwise );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isArcCurve = true;
+
+ this.type = 'ArcCurve';
+
+ }
+
+}
+
+function CubicPoly() {
+
+ /**
+ * Centripetal CatmullRom Curve - which is useful for avoiding
+ * cusps and self-intersections in non-uniform catmull rom curves.
+ * http://www.cemyuksel.com/research/catmullrom_param/catmullrom.pdf
+ *
+ * curve.type accepts centripetal(default), chordal and catmullrom
+ * curve.tension is used for catmullrom which defaults to 0.5
+ */
+
+ /*
+ Based on an optimized c++ solution in
+ - http://stackoverflow.com/questions/9489736/catmull-rom-curve-with-no-cusps-and-no-self-intersections/
+ - http://ideone.com/NoEbVM
+
+ This CubicPoly class could be used for reusing some variables and calculations,
+ but for three.js curve use, it could be possible inlined and flatten into a single function call
+ which can be placed in CurveUtils.
+ */
+
+ let c0 = 0, c1 = 0, c2 = 0, c3 = 0;
+
+ /*
+ * Compute coefficients for a cubic polynomial
+ * p(s) = c0 + c1*s + c2*s^2 + c3*s^3
+ * such that
+ * p(0) = x0, p(1) = x1
+ * and
+ * p'(0) = t0, p'(1) = t1.
+ */
+ function init( x0, x1, t0, t1 ) {
+
+ c0 = x0;
+ c1 = t0;
+ c2 = -3 * x0 + 3 * x1 - 2 * t0 - t1;
+ c3 = 2 * x0 - 2 * x1 + t0 + t1;
+
+ }
+
+ return {
+
+ initCatmullRom: function ( x0, x1, x2, x3, tension ) {
+
+ init( x1, x2, tension * ( x2 - x0 ), tension * ( x3 - x1 ) );
+
+ },
+
+ initNonuniformCatmullRom: function ( x0, x1, x2, x3, dt0, dt1, dt2 ) {
+
+ // compute tangents when parameterized in [t1,t2]
+ let t1 = ( x1 - x0 ) / dt0 - ( x2 - x0 ) / ( dt0 + dt1 ) + ( x2 - x1 ) / dt1;
+ let t2 = ( x2 - x1 ) / dt1 - ( x3 - x1 ) / ( dt1 + dt2 ) + ( x3 - x2 ) / dt2;
+
+ // rescale tangents for parametrization in [0,1]
+ t1 *= dt1;
+ t2 *= dt1;
+
+ init( x1, x2, t1, t2 );
+
+ },
+
+ calc: function ( t ) {
+
+ const t2 = t * t;
+ const t3 = t2 * t;
+ return c0 + c1 * t + c2 * t2 + c3 * t3;
+
+ }
+
+ };
+
+}
+
+//
+
+const tmp = /*@__PURE__*/ new Vector3();
+const tmp2 = /*@__PURE__*/ new Vector3();
+const px = /*@__PURE__*/ new CubicPoly();
+const py = /*@__PURE__*/ new CubicPoly();
+const pz = /*@__PURE__*/ new CubicPoly();
+
+/**
+ * A curve representing a Catmull-Rom spline.
+ *
+ * ```js
+ * //Create a closed wavey loop
+ * const curve = new THREE.CatmullRomCurve3( [
+ * new THREE.Vector3( -10, 0, 10 ),
+ * new THREE.Vector3( -5, 5, 5 ),
+ * new THREE.Vector3( 0, 0, 0 ),
+ * new THREE.Vector3( 5, -5, 5 ),
+ * new THREE.Vector3( 10, 0, 10 )
+ * ] );
+ *
+ * const points = curve.getPoints( 50 );
+ * const geometry = new THREE.BufferGeometry().setFromPoints( points );
+ *
+ * const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
+ *
+ * // Create the final object to add to the scene
+ * const curveObject = new THREE.Line( geometry, material );
+ * ```
+ *
+ * @augments Curve
+ */
+class CatmullRomCurve3 extends Curve {
+
+ /**
+ * Constructs a new Catmull-Rom curve.
+ *
+ * @param {Array} [points] - An array of 3D points defining the curve.
+ * @param {boolean} [closed=false] - Whether the curve is closed or not.
+ * @param {('centripetal'|'chordal'|'catmullrom')} [curveType='centripetal'] - The curve type.
+ * @param {number} [tension=0.5] - Tension of the curve.
+ */
+ constructor( points = [], closed = false, curveType = 'centripetal', tension = 0.5 ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCatmullRomCurve3 = true;
+
+ this.type = 'CatmullRomCurve3';
+
+ /**
+ * An array of 3D points defining the curve.
+ *
+ * @type {Array}
+ */
+ this.points = points;
+
+ /**
+ * Whether the curve is closed or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.closed = closed;
+
+ /**
+ * The curve type.
+ *
+ * @type {('centripetal'|'chordal'|'catmullrom')}
+ * @default 'centripetal'
+ */
+ this.curveType = curveType;
+
+ /**
+ * Tension of the curve.
+ *
+ * @type {number}
+ * @default 0.5
+ */
+ this.tension = tension;
+
+ }
+
+ /**
+ * Returns a point on the curve.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {Vector3} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector3} The position on the curve.
+ */
+ getPoint( t, optionalTarget = new Vector3() ) {
+
+ const point = optionalTarget;
+
+ const points = this.points;
+ const l = points.length;
+
+ const p = ( l - ( this.closed ? 0 : 1 ) ) * t;
+ let intPoint = Math.floor( p );
+ let weight = p - intPoint;
+
+ if ( this.closed ) {
+
+ intPoint += intPoint > 0 ? 0 : ( Math.floor( Math.abs( intPoint ) / l ) + 1 ) * l;
+
+ } else if ( weight === 0 && intPoint === l - 1 ) {
+
+ intPoint = l - 2;
+ weight = 1;
+
+ }
+
+ let p0, p3; // 4 points (p1 & p2 defined below)
+
+ if ( this.closed || intPoint > 0 ) {
+
+ p0 = points[ ( intPoint - 1 ) % l ];
+
+ } else {
+
+ // extrapolate first point
+ tmp2.subVectors( points[ 0 ], points[ 1 ] ).add( points[ 0 ] );
+ p0 = tmp2;
+
+ }
+
+ const p1 = points[ intPoint % l ];
+ const p2 = points[ ( intPoint + 1 ) % l ];
+
+ if ( this.closed || intPoint + 2 < l ) {
+
+ p3 = points[ ( intPoint + 2 ) % l ];
+
+ } else {
+
+ // extrapolate last point
+ tmp.subVectors( points[ l - 1 ], points[ l - 2 ] ).add( points[ l - 1 ] );
+ p3 = tmp;
+
+ }
+
+ if ( this.curveType === 'centripetal' || this.curveType === 'chordal' ) {
+
+ // init Centripetal / Chordal Catmull-Rom
+ const pow = this.curveType === 'chordal' ? 0.5 : 0.25;
+ let dt0 = Math.pow( p0.distanceToSquared( p1 ), pow );
+ let dt1 = Math.pow( p1.distanceToSquared( p2 ), pow );
+ let dt2 = Math.pow( p2.distanceToSquared( p3 ), pow );
+
+ // safety check for repeated points
+ if ( dt1 < 1e-4 ) dt1 = 1.0;
+ if ( dt0 < 1e-4 ) dt0 = dt1;
+ if ( dt2 < 1e-4 ) dt2 = dt1;
+
+ px.initNonuniformCatmullRom( p0.x, p1.x, p2.x, p3.x, dt0, dt1, dt2 );
+ py.initNonuniformCatmullRom( p0.y, p1.y, p2.y, p3.y, dt0, dt1, dt2 );
+ pz.initNonuniformCatmullRom( p0.z, p1.z, p2.z, p3.z, dt0, dt1, dt2 );
+
+ } else if ( this.curveType === 'catmullrom' ) {
+
+ px.initCatmullRom( p0.x, p1.x, p2.x, p3.x, this.tension );
+ py.initCatmullRom( p0.y, p1.y, p2.y, p3.y, this.tension );
+ pz.initCatmullRom( p0.z, p1.z, p2.z, p3.z, this.tension );
+
+ }
+
+ point.set(
+ px.calc( weight ),
+ py.calc( weight ),
+ pz.calc( weight )
+ );
+
+ return point;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.points = [];
+
+ for ( let i = 0, l = source.points.length; i < l; i ++ ) {
+
+ const point = source.points[ i ];
+
+ this.points.push( point.clone() );
+
+ }
+
+ this.closed = source.closed;
+ this.curveType = source.curveType;
+ this.tension = source.tension;
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.points = [];
+
+ for ( let i = 0, l = this.points.length; i < l; i ++ ) {
+
+ const point = this.points[ i ];
+ data.points.push( point.toArray() );
+
+ }
+
+ data.closed = this.closed;
+ data.curveType = this.curveType;
+ data.tension = this.tension;
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.points = [];
+
+ for ( let i = 0, l = json.points.length; i < l; i ++ ) {
+
+ const point = json.points[ i ];
+ this.points.push( new Vector3().fromArray( point ) );
+
+ }
+
+ this.closed = json.closed;
+ this.curveType = json.curveType;
+ this.tension = json.tension;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * Interpolations contains spline and Bézier functions internally used by concrete curve classes.
+ *
+ * Bezier Curves formulas obtained from: https://en.wikipedia.org/wiki/B%C3%A9zier_curve
+ *
+ * @module Interpolations
+ */
+
+/**
+ * Computes a point on a Catmull-Rom spline.
+ *
+ * @param {number} t - The interpolation factor.
+ * @param {number} p0 - The first control point.
+ * @param {number} p1 - The second control point.
+ * @param {number} p2 - The third control point.
+ * @param {number} p3 - The fourth control point.
+ * @return {number} The calculated point on a Catmull-Rom spline.
+ */
+function CatmullRom( t, p0, p1, p2, p3 ) {
+
+ const v0 = ( p2 - p0 ) * 0.5;
+ const v1 = ( p3 - p1 ) * 0.5;
+ const t2 = t * t;
+ const t3 = t * t2;
+ return ( 2 * p1 - 2 * p2 + v0 + v1 ) * t3 + ( -3 * p1 + 3 * p2 - 2 * v0 - v1 ) * t2 + v0 * t + p1;
+
+}
+
+//
+
+function QuadraticBezierP0( t, p ) {
+
+ const k = 1 - t;
+ return k * k * p;
+
+}
+
+function QuadraticBezierP1( t, p ) {
+
+ return 2 * ( 1 - t ) * t * p;
+
+}
+
+function QuadraticBezierP2( t, p ) {
+
+ return t * t * p;
+
+}
+
+/**
+ * Computes a point on a Quadratic Bezier curve.
+ *
+ * @param {number} t - The interpolation factor.
+ * @param {number} p0 - The first control point.
+ * @param {number} p1 - The second control point.
+ * @param {number} p2 - The third control point.
+ * @return {number} The calculated point on a Quadratic Bezier curve.
+ */
+function QuadraticBezier( t, p0, p1, p2 ) {
+
+ return QuadraticBezierP0( t, p0 ) + QuadraticBezierP1( t, p1 ) +
+ QuadraticBezierP2( t, p2 );
+
+}
+
+//
+
+function CubicBezierP0( t, p ) {
+
+ const k = 1 - t;
+ return k * k * k * p;
+
+}
+
+function CubicBezierP1( t, p ) {
+
+ const k = 1 - t;
+ return 3 * k * k * t * p;
+
+}
+
+function CubicBezierP2( t, p ) {
+
+ return 3 * ( 1 - t ) * t * t * p;
+
+}
+
+function CubicBezierP3( t, p ) {
+
+ return t * t * t * p;
+
+}
+
+/**
+ * Computes a point on a Cubic Bezier curve.
+ *
+ * @param {number} t - The interpolation factor.
+ * @param {number} p0 - The first control point.
+ * @param {number} p1 - The second control point.
+ * @param {number} p2 - The third control point.
+ * @param {number} p3 - The fourth control point.
+ * @return {number} The calculated point on a Cubic Bezier curve.
+ */
+function CubicBezier( t, p0, p1, p2, p3 ) {
+
+ return CubicBezierP0( t, p0 ) + CubicBezierP1( t, p1 ) + CubicBezierP2( t, p2 ) +
+ CubicBezierP3( t, p3 );
+
+}
+
+/**
+ * A curve representing a 2D Cubic Bezier curve.
+ *
+ * ```js
+ * const curve = new THREE.CubicBezierCurve(
+ * new THREE.Vector2( - 0, 0 ),
+ * new THREE.Vector2( - 5, 15 ),
+ * new THREE.Vector2( 20, 15 ),
+ * new THREE.Vector2( 10, 0 )
+ * );
+ *
+ * const points = curve.getPoints( 50 );
+ * const geometry = new THREE.BufferGeometry().setFromPoints( points );
+ *
+ * const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
+ *
+ * // Create the final object to add to the scene
+ * const curveObject = new THREE.Line( geometry, material );
+ * ```
+ *
+ * @augments Curve
+ */
+class CubicBezierCurve extends Curve {
+
+ /**
+ * Constructs a new Cubic Bezier curve.
+ *
+ * @param {Vector2} [v0] - The start point.
+ * @param {Vector2} [v1] - The first control point.
+ * @param {Vector2} [v2] - The second control point.
+ * @param {Vector2} [v3] - The end point.
+ */
+ constructor( v0 = new Vector2(), v1 = new Vector2(), v2 = new Vector2(), v3 = new Vector2() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCubicBezierCurve = true;
+
+ this.type = 'CubicBezierCurve';
+
+ /**
+ * The start point.
+ *
+ * @type {Vector2}
+ */
+ this.v0 = v0;
+
+ /**
+ * The first control point.
+ *
+ * @type {Vector2}
+ */
+ this.v1 = v1;
+
+ /**
+ * The second control point.
+ *
+ * @type {Vector2}
+ */
+ this.v2 = v2;
+
+ /**
+ * The end point.
+ *
+ * @type {Vector2}
+ */
+ this.v3 = v3;
+
+ }
+
+ /**
+ * Returns a point on the curve.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {Vector2} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector2} The position on the curve.
+ */
+ getPoint( t, optionalTarget = new Vector2() ) {
+
+ const point = optionalTarget;
+
+ const v0 = this.v0, v1 = this.v1, v2 = this.v2, v3 = this.v3;
+
+ point.set(
+ CubicBezier( t, v0.x, v1.x, v2.x, v3.x ),
+ CubicBezier( t, v0.y, v1.y, v2.y, v3.y )
+ );
+
+ return point;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.v0.copy( source.v0 );
+ this.v1.copy( source.v1 );
+ this.v2.copy( source.v2 );
+ this.v3.copy( source.v3 );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.v0 = this.v0.toArray();
+ data.v1 = this.v1.toArray();
+ data.v2 = this.v2.toArray();
+ data.v3 = this.v3.toArray();
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.v0.fromArray( json.v0 );
+ this.v1.fromArray( json.v1 );
+ this.v2.fromArray( json.v2 );
+ this.v3.fromArray( json.v3 );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing a 3D Cubic Bezier curve.
+ *
+ * @augments Curve
+ */
+class CubicBezierCurve3 extends Curve {
+
+ /**
+ * Constructs a new Cubic Bezier curve.
+ *
+ * @param {Vector3} [v0] - The start point.
+ * @param {Vector3} [v1] - The first control point.
+ * @param {Vector3} [v2] - The second control point.
+ * @param {Vector3} [v3] - The end point.
+ */
+ constructor( v0 = new Vector3(), v1 = new Vector3(), v2 = new Vector3(), v3 = new Vector3() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCubicBezierCurve3 = true;
+
+ this.type = 'CubicBezierCurve3';
+
+ /**
+ * The start point.
+ *
+ * @type {Vector3}
+ */
+ this.v0 = v0;
+
+ /**
+ * The first control point.
+ *
+ * @type {Vector3}
+ */
+ this.v1 = v1;
+
+ /**
+ * The second control point.
+ *
+ * @type {Vector3}
+ */
+ this.v2 = v2;
+
+ /**
+ * The end point.
+ *
+ * @type {Vector3}
+ */
+ this.v3 = v3;
+
+ }
+
+ /**
+ * Returns a point on the curve.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {Vector3} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector3} The position on the curve.
+ */
+ getPoint( t, optionalTarget = new Vector3() ) {
+
+ const point = optionalTarget;
+
+ const v0 = this.v0, v1 = this.v1, v2 = this.v2, v3 = this.v3;
+
+ point.set(
+ CubicBezier( t, v0.x, v1.x, v2.x, v3.x ),
+ CubicBezier( t, v0.y, v1.y, v2.y, v3.y ),
+ CubicBezier( t, v0.z, v1.z, v2.z, v3.z )
+ );
+
+ return point;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.v0.copy( source.v0 );
+ this.v1.copy( source.v1 );
+ this.v2.copy( source.v2 );
+ this.v3.copy( source.v3 );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.v0 = this.v0.toArray();
+ data.v1 = this.v1.toArray();
+ data.v2 = this.v2.toArray();
+ data.v3 = this.v3.toArray();
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.v0.fromArray( json.v0 );
+ this.v1.fromArray( json.v1 );
+ this.v2.fromArray( json.v2 );
+ this.v3.fromArray( json.v3 );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing a 2D line segment.
+ *
+ * @augments Curve
+ */
+class LineCurve extends Curve {
+
+ /**
+ * Constructs a new line curve.
+ *
+ * @param {Vector2} [v1] - The start point.
+ * @param {Vector2} [v2] - The end point.
+ */
+ constructor( v1 = new Vector2(), v2 = new Vector2() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLineCurve = true;
+
+ this.type = 'LineCurve';
+
+ /**
+ * The start point.
+ *
+ * @type {Vector2}
+ */
+ this.v1 = v1;
+
+ /**
+ * The end point.
+ *
+ * @type {Vector2}
+ */
+ this.v2 = v2;
+
+ }
+
+ /**
+ * Returns a point on the line.
+ *
+ * @param {number} t - A interpolation factor representing a position on the line. Must be in the range `[0,1]`.
+ * @param {Vector2} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector2} The position on the line.
+ */
+ getPoint( t, optionalTarget = new Vector2() ) {
+
+ const point = optionalTarget;
+
+ if ( t === 1 ) {
+
+ point.copy( this.v2 );
+
+ } else {
+
+ point.copy( this.v2 ).sub( this.v1 );
+ point.multiplyScalar( t ).add( this.v1 );
+
+ }
+
+ return point;
+
+ }
+
+ // Line curve is linear, so we can overwrite default getPointAt
+ getPointAt( u, optionalTarget ) {
+
+ return this.getPoint( u, optionalTarget );
+
+ }
+
+ getTangent( t, optionalTarget = new Vector2() ) {
+
+ return optionalTarget.subVectors( this.v2, this.v1 ).normalize();
+
+ }
+
+ getTangentAt( u, optionalTarget ) {
+
+ return this.getTangent( u, optionalTarget );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.v1.copy( source.v1 );
+ this.v2.copy( source.v2 );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.v1 = this.v1.toArray();
+ data.v2 = this.v2.toArray();
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.v1.fromArray( json.v1 );
+ this.v2.fromArray( json.v2 );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing a 3D line segment.
+ *
+ * @augments Curve
+ */
+class LineCurve3 extends Curve {
+
+ /**
+ * Constructs a new line curve.
+ *
+ * @param {Vector3} [v1] - The start point.
+ * @param {Vector3} [v2] - The end point.
+ */
+ constructor( v1 = new Vector3(), v2 = new Vector3() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLineCurve3 = true;
+
+ this.type = 'LineCurve3';
+
+ /**
+ * The start point.
+ *
+ * @type {Vector3}
+ */
+ this.v1 = v1;
+
+ /**
+ * The end point.
+ *
+ * @type {Vector2}
+ */
+ this.v2 = v2;
+
+ }
+
+ /**
+ * Returns a point on the line.
+ *
+ * @param {number} t - A interpolation factor representing a position on the line. Must be in the range `[0,1]`.
+ * @param {Vector3} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector3} The position on the line.
+ */
+ getPoint( t, optionalTarget = new Vector3() ) {
+
+ const point = optionalTarget;
+
+ if ( t === 1 ) {
+
+ point.copy( this.v2 );
+
+ } else {
+
+ point.copy( this.v2 ).sub( this.v1 );
+ point.multiplyScalar( t ).add( this.v1 );
+
+ }
+
+ return point;
+
+ }
+
+ // Line curve is linear, so we can overwrite default getPointAt
+ getPointAt( u, optionalTarget ) {
+
+ return this.getPoint( u, optionalTarget );
+
+ }
+
+ getTangent( t, optionalTarget = new Vector3() ) {
+
+ return optionalTarget.subVectors( this.v2, this.v1 ).normalize();
+
+ }
+
+ getTangentAt( u, optionalTarget ) {
+
+ return this.getTangent( u, optionalTarget );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.v1.copy( source.v1 );
+ this.v2.copy( source.v2 );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.v1 = this.v1.toArray();
+ data.v2 = this.v2.toArray();
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.v1.fromArray( json.v1 );
+ this.v2.fromArray( json.v2 );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing a 2D Quadratic Bezier curve.
+ *
+ * ```js
+ * const curve = new THREE.QuadraticBezierCurve(
+ * new THREE.Vector2( - 10, 0 ),
+ * new THREE.Vector2( 20, 15 ),
+ * new THREE.Vector2( 10, 0 )
+ * )
+ *
+ * const points = curve.getPoints( 50 );
+ * const geometry = new THREE.BufferGeometry().setFromPoints( points );
+ *
+ * const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
+ *
+ * // Create the final object to add to the scene
+ * const curveObject = new THREE.Line( geometry, material );
+ * ```
+ *
+ * @augments Curve
+ */
+class QuadraticBezierCurve extends Curve {
+
+ /**
+ * Constructs a new Quadratic Bezier curve.
+ *
+ * @param {Vector2} [v0] - The start point.
+ * @param {Vector2} [v1] - The control point.
+ * @param {Vector2} [v2] - The end point.
+ */
+ constructor( v0 = new Vector2(), v1 = new Vector2(), v2 = new Vector2() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isQuadraticBezierCurve = true;
+
+ this.type = 'QuadraticBezierCurve';
+
+ /**
+ * The start point.
+ *
+ * @type {Vector2}
+ */
+ this.v0 = v0;
+
+ /**
+ * The control point.
+ *
+ * @type {Vector2}
+ */
+ this.v1 = v1;
+
+ /**
+ * The end point.
+ *
+ * @type {Vector2}
+ */
+ this.v2 = v2;
+
+ }
+
+ /**
+ * Returns a point on the curve.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {Vector2} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector2} The position on the curve.
+ */
+ getPoint( t, optionalTarget = new Vector2() ) {
+
+ const point = optionalTarget;
+
+ const v0 = this.v0, v1 = this.v1, v2 = this.v2;
+
+ point.set(
+ QuadraticBezier( t, v0.x, v1.x, v2.x ),
+ QuadraticBezier( t, v0.y, v1.y, v2.y )
+ );
+
+ return point;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.v0.copy( source.v0 );
+ this.v1.copy( source.v1 );
+ this.v2.copy( source.v2 );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.v0 = this.v0.toArray();
+ data.v1 = this.v1.toArray();
+ data.v2 = this.v2.toArray();
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.v0.fromArray( json.v0 );
+ this.v1.fromArray( json.v1 );
+ this.v2.fromArray( json.v2 );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing a 3D Quadratic Bezier curve.
+ *
+ * @augments Curve
+ */
+class QuadraticBezierCurve3 extends Curve {
+
+ /**
+ * Constructs a new Quadratic Bezier curve.
+ *
+ * @param {Vector3} [v0] - The start point.
+ * @param {Vector3} [v1] - The control point.
+ * @param {Vector3} [v2] - The end point.
+ */
+ constructor( v0 = new Vector3(), v1 = new Vector3(), v2 = new Vector3() ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isQuadraticBezierCurve3 = true;
+
+ this.type = 'QuadraticBezierCurve3';
+
+ /**
+ * The start point.
+ *
+ * @type {Vector3}
+ */
+ this.v0 = v0;
+
+ /**
+ * The control point.
+ *
+ * @type {Vector3}
+ */
+ this.v1 = v1;
+
+ /**
+ * The end point.
+ *
+ * @type {Vector3}
+ */
+ this.v2 = v2;
+
+ }
+
+ /**
+ * Returns a point on the curve.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {Vector3} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector3} The position on the curve.
+ */
+ getPoint( t, optionalTarget = new Vector3() ) {
+
+ const point = optionalTarget;
+
+ const v0 = this.v0, v1 = this.v1, v2 = this.v2;
+
+ point.set(
+ QuadraticBezier( t, v0.x, v1.x, v2.x ),
+ QuadraticBezier( t, v0.y, v1.y, v2.y ),
+ QuadraticBezier( t, v0.z, v1.z, v2.z )
+ );
+
+ return point;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.v0.copy( source.v0 );
+ this.v1.copy( source.v1 );
+ this.v2.copy( source.v2 );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.v0 = this.v0.toArray();
+ data.v1 = this.v1.toArray();
+ data.v2 = this.v2.toArray();
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.v0.fromArray( json.v0 );
+ this.v1.fromArray( json.v1 );
+ this.v2.fromArray( json.v2 );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A curve representing a 2D spline curve.
+ *
+ * ```js
+ * // Create a sine-like wave
+ * const curve = new THREE.SplineCurve( [
+ * new THREE.Vector2( -10, 0 ),
+ * new THREE.Vector2( -5, 5 ),
+ * new THREE.Vector2( 0, 0 ),
+ * new THREE.Vector2( 5, -5 ),
+ * new THREE.Vector2( 10, 0 )
+ * ] );
+ *
+ * const points = curve.getPoints( 50 );
+ * const geometry = new THREE.BufferGeometry().setFromPoints( points );
+ *
+ * const material = new THREE.LineBasicMaterial( { color: 0xff0000 } );
+ *
+ * // Create the final object to add to the scene
+ * const splineObject = new THREE.Line( geometry, material );
+ * ```
+ *
+ * @augments Curve
+ */
+class SplineCurve extends Curve {
+
+ /**
+ * Constructs a new 2D spline curve.
+ *
+ * @param {Array} [points] - An array of 2D points defining the curve.
+ */
+ constructor( points = [] ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSplineCurve = true;
+
+ this.type = 'SplineCurve';
+
+ /**
+ * An array of 2D points defining the curve.
+ *
+ * @type {Array}
+ */
+ this.points = points;
+
+ }
+
+ /**
+ * Returns a point on the curve.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {Vector2} [optionalTarget] - The optional target vector the result is written to.
+ * @return {Vector2} The position on the curve.
+ */
+ getPoint( t, optionalTarget = new Vector2() ) {
+
+ const point = optionalTarget;
+
+ const points = this.points;
+ const p = ( points.length - 1 ) * t;
+
+ const intPoint = Math.floor( p );
+ const weight = p - intPoint;
+
+ const p0 = points[ intPoint === 0 ? intPoint : intPoint - 1 ];
+ const p1 = points[ intPoint ];
+ const p2 = points[ intPoint > points.length - 2 ? points.length - 1 : intPoint + 1 ];
+ const p3 = points[ intPoint > points.length - 3 ? points.length - 1 : intPoint + 2 ];
+
+ point.set(
+ CatmullRom( weight, p0.x, p1.x, p2.x, p3.x ),
+ CatmullRom( weight, p0.y, p1.y, p2.y, p3.y )
+ );
+
+ return point;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.points = [];
+
+ for ( let i = 0, l = source.points.length; i < l; i ++ ) {
+
+ const point = source.points[ i ];
+
+ this.points.push( point.clone() );
+
+ }
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.points = [];
+
+ for ( let i = 0, l = this.points.length; i < l; i ++ ) {
+
+ const point = this.points[ i ];
+ data.points.push( point.toArray() );
+
+ }
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.points = [];
+
+ for ( let i = 0, l = json.points.length; i < l; i ++ ) {
+
+ const point = json.points[ i ];
+ this.points.push( new Vector2().fromArray( point ) );
+
+ }
+
+ return this;
+
+ }
+
+}
+
+var Curves = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ ArcCurve: ArcCurve,
+ CatmullRomCurve3: CatmullRomCurve3,
+ CubicBezierCurve: CubicBezierCurve,
+ CubicBezierCurve3: CubicBezierCurve3,
+ EllipseCurve: EllipseCurve,
+ LineCurve: LineCurve,
+ LineCurve3: LineCurve3,
+ QuadraticBezierCurve: QuadraticBezierCurve,
+ QuadraticBezierCurve3: QuadraticBezierCurve3,
+ SplineCurve: SplineCurve
+});
+
+/**
+ * A base class extending {@link Curve}. `CurvePath` is simply an
+ * array of connected curves, but retains the API of a curve.
+ *
+ * @augments Curve
+ */
+class CurvePath extends Curve {
+
+ /**
+ * Constructs a new curve path.
+ */
+ constructor() {
+
+ super();
+
+ this.type = 'CurvePath';
+
+ /**
+ * An array of curves defining the
+ * path.
+ *
+ * @type {Array}
+ */
+ this.curves = [];
+
+ /**
+ * Whether the path should automatically be closed
+ * by a line curve.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.autoClose = false;
+
+ }
+
+ /**
+ * Adds a curve to this curve path.
+ *
+ * @param {Curve} curve - The curve to add.
+ */
+ add( curve ) {
+
+ this.curves.push( curve );
+
+ }
+
+ /**
+ * Adds a line curve to close the path.
+ *
+ * @return {CurvePath} A reference to this curve path.
+ */
+ closePath() {
+
+ // Add a line curve if start and end of lines are not connected
+ const startPoint = this.curves[ 0 ].getPoint( 0 );
+ const endPoint = this.curves[ this.curves.length - 1 ].getPoint( 1 );
+
+ if ( ! startPoint.equals( endPoint ) ) {
+
+ const lineType = ( startPoint.isVector2 === true ) ? 'LineCurve' : 'LineCurve3';
+ this.curves.push( new Curves[ lineType ]( endPoint, startPoint ) );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * This method returns a vector in 2D or 3D space (depending on the curve definitions)
+ * for the given interpolation factor.
+ *
+ * @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
+ * @param {(Vector2|Vector3)} [optionalTarget] - The optional target vector the result is written to.
+ * @return {?(Vector2|Vector3)} The position on the curve. It can be a 2D or 3D vector depending on the curve definition.
+ */
+ getPoint( t, optionalTarget ) {
+
+ // To get accurate point with reference to
+ // entire path distance at time t,
+ // following has to be done:
+
+ // 1. Length of each sub path have to be known
+ // 2. Locate and identify type of curve
+ // 3. Get t for the curve
+ // 4. Return curve.getPointAt(t')
+
+ const d = t * this.getLength();
+ const curveLengths = this.getCurveLengths();
+ let i = 0;
+
+ // To think about boundaries points.
+
+ while ( i < curveLengths.length ) {
+
+ if ( curveLengths[ i ] >= d ) {
+
+ const diff = curveLengths[ i ] - d;
+ const curve = this.curves[ i ];
+
+ const segmentLength = curve.getLength();
+ const u = segmentLength === 0 ? 0 : 1 - diff / segmentLength;
+
+ return curve.getPointAt( u, optionalTarget );
+
+ }
+
+ i ++;
+
+ }
+
+ return null;
+
+ // loop where sum != 0, sum > d , sum+1 } The curve lengths.
+ */
+ getCurveLengths() {
+
+ // Compute lengths and cache them
+ // We cannot overwrite getLengths() because UtoT mapping uses it.
+ // We use cache values if curves and cache array are same length
+
+ if ( this.cacheLengths && this.cacheLengths.length === this.curves.length ) {
+
+ return this.cacheLengths;
+
+ }
+
+ // Get length of sub-curve
+ // Push sums into cached array
+
+ const lengths = [];
+ let sums = 0;
+
+ for ( let i = 0, l = this.curves.length; i < l; i ++ ) {
+
+ sums += this.curves[ i ].getLength();
+ lengths.push( sums );
+
+ }
+
+ this.cacheLengths = lengths;
+
+ return lengths;
+
+ }
+
+ getSpacedPoints( divisions = 40 ) {
+
+ const points = [];
+
+ for ( let i = 0; i <= divisions; i ++ ) {
+
+ points.push( this.getPoint( i / divisions ) );
+
+ }
+
+ if ( this.autoClose ) {
+
+ points.push( points[ 0 ] );
+
+ }
+
+ return points;
+
+ }
+
+ getPoints( divisions = 12 ) {
+
+ const points = [];
+ let last;
+
+ for ( let i = 0, curves = this.curves; i < curves.length; i ++ ) {
+
+ const curve = curves[ i ];
+ const resolution = curve.isEllipseCurve ? divisions * 2
+ : ( curve.isLineCurve || curve.isLineCurve3 ) ? 1
+ : curve.isSplineCurve ? divisions * curve.points.length
+ : divisions;
+
+ const pts = curve.getPoints( resolution );
+
+ for ( let j = 0; j < pts.length; j ++ ) {
+
+ const point = pts[ j ];
+
+ if ( last && last.equals( point ) ) continue; // ensures no consecutive points are duplicates
+
+ points.push( point );
+ last = point;
+
+ }
+
+ }
+
+ if ( this.autoClose && points.length > 1 && ! points[ points.length - 1 ].equals( points[ 0 ] ) ) {
+
+ points.push( points[ 0 ] );
+
+ }
+
+ return points;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.curves = [];
+
+ for ( let i = 0, l = source.curves.length; i < l; i ++ ) {
+
+ const curve = source.curves[ i ];
+
+ this.curves.push( curve.clone() );
+
+ }
+
+ this.autoClose = source.autoClose;
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.autoClose = this.autoClose;
+ data.curves = [];
+
+ for ( let i = 0, l = this.curves.length; i < l; i ++ ) {
+
+ const curve = this.curves[ i ];
+ data.curves.push( curve.toJSON() );
+
+ }
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.autoClose = json.autoClose;
+ this.curves = [];
+
+ for ( let i = 0, l = json.curves.length; i < l; i ++ ) {
+
+ const curve = json.curves[ i ];
+ this.curves.push( new Curves[ curve.type ]().fromJSON( curve ) );
+
+ }
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A 2D path representation. The class provides methods for creating paths
+ * and contours of 2D shapes similar to the 2D Canvas API.
+ *
+ * ```js
+ * const path = new THREE.Path();
+ *
+ * path.lineTo( 0, 0.8 );
+ * path.quadraticCurveTo( 0, 1, 0.2, 1 );
+ * path.lineTo( 1, 1 );
+ *
+ * const points = path.getPoints();
+ *
+ * const geometry = new THREE.BufferGeometry().setFromPoints( points );
+ * const material = new THREE.LineBasicMaterial( { color: 0xffffff } );
+ *
+ * const line = new THREE.Line( geometry, material );
+ * scene.add( line );
+ * ```
+ *
+ * @augments CurvePath
+ */
+class Path extends CurvePath {
+
+ /**
+ * Constructs a new path.
+ *
+ * @param {Array} [points] - An array of 2D points defining the path.
+ */
+ constructor( points ) {
+
+ super();
+
+ this.type = 'Path';
+
+ /**
+ * The current offset of the path. Any new curve added will start here.
+ *
+ * @type {Vector2}
+ */
+ this.currentPoint = new Vector2();
+
+ if ( points ) {
+
+ this.setFromPoints( points );
+
+ }
+
+ }
+
+ /**
+ * Creates a path from the given list of points. The points are added
+ * to the path as instances of {@link LineCurve}.
+ *
+ * @param {Array} points - An array of 2D points.
+ * @return {Path} A reference to this path.
+ */
+ setFromPoints( points ) {
+
+ this.moveTo( points[ 0 ].x, points[ 0 ].y );
+
+ for ( let i = 1, l = points.length; i < l; i ++ ) {
+
+ this.lineTo( points[ i ].x, points[ i ].y );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Moves {@link Path#currentPoint} to the given point.
+ *
+ * @param {number} x - The x coordinate.
+ * @param {number} y - The y coordinate.
+ * @return {Path} A reference to this path.
+ */
+ moveTo( x, y ) {
+
+ this.currentPoint.set( x, y ); // TODO consider referencing vectors instead of copying?
+
+ return this;
+
+ }
+
+ /**
+ * Adds an instance of {@link LineCurve} to the path by connecting
+ * the current point with the given one.
+ *
+ * @param {number} x - The x coordinate of the end point.
+ * @param {number} y - The y coordinate of the end point.
+ * @return {Path} A reference to this path.
+ */
+ lineTo( x, y ) {
+
+ const curve = new LineCurve( this.currentPoint.clone(), new Vector2( x, y ) );
+ this.curves.push( curve );
+
+ this.currentPoint.set( x, y );
+
+ return this;
+
+ }
+
+ /**
+ * Adds an instance of {@link QuadraticBezierCurve} to the path by connecting
+ * the current point with the given one.
+ *
+ * @param {number} aCPx - The x coordinate of the control point.
+ * @param {number} aCPy - The y coordinate of the control point.
+ * @param {number} aX - The x coordinate of the end point.
+ * @param {number} aY - The y coordinate of the end point.
+ * @return {Path} A reference to this path.
+ */
+ quadraticCurveTo( aCPx, aCPy, aX, aY ) {
+
+ const curve = new QuadraticBezierCurve(
+ this.currentPoint.clone(),
+ new Vector2( aCPx, aCPy ),
+ new Vector2( aX, aY )
+ );
+
+ this.curves.push( curve );
+
+ this.currentPoint.set( aX, aY );
+
+ return this;
+
+ }
+
+ /**
+ * Adds an instance of {@link CubicBezierCurve} to the path by connecting
+ * the current point with the given one.
+ *
+ * @param {number} aCP1x - The x coordinate of the first control point.
+ * @param {number} aCP1y - The y coordinate of the first control point.
+ * @param {number} aCP2x - The x coordinate of the second control point.
+ * @param {number} aCP2y - The y coordinate of the second control point.
+ * @param {number} aX - The x coordinate of the end point.
+ * @param {number} aY - The y coordinate of the end point.
+ * @return {Path} A reference to this path.
+ */
+ bezierCurveTo( aCP1x, aCP1y, aCP2x, aCP2y, aX, aY ) {
+
+ const curve = new CubicBezierCurve(
+ this.currentPoint.clone(),
+ new Vector2( aCP1x, aCP1y ),
+ new Vector2( aCP2x, aCP2y ),
+ new Vector2( aX, aY )
+ );
+
+ this.curves.push( curve );
+
+ this.currentPoint.set( aX, aY );
+
+ return this;
+
+ }
+
+ /**
+ * Adds an instance of {@link SplineCurve} to the path by connecting
+ * the current point with the given list of points.
+ *
+ * @param {Array} pts - An array of points in 2D space.
+ * @return {Path} A reference to this path.
+ */
+ splineThru( pts ) {
+
+ const npts = [ this.currentPoint.clone() ].concat( pts );
+
+ const curve = new SplineCurve( npts );
+ this.curves.push( curve );
+
+ this.currentPoint.copy( pts[ pts.length - 1 ] );
+
+ return this;
+
+ }
+
+ /**
+ * Adds an arc as an instance of {@link EllipseCurve} to the path, positioned relative
+ * to the current point.
+ *
+ * @param {number} [aX=0] - The x coordinate of the center of the arc offsetted from the previous curve.
+ * @param {number} [aY=0] - The y coordinate of the center of the arc offsetted from the previous curve.
+ * @param {number} [aRadius=1] - The radius of the arc.
+ * @param {number} [aStartAngle=0] - The start angle in radians.
+ * @param {number} [aEndAngle=Math.PI*2] - The end angle in radians.
+ * @param {boolean} [aClockwise=false] - Whether to sweep the arc clockwise or not.
+ * @return {Path} A reference to this path.
+ */
+ arc( aX, aY, aRadius, aStartAngle, aEndAngle, aClockwise ) {
+
+ const x0 = this.currentPoint.x;
+ const y0 = this.currentPoint.y;
+
+ this.absarc( aX + x0, aY + y0, aRadius,
+ aStartAngle, aEndAngle, aClockwise );
+
+ return this;
+
+ }
+
+ /**
+ * Adds an absolutely positioned arc as an instance of {@link EllipseCurve} to the path.
+ *
+ * @param {number} [aX=0] - The x coordinate of the center of the arc.
+ * @param {number} [aY=0] - The y coordinate of the center of the arc.
+ * @param {number} [aRadius=1] - The radius of the arc.
+ * @param {number} [aStartAngle=0] - The start angle in radians.
+ * @param {number} [aEndAngle=Math.PI*2] - The end angle in radians.
+ * @param {boolean} [aClockwise=false] - Whether to sweep the arc clockwise or not.
+ * @return {Path} A reference to this path.
+ */
+ absarc( aX, aY, aRadius, aStartAngle, aEndAngle, aClockwise ) {
+
+ this.absellipse( aX, aY, aRadius, aRadius, aStartAngle, aEndAngle, aClockwise );
+
+ return this;
+
+ }
+
+ /**
+ * Adds an ellipse as an instance of {@link EllipseCurve} to the path, positioned relative
+ * to the current point
+ *
+ * @param {number} [aX=0] - The x coordinate of the center of the ellipse offsetted from the previous curve.
+ * @param {number} [aY=0] - The y coordinate of the center of the ellipse offsetted from the previous curve.
+ * @param {number} [xRadius=1] - The radius of the ellipse in the x axis.
+ * @param {number} [yRadius=1] - The radius of the ellipse in the y axis.
+ * @param {number} [aStartAngle=0] - The start angle in radians.
+ * @param {number} [aEndAngle=Math.PI*2] - The end angle in radians.
+ * @param {boolean} [aClockwise=false] - Whether to sweep the ellipse clockwise or not.
+ * @param {number} [aRotation=0] - The rotation angle of the ellipse in radians, counterclockwise from the positive X axis.
+ * @return {Path} A reference to this path.
+ */
+ ellipse( aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation ) {
+
+ const x0 = this.currentPoint.x;
+ const y0 = this.currentPoint.y;
+
+ this.absellipse( aX + x0, aY + y0, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation );
+
+ return this;
+
+ }
+
+ /**
+ * Adds an absolutely positioned ellipse as an instance of {@link EllipseCurve} to the path.
+ *
+ * @param {number} [aX=0] - The x coordinate of the absolute center of the ellipse.
+ * @param {number} [aY=0] - The y coordinate of the absolute center of the ellipse.
+ * @param {number} [xRadius=1] - The radius of the ellipse in the x axis.
+ * @param {number} [yRadius=1] - The radius of the ellipse in the y axis.
+ * @param {number} [aStartAngle=0] - The start angle in radians.
+ * @param {number} [aEndAngle=Math.PI*2] - The end angle in radians.
+ * @param {boolean} [aClockwise=false] - Whether to sweep the ellipse clockwise or not.
+ * @param {number} [aRotation=0] - The rotation angle of the ellipse in radians, counterclockwise from the positive X axis.
+ * @return {Path} A reference to this path.
+ */
+ absellipse( aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation ) {
+
+ const curve = new EllipseCurve( aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation );
+
+ if ( this.curves.length > 0 ) {
+
+ // if a previous curve is present, attempt to join
+ const firstPoint = curve.getPoint( 0 );
+
+ if ( ! firstPoint.equals( this.currentPoint ) ) {
+
+ this.lineTo( firstPoint.x, firstPoint.y );
+
+ }
+
+ }
+
+ this.curves.push( curve );
+
+ const lastPoint = curve.getPoint( 1 );
+ this.currentPoint.copy( lastPoint );
+
+ return this;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.currentPoint.copy( source.currentPoint );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.currentPoint = this.currentPoint.toArray();
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.currentPoint.fromArray( json.currentPoint );
+
+ return this;
+
+ }
+
+}
+
+/**
+ * Defines an arbitrary 2d shape plane using paths with optional holes. It
+ * can be used with {@link ExtrudeGeometry}, {@link ShapeGeometry}, to get
+ * points, or to get triangulated faces.
+ *
+ * ```js
+ * const heartShape = new THREE.Shape();
+ *
+ * heartShape.moveTo( 25, 25 );
+ * heartShape.bezierCurveTo( 25, 25, 20, 0, 0, 0 );
+ * heartShape.bezierCurveTo( - 30, 0, - 30, 35, - 30, 35 );
+ * heartShape.bezierCurveTo( - 30, 55, - 10, 77, 25, 95 );
+ * heartShape.bezierCurveTo( 60, 77, 80, 55, 80, 35 );
+ * heartShape.bezierCurveTo( 80, 35, 80, 0, 50, 0 );
+ * heartShape.bezierCurveTo( 35, 0, 25, 25, 25, 25 );
+ *
+ * const extrudeSettings = {
+ * depth: 8,
+ * bevelEnabled: true,
+ * bevelSegments: 2,
+ * steps: 2,
+ * bevelSize: 1,
+ * bevelThickness: 1
+ * };
+ *
+ * const geometry = new THREE.ExtrudeGeometry( heartShape, extrudeSettings );
+ * const mesh = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial() );
+ * ```
+ *
+ * @augments Path
+ */
+class Shape extends Path {
+
+ /**
+ * Constructs a new shape.
+ *
+ * @param {Array} [points] - An array of 2D points defining the shape.
+ */
+ constructor( points ) {
+
+ super( points );
+
+ /**
+ * The UUID of the shape.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ this.type = 'Shape';
+
+ /**
+ * Defines the holes in the shape. Hole definitions must use the
+ * opposite winding order (CW/CCW) than the outer shape.
+ *
+ * @type {Array}
+ * @readonly
+ */
+ this.holes = [];
+
+ }
+
+ /**
+ * Returns an array representing each contour of the holes
+ * as a list of 2D points.
+ *
+ * @param {number} divisions - The fineness of the result.
+ * @return {Array>} The holes as a series of 2D points.
+ */
+ getPointsHoles( divisions ) {
+
+ const holesPts = [];
+
+ for ( let i = 0, l = this.holes.length; i < l; i ++ ) {
+
+ holesPts[ i ] = this.holes[ i ].getPoints( divisions );
+
+ }
+
+ return holesPts;
+
+ }
+
+ // get points of shape and holes (keypoints based on segments parameter)
+
+ /**
+ * Returns an object that holds contour data for the shape and its holes as
+ * arrays of 2D points.
+ *
+ * @param {number} divisions - The fineness of the result.
+ * @return {{shape:Array,holes:Array>}} An object with contour data.
+ */
+ extractPoints( divisions ) {
+
+ return {
+
+ shape: this.getPoints( divisions ),
+ holes: this.getPointsHoles( divisions )
+
+ };
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.holes = [];
+
+ for ( let i = 0, l = source.holes.length; i < l; i ++ ) {
+
+ const hole = source.holes[ i ];
+
+ this.holes.push( hole.clone() );
+
+ }
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.uuid = this.uuid;
+ data.holes = [];
+
+ for ( let i = 0, l = this.holes.length; i < l; i ++ ) {
+
+ const hole = this.holes[ i ];
+ data.holes.push( hole.toJSON() );
+
+ }
+
+ return data;
+
+ }
+
+ fromJSON( json ) {
+
+ super.fromJSON( json );
+
+ this.uuid = json.uuid;
+ this.holes = [];
+
+ for ( let i = 0, l = json.holes.length; i < l; i ++ ) {
+
+ const hole = json.holes[ i ];
+ this.holes.push( new Path().fromJSON( hole ) );
+
+ }
+
+ return this;
+
+ }
+
+}
+
+/* eslint-disable */
+// copy of mapbox/earcut version 3.0.2
+// https://github.com/mapbox/earcut/tree/v3.0.2
+
+function earcut(data, holeIndices, dim = 2) {
+
+ const hasHoles = holeIndices && holeIndices.length;
+ const outerLen = hasHoles ? holeIndices[0] * dim : data.length;
+ let outerNode = linkedList(data, 0, outerLen, dim, true);
+ const triangles = [];
+
+ if (!outerNode || outerNode.next === outerNode.prev) return triangles;
+
+ let minX, minY, invSize;
+
+ if (hasHoles) outerNode = eliminateHoles(data, holeIndices, outerNode, dim);
+
+ // if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox
+ if (data.length > 80 * dim) {
+ minX = data[0];
+ minY = data[1];
+ let maxX = minX;
+ let maxY = minY;
+
+ for (let i = dim; i < outerLen; i += dim) {
+ const x = data[i];
+ const y = data[i + 1];
+ if (x < minX) minX = x;
+ if (y < minY) minY = y;
+ if (x > maxX) maxX = x;
+ if (y > maxY) maxY = y;
+ }
+
+ // minX, minY and invSize are later used to transform coords into integers for z-order calculation
+ invSize = Math.max(maxX - minX, maxY - minY);
+ invSize = invSize !== 0 ? 32767 / invSize : 0;
+ }
+
+ earcutLinked(outerNode, triangles, dim, minX, minY, invSize, 0);
+
+ return triangles;
+}
+
+// create a circular doubly linked list from polygon points in the specified winding order
+function linkedList(data, start, end, dim, clockwise) {
+ let last;
+
+ if (clockwise === (signedArea(data, start, end, dim) > 0)) {
+ for (let i = start; i < end; i += dim) last = insertNode(i / dim | 0, data[i], data[i + 1], last);
+ } else {
+ for (let i = end - dim; i >= start; i -= dim) last = insertNode(i / dim | 0, data[i], data[i + 1], last);
+ }
+
+ if (last && equals(last, last.next)) {
+ removeNode(last);
+ last = last.next;
+ }
+
+ return last;
+}
+
+// eliminate colinear or duplicate points
+function filterPoints(start, end) {
+ if (!start) return start;
+ if (!end) end = start;
+
+ let p = start,
+ again;
+ do {
+ again = false;
+
+ if (!p.steiner && (equals(p, p.next) || area(p.prev, p, p.next) === 0)) {
+ removeNode(p);
+ p = end = p.prev;
+ if (p === p.next) break;
+ again = true;
+
+ } else {
+ p = p.next;
+ }
+ } while (again || p !== end);
+
+ return end;
+}
+
+// main ear slicing loop which triangulates a polygon (given as a linked list)
+function earcutLinked(ear, triangles, dim, minX, minY, invSize, pass) {
+ if (!ear) return;
+
+ // interlink polygon nodes in z-order
+ if (!pass && invSize) indexCurve(ear, minX, minY, invSize);
+
+ let stop = ear;
+
+ // iterate through ears, slicing them one by one
+ while (ear.prev !== ear.next) {
+ const prev = ear.prev;
+ const next = ear.next;
+
+ if (invSize ? isEarHashed(ear, minX, minY, invSize) : isEar(ear)) {
+ triangles.push(prev.i, ear.i, next.i); // cut off the triangle
+
+ removeNode(ear);
+
+ // skipping the next vertex leads to less sliver triangles
+ ear = next.next;
+ stop = next.next;
+
+ continue;
+ }
+
+ ear = next;
+
+ // if we looped through the whole remaining polygon and can't find any more ears
+ if (ear === stop) {
+ // try filtering points and slicing again
+ if (!pass) {
+ earcutLinked(filterPoints(ear), triangles, dim, minX, minY, invSize, 1);
+
+ // if this didn't work, try curing all small self-intersections locally
+ } else if (pass === 1) {
+ ear = cureLocalIntersections(filterPoints(ear), triangles);
+ earcutLinked(ear, triangles, dim, minX, minY, invSize, 2);
+
+ // as a last resort, try splitting the remaining polygon into two
+ } else if (pass === 2) {
+ splitEarcut(ear, triangles, dim, minX, minY, invSize);
+ }
+
+ break;
+ }
+ }
+}
+
+// check whether a polygon node forms a valid ear with adjacent nodes
+function isEar(ear) {
+ const a = ear.prev,
+ b = ear,
+ c = ear.next;
+
+ if (area(a, b, c) >= 0) return false; // reflex, can't be an ear
+
+ // now make sure we don't have other points inside the potential ear
+ const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
+
+ // triangle bbox
+ const x0 = Math.min(ax, bx, cx),
+ y0 = Math.min(ay, by, cy),
+ x1 = Math.max(ax, bx, cx),
+ y1 = Math.max(ay, by, cy);
+
+ let p = c.next;
+ while (p !== a) {
+ if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 &&
+ pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) &&
+ area(p.prev, p, p.next) >= 0) return false;
+ p = p.next;
+ }
+
+ return true;
+}
+
+function isEarHashed(ear, minX, minY, invSize) {
+ const a = ear.prev,
+ b = ear,
+ c = ear.next;
+
+ if (area(a, b, c) >= 0) return false; // reflex, can't be an ear
+
+ const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
+
+ // triangle bbox
+ const x0 = Math.min(ax, bx, cx),
+ y0 = Math.min(ay, by, cy),
+ x1 = Math.max(ax, bx, cx),
+ y1 = Math.max(ay, by, cy);
+
+ // z-order range for the current triangle bbox;
+ const minZ = zOrder(x0, y0, minX, minY, invSize),
+ maxZ = zOrder(x1, y1, minX, minY, invSize);
+
+ let p = ear.prevZ,
+ n = ear.nextZ;
+
+ // look for points inside the triangle in both directions
+ while (p && p.z >= minZ && n && n.z <= maxZ) {
+ if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
+ pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
+ p = p.prevZ;
+
+ if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
+ pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
+ n = n.nextZ;
+ }
+
+ // look for remaining points in decreasing z-order
+ while (p && p.z >= minZ) {
+ if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
+ pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
+ p = p.prevZ;
+ }
+
+ // look for remaining points in increasing z-order
+ while (n && n.z <= maxZ) {
+ if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
+ pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
+ n = n.nextZ;
+ }
+
+ return true;
+}
+
+// go through all polygon nodes and cure small local self-intersections
+function cureLocalIntersections(start, triangles) {
+ let p = start;
+ do {
+ const a = p.prev,
+ b = p.next.next;
+
+ if (!equals(a, b) && intersects(a, p, p.next, b) && locallyInside(a, b) && locallyInside(b, a)) {
+
+ triangles.push(a.i, p.i, b.i);
+
+ // remove two nodes involved
+ removeNode(p);
+ removeNode(p.next);
+
+ p = start = b;
+ }
+ p = p.next;
+ } while (p !== start);
+
+ return filterPoints(p);
+}
+
+// try splitting polygon into two and triangulate them independently
+function splitEarcut(start, triangles, dim, minX, minY, invSize) {
+ // look for a valid diagonal that divides the polygon into two
+ let a = start;
+ do {
+ let b = a.next.next;
+ while (b !== a.prev) {
+ if (a.i !== b.i && isValidDiagonal(a, b)) {
+ // split the polygon in two by the diagonal
+ let c = splitPolygon(a, b);
+
+ // filter colinear points around the cuts
+ a = filterPoints(a, a.next);
+ c = filterPoints(c, c.next);
+
+ // run earcut on each half
+ earcutLinked(a, triangles, dim, minX, minY, invSize, 0);
+ earcutLinked(c, triangles, dim, minX, minY, invSize, 0);
+ return;
+ }
+ b = b.next;
+ }
+ a = a.next;
+ } while (a !== start);
+}
+
+// link every hole into the outer loop, producing a single-ring polygon without holes
+function eliminateHoles(data, holeIndices, outerNode, dim) {
+ const queue = [];
+
+ for (let i = 0, len = holeIndices.length; i < len; i++) {
+ const start = holeIndices[i] * dim;
+ const end = i < len - 1 ? holeIndices[i + 1] * dim : data.length;
+ const list = linkedList(data, start, end, dim, false);
+ if (list === list.next) list.steiner = true;
+ queue.push(getLeftmost(list));
+ }
+
+ queue.sort(compareXYSlope);
+
+ // process holes from left to right
+ for (let i = 0; i < queue.length; i++) {
+ outerNode = eliminateHole(queue[i], outerNode);
+ }
+
+ return outerNode;
+}
+
+function compareXYSlope(a, b) {
+ let result = a.x - b.x;
+ // when the left-most point of 2 holes meet at a vertex, sort the holes counterclockwise so that when we find
+ // the bridge to the outer shell is always the point that they meet at.
+ if (result === 0) {
+ result = a.y - b.y;
+ if (result === 0) {
+ const aSlope = (a.next.y - a.y) / (a.next.x - a.x);
+ const bSlope = (b.next.y - b.y) / (b.next.x - b.x);
+ result = aSlope - bSlope;
+ }
+ }
+ return result;
+}
+
+// find a bridge between vertices that connects hole with an outer ring and link it
+function eliminateHole(hole, outerNode) {
+ const bridge = findHoleBridge(hole, outerNode);
+ if (!bridge) {
+ return outerNode;
+ }
+
+ const bridgeReverse = splitPolygon(bridge, hole);
+
+ // filter collinear points around the cuts
+ filterPoints(bridgeReverse, bridgeReverse.next);
+ return filterPoints(bridge, bridge.next);
+}
+
+// David Eberly's algorithm for finding a bridge between hole and outer polygon
+function findHoleBridge(hole, outerNode) {
+ let p = outerNode;
+ const hx = hole.x;
+ const hy = hole.y;
+ let qx = -Infinity;
+ let m;
+
+ // find a segment intersected by a ray from the hole's leftmost point to the left;
+ // segment's endpoint with lesser x will be potential connection point
+ // unless they intersect at a vertex, then choose the vertex
+ if (equals(hole, p)) return p;
+ do {
+ if (equals(hole, p.next)) return p.next;
+ else if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
+ const x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y);
+ if (x <= hx && x > qx) {
+ qx = x;
+ m = p.x < p.next.x ? p : p.next;
+ if (x === hx) return m; // hole touches outer segment; pick leftmost endpoint
+ }
+ }
+ p = p.next;
+ } while (p !== outerNode);
+
+ if (!m) return null;
+
+ // look for points inside the triangle of hole point, segment intersection and endpoint;
+ // if there are no points found, we have a valid connection;
+ // otherwise choose the point of the minimum angle with the ray as connection point
+
+ const stop = m;
+ const mx = m.x;
+ const my = m.y;
+ let tanMin = Infinity;
+
+ p = m;
+
+ do {
+ if (hx >= p.x && p.x >= mx && hx !== p.x &&
+ pointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p.x, p.y)) {
+
+ const tan = Math.abs(hy - p.y) / (hx - p.x); // tangential
+
+ if (locallyInside(p, hole) &&
+ (tan < tanMin || (tan === tanMin && (p.x > m.x || (p.x === m.x && sectorContainsSector(m, p)))))) {
+ m = p;
+ tanMin = tan;
+ }
+ }
+
+ p = p.next;
+ } while (p !== stop);
+
+ return m;
+}
+
+// whether sector in vertex m contains sector in vertex p in the same coordinates
+function sectorContainsSector(m, p) {
+ return area(m.prev, m, p.prev) < 0 && area(p.next, m, m.next) < 0;
+}
+
+// interlink polygon nodes in z-order
+function indexCurve(start, minX, minY, invSize) {
+ let p = start;
+ do {
+ if (p.z === 0) p.z = zOrder(p.x, p.y, minX, minY, invSize);
+ p.prevZ = p.prev;
+ p.nextZ = p.next;
+ p = p.next;
+ } while (p !== start);
+
+ p.prevZ.nextZ = null;
+ p.prevZ = null;
+
+ sortLinked(p);
+}
+
+// Simon Tatham's linked list merge sort algorithm
+// http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html
+function sortLinked(list) {
+ let numMerges;
+ let inSize = 1;
+
+ do {
+ let p = list;
+ let e;
+ list = null;
+ let tail = null;
+ numMerges = 0;
+
+ while (p) {
+ numMerges++;
+ let q = p;
+ let pSize = 0;
+ for (let i = 0; i < inSize; i++) {
+ pSize++;
+ q = q.nextZ;
+ if (!q) break;
+ }
+ let qSize = inSize;
+
+ while (pSize > 0 || (qSize > 0 && q)) {
+
+ if (pSize !== 0 && (qSize === 0 || !q || p.z <= q.z)) {
+ e = p;
+ p = p.nextZ;
+ pSize--;
+ } else {
+ e = q;
+ q = q.nextZ;
+ qSize--;
+ }
+
+ if (tail) tail.nextZ = e;
+ else list = e;
+
+ e.prevZ = tail;
+ tail = e;
+ }
+
+ p = q;
+ }
+
+ tail.nextZ = null;
+ inSize *= 2;
+
+ } while (numMerges > 1);
+
+ return list;
+}
+
+// z-order of a point given coords and inverse of the longer side of data bbox
+function zOrder(x, y, minX, minY, invSize) {
+ // coords are transformed into non-negative 15-bit integer range
+ x = (x - minX) * invSize | 0;
+ y = (y - minY) * invSize | 0;
+
+ x = (x | (x << 8)) & 0x00FF00FF;
+ x = (x | (x << 4)) & 0x0F0F0F0F;
+ x = (x | (x << 2)) & 0x33333333;
+ x = (x | (x << 1)) & 0x55555555;
+
+ y = (y | (y << 8)) & 0x00FF00FF;
+ y = (y | (y << 4)) & 0x0F0F0F0F;
+ y = (y | (y << 2)) & 0x33333333;
+ y = (y | (y << 1)) & 0x55555555;
+
+ return x | (y << 1);
+}
+
+// find the leftmost node of a polygon ring
+function getLeftmost(start) {
+ let p = start,
+ leftmost = start;
+ do {
+ if (p.x < leftmost.x || (p.x === leftmost.x && p.y < leftmost.y)) leftmost = p;
+ p = p.next;
+ } while (p !== start);
+
+ return leftmost;
+}
+
+// check if a point lies within a convex triangle
+function pointInTriangle(ax, ay, bx, by, cx, cy, px, py) {
+ return (cx - px) * (ay - py) >= (ax - px) * (cy - py) &&
+ (ax - px) * (by - py) >= (bx - px) * (ay - py) &&
+ (bx - px) * (cy - py) >= (cx - px) * (by - py);
+}
+
+// check if a point lies within a convex triangle but false if its equal to the first point of the triangle
+function pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, px, py) {
+ return !(ax === px && ay === py) && pointInTriangle(ax, ay, bx, by, cx, cy, px, py);
+}
+
+// check if a diagonal between two polygon nodes is valid (lies in polygon interior)
+function isValidDiagonal(a, b) {
+ return a.next.i !== b.i && a.prev.i !== b.i && !intersectsPolygon(a, b) && // doesn't intersect other edges
+ (locallyInside(a, b) && locallyInside(b, a) && middleInside(a, b) && // locally visible
+ (area(a.prev, a, b.prev) || area(a, b.prev, b)) || // does not create opposite-facing sectors
+ equals(a, b) && area(a.prev, a, a.next) > 0 && area(b.prev, b, b.next) > 0); // special zero-length case
+}
+
+// signed area of a triangle
+function area(p, q, r) {
+ return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
+}
+
+// check if two points are equal
+function equals(p1, p2) {
+ return p1.x === p2.x && p1.y === p2.y;
+}
+
+// check if two segments intersect
+function intersects(p1, q1, p2, q2) {
+ const o1 = sign(area(p1, q1, p2));
+ const o2 = sign(area(p1, q1, q2));
+ const o3 = sign(area(p2, q2, p1));
+ const o4 = sign(area(p2, q2, q1));
+
+ if (o1 !== o2 && o3 !== o4) return true; // general case
+
+ if (o1 === 0 && onSegment(p1, p2, q1)) return true; // p1, q1 and p2 are collinear and p2 lies on p1q1
+ if (o2 === 0 && onSegment(p1, q2, q1)) return true; // p1, q1 and q2 are collinear and q2 lies on p1q1
+ if (o3 === 0 && onSegment(p2, p1, q2)) return true; // p2, q2 and p1 are collinear and p1 lies on p2q2
+ if (o4 === 0 && onSegment(p2, q1, q2)) return true; // p2, q2 and q1 are collinear and q1 lies on p2q2
+
+ return false;
+}
+
+// for collinear points p, q, r, check if point q lies on segment pr
+function onSegment(p, q, r) {
+ return q.x <= Math.max(p.x, r.x) && q.x >= Math.min(p.x, r.x) && q.y <= Math.max(p.y, r.y) && q.y >= Math.min(p.y, r.y);
+}
+
+function sign(num) {
+ return num > 0 ? 1 : num < 0 ? -1 : 0;
+}
+
+// check if a polygon diagonal intersects any polygon segments
+function intersectsPolygon(a, b) {
+ let p = a;
+ do {
+ if (p.i !== a.i && p.next.i !== a.i && p.i !== b.i && p.next.i !== b.i &&
+ intersects(p, p.next, a, b)) return true;
+ p = p.next;
+ } while (p !== a);
+
+ return false;
+}
+
+// check if a polygon diagonal is locally inside the polygon
+function locallyInside(a, b) {
+ return area(a.prev, a, a.next) < 0 ?
+ area(a, b, a.next) >= 0 && area(a, a.prev, b) >= 0 :
+ area(a, b, a.prev) < 0 || area(a, a.next, b) < 0;
+}
+
+// check if the middle point of a polygon diagonal is inside the polygon
+function middleInside(a, b) {
+ let p = a;
+ let inside = false;
+ const px = (a.x + b.x) / 2;
+ const py = (a.y + b.y) / 2;
+ do {
+ if (((p.y > py) !== (p.next.y > py)) && p.next.y !== p.y &&
+ (px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x))
+ inside = !inside;
+ p = p.next;
+ } while (p !== a);
+
+ return inside;
+}
+
+// link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two;
+// if one belongs to the outer ring and another to a hole, it merges it into a single ring
+function splitPolygon(a, b) {
+ const a2 = createNode(a.i, a.x, a.y),
+ b2 = createNode(b.i, b.x, b.y),
+ an = a.next,
+ bp = b.prev;
+
+ a.next = b;
+ b.prev = a;
+
+ a2.next = an;
+ an.prev = a2;
+
+ b2.next = a2;
+ a2.prev = b2;
+
+ bp.next = b2;
+ b2.prev = bp;
+
+ return b2;
+}
+
+// create a node and optionally link it with previous one (in a circular doubly linked list)
+function insertNode(i, x, y, last) {
+ const p = createNode(i, x, y);
+
+ if (!last) {
+ p.prev = p;
+ p.next = p;
+
+ } else {
+ p.next = last.next;
+ p.prev = last;
+ last.next.prev = p;
+ last.next = p;
+ }
+ return p;
+}
+
+function removeNode(p) {
+ p.next.prev = p.prev;
+ p.prev.next = p.next;
+
+ if (p.prevZ) p.prevZ.nextZ = p.nextZ;
+ if (p.nextZ) p.nextZ.prevZ = p.prevZ;
+}
+
+function createNode(i, x, y) {
+ return {
+ i, // vertex index in coordinates array
+ x, y, // vertex coordinates
+ prev: null, // previous and next vertex nodes in a polygon ring
+ next: null,
+ z: 0, // z-order curve value
+ prevZ: null, // previous and next nodes in z-order
+ nextZ: null,
+ steiner: false // indicates whether this is a steiner point
+ };
+}
+
+function signedArea(data, start, end, dim) {
+ let sum = 0;
+ for (let i = start, j = end - dim; i < end; i += dim) {
+ sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1]);
+ j = i;
+ }
+ return sum;
+}
+
+/**
+ * An implementation of the earcut polygon triangulation algorithm.
+ * The code is a port of [mapbox/earcut](https://github.com/mapbox/earcut).
+ *
+ * @see https://github.com/mapbox/earcut
+ */
+class Earcut {
+
+ /**
+ * Triangulates the given shape definition by returning an array of triangles.
+ *
+ * @param {Array} data - An array with 2D points.
+ * @param {Array} holeIndices - An array with indices defining holes.
+ * @param {number} [dim=2] - The number of coordinates per vertex in the input array.
+ * @return {Array} An array representing the triangulated faces. Each face is defined by three consecutive numbers
+ * representing vertex indices.
+ */
+ static triangulate( data, holeIndices, dim = 2 ) {
+
+ return earcut( data, holeIndices, dim );
+
+ }
+
+}
+
+/**
+ * A class containing utility functions for shapes.
+ *
+ * @hideconstructor
+ */
+class ShapeUtils {
+
+ /**
+ * Calculate area of a ( 2D ) contour polygon.
+ *
+ * @param {Array} contour - An array of 2D points.
+ * @return {number} The area.
+ */
+ static area( contour ) {
+
+ const n = contour.length;
+ let a = 0.0;
+
+ for ( let p = n - 1, q = 0; q < n; p = q ++ ) {
+
+ a += contour[ p ].x * contour[ q ].y - contour[ q ].x * contour[ p ].y;
+
+ }
+
+ return a * 0.5;
+
+ }
+
+ /**
+ * Returns `true` if the given contour uses a clockwise winding order.
+ *
+ * @param {Array} pts - An array of 2D points defining a polygon.
+ * @return {boolean} Whether the given contour uses a clockwise winding order or not.
+ */
+ static isClockWise( pts ) {
+
+ return ShapeUtils.area( pts ) < 0;
+
+ }
+
+ /**
+ * Triangulates the given shape definition.
+ *
+ * @param {Array} contour - An array of 2D points defining the contour.
+ * @param {Array>} holes - An array that holds arrays of 2D points defining the holes.
+ * @return {Array>} An array that holds for each face definition an array with three indices.
+ */
+ static triangulateShape( contour, holes ) {
+
+ const vertices = []; // flat array of vertices like [ x0,y0, x1,y1, x2,y2, ... ]
+ const holeIndices = []; // array of hole indices
+ const faces = []; // final array of vertex indices like [ [ a,b,d ], [ b,c,d ] ]
+
+ removeDupEndPts( contour );
+ addContour( vertices, contour );
+
+ //
+
+ let holeIndex = contour.length;
+
+ holes.forEach( removeDupEndPts );
+
+ for ( let i = 0; i < holes.length; i ++ ) {
+
+ holeIndices.push( holeIndex );
+ holeIndex += holes[ i ].length;
+ addContour( vertices, holes[ i ] );
+
+ }
+
+ //
+
+ const triangles = Earcut.triangulate( vertices, holeIndices );
+
+ //
+
+ for ( let i = 0; i < triangles.length; i += 3 ) {
+
+ faces.push( triangles.slice( i, i + 3 ) );
+
+ }
+
+ return faces;
+
+ }
+
+}
+
+function removeDupEndPts( points ) {
+
+ const l = points.length;
+
+ if ( l > 2 && points[ l - 1 ].equals( points[ 0 ] ) ) {
+
+ points.pop();
+
+ }
+
+}
+
+function addContour( vertices, contour ) {
+
+ for ( let i = 0; i < contour.length; i ++ ) {
+
+ vertices.push( contour[ i ].x );
+ vertices.push( contour[ i ].y );
+
+ }
+
+}
+
+/**
+ * Creates extruded geometry from a path shape.
+ *
+ * ```js
+ * const length = 12, width = 8;
+ *
+ * const shape = new THREE.Shape();
+ * shape.moveTo( 0,0 );
+ * shape.lineTo( 0, width );
+ * shape.lineTo( length, width );
+ * shape.lineTo( length, 0 );
+ * shape.lineTo( 0, 0 );
+ *
+ * const geometry = new THREE.ExtrudeGeometry( shape );
+ * const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
+ * const mesh = new THREE.Mesh( geometry, material ) ;
+ * scene.add( mesh );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#ExtrudeGeometry
+ */
+class ExtrudeGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new extrude geometry.
+ *
+ * @param {Shape|Array} [shapes] - A shape or an array of shapes.
+ * @param {ExtrudeGeometry~Options} [options] - The extrude settings.
+ */
+ constructor( shapes = new Shape( [ new Vector2( 0.5, 0.5 ), new Vector2( -0.5, 0.5 ), new Vector2( -0.5, -0.5 ), new Vector2( 0.5, -0.5 ) ] ), options = {} ) {
+
+ super();
+
+ this.type = 'ExtrudeGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ shapes: shapes,
+ options: options
+ };
+
+ shapes = Array.isArray( shapes ) ? shapes : [ shapes ];
+
+ const scope = this;
+
+ const verticesArray = [];
+ const uvArray = [];
+
+ for ( let i = 0, l = shapes.length; i < l; i ++ ) {
+
+ const shape = shapes[ i ];
+ addShape( shape );
+
+ }
+
+ // build geometry
+
+ this.setAttribute( 'position', new Float32BufferAttribute( verticesArray, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvArray, 2 ) );
+
+ this.computeVertexNormals();
+
+ // functions
+
+ function addShape( shape ) {
+
+ const placeholder = [];
+
+ // options
+
+ const curveSegments = options.curveSegments !== undefined ? options.curveSegments : 12;
+ const steps = options.steps !== undefined ? options.steps : 1;
+ const depth = options.depth !== undefined ? options.depth : 1;
+
+ let bevelEnabled = options.bevelEnabled !== undefined ? options.bevelEnabled : true;
+ let bevelThickness = options.bevelThickness !== undefined ? options.bevelThickness : 0.2;
+ let bevelSize = options.bevelSize !== undefined ? options.bevelSize : bevelThickness - 0.1;
+ let bevelOffset = options.bevelOffset !== undefined ? options.bevelOffset : 0;
+ let bevelSegments = options.bevelSegments !== undefined ? options.bevelSegments : 3;
+
+ const extrudePath = options.extrudePath;
+
+ const uvgen = options.UVGenerator !== undefined ? options.UVGenerator : WorldUVGenerator;
+
+ //
+
+ let extrudePts, extrudeByPath = false;
+ let splineTube, binormal, normal, position2;
+
+ if ( extrudePath ) {
+
+ extrudePts = extrudePath.getSpacedPoints( steps );
+
+ extrudeByPath = true;
+ bevelEnabled = false; // bevels not supported for path extrusion
+
+ // SETUP TNB variables
+
+ const isClosed = extrudePath.isCatmullRomCurve3 ? extrudePath.closed : false;
+
+ splineTube = extrudePath.computeFrenetFrames( steps, isClosed );
+
+ // log(splineTube, 'splineTube', splineTube.normals.length, 'steps', steps, 'extrudePts', extrudePts.length);
+
+ binormal = new Vector3();
+ normal = new Vector3();
+ position2 = new Vector3();
+
+ }
+
+ // Safeguards if bevels are not enabled
+
+ if ( ! bevelEnabled ) {
+
+ bevelSegments = 0;
+ bevelThickness = 0;
+ bevelSize = 0;
+ bevelOffset = 0;
+
+ }
+
+ // Variables initialization
+
+ const shapePoints = shape.extractPoints( curveSegments );
+
+ let vertices = shapePoints.shape;
+ const holes = shapePoints.holes;
+
+ const reverse = ! ShapeUtils.isClockWise( vertices );
+
+ if ( reverse ) {
+
+ vertices = vertices.reverse();
+
+ // Maybe we should also check if holes are in the opposite direction, just to be safe ...
+
+ for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+
+ const ahole = holes[ h ];
+
+ if ( ShapeUtils.isClockWise( ahole ) ) {
+
+ holes[ h ] = ahole.reverse();
+
+ }
+
+ }
+
+ }
+
+ /**Merges index-adjacent points that are within a threshold distance of each other. Array is modified in-place. Threshold distance is empirical, and scaled based on the magnitude of point coordinates.
+ * @param {Array} points
+ */
+ function mergeOverlappingPoints( points ) {
+
+ const THRESHOLD = 1e-10;
+ const THRESHOLD_SQ = THRESHOLD * THRESHOLD;
+ let prevPos = points[ 0 ];
+ for ( let i = 1; i <= points.length; i ++ ) {
+
+ const currentIndex = i % points.length;
+ const currentPos = points[ currentIndex ];
+ const dx = currentPos.x - prevPos.x;
+ const dy = currentPos.y - prevPos.y;
+ const distSq = dx * dx + dy * dy;
+
+ const scalingFactorSqrt = Math.max(
+ Math.abs( currentPos.x ),
+ Math.abs( currentPos.y ),
+ Math.abs( prevPos.x ),
+ Math.abs( prevPos.y )
+ );
+ const thresholdSqScaled = THRESHOLD_SQ * scalingFactorSqrt * scalingFactorSqrt;
+ if ( distSq <= thresholdSqScaled ) {
+
+ points.splice( currentIndex, 1 );
+ i --;
+ continue;
+
+ }
+
+ prevPos = currentPos;
+
+ }
+
+ }
+
+ mergeOverlappingPoints( vertices );
+ holes.forEach( mergeOverlappingPoints );
+
+ const numHoles = holes.length;
+
+ /* Vertices */
+
+ const contour = vertices; // vertices has all points but contour has only points of circumference
+
+ for ( let h = 0; h < numHoles; h ++ ) {
+
+ const ahole = holes[ h ];
+
+ vertices = vertices.concat( ahole );
+
+ }
+
+
+ function scalePt2( pt, vec, size ) {
+
+ if ( ! vec ) error( 'ExtrudeGeometry: vec does not exist' );
+
+ return pt.clone().addScaledVector( vec, size );
+
+ }
+
+ const vlen = vertices.length;
+
+
+ // Find directions for point movement
+
+
+ function getBevelVec( inPt, inPrev, inNext ) {
+
+ // computes for inPt the corresponding point inPt' on a new contour
+ // shifted by 1 unit (length of normalized vector) to the left
+ // if we walk along contour clockwise, this new contour is outside the old one
+ //
+ // inPt' is the intersection of the two lines parallel to the two
+ // adjacent edges of inPt at a distance of 1 unit on the left side.
+
+ let v_trans_x, v_trans_y, shrink_by; // resulting translation vector for inPt
+
+ // good reading for geometry algorithms (here: line-line intersection)
+ // http://geomalgorithms.com/a05-_intersect-1.html
+
+ const v_prev_x = inPt.x - inPrev.x,
+ v_prev_y = inPt.y - inPrev.y;
+ const v_next_x = inNext.x - inPt.x,
+ v_next_y = inNext.y - inPt.y;
+
+ const v_prev_lensq = ( v_prev_x * v_prev_x + v_prev_y * v_prev_y );
+
+ // check for collinear edges
+ const collinear0 = ( v_prev_x * v_next_y - v_prev_y * v_next_x );
+
+ if ( Math.abs( collinear0 ) > Number.EPSILON ) {
+
+ // not collinear
+
+ // length of vectors for normalizing
+
+ const v_prev_len = Math.sqrt( v_prev_lensq );
+ const v_next_len = Math.sqrt( v_next_x * v_next_x + v_next_y * v_next_y );
+
+ // shift adjacent points by unit vectors to the left
+
+ const ptPrevShift_x = ( inPrev.x - v_prev_y / v_prev_len );
+ const ptPrevShift_y = ( inPrev.y + v_prev_x / v_prev_len );
+
+ const ptNextShift_x = ( inNext.x - v_next_y / v_next_len );
+ const ptNextShift_y = ( inNext.y + v_next_x / v_next_len );
+
+ // scaling factor for v_prev to intersection point
+
+ const sf = ( ( ptNextShift_x - ptPrevShift_x ) * v_next_y -
+ ( ptNextShift_y - ptPrevShift_y ) * v_next_x ) /
+ ( v_prev_x * v_next_y - v_prev_y * v_next_x );
+
+ // vector from inPt to intersection point
+
+ v_trans_x = ( ptPrevShift_x + v_prev_x * sf - inPt.x );
+ v_trans_y = ( ptPrevShift_y + v_prev_y * sf - inPt.y );
+
+ // Don't normalize!, otherwise sharp corners become ugly
+ // but prevent crazy spikes
+ const v_trans_lensq = ( v_trans_x * v_trans_x + v_trans_y * v_trans_y );
+ if ( v_trans_lensq <= 2 ) {
+
+ return new Vector2( v_trans_x, v_trans_y );
+
+ } else {
+
+ shrink_by = Math.sqrt( v_trans_lensq / 2 );
+
+ }
+
+ } else {
+
+ // handle special case of collinear edges
+
+ let direction_eq = false; // assumes: opposite
+
+ if ( v_prev_x > Number.EPSILON ) {
+
+ if ( v_next_x > Number.EPSILON ) {
+
+ direction_eq = true;
+
+ }
+
+ } else {
+
+ if ( v_prev_x < - Number.EPSILON ) {
+
+ if ( v_next_x < - Number.EPSILON ) {
+
+ direction_eq = true;
+
+ }
+
+ } else {
+
+ if ( Math.sign( v_prev_y ) === Math.sign( v_next_y ) ) {
+
+ direction_eq = true;
+
+ }
+
+ }
+
+ }
+
+ if ( direction_eq ) {
+
+ // log("Warning: lines are a straight sequence");
+ v_trans_x = - v_prev_y;
+ v_trans_y = v_prev_x;
+ shrink_by = Math.sqrt( v_prev_lensq );
+
+ } else {
+
+ // log("Warning: lines are a straight spike");
+ v_trans_x = v_prev_x;
+ v_trans_y = v_prev_y;
+ shrink_by = Math.sqrt( v_prev_lensq / 2 );
+
+ }
+
+ }
+
+ return new Vector2( v_trans_x / shrink_by, v_trans_y / shrink_by );
+
+ }
+
+
+ const contourMovements = [];
+
+ for ( let i = 0, il = contour.length, j = il - 1, k = i + 1; i < il; i ++, j ++, k ++ ) {
+
+ if ( j === il ) j = 0;
+ if ( k === il ) k = 0;
+
+ // (j)---(i)---(k)
+ // log('i,j,k', i, j , k)
+
+ contourMovements[ i ] = getBevelVec( contour[ i ], contour[ j ], contour[ k ] );
+
+ }
+
+ const holesMovements = [];
+ let oneHoleMovements, verticesMovements = contourMovements.concat();
+
+ for ( let h = 0, hl = numHoles; h < hl; h ++ ) {
+
+ const ahole = holes[ h ];
+
+ oneHoleMovements = [];
+
+ for ( let i = 0, il = ahole.length, j = il - 1, k = i + 1; i < il; i ++, j ++, k ++ ) {
+
+ if ( j === il ) j = 0;
+ if ( k === il ) k = 0;
+
+ // (j)---(i)---(k)
+ oneHoleMovements[ i ] = getBevelVec( ahole[ i ], ahole[ j ], ahole[ k ] );
+
+ }
+
+ holesMovements.push( oneHoleMovements );
+ verticesMovements = verticesMovements.concat( oneHoleMovements );
+
+ }
+
+ let faces;
+
+ if ( bevelSegments === 0 ) {
+
+ faces = ShapeUtils.triangulateShape( contour, holes );
+
+ } else {
+
+ const contractedContourVertices = [];
+ const expandedHoleVertices = [];
+
+ // Loop bevelSegments, 1 for the front, 1 for the back
+
+ for ( let b = 0; b < bevelSegments; b ++ ) {
+
+ //for ( b = bevelSegments; b > 0; b -- ) {
+
+ const t = b / bevelSegments;
+ const z = bevelThickness * Math.cos( t * Math.PI / 2 );
+ const bs = bevelSize * Math.sin( t * Math.PI / 2 ) + bevelOffset;
+
+ // contract shape
+
+ for ( let i = 0, il = contour.length; i < il; i ++ ) {
+
+ const vert = scalePt2( contour[ i ], contourMovements[ i ], bs );
+
+ v( vert.x, vert.y, - z );
+ if ( t === 0 ) contractedContourVertices.push( vert );
+
+ }
+
+ // expand holes
+
+ for ( let h = 0, hl = numHoles; h < hl; h ++ ) {
+
+ const ahole = holes[ h ];
+ oneHoleMovements = holesMovements[ h ];
+ const oneHoleVertices = [];
+ for ( let i = 0, il = ahole.length; i < il; i ++ ) {
+
+ const vert = scalePt2( ahole[ i ], oneHoleMovements[ i ], bs );
+
+ v( vert.x, vert.y, - z );
+ if ( t === 0 ) oneHoleVertices.push( vert );
+
+ }
+
+ if ( t === 0 ) expandedHoleVertices.push( oneHoleVertices );
+
+ }
+
+ }
+
+ faces = ShapeUtils.triangulateShape( contractedContourVertices, expandedHoleVertices );
+
+ }
+
+ const flen = faces.length;
+
+ const bs = bevelSize + bevelOffset;
+
+ // Back facing vertices
+
+ for ( let i = 0; i < vlen; i ++ ) {
+
+ const vert = bevelEnabled ? scalePt2( vertices[ i ], verticesMovements[ i ], bs ) : vertices[ i ];
+
+ if ( ! extrudeByPath ) {
+
+ v( vert.x, vert.y, 0 );
+
+ } else {
+
+ // v( vert.x, vert.y + extrudePts[ 0 ].y, extrudePts[ 0 ].x );
+
+ normal.copy( splineTube.normals[ 0 ] ).multiplyScalar( vert.x );
+ binormal.copy( splineTube.binormals[ 0 ] ).multiplyScalar( vert.y );
+
+ position2.copy( extrudePts[ 0 ] ).add( normal ).add( binormal );
+
+ v( position2.x, position2.y, position2.z );
+
+ }
+
+ }
+
+ // Add stepped vertices...
+ // Including front facing vertices
+
+ for ( let s = 1; s <= steps; s ++ ) {
+
+ for ( let i = 0; i < vlen; i ++ ) {
+
+ const vert = bevelEnabled ? scalePt2( vertices[ i ], verticesMovements[ i ], bs ) : vertices[ i ];
+
+ if ( ! extrudeByPath ) {
+
+ v( vert.x, vert.y, depth / steps * s );
+
+ } else {
+
+ // v( vert.x, vert.y + extrudePts[ s - 1 ].y, extrudePts[ s - 1 ].x );
+
+ normal.copy( splineTube.normals[ s ] ).multiplyScalar( vert.x );
+ binormal.copy( splineTube.binormals[ s ] ).multiplyScalar( vert.y );
+
+ position2.copy( extrudePts[ s ] ).add( normal ).add( binormal );
+
+ v( position2.x, position2.y, position2.z );
+
+ }
+
+ }
+
+ }
+
+
+ // Add bevel segments planes
+
+ //for ( b = 1; b <= bevelSegments; b ++ ) {
+ for ( let b = bevelSegments - 1; b >= 0; b -- ) {
+
+ const t = b / bevelSegments;
+ const z = bevelThickness * Math.cos( t * Math.PI / 2 );
+ const bs = bevelSize * Math.sin( t * Math.PI / 2 ) + bevelOffset;
+
+ // contract shape
+
+ for ( let i = 0, il = contour.length; i < il; i ++ ) {
+
+ const vert = scalePt2( contour[ i ], contourMovements[ i ], bs );
+ v( vert.x, vert.y, depth + z );
+
+ }
+
+ // expand holes
+
+ for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+
+ const ahole = holes[ h ];
+ oneHoleMovements = holesMovements[ h ];
+
+ for ( let i = 0, il = ahole.length; i < il; i ++ ) {
+
+ const vert = scalePt2( ahole[ i ], oneHoleMovements[ i ], bs );
+
+ if ( ! extrudeByPath ) {
+
+ v( vert.x, vert.y, depth + z );
+
+ } else {
+
+ v( vert.x, vert.y + extrudePts[ steps - 1 ].y, extrudePts[ steps - 1 ].x + z );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ /* Faces */
+
+ // Top and bottom faces
+
+ buildLidFaces();
+
+ // Sides faces
+
+ buildSideFaces();
+
+
+ ///// Internal functions
+
+ function buildLidFaces() {
+
+ const start = verticesArray.length / 3;
+
+ if ( bevelEnabled ) {
+
+ let layer = 0; // steps + 1
+ let offset = vlen * layer;
+
+ // Bottom faces
+
+ for ( let i = 0; i < flen; i ++ ) {
+
+ const face = faces[ i ];
+ f3( face[ 2 ] + offset, face[ 1 ] + offset, face[ 0 ] + offset );
+
+ }
+
+ layer = steps + bevelSegments * 2;
+ offset = vlen * layer;
+
+ // Top faces
+
+ for ( let i = 0; i < flen; i ++ ) {
+
+ const face = faces[ i ];
+ f3( face[ 0 ] + offset, face[ 1 ] + offset, face[ 2 ] + offset );
+
+ }
+
+ } else {
+
+ // Bottom faces
+
+ for ( let i = 0; i < flen; i ++ ) {
+
+ const face = faces[ i ];
+ f3( face[ 2 ], face[ 1 ], face[ 0 ] );
+
+ }
+
+ // Top faces
+
+ for ( let i = 0; i < flen; i ++ ) {
+
+ const face = faces[ i ];
+ f3( face[ 0 ] + vlen * steps, face[ 1 ] + vlen * steps, face[ 2 ] + vlen * steps );
+
+ }
+
+ }
+
+ scope.addGroup( start, verticesArray.length / 3 - start, 0 );
+
+ }
+
+ // Create faces for the z-sides of the shape
+
+ function buildSideFaces() {
+
+ const start = verticesArray.length / 3;
+ let layeroffset = 0;
+ sidewalls( contour, layeroffset );
+ layeroffset += contour.length;
+
+ for ( let h = 0, hl = holes.length; h < hl; h ++ ) {
+
+ const ahole = holes[ h ];
+ sidewalls( ahole, layeroffset );
+
+ //, true
+ layeroffset += ahole.length;
+
+ }
+
+
+ scope.addGroup( start, verticesArray.length / 3 - start, 1 );
+
+
+ }
+
+ function sidewalls( contour, layeroffset ) {
+
+ let i = contour.length;
+
+ while ( -- i >= 0 ) {
+
+ const j = i;
+ let k = i - 1;
+ if ( k < 0 ) k = contour.length - 1;
+
+ //log('b', i,j, i-1, k,vertices.length);
+
+ for ( let s = 0, sl = ( steps + bevelSegments * 2 ); s < sl; s ++ ) {
+
+ const slen1 = vlen * s;
+ const slen2 = vlen * ( s + 1 );
+
+ const a = layeroffset + j + slen1,
+ b = layeroffset + k + slen1,
+ c = layeroffset + k + slen2,
+ d = layeroffset + j + slen2;
+
+ f4( a, b, c, d );
+
+ }
+
+ }
+
+ }
+
+ function v( x, y, z ) {
+
+ placeholder.push( x );
+ placeholder.push( y );
+ placeholder.push( z );
+
+ }
+
+
+ function f3( a, b, c ) {
+
+ addVertex( a );
+ addVertex( b );
+ addVertex( c );
+
+ const nextIndex = verticesArray.length / 3;
+ const uvs = uvgen.generateTopUV( scope, verticesArray, nextIndex - 3, nextIndex - 2, nextIndex - 1 );
+
+ addUV( uvs[ 0 ] );
+ addUV( uvs[ 1 ] );
+ addUV( uvs[ 2 ] );
+
+ }
+
+ function f4( a, b, c, d ) {
+
+ addVertex( a );
+ addVertex( b );
+ addVertex( d );
+
+ addVertex( b );
+ addVertex( c );
+ addVertex( d );
+
+
+ const nextIndex = verticesArray.length / 3;
+ const uvs = uvgen.generateSideWallUV( scope, verticesArray, nextIndex - 6, nextIndex - 3, nextIndex - 2, nextIndex - 1 );
+
+ addUV( uvs[ 0 ] );
+ addUV( uvs[ 1 ] );
+ addUV( uvs[ 3 ] );
+
+ addUV( uvs[ 1 ] );
+ addUV( uvs[ 2 ] );
+ addUV( uvs[ 3 ] );
+
+ }
+
+ function addVertex( index ) {
+
+ verticesArray.push( placeholder[ index * 3 + 0 ] );
+ verticesArray.push( placeholder[ index * 3 + 1 ] );
+ verticesArray.push( placeholder[ index * 3 + 2 ] );
+
+ }
+
+
+ function addUV( vector2 ) {
+
+ uvArray.push( vector2.x );
+ uvArray.push( vector2.y );
+
+ }
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ const shapes = this.parameters.shapes;
+ const options = this.parameters.options;
+
+ return toJSON$1( shapes, options, data );
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @param {Array} shapes - An array of shapes.
+ * @return {ExtrudeGeometry} A new instance.
+ */
+ static fromJSON( data, shapes ) {
+
+ const geometryShapes = [];
+
+ for ( let j = 0, jl = data.shapes.length; j < jl; j ++ ) {
+
+ const shape = shapes[ data.shapes[ j ] ];
+
+ geometryShapes.push( shape );
+
+ }
+
+ const extrudePath = data.options.extrudePath;
+
+ if ( extrudePath !== undefined ) {
+
+ data.options.extrudePath = new Curves[ extrudePath.type ]().fromJSON( extrudePath );
+
+ }
+
+ return new ExtrudeGeometry( geometryShapes, data.options );
+
+ }
+
+}
+
+const WorldUVGenerator = {
+
+ generateTopUV: function ( geometry, vertices, indexA, indexB, indexC ) {
+
+ const a_x = vertices[ indexA * 3 ];
+ const a_y = vertices[ indexA * 3 + 1 ];
+ const b_x = vertices[ indexB * 3 ];
+ const b_y = vertices[ indexB * 3 + 1 ];
+ const c_x = vertices[ indexC * 3 ];
+ const c_y = vertices[ indexC * 3 + 1 ];
+
+ return [
+ new Vector2( a_x, a_y ),
+ new Vector2( b_x, b_y ),
+ new Vector2( c_x, c_y )
+ ];
+
+ },
+
+ generateSideWallUV: function ( geometry, vertices, indexA, indexB, indexC, indexD ) {
+
+ const a_x = vertices[ indexA * 3 ];
+ const a_y = vertices[ indexA * 3 + 1 ];
+ const a_z = vertices[ indexA * 3 + 2 ];
+ const b_x = vertices[ indexB * 3 ];
+ const b_y = vertices[ indexB * 3 + 1 ];
+ const b_z = vertices[ indexB * 3 + 2 ];
+ const c_x = vertices[ indexC * 3 ];
+ const c_y = vertices[ indexC * 3 + 1 ];
+ const c_z = vertices[ indexC * 3 + 2 ];
+ const d_x = vertices[ indexD * 3 ];
+ const d_y = vertices[ indexD * 3 + 1 ];
+ const d_z = vertices[ indexD * 3 + 2 ];
+
+ if ( Math.abs( a_y - b_y ) < Math.abs( a_x - b_x ) ) {
+
+ return [
+ new Vector2( a_x, 1 - a_z ),
+ new Vector2( b_x, 1 - b_z ),
+ new Vector2( c_x, 1 - c_z ),
+ new Vector2( d_x, 1 - d_z )
+ ];
+
+ } else {
+
+ return [
+ new Vector2( a_y, 1 - a_z ),
+ new Vector2( b_y, 1 - b_z ),
+ new Vector2( c_y, 1 - c_z ),
+ new Vector2( d_y, 1 - d_z )
+ ];
+
+ }
+
+ }
+
+};
+
+function toJSON$1( shapes, options, data ) {
+
+ data.shapes = [];
+
+ if ( Array.isArray( shapes ) ) {
+
+ for ( let i = 0, l = shapes.length; i < l; i ++ ) {
+
+ const shape = shapes[ i ];
+
+ data.shapes.push( shape.uuid );
+
+ }
+
+ } else {
+
+ data.shapes.push( shapes.uuid );
+
+ }
+
+ data.options = Object.assign( {}, options );
+
+ if ( options.extrudePath !== undefined ) data.options.extrudePath = options.extrudePath.toJSON();
+
+ return data;
+
+}
+
+/**
+ * A geometry class for representing an icosahedron.
+ *
+ * ```js
+ * const geometry = new THREE.IcosahedronGeometry();
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const icosahedron = new THREE.Mesh( geometry, material );
+ * scene.add( icosahedron );
+ * ```
+ *
+ * @augments PolyhedronGeometry
+ * @demo scenes/geometry-browser.html#IcosahedronGeometry
+ */
+class IcosahedronGeometry extends PolyhedronGeometry {
+
+ /**
+ * Constructs a new icosahedron geometry.
+ *
+ * @param {number} [radius=1] - Radius of the icosahedron.
+ * @param {number} [detail=0] - Setting this to a value greater than `0` adds vertices making it no longer a icosahedron.
+ */
+ constructor( radius = 1, detail = 0 ) {
+
+ const t = ( 1 + Math.sqrt( 5 ) ) / 2;
+
+ const vertices = [
+ -1, t, 0, 1, t, 0, -1, - t, 0, 1, - t, 0,
+ 0, -1, t, 0, 1, t, 0, -1, - t, 0, 1, - t,
+ t, 0, -1, t, 0, 1, - t, 0, -1, - t, 0, 1
+ ];
+
+ const indices = [
+ 0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11,
+ 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8,
+ 3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9,
+ 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1
+ ];
+
+ super( vertices, indices, radius, detail );
+
+ this.type = 'IcosahedronGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ detail: detail
+ };
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {IcosahedronGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new IcosahedronGeometry( data.radius, data.detail );
+
+ }
+
+}
+
+/**
+ * Creates meshes with axial symmetry like vases. The lathe rotates around the Y axis.
+ *
+ * ```js
+ * const points = [];
+ * for ( let i = 0; i < 10; i ++ ) {
+ * points.push( new THREE.Vector2( Math.sin( i * 0.2 ) * 10 + 5, ( i - 5 ) * 2 ) );
+ * }
+ * const geometry = new THREE.LatheGeometry( points );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const lathe = new THREE.Mesh( geometry, material );
+ * scene.add( lathe );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#LatheGeometry
+ */
+class LatheGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new lathe geometry.
+ *
+ * @param {Array} [points] - An array of points in 2D space. The x-coordinate of each point
+ * must be greater than zero.
+ * @param {number} [segments=12] - The number of circumference segments to generate.
+ * @param {number} [phiStart=0] - The starting angle in radians.
+ * @param {number} [phiLength=Math.PI*2] - The radian (0 to 2PI) range of the lathed section 2PI is a
+ * closed lathe, less than 2PI is a portion.
+ */
+ constructor( points = [ new Vector2( 0, -0.5 ), new Vector2( 0.5, 0 ), new Vector2( 0, 0.5 ) ], segments = 12, phiStart = 0, phiLength = Math.PI * 2 ) {
+
+ super();
+
+ this.type = 'LatheGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ points: points,
+ segments: segments,
+ phiStart: phiStart,
+ phiLength: phiLength
+ };
+
+ segments = Math.floor( segments );
+
+ // clamp phiLength so it's in range of [ 0, 2PI ]
+
+ phiLength = clamp( phiLength, 0, Math.PI * 2 );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const uvs = [];
+ const initNormals = [];
+ const normals = [];
+
+ // helper variables
+
+ const inverseSegments = 1.0 / segments;
+ const vertex = new Vector3();
+ const uv = new Vector2();
+ const normal = new Vector3();
+ const curNormal = new Vector3();
+ const prevNormal = new Vector3();
+ let dx = 0;
+ let dy = 0;
+
+ // pre-compute normals for initial "meridian"
+
+ for ( let j = 0; j <= ( points.length - 1 ); j ++ ) {
+
+ switch ( j ) {
+
+ case 0: // special handling for 1st vertex on path
+
+ dx = points[ j + 1 ].x - points[ j ].x;
+ dy = points[ j + 1 ].y - points[ j ].y;
+
+ normal.x = dy * 1.0;
+ normal.y = - dx;
+ normal.z = dy * 0.0;
+
+ prevNormal.copy( normal );
+
+ normal.normalize();
+
+ initNormals.push( normal.x, normal.y, normal.z );
+
+ break;
+
+ case ( points.length - 1 ): // special handling for last Vertex on path
+
+ initNormals.push( prevNormal.x, prevNormal.y, prevNormal.z );
+
+ break;
+
+ default: // default handling for all vertices in between
+
+ dx = points[ j + 1 ].x - points[ j ].x;
+ dy = points[ j + 1 ].y - points[ j ].y;
+
+ normal.x = dy * 1.0;
+ normal.y = - dx;
+ normal.z = dy * 0.0;
+
+ curNormal.copy( normal );
+
+ normal.x += prevNormal.x;
+ normal.y += prevNormal.y;
+ normal.z += prevNormal.z;
+
+ normal.normalize();
+
+ initNormals.push( normal.x, normal.y, normal.z );
+
+ prevNormal.copy( curNormal );
+
+ }
+
+ }
+
+ // generate vertices, uvs and normals
+
+ for ( let i = 0; i <= segments; i ++ ) {
+
+ const phi = phiStart + i * inverseSegments * phiLength;
+
+ const sin = Math.sin( phi );
+ const cos = Math.cos( phi );
+
+ for ( let j = 0; j <= ( points.length - 1 ); j ++ ) {
+
+ // vertex
+
+ vertex.x = points[ j ].x * sin;
+ vertex.y = points[ j ].y;
+ vertex.z = points[ j ].x * cos;
+
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // uv
+
+ uv.x = i / segments;
+ uv.y = j / ( points.length - 1 );
+
+ uvs.push( uv.x, uv.y );
+
+ // normal
+
+ const x = initNormals[ 3 * j + 0 ] * sin;
+ const y = initNormals[ 3 * j + 1 ];
+ const z = initNormals[ 3 * j + 0 ] * cos;
+
+ normals.push( x, y, z );
+
+ }
+
+ }
+
+ // indices
+
+ for ( let i = 0; i < segments; i ++ ) {
+
+ for ( let j = 0; j < ( points.length - 1 ); j ++ ) {
+
+ const base = j + i * points.length;
+
+ const a = base;
+ const b = base + points.length;
+ const c = base + points.length + 1;
+ const d = base + 1;
+
+ // faces
+
+ indices.push( a, b, d );
+ indices.push( c, d, b );
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {LatheGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new LatheGeometry( data.points, data.segments, data.phiStart, data.phiLength );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing an octahedron.
+ *
+ * ```js
+ * const geometry = new THREE.OctahedronGeometry();
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const octahedron = new THREE.Mesh( geometry, material );
+ * scene.add( octahedron );
+ * ```
+ *
+ * @augments PolyhedronGeometry
+ * @demo scenes/geometry-browser.html#OctahedronGeometry
+ */
+class OctahedronGeometry extends PolyhedronGeometry {
+
+ /**
+ * Constructs a new octahedron geometry.
+ *
+ * @param {number} [radius=1] - Radius of the octahedron.
+ * @param {number} [detail=0] - Setting this to a value greater than `0` adds vertices making it no longer a octahedron.
+ */
+ constructor( radius = 1, detail = 0 ) {
+
+ const vertices = [
+ 1, 0, 0, -1, 0, 0, 0, 1, 0,
+ 0, -1, 0, 0, 0, 1, 0, 0, -1
+ ];
+
+ const indices = [
+ 0, 2, 4, 0, 4, 3, 0, 3, 5,
+ 0, 5, 2, 1, 2, 5, 1, 5, 3,
+ 1, 3, 4, 1, 4, 2
+ ];
+
+ super( vertices, indices, radius, detail );
+
+ this.type = 'OctahedronGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ detail: detail
+ };
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {OctahedronGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new OctahedronGeometry( data.radius, data.detail );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing a plane.
+ *
+ * ```js
+ * const geometry = new THREE.PlaneGeometry( 1, 1 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00, side: THREE.DoubleSide } );
+ * const plane = new THREE.Mesh( geometry, material );
+ * scene.add( plane );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#PlaneGeometry
+ */
+class PlaneGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new plane geometry.
+ *
+ * @param {number} [width=1] - The width along the X axis.
+ * @param {number} [height=1] - The height along the Y axis
+ * @param {number} [widthSegments=1] - The number of segments along the X axis.
+ * @param {number} [heightSegments=1] - The number of segments along the Y axis.
+ */
+ constructor( width = 1, height = 1, widthSegments = 1, heightSegments = 1 ) {
+
+ super();
+
+ this.type = 'PlaneGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ width: width,
+ height: height,
+ widthSegments: widthSegments,
+ heightSegments: heightSegments
+ };
+
+ const width_half = width / 2;
+ const height_half = height / 2;
+
+ const gridX = Math.floor( widthSegments );
+ const gridY = Math.floor( heightSegments );
+
+ const gridX1 = gridX + 1;
+ const gridY1 = gridY + 1;
+
+ const segment_width = width / gridX;
+ const segment_height = height / gridY;
+
+ //
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ for ( let iy = 0; iy < gridY1; iy ++ ) {
+
+ const y = iy * segment_height - height_half;
+
+ for ( let ix = 0; ix < gridX1; ix ++ ) {
+
+ const x = ix * segment_width - width_half;
+
+ vertices.push( x, - y, 0 );
+
+ normals.push( 0, 0, 1 );
+
+ uvs.push( ix / gridX );
+ uvs.push( 1 - ( iy / gridY ) );
+
+ }
+
+ }
+
+ for ( let iy = 0; iy < gridY; iy ++ ) {
+
+ for ( let ix = 0; ix < gridX; ix ++ ) {
+
+ const a = ix + gridX1 * iy;
+ const b = ix + gridX1 * ( iy + 1 );
+ const c = ( ix + 1 ) + gridX1 * ( iy + 1 );
+ const d = ( ix + 1 ) + gridX1 * iy;
+
+ indices.push( a, b, d );
+ indices.push( b, c, d );
+
+ }
+
+ }
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {PlaneGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new PlaneGeometry( data.width, data.height, data.widthSegments, data.heightSegments );
+
+ }
+
+}
+
+/**
+ * A class for generating a two-dimensional ring geometry.
+ *
+ * ```js
+ * const geometry = new THREE.RingGeometry( 1, 5, 32 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00, side: THREE.DoubleSide } );
+ * const mesh = new THREE.Mesh( geometry, material );
+ * scene.add( mesh );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#RingGeometry
+ */
+class RingGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new ring geometry.
+ *
+ * @param {number} [innerRadius=0.5] - The inner radius of the ring.
+ * @param {number} [outerRadius=1] - The outer radius of the ring.
+ * @param {number} [thetaSegments=32] - Number of segments. A higher number means the ring will be more round. Minimum is `3`.
+ * @param {number} [phiSegments=1] - Number of segments per ring segment. Minimum is `1`.
+ * @param {number} [thetaStart=0] - Starting angle in radians.
+ * @param {number} [thetaLength=Math.PI*2] - Central angle in radians.
+ */
+ constructor( innerRadius = 0.5, outerRadius = 1, thetaSegments = 32, phiSegments = 1, thetaStart = 0, thetaLength = Math.PI * 2 ) {
+
+ super();
+
+ this.type = 'RingGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ innerRadius: innerRadius,
+ outerRadius: outerRadius,
+ thetaSegments: thetaSegments,
+ phiSegments: phiSegments,
+ thetaStart: thetaStart,
+ thetaLength: thetaLength
+ };
+
+ thetaSegments = Math.max( 3, thetaSegments );
+ phiSegments = Math.max( 1, phiSegments );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // some helper variables
+
+ let radius = innerRadius;
+ const radiusStep = ( ( outerRadius - innerRadius ) / phiSegments );
+ const vertex = new Vector3();
+ const uv = new Vector2();
+
+ // generate vertices, normals and uvs
+
+ for ( let j = 0; j <= phiSegments; j ++ ) {
+
+ for ( let i = 0; i <= thetaSegments; i ++ ) {
+
+ // values are generate from the inside of the ring to the outside
+
+ const segment = thetaStart + i / thetaSegments * thetaLength;
+
+ // vertex
+
+ vertex.x = radius * Math.cos( segment );
+ vertex.y = radius * Math.sin( segment );
+
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal
+
+ normals.push( 0, 0, 1 );
+
+ // uv
+
+ uv.x = ( vertex.x / outerRadius + 1 ) / 2;
+ uv.y = ( vertex.y / outerRadius + 1 ) / 2;
+
+ uvs.push( uv.x, uv.y );
+
+ }
+
+ // increase the radius for next row of vertices
+
+ radius += radiusStep;
+
+ }
+
+ // indices
+
+ for ( let j = 0; j < phiSegments; j ++ ) {
+
+ const thetaSegmentLevel = j * ( thetaSegments + 1 );
+
+ for ( let i = 0; i < thetaSegments; i ++ ) {
+
+ const segment = i + thetaSegmentLevel;
+
+ const a = segment;
+ const b = segment + thetaSegments + 1;
+ const c = segment + thetaSegments + 2;
+ const d = segment + 1;
+
+ // faces
+
+ indices.push( a, b, d );
+ indices.push( b, c, d );
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {RingGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new RingGeometry( data.innerRadius, data.outerRadius, data.thetaSegments, data.phiSegments, data.thetaStart, data.thetaLength );
+
+ }
+
+}
+
+/**
+ * Creates an one-sided polygonal geometry from one or more path shapes.
+ *
+ * ```js
+ * const arcShape = new THREE.Shape()
+ * .moveTo( 5, 1 )
+ * .absarc( 1, 1, 4, 0, Math.PI * 2, false );
+ *
+ * const geometry = new THREE.ShapeGeometry( arcShape );
+ * const material = new THREE.MeshBasicMaterial( { color: 0x00ff00, side: THREE.DoubleSide } );
+ * const mesh = new THREE.Mesh( geometry, material ) ;
+ * scene.add( mesh );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#ShapeGeometry
+ */
+class ShapeGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new shape geometry.
+ *
+ * @param {Shape|Array} [shapes] - A shape or an array of shapes.
+ * @param {number} [curveSegments=12] - Number of segments per shape.
+ */
+ constructor( shapes = new Shape( [ new Vector2( 0, 0.5 ), new Vector2( -0.5, -0.5 ), new Vector2( 0.5, -0.5 ) ] ), curveSegments = 12 ) {
+
+ super();
+
+ this.type = 'ShapeGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ shapes: shapes,
+ curveSegments: curveSegments
+ };
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // helper variables
+
+ let groupStart = 0;
+ let groupCount = 0;
+
+ // allow single and array values for "shapes" parameter
+
+ if ( Array.isArray( shapes ) === false ) {
+
+ addShape( shapes );
+
+ } else {
+
+ for ( let i = 0; i < shapes.length; i ++ ) {
+
+ addShape( shapes[ i ] );
+
+ this.addGroup( groupStart, groupCount, i ); // enables MultiMaterial support
+
+ groupStart += groupCount;
+ groupCount = 0;
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+
+ // helper functions
+
+ function addShape( shape ) {
+
+ const indexOffset = vertices.length / 3;
+ const points = shape.extractPoints( curveSegments );
+
+ let shapeVertices = points.shape;
+ const shapeHoles = points.holes;
+
+ // check direction of vertices
+
+ if ( ShapeUtils.isClockWise( shapeVertices ) === false ) {
+
+ shapeVertices = shapeVertices.reverse();
+
+ }
+
+ for ( let i = 0, l = shapeHoles.length; i < l; i ++ ) {
+
+ const shapeHole = shapeHoles[ i ];
+
+ if ( ShapeUtils.isClockWise( shapeHole ) === true ) {
+
+ shapeHoles[ i ] = shapeHole.reverse();
+
+ }
+
+ }
+
+ const faces = ShapeUtils.triangulateShape( shapeVertices, shapeHoles );
+
+ // join vertices of inner and outer paths to a single array
+
+ for ( let i = 0, l = shapeHoles.length; i < l; i ++ ) {
+
+ const shapeHole = shapeHoles[ i ];
+ shapeVertices = shapeVertices.concat( shapeHole );
+
+ }
+
+ // vertices, normals, uvs
+
+ for ( let i = 0, l = shapeVertices.length; i < l; i ++ ) {
+
+ const vertex = shapeVertices[ i ];
+
+ vertices.push( vertex.x, vertex.y, 0 );
+ normals.push( 0, 0, 1 );
+ uvs.push( vertex.x, vertex.y ); // world uvs
+
+ }
+
+ // indices
+
+ for ( let i = 0, l = faces.length; i < l; i ++ ) {
+
+ const face = faces[ i ];
+
+ const a = face[ 0 ] + indexOffset;
+ const b = face[ 1 ] + indexOffset;
+ const c = face[ 2 ] + indexOffset;
+
+ indices.push( a, b, c );
+ groupCount += 3;
+
+ }
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ const shapes = this.parameters.shapes;
+
+ return toJSON( shapes, data );
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @param {Array} shapes - An array of shapes.
+ * @return {ShapeGeometry} A new instance.
+ */
+ static fromJSON( data, shapes ) {
+
+ const geometryShapes = [];
+
+ for ( let j = 0, jl = data.shapes.length; j < jl; j ++ ) {
+
+ const shape = shapes[ data.shapes[ j ] ];
+
+ geometryShapes.push( shape );
+
+ }
+
+ return new ShapeGeometry( geometryShapes, data.curveSegments );
+
+ }
+
+}
+
+function toJSON( shapes, data ) {
+
+ data.shapes = [];
+
+ if ( Array.isArray( shapes ) ) {
+
+ for ( let i = 0, l = shapes.length; i < l; i ++ ) {
+
+ const shape = shapes[ i ];
+
+ data.shapes.push( shape.uuid );
+
+ }
+
+ } else {
+
+ data.shapes.push( shapes.uuid );
+
+ }
+
+ return data;
+
+}
+
+/**
+ * A class for generating a sphere geometry.
+ *
+ * ```js
+ * const geometry = new THREE.SphereGeometry( 15, 32, 16 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const sphere = new THREE.Mesh( geometry, material );
+ * scene.add( sphere );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#SphereGeometry
+ */
+class SphereGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new sphere geometry.
+ *
+ * @param {number} [radius=1] - The sphere radius.
+ * @param {number} [widthSegments=32] - The number of horizontal segments. Minimum value is `3`.
+ * @param {number} [heightSegments=16] - The number of vertical segments. Minimum value is `2`.
+ * @param {number} [phiStart=0] - The horizontal starting angle in radians.
+ * @param {number} [phiLength=Math.PI*2] - The horizontal sweep angle size.
+ * @param {number} [thetaStart=0] - The vertical starting angle in radians.
+ * @param {number} [thetaLength=Math.PI] - The vertical sweep angle size.
+ */
+ constructor( radius = 1, widthSegments = 32, heightSegments = 16, phiStart = 0, phiLength = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI ) {
+
+ super();
+
+ this.type = 'SphereGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ widthSegments: widthSegments,
+ heightSegments: heightSegments,
+ phiStart: phiStart,
+ phiLength: phiLength,
+ thetaStart: thetaStart,
+ thetaLength: thetaLength
+ };
+
+ widthSegments = Math.max( 3, Math.floor( widthSegments ) );
+ heightSegments = Math.max( 2, Math.floor( heightSegments ) );
+
+ const thetaEnd = Math.min( thetaStart + thetaLength, Math.PI );
+
+ let index = 0;
+ const grid = [];
+
+ const vertex = new Vector3();
+ const normal = new Vector3();
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // generate vertices, normals and uvs
+
+ for ( let iy = 0; iy <= heightSegments; iy ++ ) {
+
+ const verticesRow = [];
+
+ const v = iy / heightSegments;
+ const theta = thetaStart + v * thetaLength;
+
+ const y = radius * Math.cos( theta );
+ const ringRadius = Math.sqrt( radius * radius - y * y );
+
+ // special case for the poles
+
+ let uOffset = 0;
+
+ if ( iy === 0 && thetaStart === 0 ) {
+
+ uOffset = 0.5 / widthSegments;
+
+ } else if ( iy === heightSegments && thetaEnd === Math.PI ) {
+
+ uOffset = -0.5 / widthSegments;
+
+ }
+
+ for ( let ix = 0; ix <= widthSegments; ix ++ ) {
+
+ const u = ix / widthSegments;
+ const phi = phiStart + u * phiLength;
+
+ // vertex
+
+ vertex.x = - ringRadius * Math.cos( phi );
+ vertex.y = y;
+ vertex.z = ringRadius * Math.sin( phi );
+
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal
+
+ normal.copy( vertex ).normalize();
+ normals.push( normal.x, normal.y, normal.z );
+
+ // uv
+
+ uvs.push( u + uOffset, 1 - v );
+
+ verticesRow.push( index ++ );
+
+ }
+
+ grid.push( verticesRow );
+
+ }
+
+ // indices
+
+ for ( let iy = 0; iy < heightSegments; iy ++ ) {
+
+ for ( let ix = 0; ix < widthSegments; ix ++ ) {
+
+ const a = grid[ iy ][ ix + 1 ];
+ const b = grid[ iy ][ ix ];
+ const c = grid[ iy + 1 ][ ix ];
+ const d = grid[ iy + 1 ][ ix + 1 ];
+
+ if ( iy !== 0 || thetaStart > 0 ) indices.push( a, b, d );
+ if ( iy !== heightSegments - 1 || thetaEnd < Math.PI ) indices.push( b, c, d );
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {SphereGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new SphereGeometry( data.radius, data.widthSegments, data.heightSegments, data.phiStart, data.phiLength, data.thetaStart, data.thetaLength );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing an tetrahedron.
+ *
+ * ```js
+ * const geometry = new THREE.TetrahedronGeometry();
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const tetrahedron = new THREE.Mesh( geometry, material );
+ * scene.add( tetrahedron );
+ * ```
+ *
+ * @augments PolyhedronGeometry
+ * @demo scenes/geometry-browser.html#TetrahedronGeometry
+ */
+class TetrahedronGeometry extends PolyhedronGeometry {
+
+ /**
+ * Constructs a new tetrahedron geometry.
+ *
+ * @param {number} [radius=1] - Radius of the tetrahedron.
+ * @param {number} [detail=0] - Setting this to a value greater than `0` adds vertices making it no longer a tetrahedron.
+ */
+ constructor( radius = 1, detail = 0 ) {
+
+ const vertices = [
+ 1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1
+ ];
+
+ const indices = [
+ 2, 1, 0, 0, 3, 2, 1, 3, 0, 2, 3, 1
+ ];
+
+ super( vertices, indices, radius, detail );
+
+ this.type = 'TetrahedronGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ detail: detail
+ };
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {TetrahedronGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new TetrahedronGeometry( data.radius, data.detail );
+
+ }
+
+}
+
+/**
+ * A geometry class for representing an torus.
+ *
+ * ```js
+ * const geometry = new THREE.TorusGeometry( 10, 3, 16, 100 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const torus = new THREE.Mesh( geometry, material );
+ * scene.add( torus );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#TorusGeometry
+ */
+class TorusGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new torus geometry.
+ *
+ * @param {number} [radius=1] - Radius of the torus, from the center of the torus to the center of the tube.
+ * @param {number} [tube=0.4] - Radius of the tube. Must be smaller than `radius`.
+ * @param {number} [radialSegments=12] - The number of radial segments.
+ * @param {number} [tubularSegments=48] - The number of tubular segments.
+ * @param {number} [arc=Math.PI*2] - Central angle in radians.
+ * @param {number} [thetaStart=0] - Start of the tubular sweep in radians.
+ * @param {number} [thetaLength=Math.PI*2] - Length of the tubular sweep in radians.
+ */
+ constructor( radius = 1, tube = 0.4, radialSegments = 12, tubularSegments = 48, arc = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI * 2 ) {
+
+ super();
+
+ this.type = 'TorusGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ tube: tube,
+ radialSegments: radialSegments,
+ tubularSegments: tubularSegments,
+ arc: arc,
+ thetaStart: thetaStart,
+ thetaLength: thetaLength,
+ };
+
+ radialSegments = Math.floor( radialSegments );
+ tubularSegments = Math.floor( tubularSegments );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // helper variables
+
+ const center = new Vector3();
+ const vertex = new Vector3();
+ const normal = new Vector3();
+
+ // generate vertices, normals and uvs
+
+ for ( let j = 0; j <= radialSegments; j ++ ) {
+
+ const v = thetaStart + ( j / radialSegments ) * thetaLength;
+
+ for ( let i = 0; i <= tubularSegments; i ++ ) {
+
+ const u = i / tubularSegments * arc;
+
+ // vertex
+
+ vertex.x = ( radius + tube * Math.cos( v ) ) * Math.cos( u );
+ vertex.y = ( radius + tube * Math.cos( v ) ) * Math.sin( u );
+ vertex.z = tube * Math.sin( v );
+
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal
+
+ center.x = radius * Math.cos( u );
+ center.y = radius * Math.sin( u );
+ normal.subVectors( vertex, center ).normalize();
+
+ normals.push( normal.x, normal.y, normal.z );
+
+ // uv
+
+ uvs.push( i / tubularSegments );
+ uvs.push( j / radialSegments );
+
+ }
+
+ }
+
+ // generate indices
+
+ for ( let j = 1; j <= radialSegments; j ++ ) {
+
+ for ( let i = 1; i <= tubularSegments; i ++ ) {
+
+ // indices
+
+ const a = ( tubularSegments + 1 ) * j + i - 1;
+ const b = ( tubularSegments + 1 ) * ( j - 1 ) + i - 1;
+ const c = ( tubularSegments + 1 ) * ( j - 1 ) + i;
+ const d = ( tubularSegments + 1 ) * j + i;
+
+ // faces
+
+ indices.push( a, b, d );
+ indices.push( b, c, d );
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {TorusGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new TorusGeometry( data.radius, data.tube, data.radialSegments, data.tubularSegments, data.arc, data.thetaStart, data.thetaLength );
+
+ }
+
+}
+
+/**
+ * Creates a torus knot, the particular shape of which is defined by a pair
+ * of coprime integers, p and q. If p and q are not coprime, the result will
+ * be a torus link.
+ *
+ * ```js
+ * const geometry = new THREE.TorusKnotGeometry( 10, 3, 100, 16 );
+ * const material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
+ * const torusKnot = new THREE.Mesh( geometry, material );
+ * scene.add( torusKnot );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#TorusKnotGeometry
+ */
+class TorusKnotGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new torus knot geometry.
+ *
+ * @param {number} [radius=1] - Radius of the torus knot.
+ * @param {number} [tube=0.4] - Radius of the tube.
+ * @param {number} [tubularSegments=64] - The number of tubular segments.
+ * @param {number} [radialSegments=8] - The number of radial segments.
+ * @param {number} [p=2] - This value determines, how many times the geometry winds around its axis of rotational symmetry.
+ * @param {number} [q=3] - This value determines, how many times the geometry winds around a circle in the interior of the torus.
+ */
+ constructor( radius = 1, tube = 0.4, tubularSegments = 64, radialSegments = 8, p = 2, q = 3 ) {
+
+ super();
+
+ this.type = 'TorusKnotGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ radius: radius,
+ tube: tube,
+ tubularSegments: tubularSegments,
+ radialSegments: radialSegments,
+ p: p,
+ q: q
+ };
+
+ tubularSegments = Math.floor( tubularSegments );
+ radialSegments = Math.floor( radialSegments );
+
+ // buffers
+
+ const indices = [];
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+
+ // helper variables
+
+ const vertex = new Vector3();
+ const normal = new Vector3();
+
+ const P1 = new Vector3();
+ const P2 = new Vector3();
+
+ const B = new Vector3();
+ const T = new Vector3();
+ const N = new Vector3();
+
+ // generate vertices, normals and uvs
+
+ for ( let i = 0; i <= tubularSegments; ++ i ) {
+
+ // the radian "u" is used to calculate the position on the torus curve of the current tubular segment
+
+ const u = i / tubularSegments * p * Math.PI * 2;
+
+ // now we calculate two points. P1 is our current position on the curve, P2 is a little farther ahead.
+ // these points are used to create a special "coordinate space", which is necessary to calculate the correct vertex positions
+
+ calculatePositionOnCurve( u, p, q, radius, P1 );
+ calculatePositionOnCurve( u + 0.01, p, q, radius, P2 );
+
+ // calculate orthonormal basis
+
+ T.subVectors( P2, P1 );
+ N.addVectors( P2, P1 );
+ B.crossVectors( T, N );
+ N.crossVectors( B, T );
+
+ // normalize B, N. T can be ignored, we don't use it
+
+ B.normalize();
+ N.normalize();
+
+ for ( let j = 0; j <= radialSegments; ++ j ) {
+
+ // now calculate the vertices. they are nothing more than an extrusion of the torus curve.
+ // because we extrude a shape in the xy-plane, there is no need to calculate a z-value.
+
+ const v = j / radialSegments * Math.PI * 2;
+ const cx = - tube * Math.cos( v );
+ const cy = tube * Math.sin( v );
+
+ // now calculate the final vertex position.
+ // first we orient the extrusion with our basis vectors, then we add it to the current position on the curve
+
+ vertex.x = P1.x + ( cx * N.x + cy * B.x );
+ vertex.y = P1.y + ( cx * N.y + cy * B.y );
+ vertex.z = P1.z + ( cx * N.z + cy * B.z );
+
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ // normal (P1 is always the center/origin of the extrusion, thus we can use it to calculate the normal)
+
+ normal.subVectors( vertex, P1 ).normalize();
+
+ normals.push( normal.x, normal.y, normal.z );
+
+ // uv
+
+ uvs.push( i / tubularSegments );
+ uvs.push( j / radialSegments );
+
+ }
+
+ }
+
+ // generate indices
+
+ for ( let j = 1; j <= tubularSegments; j ++ ) {
+
+ for ( let i = 1; i <= radialSegments; i ++ ) {
+
+ // indices
+
+ const a = ( radialSegments + 1 ) * ( j - 1 ) + ( i - 1 );
+ const b = ( radialSegments + 1 ) * j + ( i - 1 );
+ const c = ( radialSegments + 1 ) * j + i;
+ const d = ( radialSegments + 1 ) * ( j - 1 ) + i;
+
+ // faces
+
+ indices.push( a, b, d );
+ indices.push( b, c, d );
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ // this function calculates the current position on the torus curve
+
+ function calculatePositionOnCurve( u, p, q, radius, position ) {
+
+ const cu = Math.cos( u );
+ const su = Math.sin( u );
+ const quOverP = q / p * u;
+ const cs = Math.cos( quOverP );
+
+ position.x = radius * ( 2 + cs ) * 0.5 * cu;
+ position.y = radius * ( 2 + cs ) * su * 0.5;
+ position.z = radius * Math.sin( quOverP ) * 0.5;
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {TorusKnotGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ return new TorusKnotGeometry( data.radius, data.tube, data.tubularSegments, data.radialSegments, data.p, data.q );
+
+ }
+
+}
+
+/**
+ * Creates a tube that extrudes along a 3D curve.
+ *
+ * ```js
+ * class CustomSinCurve extends THREE.Curve {
+ *
+ * getPoint( t, optionalTarget = new THREE.Vector3() ) {
+ *
+ * const tx = t * 3 - 1.5;
+ * const ty = Math.sin( 2 * Math.PI * t );
+ * const tz = 0;
+ *
+ * return optionalTarget.set( tx, ty, tz );
+ * }
+ *
+ * }
+ *
+ * const path = new CustomSinCurve( 10 );
+ * const geometry = new THREE.TubeGeometry( path, 20, 2, 8, false );
+ * const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
+ * const mesh = new THREE.Mesh( geometry, material );
+ * scene.add( mesh );
+ * ```
+ *
+ * @augments BufferGeometry
+ * @demo scenes/geometry-browser.html#TubeGeometry
+ */
+class TubeGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new tube geometry.
+ *
+ * @param {Curve} [path=QuadraticBezierCurve3] - A 3D curve defining the path of the tube.
+ * @param {number} [tubularSegments=64] - The number of segments that make up the tube.
+ * @param {number} [radius=1] -The radius of the tube.
+ * @param {number} [radialSegments=8] - The number of segments that make up the cross-section.
+ * @param {boolean} [closed=false] - Whether the tube is closed or not.
+ */
+ constructor( path = new QuadraticBezierCurve3( new Vector3( -1, -1, 0 ), new Vector3( -1, 1, 0 ), new Vector3( 1, 1, 0 ) ), tubularSegments = 64, radius = 1, radialSegments = 8, closed = false ) {
+
+ super();
+
+ this.type = 'TubeGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ path: path,
+ tubularSegments: tubularSegments,
+ radius: radius,
+ radialSegments: radialSegments,
+ closed: closed
+ };
+
+ const frames = path.computeFrenetFrames( tubularSegments, closed );
+
+ // expose internals
+
+ this.tangents = frames.tangents;
+ this.normals = frames.normals;
+ this.binormals = frames.binormals;
+
+ // helper variables
+
+ const vertex = new Vector3();
+ const normal = new Vector3();
+ const uv = new Vector2();
+ let P = new Vector3();
+
+ // buffer
+
+ const vertices = [];
+ const normals = [];
+ const uvs = [];
+ const indices = [];
+
+ // create buffer data
+
+ generateBufferData();
+
+ // build geometry
+
+ this.setIndex( indices );
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+ this.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
+ this.setAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) );
+
+ // functions
+
+ function generateBufferData() {
+
+ for ( let i = 0; i < tubularSegments; i ++ ) {
+
+ generateSegment( i );
+
+ }
+
+ // if the geometry is not closed, generate the last row of vertices and normals
+ // at the regular position on the given path
+ //
+ // if the geometry is closed, duplicate the first row of vertices and normals (uvs will differ)
+
+ generateSegment( ( closed === false ) ? tubularSegments : 0 );
+
+ // uvs are generated in a separate function.
+ // this makes it easy compute correct values for closed geometries
+
+ generateUVs();
+
+ // finally create faces
+
+ generateIndices();
+
+ }
+
+ function generateSegment( i ) {
+
+ // we use getPointAt to sample evenly distributed points from the given path
+
+ P = path.getPointAt( i / tubularSegments, P );
+
+ // retrieve corresponding normal and binormal
+
+ const N = frames.normals[ i ];
+ const B = frames.binormals[ i ];
+
+ // generate normals and vertices for the current segment
+
+ for ( let j = 0; j <= radialSegments; j ++ ) {
+
+ const v = j / radialSegments * Math.PI * 2;
+
+ const sin = Math.sin( v );
+ const cos = - Math.cos( v );
+
+ // normal
+
+ normal.x = ( cos * N.x + sin * B.x );
+ normal.y = ( cos * N.y + sin * B.y );
+ normal.z = ( cos * N.z + sin * B.z );
+ normal.normalize();
+
+ normals.push( normal.x, normal.y, normal.z );
+
+ // vertex
+
+ vertex.x = P.x + radius * normal.x;
+ vertex.y = P.y + radius * normal.y;
+ vertex.z = P.z + radius * normal.z;
+
+ vertices.push( vertex.x, vertex.y, vertex.z );
+
+ }
+
+ }
+
+ function generateIndices() {
+
+ for ( let j = 1; j <= tubularSegments; j ++ ) {
+
+ for ( let i = 1; i <= radialSegments; i ++ ) {
+
+ const a = ( radialSegments + 1 ) * ( j - 1 ) + ( i - 1 );
+ const b = ( radialSegments + 1 ) * j + ( i - 1 );
+ const c = ( radialSegments + 1 ) * j + i;
+ const d = ( radialSegments + 1 ) * ( j - 1 ) + i;
+
+ // faces
+
+ indices.push( a, b, d );
+ indices.push( b, c, d );
+
+ }
+
+ }
+
+ }
+
+ function generateUVs() {
+
+ for ( let i = 0; i <= tubularSegments; i ++ ) {
+
+ for ( let j = 0; j <= radialSegments; j ++ ) {
+
+ uv.x = i / tubularSegments;
+ uv.y = j / radialSegments;
+
+ uvs.push( uv.x, uv.y );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.path = this.parameters.path.toJSON();
+
+ return data;
+
+ }
+
+ /**
+ * Factory method for creating an instance of this class from the given
+ * JSON object.
+ *
+ * @param {Object} data - A JSON object representing the serialized geometry.
+ * @return {TubeGeometry} A new instance.
+ */
+ static fromJSON( data ) {
+
+ // This only works for built-in curves (e.g. CatmullRomCurve3).
+ // User defined curves or instances of CurvePath will not be deserialized.
+ return new TubeGeometry(
+ new Curves[ data.path.type ]().fromJSON( data.path ),
+ data.tubularSegments,
+ data.radius,
+ data.radialSegments,
+ data.closed
+ );
+
+ }
+
+}
+
+/**
+ * Can be used as a helper object to visualize a geometry as a wireframe.
+ *
+ * ```js
+ * const geometry = new THREE.SphereGeometry();
+ *
+ * const wireframe = new THREE.WireframeGeometry( geometry );
+ *
+ * const line = new THREE.LineSegments( wireframe );
+ * line.material.depthWrite = false;
+ * line.material.opacity = 0.25;
+ * line.material.transparent = true;
+ *
+ * scene.add( line );
+ * ```
+ *
+ * Note: It is not yet possible to serialize/deserialize instances of this class.
+ *
+ * @augments BufferGeometry
+ */
+class WireframeGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new wireframe geometry.
+ *
+ * @param {?BufferGeometry} [geometry=null] - The geometry.
+ */
+ constructor( geometry = null ) {
+
+ super();
+
+ this.type = 'WireframeGeometry';
+
+ /**
+ * Holds the constructor parameters that have been
+ * used to generate the geometry. Any modification
+ * after instantiation does not change the geometry.
+ *
+ * @type {Object}
+ */
+ this.parameters = {
+ geometry: geometry
+ };
+
+ if ( geometry !== null ) {
+
+ // buffer
+
+ const vertices = [];
+ const edges = new Set();
+
+ // helper variables
+
+ const start = new Vector3();
+ const end = new Vector3();
+
+ if ( geometry.index !== null ) {
+
+ // indexed BufferGeometry
+
+ const position = geometry.attributes.position;
+ const indices = geometry.index;
+ let groups = geometry.groups;
+
+ if ( groups.length === 0 ) {
+
+ groups = [ { start: 0, count: indices.count, materialIndex: 0 } ];
+
+ }
+
+ // create a data structure that contains all edges without duplicates
+
+ for ( let o = 0, ol = groups.length; o < ol; ++ o ) {
+
+ const group = groups[ o ];
+
+ const groupStart = group.start;
+ const groupCount = group.count;
+
+ for ( let i = groupStart, l = ( groupStart + groupCount ); i < l; i += 3 ) {
+
+ for ( let j = 0; j < 3; j ++ ) {
+
+ const index1 = indices.getX( i + j );
+ const index2 = indices.getX( i + ( j + 1 ) % 3 );
+
+ start.fromBufferAttribute( position, index1 );
+ end.fromBufferAttribute( position, index2 );
+
+ if ( isUniqueEdge( start, end, edges ) === true ) {
+
+ vertices.push( start.x, start.y, start.z );
+ vertices.push( end.x, end.y, end.z );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ } else {
+
+ // non-indexed BufferGeometry
+
+ const position = geometry.attributes.position;
+
+ for ( let i = 0, l = ( position.count / 3 ); i < l; i ++ ) {
+
+ for ( let j = 0; j < 3; j ++ ) {
+
+ // three edges per triangle, an edge is represented as (index1, index2)
+ // e.g. the first triangle has the following edges: (0,1),(1,2),(2,0)
+
+ const index1 = 3 * i + j;
+ const index2 = 3 * i + ( ( j + 1 ) % 3 );
+
+ start.fromBufferAttribute( position, index1 );
+ end.fromBufferAttribute( position, index2 );
+
+ if ( isUniqueEdge( start, end, edges ) === true ) {
+
+ vertices.push( start.x, start.y, start.z );
+ vertices.push( end.x, end.y, end.z );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ // build geometry
+
+ this.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.parameters = Object.assign( {}, source.parameters );
+
+ return this;
+
+ }
+
+}
+
+function isUniqueEdge( start, end, edges ) {
+
+ const hash1 = `${start.x},${start.y},${start.z}-${end.x},${end.y},${end.z}`;
+ const hash2 = `${end.x},${end.y},${end.z}-${start.x},${start.y},${start.z}`; // coincident edge
+
+ if ( edges.has( hash1 ) === true || edges.has( hash2 ) === true ) {
+
+ return false;
+
+ } else {
+
+ edges.add( hash1 );
+ edges.add( hash2 );
+ return true;
+
+ }
+
+}
+
+var Geometries = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ BoxGeometry: BoxGeometry,
+ CapsuleGeometry: CapsuleGeometry,
+ CircleGeometry: CircleGeometry,
+ ConeGeometry: ConeGeometry,
+ CylinderGeometry: CylinderGeometry,
+ DodecahedronGeometry: DodecahedronGeometry,
+ EdgesGeometry: EdgesGeometry,
+ ExtrudeGeometry: ExtrudeGeometry,
+ IcosahedronGeometry: IcosahedronGeometry,
+ LatheGeometry: LatheGeometry,
+ OctahedronGeometry: OctahedronGeometry,
+ PlaneGeometry: PlaneGeometry,
+ PolyhedronGeometry: PolyhedronGeometry,
+ RingGeometry: RingGeometry,
+ ShapeGeometry: ShapeGeometry,
+ SphereGeometry: SphereGeometry,
+ TetrahedronGeometry: TetrahedronGeometry,
+ TorusGeometry: TorusGeometry,
+ TorusKnotGeometry: TorusKnotGeometry,
+ TubeGeometry: TubeGeometry,
+ WireframeGeometry: WireframeGeometry
+});
+
+/**
+ * This material can receive shadows, but otherwise is completely transparent.
+ *
+ * ```js
+ * const geometry = new THREE.PlaneGeometry( 2000, 2000 );
+ * geometry.rotateX( - Math.PI / 2 );
+ *
+ * const material = new THREE.ShadowMaterial();
+ * material.opacity = 0.2;
+ *
+ * const plane = new THREE.Mesh( geometry, material );
+ * plane.position.y = -200;
+ * plane.receiveShadow = true;
+ * scene.add( plane );
+ * ```
+ *
+ * @augments Material
+ */
+class ShadowMaterial extends Material {
+
+ /**
+ * Constructs a new shadow material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isShadowMaterial = true;
+
+ this.type = 'ShadowMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (0,0,0)
+ */
+ this.color = new Color( 0x000000 );
+
+ /**
+ * Overwritten since shadow materials are transparent
+ * by default.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.transparent = true;
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * Provides utility functions for managing uniforms.
+ *
+ * @module UniformsUtils
+ */
+
+/**
+ * Clones the given uniform definitions by performing a deep-copy. That means
+ * if the value of a uniform refers to an object like a Vector3 or Texture,
+ * the cloned uniform will refer to a new object reference.
+ *
+ * @param {Object} src - An object representing uniform definitions.
+ * @return {Object} The cloned uniforms.
+ */
+function cloneUniforms( src ) {
+
+ const dst = {};
+
+ for ( const u in src ) {
+
+ dst[ u ] = {};
+
+ for ( const p in src[ u ] ) {
+
+ const property = src[ u ][ p ];
+
+ if ( isThreeObject( property ) ) {
+
+ if ( property.isRenderTargetTexture ) {
+
+ warn( 'UniformsUtils: Textures of render targets cannot be cloned via cloneUniforms() or mergeUniforms().' );
+ dst[ u ][ p ] = null;
+
+ } else {
+
+ dst[ u ][ p ] = property.clone();
+
+ }
+
+ } else if ( Array.isArray( property ) ) {
+
+ if ( isThreeObject( property[ 0 ] ) ) {
+
+ const clonedProperty = [];
+
+ for ( let i = 0, l = property.length; i < l; i ++ ) {
+
+ clonedProperty[ i ] = property[ i ].clone();
+
+ }
+
+ dst[ u ][ p ] = clonedProperty;
+
+ } else {
+
+ dst[ u ][ p ] = property.slice();
+
+ }
+
+ } else {
+
+ dst[ u ][ p ] = property;
+
+ }
+
+ }
+
+ }
+
+ return dst;
+
+}
+
+/**
+ * Merges the given uniform definitions into a single object. Since the
+ * method internally uses cloneUniforms(), it performs a deep-copy when
+ * producing the merged uniform definitions.
+ *
+ * @param {Array} uniforms - An array of objects containing uniform definitions.
+ * @return {Object} The merged uniforms.
+ */
+function mergeUniforms( uniforms ) {
+
+ const merged = {};
+
+ for ( let u = 0; u < uniforms.length; u ++ ) {
+
+ const tmp = cloneUniforms( uniforms[ u ] );
+
+ for ( const p in tmp ) {
+
+ merged[ p ] = tmp[ p ];
+
+ }
+
+ }
+
+ return merged;
+
+}
+
+function isThreeObject( property ) {
+
+ return ( property && ( property.isColor ||
+ property.isMatrix3 || property.isMatrix4 ||
+ property.isVector2 || property.isVector3 || property.isVector4 ||
+ property.isTexture || property.isQuaternion ) );
+
+}
+
+function cloneUniformsGroups( src ) {
+
+ const dst = [];
+
+ for ( let u = 0; u < src.length; u ++ ) {
+
+ dst.push( src[ u ].clone() );
+
+ }
+
+ return dst;
+
+}
+
+function getUnlitUniformColorSpace( renderer ) {
+
+ const currentRenderTarget = renderer.getRenderTarget();
+
+ if ( currentRenderTarget === null ) {
+
+ // https://github.com/mrdoob/three.js/pull/23937#issuecomment-1111067398
+ return renderer.outputColorSpace;
+
+ }
+
+ // https://github.com/mrdoob/three.js/issues/27868
+ if ( currentRenderTarget.isXRRenderTarget === true ) {
+
+ return currentRenderTarget.texture.colorSpace;
+
+ }
+
+ return ColorManagement.workingColorSpace;
+
+}
+
+// Legacy
+
+const UniformsUtils = { clone: cloneUniforms, merge: mergeUniforms };
+
+var default_vertex = "void main() {\n\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n}";
+
+var default_fragment = "void main() {\n\tgl_FragColor = vec4( 1.0, 0.0, 0.0, 1.0 );\n}";
+
+/**
+ * A material rendered with custom shaders. A shader is a small program written in GLSL.
+ * that runs on the GPU. You may want to use a custom shader if you need to implement an
+ * effect not included with any of the built-in materials.
+ *
+ * There are the following notes to bear in mind when using a `ShaderMaterial`:
+ *
+ * - `ShaderMaterial` can only be used with {@link WebGLRenderer}.
+ * - Built in attributes and uniforms are passed to the shaders along with your code. If
+ * you don't want that, use {@link RawShaderMaterial} instead.
+ * - You can use the directive `#pragma unroll_loop_start` and `#pragma unroll_loop_end`
+ * in order to unroll a `for` loop in GLSL by the shader preprocessor. The directive has
+ * to be placed right above the loop. The loop formatting has to correspond to a defined standard.
+ * - The loop has to be [normalized](https://en.wikipedia.org/wiki/Normalized_loop).
+ * - The loop variable has to be *i*.
+ * - The value `UNROLLED_LOOP_INDEX` will be replaced with the explicitly
+ * value of *i* for the given iteration and can be used in preprocessor
+ * statements.
+ *
+ * ```js
+ * const material = new THREE.ShaderMaterial( {
+ * uniforms: {
+ * time: { value: 1.0 },
+ * resolution: { value: new THREE.Vector2() }
+ * },
+ * vertexShader: document.getElementById( 'vertexShader' ).textContent,
+ * fragmentShader: document.getElementById( 'fragmentShader' ).textContent
+ * } );
+ * ```
+ *
+ * @augments Material
+ */
+class ShaderMaterial extends Material {
+
+ /**
+ * Constructs a new shader material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isShaderMaterial = true;
+
+ this.type = 'ShaderMaterial';
+
+ /**
+ * Defines custom constants using `#define` directives within the GLSL code
+ * for both the vertex shader and the fragment shader; each key/value pair
+ * yields another directive.
+ * ```js
+ * defines: {
+ * FOO: 15,
+ * BAR: true
+ * }
+ * ```
+ * Yields the lines:
+ * ```
+ * #define FOO 15
+ * #define BAR true
+ * ```
+ *
+ * @type {Object}
+ */
+ this.defines = {};
+
+ /**
+ * An object of the form:
+ * ```js
+ * {
+ * "uniform1": { value: 1.0 },
+ * "uniform2": { value: 2 }
+ * }
+ * ```
+ * specifying the uniforms to be passed to the shader code; keys are uniform
+ * names, values are definitions of the form
+ * ```
+ * {
+ * value: 1.0
+ * }
+ * ```
+ * where `value` is the value of the uniform. Names must match the name of
+ * the uniform, as defined in the GLSL code. Note that uniforms are refreshed
+ * on every frame, so updating the value of the uniform will immediately
+ * update the value available to the GLSL code.
+ *
+ * @type {Object}
+ */
+ this.uniforms = {};
+
+ /**
+ * An array holding uniforms groups for configuring UBOs.
+ *
+ * @type {Array}
+ */
+ this.uniformsGroups = [];
+
+ /**
+ * Vertex shader GLSL code. This is the actual code for the shader.
+ *
+ * @type {string}
+ */
+ this.vertexShader = default_vertex;
+
+ /**
+ * Fragment shader GLSL code. This is the actual code for the shader.
+ *
+ * @type {string}
+ */
+ this.fragmentShader = default_fragment;
+
+ /**
+ * Controls line thickness or lines.
+ *
+ * WebGL and WebGPU ignore this setting and always render line primitives with a
+ * width of one pixel.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.linewidth = 1;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * WebGL and WebGPU ignore this property and always render
+ * 1 pixel wide lines.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Defines whether the material color is affected by global fog settings; `true`
+ * to pass fog uniforms to the shader.
+ *
+ * Setting this property to `true` requires the definition of fog uniforms. It is
+ * recommended to use `UniformsUtils.merge()` to combine the custom shader uniforms
+ * with predefined fog uniforms.
+ *
+ * ```js
+ * const material = new ShaderMaterial( {
+ * uniforms: UniformsUtils.merge( [ UniformsLib[ 'fog' ], shaderUniforms ] );
+ * vertexShader: vertexShader,
+ * fragmentShader: fragmentShader,
+ * fog: true
+ * } );
+ * ```
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.fog = false;
+
+ /**
+ * Defines whether this material uses lighting; `true` to pass uniform data
+ * related to lighting to this shader.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.lights = false;
+
+ /**
+ * Defines whether this material supports clipping; `true` to let the renderer
+ * pass the clippingPlanes uniform.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.clipping = false;
+
+ /**
+ * Overwritten and set to `true` by default.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.forceSinglePass = true;
+
+ /**
+ * This object allows to enable certain WebGL 2 extensions.
+ *
+ * - clipCullDistance: set to `true` to use vertex shader clipping
+ * - multiDraw: set to `true` to use vertex shader multi_draw / enable gl_DrawID
+ *
+ * @type {{clipCullDistance:false,multiDraw:false}}
+ */
+ this.extensions = {
+ clipCullDistance: false, // set to use vertex shader clipping
+ multiDraw: false // set to use vertex shader multi_draw / enable gl_DrawID
+ };
+
+ /**
+ * When the rendered geometry doesn't include these attributes but the
+ * material does, these default values will be passed to the shaders. This
+ * avoids errors when buffer data is missing.
+ *
+ * - color: [ 1, 1, 1 ]
+ * - uv: [ 0, 0 ]
+ * - uv1: [ 0, 0 ]
+ *
+ * @type {Object}
+ */
+ this.defaultAttributeValues = {
+ 'color': [ 1, 1, 1 ],
+ 'uv': [ 0, 0 ],
+ 'uv1': [ 0, 0 ]
+ };
+
+ /**
+ * If set, this calls [gl.bindAttribLocation](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/bindAttribLocation)
+ * to bind a generic vertex index to an attribute variable.
+ *
+ * @type {string|undefined}
+ * @default undefined
+ */
+ this.index0AttributeName = undefined;
+
+ /**
+ * Can be used to force a uniform update while changing uniforms in
+ * {@link Object3D#onBeforeRender}.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.uniformsNeedUpdate = false;
+
+ /**
+ * Defines the GLSL version of custom shader code.
+ *
+ * @type {?(GLSL1|GLSL3)}
+ * @default null
+ */
+ this.glslVersion = null;
+
+ if ( parameters !== undefined ) {
+
+ this.setValues( parameters );
+
+ }
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.fragmentShader = source.fragmentShader;
+ this.vertexShader = source.vertexShader;
+
+ this.uniforms = cloneUniforms( source.uniforms );
+ this.uniformsGroups = cloneUniformsGroups( source.uniformsGroups );
+
+ this.defines = Object.assign( {}, source.defines );
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+
+ this.fog = source.fog;
+ this.lights = source.lights;
+ this.clipping = source.clipping;
+
+ this.extensions = Object.assign( {}, source.extensions );
+
+ this.glslVersion = source.glslVersion;
+
+ this.defaultAttributeValues = Object.assign( {}, source.defaultAttributeValues );
+
+ this.index0AttributeName = source.index0AttributeName;
+
+ this.uniformsNeedUpdate = source.uniformsNeedUpdate;
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.glslVersion = this.glslVersion;
+ data.uniforms = {};
+
+ for ( const name in this.uniforms ) {
+
+ const uniform = this.uniforms[ name ];
+ const value = uniform.value;
+
+ if ( value && value.isTexture ) {
+
+ data.uniforms[ name ] = {
+ type: 't',
+ value: value.toJSON( meta ).uuid
+ };
+
+ } else if ( value && value.isColor ) {
+
+ data.uniforms[ name ] = {
+ type: 'c',
+ value: value.getHex()
+ };
+
+ } else if ( value && value.isVector2 ) {
+
+ data.uniforms[ name ] = {
+ type: 'v2',
+ value: value.toArray()
+ };
+
+ } else if ( value && value.isVector3 ) {
+
+ data.uniforms[ name ] = {
+ type: 'v3',
+ value: value.toArray()
+ };
+
+ } else if ( value && value.isVector4 ) {
+
+ data.uniforms[ name ] = {
+ type: 'v4',
+ value: value.toArray()
+ };
+
+ } else if ( value && value.isMatrix3 ) {
+
+ data.uniforms[ name ] = {
+ type: 'm3',
+ value: value.toArray()
+ };
+
+ } else if ( value && value.isMatrix4 ) {
+
+ data.uniforms[ name ] = {
+ type: 'm4',
+ value: value.toArray()
+ };
+
+ } else {
+
+ data.uniforms[ name ] = {
+ value: value
+ };
+
+ // note: the array variants v2v, v3v, v4v, m4v and tv are not supported so far
+
+ }
+
+ }
+
+ if ( Object.keys( this.defines ).length > 0 ) data.defines = this.defines;
+
+ data.vertexShader = this.vertexShader;
+ data.fragmentShader = this.fragmentShader;
+
+ data.lights = this.lights;
+ data.clipping = this.clipping;
+
+ const extensions = {};
+
+ for ( const key in this.extensions ) {
+
+ if ( this.extensions[ key ] === true ) extensions[ key ] = true;
+
+ }
+
+ if ( Object.keys( extensions ).length > 0 ) data.extensions = extensions;
+
+ return data;
+
+ }
+
+ /**
+ * Deserializes the material from the given JSON.
+ *
+ * @param {Object} json - The JSON holding the serialized material.
+ * @param {Object} textures - A dictionary holding textures referenced by the material.
+ * @return {ShaderMaterial} A reference to this material.
+ */
+ fromJSON( json, textures ) {
+
+ super.fromJSON( json, textures );
+
+ if ( json.uniforms !== undefined ) {
+
+ for ( const name in json.uniforms ) {
+
+ const uniform = json.uniforms[ name ];
+
+ this.uniforms[ name ] = {};
+
+ switch ( uniform.type ) {
+
+ case 't':
+ this.uniforms[ name ].value = textures[ uniform.value ] || null;
+ break;
+
+ case 'c':
+ this.uniforms[ name ].value = new Color().setHex( uniform.value );
+ break;
+
+ case 'v2':
+ this.uniforms[ name ].value = new Vector2().fromArray( uniform.value );
+ break;
+
+ case 'v3':
+ this.uniforms[ name ].value = new Vector3().fromArray( uniform.value );
+ break;
+
+ case 'v4':
+ this.uniforms[ name ].value = new Vector4().fromArray( uniform.value );
+ break;
+
+ case 'm3':
+ this.uniforms[ name ].value = new Matrix3().fromArray( uniform.value );
+ break;
+
+ case 'm4':
+ this.uniforms[ name ].value = new Matrix4().fromArray( uniform.value );
+ break;
+
+ default:
+ this.uniforms[ name ].value = uniform.value;
+
+ }
+
+ }
+
+ }
+
+ if ( json.defines !== undefined ) this.defines = json.defines;
+ if ( json.vertexShader !== undefined ) this.vertexShader = json.vertexShader;
+ if ( json.fragmentShader !== undefined ) this.fragmentShader = json.fragmentShader;
+ if ( json.glslVersion !== undefined ) this.glslVersion = json.glslVersion;
+
+ if ( json.extensions !== undefined ) {
+
+ for ( const key in json.extensions ) {
+
+ this.extensions[ key ] = json.extensions[ key ];
+
+ }
+
+ }
+
+ if ( json.lights !== undefined ) this.lights = json.lights;
+ if ( json.clipping !== undefined ) this.clipping = json.clipping;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * This class works just like {@link ShaderMaterial}, except that definitions
+ * of built-in uniforms and attributes are not automatically prepended to the
+ * GLSL shader code.
+ *
+ * `RawShaderMaterial` can only be used with {@link WebGLRenderer}.
+ *
+ * @augments ShaderMaterial
+ */
+class RawShaderMaterial extends ShaderMaterial {
+
+ /**
+ * Constructs a new raw shader material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super( parameters );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isRawShaderMaterial = true;
+
+ this.type = 'RawShaderMaterial';
+
+ }
+
+}
+
+/**
+ * A standard physically based material, using Metallic-Roughness workflow.
+ *
+ * Physically based rendering (PBR) has recently become the standard in many
+ * 3D applications, such as [Unity](https://blogs.unity3d.com/2014/10/29/physically-based-shading-in-unity-5-a-primer/),
+ * [Unreal](https://docs.unrealengine.com/latest/INT/Engine/Rendering/Materials/PhysicallyBased/) and
+ * [3D Studio Max](http://area.autodesk.com/blogs/the-3ds-max-blog/what039s-new-for-rendering-in-3ds-max-2017).
+ *
+ * This approach differs from older approaches in that instead of using
+ * approximations for the way in which light interacts with a surface, a
+ * physically correct model is used. The idea is that, instead of tweaking
+ * materials to look good under specific lighting, a material can be created
+ * that will react 'correctly' under all lighting scenarios.
+ *
+ * In practice this gives a more accurate and realistic looking result than
+ * the {@link MeshLambertMaterial} or {@link MeshPhongMaterial}, at the cost of
+ * being somewhat more computationally expensive. `MeshStandardMaterial` uses per-fragment
+ * shading.
+ *
+ * Note that for best results you should always specify an environment map when using this material.
+ *
+ * For a non-technical introduction to the concept of PBR and how to set up a
+ * PBR material, check out these articles by the people at [marmoset](https://www.marmoset.co):
+ *
+ * - [Basic Theory of Physically Based Rendering](https://www.marmoset.co/posts/basic-theory-of-physically-based-rendering/)
+ * - [Physically Based Rendering and You Can Too](https://www.marmoset.co/posts/physically-based-rendering-and-you-can-too/)
+ *
+ * Technical details of the approach used in three.js (and most other PBR systems) can be found is this
+ * [paper from Disney](https://media.disneyanimation.com/uploads/production/publication_asset/48/asset/s2012_pbs_disney_brdf_notes_v3.pdf)
+ * (pdf), by Brent Burley.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshStandardMaterial
+ */
+class MeshStandardMaterial extends Material {
+
+ /**
+ * Constructs a new mesh standard material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshStandardMaterial = true;
+
+ this.type = 'MeshStandardMaterial';
+
+ this.defines = { 'STANDARD': '' };
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff ); // diffuse
+
+ /**
+ * How rough the material appears. `0.0` means a smooth mirror reflection, `1.0`
+ * means fully diffuse. If `roughnessMap` is also provided,
+ * both values are multiplied.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.roughness = 1.0;
+
+ /**
+ * How much the material is like a metal. Non-metallic materials such as wood
+ * or stone use `0.0`, metallic use `1.0`, with nothing (usually) in between.
+ * A value between `0.0` and `1.0` could be used for a rusty metal look.
+ * If `metalnessMap` is also provided, both values are multiplied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.metalness = 0.0;
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The light map. Requires a second set of UVs.
+ *
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `lightMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.lightMap = null;
+
+ /**
+ * Intensity of the baked light.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.lightMapIntensity = 1.0;
+
+ /**
+ * The red channel of this texture is used as the ambient occlusion map.
+ * Requires a second set of UVs.
+ *
+ * `aoMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.aoMap = null;
+
+ /**
+ * Intensity of the ambient occlusion effect. Range is `[0,1]`, where `0`
+ * disables ambient occlusion. Where intensity is `1` and the AO map's
+ * red channel is also `1`, ambient light is fully occluded on a surface.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aoMapIntensity = 1.0;
+
+ /**
+ * Emissive (light) color of the material, essentially a solid color
+ * unaffected by other lighting.
+ *
+ * @type {Color}
+ * @default (0,0,0)
+ */
+ this.emissive = new Color( 0x000000 );
+
+ /**
+ * Intensity of the emissive light. Modulates the emissive color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.emissiveIntensity = 1.0;
+
+ /**
+ * Set emissive (glow) map. The emissive map color is modulated by the
+ * emissive color and the emissive intensity. If you have an emissive map,
+ * be sure to set the emissive color to something other than black.
+ *
+ * `emissiveMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `emissiveMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.emissiveMap = null;
+
+ /**
+ * The texture to create a bump map. The black and white values map to the
+ * perceived depth in relation to the lights. Bump doesn't actually affect
+ * the geometry of the object, only the lighting. If a normal map is defined
+ * this will be ignored.
+ *
+ * `bumpMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.bumpMap = null;
+
+ /**
+ * How much the bump map affects the material. Typical range is `[0,1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.bumpScale = 1;
+
+ /**
+ * The texture to create a normal map. The RGB values affect the surface
+ * normal for each pixel fragment and change the way the color is lit. Normal
+ * maps do not change the actual shape of the surface, only the lighting. In
+ * case the material has a normal map authored using the left handed
+ * convention, the `y` component of `normalScale` should be negated to compensate
+ * for the different handedness.
+ *
+ * `normalMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.normalMap = null;
+
+ /**
+ * The type of normal map.
+ *
+ * @type {(TangentSpaceNormalMap|ObjectSpaceNormalMap)}
+ * @default TangentSpaceNormalMap
+ */
+ this.normalMapType = TangentSpaceNormalMap;
+
+ /**
+ * How much the normal map affects the material. Typical value range is `[0,1]`.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.normalScale = new Vector2( 1, 1 );
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh. For best results, pair a
+ * displacement map with a matching normal map, since the renderer can
+ * not recompute surface normals from the displaced vertices.
+ *
+ * `displacementMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ /**
+ * The green channel of this texture is used to alter the roughness of the
+ * material.
+ *
+ * `roughnessMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.roughnessMap = null;
+
+ /**
+ * The blue channel of this texture is used to alter the metalness of the
+ * material.
+ *
+ * `metalnessMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.metalnessMap = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * The environment map. To ensure a physically correct rendering, environment maps
+ * are internally pre-processed with {@link PMREMGenerator}.
+ *
+ * `envMap` represents luminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `envMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.envMap = null;
+
+ /**
+ * The rotation of the environment map in radians.
+ *
+ * @type {Euler}
+ * @default (0,0,0)
+ */
+ this.envMapRotation = new Euler();
+
+ /**
+ * Scales the effect of the environment map by multiplying its color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.envMapIntensity = 1.0;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Defines appearance of wireframe ends.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinecap = 'round';
+
+ /**
+ * Defines appearance of wireframe joints.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinejoin = 'round';
+
+ /**
+ * Whether the material is rendered with flat shading or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flatShading = false;
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.defines = { 'STANDARD': '' };
+
+ this.color.copy( source.color );
+ this.roughness = source.roughness;
+ this.metalness = source.metalness;
+
+ this.map = source.map;
+
+ this.lightMap = source.lightMap;
+ this.lightMapIntensity = source.lightMapIntensity;
+
+ this.aoMap = source.aoMap;
+ this.aoMapIntensity = source.aoMapIntensity;
+
+ this.emissive.copy( source.emissive );
+ this.emissiveMap = source.emissiveMap;
+ this.emissiveIntensity = source.emissiveIntensity;
+
+ this.bumpMap = source.bumpMap;
+ this.bumpScale = source.bumpScale;
+
+ this.normalMap = source.normalMap;
+ this.normalMapType = source.normalMapType;
+ this.normalScale.copy( source.normalScale );
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ this.roughnessMap = source.roughnessMap;
+
+ this.metalnessMap = source.metalnessMap;
+
+ this.alphaMap = source.alphaMap;
+
+ this.envMap = source.envMap;
+ this.envMapRotation.copy( source.envMapRotation );
+ this.envMapIntensity = source.envMapIntensity;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+ this.wireframeLinecap = source.wireframeLinecap;
+ this.wireframeLinejoin = source.wireframeLinejoin;
+
+ this.flatShading = source.flatShading;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * An extension of the {@link MeshStandardMaterial}, providing more advanced
+ * physically-based rendering properties:
+ *
+ * - Anisotropy: Ability to represent the anisotropic property of materials
+ * as observable with brushed metals.
+ * - Clearcoat: Some materials — like car paints, carbon fiber, and wet surfaces — require
+ * a clear, reflective layer on top of another layer that may be irregular or rough.
+ * Clearcoat approximates this effect, without the need for a separate transparent surface.
+ * - Iridescence: Allows to render the effect where hue varies depending on the viewing
+ * angle and illumination angle. This can be seen on soap bubbles, oil films, or on the
+ * wings of many insects.
+ * - Physically-based transparency: One limitation of {@link Material#opacity} is that highly
+ * transparent materials are less reflective. Physically-based transmission provides a more
+ * realistic option for thin, transparent surfaces like glass.
+ * - Advanced reflectivity: More flexible reflectivity for non-metallic materials.
+ * - Retroreflection: Redirects specular light back toward the light source for
+ * safety materials like road markings and reflective tape.
+ * - Sheen: Can be used for representing cloth and fabric materials.
+ *
+ * As a result of these complex shading features, `MeshPhysicalMaterial` has a
+ * higher performance cost, per pixel, than other three.js materials. Most
+ * effects are disabled by default, and add cost as they are enabled. For
+ * best results, always specify an environment map when using this material.
+ *
+ * @augments MeshStandardMaterial
+ * @demo scenes/material-browser.html#MeshPhysicalMaterial
+ */
+class MeshPhysicalMaterial extends MeshStandardMaterial {
+
+ /**
+ * Constructs a new mesh physical material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshPhysicalMaterial = true;
+
+ this.defines = {
+
+ 'STANDARD': '',
+ 'PHYSICAL': ''
+
+ };
+
+ this.type = 'MeshPhysicalMaterial';
+
+ /**
+ * The rotation of the anisotropy in tangent, bitangent space, measured in radians
+ * counter-clockwise from the tangent. When `anisotropyMap` is present, this
+ * property provides additional rotation to the vectors in the texture.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.anisotropyRotation = 0;
+
+ /**
+ * Red and green channels represent the anisotropy direction in `[-1, 1]` tangent,
+ * bitangent space, to be rotated by `anisotropyRotation`. The blue channel
+ * contains strength as `[0, 1]` to be multiplied by `anisotropy`.
+ *
+ * `anisotropyMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.anisotropyMap = null;
+
+ /**
+ * The red channel of this texture is multiplied against `clearcoat`,
+ * for per-pixel control over a coating's intensity.
+ *
+ * `clearcoatMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.clearcoatMap = null;
+
+ /**
+ * Roughness of the clear coat layer, from `0.0` to `1.0`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.clearcoatRoughness = 0.0;
+
+ /**
+ * The green channel of this texture is multiplied against
+ * `clearcoatRoughness`, for per-pixel control over a coating's roughness.
+ *
+ * `clearcoatRoughnessMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.clearcoatRoughnessMap = null;
+
+ /**
+ * How much `clearcoatNormalMap` affects the clear coat layer, from
+ * `(0,0)` to `(1,1)`.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.clearcoatNormalScale = new Vector2( 1, 1 );
+
+ /**
+ * Can be used to enable independent normals for the clear coat layer.
+ *
+ * `clearcoatNormalMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.clearcoatNormalMap = null;
+
+ /**
+ * Index-of-refraction for non-metallic materials, from `1.0` to `2.333`.
+ *
+ * @type {number}
+ * @default 1.5
+ */
+ this.ior = 1.5;
+
+ /**
+ * Degree of reflectivity, from `0.0` to `1.0`. Default is `0.5`, which
+ * corresponds to an index-of-refraction of `1.5`.
+ *
+ * This models the reflectivity of non-metallic materials. It has no effect
+ * when `metalness` is `1.0`
+ *
+ * @name MeshPhysicalMaterial#reflectivity
+ * @type {number}
+ * @default 0.5
+ */
+ Object.defineProperty( this, 'reflectivity', {
+ get: function () {
+
+ return ( clamp( 2.5 * ( this.ior - 1 ) / ( this.ior + 1 ), 0, 1 ) );
+
+ },
+ set: function ( reflectivity ) {
+
+ this.ior = ( 1 + 0.4 * reflectivity ) / ( 1 - 0.4 * reflectivity );
+
+ }
+ } );
+
+ /**
+ * The red channel of this texture is multiplied against `iridescence`, for per-pixel
+ * control over iridescence.
+ *
+ * `iridescenceMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.iridescenceMap = null;
+
+ /**
+ * Strength of the iridescence RGB color shift effect, represented by an index-of-refraction.
+ * Between `1.0` to `2.333`.
+ *
+ * @type {number}
+ * @default 1.3
+ */
+ this.iridescenceIOR = 1.3;
+
+ /**
+ *Array of exactly 2 elements, specifying minimum and maximum thickness of the iridescence layer.
+ Thickness of iridescence layer has an equivalent effect of the one `thickness` has on `ior`.
+ *
+ * @type {Array}
+ * @default [100,400]
+ */
+ this.iridescenceThicknessRange = [ 100, 400 ];
+
+ /**
+ * A texture that defines the thickness of the iridescence layer, stored in the green channel.
+ * Minimum and maximum values of thickness are defined by `iridescenceThicknessRange` array:
+ * - `0.0` in the green channel will result in thickness equal to first element of the array.
+ * - `1.0` in the green channel will result in thickness equal to second element of the array.
+ * - Values in-between will linearly interpolate between the elements of the array.
+ *
+ * `iridescenceThicknessMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.iridescenceThicknessMap = null;
+
+ /**
+ * The sheen tint.
+ *
+ * @type {Color}
+ * @default (0,0,0)
+ */
+ this.sheenColor = new Color( 0x000000 );
+
+ /**
+ * The RGB channels of this texture are multiplied against `sheenColor`, for per-pixel control
+ * over sheen tint.
+ *
+ * `sheenColorMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `sheenColorMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.sheenColorMap = null;
+
+ /**
+ * Roughness of the sheen layer, from `0.0` to `1.0`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.sheenRoughness = 1.0;
+
+ /**
+ * The alpha channel of this texture is multiplied against `sheenRoughness`, for per-pixel control
+ * over sheen roughness.
+ *
+ * `sheenRoughnessMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.sheenRoughnessMap = null;
+
+ /**
+ * The red channel of this texture is multiplied against `transmission`, for per-pixel control over
+ * optical transparency.
+ *
+ * `transmissionMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.transmissionMap = null;
+
+ /**
+ * The thickness of the volume beneath the surface. The value is given in the
+ * coordinate space of the mesh. If the value is `0` the material is
+ * thin-walled. Otherwise the material is a volume boundary.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.thickness = 0;
+
+ /**
+ * A texture that defines the thickness, stored in the green channel. This will
+ * be multiplied by `thickness`.
+ *
+ * `thicknessMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.thicknessMap = null;
+
+ /**
+ * Density of the medium given as the average distance that light travels in
+ * the medium before interacting with a particle. The value is given in world
+ * space units, and must be greater than zero.
+ *
+ * @type {number}
+ * @default Infinity
+ */
+ this.attenuationDistance = Infinity;
+
+ /**
+ * The color that white light turns into due to absorption when reaching the
+ * attenuation distance.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.attenuationColor = new Color( 1, 1, 1 );
+
+ /**
+ * A float that scales the amount of specular reflection for non-metals only.
+ * When set to zero, the model is effectively Lambertian. From `0.0` to `1.0`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.specularIntensity = 1.0;
+
+ /**
+ * The alpha channel of this texture is multiplied against `specularIntensity`,
+ * for per-pixel control over specular intensity.
+ *
+ * `specularIntensityMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.specularIntensityMap = null;
+
+ /**
+ * Tints the specular reflection at normal incidence for non-metals only.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.specularColor = new Color( 1, 1, 1 );
+
+ /**
+ * The RGB channels of this texture are multiplied against `specularColor`,
+ * for per-pixel control over specular color.
+ *
+ * `specularColorMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `specularColorMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.specularColorMap = null;
+
+ this._anisotropy = 0;
+ this._clearcoat = 0;
+ this._dispersion = 0;
+ this._iridescence = 0;
+ this._retroreflectivity = 0;
+ this._sheen = 0.0;
+ this._transmission = 0;
+
+ this.setValues( parameters );
+
+ }
+
+ /**
+ * The anisotropy strength, from `0.0` to `1.0`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get anisotropy() {
+
+ return this._anisotropy;
+
+ }
+
+ set anisotropy( value ) {
+
+ if ( this._anisotropy > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._anisotropy = value;
+
+ }
+
+ /**
+ * Represents the intensity of the clear coat layer, from `0.0` to `1.0`. Use
+ * clear coat related properties to enable multilayer materials that have a
+ * thin translucent layer over the base layer.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get clearcoat() {
+
+ return this._clearcoat;
+
+ }
+
+ set clearcoat( value ) {
+
+ if ( this._clearcoat > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._clearcoat = value;
+
+ }
+ /**
+ * The intensity of the iridescence layer, simulating RGB color shift based on the angle between
+ * the surface and the viewer, from `0.0` to `1.0`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get iridescence() {
+
+ return this._iridescence;
+
+ }
+
+ set iridescence( value ) {
+
+ if ( this._iridescence > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._iridescence = value;
+
+ }
+
+ /**
+ * Defines the strength of the angular separation of colors (chromatic aberration) transmitting
+ * through a relatively clear volume. Any value zero or larger is valid, the typical range of
+ * realistic values is `[0, 1]`. This property can be only be used with transmissive objects.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get dispersion() {
+
+ return this._dispersion;
+
+ }
+
+ set dispersion( value ) {
+
+ if ( this._dispersion > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._dispersion = value;
+
+ }
+
+ /**
+ * The strength of retroreflection, from `0.0` to `1.0`. A value of `1.0`
+ * evaluates the material's microfacet reflection with the view direction
+ * reflected about the surface normal, redirecting the specular lobe back
+ * toward the light source.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get retroreflectivity() {
+
+ return this._retroreflectivity;
+
+ }
+
+ set retroreflectivity( value ) {
+
+ if ( this._retroreflectivity > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._retroreflectivity = value;
+
+ }
+
+ /**
+ * The intensity of the sheen layer, from `0.0` to `1.0`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get sheen() {
+
+ return this._sheen;
+
+ }
+
+ set sheen( value ) {
+
+ if ( this._sheen > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._sheen = value;
+
+ }
+
+ /**
+ * Degree of transmission (or optical transparency), from `0.0` to `1.0`.
+ *
+ * Thin, transparent or semitransparent, plastic or glass materials remain
+ * largely reflective even if they are fully transmissive. The transmission
+ * property can be used to model these materials.
+ *
+ * When transmission is non-zero, `opacity` should be set to `1`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ get transmission() {
+
+ return this._transmission;
+
+ }
+
+ set transmission( value ) {
+
+ if ( this._transmission > 0 !== value > 0 ) {
+
+ this.version ++;
+
+ }
+
+ this._transmission = value;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.defines = {
+
+ 'STANDARD': '',
+ 'PHYSICAL': ''
+
+ };
+
+ this.anisotropy = source.anisotropy;
+ this.anisotropyRotation = source.anisotropyRotation;
+ this.anisotropyMap = source.anisotropyMap;
+
+ this.clearcoat = source.clearcoat;
+ this.clearcoatMap = source.clearcoatMap;
+ this.clearcoatRoughness = source.clearcoatRoughness;
+ this.clearcoatRoughnessMap = source.clearcoatRoughnessMap;
+ this.clearcoatNormalMap = source.clearcoatNormalMap;
+ this.clearcoatNormalScale.copy( source.clearcoatNormalScale );
+
+ this.dispersion = source.dispersion;
+ this.ior = source.ior;
+
+ this.iridescence = source.iridescence;
+ this.iridescenceMap = source.iridescenceMap;
+ this.iridescenceIOR = source.iridescenceIOR;
+ this.iridescenceThicknessRange = [ ...source.iridescenceThicknessRange ];
+ this.iridescenceThicknessMap = source.iridescenceThicknessMap;
+
+ this.retroreflectivity = source.retroreflectivity;
+
+ this.sheen = source.sheen;
+ this.sheenColor.copy( source.sheenColor );
+ this.sheenColorMap = source.sheenColorMap;
+ this.sheenRoughness = source.sheenRoughness;
+ this.sheenRoughnessMap = source.sheenRoughnessMap;
+
+ this.transmission = source.transmission;
+ this.transmissionMap = source.transmissionMap;
+
+ this.thickness = source.thickness;
+ this.thicknessMap = source.thicknessMap;
+ this.attenuationDistance = source.attenuationDistance;
+ this.attenuationColor.copy( source.attenuationColor );
+
+ this.specularIntensity = source.specularIntensity;
+ this.specularIntensityMap = source.specularIntensityMap;
+ this.specularColor.copy( source.specularColor );
+ this.specularColorMap = source.specularColorMap;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A material for shiny surfaces with specular highlights.
+ *
+ * The material uses a non-physically based [Blinn-Phong](https://en.wikipedia.org/wiki/Blinn-Phong_shading_model)
+ * model for calculating reflectance. Unlike the Lambertian model used in the
+ * {@link MeshLambertMaterial} this can simulate shiny surfaces with specular
+ * highlights (such as varnished wood). `MeshPhongMaterial` uses per-fragment shading.
+ *
+ * Performance will generally be greater when using this material over the
+ * {@link MeshStandardMaterial} or {@link MeshPhysicalMaterial}, at the cost of
+ * some graphical accuracy.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshPhongMaterial
+ */
+class MeshPhongMaterial extends Material {
+
+ /**
+ * Constructs a new mesh phong material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshPhongMaterial = true;
+
+ this.type = 'MeshPhongMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff ); // diffuse
+
+ /**
+ * Specular color of the material. The default color is set to `0x111111` (very dark grey)
+ *
+ * This defines how shiny the material is and the color of its shine.
+ *
+ * @type {Color}
+ */
+ this.specular = new Color( 0x111111 );
+
+ /**
+ * How shiny the specular highlight is; a higher value gives a sharper highlight.
+ *
+ * @type {number}
+ * @default 30
+ */
+ this.shininess = 30;
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The light map. Requires a second set of UVs.
+ *
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `lightMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.lightMap = null;
+
+ /**
+ * Intensity of the baked light.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.lightMapIntensity = 1.0;
+
+ /**
+ * The red channel of this texture is used as the ambient occlusion map.
+ * Requires a second set of UVs.
+ *
+ * `aoMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.aoMap = null;
+
+ /**
+ * Intensity of the ambient occlusion effect. Range is `[0,1]`, where `0`
+ * disables ambient occlusion. Where intensity is `1` and the AO map's
+ * red channel is also `1`, ambient light is fully occluded on a surface.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aoMapIntensity = 1.0;
+
+ /**
+ * Emissive (light) color of the material, essentially a solid color
+ * unaffected by other lighting.
+ *
+ * @type {Color}
+ * @default (0,0,0)
+ */
+ this.emissive = new Color( 0x000000 );
+
+ /**
+ * Intensity of the emissive light. Modulates the emissive color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.emissiveIntensity = 1.0;
+
+ /**
+ * Set emissive (glow) map. The emissive map color is modulated by the
+ * emissive color and the emissive intensity. If you have an emissive map,
+ * be sure to set the emissive color to something other than black.
+ *
+ * `emissiveMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `emissiveMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.emissiveMap = null;
+
+ /**
+ * The texture to create a bump map. The black and white values map to the
+ * perceived depth in relation to the lights. Bump doesn't actually affect
+ * the geometry of the object, only the lighting. If a normal map is defined
+ * this will be ignored.
+ *
+ * `bumpMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.bumpMap = null;
+
+ /**
+ * How much the bump map affects the material. Typical range is `[0,1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.bumpScale = 1;
+
+ /**
+ * The texture to create a normal map. The RGB values affect the surface
+ * normal for each pixel fragment and change the way the color is lit. Normal
+ * maps do not change the actual shape of the surface, only the lighting. In
+ * case the material has a normal map authored using the left handed
+ * convention, the `y` component of `normalScale` should be negated to compensate
+ * for the different handedness.
+ *
+ * `normalMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.normalMap = null;
+
+ /**
+ * The type of normal map.
+ *
+ * @type {(TangentSpaceNormalMap|ObjectSpaceNormalMap)}
+ * @default TangentSpaceNormalMap
+ */
+ this.normalMapType = TangentSpaceNormalMap;
+
+ /**
+ * How much the normal map affects the material. Typical value range is `[0,1]`.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.normalScale = new Vector2( 1, 1 );
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh. For best results, pair a
+ * displacement map with a matching normal map, since the renderer can
+ * not recompute surface normals from the displaced vertices.
+ *
+ * `displacementMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ /**
+ * The specular map value affects both how much the specular surface
+ * highlight contributes and how much of the environment map affects the
+ * surface.
+ *
+ * `specularMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `specularMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.specularMap = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * The environment map.
+ *
+ * `envMap` represents luminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `envMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.envMap = null;
+
+ /**
+ * The rotation of the environment map in radians.
+ *
+ * @type {Euler}
+ * @default (0,0,0)
+ */
+ this.envMapRotation = new Euler();
+
+ /**
+ * How to combine the result of the surface's color with the environment map, if any.
+ *
+ * When set to `MixOperation`, the {@link MeshBasicMaterial#reflectivity} is used to
+ * blend between the two colors.
+ *
+ * @type {(MultiplyOperation|MixOperation|AddOperation)}
+ * @default MultiplyOperation
+ */
+ this.combine = MultiplyOperation;
+
+ /**
+ * How much the environment map affects the surface.
+ * The valid range is between `0` (no reflections) and `1` (full reflections).
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.reflectivity = 1;
+
+ /**
+ * Scales the effect of the environment map by multiplying its color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.envMapIntensity = 1.0;
+
+ /**
+ * The index of refraction (IOR) of air (approximately 1) divided by the
+ * index of refraction of the material. It is used with environment mapping
+ * modes {@link CubeRefractionMapping} and {@link EquirectangularRefractionMapping}.
+ * The refraction ratio should not exceed `1`.
+ *
+ * @type {number}
+ * @default 0.98
+ */
+ this.refractionRatio = 0.98;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Defines appearance of wireframe ends.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinecap = 'round';
+
+ /**
+ * Defines appearance of wireframe joints.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinejoin = 'round';
+
+ /**
+ * Whether the material is rendered with flat shading or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flatShading = false;
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+ this.specular.copy( source.specular );
+ this.shininess = source.shininess;
+
+ this.map = source.map;
+
+ this.lightMap = source.lightMap;
+ this.lightMapIntensity = source.lightMapIntensity;
+
+ this.aoMap = source.aoMap;
+ this.aoMapIntensity = source.aoMapIntensity;
+
+ this.emissive.copy( source.emissive );
+ this.emissiveMap = source.emissiveMap;
+ this.emissiveIntensity = source.emissiveIntensity;
+
+ this.bumpMap = source.bumpMap;
+ this.bumpScale = source.bumpScale;
+
+ this.normalMap = source.normalMap;
+ this.normalMapType = source.normalMapType;
+ this.normalScale.copy( source.normalScale );
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ this.specularMap = source.specularMap;
+
+ this.alphaMap = source.alphaMap;
+
+ this.envMap = source.envMap;
+ this.envMapRotation.copy( source.envMapRotation );
+ this.combine = source.combine;
+ this.reflectivity = source.reflectivity;
+ this.envMapIntensity = source.envMapIntensity;
+ this.refractionRatio = source.refractionRatio;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+ this.wireframeLinecap = source.wireframeLinecap;
+ this.wireframeLinejoin = source.wireframeLinejoin;
+
+ this.flatShading = source.flatShading;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A material implementing toon shading.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshToonMaterial
+ */
+class MeshToonMaterial extends Material {
+
+ /**
+ * Constructs a new mesh toon material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshToonMaterial = true;
+
+ this.defines = { 'TOON': '' };
+
+ this.type = 'MeshToonMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff );
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * Gradient map for toon shading. It's required to set
+ * {@link Texture#minFilter} and {@link Texture#magFilter} to {@link NearestFilter}
+ * when using this type of texture.
+ *
+ * `gradientMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.gradientMap = null;
+
+ /**
+ * The light map. Requires a second set of UVs.
+ *
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `lightMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.lightMap = null;
+
+ /**
+ * Intensity of the baked light.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.lightMapIntensity = 1.0;
+
+ /**
+ * The red channel of this texture is used as the ambient occlusion map.
+ * Requires a second set of UVs.
+ *
+ * `aoMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.aoMap = null;
+
+ /**
+ * Intensity of the ambient occlusion effect. Range is `[0,1]`, where `0`
+ * disables ambient occlusion. Where intensity is `1` and the AO map's
+ * red channel is also `1`, ambient light is fully occluded on a surface.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aoMapIntensity = 1.0;
+
+ /**
+ * Emissive (light) color of the material, essentially a solid color
+ * unaffected by other lighting.
+ *
+ * @type {Color}
+ * @default (0,0,0)
+ */
+ this.emissive = new Color( 0x000000 );
+
+ /**
+ * Intensity of the emissive light. Modulates the emissive color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.emissiveIntensity = 1.0;
+
+ /**
+ * Set emissive (glow) map. The emissive map color is modulated by the
+ * emissive color and the emissive intensity. If you have an emissive map,
+ * be sure to set the emissive color to something other than black.
+ *
+ * `emissiveMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `emissiveMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.emissiveMap = null;
+
+ /**
+ * The texture to create a bump map. The black and white values map to the
+ * perceived depth in relation to the lights. Bump doesn't actually affect
+ * the geometry of the object, only the lighting. If a normal map is defined
+ * this will be ignored.
+ *
+ * `bumpMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.bumpMap = null;
+
+ /**
+ * How much the bump map affects the material. Typical range is `[0,1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.bumpScale = 1;
+
+ /**
+ * The texture to create a normal map. The RGB values affect the surface
+ * normal for each pixel fragment and change the way the color is lit. Normal
+ * maps do not change the actual shape of the surface, only the lighting. In
+ * case the material has a normal map authored using the left handed
+ * convention, the `y` component of `normalScale` should be negated to compensate
+ * for the different handedness.
+ *
+ * `normalMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.normalMap = null;
+
+ /**
+ * The type of normal map.
+ *
+ * @type {(TangentSpaceNormalMap|ObjectSpaceNormalMap)}
+ * @default TangentSpaceNormalMap
+ */
+ this.normalMapType = TangentSpaceNormalMap;
+
+ /**
+ * How much the normal map affects the material. Typical value range is `[0,1]`.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.normalScale = new Vector2( 1, 1 );
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh. For best results, pair a
+ * displacement map with a matching normal map, since the renderer can
+ * not recompute surface normals from the displaced vertices.
+ *
+ * `displacementMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Defines appearance of wireframe ends.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinecap = 'round';
+
+ /**
+ * Defines appearance of wireframe joints.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinejoin = 'round';
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+
+ this.map = source.map;
+ this.gradientMap = source.gradientMap;
+
+ this.lightMap = source.lightMap;
+ this.lightMapIntensity = source.lightMapIntensity;
+
+ this.aoMap = source.aoMap;
+ this.aoMapIntensity = source.aoMapIntensity;
+
+ this.emissive.copy( source.emissive );
+ this.emissiveMap = source.emissiveMap;
+ this.emissiveIntensity = source.emissiveIntensity;
+
+ this.bumpMap = source.bumpMap;
+ this.bumpScale = source.bumpScale;
+
+ this.normalMap = source.normalMap;
+ this.normalMapType = source.normalMapType;
+ this.normalScale.copy( source.normalScale );
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ this.alphaMap = source.alphaMap;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+ this.wireframeLinecap = source.wireframeLinecap;
+ this.wireframeLinejoin = source.wireframeLinejoin;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A material that maps the normal vectors to RGB colors.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshNormalMaterial
+ */
+class MeshNormalMaterial extends Material {
+
+ /**
+ * Constructs a new mesh normal material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshNormalMaterial = true;
+
+ this.type = 'MeshNormalMaterial';
+
+ /**
+ * The texture to create a bump map. The black and white values map to the
+ * perceived depth in relation to the lights. Bump doesn't actually affect
+ * the geometry of the object, only the lighting. If a normal map is defined
+ * this will be ignored.
+ *
+ * `bumpMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.bumpMap = null;
+
+ /**
+ * How much the bump map affects the material. Typical range is `[0,1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.bumpScale = 1;
+
+ /**
+ * The texture to create a normal map. The RGB values affect the surface
+ * normal for each pixel fragment and change the way the color is lit. Normal
+ * maps do not change the actual shape of the surface, only the lighting. In
+ * case the material has a normal map authored using the left handed
+ * convention, the `y` component of `normalScale` should be negated to compensate
+ * for the different handedness.
+ *
+ * `normalMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.normalMap = null;
+
+ /**
+ * The type of normal map.
+ *
+ * @type {(TangentSpaceNormalMap|ObjectSpaceNormalMap)}
+ * @default TangentSpaceNormalMap
+ */
+ this.normalMapType = TangentSpaceNormalMap;
+
+ /**
+ * How much the normal map affects the material. Typical value range is `[0,1]`.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.normalScale = new Vector2( 1, 1 );
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh. For best results, pair a
+ * displacement map with a matching normal map, since the renderer can
+ * not recompute surface normals from the displaced vertices.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * WebGL and WebGPU ignore this property and always render
+ * 1 pixel wide lines.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Whether the material is rendered with flat shading or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flatShading = false;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.bumpMap = source.bumpMap;
+ this.bumpScale = source.bumpScale;
+
+ this.normalMap = source.normalMap;
+ this.normalMapType = source.normalMapType;
+ this.normalScale.copy( source.normalScale );
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+
+ this.flatShading = source.flatShading;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A material for non-shiny surfaces, without specular highlights.
+ *
+ * The material uses a non-physically based [Lambertian](https://en.wikipedia.org/wiki/Lambertian_reflectance)
+ * model for calculating reflectance. This can simulate some surfaces (such
+ * as untreated wood or stone) well, but cannot simulate shiny surfaces with
+ * specular highlights (such as varnished wood). `MeshLambertMaterial` uses per-fragment
+ * shading.
+ *
+ * Due to the simplicity of the reflectance and illumination models,
+ * performance will be greater when using this material over the
+ * {@link MeshPhongMaterial}, {@link MeshStandardMaterial} or
+ * {@link MeshPhysicalMaterial}, at the cost of some graphical accuracy.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshLambertMaterial
+ */
+class MeshLambertMaterial extends Material {
+
+ /**
+ * Constructs a new mesh lambert material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshLambertMaterial = true;
+
+ this.type = 'MeshLambertMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff ); // diffuse
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The light map. Requires a second set of UVs.
+ *
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `lightMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.lightMap = null;
+
+ /**
+ * Intensity of the baked light.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.lightMapIntensity = 1.0;
+
+ /**
+ * The red channel of this texture is used as the ambient occlusion map.
+ * Requires a second set of UVs.
+ *
+ * `aoMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.aoMap = null;
+
+ /**
+ * Intensity of the ambient occlusion effect. Range is `[0,1]`, where `0`
+ * disables ambient occlusion. Where intensity is `1` and the AO map's
+ * red channel is also `1`, ambient light is fully occluded on a surface.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aoMapIntensity = 1.0;
+
+ /**
+ * Emissive (light) color of the material, essentially a solid color
+ * unaffected by other lighting.
+ *
+ * @type {Color}
+ * @default (0,0,0)
+ */
+ this.emissive = new Color( 0x000000 );
+
+ /**
+ * Intensity of the emissive light. Modulates the emissive color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.emissiveIntensity = 1.0;
+
+ /**
+ * Set emissive (glow) map. The emissive map color is modulated by the
+ * emissive color and the emissive intensity. If you have an emissive map,
+ * be sure to set the emissive color to something other than black.
+ *
+ * `emissiveMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `emissiveMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.emissiveMap = null;
+
+ /**
+ * The texture to create a bump map. The black and white values map to the
+ * perceived depth in relation to the lights. Bump doesn't actually affect
+ * the geometry of the object, only the lighting. If a normal map is defined
+ * this will be ignored.
+ *
+ * `bumpMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.bumpMap = null;
+
+ /**
+ * How much the bump map affects the material. Typical range is `[0,1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.bumpScale = 1;
+
+ /**
+ * The texture to create a normal map. The RGB values affect the surface
+ * normal for each pixel fragment and change the way the color is lit. Normal
+ * maps do not change the actual shape of the surface, only the lighting. In
+ * case the material has a normal map authored using the left handed
+ * convention, the `y` component of `normalScale` should be negated to compensate
+ * for the different handedness.
+ *
+ * `normalMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.normalMap = null;
+
+ /**
+ * The type of normal map.
+ *
+ * @type {(TangentSpaceNormalMap|ObjectSpaceNormalMap)}
+ * @default TangentSpaceNormalMap
+ */
+ this.normalMapType = TangentSpaceNormalMap;
+
+ /**
+ * How much the normal map affects the material. Typical value range is `[0,1]`.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.normalScale = new Vector2( 1, 1 );
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh. For best results, pair a
+ * displacement map with a matching normal map, since the renderer can
+ * not recompute surface normals from the displaced vertices.
+ *
+ * `displacementMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ /**
+ * Specular map used by the material.
+ *
+ * `specularMap` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `specularMap` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.specularMap = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * The environment map.
+ *
+ * `envMap` represents luminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. Most `envMap` textures set
+ * `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
+ * such as `.exr` or `.hdr`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.envMap = null;
+
+ /**
+ * The rotation of the environment map in radians.
+ *
+ * @type {Euler}
+ * @default (0,0,0)
+ */
+ this.envMapRotation = new Euler();
+
+ /**
+ * How to combine the result of the surface's color with the environment map, if any.
+ *
+ * When set to `MixOperation`, the {@link MeshBasicMaterial#reflectivity} is used to
+ * blend between the two colors.
+ *
+ * @type {(MultiplyOperation|MixOperation|AddOperation)}
+ * @default MultiplyOperation
+ */
+ this.combine = MultiplyOperation;
+
+ /**
+ * How much the environment map affects the surface.
+ * The valid range is between `0` (no reflections) and `1` (full reflections).
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.reflectivity = 1;
+
+ /**
+ * Scales the effect of the environment map by multiplying its color.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.envMapIntensity = 1.0;
+
+ /**
+ * The index of refraction (IOR) of air (approximately 1) divided by the
+ * index of refraction of the material. It is used with environment mapping
+ * modes {@link CubeRefractionMapping} and {@link EquirectangularRefractionMapping}.
+ * The refraction ratio should not exceed `1`.
+ *
+ * @type {number}
+ * @default 0.98
+ */
+ this.refractionRatio = 0.98;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Defines appearance of wireframe ends.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinecap = 'round';
+
+ /**
+ * Defines appearance of wireframe joints.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {('round'|'bevel'|'miter')}
+ * @default 'round'
+ */
+ this.wireframeLinejoin = 'round';
+
+ /**
+ * Whether the material is rendered with flat shading or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flatShading = false;
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.color.copy( source.color );
+
+ this.map = source.map;
+
+ this.lightMap = source.lightMap;
+ this.lightMapIntensity = source.lightMapIntensity;
+
+ this.aoMap = source.aoMap;
+ this.aoMapIntensity = source.aoMapIntensity;
+
+ this.emissive.copy( source.emissive );
+ this.emissiveMap = source.emissiveMap;
+ this.emissiveIntensity = source.emissiveIntensity;
+
+ this.bumpMap = source.bumpMap;
+ this.bumpScale = source.bumpScale;
+
+ this.normalMap = source.normalMap;
+ this.normalMapType = source.normalMapType;
+ this.normalScale.copy( source.normalScale );
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ this.specularMap = source.specularMap;
+
+ this.alphaMap = source.alphaMap;
+
+ this.envMap = source.envMap;
+ this.envMapRotation.copy( source.envMapRotation );
+ this.combine = source.combine;
+ this.reflectivity = source.reflectivity;
+ this.envMapIntensity = source.envMapIntensity;
+ this.refractionRatio = source.refractionRatio;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+ this.wireframeLinecap = source.wireframeLinecap;
+ this.wireframeLinejoin = source.wireframeLinejoin;
+
+ this.flatShading = source.flatShading;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A material for drawing geometry by depth. Depth is based off of the camera
+ * near and far plane. White is nearest, black is farthest.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshDepthMaterial
+ */
+class MeshDepthMaterial extends Material {
+
+ /**
+ * Constructs a new mesh depth material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshDepthMaterial = true;
+
+ this.type = 'MeshDepthMaterial';
+
+ /**
+ * Type for depth packing.
+ *
+ * @type {(BasicDepthPacking|RGBADepthPacking|RGBDepthPacking|RGDepthPacking)}
+ * @default BasicDepthPacking
+ */
+ this.depthPacking = BasicDepthPacking;
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh.
+ *
+ * `displacementMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * WebGL and WebGPU ignore this property and always render
+ * 1 pixel wide lines.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.depthPacking = source.depthPacking;
+
+ this.map = source.map;
+
+ this.alphaMap = source.alphaMap;
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A material used internally for implementing shadow mapping with
+ * point lights.
+ *
+ * Can also be used to customize the shadow casting of an object by assigning
+ * an instance of `MeshDistanceMaterial` to {@link Object3D#customDistanceMaterial}.
+ * The following examples demonstrates this approach in order to ensure
+ * transparent parts of objects do not cast shadows.
+ *
+ * @augments Material
+ */
+class MeshDistanceMaterial extends Material {
+
+ /**
+ * Constructs a new mesh distance material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshDistanceMaterial = true;
+
+ this.type = 'MeshDistanceMaterial';
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh.
+ *
+ * `displacementMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.map = source.map;
+
+ this.alphaMap = source.alphaMap;
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * This material is defined by a MatCap (or Lit Sphere) texture, which encodes the
+ * material color and shading.
+ *
+ * `MeshMatcapMaterial` does not respond to lights since the matcap image file encodes
+ * baked lighting. It will cast a shadow onto an object that receives shadows
+ * (and shadow clipping works), but it will not self-shadow or receive
+ * shadows.
+ *
+ * @augments Material
+ * @demo scenes/material-browser.html#MeshMatcapMaterial
+ */
+class MeshMatcapMaterial extends Material {
+
+ /**
+ * Constructs a new mesh matcap material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isMeshMatcapMaterial = true;
+
+ this.defines = { 'MATCAP': '' };
+
+ this.type = 'MeshMatcapMaterial';
+
+ /**
+ * Color of the material.
+ *
+ * @type {Color}
+ * @default (1,1,1)
+ */
+ this.color = new Color( 0xffffff ); // diffuse
+
+ /**
+ * The matcap map.
+ *
+ * `matcap` represents luminance data, and the texture must be assigned
+ * a {@link Texture#colorSpace}. HDR `matcap` textures (e.g. `.exr`)
+ * typically set `texture.colorSpace = LinearSRGBColorSpace`, while LDR
+ * `matcap` textures (e.g. `.png`, `.jpg`, `.webp`) typically set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.matcap = null;
+
+ /**
+ * The color map. May optionally include an alpha channel, typically combined
+ * with {@link Material#transparent} or {@link Material#alphaTest}. The texture map
+ * color is modulated by the diffuse `color`.
+ *
+ * `map` represents color data, and the texture must be assigned a
+ * {@link Texture#colorSpace}. Most `map` textures set
+ * `texture.colorSpace = SRGBColorSpace`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The texture to create a bump map. The black and white values map to the
+ * perceived depth in relation to the lights. Bump doesn't actually affect
+ * the geometry of the object, only the lighting. If a normal map is defined
+ * this will be ignored.
+ *
+ * `bumpMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.bumpMap = null;
+
+ /**
+ * How much the bump map affects the material. Typical range is `[0,1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.bumpScale = 1;
+
+ /**
+ * The texture to create a normal map. The RGB values affect the surface
+ * normal for each pixel fragment and change the way the color is lit. Normal
+ * maps do not change the actual shape of the surface, only the lighting. In
+ * case the material has a normal map authored using the left handed
+ * convention, the `y` component of `normalScale` should be negated to compensate
+ * for the different handedness.
+ *
+ * `normalMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.normalMap = null;
+
+ /**
+ * The type of normal map.
+ *
+ * @type {(TangentSpaceNormalMap|ObjectSpaceNormalMap)}
+ * @default TangentSpaceNormalMap
+ */
+ this.normalMapType = TangentSpaceNormalMap;
+
+ /**
+ * How much the normal map affects the material. Typical value range is `[0,1]`.
+ *
+ * @type {Vector2}
+ * @default (1,1)
+ */
+ this.normalScale = new Vector2( 1, 1 );
+
+ /**
+ * The displacement map affects the position of the mesh's vertices. Unlike
+ * other maps which only affect the light and shade of the material the
+ * displaced vertices can cast shadows, block other objects, and otherwise
+ * act as real geometry. The displacement texture is an image where the value
+ * of each pixel (white being the highest) is mapped against, and
+ * repositions, the vertices of the mesh. For best results, pair a
+ * displacement map with a matching normal map, since the renderer can
+ * not recompute surface normals from the displaced vertices.
+ *
+ * `displacementMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.displacementMap = null;
+
+ /**
+ * How much the displacement map affects the mesh (where black is no
+ * displacement, and white is maximum displacement). Without a displacement
+ * map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementScale = 1;
+
+ /**
+ * The offset of the displacement map's values on the mesh's vertices.
+ * The bias is added to the scaled sample of the displacement map.
+ * Without a displacement map set, this value is not applied.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.displacementBias = 0;
+
+ /**
+ * The alpha map is a grayscale texture that controls the opacity across the
+ * surface (black: fully transparent; white: fully opaque).
+ *
+ * Only the color of the texture is used, ignoring the alpha channel if one
+ * exists. For RGB and RGBA textures, the renderer will use the green channel
+ * when sampling this texture due to the extra bit of precision provided for
+ * green in DXT-compressed and uncompressed RGB 565 formats. Luminance-only and
+ * luminance/alpha textures will also still work as expected.
+ *
+ * `alphaMap` represents non-color data. Any texture assigned must have
+ * `texture.colorSpace = NoColorSpace` (default).
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.alphaMap = null;
+
+ /**
+ * Renders the geometry as a wireframe.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.wireframe = false;
+
+ /**
+ * Controls the thickness of the wireframe.
+ *
+ * Can only be used with {@link SVGRenderer}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.wireframeLinewidth = 1;
+
+ /**
+ * Whether the material is rendered with flat shading or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.flatShading = false;
+
+ /**
+ * Whether the material is affected by fog or not.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.fog = true;
+
+ this.setValues( parameters );
+
+ }
+
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.defines = { 'MATCAP': '' };
+
+ this.color.copy( source.color );
+
+ this.matcap = source.matcap;
+
+ this.map = source.map;
+
+ this.bumpMap = source.bumpMap;
+ this.bumpScale = source.bumpScale;
+
+ this.normalMap = source.normalMap;
+ this.normalMapType = source.normalMapType;
+ this.normalScale.copy( source.normalScale );
+
+ this.displacementMap = source.displacementMap;
+ this.displacementScale = source.displacementScale;
+ this.displacementBias = source.displacementBias;
+
+ this.alphaMap = source.alphaMap;
+
+ this.wireframe = source.wireframe;
+ this.wireframeLinewidth = source.wireframeLinewidth;
+
+ this.flatShading = source.flatShading;
+
+ this.fog = source.fog;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * A material for rendering line primitives.
+ *
+ * Materials define the appearance of renderable 3D objects.
+ *
+ * ```js
+ * const material = new THREE.LineDashedMaterial( {
+ * color: 0xffffff,
+ * scale: 1,
+ * dashSize: 3,
+ * gapSize: 1,
+ * } );
+ * ```
+ *
+ * @augments LineBasicMaterial
+ */
+class LineDashedMaterial extends LineBasicMaterial {
+
+ /**
+ * Constructs a new line dashed material.
+ *
+ * @param {Object} [parameters] - An object with one or more properties
+ * defining the material's appearance. Any property of the material
+ * (including any property from inherited materials) can be passed
+ * in here. Color values can be passed any type of value accepted
+ * by {@link Color#set}.
+ */
+ constructor( parameters ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLineDashedMaterial = true;
+ this.type = 'LineDashedMaterial';
+
+ /**
+ * The scale of the dashed part of a line.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.scale = 1;
+
+ /**
+ * The size of the dash. This is both the gap with the stroke.
+ *
+ * @type {number}
+ * @default 3
+ */
+ this.dashSize = 3;
+
+ /**
+ * The size of the gap.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.gapSize = 1;
+
+ this.setValues( parameters );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.scale = source.scale;
+ this.dashSize = source.dashSize;
+ this.gapSize = source.gapSize;
+
+ return this;
+
+ }
+
+}
+
+/**
+ * Converts an array to a specific type.
+ *
+ * @param {TypedArray|Array} array - The array to convert.
+ * @param {TypedArray.constructor} type - The constructor of a typed array that defines the new type.
+ * @return {TypedArray} The converted array.
+ */
+function convertArray( array, type ) {
+
+ if ( ! array || array.constructor === type ) return array;
+
+ if ( typeof type.BYTES_PER_ELEMENT === 'number' ) {
+
+ return new type( array ); // create typed array
+
+ }
+
+ return Array.prototype.slice.call( array ); // create Array
+
+}
+
+/**
+ * Returns `true` if the given keyframe track settings hold Bezier tangent data.
+ *
+ * @param {?Object} settings - The settings of a keyframe track.
+ * @return {boolean} Whether both tangent arrays are defined or not.
+ */
+function hasTangents( settings ) {
+
+ return settings !== undefined && settings.inTangents !== undefined && settings.outTangents !== undefined;
+
+}
+
+/**
+ * Returns an array by which times and values can be sorted.
+ *
+ * @param {Array} times - The keyframe time values.
+ * @return {Array} The array.
+ */
+function getKeyframeOrder( times ) {
+
+ function compareTime( i, j ) {
+
+ return times[ i ] - times[ j ];
+
+ }
+
+ const n = times.length;
+ const result = new Array( n );
+ for ( let i = 0; i !== n; ++ i ) result[ i ] = i;
+
+ result.sort( compareTime );
+
+ return result;
+
+}
+
+/**
+ * Sorts the given array by the previously computed order via `getKeyframeOrder()`.
+ *
+ * @param {Array} values - The values to sort.
+ * @param {number} stride - The stride.
+ * @param {Array} order - The sort order.
+ * @return {Array} The sorted values.
+ */
+function sortedArray( values, stride, order ) {
+
+ const nValues = values.length;
+ const result = new values.constructor( nValues );
+
+ for ( let i = 0, dstOffset = 0; dstOffset !== nValues; ++ i ) {
+
+ const srcOffset = order[ i ] * stride;
+
+ for ( let j = 0; j !== stride; ++ j ) {
+
+ result[ dstOffset ++ ] = values[ srcOffset + j ];
+
+ }
+
+ }
+
+ return result;
+
+}
+
+/**
+ * Used for parsing AOS keyframe formats.
+ *
+ * @param {Array} jsonKeys - A list of JSON keyframes.
+ * @param {Array} times - This array will be filled with keyframe times by this function.
+ * @param {Array} values - This array will be filled with keyframe values by this function.
+ * @param {string} valuePropertyName - The name of the property to use.
+ */
+function flattenJSON( jsonKeys, times, values, valuePropertyName ) {
+
+ let i = 1, key = jsonKeys[ 0 ];
+
+ while ( key !== undefined && key[ valuePropertyName ] === undefined ) {
+
+ key = jsonKeys[ i ++ ];
+
+ }
+
+ if ( key === undefined ) return; // no data
+
+ let value = key[ valuePropertyName ];
+ if ( value === undefined ) return; // no data
+
+ if ( Array.isArray( value ) ) {
+
+ do {
+
+ value = key[ valuePropertyName ];
+
+ if ( value !== undefined ) {
+
+ times.push( key.time );
+ values.push( ...value ); // push all elements
+
+ }
+
+ key = jsonKeys[ i ++ ];
+
+ } while ( key !== undefined );
+
+ } else if ( value.toArray !== undefined ) {
+
+ // ...assume THREE.Math-ish
+
+ do {
+
+ value = key[ valuePropertyName ];
+
+ if ( value !== undefined ) {
+
+ times.push( key.time );
+ value.toArray( values, values.length );
+
+ }
+
+ key = jsonKeys[ i ++ ];
+
+ } while ( key !== undefined );
+
+ } else {
+
+ // otherwise push as-is
+
+ do {
+
+ value = key[ valuePropertyName ];
+
+ if ( value !== undefined ) {
+
+ times.push( key.time );
+ values.push( value );
+
+ }
+
+ key = jsonKeys[ i ++ ];
+
+ } while ( key !== undefined );
+
+ }
+
+}
+
+/**
+ * Creates a new clip, containing only the segment of the original clip between the given frames.
+ *
+ * @param {AnimationClip} sourceClip - The values to sort.
+ * @param {string} name - The name of the clip.
+ * @param {number} startFrame - The start frame.
+ * @param {number} endFrame - The end frame.
+ * @param {number} [fps=30] - The FPS.
+ * @return {AnimationClip} The new sub clip.
+ */
+function subclip( sourceClip, name, startFrame, endFrame, fps = 30 ) {
+
+ const clip = sourceClip.clone();
+
+ clip.name = name;
+
+ const tracks = [];
+
+ for ( let i = 0; i < clip.tracks.length; ++ i ) {
+
+ const track = clip.tracks[ i ];
+ const valueSize = track.getValueSize();
+
+ const times = [];
+ const values = [];
+
+ for ( let j = 0; j < track.times.length; ++ j ) {
+
+ const frame = track.times[ j ] * fps;
+
+ if ( frame < startFrame || frame >= endFrame ) continue;
+
+ times.push( track.times[ j ] );
+
+ for ( let k = 0; k < valueSize; ++ k ) {
+
+ values.push( track.values[ j * valueSize + k ] );
+
+ }
+
+ }
+
+ if ( times.length === 0 ) continue;
+
+ track.times = convertArray( times, track.times.constructor );
+ track.values = convertArray( values, track.values.constructor );
+
+ tracks.push( track );
+
+ }
+
+ clip.tracks = tracks;
+
+ // find minimum .times value across all tracks in the trimmed clip
+
+ let minStartTime = Infinity;
+
+ for ( let i = 0; i < clip.tracks.length; ++ i ) {
+
+ if ( minStartTime > clip.tracks[ i ].times[ 0 ] ) {
+
+ minStartTime = clip.tracks[ i ].times[ 0 ];
+
+ }
+
+ }
+
+ // shift all tracks such that clip begins at t=0
+
+ for ( let i = 0; i < clip.tracks.length; ++ i ) {
+
+ clip.tracks[ i ].shift( -1 * minStartTime );
+
+ }
+
+ clip.resetDuration();
+
+ return clip;
+
+}
+
+/**
+ * Converts the keyframes of the given animation clip to an additive format.
+ *
+ * @param {AnimationClip} targetClip - The clip to make additive.
+ * @param {number} [referenceFrame=0] - The reference frame.
+ * @param {AnimationClip} [referenceClip=targetClip] - The reference clip.
+ * @param {number} [fps=30] - The FPS.
+ * @return {AnimationClip} The updated clip which is now additive.
+ */
+function makeClipAdditive( targetClip, referenceFrame = 0, referenceClip = targetClip, fps = 30 ) {
+
+ if ( fps <= 0 ) fps = 30;
+
+ const numTracks = referenceClip.tracks.length;
+ const referenceTime = referenceFrame / fps;
+
+ // Make each track's values relative to the values at the reference frame
+ for ( let i = 0; i < numTracks; ++ i ) {
+
+ const referenceTrack = referenceClip.tracks[ i ];
+ const referenceTrackType = referenceTrack.ValueTypeName;
+
+ // Skip this track if it's non-numeric
+ if ( referenceTrackType === 'bool' || referenceTrackType === 'string' ) continue;
+
+ // Find the track in the target clip whose name and type matches the reference track
+ const targetTrack = targetClip.tracks.find( function ( track ) {
+
+ return track.name === referenceTrack.name
+ && track.ValueTypeName === referenceTrackType;
+
+ } );
+
+ if ( targetTrack === undefined ) continue;
+
+ let referenceOffset = 0;
+ const referenceValueSize = referenceTrack.getValueSize();
+
+ if ( referenceTrack.createInterpolant.isInterpolantFactoryMethodGLTFCubicSpline ) {
+
+ referenceOffset = referenceValueSize / 3;
+
+ }
+
+ let targetOffset = 0;
+ const targetValueSize = targetTrack.getValueSize();
+
+ if ( targetTrack.createInterpolant.isInterpolantFactoryMethodGLTFCubicSpline ) {
+
+ targetOffset = targetValueSize / 3;
+
+ }
+
+ const lastIndex = referenceTrack.times.length - 1;
+ let referenceValue;
+
+ // Find the value to subtract out of the track
+ if ( referenceTime <= referenceTrack.times[ 0 ] ) {
+
+ // Reference frame is earlier than the first keyframe, so just use the first keyframe
+ const startIndex = referenceOffset;
+ const endIndex = referenceValueSize - referenceOffset;
+ referenceValue = referenceTrack.values.slice( startIndex, endIndex );
+
+ } else if ( referenceTime >= referenceTrack.times[ lastIndex ] ) {
+
+ // Reference frame is after the last keyframe, so just use the last keyframe
+ const startIndex = lastIndex * referenceValueSize + referenceOffset;
+ const endIndex = startIndex + referenceValueSize - referenceOffset;
+ referenceValue = referenceTrack.values.slice( startIndex, endIndex );
+
+ } else {
+
+ // Interpolate to the reference value
+ const interpolant = referenceTrack.createInterpolant();
+ const startIndex = referenceOffset;
+ const endIndex = referenceValueSize - referenceOffset;
+ interpolant.evaluate( referenceTime );
+ referenceValue = interpolant.resultBuffer.slice( startIndex, endIndex );
+
+ }
+
+ // Conjugate the quaternion
+ if ( referenceTrackType === 'quaternion' ) {
+
+ const referenceQuat = new Quaternion().fromArray( referenceValue ).normalize().conjugate();
+ referenceQuat.toArray( referenceValue );
+
+ }
+
+ // Subtract the reference value from all of the track values
+
+ const numTimes = targetTrack.times.length;
+ for ( let j = 0; j < numTimes; ++ j ) {
+
+ const valueStart = j * targetValueSize + targetOffset;
+
+ if ( referenceTrackType === 'quaternion' ) {
+
+ // Multiply the conjugate for quaternion track types
+ Quaternion.multiplyQuaternionsFlat(
+ targetTrack.values,
+ valueStart,
+ referenceValue,
+ 0,
+ targetTrack.values,
+ valueStart
+ );
+
+ } else {
+
+ const valueEnd = targetValueSize - targetOffset * 2;
+
+ // Subtract each value for all other numeric track types
+ for ( let k = 0; k < valueEnd; ++ k ) {
+
+ targetTrack.values[ valueStart + k ] -= referenceValue[ k ];
+
+ }
+
+ }
+
+ }
+
+ }
+
+ targetClip.blendMode = AdditiveAnimationBlendMode;
+
+ return targetClip;
+
+}
+
+/**
+ * A class with various methods to assist with animations.
+ *
+ * @hideconstructor
+ */
+class AnimationUtils {
+
+ /**
+ * Converts an array to a specific type
+ *
+ * @static
+ * @param {TypedArray|Array} array - The array to convert.
+ * @param {TypedArray.constructor} type - The constructor of a type array.
+ * @return {TypedArray} The converted array
+ */
+ static convertArray( array, type ) {
+
+ return convertArray( array, type );
+
+ }
+
+ /**
+ * Returns `true` if the given object is a typed array.
+ *
+ * @static
+ * @param {any} object - The object to check.
+ * @return {boolean} Whether the given object is a typed array.
+ */
+ static isTypedArray( object ) {
+
+ return isTypedArray( object );
+
+ }
+
+ /**
+ * Returns `true` if the given keyframe track settings hold Bezier tangent data.
+ *
+ * @static
+ * @param {?Object} settings - The settings of a keyframe track.
+ * @return {boolean} Whether both tangent arrays are defined or not.
+ */
+ static hasTangents( settings ) {
+
+ return hasTangents( settings );
+
+ }
+
+ /**
+ * Returns an array by which times and values can be sorted.
+ *
+ * @static
+ * @param {Array} times - The keyframe time values.
+ * @return {Array} The array.
+ */
+ static getKeyframeOrder( times ) {
+
+ return getKeyframeOrder( times );
+
+ }
+
+ /**
+ * Sorts the given array by the previously computed order via `getKeyframeOrder()`.
+ *
+ * @static
+ * @param {Array} values - The values to sort.
+ * @param {number} stride - The stride.
+ * @param {Array} order - The sort order.
+ * @return {Array} The sorted values.
+ */
+ static sortedArray( values, stride, order ) {
+
+ return sortedArray( values, stride, order );
+
+ }
+
+ /**
+ * Used for parsing AOS keyframe formats.
+ *
+ * @static
+ * @param {Array} jsonKeys - A list of JSON keyframes.
+ * @param {Array} times - This array will be filled with keyframe times by this method.
+ * @param {Array} values - This array will be filled with keyframe values by this method.
+ * @param {string} valuePropertyName - The name of the property to use.
+ */
+ static flattenJSON( jsonKeys, times, values, valuePropertyName ) {
+
+ flattenJSON( jsonKeys, times, values, valuePropertyName );
+
+ }
+
+ /**
+ * Creates a new clip, containing only the segment of the original clip between the given frames.
+ *
+ * @static
+ * @param {AnimationClip} sourceClip - The values to sort.
+ * @param {string} name - The name of the clip.
+ * @param {number} startFrame - The start frame.
+ * @param {number} endFrame - The end frame.
+ * @param {number} [fps=30] - The FPS.
+ * @return {AnimationClip} The new sub clip.
+ */
+ static subclip( sourceClip, name, startFrame, endFrame, fps = 30 ) {
+
+ return subclip( sourceClip, name, startFrame, endFrame, fps );
+
+ }
+
+ /**
+ * Converts the keyframes of the given animation clip to an additive format.
+ *
+ * @static
+ * @param {AnimationClip} targetClip - The clip to make additive.
+ * @param {number} [referenceFrame=0] - The reference frame.
+ * @param {AnimationClip} [referenceClip=targetClip] - The reference clip.
+ * @param {number} [fps=30] - The FPS.
+ * @return {AnimationClip} The updated clip which is now additive.
+ */
+ static makeClipAdditive( targetClip, referenceFrame = 0, referenceClip = targetClip, fps = 30 ) {
+
+ return makeClipAdditive( targetClip, referenceFrame, referenceClip, fps );
+
+ }
+
+}
+
+/**
+ * Abstract base class of interpolants over parametric samples.
+ *
+ * The parameter domain is one dimensional, typically the time or a path
+ * along a curve defined by the data.
+ *
+ * The sample values can have any dimensionality and derived classes may
+ * apply special interpretations to the data.
+ *
+ * This class provides the interval seek in a Template Method, deferring
+ * the actual interpolation to derived classes.
+ *
+ * Time complexity is O(1) for linear access crossing at most two points
+ * and O(log N) for random access, where N is the number of positions.
+ *
+ * References: {@link http://www.oodesign.com/template-method-pattern.html}
+ *
+ * @abstract
+ */
+class Interpolant {
+
+ /**
+ * Constructs a new interpolant.
+ *
+ * @param {TypedArray} parameterPositions - The parameter positions hold the interpolation factors.
+ * @param {TypedArray} sampleValues - The sample values.
+ * @param {number} sampleSize - The sample size
+ * @param {TypedArray} [resultBuffer] - The result buffer.
+ */
+ constructor( parameterPositions, sampleValues, sampleSize, resultBuffer ) {
+
+ /**
+ * The parameter positions.
+ *
+ * @type {TypedArray}
+ */
+ this.parameterPositions = parameterPositions;
+
+ /**
+ * A cache index.
+ *
+ * @private
+ * @type {number}
+ * @default 0
+ */
+ this._cachedIndex = 0;
+
+ /**
+ * The result buffer.
+ *
+ * @type {TypedArray}
+ */
+ this.resultBuffer = resultBuffer !== undefined ? resultBuffer : new sampleValues.constructor( sampleSize );
+
+ /**
+ * The sample values.
+ *
+ * @type {TypedArray}
+ */
+ this.sampleValues = sampleValues;
+
+ /**
+ * The value size.
+ *
+ * @type {TypedArray}
+ */
+ this.valueSize = sampleSize;
+
+ /**
+ * The interpolation settings.
+ *
+ * @type {?Object}
+ * @default null
+ */
+ this.settings = null;
+
+ /**
+ * The default settings object.
+ *
+ * @type {Object}
+ */
+ this.DefaultSettings_ = {};
+
+ }
+
+ /**
+ * Evaluate the interpolant at position `t`.
+ *
+ * @param {number} t - The interpolation factor.
+ * @return {TypedArray} The result buffer.
+ */
+ evaluate( t ) {
+
+ const pp = this.parameterPositions;
+ let i1 = this._cachedIndex,
+ t1 = pp[ i1 ],
+ t0 = pp[ i1 - 1 ];
+
+ validate_interval: {
+
+ seek: {
+
+ let right;
+
+ linear_scan: {
+
+ //- See http://jsperf.com/comparison-to-undefined/3
+ //- slower code:
+ //-
+ //- if ( t >= t1 || t1 === undefined ) {
+ forward_scan: if ( ! ( t < t1 ) ) {
+
+ for ( let giveUpAt = i1 + 2; ; ) {
+
+ if ( t1 === undefined ) {
+
+ if ( t < t0 ) break forward_scan;
+
+ // after end
+
+ i1 = pp.length;
+ this._cachedIndex = i1;
+ return this.copySampleValue_( i1 - 1 );
+
+ }
+
+ if ( i1 === giveUpAt ) break; // this loop
+
+ t0 = t1;
+ t1 = pp[ ++ i1 ];
+
+ if ( t < t1 ) {
+
+ // we have arrived at the sought interval
+ break seek;
+
+ }
+
+ }
+
+ // prepare binary search on the right side of the index
+ right = pp.length;
+ break linear_scan;
+
+ }
+
+ //- slower code:
+ //- if ( t < t0 || t0 === undefined ) {
+ if ( ! ( t >= t0 ) ) {
+
+ // looping?
+
+ const t1global = pp[ 1 ];
+
+ if ( t < t1global ) {
+
+ i1 = 2; // + 1, using the scan for the details
+ t0 = t1global;
+
+ }
+
+ // linear reverse scan
+
+ for ( let giveUpAt = i1 - 2; ; ) {
+
+ if ( t0 === undefined ) {
+
+ // before start
+
+ this._cachedIndex = 0;
+ return this.copySampleValue_( 0 );
+
+ }
+
+ if ( i1 === giveUpAt ) break; // this loop
+
+ t1 = t0;
+ t0 = pp[ -- i1 - 1 ];
+
+ if ( t >= t0 ) {
+
+ // we have arrived at the sought interval
+ break seek;
+
+ }
+
+ }
+
+ // prepare binary search on the left side of the index
+ right = i1;
+ i1 = 0;
+ break linear_scan;
+
+ }
+
+ // the interval is valid
+
+ break validate_interval;
+
+ } // linear scan
+
+ // binary search
+
+ while ( i1 < right ) {
+
+ const mid = ( i1 + right ) >>> 1;
+
+ if ( t < pp[ mid ] ) {
+
+ right = mid;
+
+ } else {
+
+ i1 = mid + 1;
+
+ }
+
+ }
+
+ t1 = pp[ i1 ];
+ t0 = pp[ i1 - 1 ];
+
+ // check boundary cases, again
+
+ if ( t0 === undefined ) {
+
+ this._cachedIndex = 0;
+ return this.copySampleValue_( 0 );
+
+ }
+
+ if ( t1 === undefined ) {
+
+ i1 = pp.length;
+ this._cachedIndex = i1;
+ return this.copySampleValue_( i1 - 1 );
+
+ }
+
+ } // seek
+
+ this._cachedIndex = i1;
+
+ this.intervalChanged_( i1, t0, t1 );
+
+ } // validate_interval
+
+ return this.interpolate_( i1, t0, t, t1 );
+
+ }
+
+ /**
+ * Returns the interpolation settings.
+ *
+ * @return {Object} The interpolation settings.
+ */
+ getSettings_() {
+
+ return this.settings || this.DefaultSettings_;
+
+ }
+
+ /**
+ * Copies a sample value to the result buffer.
+ *
+ * @param {number} index - An index into the sample value buffer.
+ * @return {TypedArray} The result buffer.
+ */
+ copySampleValue_( index ) {
+
+ // copies a sample value to the result buffer
+
+ const result = this.resultBuffer,
+ values = this.sampleValues,
+ stride = this.valueSize,
+ offset = index * stride;
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ result[ i ] = values[ offset + i ];
+
+ }
+
+ return result;
+
+ }
+
+ /**
+ * Copies a sample value to the result buffer.
+ *
+ * @abstract
+ * @param {number} i1 - An index into the sample value buffer.
+ * @param {number} t0 - The previous interpolation factor.
+ * @param {number} t - The current interpolation factor.
+ * @param {number} t1 - The next interpolation factor.
+ * @return {TypedArray} The result buffer.
+ */
+ interpolate_( /* i1, t0, t, t1 */ ) {
+
+ throw new Error( 'THREE.Interpolant: Call to abstract method.' );
+ // implementations shall return this.resultBuffer
+
+ }
+
+ /**
+ * Optional method that is executed when the interval has changed.
+ *
+ * @param {number} i1 - An index into the sample value buffer.
+ * @param {number} t0 - The previous interpolation factor.
+ * @param {number} t - The current interpolation factor.
+ */
+ intervalChanged_( /* i1, t0, t1 */ ) {
+
+ // empty
+
+ }
+
+}
+
+/**
+ * Fast and simple cubic spline interpolant.
+ *
+ * It was derived from a Hermitian construction setting the first derivative
+ * at each sample position to the linear slope between neighboring positions
+ * over their parameter interval.
+ *
+ * @augments Interpolant
+ */
+class CubicInterpolant extends Interpolant {
+
+ /**
+ * Constructs a new cubic interpolant.
+ *
+ * @param {TypedArray} parameterPositions - The parameter positions hold the interpolation factors.
+ * @param {TypedArray} sampleValues - The sample values.
+ * @param {number} sampleSize - The sample size
+ * @param {TypedArray} [resultBuffer] - The result buffer.
+ */
+ constructor( parameterPositions, sampleValues, sampleSize, resultBuffer ) {
+
+ super( parameterPositions, sampleValues, sampleSize, resultBuffer );
+
+ this._weightPrev = -0;
+ this._offsetPrev = -0;
+ this._weightNext = -0;
+ this._offsetNext = -0;
+
+ this.DefaultSettings_ = {
+
+ endingStart: ZeroCurvatureEnding,
+ endingEnd: ZeroCurvatureEnding
+
+ };
+
+ }
+
+ intervalChanged_( i1, t0, t1 ) {
+
+ const pp = this.parameterPositions;
+ let iPrev = i1 - 2,
+ iNext = i1 + 1,
+
+ tPrev = pp[ iPrev ],
+ tNext = pp[ iNext ];
+
+ if ( tPrev === undefined ) {
+
+ switch ( this.getSettings_().endingStart ) {
+
+ case ZeroSlopeEnding:
+
+ // f'(t0) = 0
+ iPrev = i1;
+ tPrev = 2 * t0 - t1;
+
+ break;
+
+ case WrapAroundEnding:
+
+ // use the other end of the curve
+ iPrev = pp.length - 2;
+ tPrev = t0 + pp[ iPrev ] - pp[ iPrev + 1 ];
+
+ break;
+
+ default: // ZeroCurvatureEnding
+
+ // f''(t0) = 0 a.k.a. Natural Spline
+ iPrev = i1;
+ tPrev = t1;
+
+ }
+
+ }
+
+ if ( tNext === undefined ) {
+
+ switch ( this.getSettings_().endingEnd ) {
+
+ case ZeroSlopeEnding:
+
+ // f'(tN) = 0
+ iNext = i1;
+ tNext = 2 * t1 - t0;
+
+ break;
+
+ case WrapAroundEnding:
+
+ // use the other end of the curve
+ iNext = 1;
+ tNext = t1 + pp[ 1 ] - pp[ 0 ];
+
+ break;
+
+ default: // ZeroCurvatureEnding
+
+ // f''(tN) = 0, a.k.a. Natural Spline
+ iNext = i1 - 1;
+ tNext = t0;
+
+ }
+
+ }
+
+ const halfDt = ( t1 - t0 ) * 0.5,
+ stride = this.valueSize;
+
+ this._weightPrev = halfDt / ( t0 - tPrev );
+ this._weightNext = halfDt / ( tNext - t1 );
+ this._offsetPrev = iPrev * stride;
+ this._offsetNext = iNext * stride;
+
+ }
+
+ interpolate_( i1, t0, t, t1 ) {
+
+ const result = this.resultBuffer,
+ values = this.sampleValues,
+ stride = this.valueSize,
+
+ o1 = i1 * stride, o0 = o1 - stride,
+ oP = this._offsetPrev, oN = this._offsetNext,
+ wP = this._weightPrev, wN = this._weightNext,
+
+ p = ( t - t0 ) / ( t1 - t0 ),
+ pp = p * p,
+ ppp = pp * p;
+
+ // evaluate polynomials
+
+ const sP = - wP * ppp + 2 * wP * pp - wP * p;
+ const s0 = ( 1 + wP ) * ppp + ( -1.5 - 2 * wP ) * pp + ( -0.5 + wP ) * p + 1;
+ const s1 = ( -1 - wN ) * ppp + ( 1.5 + wN ) * pp + 0.5 * p;
+ const sN = wN * ppp - wN * pp;
+
+ // combine data linearly
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ result[ i ] =
+ sP * values[ oP + i ] +
+ s0 * values[ o0 + i ] +
+ s1 * values[ o1 + i ] +
+ sN * values[ oN + i ];
+
+ }
+
+ return result;
+
+ }
+
+}
+
+/**
+ * A basic linear interpolant.
+ *
+ * @augments Interpolant
+ */
+class LinearInterpolant extends Interpolant {
+
+ /**
+ * Constructs a new linear interpolant.
+ *
+ * @param {TypedArray} parameterPositions - The parameter positions hold the interpolation factors.
+ * @param {TypedArray} sampleValues - The sample values.
+ * @param {number} sampleSize - The sample size
+ * @param {TypedArray} [resultBuffer] - The result buffer.
+ */
+ constructor( parameterPositions, sampleValues, sampleSize, resultBuffer ) {
+
+ super( parameterPositions, sampleValues, sampleSize, resultBuffer );
+
+ }
+
+ interpolate_( i1, t0, t, t1 ) {
+
+ const result = this.resultBuffer,
+ values = this.sampleValues,
+ stride = this.valueSize,
+
+ offset1 = i1 * stride,
+ offset0 = offset1 - stride,
+
+ weight1 = ( t - t0 ) / ( t1 - t0 ),
+ weight0 = 1 - weight1;
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ result[ i ] =
+ values[ offset0 + i ] * weight0 +
+ values[ offset1 + i ] * weight1;
+
+ }
+
+ return result;
+
+ }
+
+}
+
+/**
+ * Interpolant that evaluates to the sample value at the position preceding
+ * the parameter.
+ *
+ * @augments Interpolant
+ */
+class DiscreteInterpolant extends Interpolant {
+
+ /**
+ * Constructs a new discrete interpolant.
+ *
+ * @param {TypedArray} parameterPositions - The parameter positions hold the interpolation factors.
+ * @param {TypedArray} sampleValues - The sample values.
+ * @param {number} sampleSize - The sample size
+ * @param {TypedArray} [resultBuffer] - The result buffer.
+ */
+ constructor( parameterPositions, sampleValues, sampleSize, resultBuffer ) {
+
+ super( parameterPositions, sampleValues, sampleSize, resultBuffer );
+
+ }
+
+ interpolate_( i1 /*, t0, t, t1 */ ) {
+
+ return this.copySampleValue_( i1 - 1 );
+
+ }
+
+}
+
+/**
+ * A Bezier interpolant using cubic Bezier curves with 2D control points.
+ *
+ * This interpolant supports the COLLADA/Maya style of Bezier animation where
+ * each keyframe has explicit in/out tangent control points specified as
+ * 2D coordinates (time, value).
+ *
+ * Tangent data is read from `inTangents` and `outTangents` on the interpolant
+ * (populated by `KeyframeTrack.InterpolantFactoryMethodBezier`).
+ *
+ * For a track with N keyframes and stride S:
+ * - Each tangent array has N * S * 2 values
+ * - Layout: [k0_c0_time, k0_c0_value, k0_c1_time, k0_c1_value, ..., k0_cS_time, k0_cS_value,
+ * k1_c0_time, k1_c0_value, ...]
+ *
+ * @augments Interpolant
+ */
+class BezierInterpolant extends Interpolant {
+
+ interpolate_( i1, t0, t, t1 ) {
+
+ const result = this.resultBuffer;
+ const values = this.sampleValues;
+ const stride = this.valueSize;
+
+ const offset1 = i1 * stride;
+ const offset0 = offset1 - stride;
+
+ const inTangents = this.inTangents;
+ const outTangents = this.outTangents;
+
+ // If no tangent data, fall back to linear interpolation
+ if ( ! inTangents || ! outTangents ) {
+
+ const weight1 = ( t - t0 ) / ( t1 - t0 );
+ const weight0 = 1 - weight1;
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ result[ i ] = values[ offset0 + i ] * weight0 + values[ offset1 + i ] * weight1;
+
+ }
+
+ return result;
+
+ }
+
+ const tangentStride = stride * 2;
+ const i0 = i1 - 1;
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ const v0 = values[ offset0 + i ];
+ const v1 = values[ offset1 + i ];
+
+ // outTangent of previous keyframe (C0)
+ const outTangentOffset = i0 * tangentStride + i * 2;
+ const c0x = outTangents[ outTangentOffset ];
+ const c0y = outTangents[ outTangentOffset + 1 ];
+
+ // inTangent of current keyframe (C1)
+ const inTangentOffset = i1 * tangentStride + i * 2;
+ const c1x = inTangents[ inTangentOffset ];
+ const c1y = inTangents[ inTangentOffset + 1 ];
+
+ // Find the curve parameter s where the Bezier X(s) matches t, then evaluate Y(s)
+ const s = solveBezierParameter( t, t0, c0x, c1x, t1 );
+
+ result[ i ] = cubicBezier( s, v0, c0y, c1y, v1 );
+
+ }
+
+ return result;
+
+ }
+
+}
+
+function cubicBezier( s, p0, p1, p2, p3 ) {
+
+ const k = 1 - s;
+
+ return k * k * k * p0 + 3 * k * k * s * p1 + 3 * k * s * s * p2 + s * s * s * p3;
+
+}
+
+function cubicBezierSlope( s, p0, p1, p2, p3 ) {
+
+ const k = 1 - s;
+
+ return 3 * k * k * ( p1 - p0 ) + 6 * k * s * ( p2 - p1 ) + 3 * s * s * ( p3 - p2 );
+
+}
+
+// Solves cubicBezier( s, x0, x1, x2, x3 ) = x for s in [0,1] using Newton-Raphson
+
+function solveBezierParameter( x, x0, x1, x2, x3 ) {
+
+ let s = ( x - x0 ) / ( x3 - x0 );
+
+ for ( let i = 0; i < 8; i ++ ) {
+
+ const error = cubicBezier( s, x0, x1, x2, x3 ) - x;
+ if ( Math.abs( error ) < 1e-10 ) break;
+
+ const slope = cubicBezierSlope( s, x0, x1, x2, x3 );
+ if ( Math.abs( slope ) < 1e-10 ) break;
+
+ s = Math.max( 0, Math.min( 1, s - error / slope ) );
+
+ }
+
+ return s;
+
+}
+
+/**
+ * Represents a timed sequence of keyframes, which are composed of lists of
+ * times and related values, and which are used to animate a specific property
+ * of an object.
+ */
+class KeyframeTrack {
+
+ /**
+ * Constructs a new keyframe track.
+ *
+ * @param {string} name - The keyframe track's name.
+ * @param {Array} times - A list of keyframe times.
+ * @param {Array} values - A list of keyframe values.
+ * @param {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth|InterpolateBezier)} [interpolation] - The interpolation type.
+ */
+ constructor( name, times, values, interpolation ) {
+
+ if ( name === undefined ) throw new Error( 'THREE.KeyframeTrack: track name is undefined' );
+ if ( times === undefined || times.length === 0 ) throw new Error( 'THREE.KeyframeTrack: no keyframes in track named ' + name );
+
+ /**
+ * The track's name can refer to morph targets or bones or
+ * possibly other values within an animated object. See {@link PropertyBinding#parseTrackName}
+ * for the forms of strings that can be parsed for property binding.
+ *
+ * @type {string}
+ */
+ this.name = name;
+
+ /**
+ * The keyframe times.
+ *
+ * @type {Float32Array}
+ */
+ this.times = convertArray( times, this.TimeBufferType );
+
+ /**
+ * The keyframe values.
+ *
+ * @type {Float32Array}
+ */
+ this.values = convertArray( values, this.ValueBufferType );
+
+ this.setInterpolation( interpolation || this.DefaultInterpolation );
+
+ }
+
+ /**
+ * Converts the keyframe track to JSON.
+ *
+ * @static
+ * @param {KeyframeTrack} track - The keyframe track to serialize.
+ * @return {Object} The serialized keyframe track as JSON.
+ */
+ static toJSON( track ) {
+
+ const trackType = track.constructor;
+
+ let json;
+
+ // derived classes can define a static toJSON method
+ if ( trackType.toJSON !== this.toJSON ) {
+
+ json = trackType.toJSON( track );
+
+ } else {
+
+ // by default, we assume the data can be serialized as-is
+ json = {
+
+ 'name': track.name,
+ 'times': convertArray( track.times, Array ),
+ 'values': convertArray( track.values, Array )
+
+ };
+
+ const interpolation = track.getInterpolation();
+
+ if ( interpolation !== track.DefaultInterpolation ) {
+
+ json.interpolation = interpolation;
+
+ }
+
+ if ( hasTangents( track.settings ) ) {
+
+ json.settings = {
+ inTangents: convertArray( track.settings.inTangents, Array ),
+ outTangents: convertArray( track.settings.outTangents, Array )
+ };
+
+ }
+
+ }
+
+ json.type = track.ValueTypeName; // mandatory
+
+ return json;
+
+ }
+
+ /**
+ * Factory method for creating a new discrete interpolant.
+ *
+ * @static
+ * @param {TypedArray} [result] - The result buffer.
+ * @return {DiscreteInterpolant} The new interpolant.
+ */
+ InterpolantFactoryMethodDiscrete( result ) {
+
+ return new DiscreteInterpolant( this.times, this.values, this.getValueSize(), result );
+
+ }
+
+ /**
+ * Factory method for creating a new linear interpolant.
+ *
+ * @static
+ * @param {TypedArray} [result] - The result buffer.
+ * @return {LinearInterpolant} The new interpolant.
+ */
+ InterpolantFactoryMethodLinear( result ) {
+
+ return new LinearInterpolant( this.times, this.values, this.getValueSize(), result );
+
+ }
+
+ /**
+ * Factory method for creating a new smooth interpolant.
+ *
+ * @static
+ * @param {TypedArray} [result] - The result buffer.
+ * @return {CubicInterpolant} The new interpolant.
+ */
+ InterpolantFactoryMethodSmooth( result ) {
+
+ return new CubicInterpolant( this.times, this.values, this.getValueSize(), result );
+
+ }
+
+ /**
+ * Factory method for creating a new Bezier interpolant.
+ *
+ * The Bezier interpolant requires tangent data to be set via the `settings` property
+ * on the track before creating the interpolant. The settings should contain:
+ * - `inTangents`: Float32Array with [time, value] pairs per keyframe per component
+ * - `outTangents`: Float32Array with [time, value] pairs per keyframe per component
+ *
+ * @static
+ * @param {TypedArray} [result] - The result buffer.
+ * @return {BezierInterpolant} The new interpolant.
+ */
+ InterpolantFactoryMethodBezier( result ) {
+
+ const interpolant = new BezierInterpolant( this.times, this.values, this.getValueSize(), result );
+
+ if ( this.settings ) {
+
+ interpolant.inTangents = this.settings.inTangents;
+ interpolant.outTangents = this.settings.outTangents;
+
+ }
+
+ return interpolant;
+
+ }
+
+ /**
+ * Defines the interpolation factor method for this keyframe track.
+ *
+ * @param {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth|InterpolateBezier)} interpolation - The interpolation type.
+ * @return {KeyframeTrack} A reference to this keyframe track.
+ */
+ setInterpolation( interpolation ) {
+
+ let factoryMethod;
+
+ switch ( interpolation ) {
+
+ case InterpolateDiscrete:
+
+ factoryMethod = this.InterpolantFactoryMethodDiscrete;
+
+ break;
+
+ case InterpolateLinear:
+
+ factoryMethod = this.InterpolantFactoryMethodLinear;
+
+ break;
+
+ case InterpolateSmooth:
+
+ factoryMethod = this.InterpolantFactoryMethodSmooth;
+
+ break;
+
+ case InterpolateBezier:
+
+ factoryMethod = this.InterpolantFactoryMethodBezier;
+
+ break;
+
+ }
+
+ if ( factoryMethod === undefined ) {
+
+ const message = 'unsupported interpolation for ' +
+ this.ValueTypeName + ' keyframe track named ' + this.name;
+
+ if ( this.createInterpolant === undefined ) {
+
+ // fall back to default, unless the default itself is messed up
+ if ( interpolation !== this.DefaultInterpolation ) {
+
+ this.setInterpolation( this.DefaultInterpolation );
+
+ } else {
+
+ throw new Error( message ); // fatal, in this case
+
+ }
+
+ }
+
+ warn( 'KeyframeTrack:', message );
+ return this;
+
+ }
+
+ this.createInterpolant = factoryMethod;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the current interpolation type.
+ *
+ * @return {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth|InterpolateBezier)} The interpolation type.
+ */
+ getInterpolation() {
+
+ switch ( this.createInterpolant ) {
+
+ case this.InterpolantFactoryMethodDiscrete:
+
+ return InterpolateDiscrete;
+
+ case this.InterpolantFactoryMethodLinear:
+
+ return InterpolateLinear;
+
+ case this.InterpolantFactoryMethodSmooth:
+
+ return InterpolateSmooth;
+
+ case this.InterpolantFactoryMethodBezier:
+
+ return InterpolateBezier;
+
+ }
+
+ }
+
+ /**
+ * Returns the value size.
+ *
+ * @return {number} The value size.
+ */
+ getValueSize() {
+
+ return this.values.length / this.times.length;
+
+ }
+
+ /**
+ * Moves all keyframes either forward or backward in time.
+ *
+ * @param {number} timeOffset - The offset to move the time values.
+ * @return {KeyframeTrack} A reference to this keyframe track.
+ */
+ shift( timeOffset ) {
+
+ if ( timeOffset !== 0.0 ) {
+
+ const times = this.times;
+
+ for ( let i = 0, n = times.length; i !== n; ++ i ) {
+
+ times[ i ] += timeOffset;
+
+ }
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Scale all keyframe times by a factor (useful for frame - seconds conversions).
+ *
+ * @param {number} timeScale - The time scale.
+ * @return {KeyframeTrack} A reference to this keyframe track.
+ */
+ scale( timeScale ) {
+
+ if ( timeScale !== 1.0 ) {
+
+ const times = this.times;
+
+ for ( let i = 0, n = times.length; i !== n; ++ i ) {
+
+ times[ i ] *= timeScale;
+
+ }
+
+ if ( hasTangents( this.settings ) ) {
+
+ scaleTangentTimes( this.settings.inTangents, timeScale );
+ scaleTangentTimes( this.settings.outTangents, timeScale );
+
+ }
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Removes keyframes before and after animation without changing any values within the defined time range.
+ *
+ * Note: The method does not shift around keys to the start of the track time, because for interpolated
+ * keys this will change their values
+ *
+ * @param {number} startTime - The start time.
+ * @param {number} endTime - The end time.
+ * @return {KeyframeTrack} A reference to this keyframe track.
+ */
+ trim( startTime, endTime ) {
+
+ const times = this.times,
+ nKeys = times.length;
+
+ let from = 0,
+ to = nKeys - 1;
+
+ while ( from !== nKeys && times[ from ] < startTime ) {
+
+ ++ from;
+
+ }
+
+ while ( to !== -1 && times[ to ] > endTime ) {
+
+ -- to;
+
+ }
+
+ ++ to; // inclusive -> exclusive bound
+
+ if ( from !== 0 || to !== nKeys ) {
+
+ // empty tracks are forbidden, so keep at least one keyframe
+ if ( from >= to ) {
+
+ to = Math.max( to, 1 );
+ from = to - 1;
+
+ }
+
+ const stride = this.getValueSize();
+ this.times = times.slice( from, to );
+ this.values = this.values.slice( from * stride, to * stride );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Performs minimal validation on the keyframe track. Returns `true` if the values
+ * are valid.
+ *
+ * @return {boolean} Whether the keyframes are valid or not.
+ */
+ validate() {
+
+ let valid = true;
+
+ const valueSize = this.getValueSize();
+ if ( valueSize - Math.floor( valueSize ) !== 0 ) {
+
+ error( 'KeyframeTrack: Invalid value size in track.', this );
+ valid = false;
+
+ }
+
+ const times = this.times,
+ values = this.values,
+
+ nKeys = times.length;
+
+ if ( nKeys === 0 ) {
+
+ error( 'KeyframeTrack: Track is empty.', this );
+ valid = false;
+
+ }
+
+ let prevTime = null;
+
+ for ( let i = 0; i !== nKeys; i ++ ) {
+
+ const currTime = times[ i ];
+
+ if ( typeof currTime === 'number' && isNaN( currTime ) ) {
+
+ error( 'KeyframeTrack: Time is not a valid number.', this, i, currTime );
+ valid = false;
+ break;
+
+ }
+
+ if ( prevTime !== null && prevTime > currTime ) {
+
+ error( 'KeyframeTrack: Out of order keys.', this, i, currTime, prevTime );
+ valid = false;
+ break;
+
+ }
+
+ prevTime = currTime;
+
+ }
+
+ if ( values !== undefined ) {
+
+ if ( isTypedArray( values ) ) {
+
+ for ( let i = 0, n = values.length; i !== n; ++ i ) {
+
+ const value = values[ i ];
+
+ if ( isNaN( value ) ) {
+
+ error( 'KeyframeTrack: Value is not a valid number.', this, i, value );
+ valid = false;
+ break;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ return valid;
+
+ }
+
+ /**
+ * Optimizes this keyframe track by removing equivalent sequential keys (which are
+ * common in morph target sequences).
+ *
+ * @return {KeyframeTrack} A reference to this keyframe track.
+ */
+ optimize() {
+
+ // (0,0,0,0,1,1,1,0,0,0,0,0,0,0) --> (0,0,1,1,0,0)
+
+ // times or values may be shared with other tracks, so overwriting is unsafe
+ const times = this.times.slice(),
+ values = this.values.slice(),
+ stride = this.getValueSize(),
+
+ smoothInterpolation = this.getInterpolation() === InterpolateSmooth,
+
+ lastIndex = times.length - 1;
+
+ let writeIndex = 1;
+
+ for ( let i = 1; i < lastIndex; ++ i ) {
+
+ let keep = false;
+
+ const time = times[ i ];
+ const timeNext = times[ i + 1 ];
+
+ // remove adjacent keyframes scheduled at the same time
+
+ if ( time !== timeNext && ( i !== 1 || time !== times[ 0 ] ) ) {
+
+ if ( ! smoothInterpolation ) {
+
+ // remove unnecessary keyframes same as their neighbors
+
+ const offset = i * stride,
+ offsetP = offset - stride,
+ offsetN = offset + stride;
+
+ for ( let j = 0; j !== stride; ++ j ) {
+
+ const value = values[ offset + j ];
+
+ if ( value !== values[ offsetP + j ] ||
+ value !== values[ offsetN + j ] ) {
+
+ keep = true;
+ break;
+
+ }
+
+ }
+
+ } else {
+
+ keep = true;
+
+ }
+
+ }
+
+ // in-place compaction
+
+ if ( keep ) {
+
+ if ( i !== writeIndex ) {
+
+ times[ writeIndex ] = times[ i ];
+
+ const readOffset = i * stride,
+ writeOffset = writeIndex * stride;
+
+ for ( let j = 0; j !== stride; ++ j ) {
+
+ values[ writeOffset + j ] = values[ readOffset + j ];
+
+ }
+
+ }
+
+ ++ writeIndex;
+
+ }
+
+ }
+
+ // flush last keyframe (compaction looks ahead)
+
+ if ( lastIndex > 0 ) {
+
+ times[ writeIndex ] = times[ lastIndex ];
+
+ for ( let readOffset = lastIndex * stride, writeOffset = writeIndex * stride, j = 0; j !== stride; ++ j ) {
+
+ values[ writeOffset + j ] = values[ readOffset + j ];
+
+ }
+
+ ++ writeIndex;
+
+ }
+
+ if ( writeIndex !== times.length ) {
+
+ this.times = times.slice( 0, writeIndex );
+ this.values = values.slice( 0, writeIndex * stride );
+
+ } else {
+
+ this.times = times;
+ this.values = values;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new keyframe track with copied values from this instance.
+ *
+ * @return {KeyframeTrack} A clone of this instance.
+ */
+ clone() {
+
+ const times = this.times.slice();
+ const values = this.values.slice();
+
+ const TypedKeyframeTrack = this.constructor;
+ const track = new TypedKeyframeTrack( this.name, times, values );
+
+ // Interpolant argument to constructor is not saved, so copy the factory method directly.
+ track.createInterpolant = this.createInterpolant;
+
+ if ( hasTangents( this.settings ) ) {
+
+ track.settings = {
+ inTangents: this.settings.inTangents.slice(),
+ outTangents: this.settings.outTangents.slice()
+ };
+
+ }
+
+ return track;
+
+ }
+
+}
+
+function scaleTangentTimes( tangents, timeScale ) {
+
+ // tangents are [ time, value ] pairs, so only every second entry is a time
+
+ for ( let i = 0, n = tangents.length; i !== n; i += 2 ) {
+
+ tangents[ i ] *= timeScale;
+
+ }
+
+}
+
+/**
+ * The value type name.
+ *
+ * @type {string}
+ * @default ''
+ */
+KeyframeTrack.prototype.ValueTypeName = '';
+
+/**
+ * The time buffer type of this keyframe track.
+ *
+ * @type {TypedArray|Array}
+ * @default Float32Array.constructor
+ */
+KeyframeTrack.prototype.TimeBufferType = Float32Array;
+
+/**
+ * The value buffer type of this keyframe track.
+ *
+ * @type {TypedArray|Array}
+ * @default Float32Array.constructor
+ */
+KeyframeTrack.prototype.ValueBufferType = Float32Array;
+
+/**
+ * The default interpolation type of this keyframe track.
+ *
+ * @type {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth|InterpolateBezier)}
+ * @default InterpolateLinear
+ */
+KeyframeTrack.prototype.DefaultInterpolation = InterpolateLinear;
+
+/**
+ * A track for boolean keyframe values.
+ *
+ * @augments KeyframeTrack
+ */
+class BooleanKeyframeTrack extends KeyframeTrack {
+
+ /**
+ * Constructs a new boolean keyframe track.
+ *
+ * This keyframe track type has no `interpolation` parameter because the
+ * interpolation is always discrete.
+ *
+ * @param {string} name - The keyframe track's name.
+ * @param {Array} times - A list of keyframe times.
+ * @param {Array} values - A list of keyframe values.
+ */
+ constructor( name, times, values ) {
+
+ super( name, times, values );
+
+ }
+
+}
+
+/**
+ * The value type name.
+ *
+ * @type {string}
+ * @default 'bool'
+ */
+BooleanKeyframeTrack.prototype.ValueTypeName = 'bool';
+
+/**
+ * The value buffer type of this keyframe track.
+ *
+ * @type {TypedArray|Array}
+ * @default Array.constructor
+ */
+BooleanKeyframeTrack.prototype.ValueBufferType = Array;
+
+/**
+ * The default interpolation type of this keyframe track.
+ *
+ * @type {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth)}
+ * @default InterpolateDiscrete
+ */
+BooleanKeyframeTrack.prototype.DefaultInterpolation = InterpolateDiscrete;
+BooleanKeyframeTrack.prototype.InterpolantFactoryMethodLinear = undefined;
+BooleanKeyframeTrack.prototype.InterpolantFactoryMethodSmooth = undefined;
+
+/**
+ * A track for color keyframe values.
+ *
+ * @augments KeyframeTrack
+ */
+class ColorKeyframeTrack extends KeyframeTrack {
+
+ /**
+ * Constructs a new color keyframe track.
+ *
+ * @param {string} name - The keyframe track's name.
+ * @param {Array} times - A list of keyframe times.
+ * @param {Array} values - A list of keyframe values.
+ * @param {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth)} [interpolation] - The interpolation type.
+ */
+ constructor( name, times, values, interpolation ) {
+
+ super( name, times, values, interpolation );
+
+ }
+
+}
+
+/**
+ * The value type name.
+ *
+ * @type {string}
+ * @default 'color'
+ */
+ColorKeyframeTrack.prototype.ValueTypeName = 'color';
+
+/**
+ * A track for numeric keyframe values.
+ *
+ * @augments KeyframeTrack
+ */
+class NumberKeyframeTrack extends KeyframeTrack {
+
+ /**
+ * Constructs a new number keyframe track.
+ *
+ * @param {string} name - The keyframe track's name.
+ * @param {Array} times - A list of keyframe times.
+ * @param {Array} values - A list of keyframe values.
+ * @param {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth)} [interpolation] - The interpolation type.
+ */
+ constructor( name, times, values, interpolation ) {
+
+ super( name, times, values, interpolation );
+
+ }
+
+}
+
+/**
+ * The value type name.
+ *
+ * @type {string}
+ * @default 'number'
+ */
+NumberKeyframeTrack.prototype.ValueTypeName = 'number';
+
+/**
+ * Spherical linear unit quaternion interpolant.
+ *
+ * @augments Interpolant
+ */
+class QuaternionLinearInterpolant extends Interpolant {
+
+ /**
+ * Constructs a new SLERP interpolant.
+ *
+ * @param {TypedArray} parameterPositions - The parameter positions hold the interpolation factors.
+ * @param {TypedArray} sampleValues - The sample values.
+ * @param {number} sampleSize - The sample size
+ * @param {TypedArray} [resultBuffer] - The result buffer.
+ */
+ constructor( parameterPositions, sampleValues, sampleSize, resultBuffer ) {
+
+ super( parameterPositions, sampleValues, sampleSize, resultBuffer );
+
+ }
+
+ interpolate_( i1, t0, t, t1 ) {
+
+ const result = this.resultBuffer,
+ values = this.sampleValues,
+ stride = this.valueSize,
+
+ alpha = ( t - t0 ) / ( t1 - t0 );
+
+ let offset = i1 * stride;
+
+ for ( let end = offset + stride; offset !== end; offset += 4 ) {
+
+ Quaternion.slerpFlat( result, 0, values, offset - stride, values, offset, alpha );
+
+ }
+
+ return result;
+
+ }
+
+}
+
+/**
+ * A track for Quaternion keyframe values.
+ *
+ * @augments KeyframeTrack
+ */
+class QuaternionKeyframeTrack extends KeyframeTrack {
+
+ /**
+ * Constructs a new Quaternion keyframe track.
+ *
+ * @param {string} name - The keyframe track's name.
+ * @param {Array} times - A list of keyframe times.
+ * @param {Array} values - A list of keyframe values.
+ * @param {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth)} [interpolation] - The interpolation type.
+ */
+ constructor( name, times, values, interpolation ) {
+
+ super( name, times, values, interpolation );
+
+ }
+
+ /**
+ * Overwritten so the method returns Quaternion based interpolant.
+ *
+ * @static
+ * @param {TypedArray} [result] - The result buffer.
+ * @return {QuaternionLinearInterpolant} The new interpolant.
+ */
+ InterpolantFactoryMethodLinear( result ) {
+
+ return new QuaternionLinearInterpolant( this.times, this.values, this.getValueSize(), result );
+
+ }
+
+}
+
+/**
+ * The value type name.
+ *
+ * @type {string}
+ * @default 'quaternion'
+ */
+QuaternionKeyframeTrack.prototype.ValueTypeName = 'quaternion';
+// ValueBufferType is inherited
+// DefaultInterpolation is inherited;
+QuaternionKeyframeTrack.prototype.InterpolantFactoryMethodSmooth = undefined;
+
+/**
+ * A track for string keyframe values.
+ *
+ * @augments KeyframeTrack
+ */
+class StringKeyframeTrack extends KeyframeTrack {
+
+ /**
+ * Constructs a new string keyframe track.
+ *
+ * This keyframe track type has no `interpolation` parameter because the
+ * interpolation is always discrete.
+ *
+ * @param {string} name - The keyframe track's name.
+ * @param {Array} times - A list of keyframe times.
+ * @param {Array} values - A list of keyframe values.
+ */
+ constructor( name, times, values ) {
+
+ super( name, times, values );
+
+ }
+
+}
+
+/**
+ * The value type name.
+ *
+ * @type {string}
+ * @default 'string'
+ */
+StringKeyframeTrack.prototype.ValueTypeName = 'string';
+
+/**
+ * The value buffer type of this keyframe track.
+ *
+ * @type {TypedArray|Array}
+ * @default Array.constructor
+ */
+StringKeyframeTrack.prototype.ValueBufferType = Array;
+
+/**
+ * The default interpolation type of this keyframe track.
+ *
+ * @type {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth)}
+ * @default InterpolateDiscrete
+ */
+StringKeyframeTrack.prototype.DefaultInterpolation = InterpolateDiscrete;
+StringKeyframeTrack.prototype.InterpolantFactoryMethodLinear = undefined;
+StringKeyframeTrack.prototype.InterpolantFactoryMethodSmooth = undefined;
+
+/**
+ * A track for vector keyframe values.
+ *
+ * @augments KeyframeTrack
+ */
+class VectorKeyframeTrack extends KeyframeTrack {
+
+ /**
+ * Constructs a new vector keyframe track.
+ *
+ * @param {string} name - The keyframe track's name.
+ * @param {Array} times - A list of keyframe times.
+ * @param {Array} values - A list of keyframe values.
+ * @param {(InterpolateLinear|InterpolateDiscrete|InterpolateSmooth)} [interpolation] - The interpolation type.
+ */
+ constructor( name, times, values, interpolation ) {
+
+ super( name, times, values, interpolation );
+
+ }
+
+}
+
+/**
+ * The value type name.
+ *
+ * @type {string}
+ * @default 'vector'
+ */
+VectorKeyframeTrack.prototype.ValueTypeName = 'vector';
+
+/**
+ * A reusable set of keyframe tracks which represent an animation.
+ */
+class AnimationClip {
+
+ /**
+ * Constructs a new animation clip.
+ *
+ * Note: Instead of instantiating an AnimationClip directly with the constructor, you can
+ * use the static interface of this class for creating clips. In most cases though, animation clips
+ * will automatically be created by loaders when importing animated 3D assets.
+ *
+ * @param {string} [name=''] - The clip's name.
+ * @param {number} [duration=-1] - The clip's duration in seconds. If a negative value is passed,
+ * the duration will be calculated from the passed keyframes.
+ * @param {Array} tracks - An array of keyframe tracks.
+ * @param {(NormalAnimationBlendMode|AdditiveAnimationBlendMode)} [blendMode=NormalAnimationBlendMode] - Defines how the animation
+ * is blended/combined when two or more animations are simultaneously played.
+ */
+ constructor( name = '', duration = -1, tracks = [], blendMode = NormalAnimationBlendMode ) {
+
+ /**
+ * The clip's name.
+ *
+ * @type {string}
+ */
+ this.name = name;
+
+ /**
+ * An array of keyframe tracks.
+ *
+ * @type {Array}
+ */
+ this.tracks = tracks;
+
+ /**
+ * The clip's duration in seconds.
+ *
+ * @type {number}
+ */
+ this.duration = duration;
+
+ /**
+ * Defines how the animation is blended/combined when two or more animations
+ * are simultaneously played.
+ *
+ * @type {(NormalAnimationBlendMode|AdditiveAnimationBlendMode)}
+ */
+ this.blendMode = blendMode;
+
+ /**
+ * The UUID of the animation clip.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ /**
+ * An object that can be used to store custom data about the animation clip.
+ * It should not hold references to functions as these will not be cloned.
+ *
+ * @type {Object}
+ */
+ this.userData = {};
+
+ // this means it should figure out its duration by scanning the tracks
+ if ( this.duration < 0 ) {
+
+ this.resetDuration();
+
+ }
+
+ }
+
+ /**
+ * Factory method for creating an animation clip from the given JSON.
+ *
+ * @static
+ * @param {Object} json - The serialized animation clip.
+ * @return {AnimationClip} The new animation clip.
+ */
+ static parse( json ) {
+
+ const tracks = [],
+ jsonTracks = json.tracks,
+ frameTime = 1.0 / ( json.fps || 1.0 );
+
+ for ( let i = 0, n = jsonTracks.length; i !== n; ++ i ) {
+
+ tracks.push( parseKeyframeTrack( jsonTracks[ i ] ).scale( frameTime ) );
+
+ }
+
+ const clip = new this( json.name, json.duration, tracks, json.blendMode );
+ clip.uuid = json.uuid;
+
+ clip.userData = JSON.parse( json.userData || '{}' );
+
+ return clip;
+
+ }
+
+ /**
+ * Serializes the given animation clip into JSON.
+ *
+ * @static
+ * @param {AnimationClip} clip - The animation clip to serialize.
+ * @return {Object} The JSON object.
+ */
+ static toJSON( clip ) {
+
+ const tracks = [],
+ clipTracks = clip.tracks;
+
+ const json = {
+
+ 'name': clip.name,
+ 'duration': clip.duration,
+ 'tracks': tracks,
+ 'uuid': clip.uuid,
+ 'blendMode': clip.blendMode,
+ 'userData': JSON.stringify( clip.userData ),
+
+ };
+
+ for ( let i = 0, n = clipTracks.length; i !== n; ++ i ) {
+
+ tracks.push( KeyframeTrack.toJSON( clipTracks[ i ] ) );
+
+ }
+
+ return json;
+
+ }
+
+ /**
+ * Returns a new animation clip from the passed morph targets array of a
+ * geometry, taking a name and the number of frames per second.
+ *
+ * Note: The fps parameter is required, but the animation speed can be
+ * overridden via {@link AnimationAction#setDuration}.
+ *
+ * @static
+ * @param {string} name - The name of the animation clip.
+ * @param {Array} morphTargetSequence - A sequence of morph targets.
+ * @param {number} fps - The Frames-Per-Second value.
+ * @param {boolean} noLoop - Whether the clip should be no loop or not.
+ * @return {AnimationClip} The new animation clip.
+ */
+ static CreateFromMorphTargetSequence( name, morphTargetSequence, fps, noLoop ) {
+
+ const numMorphTargets = morphTargetSequence.length;
+ const tracks = [];
+
+ for ( let i = 0; i < numMorphTargets; i ++ ) {
+
+ let times = [];
+ let values = [];
+
+ times.push(
+ ( i + numMorphTargets - 1 ) % numMorphTargets,
+ i,
+ ( i + 1 ) % numMorphTargets );
+
+ values.push( 0, 1, 0 );
+
+ const order = getKeyframeOrder( times );
+ times = sortedArray( times, 1, order );
+ values = sortedArray( values, 1, order );
+
+ // if there is a key at the first frame, duplicate it as the
+ // last frame as well for perfect loop.
+ if ( ! noLoop && times[ 0 ] === 0 ) {
+
+ times.push( numMorphTargets );
+ values.push( values[ 0 ] );
+
+ }
+
+ tracks.push(
+ new NumberKeyframeTrack(
+ '.morphTargetInfluences[' + morphTargetSequence[ i ].name + ']',
+ times, values
+ ).scale( 1.0 / fps ) );
+
+ }
+
+ return new this( name, -1, tracks );
+
+ }
+
+ /**
+ * Searches for an animation clip by name, taking as its first parameter
+ * either an array of clips, or a mesh or geometry that contains an
+ * array named "animations" property.
+ *
+ * @static
+ * @param {(Array|Object3D)} objectOrClipArray - The array or object to search through.
+ * @param {string} name - The name to search for.
+ * @return {?AnimationClip} The found animation clip. Returns `null` if no clip has been found.
+ */
+ static findByName( objectOrClipArray, name ) {
+
+ let clipArray = objectOrClipArray;
+
+ if ( ! Array.isArray( objectOrClipArray ) ) {
+
+ const o = objectOrClipArray;
+ clipArray = o.geometry && o.geometry.animations || o.animations;
+
+ }
+
+ for ( let i = 0; i < clipArray.length; i ++ ) {
+
+ if ( clipArray[ i ].name === name ) {
+
+ return clipArray[ i ];
+
+ }
+
+ }
+
+ return null;
+
+ }
+
+ /**
+ * Returns an array of new AnimationClips created from the morph target
+ * sequences of a geometry, trying to sort morph target names into
+ * animation-group-based patterns like "Walk_001, Walk_002, Run_001, Run_002...".
+ *
+ * See {@link MD2Loader#parse} as an example for how the method should be used.
+ *
+ * @static
+ * @param {Array} morphTargets - A sequence of morph targets.
+ * @param {number} fps - The Frames-Per-Second value.
+ * @param {boolean} noLoop - Whether the clip should be no loop or not.
+ * @return {Array} An array of new animation clips.
+ */
+ static CreateClipsFromMorphTargetSequences( morphTargets, fps, noLoop ) {
+
+ const animationToMorphTargets = {};
+
+ // tested with https://regex101.com/ on trick sequences
+ // such flamingo_flyA_003, flamingo_run1_003, crdeath0059
+ const pattern = /^([\w-]*?)([\d]+)$/;
+
+ // sort morph target names into animation groups based
+ // patterns like Walk_001, Walk_002, Run_001, Run_002
+ for ( let i = 0, il = morphTargets.length; i < il; i ++ ) {
+
+ const morphTarget = morphTargets[ i ];
+ const parts = morphTarget.name.match( pattern );
+
+ if ( parts && parts.length > 1 ) {
+
+ const name = parts[ 1 ];
+
+ let animationMorphTargets = animationToMorphTargets[ name ];
+
+ if ( ! animationMorphTargets ) {
+
+ animationToMorphTargets[ name ] = animationMorphTargets = [];
+
+ }
+
+ animationMorphTargets.push( morphTarget );
+
+ }
+
+ }
+
+ const clips = [];
+
+ for ( const name in animationToMorphTargets ) {
+
+ clips.push( this.CreateFromMorphTargetSequence( name, animationToMorphTargets[ name ], fps, noLoop ) );
+
+ }
+
+ return clips;
+
+ }
+
+ /**
+ * Sets the duration of this clip to the duration of its longest keyframe track.
+ *
+ * @return {AnimationClip} A reference to this animation clip.
+ */
+ resetDuration() {
+
+ const tracks = this.tracks;
+ let duration = 0;
+
+ for ( let i = 0, n = tracks.length; i !== n; ++ i ) {
+
+ const track = this.tracks[ i ];
+
+ duration = Math.max( duration, track.times[ track.times.length - 1 ] );
+
+ }
+
+ this.duration = duration;
+
+ return this;
+
+ }
+
+ /**
+ * Trims all tracks to the clip's duration.
+ *
+ * @return {AnimationClip} A reference to this animation clip.
+ */
+ trim() {
+
+ for ( let i = 0; i < this.tracks.length; i ++ ) {
+
+ this.tracks[ i ].trim( 0, this.duration );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Performs minimal validation on each track in the clip. Returns `true` if all
+ * tracks are valid.
+ *
+ * @return {boolean} Whether the clip's keyframes are valid or not.
+ */
+ validate() {
+
+ let valid = true;
+
+ for ( let i = 0; i < this.tracks.length; i ++ ) {
+
+ valid = valid && this.tracks[ i ].validate();
+
+ }
+
+ return valid;
+
+ }
+
+ /**
+ * Optimizes each track by removing equivalent sequential keys (which are
+ * common in morph target sequences).
+ *
+ * @return {AnimationClip} A reference to this animation clip.
+ */
+ optimize() {
+
+ for ( let i = 0; i < this.tracks.length; i ++ ) {
+
+ this.tracks[ i ].optimize();
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new animation clip with copied values from this instance.
+ *
+ * @return {AnimationClip} A clone of this instance.
+ */
+ clone() {
+
+ const tracks = [];
+
+ for ( let i = 0; i < this.tracks.length; i ++ ) {
+
+ tracks.push( this.tracks[ i ].clone() );
+
+ }
+
+ const clip = new this.constructor( this.name, this.duration, tracks, this.blendMode );
+
+ clip.userData = JSON.parse( JSON.stringify( this.userData ) );
+
+ return clip;
+
+ }
+
+ /**
+ * Serializes this animation clip into JSON.
+ *
+ * @return {Object} The JSON object.
+ */
+ toJSON() {
+
+ return this.constructor.toJSON( this );
+
+ }
+
+}
+
+function getTrackTypeForValueTypeName( typeName ) {
+
+ switch ( typeName.toLowerCase() ) {
+
+ case 'scalar':
+ case 'double':
+ case 'float':
+ case 'number':
+ case 'integer':
+
+ return NumberKeyframeTrack;
+
+ case 'vector':
+ case 'vector2':
+ case 'vector3':
+ case 'vector4':
+
+ return VectorKeyframeTrack;
+
+ case 'color':
+
+ return ColorKeyframeTrack;
+
+ case 'quaternion':
+
+ return QuaternionKeyframeTrack;
+
+ case 'bool':
+ case 'boolean':
+
+ return BooleanKeyframeTrack;
+
+ case 'string':
+
+ return StringKeyframeTrack;
+
+ }
+
+ throw new Error( 'THREE.KeyframeTrack: Unsupported typeName: ' + typeName );
+
+}
+
+function parseKeyframeTrack( json ) {
+
+ if ( json.type === undefined ) {
+
+ throw new Error( 'THREE.KeyframeTrack: track type undefined, can not parse' );
+
+ }
+
+ const trackType = getTrackTypeForValueTypeName( json.type );
+
+ if ( json.times === undefined ) {
+
+ const times = [], values = [];
+
+ flattenJSON( json.keys, times, values, 'value' );
+
+ json.times = times;
+ json.values = values;
+
+ }
+
+ let track;
+
+ // derived classes can define a static parse method
+ if ( trackType.parse !== undefined ) {
+
+ track = trackType.parse( json );
+
+ } else {
+
+ // by default, we assume a constructor compatible with the base
+ track = new trackType( json.name, json.times, json.values, json.interpolation );
+
+ }
+
+ if ( hasTangents( json.settings ) ) {
+
+ track.settings = {
+ inTangents: convertArray( json.settings.inTangents, Float32Array ),
+ outTangents: convertArray( json.settings.outTangents, Float32Array )
+ };
+
+ }
+
+ return track;
+
+}
+
+/**
+ * @class
+ * @classdesc A simple caching system, used internally by {@link FileLoader}.
+ * To enable caching across all loaders that use {@link FileLoader}, add `THREE.Cache.enabled = true.` once in your app.
+ * @hideconstructor
+ */
+const Cache = {
+
+ /**
+ * Whether caching is enabled or not.
+ *
+ * @static
+ * @type {boolean}
+ * @default false
+ */
+ enabled: false,
+
+ /**
+ * A dictionary that holds cached files.
+ *
+ * @static
+ * @type {Object}
+ */
+ files: {},
+
+ /**
+ * Adds a cache entry with a key to reference the file. If this key already
+ * holds a file, it is overwritten.
+ *
+ * @static
+ * @param {string} key - The key to reference the cached file.
+ * @param {Object} file - The file to be cached.
+ */
+ add: function ( key, file ) {
+
+ if ( this.enabled === false ) return;
+
+ if ( isBlobURL( key ) ) return;
+
+ // log( 'Cache', 'Adding key:', key );
+
+ this.files[ key ] = file;
+
+ },
+
+ /**
+ * Gets the cached value for the given key.
+ *
+ * @static
+ * @param {string} key - The key to reference the cached file.
+ * @return {Object|undefined} The cached file. If the key does not exist `undefined` is returned.
+ */
+ get: function ( key ) {
+
+ if ( this.enabled === false ) return;
+
+ if ( isBlobURL( key ) ) return;
+
+ // log( 'Cache', 'Checking key:', key );
+
+ return this.files[ key ];
+
+ },
+
+ /**
+ * Removes the cached file associated with the given key.
+ *
+ * @static
+ * @param {string} key - The key to reference the cached file.
+ */
+ remove: function ( key ) {
+
+ delete this.files[ key ];
+
+ },
+
+ /**
+ * Remove all values from the cache.
+ *
+ * @static
+ */
+ clear: function () {
+
+ this.files = {};
+
+ }
+
+};
+
+/**
+ * Returns true if the given cache key contains the blob: scheme.
+ *
+ * @private
+ * @param {string} key - The cache key.
+ * @return {boolean} Whether the given cache key contains the blob: scheme or not.
+ */
+function isBlobURL( key ) {
+
+ try {
+
+ const urlString = key.slice( key.indexOf( ':' ) + 1 ); // remove type identifier
+
+ const url = new URL( urlString );
+ return url.protocol === 'blob:';
+
+ } catch ( e ) {
+
+ // If the string is not a valid URL, it throws an error
+ return false;
+
+ }
+
+}
+
+/**
+ * Handles and keeps track of loaded and pending data. A default global
+ * instance of this class is created and used by loaders if not supplied
+ * manually.
+ *
+ * In general that should be sufficient, however there are times when it can
+ * be useful to have separate loaders - for example if you want to show
+ * separate loading bars for objects and textures.
+ *
+ * ```js
+ * const manager = new THREE.LoadingManager();
+ * manager.onLoad = () => console.log( 'Loading complete!' );
+ *
+ * const loader1 = new OBJLoader( manager );
+ * const loader2 = new ColladaLoader( manager );
+ * ```
+ */
+class LoadingManager {
+
+ /**
+ * Constructs a new loading manager.
+ *
+ * @param {Function} [onLoad] - Executes when all items have been loaded.
+ * @param {Function} [onProgress] - Executes when single items have been loaded.
+ * @param {Function} [onError] - Executes when an error occurs.
+ */
+ constructor( onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ let isLoading = false;
+ let itemsLoaded = 0;
+ let itemsTotal = 0;
+ let urlModifier = undefined;
+ const handlers = [];
+
+ // Refer to #5689 for the reason why we don't set .onStart
+ // in the constructor
+
+ /**
+ * Executes when an item starts loading.
+ *
+ * @type {Function|undefined}
+ * @default undefined
+ */
+ this.onStart = undefined;
+
+ /**
+ * Executes when all items have been loaded.
+ *
+ * @type {Function|undefined}
+ * @default undefined
+ */
+ this.onLoad = onLoad;
+
+ /**
+ * Executes when single items have been loaded.
+ *
+ * @type {Function|undefined}
+ * @default undefined
+ */
+ this.onProgress = onProgress;
+
+ /**
+ * Executes when an error occurs.
+ *
+ * @type {Function|undefined}
+ * @default undefined
+ */
+ this.onError = onError;
+
+ /**
+ * Used for aborting ongoing requests in loaders using this manager.
+ *
+ * @private
+ * @type {AbortController | null}
+ */
+ this._abortController = null;
+
+ /**
+ * This should be called by any loader using the manager when the loader
+ * starts loading an item.
+ *
+ * @param {string} url - The URL to load.
+ */
+ this.itemStart = function ( url ) {
+
+ itemsTotal ++;
+
+ if ( isLoading === false ) {
+
+ if ( scope.onStart !== undefined ) {
+
+ scope.onStart( url, itemsLoaded, itemsTotal );
+
+ }
+
+ }
+
+ isLoading = true;
+
+ };
+
+ /**
+ * This should be called by any loader using the manager when the loader
+ * ended loading an item.
+ *
+ * @param {string} url - The URL of the loaded item.
+ */
+ this.itemEnd = function ( url ) {
+
+ itemsLoaded ++;
+
+ if ( scope.onProgress !== undefined ) {
+
+ scope.onProgress( url, itemsLoaded, itemsTotal );
+
+ }
+
+ if ( itemsLoaded === itemsTotal ) {
+
+ isLoading = false;
+
+ if ( scope.onLoad !== undefined ) {
+
+ scope.onLoad();
+
+ }
+
+ }
+
+ };
+
+ /**
+ * This should be called by any loader using the manager when the loader
+ * encounters an error when loading an item.
+ *
+ * @param {string} url - The URL of the item that produces an error.
+ */
+ this.itemError = function ( url ) {
+
+ if ( scope.onError !== undefined ) {
+
+ scope.onError( url );
+
+ }
+
+ };
+
+ /**
+ * Given a URL, uses the URL modifier callback (if any) and returns a
+ * resolved URL. If no URL modifier is set, returns the original URL.
+ *
+ * @param {string} url - The URL to load.
+ * @return {string} The resolved URL.
+ */
+ this.resolveURL = function ( url ) {
+
+ // Normalize to NFC so that Unicode URIs (e.g. from glTF)
+ // are percent-encoded correctly per RFC 3987.
+
+ url = url.normalize( 'NFC' );
+
+ if ( urlModifier ) {
+
+ return urlModifier( url );
+
+ }
+
+ return url;
+
+ };
+
+ /**
+ * If provided, the callback will be passed each resource URL before a
+ * request is sent. The callback may return the original URL, or a new URL to
+ * override loading behavior. This behavior can be used to load assets from
+ * .ZIP files, drag-and-drop APIs, and Data URIs.
+ *
+ * ```js
+ * const blobs = {'fish.gltf': blob1, 'diffuse.png': blob2, 'normal.png': blob3};
+ *
+ * const manager = new THREE.LoadingManager();
+ *
+ * // Initialize loading manager with URL callback.
+ * const objectURLs = [];
+ * manager.setURLModifier( ( url ) => {
+ *
+ * url = URL.createObjectURL( blobs[ url ] );
+ * objectURLs.push( url );
+ * return url;
+ *
+ * } );
+ *
+ * // Load as usual, then revoke the blob URLs.
+ * const loader = new GLTFLoader( manager );
+ * loader.load( 'fish.gltf', (gltf) => {
+ *
+ * scene.add( gltf.scene );
+ * objectURLs.forEach( ( url ) => URL.revokeObjectURL( url ) );
+ *
+ * } );
+ * ```
+ *
+ * @param {function(string):string} transform - URL modifier callback. Called with an URL and must return a resolved URL.
+ * @return {LoadingManager} A reference to this loading manager.
+ */
+ this.setURLModifier = function ( transform ) {
+
+ urlModifier = transform;
+
+ return this;
+
+ };
+
+ /**
+ * Registers a loader with the given regular expression. Can be used to
+ * define what loader should be used in order to load specific files. A
+ * typical use case is to overwrite the default loader for textures.
+ *
+ * ```js
+ * // add handler for TGA textures
+ * manager.addHandler( /\.tga$/i, new TGALoader() );
+ * ```
+ *
+ * @param {string} regex - A regular expression.
+ * @param {Loader} loader - A loader that should handle matched cases.
+ * @return {LoadingManager} A reference to this loading manager.
+ */
+ this.addHandler = function ( regex, loader ) {
+
+ handlers.push( regex, loader );
+
+ return this;
+
+ };
+
+ /**
+ * Removes the loader for the given regular expression.
+ *
+ * @param {string} regex - A regular expression.
+ * @return {LoadingManager} A reference to this loading manager.
+ */
+ this.removeHandler = function ( regex ) {
+
+ const index = handlers.indexOf( regex );
+
+ if ( index !== -1 ) {
+
+ handlers.splice( index, 2 );
+
+ }
+
+ return this;
+
+ };
+
+ /**
+ * Can be used to retrieve the registered loader for the given file path.
+ *
+ * @param {string} file - The file path.
+ * @return {?Loader} The registered loader. Returns `null` if no loader was found.
+ */
+ this.getHandler = function ( file ) {
+
+ for ( let i = 0, l = handlers.length; i < l; i += 2 ) {
+
+ const regex = handlers[ i ];
+ const loader = handlers[ i + 1 ];
+
+ if ( regex.global ) regex.lastIndex = 0; // see #17920
+
+ if ( regex.test( file ) ) {
+
+ return loader;
+
+ }
+
+ }
+
+ return null;
+
+ };
+
+ /**
+ * Can be used to abort ongoing loading requests in loaders using this manager.
+ * The abort only works if the loaders implement {@link Loader#abort} and `AbortSignal.any()`
+ * is supported in the browser.
+ *
+ * @return {LoadingManager} A reference to this loading manager.
+ */
+ this.abort = function () {
+
+
+ this.abortController.abort();
+ this._abortController = null;
+
+ return this;
+
+ };
+
+ }
+
+ // TODO: Revert this back to a single member variable once this issue has been fixed
+ // https://github.com/cloudflare/workerd/issues/3657
+
+ /**
+ * Used for aborting ongoing requests in loaders using this manager.
+ *
+ * @type {AbortController}
+ */
+ get abortController() {
+
+ if ( ! this._abortController ) {
+
+ this._abortController = new AbortController();
+
+ }
+
+ return this._abortController;
+
+ }
+
+}
+
+/**
+ * The global default loading manager.
+ *
+ * @constant
+ * @type {LoadingManager}
+ */
+const DefaultLoadingManager = /*@__PURE__*/ new LoadingManager();
+
+/**
+ * Abstract base class for loaders.
+ *
+ * @abstract
+ */
+class Loader {
+
+ /**
+ * Constructs a new loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ /**
+ * The loading manager.
+ *
+ * @type {LoadingManager}
+ * @default DefaultLoadingManager
+ */
+ this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager;
+
+ /**
+ * The crossOrigin string to implement CORS for loading the url from a
+ * different domain that allows CORS.
+ *
+ * @type {string}
+ * @default 'anonymous'
+ */
+ this.crossOrigin = 'anonymous';
+
+ /**
+ * Whether the XMLHttpRequest uses credentials.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.withCredentials = false;
+
+ /**
+ * The base path from which the asset will be loaded.
+ *
+ * @type {string}
+ */
+ this.path = '';
+
+ /**
+ * The base path from which additional resources like textures will be loaded.
+ *
+ * @type {string}
+ */
+ this.resourcePath = '';
+
+ /**
+ * The [request header](https://developer.mozilla.org/en-US/docs/Glossary/Request_header)
+ * used in HTTP request.
+ *
+ * @type {Object}
+ */
+ this.requestHeader = {};
+
+ if ( typeof __THREE_DEVTOOLS__ !== 'undefined' ) {
+
+ __THREE_DEVTOOLS__.dispatchEvent( new CustomEvent( 'observe', { detail: this } ) );
+
+ }
+
+ }
+
+ /**
+ * This method needs to be implemented by all concrete loaders. It holds the
+ * logic for loading assets from the backend.
+ *
+ * @abstract
+ * @param {string} url - The path/URL of the file to be loaded.
+ * @param {Function} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} [onProgress] - Executed while the loading is in progress.
+ * @param {onErrorCallback} [onError] - Executed when errors occur.
+ */
+ load( /* url, onLoad, onProgress, onError */ ) {}
+
+ /**
+ * A async version of {@link Loader#load}.
+ *
+ * @param {string} url - The path/URL of the file to be loaded.
+ * @param {onProgressCallback} [onProgress] - Executed while the loading is in progress.
+ * @return {Promise} A Promise that resolves when the asset has been loaded.
+ */
+ loadAsync( url, onProgress ) {
+
+ const scope = this;
+
+ return new Promise( function ( resolve, reject ) {
+
+ scope.load( url, resolve, onProgress, reject );
+
+ } );
+
+ }
+
+ /**
+ * This method needs to be implemented by all concrete loaders. It holds the
+ * logic for parsing the asset into three.js entities.
+ *
+ * @abstract
+ * @param {any} data - The data to parse.
+ */
+ parse( /* data */ ) {}
+
+ /**
+ * Sets the `crossOrigin` String to implement CORS for loading the URL
+ * from a different domain that allows CORS.
+ *
+ * @param {string} crossOrigin - The `crossOrigin` value.
+ * @return {Loader} A reference to this instance.
+ */
+ setCrossOrigin( crossOrigin ) {
+
+ this.crossOrigin = crossOrigin;
+ return this;
+
+ }
+
+ /**
+ * Whether the XMLHttpRequest uses credentials such as cookies, authorization
+ * headers or TLS client certificates, see [XMLHttpRequest.withCredentials](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials).
+ *
+ * Note: This setting has no effect if you are loading files locally or from the same domain.
+ *
+ * @param {boolean} value - The `withCredentials` value.
+ * @return {Loader} A reference to this instance.
+ */
+ setWithCredentials( value ) {
+
+ this.withCredentials = value;
+ return this;
+
+ }
+
+ /**
+ * Sets the base path for the asset.
+ *
+ * @param {string} path - The base path.
+ * @return {Loader} A reference to this instance.
+ */
+ setPath( path ) {
+
+ this.path = path;
+ return this;
+
+ }
+
+ /**
+ * Sets the base path for dependent resources like textures.
+ *
+ * @param {string} resourcePath - The resource path.
+ * @return {Loader} A reference to this instance.
+ */
+ setResourcePath( resourcePath ) {
+
+ this.resourcePath = resourcePath;
+ return this;
+
+ }
+
+ /**
+ * Sets the given request header.
+ *
+ * @param {Object} requestHeader - A [request header](https://developer.mozilla.org/en-US/docs/Glossary/Request_header)
+ * for configuring the HTTP request.
+ * @return {Loader} A reference to this instance.
+ */
+ setRequestHeader( requestHeader ) {
+
+ this.requestHeader = requestHeader;
+ return this;
+
+ }
+
+ /**
+ * This method can be implemented in loaders for aborting ongoing requests.
+ *
+ * @abstract
+ * @return {Loader} A reference to this instance.
+ */
+ abort() {
+
+ return this;
+
+ }
+
+}
+
+/**
+ * Callback for onProgress in loaders.
+ *
+ * @callback onProgressCallback
+ * @param {ProgressEvent} event - An instance of `ProgressEvent` that represents the current loading status.
+ */
+
+/**
+ * Callback for onError in loaders.
+ *
+ * @callback onErrorCallback
+ * @param {Error} error - The error which occurred during the loading process.
+ */
+
+/**
+ * The default material name that is used by loaders
+ * when creating materials for loaded 3D objects.
+ *
+ * Note: Not all loaders might honor this setting.
+ *
+ * @static
+ * @type {string}
+ * @default '__DEFAULT'
+ */
+Loader.DEFAULT_MATERIAL_NAME = '__DEFAULT';
+
+const loading = {};
+
+class HttpError extends Error {
+
+ constructor( message, response ) {
+
+ super( message );
+ this.response = response;
+
+ }
+
+}
+
+/**
+ * A low level class for loading resources with the Fetch API, used internally by
+ * most loaders. It can also be used directly to load any file type that does
+ * not have a loader.
+ *
+ * This loader supports caching. If you want to use it, add `THREE.Cache.enabled = true;`
+ * once to your application.
+ *
+ * ```js
+ * const loader = new THREE.FileLoader();
+ * const data = await loader.loadAsync( 'example.txt' );
+ * ```
+ *
+ * @augments Loader
+ */
+class FileLoader extends Loader {
+
+ /**
+ * Constructs a new file loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ /**
+ * The expected mime type. Valid values can be found
+ * [here](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString#mimetype)
+ *
+ * @type {string}
+ */
+ this.mimeType = '';
+
+ /**
+ * The expected response type.
+ *
+ * @type {('arraybuffer'|'blob'|'document'|'json'|'')}
+ * @default ''
+ */
+ this.responseType = '';
+
+ /**
+ * Used for aborting requests.
+ *
+ * @private
+ * @type {AbortController}
+ */
+ this._abortController = new AbortController();
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the loaded response to the `onLoad()` callback.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(any)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} [onProgress] - Executed while the loading is in progress.
+ * @param {onErrorCallback} [onError] - Executed when errors occur.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ if ( url === undefined ) url = '';
+
+ if ( this.path !== undefined ) url = this.path + url;
+
+ url = this.manager.resolveURL( url );
+
+ const cached = Cache.get( `file:${url}` );
+
+ if ( cached !== undefined ) {
+
+ this.manager.itemStart( url );
+
+ setTimeout( () => {
+
+ if ( onLoad ) onLoad( cached );
+
+ this.manager.itemEnd( url );
+
+ }, 0 );
+
+ return;
+
+ }
+
+ // Check if request is duplicate
+
+ if ( loading[ url ] !== undefined ) {
+
+ loading[ url ].push( {
+
+ onLoad: onLoad,
+ onProgress: onProgress,
+ onError: onError
+
+ } );
+
+ return;
+
+ }
+
+ // Initialise array for duplicate requests
+ loading[ url ] = [];
+
+ loading[ url ].push( {
+ onLoad: onLoad,
+ onProgress: onProgress,
+ onError: onError,
+ } );
+
+ // create request
+ const req = new Request( url, {
+ headers: new Headers( this.requestHeader ),
+ credentials: this.withCredentials ? 'include' : 'same-origin',
+ signal: ( typeof AbortSignal.any === 'function' ) ? AbortSignal.any( [ this._abortController.signal, this.manager.abortController.signal ] ) : this._abortController.signal
+ } );
+
+ // record states ( avoid data race )
+ const mimeType = this.mimeType;
+ const responseType = this.responseType;
+
+ // start the fetch
+ fetch( req )
+ .then( response => {
+
+ if ( response.status === 200 || response.status === 0 ) {
+
+ // Some browsers return HTTP Status 0 when using non-http protocol
+ // e.g. 'file://' or 'data://'. Handle as success.
+
+ if ( response.status === 0 ) {
+
+ warn( 'FileLoader: HTTP Status 0 received.' );
+
+ }
+
+ // Workaround: Checking if response.body === undefined for Alipay browser #23548
+
+ if ( typeof ReadableStream === 'undefined' || response.body === undefined || response.body.getReader === undefined ) {
+
+ return response;
+
+ }
+
+ const callbacks = loading[ url ];
+ const reader = response.body.getReader();
+
+ // Nginx needs X-File-Size check
+ // https://serverfault.com/questions/482875/why-does-nginx-remove-content-length-header-for-chunked-content
+ const contentLength = response.headers.get( 'X-File-Size' ) || response.headers.get( 'Content-Length' );
+ const total = contentLength ? parseInt( contentLength ) : 0;
+ const lengthComputable = total !== 0;
+ let loaded = 0;
+
+ // periodically read data into the new stream tracking while download progress
+ const stream = new ReadableStream( {
+ start( controller ) {
+
+ readData();
+
+ function readData() {
+
+ reader.read().then( ( { done, value } ) => {
+
+ if ( done ) {
+
+ controller.close();
+
+ } else {
+
+ loaded += value.byteLength;
+
+ const event = new ProgressEvent( 'progress', { lengthComputable, loaded, total } );
+ for ( let i = 0, il = callbacks.length; i < il; i ++ ) {
+
+ const callback = callbacks[ i ];
+ if ( callback.onProgress ) callback.onProgress( event );
+
+ }
+
+ controller.enqueue( value );
+ readData();
+
+ }
+
+ }, ( e ) => {
+
+ controller.error( e );
+
+ } );
+
+ }
+
+ }
+
+ } );
+
+ return new Response( stream );
+
+ } else {
+
+ throw new HttpError( `fetch for "${response.url}" responded with ${response.status}: ${response.statusText}`, response );
+
+ }
+
+ } )
+ .then( response => {
+
+ switch ( responseType ) {
+
+ case 'arraybuffer':
+
+ return response.arrayBuffer();
+
+ case 'blob':
+
+ return response.blob();
+
+ case 'document':
+
+ return response.text()
+ .then( text => {
+
+ const parser = new DOMParser();
+ return parser.parseFromString( text, mimeType );
+
+ } );
+
+ case 'json':
+
+ return response.json();
+
+ default:
+
+ if ( mimeType === '' ) {
+
+ return response.text();
+
+ } else {
+
+ // sniff encoding
+ const re = /charset="?([^;"\s]*)"?/i;
+ const exec = re.exec( mimeType );
+ const label = exec && exec[ 1 ] ? exec[ 1 ].toLowerCase() : undefined;
+ const decoder = new TextDecoder( label );
+ return response.arrayBuffer().then( ab => decoder.decode( ab ) );
+
+ }
+
+ }
+
+ } )
+ .then( data => {
+
+ // Add to cache only on HTTP success, so that we do not cache
+ // error response bodies as proper responses to requests.
+ Cache.add( `file:${url}`, data );
+
+ const callbacks = loading[ url ];
+ delete loading[ url ];
+
+ for ( let i = 0, il = callbacks.length; i < il; i ++ ) {
+
+ const callback = callbacks[ i ];
+ if ( callback.onLoad ) callback.onLoad( data );
+
+ }
+
+ } )
+ .catch( err => {
+
+ // Abort errors and other errors are handled the same
+
+ const callbacks = loading[ url ];
+
+ if ( callbacks === undefined ) {
+
+ // When onLoad was called and url was deleted in `loading`
+ this.manager.itemError( url );
+ throw err;
+
+ }
+
+ delete loading[ url ];
+
+ for ( let i = 0, il = callbacks.length; i < il; i ++ ) {
+
+ const callback = callbacks[ i ];
+ if ( callback.onError ) callback.onError( err );
+
+ }
+
+ this.manager.itemError( url );
+
+ } )
+ .finally( () => {
+
+ this.manager.itemEnd( url );
+
+ } );
+
+ this.manager.itemStart( url );
+
+ }
+
+ /**
+ * Sets the expected response type.
+ *
+ * @param {('arraybuffer'|'blob'|'document'|'json'|'')} value - The response type.
+ * @return {FileLoader} A reference to this file loader.
+ */
+ setResponseType( value ) {
+
+ this.responseType = value;
+ return this;
+
+ }
+
+ /**
+ * Sets the expected mime type of the loaded file.
+ *
+ * @param {string} value - The mime type.
+ * @return {FileLoader} A reference to this file loader.
+ */
+ setMimeType( value ) {
+
+ this.mimeType = value;
+ return this;
+
+ }
+
+ /**
+ * Aborts ongoing fetch requests.
+ *
+ * @return {FileLoader} A reference to this instance.
+ */
+ abort() {
+
+ this._abortController.abort();
+ this._abortController = new AbortController();
+
+ return this;
+
+ }
+
+}
+
+/**
+ * Class for loading animation clips in the JSON format. The files are internally
+ * loaded via {@link FileLoader}.
+ *
+ * ```js
+ * const loader = new THREE.AnimationLoader();
+ * const animations = await loader.loadAsync( 'animations/animation.js' );
+ * ```
+ *
+ * @augments Loader
+ */
+class AnimationLoader extends Loader {
+
+ /**
+ * Constructs a new animation loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the loaded animations as an array
+ * holding instances of {@link AnimationClip} to the `onLoad()` callback.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(Array)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ const loader = new FileLoader( this.manager );
+ loader.setPath( this.path );
+ loader.setRequestHeader( this.requestHeader );
+ loader.setWithCredentials( this.withCredentials );
+ loader.load( url, function ( text ) {
+
+ try {
+
+ onLoad( scope.parse( JSON.parse( text ) ) );
+
+ } catch ( e ) {
+
+ if ( onError ) {
+
+ onError( e );
+
+ } else {
+
+ error( e );
+
+ }
+
+ scope.manager.itemError( url );
+
+ }
+
+ }, onProgress, onError );
+
+ }
+
+ /**
+ * Parses the given JSON object and returns an array of animation clips.
+ *
+ * @param {Object} json - The serialized animation clips.
+ * @return {Array} The parsed animation clips.
+ */
+ parse( json ) {
+
+ const animations = [];
+
+ for ( let i = 0; i < json.length; i ++ ) {
+
+ const clip = AnimationClip.parse( json[ i ] );
+
+ animations.push( clip );
+
+ }
+
+ return animations;
+
+ }
+
+}
+
+/**
+ * Abstract base class for loading compressed texture formats S3TC, ASTC or ETC.
+ * Textures are internally loaded via {@link FileLoader}.
+ *
+ * Derived classes have to implement the `parse()` method which holds the parsing
+ * for the respective format.
+ *
+ * @abstract
+ * @augments Loader
+ */
+class CompressedTextureLoader extends Loader {
+
+ /**
+ * Constructs a new compressed texture loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and passes the loaded compressed texture
+ * to the `onLoad()` callback. The method also returns a new texture object which can
+ * directly be used for material creation. If you do it this way, the texture
+ * may pop up in your scene once the respective loading process is finished.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(CompressedTexture)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ * @return {CompressedTexture} The compressed texture.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ const images = [];
+
+ const texture = new CompressedTexture();
+
+ const loader = new FileLoader( this.manager );
+ loader.setPath( this.path );
+ loader.setResponseType( 'arraybuffer' );
+ loader.setRequestHeader( this.requestHeader );
+ loader.setWithCredentials( scope.withCredentials );
+
+ let loaded = 0;
+
+ function loadTexture( i ) {
+
+ loader.load( url[ i ], function ( buffer ) {
+
+ const texDatas = scope.parse( buffer, true );
+
+ images[ i ] = {
+ width: texDatas.width,
+ height: texDatas.height,
+ format: texDatas.format,
+ mipmaps: texDatas.mipmaps
+ };
+
+ loaded += 1;
+
+ if ( loaded === 6 ) {
+
+ if ( texDatas.mipmapCount === 1 ) texture.minFilter = LinearFilter;
+
+ texture.image = images;
+ texture.format = texDatas.format;
+ texture.needsUpdate = true;
+
+ if ( onLoad ) onLoad( texture );
+
+ }
+
+ }, onProgress, onError );
+
+ }
+
+ if ( Array.isArray( url ) ) {
+
+ for ( let i = 0, il = url.length; i < il; ++ i ) {
+
+ loadTexture( i );
+
+ }
+
+ } else {
+
+ // compressed cubemap texture stored in a single DDS file
+
+ loader.load( url, function ( buffer ) {
+
+ const texDatas = scope.parse( buffer, true );
+
+ if ( texDatas.isCubemap ) {
+
+ const faces = texDatas.mipmaps.length / texDatas.mipmapCount;
+
+ for ( let f = 0; f < faces; f ++ ) {
+
+ images[ f ] = { mipmaps: [] };
+
+ for ( let i = 0; i < texDatas.mipmapCount; i ++ ) {
+
+ images[ f ].mipmaps.push( texDatas.mipmaps[ f * texDatas.mipmapCount + i ] );
+ images[ f ].format = texDatas.format;
+ images[ f ].width = texDatas.width;
+ images[ f ].height = texDatas.height;
+
+ }
+
+ }
+
+ texture.image = images;
+
+ } else {
+
+ texture.image.width = texDatas.width;
+ texture.image.height = texDatas.height;
+ texture.mipmaps = texDatas.mipmaps;
+
+ }
+
+ if ( texDatas.mipmapCount === 1 ) {
+
+ texture.minFilter = LinearFilter;
+
+ }
+
+ texture.format = texDatas.format;
+ texture.needsUpdate = true;
+
+ if ( onLoad ) onLoad( texture );
+
+ }, onProgress, onError );
+
+ }
+
+ return texture;
+
+ }
+
+}
+
+const _loading = new WeakMap();
+
+/**
+ * A loader for loading images. The class loads images with the HTML `Image` API.
+ *
+ * ```js
+ * const loader = new THREE.ImageLoader();
+ * const image = await loader.loadAsync( 'image.png' );
+ * ```
+ * Please note that `ImageLoader` has dropped support for progress
+ * events in `r84`. For an `ImageLoader` that supports progress events, see
+ * [this thread](https://github.com/mrdoob/three.js/issues/10439#issuecomment-275785639).
+ *
+ * @augments Loader
+ */
+class ImageLoader extends Loader {
+
+ /**
+ * Constructs a new image loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and passes the loaded image
+ * to the `onLoad()` callback. The method also returns a new `Image` object which can
+ * directly be used for texture creation. If you do it this way, the texture
+ * may pop up in your scene once the respective loading process is finished.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(Image)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Unsupported in this loader.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ * @return {Image} The image.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ if ( this.path !== undefined ) url = this.path + url;
+
+ url = this.manager.resolveURL( url );
+
+ const scope = this;
+
+ const cached = Cache.get( `image:${url}` );
+
+ if ( cached !== undefined ) {
+
+ if ( cached.complete === true ) {
+
+ scope.manager.itemStart( url );
+
+ setTimeout( function () {
+
+ if ( onLoad ) onLoad( cached );
+
+ scope.manager.itemEnd( url );
+
+ }, 0 );
+
+ } else {
+
+ let arr = _loading.get( cached );
+
+ if ( arr === undefined ) {
+
+ arr = [];
+ _loading.set( cached, arr );
+
+ }
+
+ arr.push( { onLoad, onError } );
+
+ }
+
+ return cached;
+
+ }
+
+ const image = createElementNS( 'img' );
+
+ function onImageLoad() {
+
+ removeEventListeners();
+
+ if ( onLoad ) onLoad( this );
+
+ //
+
+ const callbacks = _loading.get( this ) || [];
+
+ for ( let i = 0; i < callbacks.length; i ++ ) {
+
+ const callback = callbacks[ i ];
+ if ( callback.onLoad ) callback.onLoad( this );
+
+ }
+
+ _loading.delete( this );
+
+ scope.manager.itemEnd( url );
+
+ }
+
+ function onImageError( event ) {
+
+ removeEventListeners();
+
+ if ( onError ) onError( event );
+
+ Cache.remove( `image:${url}` );
+
+ //
+
+ const callbacks = _loading.get( this ) || [];
+
+ for ( let i = 0; i < callbacks.length; i ++ ) {
+
+ const callback = callbacks[ i ];
+ if ( callback.onError ) callback.onError( event );
+
+ }
+
+ _loading.delete( this );
+
+
+ scope.manager.itemError( url );
+ scope.manager.itemEnd( url );
+
+ }
+
+ function removeEventListeners() {
+
+ image.removeEventListener( 'load', onImageLoad, false );
+ image.removeEventListener( 'error', onImageError, false );
+
+ }
+
+ image.addEventListener( 'load', onImageLoad, false );
+ image.addEventListener( 'error', onImageError, false );
+
+ if ( url.slice( 0, 5 ) !== 'data:' ) {
+
+ if ( this.crossOrigin !== undefined ) image.crossOrigin = this.crossOrigin;
+
+ }
+
+ Cache.add( `image:${url}`, image );
+ scope.manager.itemStart( url );
+
+ image.src = url;
+
+ return image;
+
+ }
+
+}
+
+/**
+ * Class for loading cube textures. Images are internally loaded via {@link ImageLoader}.
+ *
+ * The loader returns an instance of {@link CubeTexture} and expects the cube map to
+ * be defined as six separate images representing the sides of a cube. Other cube map definitions
+ * like vertical and horizontal cross, column and row layouts are not supported.
+ *
+ * Note that, by convention, cube maps are specified in a coordinate system
+ * in which positive-x is to the right when looking up the positive-z axis --
+ * in other words, using a left-handed coordinate system. Since three.js uses
+ * a right-handed coordinate system, environment maps used in three.js will
+ * have pos-x and neg-x swapped.
+ *
+ * The loaded cube texture is in sRGB color space. Meaning {@link Texture#colorSpace}
+ * is set to `SRGBColorSpace` by default.
+ *
+ * ```js
+ * const loader = new THREE.CubeTextureLoader().setPath( 'textures/cubeMaps/' );
+ * const cubeTexture = await loader.loadAsync( [
+ * 'px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png'
+ * ] );
+ * scene.background = cubeTexture;
+ * ```
+ *
+ * @augments Loader
+ */
+class CubeTextureLoader extends Loader {
+
+ /**
+ * Constructs a new cube texture loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the fully loaded cube texture
+ * to the `onLoad()` callback. The method also returns a new cube texture object which can
+ * directly be used for material creation. If you do it this way, the cube texture
+ * may pop up in your scene once the respective loading process is finished.
+ *
+ * @param {Array} urls - Array of 6 URLs to images, one for each side of the
+ * cube texture. The urls should be specified in the following order: pos-x,
+ * neg-x, pos-y, neg-y, pos-z, neg-z. An array of data URIs are allowed as well.
+ * @param {function(CubeTexture)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Unsupported in this loader.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ * @return {CubeTexture} The cube texture.
+ */
+ load( urls, onLoad, onProgress, onError ) {
+
+ const texture = new CubeTexture();
+ texture.colorSpace = SRGBColorSpace;
+
+ const loader = new ImageLoader( this.manager );
+ loader.setCrossOrigin( this.crossOrigin );
+ loader.setPath( this.path );
+
+ let loaded = 0;
+
+ function loadTexture( i ) {
+
+ loader.load( urls[ i ], function ( image ) {
+
+ texture.images[ i ] = image;
+
+ loaded ++;
+
+ if ( loaded === 6 ) {
+
+ texture.needsUpdate = true;
+
+ if ( onLoad ) onLoad( texture );
+
+ }
+
+ }, undefined, onError );
+
+ }
+
+ for ( let i = 0; i < urls.length; ++ i ) {
+
+ loadTexture( i );
+
+ }
+
+ return texture;
+
+ }
+
+}
+
+/**
+ * Abstract base class for loading binary texture formats RGBE, EXR or TGA.
+ * Textures are internally loaded via {@link FileLoader}.
+ *
+ * Derived classes have to implement the `parse()` method which holds the parsing
+ * for the respective format.
+ *
+ * @abstract
+ * @augments Loader
+ */
+class DataTextureLoader extends Loader {
+
+ /**
+ * Constructs a new data texture loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and passes the loaded data texture
+ * to the `onLoad()` callback. The method also returns a new texture object which can
+ * directly be used for material creation. If you do it this way, the texture
+ * may pop up in your scene once the respective loading process is finished.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(DataTexture)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ * @return {DataTexture} The data texture.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ const texture = new DataTexture();
+
+ const loader = new FileLoader( this.manager );
+ loader.setResponseType( 'arraybuffer' );
+ loader.setRequestHeader( this.requestHeader );
+ loader.setPath( this.path );
+ loader.setWithCredentials( scope.withCredentials );
+ loader.load( url, function ( buffer ) {
+
+ let texData;
+
+ try {
+
+ texData = scope.parse( buffer );
+
+ } catch ( e ) {
+
+ if ( onError !== undefined ) {
+
+ onError( e );
+
+ } else {
+
+ error( e );
+
+ }
+
+ return;
+
+ }
+
+ scope._applyTexData( texture, texData );
+
+ if ( onLoad ) onLoad( texture, texData );
+
+ }, onProgress, onError );
+
+
+ return texture;
+
+ }
+
+ /**
+ * Parses the given buffer and returns a configured data texture. Use this method
+ * for parsing texture data that is already in memory (e.g. drag and drop or data
+ * loaded from a server) without going through {@link DataTextureLoader#load}.
+ *
+ * @param {ArrayBuffer} buffer - The raw texture data.
+ * @return {DataTexture} The data texture.
+ */
+ createDataTexture( buffer ) {
+
+ const texture = new DataTexture();
+
+ this._applyTexData( texture, this.parse( buffer ) );
+
+ return texture;
+
+ }
+
+ /**
+ * Applies the given parsed texture data to the given data texture.
+ *
+ * @private
+ * @param {DataTexture} texture - The data texture.
+ * @param {DataTextureLoader~TexData} texData - The parsed texture data.
+ */
+ _applyTexData( texture, texData ) {
+
+ if ( texData.image !== undefined ) {
+
+ texture.image = texData.image;
+
+ } else if ( texData.data !== undefined ) {
+
+ texture.image.width = texData.width;
+ texture.image.height = texData.height;
+ texture.image.data = texData.data;
+
+ }
+
+ texture.wrapS = texData.wrapS !== undefined ? texData.wrapS : ClampToEdgeWrapping;
+ texture.wrapT = texData.wrapT !== undefined ? texData.wrapT : ClampToEdgeWrapping;
+
+ texture.magFilter = texData.magFilter !== undefined ? texData.magFilter : LinearFilter;
+ texture.minFilter = texData.minFilter !== undefined ? texData.minFilter : LinearFilter;
+
+ texture.anisotropy = texData.anisotropy !== undefined ? texData.anisotropy : 1;
+
+ if ( texData.colorSpace !== undefined ) {
+
+ texture.colorSpace = texData.colorSpace;
+
+ }
+
+ if ( texData.flipY !== undefined ) {
+
+ texture.flipY = texData.flipY;
+
+ }
+
+ if ( texData.format !== undefined ) {
+
+ texture.format = texData.format;
+
+ }
+
+ if ( texData.type !== undefined ) {
+
+ texture.type = texData.type;
+
+ }
+
+ if ( texData.mipmaps !== undefined ) {
+
+ texture.mipmaps = texData.mipmaps;
+ texture.minFilter = LinearMipmapLinearFilter; // presumably...
+
+ }
+
+ if ( texData.mipmapCount === 1 ) {
+
+ texture.minFilter = LinearFilter;
+
+ }
+
+ if ( texData.generateMipmaps !== undefined ) {
+
+ texture.generateMipmaps = texData.generateMipmaps;
+
+ }
+
+ texture.needsUpdate = true;
+
+ }
+
+}
+
+/**
+ * Class for loading textures. Images are internally
+ * loaded via {@link ImageLoader}.
+ *
+ * ```js
+ * const loader = new THREE.TextureLoader();
+ * const texture = await loader.loadAsync( 'textures/land_ocean_ice_cloud_2048.jpg' );
+ *
+ * const material = new THREE.MeshBasicMaterial( { map:texture } );
+ * ```
+ * Please note that `TextureLoader` has dropped support for progress
+ * events in `r84`. For a `TextureLoader` that supports progress events, see
+ * [this thread](https://github.com/mrdoob/three.js/issues/10439#issuecomment-293260145).
+ *
+ * @augments Loader
+ */
+class TextureLoader extends Loader {
+
+ /**
+ * Constructs a new texture loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the fully loaded texture
+ * to the `onLoad()` callback. The method also returns a new texture object which can
+ * directly be used for material creation. If you do it this way, the texture
+ * may pop up in your scene once the respective loading process is finished.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(Texture)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Unsupported in this loader.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ * @return {Texture} The texture.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const texture = new Texture();
+
+ const loader = new ImageLoader( this.manager );
+ loader.setCrossOrigin( this.crossOrigin );
+ loader.setPath( this.path );
+
+ loader.load( url, function ( image ) {
+
+ texture.image = image;
+ texture.needsUpdate = true;
+
+ if ( onLoad !== undefined ) {
+
+ onLoad( texture );
+
+ }
+
+ }, onProgress, onError );
+
+ return texture;
+
+ }
+
+}
+
+/**
+ * Abstract base class for lights - all other light types inherit the
+ * properties and methods described here.
+ *
+ * @abstract
+ * @augments Object3D
+ */
+class Light extends Object3D {
+
+ /**
+ * Constructs a new light.
+ *
+ * @param {(number|Color|string)} [color=0xffffff] - The light's color.
+ * @param {number} [intensity=1] - The light's strength/intensity.
+ */
+ constructor( color, intensity = 1 ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLight = true;
+
+ this.type = 'Light';
+
+ /**
+ * The light's color.
+ *
+ * @type {Color}
+ */
+ this.color = new Color( color );
+
+ /**
+ * The light's intensity.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.intensity = intensity;
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.color.copy( source.color );
+ this.intensity = source.intensity;
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.color = this.color.getHex();
+ data.object.intensity = this.intensity;
+
+ return data;
+
+ }
+
+}
+
+/**
+ * A light source positioned directly above the scene, with color fading from
+ * the sky color to the ground color.
+ *
+ * This light cannot be used to cast shadows.
+ *
+ * ```js
+ * const light = new THREE.HemisphereLight( 0xffffbb, 0x080820, 1 );
+ * scene.add( light );
+ * ```
+ *
+ * @augments Light
+ */
+class HemisphereLight extends Light {
+
+ /**
+ * Constructs a new hemisphere light.
+ *
+ * @param {(number|Color|string)} [skyColor=0xffffff] - The light's sky color.
+ * @param {(number|Color|string)} [groundColor=0xffffff] - The light's ground color.
+ * @param {number} [intensity=1] - The light's strength/intensity.
+ */
+ constructor( skyColor, groundColor, intensity ) {
+
+ super( skyColor, intensity );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isHemisphereLight = true;
+
+ this.type = 'HemisphereLight';
+
+ this.position.copy( Object3D.DEFAULT_UP );
+ this.updateMatrix();
+
+ /**
+ * The light's ground color.
+ *
+ * @type {Color}
+ */
+ this.groundColor = new Color( groundColor );
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.groundColor.copy( source.groundColor );
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.groundColor = this.groundColor.getHex();
+
+ return data;
+
+ }
+
+}
+
+const _projScreenMatrix = /*@__PURE__*/ new Matrix4();
+const _lightPositionWorld = /*@__PURE__*/ new Vector3();
+const _lookTarget = /*@__PURE__*/ new Vector3();
+
+/**
+ * Abstract base class for light shadow classes. These classes
+ * represent the shadow configuration for different light types.
+ *
+ * @abstract
+ */
+class LightShadow {
+
+ /**
+ * Constructs a new light shadow.
+ *
+ * @param {Camera} camera - The light's view of the world.
+ */
+ constructor( camera ) {
+
+ /**
+ * The light's view of the world.
+ *
+ * @type {Camera}
+ */
+ this.camera = camera;
+
+ /**
+ * The intensity of the shadow. The default is `1`.
+ * Valid values are in the range `[0, 1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.intensity = 1;
+
+ /**
+ * Shadow map bias, how much to add or subtract from the normalized depth
+ * when deciding whether a surface is in shadow.
+ *
+ * The default is `0`. Very tiny adjustments here (in the order of `0.0001`)
+ * may help reduce artifacts in shadows.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.bias = 0;
+
+ /**
+ * A node version of `bias`. Only supported with `WebGPURenderer`.
+ *
+ * If a bias node is defined, `bias` has no effect.
+ *
+ * @type {?Node}
+ * @default null
+ */
+ this.biasNode = null;
+
+ /**
+ * Defines how much the position used to query the shadow map is offset along
+ * the object normal. The default is `0`. Increasing this value can be used to
+ * reduce shadow acne especially in large scenes where light shines onto
+ * geometry at a shallow angle. The cost is that shadows may appear distorted.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.normalBias = 0;
+
+ /**
+ * Setting this to values greater than 1 will blur the edges of the shadow.
+ * High values will cause unwanted banding effects in the shadows - a greater
+ * map size will allow for a higher value to be used here before these effects
+ * become visible.
+ *
+ * The property has no effect when the shadow map type is `BasicShadowMap`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.radius = 1;
+
+ /**
+ * The amount of samples to use when blurring a VSM shadow map.
+ *
+ * @type {number}
+ * @default 8
+ */
+ this.blurSamples = 8;
+
+ /**
+ * Defines the width and height of the shadow map. Higher values give better quality
+ * shadows at the cost of computation time. Values must be powers of two.
+ *
+ * @type {Vector2}
+ * @default (512,512)
+ */
+ this.mapSize = new Vector2( 512, 512 );
+
+ /**
+ * The type of shadow texture. The default is `UnsignedByteType`.
+ *
+ * @type {number}
+ * @default UnsignedByteType
+ */
+ this.mapType = UnsignedByteType;
+
+ /**
+ * The depth map generated using the internal camera; a location beyond a
+ * pixel's depth is in shadow. Computed internally during rendering.
+ *
+ * @type {?RenderTarget}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * The distribution map generated using the internal camera; an occlusion is
+ * calculated based on the distribution of depths. Computed internally during
+ * rendering.
+ *
+ * @type {?RenderTarget}
+ * @default null
+ */
+ this.mapPass = null;
+
+ /**
+ * Model to shadow camera space, to compute location and depth in shadow map.
+ * This is computed internally during rendering.
+ *
+ * @type {Matrix4}
+ */
+ this.matrix = new Matrix4();
+
+ /**
+ * Enables automatic updates of the light's shadow. If you do not require dynamic
+ * lighting / shadows, you may set this to `false`.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.autoUpdate = true;
+
+ /**
+ * When set to `true`, shadow maps will be updated in the next `render` call.
+ * If you have set {@link LightShadow#autoUpdate} to `false`, you will need to
+ * set this property to `true` and then make a render call to update the light's shadow.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.needsUpdate = false;
+
+ this._frustum = new Frustum();
+ this._frameExtents = new Vector2( 1, 1 );
+
+ this._viewportCount = 1;
+
+ this._viewports = [
+
+ new Vector4( 0, 0, 1, 1 )
+
+ ];
+
+ }
+
+ /**
+ * Used internally by the renderer to get the number of viewports that need
+ * to be rendered for this shadow.
+ *
+ * @return {number} The viewport count.
+ */
+ getViewportCount() {
+
+ return this._viewportCount;
+
+ }
+
+ /**
+ * Used internally by the renderer to get the camera that renders the given viewport.
+ *
+ * @param {number} [viewportIndex=0] - The viewport index.
+ * @return {Camera} The shadow camera.
+ */
+ getCamera( /* viewportIndex */ ) {
+
+ return this.camera;
+
+ }
+
+ /**
+ * Gets the shadow cameras frustum. Used internally by the renderer to cull objects.
+ *
+ * @return {Frustum} The shadow camera frustum.
+ */
+ getFrustum() {
+
+ return this._frustum;
+
+ }
+
+ /**
+ * Update the matrices for the camera and shadow, used internally by the renderer.
+ *
+ * @param {Light} light - The light for which the shadow is being rendered.
+ */
+ updateMatrices( light ) {
+
+ const shadowCamera = this.camera;
+ _lightPositionWorld.setFromMatrixPosition( light.matrixWorld );
+ shadowCamera.position.copy( _lightPositionWorld );
+
+ _lookTarget.setFromMatrixPosition( light.target.matrixWorld );
+ shadowCamera.lookAt( _lookTarget );
+ shadowCamera.updateMatrixWorld();
+ this._updateMatrix( shadowCamera, this.matrix, this._frustum );
+
+ }
+
+ /**
+ * Updates a shadow projection matrix and its corresponding frustum.
+ *
+ * @private
+ * @param {Camera} shadowCamera - The shadow camera.
+ * @param {Matrix4} shadowMatrix - The target shadow matrix.
+ * @param {Frustum} frustum - The target frustum.
+ * @param {Vector4} [viewport] - The viewport within the shadow atlas.
+ */
+ _updateMatrix( shadowCamera, shadowMatrix, frustum, viewport ) {
+
+ _projScreenMatrix.multiplyMatrices( shadowCamera.projectionMatrix, shadowCamera.matrixWorldInverse );
+ frustum.setFromProjectionMatrix( _projScreenMatrix, shadowCamera.coordinateSystem, shadowCamera.reversedDepth );
+
+ const frameExtents = this._frameExtents;
+ const scaleX = viewport ? viewport.z / frameExtents.x : 1;
+ const scaleY = viewport ? viewport.w / frameExtents.y : 1;
+ const offsetX = viewport ? viewport.x / frameExtents.x : 0;
+ const offsetY = viewport ? viewport.y / frameExtents.y : 0;
+
+ if ( shadowCamera.coordinateSystem === WebGPUCoordinateSystem || shadowCamera.reversedDepth ) {
+
+ shadowMatrix.set(
+ 0.5 * scaleX, 0.0, 0.0, 0.5 * scaleX + offsetX,
+ 0.0, 0.5 * scaleY, 0.0, 0.5 * scaleY + offsetY,
+ 0.0, 0.0, 1.0, 0.0, // Identity Z (preserving the correct [0, 1] range from the projection matrix)
+ 0.0, 0.0, 0.0, 1.0
+ );
+
+ } else {
+
+ shadowMatrix.set(
+ 0.5 * scaleX, 0.0, 0.0, 0.5 * scaleX + offsetX,
+ 0.0, 0.5 * scaleY, 0.0, 0.5 * scaleY + offsetY,
+ 0.0, 0.0, 0.5, 0.5,
+ 0.0, 0.0, 0.0, 1.0
+ );
+
+ }
+
+ shadowMatrix.multiply( _projScreenMatrix );
+
+ }
+
+ /**
+ * Returns a viewport definition for the given viewport index.
+ *
+ * @param {number} viewportIndex - The viewport index.
+ * @return {Vector4} The viewport.
+ */
+ getViewport( viewportIndex ) {
+
+ return this._viewports[ viewportIndex ];
+
+ }
+
+ /**
+ * Returns the frame extends.
+ *
+ * @return {Vector2} The frame extends.
+ */
+ getFrameExtents() {
+
+ return this._frameExtents;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ */
+ dispose() {
+
+ if ( this.map ) {
+
+ this.map.dispose();
+
+ }
+
+ if ( this.mapPass ) {
+
+ this.mapPass.dispose();
+
+ }
+
+ }
+
+ /**
+ * Copies the values of the given light shadow instance to this instance.
+ *
+ * @param {LightShadow} source - The light shadow to copy.
+ * @return {LightShadow} A reference to this light shadow instance.
+ */
+ copy( source ) {
+
+ this.camera = source.camera.clone();
+
+ this.intensity = source.intensity;
+
+ this.bias = source.bias;
+ this.radius = source.radius;
+
+ this.autoUpdate = source.autoUpdate;
+ this.needsUpdate = source.needsUpdate;
+ this.normalBias = source.normalBias;
+ this.blurSamples = source.blurSamples;
+
+ this.mapSize.copy( source.mapSize );
+
+ this.biasNode = source.biasNode;
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new light shadow instance with copied values from this instance.
+ *
+ * @return {LightShadow} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Serializes the light shadow into JSON.
+ *
+ * @return {Object} A JSON object representing the serialized light shadow.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON() {
+
+ const object = {};
+
+ object.intensity = this.intensity;
+ object.bias = this.bias;
+ object.normalBias = this.normalBias;
+ object.radius = this.radius;
+ object.blurSamples = this.blurSamples;
+ object.mapSize = this.mapSize.toArray();
+
+ object.camera = this.camera.toJSON( false ).object;
+ delete object.camera.matrix;
+
+ return object;
+
+ }
+
+}
+
+const _position$2 = /*@__PURE__*/ new Vector3();
+const _quaternion$2 = /*@__PURE__*/ new Quaternion();
+const _scale$2 = /*@__PURE__*/ new Vector3();
+
+/**
+ * Abstract base class for cameras. This class should always be inherited
+ * when you build a new camera.
+ *
+ * @abstract
+ * @augments Object3D
+ */
+class Camera extends Object3D {
+
+ /**
+ * Constructs a new camera.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isCamera = true;
+
+ this.type = 'Camera';
+
+ /**
+ * The inverse of the camera's world matrix.
+ *
+ * @type {Matrix4}
+ */
+ this.matrixWorldInverse = new Matrix4();
+
+ /**
+ * The camera's projection matrix.
+ *
+ * @type {Matrix4}
+ */
+ this.projectionMatrix = new Matrix4();
+
+ /**
+ * The inverse of the camera's projection matrix.
+ *
+ * @type {Matrix4}
+ */
+ this.projectionMatrixInverse = new Matrix4();
+
+ /**
+ * The coordinate system in which the camera is used.
+ *
+ * @type {(WebGLCoordinateSystem|WebGPUCoordinateSystem)}
+ */
+ this.coordinateSystem = WebGLCoordinateSystem;
+
+ this._reversedDepth = false;
+
+ }
+
+ /**
+ * The flag that indicates whether the camera uses a reversed depth buffer.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ get reversedDepth() {
+
+ return this._reversedDepth;
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.matrixWorldInverse.copy( source.matrixWorldInverse );
+
+ this.projectionMatrix.copy( source.projectionMatrix );
+ this.projectionMatrixInverse.copy( source.projectionMatrixInverse );
+
+ this.coordinateSystem = source.coordinateSystem;
+
+ return this;
+
+ }
+
+ /**
+ * Returns a vector representing the ("look") direction of the 3D object in world space.
+ *
+ * This method is overwritten since cameras have a different forward vector compared to other
+ * 3D objects. A camera looks down its local, negative z-axis by default.
+ *
+ * @param {Vector3} target - The target vector the result is stored to.
+ * @return {Vector3} The 3D object's direction in world space.
+ */
+ getWorldDirection( target ) {
+
+ return super.getWorldDirection( target ).negate();
+
+ }
+
+ updateMatrixWorld( force ) {
+
+ super.updateMatrixWorld( force );
+
+ // exclude scale from view matrix to be glTF conform
+
+ this.matrixWorld.decompose( _position$2, _quaternion$2, _scale$2 );
+
+ if ( _scale$2.x === 1 && _scale$2.y === 1 && _scale$2.z === 1 ) {
+
+ this.matrixWorldInverse.copy( this.matrixWorld ).invert();
+
+ } else {
+
+ this.matrixWorldInverse.compose( _position$2, _quaternion$2, _scale$2.set( 1, 1, 1 ) ).invert();
+
+ }
+
+ }
+
+ updateWorldMatrix( updateParents, updateChildren, force = false ) {
+
+ super.updateWorldMatrix( updateParents, updateChildren, force );
+
+ // exclude scale from view matrix to be glTF conform
+
+ this.matrixWorld.decompose( _position$2, _quaternion$2, _scale$2 );
+
+ if ( _scale$2.x === 1 && _scale$2.y === 1 && _scale$2.z === 1 ) {
+
+ this.matrixWorldInverse.copy( this.matrixWorld ).invert();
+
+ } else {
+
+ this.matrixWorldInverse.compose( _position$2, _quaternion$2, _scale$2.set( 1, 1, 1 ) ).invert();
+
+ }
+
+ }
+
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+}
+
+const _v3$1 = /*@__PURE__*/ new Vector3();
+const _minTarget = /*@__PURE__*/ new Vector2();
+const _maxTarget = /*@__PURE__*/ new Vector2();
+
+/**
+ * Camera that uses [perspective projection](https://en.wikipedia.org/wiki/Perspective_(graphical)).
+ *
+ * This projection mode is designed to mimic the way the human eye sees. It
+ * is the most common projection mode used for rendering a 3D scene.
+ *
+ * ```js
+ * const camera = new THREE.PerspectiveCamera( 45, width / height, 1, 1000 );
+ * scene.add( camera );
+ * ```
+ *
+ * @augments Camera
+ */
+class PerspectiveCamera extends Camera {
+
+ /**
+ * Constructs a new perspective camera.
+ *
+ * @param {number} [fov=50] - The vertical field of view.
+ * @param {number} [aspect=1] - The aspect ratio.
+ * @param {number} [near=0.1] - The camera's near plane.
+ * @param {number} [far=2000] - The camera's far plane.
+ */
+ constructor( fov = 50, aspect = 1, near = 0.1, far = 2000 ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isPerspectiveCamera = true;
+
+ this.type = 'PerspectiveCamera';
+
+ /**
+ * The vertical field of view, from bottom to top of view,
+ * in degrees.
+ *
+ * @type {number}
+ * @default 50
+ */
+ this.fov = fov;
+
+ /**
+ * The zoom factor of the camera.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.zoom = 1;
+
+ /**
+ * The camera's near plane. The valid range is greater than `0`
+ * and less than the current value of {@link PerspectiveCamera#far}.
+ *
+ * Note that, unlike for the {@link OrthographicCamera}, `0` is not a
+ * valid value for a perspective camera's near plane.
+ *
+ * @type {number}
+ * @default 0.1
+ */
+ this.near = near;
+
+ /**
+ * The camera's far plane. Must be greater than the
+ * current value of {@link PerspectiveCamera#near}.
+ *
+ * @type {number}
+ * @default 2000
+ */
+ this.far = far;
+
+ /**
+ * Object distance used for stereoscopy and depth-of-field effects. This
+ * parameter does not influence the projection matrix unless a
+ * {@link StereoCamera} is being used.
+ *
+ * @type {number}
+ * @default 10
+ */
+ this.focus = 10;
+
+ /**
+ * The aspect ratio, usually the canvas width / canvas height.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aspect = aspect;
+
+ /**
+ * Represents the frustum window specification. This property should not be edited
+ * directly but via {@link PerspectiveCamera#setViewOffset} and {@link PerspectiveCamera#clearViewOffset}.
+ *
+ * @type {?Object}
+ * @default null
+ */
+ this.view = null;
+
+ /**
+ * Film size used for the larger axis. Default is `35` (millimeters). This
+ * parameter does not influence the projection matrix unless {@link PerspectiveCamera#filmOffset}
+ * is set to a nonzero value.
+ *
+ * @type {number}
+ * @default 35
+ */
+ this.filmGauge = 35;
+
+ /**
+ * Horizontal off-center offset in the same unit as {@link PerspectiveCamera#filmGauge}.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.filmOffset = 0;
+
+ this.updateProjectionMatrix();
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.fov = source.fov;
+ this.zoom = source.zoom;
+
+ this.near = source.near;
+ this.far = source.far;
+ this.focus = source.focus;
+
+ this.aspect = source.aspect;
+ this.view = source.view === null ? null : Object.assign( {}, source.view );
+
+ this.filmGauge = source.filmGauge;
+ this.filmOffset = source.filmOffset;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the FOV by focal length in respect to the current {@link PerspectiveCamera#filmGauge}.
+ *
+ * The default film gauge is 35, so that the focal length can be specified for
+ * a 35mm (full frame) camera.
+ *
+ * @param {number} focalLength - Values for focal length and film gauge must have the same unit.
+ */
+ setFocalLength( focalLength ) {
+
+ /** see {@link http://www.bobatkins.com/photography/technical/field_of_view.html} */
+ const vExtentSlope = 0.5 * this.getFilmHeight() / focalLength;
+
+ this.fov = RAD2DEG * 2 * Math.atan( vExtentSlope );
+ this.updateProjectionMatrix();
+
+ }
+
+ /**
+ * Returns the focal length from the current {@link PerspectiveCamera#fov} and
+ * {@link PerspectiveCamera#filmGauge}.
+ *
+ * @return {number} The computed focal length.
+ */
+ getFocalLength() {
+
+ const vExtentSlope = Math.tan( DEG2RAD * 0.5 * this.fov );
+
+ return 0.5 * this.getFilmHeight() / vExtentSlope;
+
+ }
+
+ /**
+ * Returns the current vertical field of view angle in degrees considering {@link PerspectiveCamera#zoom}.
+ *
+ * @return {number} The effective FOV.
+ */
+ getEffectiveFOV() {
+
+ return RAD2DEG * 2 * Math.atan(
+ Math.tan( DEG2RAD * 0.5 * this.fov ) / this.zoom );
+
+ }
+
+ /**
+ * Returns the width of the image on the film. If {@link PerspectiveCamera#aspect} is greater than or
+ * equal to one (landscape format), the result equals {@link PerspectiveCamera#filmGauge}.
+ *
+ * @return {number} The film width.
+ */
+ getFilmWidth() {
+
+ // film not completely covered in portrait format (aspect < 1)
+ return this.filmGauge * Math.min( this.aspect, 1 );
+
+ }
+
+ /**
+ * Returns the height of the image on the film. If {@link PerspectiveCamera#aspect} is greater than or
+ * equal to one (landscape format), the result equals {@link PerspectiveCamera#filmGauge}.
+ *
+ * @return {number} The film width.
+ */
+ getFilmHeight() {
+
+ // film not completely covered in landscape format (aspect > 1)
+ return this.filmGauge / Math.max( this.aspect, 1 );
+
+ }
+
+ /**
+ * Computes the 2D bounds of the camera's viewable rectangle at a given distance along the viewing direction.
+ * Sets `minTarget` and `maxTarget` to the coordinates of the lower-left and upper-right corners of the view rectangle.
+ *
+ * @param {number} distance - The viewing distance.
+ * @param {Vector2} minTarget - The lower-left corner of the view rectangle is written into this vector.
+ * @param {Vector2} maxTarget - The upper-right corner of the view rectangle is written into this vector.
+ */
+ getViewBounds( distance, minTarget, maxTarget ) {
+
+ _v3$1.set( -1, -1, 0.5 ).applyMatrix4( this.projectionMatrixInverse );
+
+ minTarget.set( _v3$1.x, _v3$1.y ).multiplyScalar( - distance / _v3$1.z );
+
+ _v3$1.set( 1, 1, 0.5 ).applyMatrix4( this.projectionMatrixInverse );
+
+ maxTarget.set( _v3$1.x, _v3$1.y ).multiplyScalar( - distance / _v3$1.z );
+
+ }
+
+ /**
+ * Computes the width and height of the camera's viewable rectangle at a given distance along the viewing direction.
+ *
+ * @param {number} distance - The viewing distance.
+ * @param {Vector2} target - The target vector that is used to store result where x is width and y is height.
+ * @returns {Vector2} The view size.
+ */
+ getViewSize( distance, target ) {
+
+ this.getViewBounds( distance, _minTarget, _maxTarget );
+
+ return target.subVectors( _maxTarget, _minTarget );
+
+ }
+
+ /**
+ * Sets an offset in a larger frustum. This is useful for multi-window or
+ * multi-monitor/multi-machine setups.
+ *
+ * For example, if you have 3x2 monitors and each monitor is 1920x1080 and
+ * the monitors are in grid like this
+ *```
+ * +---+---+---+
+ * | A | B | C |
+ * +---+---+---+
+ * | D | E | F |
+ * +---+---+---+
+ *```
+ * then for each monitor you would call it like this:
+ *```js
+ * const w = 1920;
+ * const h = 1080;
+ * const fullWidth = w * 3;
+ * const fullHeight = h * 2;
+ *
+ * // --A--
+ * camera.setViewOffset( fullWidth, fullHeight, w * 0, h * 0, w, h );
+ * // --B--
+ * camera.setViewOffset( fullWidth, fullHeight, w * 1, h * 0, w, h );
+ * // --C--
+ * camera.setViewOffset( fullWidth, fullHeight, w * 2, h * 0, w, h );
+ * // --D--
+ * camera.setViewOffset( fullWidth, fullHeight, w * 0, h * 1, w, h );
+ * // --E--
+ * camera.setViewOffset( fullWidth, fullHeight, w * 1, h * 1, w, h );
+ * // --F--
+ * camera.setViewOffset( fullWidth, fullHeight, w * 2, h * 1, w, h );
+ * ```
+ *
+ * Note there is no reason monitors have to be the same size or in a grid.
+ *
+ * @param {number} fullWidth - The full width of multiview setup.
+ * @param {number} fullHeight - The full height of multiview setup.
+ * @param {number} x - The horizontal offset of the subcamera.
+ * @param {number} y - The vertical offset of the subcamera.
+ * @param {number} width - The width of subcamera.
+ * @param {number} height - The height of subcamera.
+ */
+ setViewOffset( fullWidth, fullHeight, x, y, width, height ) {
+
+ this.aspect = fullWidth / fullHeight;
+
+ if ( this.view === null ) {
+
+ this.view = {
+ enabled: true,
+ fullWidth: 1,
+ fullHeight: 1,
+ offsetX: 0,
+ offsetY: 0,
+ width: 1,
+ height: 1
+ };
+
+ }
+
+ this.view.enabled = true;
+ this.view.fullWidth = fullWidth;
+ this.view.fullHeight = fullHeight;
+ this.view.offsetX = x;
+ this.view.offsetY = y;
+ this.view.width = width;
+ this.view.height = height;
+
+ this.updateProjectionMatrix();
+
+ }
+
+ /**
+ * Removes the view offset from the projection matrix.
+ */
+ clearViewOffset() {
+
+ if ( this.view !== null ) {
+
+ this.view.enabled = false;
+
+ }
+
+ this.updateProjectionMatrix();
+
+ }
+
+ /**
+ * Updates the camera's projection matrix. Must be called after any change of
+ * camera properties.
+ */
+ updateProjectionMatrix() {
+
+ const near = this.near;
+ let top = near * Math.tan( DEG2RAD * 0.5 * this.fov ) / this.zoom;
+ let height = 2 * top;
+ let width = this.aspect * height;
+ let left = -0.5 * width;
+ const view = this.view;
+
+ if ( this.view !== null && this.view.enabled ) {
+
+ const fullWidth = view.fullWidth,
+ fullHeight = view.fullHeight;
+
+ left += view.offsetX * width / fullWidth;
+ top -= view.offsetY * height / fullHeight;
+ width *= view.width / fullWidth;
+ height *= view.height / fullHeight;
+
+ }
+
+ const skew = this.filmOffset;
+ if ( skew !== 0 ) left += near * skew / this.getFilmWidth();
+
+ this.projectionMatrix.makePerspective( left, left + width, top, top - height, near, this.far, this.coordinateSystem, this.reversedDepth );
+
+ this.projectionMatrixInverse.copy( this.projectionMatrix ).invert();
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.fov = this.fov;
+ data.object.zoom = this.zoom;
+
+ data.object.near = this.near;
+ data.object.far = this.far;
+ data.object.focus = this.focus;
+
+ data.object.aspect = this.aspect;
+
+ if ( this.view !== null ) data.object.view = Object.assign( {}, this.view );
+
+ data.object.filmGauge = this.filmGauge;
+ data.object.filmOffset = this.filmOffset;
+
+ return data;
+
+ }
+
+}
+
+/**
+ * Represents the shadow configuration of directional lights.
+ *
+ * @augments LightShadow
+ */
+class SpotLightShadow extends LightShadow {
+
+ /**
+ * Constructs a new spot light shadow.
+ */
+ constructor() {
+
+ super( new PerspectiveCamera( 50, 1, 0.5, 500 ) );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSpotLightShadow = true;
+
+ /**
+ * Used to focus the shadow camera. The camera's field of view is set as a
+ * percentage of the spotlight's field-of-view. Range is `[0, 1]`.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.focus = 1;
+
+ /**
+ * Texture aspect ratio.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aspect = 1;
+
+ }
+
+ updateMatrices( light ) {
+
+ const camera = this.camera;
+
+ const fov = RAD2DEG * 2 * light.angle * this.focus;
+ const aspect = ( this.mapSize.width / this.mapSize.height ) * this.aspect;
+ const far = light.distance || camera.far;
+
+ if ( fov !== camera.fov || aspect !== camera.aspect || far !== camera.far ) {
+
+ camera.fov = fov;
+ camera.aspect = aspect;
+ camera.far = far;
+ camera.updateProjectionMatrix();
+
+ }
+
+ super.updateMatrices( light );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.focus = source.focus;
+ this.aspect = source.aspect;
+
+ return this;
+
+ }
+
+ /**
+ * Serializes the light shadow into JSON.
+ *
+ * @return {Object} A JSON object representing the serialized light shadow.
+ * @see {@link ObjectLoader#parse}
+ */
+ toJSON() {
+
+ const object = super.toJSON();
+
+ object.focus = this.focus;
+ object.aspect = this.aspect;
+
+ return object;
+
+ }
+
+}
+
+/**
+ * This light gets emitted from a single point in one direction, along a cone
+ * that increases in size the further from the light it gets.
+ *
+ * This light can cast shadows - see the {@link SpotLightShadow} for details.
+ *
+ * ```js
+ * // white spotlight shining from the side, modulated by a texture
+ * const spotLight = new THREE.SpotLight( 0xffffff );
+ * spotLight.position.set( 100, 1000, 100 );
+ * spotLight.map = new THREE.TextureLoader().load( url );
+ *
+ * spotLight.castShadow = true;
+ * spotLight.shadow.mapSize.width = 1024;
+ * spotLight.shadow.mapSize.height = 1024;
+ * spotLight.shadow.camera.near = 500;
+ * spotLight.shadow.camera.far = 4000;
+ * spotLight.shadow.camera.fov = 30;s
+ * ```
+ *
+ * @augments Light
+ */
+class SpotLight extends Light {
+
+ /**
+ * Constructs a new spot light.
+ *
+ * @param {(number|Color|string)} [color=0xffffff] - The light's color.
+ * @param {number} [intensity=1] - The light's strength/intensity measured in candela (cd).
+ * @param {number} [distance=0] - Maximum range of the light. `0` means no limit.
+ * @param {number} [angle=Math.PI/3] - Maximum angle of light dispersion from its direction whose upper bound is `Math.PI/2`.
+ * @param {number} [penumbra=0] - Percent of the spotlight cone that is attenuated due to penumbra. Value range is `[0,1]`.
+ * @param {number} [decay=2] - The amount the light dims along the distance of the light.
+ */
+ constructor( color, intensity, distance = 0, angle = Math.PI / 3, penumbra = 0, decay = 2 ) {
+
+ super( color, intensity );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSpotLight = true;
+
+ this.type = 'SpotLight';
+
+ this.position.copy( Object3D.DEFAULT_UP );
+ this.updateMatrix();
+
+ /**
+ * The spot light points from its position to the
+ * target's position.
+ *
+ * For the target's position to be changed to anything other
+ * than the default, it must be added to the scene.
+ *
+ * It is also possible to set the target to be another 3D object
+ * in the scene. The light will now track the target object.
+ *
+ * @type {Object3D}
+ */
+ this.target = new Object3D();
+
+ /**
+ * Maximum range of the light. `0` means no limit.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.distance = distance;
+
+ /**
+ * Maximum angle of light dispersion from its direction whose upper bound is `Math.PI/2`.
+ *
+ * @type {number}
+ * @default Math.PI/3
+ */
+ this.angle = angle;
+
+ /**
+ * Percent of the spotlight cone that is attenuated due to penumbra.
+ * Value range is `[0,1]`.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.penumbra = penumbra;
+
+ /**
+ * The amount the light dims along the distance of the light. In context of
+ * physically-correct rendering the default value should not be changed.
+ *
+ * @type {number}
+ * @default 2
+ */
+ this.decay = decay;
+
+ /**
+ * A texture used to modulate the color of the light. The spot light
+ * color is mixed with the RGB value of this texture, with a ratio
+ * corresponding to its alpha value. The cookie-like masking effect is
+ * reproduced using pixel values (0, 0, 0, 1-cookie_value).
+ *
+ * *Warning*: This property is disabled if {@link Object3D#castShadow} is set to `false`.
+ *
+ * @type {?Texture}
+ * @default null
+ */
+ this.map = null;
+
+ /**
+ * This property holds the light's shadow configuration.
+ *
+ * @type {SpotLightShadow}
+ */
+ this.shadow = new SpotLightShadow();
+
+ }
+
+ /**
+ * The light's power. Power is the luminous power of the light measured in lumens (lm).
+ * Changing the power will also change the light's intensity.
+ *
+ * @type {number}
+ */
+ get power() {
+
+ // compute the light's luminous power (in lumens) from its intensity (in candela)
+ // by convention for a spotlight, luminous power (lm) = π * luminous intensity (cd)
+ return this.intensity * Math.PI;
+
+ }
+
+ set power( power ) {
+
+ // set the light's intensity (in candela) from the desired luminous power (in lumens)
+ this.intensity = power / Math.PI;
+
+ }
+
+ dispose() {
+
+ super.dispose();
+
+ this.shadow.dispose();
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.distance = source.distance;
+ this.angle = source.angle;
+ this.penumbra = source.penumbra;
+ this.decay = source.decay;
+
+ this.target = source.target.clone();
+ this.map = source.map;
+ this.shadow = source.shadow.clone();
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.distance = this.distance;
+ data.object.angle = this.angle;
+ data.object.decay = this.decay;
+ data.object.penumbra = this.penumbra;
+
+ data.object.target = this.target.uuid;
+
+ if ( this.map && this.map.isTexture ) data.object.map = this.map.toJSON( meta ).uuid;
+
+ data.object.shadow = this.shadow.toJSON();
+
+ return data;
+
+ }
+
+}
+
+/**
+ * Represents the shadow configuration of point lights.
+ *
+ * @augments LightShadow
+ */
+class PointLightShadow extends LightShadow {
+
+ /**
+ * Constructs a new point light shadow.
+ */
+ constructor() {
+
+ super( new PerspectiveCamera( 90, 1, 0.5, 500 ) );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isPointLightShadow = true;
+
+ }
+
+}
+
+/**
+ * A light that gets emitted from a single point in all directions. A common
+ * use case for this is to replicate the light emitted from a bare
+ * lightbulb.
+ *
+ * This light can cast shadows - see the {@link PointLightShadow} for details.
+ *
+ * ```js
+ * const light = new THREE.PointLight( 0xff0000, 1, 100 );
+ * light.position.set( 50, 50, 50 );
+ * scene.add( light );
+ * ```
+ *
+ * @augments Light
+ */
+class PointLight extends Light {
+
+ /**
+ * Constructs a new point light.
+ *
+ * @param {(number|Color|string)} [color=0xffffff] - The light's color.
+ * @param {number} [intensity=1] - The light's strength/intensity measured in candela (cd).
+ * @param {number} [distance=0] - Maximum range of the light. `0` means no limit.
+ * @param {number} [decay=2] - The amount the light dims along the distance of the light.
+ */
+ constructor( color, intensity, distance = 0, decay = 2 ) {
+
+ super( color, intensity );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isPointLight = true;
+
+ this.type = 'PointLight';
+
+ /**
+ * When distance is zero, light will attenuate according to inverse-square
+ * law to infinite distance. When distance is non-zero, light will attenuate
+ * according to inverse-square law until near the distance cutoff, where it
+ * will then attenuate quickly and smoothly to 0. Inherently, cutoffs are not
+ * physically correct.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.distance = distance;
+
+ /**
+ * The amount the light dims along the distance of the light. In context of
+ * physically-correct rendering the default value should not be changed.
+ *
+ * @type {number}
+ * @default 2
+ */
+ this.decay = decay;
+
+ /**
+ * This property holds the light's shadow configuration.
+ *
+ * @type {PointLightShadow}
+ */
+ this.shadow = new PointLightShadow();
+
+ }
+
+ /**
+ * The light's power. Power is the luminous power of the light measured in lumens (lm).
+ * Changing the power will also change the light's intensity.
+ *
+ * @type {number}
+ */
+ get power() {
+
+ // compute the light's luminous power (in lumens) from its intensity (in candela)
+ // for an isotropic light source, luminous power (lm) = 4 π luminous intensity (cd)
+ return this.intensity * 4 * Math.PI;
+
+ }
+
+ set power( power ) {
+
+ // set the light's intensity (in candela) from the desired luminous power (in lumens)
+ this.intensity = power / ( 4 * Math.PI );
+
+ }
+
+ dispose() {
+
+ super.dispose();
+
+ this.shadow.dispose();
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.distance = source.distance;
+ this.decay = source.decay;
+
+ this.shadow = source.shadow.clone();
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.distance = this.distance;
+ data.object.decay = this.decay;
+
+ data.object.shadow = this.shadow.toJSON();
+
+ return data;
+
+ }
+
+}
+
+/**
+ * Camera that uses [orthographic projection](https://en.wikipedia.org/wiki/Orthographic_projection).
+ *
+ * In this projection mode, an object's size in the rendered image stays
+ * constant regardless of its distance from the camera. This can be useful
+ * for rendering 2D scenes and UI elements, amongst other things.
+ *
+ * ```js
+ * const camera = new THREE.OrthographicCamera( width / - 2, width / 2, height / 2, height / - 2, 1, 1000 );
+ * scene.add( camera );
+ * ```
+ *
+ * @augments Camera
+ */
+class OrthographicCamera extends Camera {
+
+ /**
+ * Constructs a new orthographic camera.
+ *
+ * @param {number} [left=-1] - The left plane of the camera's frustum.
+ * @param {number} [right=1] - The right plane of the camera's frustum.
+ * @param {number} [top=1] - The top plane of the camera's frustum.
+ * @param {number} [bottom=-1] - The bottom plane of the camera's frustum.
+ * @param {number} [near=0.1] - The camera's near plane.
+ * @param {number} [far=2000] - The camera's far plane.
+ */
+ constructor( left = -1, right = 1, top = 1, bottom = -1, near = 0.1, far = 2000 ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isOrthographicCamera = true;
+
+ this.type = 'OrthographicCamera';
+
+ /**
+ * The zoom factor of the camera.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.zoom = 1;
+
+ /**
+ * Represents the frustum window specification. This property should not be edited
+ * directly but via {@link PerspectiveCamera#setViewOffset} and {@link PerspectiveCamera#clearViewOffset}.
+ *
+ * @type {?Object}
+ * @default null
+ */
+ this.view = null;
+
+ /**
+ * The left plane of the camera's frustum.
+ *
+ * @type {number}
+ * @default -1
+ */
+ this.left = left;
+
+ /**
+ * The right plane of the camera's frustum.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.right = right;
+
+ /**
+ * The top plane of the camera's frustum.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.top = top;
+
+ /**
+ * The bottom plane of the camera's frustum.
+ *
+ * @type {number}
+ * @default -1
+ */
+ this.bottom = bottom;
+
+ /**
+ * The camera's near plane. The valid range is greater than `0`
+ * and less than the current value of {@link OrthographicCamera#far}.
+ *
+ * Note that, unlike for the {@link PerspectiveCamera}, `0` is a
+ * valid value for an orthographic camera's near plane.
+ *
+ * @type {number}
+ * @default 0.1
+ */
+ this.near = near;
+
+ /**
+ * The camera's far plane. Must be greater than the
+ * current value of {@link OrthographicCamera#near}.
+ *
+ * @type {number}
+ * @default 2000
+ */
+ this.far = far;
+
+ this.updateProjectionMatrix();
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ this.left = source.left;
+ this.right = source.right;
+ this.top = source.top;
+ this.bottom = source.bottom;
+ this.near = source.near;
+ this.far = source.far;
+
+ this.zoom = source.zoom;
+ this.view = source.view === null ? null : Object.assign( {}, source.view );
+
+ return this;
+
+ }
+
+ /**
+ * Sets an offset in a larger frustum. This is useful for multi-window or
+ * multi-monitor/multi-machine setups.
+ *
+ * @param {number} fullWidth - The full width of multiview setup.
+ * @param {number} fullHeight - The full height of multiview setup.
+ * @param {number} x - The horizontal offset of the subcamera.
+ * @param {number} y - The vertical offset of the subcamera.
+ * @param {number} width - The width of subcamera.
+ * @param {number} height - The height of subcamera.
+ * @see {@link PerspectiveCamera#setViewOffset}
+ */
+ setViewOffset( fullWidth, fullHeight, x, y, width, height ) {
+
+ if ( this.view === null ) {
+
+ this.view = {
+ enabled: true,
+ fullWidth: 1,
+ fullHeight: 1,
+ offsetX: 0,
+ offsetY: 0,
+ width: 1,
+ height: 1
+ };
+
+ }
+
+ this.view.enabled = true;
+ this.view.fullWidth = fullWidth;
+ this.view.fullHeight = fullHeight;
+ this.view.offsetX = x;
+ this.view.offsetY = y;
+ this.view.width = width;
+ this.view.height = height;
+
+ this.updateProjectionMatrix();
+
+ }
+
+ /**
+ * Removes the view offset from the projection matrix.
+ */
+ clearViewOffset() {
+
+ if ( this.view !== null ) {
+
+ this.view.enabled = false;
+
+ }
+
+ this.updateProjectionMatrix();
+
+ }
+
+ /**
+ * Updates the camera's projection matrix. Must be called after any change of
+ * camera properties.
+ */
+ updateProjectionMatrix() {
+
+ const dx = ( this.right - this.left ) / ( 2 * this.zoom );
+ const dy = ( this.top - this.bottom ) / ( 2 * this.zoom );
+ const cx = ( this.right + this.left ) / 2;
+ const cy = ( this.top + this.bottom ) / 2;
+
+ let left = cx - dx;
+ let right = cx + dx;
+ let top = cy + dy;
+ let bottom = cy - dy;
+
+ if ( this.view !== null && this.view.enabled ) {
+
+ const scaleW = ( this.right - this.left ) / this.view.fullWidth / this.zoom;
+ const scaleH = ( this.top - this.bottom ) / this.view.fullHeight / this.zoom;
+
+ left += scaleW * this.view.offsetX;
+ right = left + scaleW * this.view.width;
+ top -= scaleH * this.view.offsetY;
+ bottom = top - scaleH * this.view.height;
+
+ }
+
+ this.projectionMatrix.makeOrthographic( left, right, top, bottom, this.near, this.far, this.coordinateSystem, this.reversedDepth );
+
+ this.projectionMatrixInverse.copy( this.projectionMatrix ).invert();
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.zoom = this.zoom;
+ data.object.left = this.left;
+ data.object.right = this.right;
+ data.object.top = this.top;
+ data.object.bottom = this.bottom;
+ data.object.near = this.near;
+ data.object.far = this.far;
+
+ if ( this.view !== null ) data.object.view = Object.assign( {}, this.view );
+
+ return data;
+
+ }
+
+}
+
+/**
+ * Represents the shadow configuration of directional lights.
+ *
+ * @augments LightShadow
+ */
+class DirectionalLightShadow extends LightShadow {
+
+ /**
+ * Constructs a new directional light shadow.
+ */
+ constructor() {
+
+ super( new OrthographicCamera( -5, 5, 5, -5, 0.5, 500 ) );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isDirectionalLightShadow = true;
+
+ }
+
+}
+
+/**
+ * A light that gets emitted in a specific direction. This light will behave
+ * as though it is infinitely far away and the rays produced from it are all
+ * parallel. The common use case for this is to simulate daylight; the sun is
+ * far enough away that its position can be considered to be infinite, and
+ * all light rays coming from it are parallel.
+ *
+ * A common point of confusion for directional lights is that setting the
+ * rotation has no effect. This is because three.js's DirectionalLight is the
+ * equivalent to what is often called a 'Target Direct Light' in other
+ * applications.
+ *
+ * This means that its direction is calculated as pointing from the light's
+ * {@link Object3D#position} to the {@link DirectionalLight#target} position
+ * (as opposed to a 'Free Direct Light' that just has a rotation
+ * component).
+ *
+ * This light can cast shadows - see the {@link DirectionalLightShadow} for details.
+ *
+ * ```js
+ * // White directional light at half intensity shining from the top.
+ * const directionalLight = new THREE.DirectionalLight( 0xffffff, 0.5 );
+ * scene.add( directionalLight );
+ * ```
+ *
+ * @augments Light
+ */
+class DirectionalLight extends Light {
+
+ /**
+ * Constructs a new directional light.
+ *
+ * @param {(number|Color|string)} [color=0xffffff] - The light's color.
+ * @param {number} [intensity=1] - The light's strength/intensity.
+ */
+ constructor( color, intensity ) {
+
+ super( color, intensity );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isDirectionalLight = true;
+
+ this.type = 'DirectionalLight';
+
+ this.position.copy( Object3D.DEFAULT_UP );
+ this.updateMatrix();
+
+ /**
+ * The directional light points from its position to the
+ * target's position.
+ *
+ * For the target's position to be changed to anything other
+ * than the default, it must be added to the scene.
+ *
+ * It is also possible to set the target to be another 3D object
+ * in the scene. The light will now track the target object.
+ *
+ * @type {Object3D}
+ */
+ this.target = new Object3D();
+
+ /**
+ * This property holds the light's shadow configuration.
+ *
+ * @type {DirectionalLightShadow}
+ */
+ this.shadow = new DirectionalLightShadow();
+
+ }
+
+ dispose() {
+
+ super.dispose();
+
+ this.shadow.dispose();
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.target = source.target.clone();
+ this.shadow = source.shadow.clone();
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.shadow = this.shadow.toJSON();
+ data.object.target = this.target.uuid;
+
+ return data;
+
+ }
+
+}
+
+/**
+ * This light globally illuminates all objects in the scene equally.
+ *
+ * It cannot be used to cast shadows as it does not have a direction.
+ *
+ * ```js
+ * const light = new THREE.AmbientLight( 0x404040 ); // soft white light
+ * scene.add( light );
+ * ```
+ *
+ * @augments Light
+ */
+class AmbientLight extends Light {
+
+ /**
+ * Constructs a new ambient light.
+ *
+ * @param {(number|Color|string)} [color=0xffffff] - The light's color.
+ * @param {number} [intensity=1] - The light's strength/intensity.
+ */
+ constructor( color, intensity ) {
+
+ super( color, intensity );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isAmbientLight = true;
+
+ this.type = 'AmbientLight';
+
+ }
+
+}
+
+/**
+ * This class emits light uniformly across the face a rectangular plane.
+ * This light type can be used to simulate light sources such as bright
+ * windows or strip lighting.
+ *
+ * Important Notes:
+ *
+ * - There is no shadow support.
+ * - Only PBR materials are supported.
+ * - You have to include `RectAreaLightUniformsLib` (`WebGLRenderer`) or `RectAreaLightTexturesLib` (`WebGPURenderer`)
+ * into your app and init the uniforms/textures.
+ *
+ * ```js
+ * RectAreaLightUniformsLib.init(); // only relevant for WebGLRenderer
+ * THREE.RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() ); // only relevant for WebGPURenderer
+ *
+ * const intensity = 1; const width = 10; const height = 10;
+ * const rectLight = new THREE.RectAreaLight( 0xffffff, intensity, width, height );
+ * rectLight.position.set( 5, 5, 0 );
+ * rectLight.lookAt( 0, 0, 0 );
+ * scene.add( rectLight )
+ * ```
+ *
+ * @augments Light
+ */
+class RectAreaLight extends Light {
+
+ /**
+ * Constructs a new area light.
+ *
+ * @param {(number|Color|string)} [color=0xffffff] - The light's color.
+ * @param {number} [intensity=1] - The light's strength/intensity.
+ * @param {number} [width=10] - The width of the light.
+ * @param {number} [height=10] - The height of the light.
+ */
+ constructor( color, intensity, width = 10, height = 10 ) {
+
+ super( color, intensity );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isRectAreaLight = true;
+
+ this.type = 'RectAreaLight';
+
+ /**
+ * The width of the light.
+ *
+ * @type {number}
+ * @default 10
+ */
+ this.width = width;
+
+ /**
+ * The height of the light.
+ *
+ * @type {number}
+ * @default 10
+ */
+ this.height = height;
+
+ }
+
+ /**
+ * The light's power. Power is the luminous power of the light measured in lumens (lm).
+ * Changing the power will also change the light's intensity.
+ *
+ * @type {number}
+ */
+ get power() {
+
+ // compute the light's luminous power (in lumens) from its intensity (in nits)
+ return this.intensity * this.width * this.height * Math.PI;
+
+ }
+
+ set power( power ) {
+
+ // set the light's intensity (in nits) from the desired luminous power (in lumens)
+ this.intensity = power / ( this.width * this.height * Math.PI );
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.width = source.width;
+ this.height = source.height;
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.width = this.width;
+ data.object.height = this.height;
+
+ return data;
+
+ }
+
+}
+
+/**
+ * Represents a third-order spherical harmonics (SH). Light probes use this class
+ * to encode lighting information.
+ *
+ * - Primary reference: {@link https://graphics.stanford.edu/papers/envmap/envmap.pdf}
+ * - Secondary reference: {@link https://www.ppsloan.org/publications/StupidSH36.pdf}
+ */
+class SphericalHarmonics3 {
+
+ /**
+ * Constructs a new spherical harmonics.
+ */
+ constructor() {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isSphericalHarmonics3 = true;
+
+ /**
+ * An array holding the (9) SH coefficients.
+ *
+ * @type {Array}
+ */
+ this.coefficients = [];
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.coefficients.push( new Vector3() );
+
+ }
+
+ }
+
+ /**
+ * Sets the given SH coefficients to this instance by copying
+ * the values.
+ *
+ * @param {Array} coefficients - The SH coefficients.
+ * @return {SphericalHarmonics3} A reference to this spherical harmonics.
+ */
+ set( coefficients ) {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.coefficients[ i ].copy( coefficients[ i ] );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets all SH coefficients to `0`.
+ *
+ * @return {SphericalHarmonics3} A reference to this spherical harmonics.
+ */
+ zero() {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.coefficients[ i ].set( 0, 0, 0 );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the radiance in the direction of the given normal.
+ *
+ * @param {Vector3} normal - The normal vector (assumed to be unit length)
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The radiance.
+ */
+ getAt( normal, target ) {
+
+ // normal is assumed to be unit length
+
+ const x = normal.x, y = normal.y, z = normal.z;
+
+ const coeff = this.coefficients;
+
+ // band 0
+ target.copy( coeff[ 0 ] ).multiplyScalar( 0.282095 );
+
+ // band 1
+ target.addScaledVector( coeff[ 1 ], 0.488603 * y );
+ target.addScaledVector( coeff[ 2 ], 0.488603 * z );
+ target.addScaledVector( coeff[ 3 ], 0.488603 * x );
+
+ // band 2
+ target.addScaledVector( coeff[ 4 ], 1.092548 * ( x * y ) );
+ target.addScaledVector( coeff[ 5 ], 1.092548 * ( y * z ) );
+ target.addScaledVector( coeff[ 6 ], 0.315392 * ( 3.0 * z * z - 1.0 ) );
+ target.addScaledVector( coeff[ 7 ], 1.092548 * ( x * z ) );
+ target.addScaledVector( coeff[ 8 ], 0.546274 * ( x * x - y * y ) );
+
+ return target;
+
+ }
+
+ /**
+ * Returns the irradiance (radiance convolved with cosine lobe) in the
+ * direction of the given normal.
+ *
+ * @param {Vector3} normal - The normal vector (assumed to be unit length)
+ * @param {Vector3} target - The target vector that is used to store the method's result.
+ * @return {Vector3} The irradiance.
+ */
+ getIrradianceAt( normal, target ) {
+
+ // normal is assumed to be unit length
+
+ const x = normal.x, y = normal.y, z = normal.z;
+
+ const coeff = this.coefficients;
+
+ // band 0
+ target.copy( coeff[ 0 ] ).multiplyScalar( 0.886227 ); // π * 0.282095
+
+ // band 1
+ target.addScaledVector( coeff[ 1 ], 2.0 * 0.511664 * y ); // ( 2 * π / 3 ) * 0.488603
+ target.addScaledVector( coeff[ 2 ], 2.0 * 0.511664 * z );
+ target.addScaledVector( coeff[ 3 ], 2.0 * 0.511664 * x );
+
+ // band 2
+ target.addScaledVector( coeff[ 4 ], 2.0 * 0.429043 * x * y ); // ( π / 4 ) * 1.092548
+ target.addScaledVector( coeff[ 5 ], 2.0 * 0.429043 * y * z );
+ target.addScaledVector( coeff[ 6 ], 0.743125 * z * z - 0.247708 ); // ( π / 4 ) * 0.315392 * 3
+ target.addScaledVector( coeff[ 7 ], 2.0 * 0.429043 * x * z );
+ target.addScaledVector( coeff[ 8 ], 0.429043 * ( x * x - y * y ) ); // ( π / 4 ) * 0.546274
+
+ return target;
+
+ }
+
+ /**
+ * Adds the given SH to this instance.
+ *
+ * @param {SphericalHarmonics3} sh - The SH to add.
+ * @return {SphericalHarmonics3} A reference to this spherical harmonics.
+ */
+ add( sh ) {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.coefficients[ i ].add( sh.coefficients[ i ] );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * A convenience method for performing {@link SphericalHarmonics3#add} and
+ * {@link SphericalHarmonics3#scale} at once.
+ *
+ * @param {SphericalHarmonics3} sh - The SH to add.
+ * @param {number} s - The scale factor.
+ * @return {SphericalHarmonics3} A reference to this spherical harmonics.
+ */
+ addScaledSH( sh, s ) {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.coefficients[ i ].addScaledVector( sh.coefficients[ i ], s );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Scales this SH by the given scale factor.
+ *
+ * @param {number} s - The scale factor.
+ * @return {SphericalHarmonics3} A reference to this spherical harmonics.
+ */
+ scale( s ) {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.coefficients[ i ].multiplyScalar( s );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Linear interpolates between the given SH and this instance by the given
+ * alpha factor.
+ *
+ * @param {SphericalHarmonics3} sh - The SH to interpolate with.
+ * @param {number} alpha - The alpha factor.
+ * @return {SphericalHarmonics3} A reference to this spherical harmonics.
+ */
+ lerp( sh, alpha ) {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ this.coefficients[ i ].lerp( sh.coefficients[ i ], alpha );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns `true` if this spherical harmonics is equal with the given one.
+ *
+ * @param {SphericalHarmonics3} sh - The spherical harmonics to test for equality.
+ * @return {boolean} Whether this spherical harmonics is equal with the given one.
+ */
+ equals( sh ) {
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ if ( ! this.coefficients[ i ].equals( sh.coefficients[ i ] ) ) {
+
+ return false;
+
+ }
+
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Copies the values of the given spherical harmonics to this instance.
+ *
+ * @param {SphericalHarmonics3} sh - The spherical harmonics to copy.
+ * @return {SphericalHarmonics3} A reference to this spherical harmonics.
+ */
+ copy( sh ) {
+
+ return this.set( sh.coefficients );
+
+ }
+
+ /**
+ * Returns a new spherical harmonics with copied values from this instance.
+ *
+ * @return {SphericalHarmonics3} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+ /**
+ * Sets the SH coefficients of this instance from the given array.
+ *
+ * @param {Array} array - An array holding the SH coefficients.
+ * @param {number} [offset=0] - The array offset where to start copying.
+ * @return {SphericalHarmonics3} A clone of this instance.
+ */
+ fromArray( array, offset = 0 ) {
+
+ const coefficients = this.coefficients;
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ coefficients[ i ].fromArray( array, offset + ( i * 3 ) );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns an array with the SH coefficients, or copies them into the provided
+ * array. The coefficients are represented as numbers.
+ *
+ * @param {Array} [array=[]] - The target array.
+ * @param {number} [offset=0] - The array offset where to start copying.
+ * @return {Array} An array with flat SH coefficients.
+ */
+ toArray( array = [], offset = 0 ) {
+
+ const coefficients = this.coefficients;
+
+ for ( let i = 0; i < 9; i ++ ) {
+
+ coefficients[ i ].toArray( array, offset + ( i * 3 ) );
+
+ }
+
+ return array;
+
+ }
+
+ /**
+ * Computes the SH basis for the given normal vector.
+ *
+ * @param {Vector3} normal - The normal.
+ * @param {Array} shBasis - The target array holding the SH basis.
+ */
+ static getBasisAt( normal, shBasis ) {
+
+ // normal is assumed to be unit length
+
+ const x = normal.x, y = normal.y, z = normal.z;
+
+ // band 0
+ shBasis[ 0 ] = 0.282095;
+
+ // band 1
+ shBasis[ 1 ] = 0.488603 * y;
+ shBasis[ 2 ] = 0.488603 * z;
+ shBasis[ 3 ] = 0.488603 * x;
+
+ // band 2
+ shBasis[ 4 ] = 1.092548 * x * y;
+ shBasis[ 5 ] = 1.092548 * y * z;
+ shBasis[ 6 ] = 0.315392 * ( 3 * z * z - 1 );
+ shBasis[ 7 ] = 1.092548 * x * z;
+ shBasis[ 8 ] = 0.546274 * ( x * x - y * y );
+
+ }
+
+}
+
+/**
+ * Light probes are an alternative way of adding light to a 3D scene. Unlike
+ * classical light sources (e.g. directional, point or spot lights), light
+ * probes do not emit light. Instead they store information about light
+ * passing through 3D space. During rendering, the light that hits a 3D
+ * object is approximated by using the data from the light probe.
+ *
+ * Light probes are usually created from (radiance) environment maps. The
+ * class {@link LightProbeGenerator} can be used to create light probes from
+ * cube textures or render targets. However, light estimation data could also
+ * be provided in other forms e.g. by WebXR. This enables the rendering of
+ * augmented reality content that reacts to real world lighting.
+ *
+ * The current probe implementation in three.js supports so-called diffuse
+ * light probes. This type of light probe is functionally equivalent to an
+ * irradiance environment map.
+ *
+ * @augments Light
+ */
+class LightProbe extends Light {
+
+ /**
+ * Constructs a new light probe.
+ *
+ * @param {SphericalHarmonics3} sh - The spherical harmonics which represents encoded lighting information.
+ * @param {number} [intensity=1] - The light's strength/intensity.
+ */
+ constructor( sh = new SphericalHarmonics3(), intensity = 1 ) {
+
+ super( undefined, intensity );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isLightProbe = true;
+
+ /**
+ * A light probe uses spherical harmonics to encode lighting information.
+ *
+ * @type {SphericalHarmonics3}
+ */
+ this.sh = sh;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.sh.copy( source.sh );
+
+ return this;
+
+ }
+
+ toJSON( meta ) {
+
+ const data = super.toJSON( meta );
+
+ data.object.sh = this.sh.toArray();
+
+ return data;
+
+ }
+
+}
+
+const _customMaterials = {};
+
+/**
+ * Class for loading materials. The files are internally
+ * loaded via {@link FileLoader}.
+ *
+ * ```js
+ * const loader = new THREE.MaterialLoader();
+ * const material = await loader.loadAsync( 'material.json' );
+ * ```
+ * This loader does not support node materials. Use {@link NodeMaterialLoader} instead.
+ *
+ * @augments Loader
+ */
+class MaterialLoader extends Loader {
+
+ /**
+ * Constructs a new material loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ /**
+ * A dictionary holding textures used by the material.
+ *
+ * @type {Object}
+ */
+ this.textures = {};
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the loaded material to the `onLoad()` callback.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(Material)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ const loader = new FileLoader( scope.manager );
+ loader.setPath( scope.path );
+ loader.setRequestHeader( scope.requestHeader );
+ loader.setWithCredentials( scope.withCredentials );
+ loader.load( url, function ( text ) {
+
+ try {
+
+ onLoad( scope.parse( JSON.parse( text ) ) );
+
+ } catch ( e ) {
+
+ if ( onError ) {
+
+ onError( e );
+
+ } else {
+
+ error( e );
+
+ }
+
+ scope.manager.itemError( url );
+
+ }
+
+ }, onProgress, onError );
+
+ }
+
+ /**
+ * Parses the given JSON object and returns a material.
+ *
+ * @param {Object} json - The serialized material.
+ * @return {Material} The parsed material.
+ */
+ parse( json ) {
+
+ const material = this.createMaterialFromType( json.type );
+
+ material.fromJSON( json, this.textures );
+
+ return material;
+
+ }
+
+ /**
+ * Textures are not embedded in the material JSON so they have
+ * to be injected before the loading process starts.
+ *
+ * @param {Object} value - A dictionary holding textures for material properties.
+ * @return {MaterialLoader} A reference to this material loader.
+ */
+ setTextures( value ) {
+
+ this.textures = value;
+ return this;
+
+ }
+
+ /**
+ * Creates a material for the given type.
+ *
+ * @param {string} type - The material type.
+ * @return {Material} The new material.
+ */
+ createMaterialFromType( type ) {
+
+ return MaterialLoader.createMaterialFromType( type );
+
+ }
+
+ /**
+ * Creates a material for the given type.
+ *
+ * @static
+ * @param {string} type - The material type.
+ * @return {Material} The new material.
+ */
+ static createMaterialFromType( type ) {
+
+ const materialLib = {
+ ShadowMaterial,
+ SpriteMaterial,
+ RawShaderMaterial,
+ ShaderMaterial,
+ PointsMaterial,
+ MeshPhysicalMaterial,
+ MeshStandardMaterial,
+ MeshPhongMaterial,
+ MeshToonMaterial,
+ MeshNormalMaterial,
+ MeshLambertMaterial,
+ MeshDepthMaterial,
+ MeshDistanceMaterial,
+ MeshBasicMaterial,
+ MeshMatcapMaterial,
+ LineDashedMaterial,
+ LineBasicMaterial,
+ Material,
+ ... _customMaterials
+ };
+
+ const MaterialType = materialLib[ type ];
+
+ let materialInstance;
+
+ if ( MaterialType === undefined ) {
+
+ warnOnce( `MaterialLoader: Unknown material type "${ type }". Use .registerMaterial() before starting the deserialization process.` );
+ materialInstance = new Material();
+
+ } else {
+
+ materialInstance = new MaterialType();
+
+ }
+
+ return materialInstance;
+
+ }
+
+ /**
+ * Registers the given material at the internal
+ * material library.
+ *
+ * @static
+ * @param {string} type - The material type.
+ * @param {Material.constructor} materialClass - The material class.
+ */
+ static registerMaterial( type, materialClass ) {
+
+ _customMaterials[ type ] = materialClass;
+
+ }
+
+}
+
+/**
+ * A class with loader utility functions.
+ */
+class LoaderUtils {
+
+ /**
+ * Extracts the base URL from the given URL.
+ *
+ * @param {string} url -The URL to extract the base URL from.
+ * @return {string} The extracted base URL.
+ */
+ static extractUrlBase( url ) {
+
+ const index = url.lastIndexOf( '/' );
+
+ if ( index === -1 ) return './';
+
+ return url.slice( 0, index + 1 );
+
+ }
+
+ /**
+ * Resolves relative URLs against the given path. Absolute paths, data urls,
+ * and blob URLs will be returned as is. Invalid URLs will return an empty
+ * string.
+ *
+ * @param {string} url -The URL to resolve.
+ * @param {string} path - The base path for relative URLs to be resolved against.
+ * @return {string} The resolved URL.
+ */
+ static resolveURL( url, path ) {
+
+ // Invalid URL
+ if ( typeof url !== 'string' || url === '' ) return '';
+
+ // Host Relative URL
+ if ( /^https?:\/\//i.test( path ) && /^\//.test( url ) ) {
+
+ path = path.replace( /(^https?:\/\/[^\/]+).*/i, '$1' );
+
+ }
+
+ // Absolute URL http://,https://,//
+ if ( /^(https?:)?\/\//i.test( url ) ) return url;
+
+ // Data URI
+ if ( /^data:.*,.*$/i.test( url ) ) return url;
+
+ // Blob URL
+ if ( /^blob:.*$/i.test( url ) ) return url;
+
+ // Relative URL
+ return path + url;
+
+ }
+
+}
+
+/**
+ * An instanced version of a geometry.
+ */
+class InstancedBufferGeometry extends BufferGeometry {
+
+ /**
+ * Constructs a new instanced buffer geometry.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isInstancedBufferGeometry = true;
+
+ this.type = 'InstancedBufferGeometry';
+
+ /**
+ * The instance count.
+ *
+ * @type {number}
+ * @default Infinity
+ */
+ this.instanceCount = Infinity;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.instanceCount = source.instanceCount;
+
+ return this;
+
+ }
+
+ toJSON() {
+
+ const data = super.toJSON();
+
+ data.instanceCount = this.instanceCount;
+
+ data.isInstancedBufferGeometry = true;
+
+ return data;
+
+ }
+
+}
+
+/**
+ * Class for loading geometries. The files are internally
+ * loaded via {@link FileLoader}.
+ *
+ * ```js
+ * const loader = new THREE.BufferGeometryLoader();
+ * const geometry = await loader.loadAsync( 'models/json/pressure.json' );
+ *
+ * const material = new THREE.MeshBasicMaterial( { color: 0xF5F5F5 } );
+ * const object = new THREE.Mesh( geometry, material );
+ * scene.add( object );
+ * ```
+ *
+ * @augments Loader
+ */
+class BufferGeometryLoader extends Loader {
+
+ /**
+ * Constructs a new geometry loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the loaded geometry to the `onLoad()` callback.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ const loader = new FileLoader( scope.manager );
+ loader.setPath( scope.path );
+ loader.setRequestHeader( scope.requestHeader );
+ loader.setWithCredentials( scope.withCredentials );
+ loader.load( url, function ( text ) {
+
+ try {
+
+ onLoad( scope.parse( JSON.parse( text ) ) );
+
+ } catch ( e ) {
+
+ if ( onError ) {
+
+ onError( e );
+
+ } else {
+
+ error( e );
+
+ }
+
+ scope.manager.itemError( url );
+
+ }
+
+ }, onProgress, onError );
+
+ }
+
+ /**
+ * Parses the given JSON object and returns a geometry.
+ *
+ * @param {Object} json - The serialized geometry.
+ * @return {BufferGeometry} The parsed geometry.
+ */
+ parse( json ) {
+
+ const interleavedBufferMap = {};
+ const arrayBufferMap = {};
+
+ function getInterleavedBuffer( json, uuid ) {
+
+ if ( interleavedBufferMap[ uuid ] !== undefined ) return interleavedBufferMap[ uuid ];
+
+ const interleavedBuffers = json.interleavedBuffers;
+ const interleavedBuffer = interleavedBuffers[ uuid ];
+
+ const buffer = getArrayBuffer( json, interleavedBuffer.buffer );
+
+ const array = getTypedArray( interleavedBuffer.type, buffer );
+ const ib = new InterleavedBuffer( array, interleavedBuffer.stride );
+ ib.uuid = interleavedBuffer.uuid;
+
+ if ( interleavedBuffer.usage !== undefined ) ib.setUsage( interleavedBuffer.usage );
+
+ interleavedBufferMap[ uuid ] = ib;
+
+ return ib;
+
+ }
+
+ function getArrayBuffer( json, uuid ) {
+
+ if ( arrayBufferMap[ uuid ] !== undefined ) return arrayBufferMap[ uuid ];
+
+ const arrayBuffers = json.arrayBuffers;
+ const arrayBuffer = arrayBuffers[ uuid ];
+
+ const ab = new Uint32Array( arrayBuffer ).buffer;
+
+ arrayBufferMap[ uuid ] = ab;
+
+ return ab;
+
+ }
+
+ const geometry = json.isInstancedBufferGeometry ? new InstancedBufferGeometry() : new BufferGeometry();
+
+ const index = json.data.index;
+
+ if ( index !== undefined ) {
+
+ const typedArray = getTypedArray( index.type, index.array );
+ geometry.setIndex( new BufferAttribute( typedArray, 1 ) );
+
+ }
+
+ const attributes = json.data.attributes;
+
+ for ( const key in attributes ) {
+
+ const attribute = attributes[ key ];
+ let bufferAttribute;
+
+ if ( attribute.isInterleavedBufferAttribute ) {
+
+ const interleavedBuffer = getInterleavedBuffer( json.data, attribute.data );
+ bufferAttribute = new InterleavedBufferAttribute( interleavedBuffer, attribute.itemSize, attribute.offset, attribute.normalized );
+
+ } else {
+
+ const typedArray = getTypedArray( attribute.type, attribute.array );
+ const bufferAttributeConstr = attribute.isInstancedBufferAttribute ? InstancedBufferAttribute : BufferAttribute;
+ bufferAttribute = new bufferAttributeConstr( typedArray, attribute.itemSize, attribute.normalized );
+
+ }
+
+ if ( attribute.name !== undefined ) bufferAttribute.name = attribute.name;
+ if ( attribute.usage !== undefined ) bufferAttribute.setUsage( attribute.usage );
+ if ( attribute.gpuType !== undefined ) bufferAttribute.gpuType = attribute.gpuType;
+
+ geometry.setAttribute( key, bufferAttribute );
+
+ }
+
+ const morphAttributes = json.data.morphAttributes;
+
+ if ( morphAttributes ) {
+
+ for ( const key in morphAttributes ) {
+
+ const attributeArray = morphAttributes[ key ];
+
+ const array = [];
+
+ for ( let i = 0, il = attributeArray.length; i < il; i ++ ) {
+
+ const attribute = attributeArray[ i ];
+ let bufferAttribute;
+
+ if ( attribute.isInterleavedBufferAttribute ) {
+
+ const interleavedBuffer = getInterleavedBuffer( json.data, attribute.data );
+ bufferAttribute = new InterleavedBufferAttribute( interleavedBuffer, attribute.itemSize, attribute.offset, attribute.normalized );
+
+ } else {
+
+ const typedArray = getTypedArray( attribute.type, attribute.array );
+ bufferAttribute = new BufferAttribute( typedArray, attribute.itemSize, attribute.normalized );
+
+ }
+
+ if ( attribute.name !== undefined ) bufferAttribute.name = attribute.name;
+ if ( attribute.usage !== undefined ) bufferAttribute.setUsage( attribute.usage );
+ if ( attribute.gpuType !== undefined ) bufferAttribute.gpuType = attribute.gpuType;
+ array.push( bufferAttribute );
+
+ }
+
+ geometry.morphAttributes[ key ] = array;
+
+ }
+
+ }
+
+ const morphTargetsRelative = json.data.morphTargetsRelative;
+
+ if ( morphTargetsRelative ) {
+
+ geometry.morphTargetsRelative = true;
+
+ }
+
+ const groups = json.data.groups || json.data.drawcalls || json.data.offsets;
+
+ if ( groups !== undefined ) {
+
+ for ( let i = 0, n = groups.length; i !== n; ++ i ) {
+
+ const group = groups[ i ];
+
+ geometry.addGroup( group.start, group.count, group.materialIndex );
+
+ }
+
+ }
+
+ const boundingSphere = json.data.boundingSphere;
+
+ if ( boundingSphere !== undefined ) {
+
+ geometry.boundingSphere = new Sphere().fromJSON( boundingSphere );
+
+ }
+
+ if ( json.name ) geometry.name = json.name;
+ if ( json.userData ) geometry.userData = json.userData;
+
+ return geometry;
+
+ }
+
+}
+
+const _customGeometries = {};
+
+/**
+ * A loader for loading a JSON resource in the [JSON Object/Scene format](https://github.com/mrdoob/three.js/wiki/JSON-Object-Scene-format-4).
+ * The files are internally loaded via {@link FileLoader}.
+ *
+ * ```js
+ * const loader = new THREE.ObjectLoader();
+ * const obj = await loader.loadAsync( 'models/json/example.json' );
+ * scene.add( obj );
+ *
+ * // Alternatively, to parse a previously loaded JSON structure
+ * const object = await loader.parseAsync( a_json_object );
+ * scene.add( object );
+ * ```
+ *
+ * @augments Loader
+ */
+class ObjectLoader extends Loader {
+
+ /**
+ * Constructs a new object loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the loaded 3D object to the `onLoad()` callback.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(Object3D)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ const path = ( this.path === '' ) ? LoaderUtils.extractUrlBase( url ) : this.path;
+ this.resourcePath = this.resourcePath || path;
+
+ const loader = new FileLoader( this.manager );
+ loader.setPath( this.path );
+ loader.setRequestHeader( this.requestHeader );
+ loader.setWithCredentials( this.withCredentials );
+ loader.load( url, function ( text ) {
+
+ let json = null;
+
+ try {
+
+ json = JSON.parse( text );
+
+ } catch ( e ) {
+
+ if ( onError !== undefined ) onError( e );
+
+ error( 'ObjectLoader: Can\'t parse ' + url + '.', e.message );
+
+ return;
+
+ }
+
+ const metadata = json.metadata;
+
+ if ( metadata === undefined || metadata.type === undefined || metadata.type.toLowerCase() === 'geometry' ) {
+
+ if ( onError !== undefined ) onError( new Error( 'THREE.ObjectLoader: Can\'t load ' + url ) );
+
+ error( 'ObjectLoader: Can\'t load ' + url );
+ return;
+
+ }
+
+ scope.parse( json, onLoad );
+
+ }, onProgress, onError );
+
+ }
+
+ /**
+ * Async version of {@link ObjectLoader#load}.
+ *
+ * @async
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @return {Promise} A Promise that resolves with the loaded 3D object.
+ */
+ async loadAsync( url, onProgress ) {
+
+ const scope = this;
+
+ const path = ( this.path === '' ) ? LoaderUtils.extractUrlBase( url ) : this.path;
+ this.resourcePath = this.resourcePath || path;
+
+ const loader = new FileLoader( this.manager );
+ loader.setPath( this.path );
+ loader.setRequestHeader( this.requestHeader );
+ loader.setWithCredentials( this.withCredentials );
+
+ const text = await loader.loadAsync( url, onProgress );
+
+ let json;
+
+ try {
+
+ json = JSON.parse( text );
+
+ } catch ( e ) {
+
+ throw new Error( 'THREE.ObjectLoader: Can\'t parse ' + url + '. ' + e.message );
+
+ }
+
+ const metadata = json.metadata;
+
+ if ( metadata === undefined || metadata.type === undefined || metadata.type.toLowerCase() === 'geometry' ) {
+
+ throw new Error( 'THREE.ObjectLoader: Can\'t load ' + url );
+
+ }
+
+ return await scope.parseAsync( json );
+
+ }
+
+ /**
+ * Parses the given JSON. This is used internally by {@link ObjectLoader#load}
+ * but can also be used directly to parse a previously loaded JSON structure.
+ *
+ * @param {Object} json - The serialized 3D object.
+ * @param {onLoad} onLoad - Executed when all resources (e.g. textures) have been fully loaded.
+ * @return {Object3D} The parsed 3D object.
+ */
+ parse( json, onLoad ) {
+
+ const animations = this.parseAnimations( json.animations );
+ const shapes = this.parseShapes( json.shapes );
+ const geometries = this.parseGeometries( json.geometries, shapes );
+
+ const images = this.parseImages( json.images, function () {
+
+ if ( onLoad !== undefined ) onLoad( object );
+
+ } );
+
+ const textures = this.parseTextures( json.textures, images );
+ const materials = this.parseMaterials( json.materials, textures );
+
+ const object = this.parseObject( json.object, geometries, materials, textures, animations );
+ const skeletons = this.parseSkeletons( json.skeletons, object );
+
+ this.bindSkeletons( object, skeletons );
+ this.bindLightTargets( object );
+
+ //
+
+ if ( onLoad !== undefined ) {
+
+ let hasImages = false;
+
+ for ( const uuid in images ) {
+
+ if ( images[ uuid ].data instanceof HTMLImageElement ) {
+
+ hasImages = true;
+ break;
+
+ }
+
+ }
+
+ if ( hasImages === false ) onLoad( object );
+
+ }
+
+ return object;
+
+ }
+
+ /**
+ * Async version of {@link ObjectLoader#parse}.
+ *
+ * @param {Object} json - The serialized 3D object.
+ * @return {Promise} A Promise that resolves with the parsed 3D object.
+ */
+ async parseAsync( json ) {
+
+ const animations = this.parseAnimations( json.animations );
+ const shapes = this.parseShapes( json.shapes );
+ const geometries = this.parseGeometries( json.geometries, shapes );
+
+ const images = await this.parseImagesAsync( json.images );
+
+ const textures = this.parseTextures( json.textures, images );
+ const materials = this.parseMaterials( json.materials, textures );
+
+ const object = this.parseObject( json.object, geometries, materials, textures, animations );
+ const skeletons = this.parseSkeletons( json.skeletons, object );
+
+ this.bindSkeletons( object, skeletons );
+ this.bindLightTargets( object );
+
+ return object;
+
+ }
+
+ /**
+ * Registers the given geometry at the internal
+ * geometry library.
+ *
+ * @static
+ * @param {string} type - The geometry type.
+ * @param {BufferGeometry.constructor} geometryClass - The geometry class.
+ */
+ static registerGeometry( type, geometryClass ) {
+
+ _customGeometries[ type ] = geometryClass;
+
+ }
+
+ // internals
+
+ parseShapes( json ) {
+
+ const shapes = {};
+
+ if ( json !== undefined ) {
+
+ for ( let i = 0, l = json.length; i < l; i ++ ) {
+
+ const shape = new Shape().fromJSON( json[ i ] );
+
+ shapes[ shape.uuid ] = shape;
+
+ }
+
+ }
+
+ return shapes;
+
+ }
+
+ parseSkeletons( json, object ) {
+
+ const skeletons = {};
+ const bones = {};
+
+ // generate bone lookup table
+
+ object.traverse( function ( child ) {
+
+ if ( child.isBone ) bones[ child.uuid ] = child;
+
+ } );
+
+ // create skeletons
+
+ if ( json !== undefined ) {
+
+ for ( let i = 0, l = json.length; i < l; i ++ ) {
+
+ const skeleton = new Skeleton().fromJSON( json[ i ], bones );
+
+ skeletons[ skeleton.uuid ] = skeleton;
+
+ }
+
+ }
+
+ return skeletons;
+
+ }
+
+ parseGeometries( json, shapes ) {
+
+ const geometries = {};
+
+ if ( json !== undefined ) {
+
+ const bufferGeometryLoader = new BufferGeometryLoader();
+
+ for ( let i = 0, l = json.length; i < l; i ++ ) {
+
+ let geometry;
+ const data = json[ i ];
+
+ switch ( data.type ) {
+
+ case 'BufferGeometry':
+ case 'InstancedBufferGeometry':
+
+ geometry = bufferGeometryLoader.parse( data );
+ break;
+
+ default:
+
+ if ( data.type in Geometries ) {
+
+ geometry = Geometries[ data.type ].fromJSON( data, shapes );
+
+ } else if ( data.type in _customGeometries ) {
+
+ geometry = _customGeometries[ data.type ].fromJSON( data, shapes );
+
+ } else {
+
+ warn( `ObjectLoader: Unknown geometry type "${ data.type }". Use .registerGeometry() before starting the deserialization process.` );
+
+ }
+
+ }
+
+ geometry.uuid = data.uuid;
+
+ if ( data.name !== undefined ) geometry.name = data.name;
+ if ( data.userData !== undefined ) geometry.userData = data.userData;
+
+ geometries[ data.uuid ] = geometry;
+
+ }
+
+ }
+
+ return geometries;
+
+ }
+
+ parseMaterials( json, textures ) {
+
+ const cache = {}; // MultiMaterial
+ const materials = {};
+
+ if ( json !== undefined ) {
+
+ const loader = new MaterialLoader();
+ loader.setTextures( textures );
+
+ for ( let i = 0, l = json.length; i < l; i ++ ) {
+
+ const data = json[ i ];
+
+ if ( cache[ data.uuid ] === undefined ) {
+
+ cache[ data.uuid ] = loader.parse( data );
+
+ }
+
+ materials[ data.uuid ] = cache[ data.uuid ];
+
+ }
+
+ }
+
+ return materials;
+
+ }
+
+ parseAnimations( json ) {
+
+ const animations = {};
+
+ if ( json !== undefined ) {
+
+ for ( let i = 0; i < json.length; i ++ ) {
+
+ const data = json[ i ];
+
+ const clip = AnimationClip.parse( data );
+
+ animations[ clip.uuid ] = clip;
+
+ }
+
+ }
+
+ return animations;
+
+ }
+
+ parseImages( json, onLoad ) {
+
+ const scope = this;
+ const images = {};
+
+ let loader;
+
+ function loadImage( url ) {
+
+ url = scope.manager.resolveURL( url );
+
+ scope.manager.itemStart( url );
+
+ return loader.load( url, function () {
+
+ scope.manager.itemEnd( url );
+
+ }, undefined, function () {
+
+ scope.manager.itemError( url );
+ scope.manager.itemEnd( url );
+
+ } );
+
+ }
+
+ function deserializeImage( image ) {
+
+ if ( typeof image === 'string' ) {
+
+ const url = image;
+
+ const path = /^(\/\/)|([a-z]+:(\/\/)?)/i.test( url ) ? url : scope.resourcePath + url;
+
+ return loadImage( path );
+
+ } else {
+
+ if ( image.data ) {
+
+ return {
+ data: getTypedArray( image.type, image.data ),
+ width: image.width,
+ height: image.height
+ };
+
+ } else {
+
+ return null;
+
+ }
+
+ }
+
+ }
+
+ if ( json !== undefined && json.length > 0 ) {
+
+ const manager = new LoadingManager( onLoad );
+
+ loader = new ImageLoader( manager );
+ loader.setCrossOrigin( this.crossOrigin );
+
+ for ( let i = 0, il = json.length; i < il; i ++ ) {
+
+ const image = json[ i ];
+ const url = image.url;
+
+ if ( Array.isArray( url ) ) {
+
+ // load array of images e.g CubeTexture
+
+ const imageArray = [];
+
+ for ( let j = 0, jl = url.length; j < jl; j ++ ) {
+
+ const currentUrl = url[ j ];
+
+ const deserializedImage = deserializeImage( currentUrl );
+
+ if ( deserializedImage !== null ) {
+
+ if ( deserializedImage instanceof HTMLImageElement ) {
+
+ imageArray.push( deserializedImage );
+
+ } else {
+
+ // special case: handle array of data textures for cube textures
+
+ imageArray.push( new DataTexture( deserializedImage.data, deserializedImage.width, deserializedImage.height ) );
+
+ }
+
+ }
+
+ }
+
+ images[ image.uuid ] = new TextureSource( imageArray );
+
+ } else {
+
+ // load single image
+
+ const deserializedImage = deserializeImage( image.url );
+ images[ image.uuid ] = new TextureSource( deserializedImage );
+
+
+ }
+
+ }
+
+ }
+
+ return images;
+
+ }
+
+ async parseImagesAsync( json ) {
+
+ const scope = this;
+ const images = {};
+
+ let loader;
+
+ async function deserializeImage( image ) {
+
+ if ( typeof image === 'string' ) {
+
+ const url = image;
+
+ const path = /^(\/\/)|([a-z]+:(\/\/)?)/i.test( url ) ? url : scope.resourcePath + url;
+
+ return await loader.loadAsync( path );
+
+ } else {
+
+ if ( image.data ) {
+
+ return {
+ data: getTypedArray( image.type, image.data ),
+ width: image.width,
+ height: image.height
+ };
+
+ } else {
+
+ return null;
+
+ }
+
+ }
+
+ }
+
+ if ( json !== undefined && json.length > 0 ) {
+
+ loader = new ImageLoader( this.manager );
+ loader.setCrossOrigin( this.crossOrigin );
+
+ for ( let i = 0, il = json.length; i < il; i ++ ) {
+
+ const image = json[ i ];
+ const url = image.url;
+
+ if ( Array.isArray( url ) ) {
+
+ // load array of images e.g CubeTexture
+
+ const imageArray = [];
+
+ for ( let j = 0, jl = url.length; j < jl; j ++ ) {
+
+ const currentUrl = url[ j ];
+
+ const deserializedImage = await deserializeImage( currentUrl );
+
+ if ( deserializedImage !== null ) {
+
+ if ( deserializedImage instanceof HTMLImageElement ) {
+
+ imageArray.push( deserializedImage );
+
+ } else {
+
+ // special case: handle array of data textures for cube textures
+
+ imageArray.push( new DataTexture( deserializedImage.data, deserializedImage.width, deserializedImage.height ) );
+
+ }
+
+ }
+
+ }
+
+ images[ image.uuid ] = new TextureSource( imageArray );
+
+ } else {
+
+ // load single image
+
+ const deserializedImage = await deserializeImage( image.url );
+ images[ image.uuid ] = new TextureSource( deserializedImage );
+
+ }
+
+ }
+
+ }
+
+ return images;
+
+ }
+
+ parseTextures( json, images ) {
+
+ function parseConstant( value, type ) {
+
+ if ( typeof value === 'number' ) return value;
+
+ warn( 'ObjectLoader.parseTexture: Constant should be in numeric form.', value );
+
+ return type[ value ];
+
+ }
+
+ const textures = {};
+
+ if ( json !== undefined ) {
+
+ for ( let i = 0, l = json.length; i < l; i ++ ) {
+
+ const data = json[ i ];
+
+ if ( data.image === undefined ) {
+
+ warn( 'ObjectLoader: No "image" specified for', data.uuid );
+
+ }
+
+ if ( images[ data.image ] === undefined ) {
+
+ warn( 'ObjectLoader: Undefined image', data.image );
+
+ }
+
+ const source = images[ data.image ];
+ const image = source.data;
+
+ let texture;
+
+ if ( Array.isArray( image ) ) {
+
+ texture = new CubeTexture();
+
+ if ( image.length === 6 ) texture.needsUpdate = true;
+
+ } else {
+
+ if ( image && image.data ) {
+
+ texture = new DataTexture();
+
+ } else {
+
+ texture = new Texture();
+
+ }
+
+ if ( image ) texture.needsUpdate = true; // textures can have undefined image data
+
+ }
+
+ texture.source = source;
+
+ texture.uuid = data.uuid;
+
+ if ( data.name !== undefined ) texture.name = data.name;
+
+ if ( data.mapping !== undefined ) texture.mapping = parseConstant( data.mapping, TEXTURE_MAPPING );
+ if ( data.channel !== undefined ) texture.channel = data.channel;
+
+ if ( data.offset !== undefined ) texture.offset.fromArray( data.offset );
+ if ( data.repeat !== undefined ) texture.repeat.fromArray( data.repeat );
+ if ( data.center !== undefined ) texture.center.fromArray( data.center );
+ if ( data.rotation !== undefined ) texture.rotation = data.rotation;
+
+ if ( data.wrap !== undefined ) {
+
+ texture.wrapS = parseConstant( data.wrap[ 0 ], TEXTURE_WRAPPING );
+ texture.wrapT = parseConstant( data.wrap[ 1 ], TEXTURE_WRAPPING );
+
+ }
+
+ if ( data.format !== undefined ) texture.format = data.format;
+ if ( data.internalFormat !== undefined ) texture.internalFormat = data.internalFormat;
+ if ( data.type !== undefined ) texture.type = data.type;
+ if ( data.colorSpace !== undefined ) texture.colorSpace = data.colorSpace;
+
+ if ( data.minFilter !== undefined ) texture.minFilter = parseConstant( data.minFilter, TEXTURE_FILTER );
+ if ( data.magFilter !== undefined ) texture.magFilter = parseConstant( data.magFilter, TEXTURE_FILTER );
+ if ( data.anisotropy !== undefined ) texture.anisotropy = data.anisotropy;
+
+ if ( data.flipY !== undefined ) texture.flipY = data.flipY;
+
+ if ( data.generateMipmaps !== undefined ) texture.generateMipmaps = data.generateMipmaps;
+ if ( data.premultiplyAlpha !== undefined ) texture.premultiplyAlpha = data.premultiplyAlpha;
+ if ( data.unpackAlignment !== undefined ) texture.unpackAlignment = data.unpackAlignment;
+ if ( data.compareFunction !== undefined ) texture.compareFunction = data.compareFunction;
+ if ( data.normalized !== undefined ) texture.normalized = data.normalized;
+
+ if ( data.userData !== undefined ) texture.userData = data.userData;
+
+ textures[ data.uuid ] = texture;
+
+ }
+
+ }
+
+ return textures;
+
+ }
+
+ parseObject( data, geometries, materials, textures, animations ) {
+
+ let object;
+
+ function getGeometry( name ) {
+
+ if ( geometries[ name ] === undefined ) {
+
+ warn( 'ObjectLoader: Undefined geometry', name );
+
+ }
+
+ return geometries[ name ];
+
+ }
+
+ function getMaterial( name ) {
+
+ if ( name === undefined ) return undefined;
+
+ if ( Array.isArray( name ) ) {
+
+ const array = [];
+
+ for ( let i = 0, l = name.length; i < l; i ++ ) {
+
+ const uuid = name[ i ];
+
+ if ( materials[ uuid ] === undefined ) {
+
+ warn( 'ObjectLoader: Undefined material', uuid );
+
+ }
+
+ array.push( materials[ uuid ] );
+
+ }
+
+ return array;
+
+ }
+
+ if ( materials[ name ] === undefined ) {
+
+ warn( 'ObjectLoader: Undefined material', name );
+
+ }
+
+ return materials[ name ];
+
+ }
+
+ function getTexture( uuid ) {
+
+ if ( textures[ uuid ] === undefined ) {
+
+ warn( 'ObjectLoader: Undefined texture', uuid );
+
+ }
+
+ return textures[ uuid ];
+
+ }
+
+ let geometry, material;
+
+ switch ( data.type ) {
+
+ case 'Scene':
+
+ object = new Scene();
+
+ if ( data.background !== undefined ) {
+
+ if ( Number.isInteger( data.background ) ) {
+
+ object.background = new Color( data.background );
+
+ } else {
+
+ object.background = getTexture( data.background );
+
+ }
+
+ }
+
+ if ( data.environment !== undefined ) {
+
+ object.environment = getTexture( data.environment );
+
+ }
+
+ if ( data.fog !== undefined ) {
+
+ if ( data.fog.type === 'Fog' ) {
+
+ object.fog = new Fog( data.fog.color, data.fog.near, data.fog.far );
+
+ } else if ( data.fog.type === 'FogExp2' ) {
+
+ object.fog = new FogExp2( data.fog.color, data.fog.density );
+
+ }
+
+ if ( data.fog.name !== '' ) {
+
+ object.fog.name = data.fog.name;
+
+ }
+
+ }
+
+ if ( data.backgroundBlurriness !== undefined ) object.backgroundBlurriness = data.backgroundBlurriness;
+ if ( data.backgroundIntensity !== undefined ) object.backgroundIntensity = data.backgroundIntensity;
+ if ( data.backgroundRotation !== undefined ) object.backgroundRotation.fromArray( data.backgroundRotation );
+
+ if ( data.environmentIntensity !== undefined ) object.environmentIntensity = data.environmentIntensity;
+ if ( data.environmentRotation !== undefined ) object.environmentRotation.fromArray( data.environmentRotation );
+
+ break;
+
+ case 'PerspectiveCamera':
+
+ object = new PerspectiveCamera( data.fov, data.aspect, data.near, data.far );
+
+ if ( data.focus !== undefined ) object.focus = data.focus;
+ if ( data.zoom !== undefined ) object.zoom = data.zoom;
+ if ( data.filmGauge !== undefined ) object.filmGauge = data.filmGauge;
+ if ( data.filmOffset !== undefined ) object.filmOffset = data.filmOffset;
+ if ( data.view !== undefined ) object.view = Object.assign( {}, data.view );
+
+ break;
+
+ case 'OrthographicCamera':
+
+ object = new OrthographicCamera( data.left, data.right, data.top, data.bottom, data.near, data.far );
+
+ if ( data.zoom !== undefined ) object.zoom = data.zoom;
+ if ( data.view !== undefined ) object.view = Object.assign( {}, data.view );
+
+ break;
+
+ case 'AmbientLight':
+
+ object = new AmbientLight( data.color, data.intensity );
+
+ break;
+
+ case 'DirectionalLight':
+
+ object = new DirectionalLight( data.color, data.intensity );
+ object.target = data.target || '';
+
+ break;
+
+ case 'PointLight':
+
+ object = new PointLight( data.color, data.intensity, data.distance, data.decay );
+
+ break;
+
+ case 'RectAreaLight':
+
+ object = new RectAreaLight( data.color, data.intensity, data.width, data.height );
+
+ break;
+
+ case 'SpotLight':
+
+ object = new SpotLight( data.color, data.intensity, data.distance, data.angle, data.penumbra, data.decay );
+ object.target = data.target || '';
+
+ break;
+
+ case 'HemisphereLight':
+
+ object = new HemisphereLight( data.color, data.groundColor, data.intensity );
+
+ break;
+
+ case 'LightProbe':
+
+ const sh = new SphericalHarmonics3().fromArray( data.sh );
+ object = new LightProbe( sh, data.intensity );
+
+ break;
+
+ case 'SkinnedMesh':
+
+ geometry = getGeometry( data.geometry );
+ material = getMaterial( data.material );
+
+ object = new SkinnedMesh( geometry, material );
+
+ if ( data.bindMode !== undefined ) object.bindMode = data.bindMode;
+ if ( data.bindMatrix !== undefined ) object.bindMatrix.fromArray( data.bindMatrix );
+ if ( data.skeleton !== undefined ) object.skeleton = data.skeleton;
+
+ break;
+
+ case 'Mesh':
+
+ geometry = getGeometry( data.geometry );
+ material = getMaterial( data.material );
+
+ object = new Mesh( geometry, material );
+
+ break;
+
+ case 'InstancedMesh':
+
+ geometry = getGeometry( data.geometry );
+ material = getMaterial( data.material );
+ const count = data.count;
+ const instanceMatrix = data.instanceMatrix;
+ const instanceColor = data.instanceColor;
+
+ object = new InstancedMesh( geometry, material, count );
+ object.instanceMatrix = new InstancedBufferAttribute( new Float32Array( instanceMatrix.array ), 16 );
+ if ( instanceColor !== undefined ) object.instanceColor = new InstancedBufferAttribute( new Float32Array( instanceColor.array ), instanceColor.itemSize );
+
+ break;
+
+ case 'BatchedMesh':
+
+ geometry = getGeometry( data.geometry );
+ material = getMaterial( data.material );
+
+ object = new BatchedMesh( data.maxInstanceCount, data.maxVertexCount, data.maxIndexCount, material );
+ object.geometry = geometry;
+ object.perObjectFrustumCulled = data.perObjectFrustumCulled;
+ object.sortObjects = data.sortObjects;
+
+ object._drawRanges = data.drawRanges;
+ object._reservedRanges = data.reservedRanges;
+
+ object._geometryInfo = data.geometryInfo.map( info => {
+
+ let box = null;
+ let sphere = null;
+ if ( info.boundingBox !== undefined ) {
+
+ box = new Box3().fromJSON( info.boundingBox );
+
+ }
+
+ if ( info.boundingSphere !== undefined ) {
+
+ sphere = new Sphere().fromJSON( info.boundingSphere );
+
+ }
+
+ return {
+ ...info,
+ boundingBox: box,
+ boundingSphere: sphere
+ };
+
+ } );
+ object._instanceInfo = data.instanceInfo;
+
+ object._availableInstanceIds = data._availableInstanceIds;
+ object._availableGeometryIds = data._availableGeometryIds;
+
+ object._nextIndexStart = data.nextIndexStart;
+ object._nextVertexStart = data.nextVertexStart;
+ object._geometryCount = data.geometryCount;
+
+ object._maxInstanceCount = data.maxInstanceCount;
+ object._maxVertexCount = data.maxVertexCount;
+ object._maxIndexCount = data.maxIndexCount;
+
+ object._geometryInitialized = data.geometryInitialized;
+
+ object._matricesTexture = getTexture( data.matricesTexture.uuid );
+
+ object._indirectTexture = getTexture( data.indirectTexture.uuid );
+
+ if ( data.colorsTexture !== undefined ) {
+
+ object._colorsTexture = getTexture( data.colorsTexture.uuid );
+
+ }
+
+ if ( data.boundingSphere !== undefined ) {
+
+ object.boundingSphere = new Sphere().fromJSON( data.boundingSphere );
+
+ }
+
+ if ( data.boundingBox !== undefined ) {
+
+ object.boundingBox = new Box3().fromJSON( data.boundingBox );
+
+ }
+
+ break;
+
+ case 'LOD':
+
+ object = new LOD();
+
+ break;
+
+ case 'Line':
+
+ object = new Line( getGeometry( data.geometry ), getMaterial( data.material ) );
+
+ break;
+
+ case 'LineLoop':
+
+ object = new LineLoop( getGeometry( data.geometry ), getMaterial( data.material ) );
+
+ break;
+
+ case 'LineSegments':
+
+ object = new LineSegments( getGeometry( data.geometry ), getMaterial( data.material ) );
+
+ break;
+
+ case 'PointCloud':
+ case 'Points':
+
+ object = new Points( getGeometry( data.geometry ), getMaterial( data.material ) );
+
+ break;
+
+ case 'Sprite':
+
+ object = new Sprite( getMaterial( data.material ) );
+
+ break;
+
+ case 'Group':
+
+ object = new Group();
+
+ break;
+
+ case 'Bone':
+
+ object = new Bone();
+
+ break;
+
+ default:
+
+ object = new Object3D();
+
+ }
+
+ object.uuid = data.uuid;
+
+ if ( data.name !== undefined ) object.name = data.name;
+
+ if ( data.matrix !== undefined ) {
+
+ object.matrix.fromArray( data.matrix );
+
+ if ( data.matrixAutoUpdate !== undefined ) object.matrixAutoUpdate = data.matrixAutoUpdate;
+ if ( object.matrixAutoUpdate ) object.matrix.decompose( object.position, object.quaternion, object.scale );
+
+ } else {
+
+ if ( data.position !== undefined ) object.position.fromArray( data.position );
+ if ( data.rotation !== undefined ) object.rotation.fromArray( data.rotation );
+ if ( data.quaternion !== undefined ) object.quaternion.fromArray( data.quaternion );
+ if ( data.scale !== undefined ) object.scale.fromArray( data.scale );
+
+ }
+
+ if ( data.up !== undefined ) object.up.fromArray( data.up );
+
+ if ( data.pivot !== undefined ) object.pivot = new Vector3().fromArray( data.pivot );
+
+ if ( data.morphTargetDictionary !== undefined ) object.morphTargetDictionary = Object.assign( {}, data.morphTargetDictionary );
+ if ( data.morphTargetInfluences !== undefined ) object.morphTargetInfluences = data.morphTargetInfluences.slice();
+
+ if ( data.castShadow !== undefined ) object.castShadow = data.castShadow;
+ if ( data.receiveShadow !== undefined ) object.receiveShadow = data.receiveShadow;
+
+ if ( data.shadow ) {
+
+ if ( data.shadow.intensity !== undefined ) object.shadow.intensity = data.shadow.intensity;
+ if ( data.shadow.bias !== undefined ) object.shadow.bias = data.shadow.bias;
+ if ( data.shadow.normalBias !== undefined ) object.shadow.normalBias = data.shadow.normalBias;
+ if ( data.shadow.radius !== undefined ) object.shadow.radius = data.shadow.radius;
+ if ( data.shadow.blurSamples !== undefined ) object.shadow.blurSamples = data.shadow.blurSamples;
+ if ( data.shadow.focus !== undefined ) object.shadow.focus = data.shadow.focus;
+ if ( data.shadow.aspect !== undefined ) object.shadow.aspect = data.shadow.aspect;
+ if ( data.shadow.mapSize !== undefined ) object.shadow.mapSize.fromArray( data.shadow.mapSize );
+ if ( data.shadow.camera !== undefined ) object.shadow.camera = this.parseObject( data.shadow.camera );
+
+ }
+
+ if ( data.visible !== undefined ) object.visible = data.visible;
+ if ( data.frustumCulled !== undefined ) object.frustumCulled = data.frustumCulled;
+ if ( data.renderOrder !== undefined ) object.renderOrder = data.renderOrder;
+ if ( data.static !== undefined ) object.static = data.static;
+ if ( data.userData !== undefined ) object.userData = data.userData;
+ if ( data.layers !== undefined ) object.layers.mask = data.layers;
+
+ if ( data.children !== undefined ) {
+
+ const children = data.children;
+
+ for ( let i = 0; i < children.length; i ++ ) {
+
+ object.add( this.parseObject( children[ i ], geometries, materials, textures, animations ) );
+
+ }
+
+ }
+
+ if ( data.animations !== undefined ) {
+
+ const objectAnimations = data.animations;
+
+ for ( let i = 0; i < objectAnimations.length; i ++ ) {
+
+ const uuid = objectAnimations[ i ];
+
+ object.animations.push( animations[ uuid ] );
+
+ }
+
+ }
+
+ if ( data.type === 'LOD' ) {
+
+ if ( data.autoUpdate !== undefined ) object.autoUpdate = data.autoUpdate;
+
+ const levels = data.levels;
+
+ for ( let l = 0; l < levels.length; l ++ ) {
+
+ const level = levels[ l ];
+ const child = object.getObjectByProperty( 'uuid', level.object );
+
+ if ( child !== undefined ) {
+
+ object.addLevel( child, level.distance, level.hysteresis );
+
+ }
+
+ }
+
+ }
+
+ return object;
+
+ }
+
+ bindSkeletons( object, skeletons ) {
+
+ if ( Object.keys( skeletons ).length === 0 ) return;
+
+ object.traverse( function ( child ) {
+
+ if ( child.isSkinnedMesh === true && child.skeleton !== undefined ) {
+
+ const skeleton = skeletons[ child.skeleton ];
+
+ if ( skeleton === undefined ) {
+
+ warn( 'ObjectLoader: No skeleton found with UUID:', child.skeleton );
+
+ } else {
+
+ child.bind( skeleton, child.bindMatrix );
+
+ }
+
+ }
+
+ } );
+
+ }
+
+ bindLightTargets( object ) {
+
+ object.traverse( function ( child ) {
+
+ if ( child.isDirectionalLight || child.isSpotLight ) {
+
+ const uuid = child.target;
+
+ const target = object.getObjectByProperty( 'uuid', uuid );
+
+ if ( target !== undefined ) {
+
+ child.target = target;
+
+ } else {
+
+ child.target = new Object3D();
+
+ }
+
+ }
+
+ } );
+
+ }
+
+}
+
+const TEXTURE_MAPPING = {
+ UVMapping: UVMapping,
+ CubeReflectionMapping: CubeReflectionMapping,
+ CubeRefractionMapping: CubeRefractionMapping,
+ EquirectangularReflectionMapping: EquirectangularReflectionMapping,
+ EquirectangularRefractionMapping: EquirectangularRefractionMapping,
+ CubeUVReflectionMapping: CubeUVReflectionMapping
+};
+
+const TEXTURE_WRAPPING = {
+ RepeatWrapping: RepeatWrapping,
+ ClampToEdgeWrapping: ClampToEdgeWrapping,
+ MirroredRepeatWrapping: MirroredRepeatWrapping
+};
+
+const TEXTURE_FILTER = {
+ NearestFilter: NearestFilter,
+ NearestMipmapNearestFilter: NearestMipmapNearestFilter,
+ NearestMipmapLinearFilter: NearestMipmapLinearFilter,
+ LinearFilter: LinearFilter,
+ LinearMipmapNearestFilter: LinearMipmapNearestFilter,
+ LinearMipmapLinearFilter: LinearMipmapLinearFilter
+};
+
+const _errorMap = new WeakMap();
+
+/**
+ * A loader for loading images as an [ImageBitmap](https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmap).
+ * An `ImageBitmap` provides an asynchronous and resource efficient pathway to prepare
+ * textures for rendering.
+ *
+ * Note that {@link Texture#flipY} and {@link Texture#premultiplyAlpha} are ignored with image bitmaps.
+ * These options need to be configured via {@link ImageBitmapLoader#setOptions} prior to loading,
+ * unlike regular images which can be configured on the Texture to set these options on GPU upload instead.
+ *
+ * To match the default behaviour of {@link Texture}, the following options are needed:
+ *
+ * ```js
+ * { imageOrientation: 'flipY', premultiplyAlpha: 'none' }
+ * ```
+ *
+ * Also note that unlike {@link FileLoader}, this loader will only avoid multiple concurrent requests to the same URL if {@link Cache} is enabled.
+ *
+ * ```js
+ * const loader = new THREE.ImageBitmapLoader();
+ * loader.setOptions( { imageOrientation: 'flipY' } ); // set options if needed
+ * const imageBitmap = await loader.loadAsync( 'image.png' );
+ *
+ * const texture = new THREE.Texture( imageBitmap );
+ * texture.needsUpdate = true;
+ * ```
+ *
+ * @augments Loader
+ */
+class ImageBitmapLoader extends Loader {
+
+ /**
+ * Constructs a new image bitmap loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isImageBitmapLoader = true;
+
+ if ( typeof createImageBitmap === 'undefined' ) {
+
+ warn( 'ImageBitmapLoader: createImageBitmap() not supported.' );
+
+ }
+
+ if ( typeof fetch === 'undefined' ) {
+
+ warn( 'ImageBitmapLoader: fetch() not supported.' );
+
+ }
+
+ /**
+ * Represents the loader options.
+ *
+ * @type {Object}
+ * @default {premultiplyAlpha:'none'}
+ */
+ this.options = { premultiplyAlpha: 'none' };
+
+ /**
+ * Used for aborting requests.
+ *
+ * @private
+ * @type {AbortController}
+ */
+ this._abortController = new AbortController();
+
+ }
+
+ /**
+ * Sets the given loader options. The structure of the object must match the `options` parameter of
+ * [createImageBitmap](https://developer.mozilla.org/en-US/docs/Web/API/Window/createImageBitmap).
+ *
+ * Note: When caching is enabled, the cache key is based on the URL only. Loading the same URL with
+ * different options will return the cached result of the first request.
+ *
+ * @param {Object} options - The loader options to set.
+ * @return {ImageBitmapLoader} A reference to this image bitmap loader.
+ */
+ setOptions( options ) {
+
+ this.options = options;
+
+ return this;
+
+ }
+
+ /**
+ * Starts loading from the given URL and pass the loaded image bitmap to the `onLoad()` callback.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(ImageBitmap)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Unsupported in this loader.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ if ( url === undefined ) url = '';
+
+ if ( this.path !== undefined ) url = this.path + url;
+
+ url = this.manager.resolveURL( url );
+
+ const scope = this;
+
+ const cached = Cache.get( `image-bitmap:${url}` );
+
+ if ( cached !== undefined ) {
+
+ scope.manager.itemStart( url );
+
+ // If cached is a promise, wait for it to resolve
+ if ( cached.then ) {
+
+ cached.then( imageBitmap => {
+
+ // check if there is an error for the cached promise
+
+ if ( _errorMap.has( cached ) === true ) {
+
+ if ( onError ) onError( _errorMap.get( cached ) );
+
+ scope.manager.itemError( url );
+ scope.manager.itemEnd( url );
+
+ } else {
+
+ if ( onLoad ) onLoad( imageBitmap );
+
+ scope.manager.itemEnd( url );
+
+ }
+
+ } );
+
+ return;
+
+ }
+
+ // If cached is not a promise (i.e., it's already an imageBitmap)
+ setTimeout( function () {
+
+ if ( onLoad ) onLoad( cached );
+
+ scope.manager.itemEnd( url );
+
+ }, 0 );
+
+ return;
+
+ }
+
+ const fetchOptions = {};
+ fetchOptions.credentials = ( this.crossOrigin === 'anonymous' ) ? 'same-origin' : 'include';
+ fetchOptions.headers = this.requestHeader;
+ fetchOptions.signal = ( typeof AbortSignal.any === 'function' ) ? AbortSignal.any( [ this._abortController.signal, this.manager.abortController.signal ] ) : this._abortController.signal;
+
+ const promise = fetch( url, fetchOptions ).then( function ( res ) {
+
+ return res.blob();
+
+ } ).then( function ( blob ) {
+
+ return createImageBitmap( blob, Object.assign( {}, scope.options, { colorSpaceConversion: 'none' } ) );
+
+ } ).then( function ( imageBitmap ) {
+
+ Cache.add( `image-bitmap:${url}`, imageBitmap );
+
+ if ( onLoad ) onLoad( imageBitmap );
+
+ scope.manager.itemEnd( url );
+
+ return imageBitmap; // see #34150
+
+ } ).catch( function ( e ) {
+
+ if ( onError ) onError( e );
+
+ _errorMap.set( promise, e );
+
+ Cache.remove( `image-bitmap:${url}` );
+
+ scope.manager.itemError( url );
+ scope.manager.itemEnd( url );
+
+ } );
+
+ Cache.add( `image-bitmap:${url}`, promise );
+ scope.manager.itemStart( url );
+
+ }
+
+ /**
+ * Aborts ongoing fetch requests.
+ *
+ * @return {ImageBitmapLoader} A reference to this instance.
+ */
+ abort() {
+
+ this._abortController.abort();
+ this._abortController = new AbortController();
+
+ return this;
+
+ }
+
+}
+
+let _context;
+
+/**
+ * Manages the global audio context in the engine.
+ *
+ * @hideconstructor
+ */
+class AudioContext {
+
+ /**
+ * Returns the global native audio context.
+ *
+ * @return {Window.AudioContext} The native audio context.
+ */
+ static getContext() {
+
+ if ( _context === undefined ) {
+
+ _context = new ( window.AudioContext || window.webkitAudioContext )();
+
+ }
+
+ return _context;
+
+ }
+
+ /**
+ * Allows to set the global native audio context from outside.
+ *
+ * @param {Window.AudioContext} value - The native context to set.
+ */
+ static setContext( value ) {
+
+ _context = value;
+
+ }
+
+}
+
+/**
+ * Class for loading audio buffers. Audios are internally
+ * loaded via {@link FileLoader}.
+ *
+ * ```js
+ * const audioListener = new THREE.AudioListener();
+ * const ambientSound = new THREE.Audio( audioListener );
+ *
+ * const loader = new THREE.AudioLoader();
+ * const audioBuffer = await loader.loadAsync( 'audio/ambient_ocean.ogg' );
+ *
+ * ambientSound.setBuffer( audioBuffer );
+ * ambientSound.play();
+ * ```
+ *
+ * @augments Loader
+ */
+class AudioLoader extends Loader {
+
+ /**
+ * Constructs a new audio loader.
+ *
+ * @param {LoadingManager} [manager] - The loading manager.
+ */
+ constructor( manager ) {
+
+ super( manager );
+
+ }
+
+ /**
+ * Starts loading from the given URL and passes the loaded audio buffer
+ * to the `onLoad()` callback.
+ *
+ * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+ * @param {function(AudioBuffer)} onLoad - Executed when the loading process has been finished.
+ * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+ * @param {onErrorCallback} onError - Executed when errors occur.
+ */
+ load( url, onLoad, onProgress, onError ) {
+
+ const scope = this;
+
+ const loader = new FileLoader( this.manager );
+ loader.setResponseType( 'arraybuffer' );
+ loader.setPath( this.path );
+ loader.setRequestHeader( this.requestHeader );
+ loader.setWithCredentials( this.withCredentials );
+ loader.load( url, function ( buffer ) {
+
+ try {
+
+ // Create a copy of the buffer. The `decodeAudioData` method
+ // detaches the buffer when complete, preventing reuse.
+ const bufferCopy = buffer.slice( 0 );
+
+ const context = AudioContext.getContext();
+
+ const decodeUrl = url + '#decode';
+ scope.manager.itemStart( decodeUrl ); // prevent loading manager from completing too early, see #33378
+
+ context.decodeAudioData( bufferCopy, function ( audioBuffer ) {
+
+ onLoad( audioBuffer );
+ scope.manager.itemEnd( decodeUrl );
+
+ } ).catch( function ( e ) {
+
+ handleError( e );
+ scope.manager.itemEnd( decodeUrl );
+
+ } );
+
+ } catch ( e ) {
+
+ handleError( e );
+
+ }
+
+ }, onProgress, onError );
+
+ function handleError( e ) {
+
+ if ( onError ) {
+
+ onError( e );
+
+ } else {
+
+ error( e );
+
+ }
+
+ scope.manager.itemError( url );
+
+ }
+
+ }
+
+}
+
+const _eyeRight = /*@__PURE__*/ new Matrix4();
+const _eyeLeft = /*@__PURE__*/ new Matrix4();
+const _projectionMatrix = /*@__PURE__*/ new Matrix4();
+
+/**
+ * A special type of camera that uses two perspective cameras with
+ * stereoscopic projection. Can be used for rendering stereo effects
+ * like [3D Anaglyph](https://en.wikipedia.org/wiki/Anaglyph_3D) or
+ * [Parallax Barrier](https://en.wikipedia.org/wiki/parallax_barrier).
+ */
+class StereoCamera {
+
+ /**
+ * Constructs a new stereo camera.
+ */
+ constructor() {
+
+ /**
+ * The type property is used for detecting the object type
+ * in context of serialization/deserialization.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.type = 'StereoCamera';
+
+ /**
+ * The aspect.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.aspect = 1;
+
+ /**
+ * The eye separation which represents the distance
+ * between the left and right camera.
+ *
+ * @type {number}
+ * @default 0.064
+ */
+ this.eyeSep = 0.064;
+
+ /**
+ * The camera representing the left eye. This is added to layer `1` so objects to be
+ * rendered by the left camera must also be added to this layer.
+ *
+ * @type {PerspectiveCamera}
+ */
+ this.cameraL = new PerspectiveCamera();
+ this.cameraL.layers.enable( 1 );
+ this.cameraL.matrixAutoUpdate = false;
+
+ /**
+ * The camera representing the right eye. This is added to layer `2` so objects to be
+ * rendered by the right camera must also be added to this layer.
+ *
+ * @type {PerspectiveCamera}
+ */
+ this.cameraR = new PerspectiveCamera();
+ this.cameraR.layers.enable( 2 );
+ this.cameraR.matrixAutoUpdate = false;
+
+ this._cache = {
+ focus: null,
+ fov: null,
+ aspect: null,
+ near: null,
+ far: null,
+ zoom: null,
+ eyeSep: null
+ };
+
+ }
+
+ /**
+ * Updates the stereo camera based on the given perspective camera.
+ *
+ * @param {PerspectiveCamera} camera - The perspective camera.
+ */
+ update( camera ) {
+
+ const cache = this._cache;
+
+ const needsUpdate = cache.focus !== camera.focus || cache.fov !== camera.fov ||
+ cache.aspect !== camera.aspect * this.aspect || cache.near !== camera.near ||
+ cache.far !== camera.far || cache.zoom !== camera.zoom || cache.eyeSep !== this.eyeSep;
+
+ if ( needsUpdate ) {
+
+ cache.focus = camera.focus;
+ cache.fov = camera.fov;
+ cache.aspect = camera.aspect * this.aspect;
+ cache.near = camera.near;
+ cache.far = camera.far;
+ cache.zoom = camera.zoom;
+ cache.eyeSep = this.eyeSep;
+
+ // Off-axis stereoscopic effect based on
+ // http://paulbourke.net/stereographics/stereorender/
+
+ _projectionMatrix.copy( camera.projectionMatrix );
+ const eyeSepHalf = cache.eyeSep / 2;
+ const eyeSepOnProjection = eyeSepHalf * cache.near / cache.focus;
+ const ymax = ( cache.near * Math.tan( DEG2RAD * cache.fov * 0.5 ) ) / cache.zoom;
+ let xmin, xmax;
+
+ // translate xOffset
+
+ _eyeLeft.elements[ 12 ] = - eyeSepHalf;
+ _eyeRight.elements[ 12 ] = eyeSepHalf;
+
+ // for left eye
+
+ xmin = - ymax * cache.aspect + eyeSepOnProjection;
+ xmax = ymax * cache.aspect + eyeSepOnProjection;
+
+ _projectionMatrix.elements[ 0 ] = 2 * cache.near / ( xmax - xmin );
+ _projectionMatrix.elements[ 8 ] = ( xmax + xmin ) / ( xmax - xmin );
+
+ this.cameraL.projectionMatrix.copy( _projectionMatrix );
+
+ // for right eye
+
+ xmin = - ymax * cache.aspect - eyeSepOnProjection;
+ xmax = ymax * cache.aspect - eyeSepOnProjection;
+
+ _projectionMatrix.elements[ 0 ] = 2 * cache.near / ( xmax - xmin );
+ _projectionMatrix.elements[ 8 ] = ( xmax + xmin ) / ( xmax - xmin );
+
+ this.cameraR.projectionMatrix.copy( _projectionMatrix );
+
+ }
+
+ this.cameraL.matrix.copy( camera.matrixWorld ).multiply( _eyeLeft );
+ this.cameraL.matrixWorldNeedsUpdate = true;
+
+ this.cameraR.matrix.copy( camera.matrixWorld ).multiply( _eyeRight );
+ this.cameraR.matrixWorldNeedsUpdate = true;
+
+ }
+
+}
+
+const fov = -90; // negative fov is not an error
+const aspect = 1;
+
+/**
+ * A special type of camera that is positioned in 3D space to render its surroundings into a
+ * cube render target. The render target can then be used as an environment map for rendering
+ * realtime reflections in your scene.
+ *
+ * ```js
+ * // Create cube render target
+ * const cubeRenderTarget = new THREE.WebGLCubeRenderTarget( 256, { generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter } );
+ *
+ * // Create cube camera
+ * const cubeCamera = new THREE.CubeCamera( 1, 100000, cubeRenderTarget );
+ * scene.add( cubeCamera );
+ *
+ * // Create car
+ * const chromeMaterial = new THREE.MeshLambertMaterial( { color: 0xffffff, envMap: cubeRenderTarget.texture } );
+ * const car = new THREE.Mesh( carGeometry, chromeMaterial );
+ * scene.add( car );
+ *
+ * // Update the render target cube
+ * car.visible = false;
+ * cubeCamera.position.copy( car.position );
+ * cubeCamera.update( renderer, scene );
+ *
+ * // Render the scene
+ * car.visible = true;
+ * renderer.render( scene, camera );
+ * ```
+ *
+ * @augments Object3D
+ */
+class CubeCamera extends Object3D {
+
+ /**
+ * Constructs a new cube camera.
+ *
+ * @param {number} near - The camera's near plane.
+ * @param {number} far - The camera's far plane.
+ * @param {WebGLCubeRenderTarget} renderTarget - The cube render target.
+ */
+ constructor( near, far, renderTarget ) {
+
+ super();
+
+ this.type = 'CubeCamera';
+
+ /**
+ * A reference to the cube render target.
+ *
+ * @type {WebGLCubeRenderTarget}
+ */
+ this.renderTarget = renderTarget;
+
+ /**
+ * The current active coordinate system.
+ *
+ * @type {?(WebGLCoordinateSystem|WebGPUCoordinateSystem)}
+ * @default null
+ */
+ this.coordinateSystem = null;
+
+ /**
+ * The current active mipmap level
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.activeMipmapLevel = 0;
+
+ const cameraPX = new PerspectiveCamera( fov, aspect, near, far );
+ cameraPX.layers = this.layers;
+ this.add( cameraPX );
+
+ const cameraNX = new PerspectiveCamera( fov, aspect, near, far );
+ cameraNX.layers = this.layers;
+ this.add( cameraNX );
+
+ const cameraPY = new PerspectiveCamera( fov, aspect, near, far );
+ cameraPY.layers = this.layers;
+ this.add( cameraPY );
+
+ const cameraNY = new PerspectiveCamera( fov, aspect, near, far );
+ cameraNY.layers = this.layers;
+ this.add( cameraNY );
+
+ const cameraPZ = new PerspectiveCamera( fov, aspect, near, far );
+ cameraPZ.layers = this.layers;
+ this.add( cameraPZ );
+
+ const cameraNZ = new PerspectiveCamera( fov, aspect, near, far );
+ cameraNZ.layers = this.layers;
+ this.add( cameraNZ );
+
+ }
+
+ /**
+ * Must be called when the coordinate system of the cube camera is changed.
+ */
+ updateCoordinateSystem() {
+
+ const coordinateSystem = this.coordinateSystem;
+
+ const cameras = this.children.concat();
+
+ const [ cameraPX, cameraNX, cameraPY, cameraNY, cameraPZ, cameraNZ ] = cameras;
+
+ for ( const camera of cameras ) this.remove( camera );
+
+ if ( coordinateSystem === WebGLCoordinateSystem ) {
+
+ cameraPX.up.set( 0, 1, 0 );
+ cameraPX.lookAt( 1, 0, 0 );
+
+ cameraNX.up.set( 0, 1, 0 );
+ cameraNX.lookAt( -1, 0, 0 );
+
+ cameraPY.up.set( 0, 0, -1 );
+ cameraPY.lookAt( 0, 1, 0 );
+
+ cameraNY.up.set( 0, 0, 1 );
+ cameraNY.lookAt( 0, -1, 0 );
+
+ cameraPZ.up.set( 0, 1, 0 );
+ cameraPZ.lookAt( 0, 0, 1 );
+
+ cameraNZ.up.set( 0, 1, 0 );
+ cameraNZ.lookAt( 0, 0, -1 );
+
+ } else if ( coordinateSystem === WebGPUCoordinateSystem ) {
+
+ cameraPX.up.set( 0, -1, 0 );
+ cameraPX.lookAt( -1, 0, 0 );
+
+ cameraNX.up.set( 0, -1, 0 );
+ cameraNX.lookAt( 1, 0, 0 );
+
+ cameraPY.up.set( 0, 0, 1 );
+ cameraPY.lookAt( 0, 1, 0 );
+
+ cameraNY.up.set( 0, 0, -1 );
+ cameraNY.lookAt( 0, -1, 0 );
+
+ cameraPZ.up.set( 0, -1, 0 );
+ cameraPZ.lookAt( 0, 0, 1 );
+
+ cameraNZ.up.set( 0, -1, 0 );
+ cameraNZ.lookAt( 0, 0, -1 );
+
+ } else {
+
+ throw new Error( 'THREE.CubeCamera.updateCoordinateSystem(): Invalid coordinate system: ' + coordinateSystem );
+
+ }
+
+ for ( const camera of cameras ) {
+
+ this.add( camera );
+
+ camera.updateMatrixWorld();
+
+ }
+
+ }
+
+ /**
+ * Calling this method will render the given scene with the given renderer
+ * into the cube render target of the camera.
+ *
+ * @param {(Renderer|WebGLRenderer)} renderer - The renderer.
+ * @param {Scene} scene - The scene to render.
+ */
+ update( renderer, scene ) {
+
+ if ( this.parent === null ) this.updateMatrixWorld();
+
+ const { renderTarget, activeMipmapLevel } = this;
+
+ if ( this.coordinateSystem !== renderer.coordinateSystem ) {
+
+ this.coordinateSystem = renderer.coordinateSystem;
+
+ this.updateCoordinateSystem();
+
+ }
+
+ const [ cameraPX, cameraNX, cameraPY, cameraNY, cameraPZ, cameraNZ ] = this.children;
+
+ const currentRenderTarget = renderer.getRenderTarget();
+ const currentActiveCubeFace = renderer.getActiveCubeFace();
+ const currentActiveMipmapLevel = renderer.getActiveMipmapLevel();
+
+ const currentXrEnabled = renderer.xr.enabled;
+
+ renderer.xr.enabled = false;
+
+ const generateMipmaps = renderTarget.texture.generateMipmaps;
+
+ renderTarget.texture.generateMipmaps = false;
+
+ // https://github.com/mrdoob/three.js/issues/31413#issuecomment-3095966812
+
+ let reversedDepthBuffer = false;
+
+ if ( renderer.isWebGLRenderer === true ) {
+
+ reversedDepthBuffer = renderer.state.buffers.depth.getReversed();
+
+ } else {
+
+ reversedDepthBuffer = renderer.reversedDepthBuffer;
+
+ }
+
+ renderer.setRenderTarget( renderTarget, 0, activeMipmapLevel );
+ if ( reversedDepthBuffer && renderer.autoClear === false ) renderer.clearDepth();
+ renderer.render( scene, cameraPX );
+
+ renderer.setRenderTarget( renderTarget, 1, activeMipmapLevel );
+ if ( reversedDepthBuffer && renderer.autoClear === false ) renderer.clearDepth();
+ renderer.render( scene, cameraNX );
+
+ renderer.setRenderTarget( renderTarget, 2, activeMipmapLevel );
+ if ( reversedDepthBuffer && renderer.autoClear === false ) renderer.clearDepth();
+ renderer.render( scene, cameraPY );
+
+ renderer.setRenderTarget( renderTarget, 3, activeMipmapLevel );
+ if ( reversedDepthBuffer && renderer.autoClear === false ) renderer.clearDepth();
+ renderer.render( scene, cameraNY );
+
+ renderer.setRenderTarget( renderTarget, 4, activeMipmapLevel );
+ if ( reversedDepthBuffer && renderer.autoClear === false ) renderer.clearDepth();
+ renderer.render( scene, cameraPZ );
+
+ // mipmaps are generated during the last call of render()
+ // at this point, all sides of the cube render target are defined
+
+ renderTarget.texture.generateMipmaps = generateMipmaps;
+
+ renderer.setRenderTarget( renderTarget, 5, activeMipmapLevel );
+ if ( reversedDepthBuffer && renderer.autoClear === false ) renderer.clearDepth();
+ renderer.render( scene, cameraNZ );
+
+ renderer.setRenderTarget( currentRenderTarget, currentActiveCubeFace, currentActiveMipmapLevel );
+
+ renderer.xr.enabled = currentXrEnabled;
+
+ renderTarget.texture.needsPMREMUpdate = true;
+
+ }
+
+}
+
+/**
+ * This type of camera can be used in order to efficiently render a scene with a
+ * predefined set of cameras. This is an important performance aspect for
+ * rendering VR scenes.
+ *
+ * An instance of `ArrayCamera` always has an array of sub cameras. It's mandatory
+ * to define for each sub camera the `viewport` property which determines the
+ * part of the viewport that is rendered with this camera.
+ *
+ * @augments PerspectiveCamera
+ */
+class ArrayCamera extends PerspectiveCamera {
+
+ /**
+ * Constructs a new array camera.
+ *
+ * @param {Array} [array=[]] - An array of perspective sub cameras.
+ */
+ constructor( array = [] ) {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isArrayCamera = true;
+
+ /**
+ * Whether this camera is used with multiview rendering or not.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default false
+ */
+ this.isMultiViewCamera = false;
+
+ /**
+ * An array of perspective sub cameras.
+ *
+ * @type {Array}
+ */
+ this.cameras = array;
+
+ }
+
+}
+
+/**
+ * This class is an alternative to {@link Clock} with a different API design and behavior.
+ * The goal is to avoid the conceptual flaws that became apparent in `Clock` over time.
+ *
+ * - `Timer` has an `update()` method that updates its internal state. That makes it possible to
+ * call `getDelta()` and `getElapsed()` multiple times per simulation step without getting different values.
+ * - The class can make use of the Page Visibility API to avoid large time delta values when the app
+ * is inactive (e.g. tab switched or browser hidden).
+ *
+ * ```js
+ * const timer = new Timer();
+ * timer.connect( document ); // use Page Visibility API
+ * ```
+ */
+class Timer {
+
+ /**
+ * Constructs a new timer.
+ */
+ constructor() {
+
+ this._previousTime = 0;
+ this._currentTime = 0;
+ this._startTime = performance.now();
+
+ this._delta = 0;
+ this._elapsed = 0;
+
+ this._timescale = 1;
+
+ this._document = null;
+ this._pageVisibilityHandler = null;
+
+ }
+
+ /**
+ * Connect the timer to the given document.Calling this method is not mandatory to
+ * use the timer but enables the usage of the Page Visibility API to avoid large time
+ * delta values.
+ *
+ * @param {Document} document - The document.
+ */
+ connect( document ) {
+
+ this._document = document;
+
+ // use Page Visibility API to avoid large time delta values
+
+ if ( document.hidden !== undefined ) {
+
+ this._pageVisibilityHandler = handleVisibilityChange.bind( this );
+
+ document.addEventListener( 'visibilitychange', this._pageVisibilityHandler, false );
+
+ }
+
+ }
+
+ /**
+ * Disconnects the timer from the DOM and also disables the usage of the Page Visibility API.
+ */
+ disconnect() {
+
+ if ( this._pageVisibilityHandler !== null ) {
+
+ this._document.removeEventListener( 'visibilitychange', this._pageVisibilityHandler );
+ this._pageVisibilityHandler = null;
+
+ }
+
+ this._document = null;
+
+ }
+
+ /**
+ * Returns the time delta in seconds.
+ *
+ * @return {number} The time delta in second.
+ */
+ getDelta() {
+
+ return this._delta / 1000;
+
+ }
+
+ /**
+ * Returns the elapsed time in seconds.
+ *
+ * @return {number} The elapsed time in second.
+ */
+ getElapsed() {
+
+ return this._elapsed / 1000;
+
+ }
+
+ /**
+ * Returns the timescale.
+ *
+ * @return {number} The timescale.
+ */
+ getTimescale() {
+
+ return this._timescale;
+
+ }
+
+ /**
+ * Sets the given timescale which scale the time delta computation
+ * in `update()`.
+ *
+ * @param {number} timescale - The timescale to set.
+ * @return {Timer} A reference to this timer.
+ */
+ setTimescale( timescale ) {
+
+ this._timescale = timescale;
+
+ return this;
+
+ }
+
+ /**
+ * Resets the time computation for the current simulation step.
+ *
+ * @return {Timer} A reference to this timer.
+ */
+ reset() {
+
+ this._currentTime = performance.now() - this._startTime;
+
+ return this;
+
+ }
+
+ /**
+ * Can be used to free all internal resources. Usually called when
+ * the timer instance isn't required anymore.
+ */
+ dispose() {
+
+ this.disconnect();
+
+ }
+
+ /**
+ * Updates the internal state of the timer. This method should be called
+ * once per simulation step and before you perform queries against the timer
+ * (e.g. via `getDelta()`).
+ *
+ * @param {number} timestamp - The current time in milliseconds. Can be obtained
+ * from the `requestAnimationFrame` callback argument. If not provided, the current
+ * time will be determined with `performance.now`.
+ * @return {Timer} A reference to this timer.
+ */
+ update( timestamp ) {
+
+ if ( this._pageVisibilityHandler !== null && this._document.hidden === true ) {
+
+ this._delta = 0;
+
+ } else {
+
+ this._previousTime = this._currentTime;
+ this._currentTime = ( timestamp !== undefined ? timestamp : performance.now() ) - this._startTime;
+
+ this._delta = ( this._currentTime - this._previousTime ) * this._timescale;
+ this._elapsed += this._delta; // _elapsed is the accumulation of all previous deltas
+
+ }
+
+ return this;
+
+ }
+
+}
+
+function handleVisibilityChange() {
+
+ if ( this._document.hidden === false ) this.reset();
+
+}
+
+const _position$1 = /*@__PURE__*/ new Vector3();
+const _quaternion$1 = /*@__PURE__*/ new Quaternion();
+const _scale$1 = /*@__PURE__*/ new Vector3();
+
+const _forward = /*@__PURE__*/ new Vector3();
+const _up = /*@__PURE__*/ new Vector3();
+
+/**
+ * The class represents a virtual listener of the all positional and non-positional audio effects
+ * in the scene. A three.js application usually creates a single listener. It is a mandatory
+ * constructor parameter for audios entities like {@link Audio} and {@link PositionalAudio}.
+ *
+ * In most cases, the listener object is a child of the camera. So the 3D transformation of the
+ * camera represents the 3D transformation of the listener.
+ *
+ * @augments Object3D
+ */
+class AudioListener extends Object3D {
+
+ /**
+ * Constructs a new audio listener.
+ */
+ constructor() {
+
+ super();
+
+ this.type = 'AudioListener';
+
+ /**
+ * The native audio context.
+ *
+ * @type {AudioContext}
+ * @readonly
+ */
+ this.context = AudioContext.getContext();
+
+ /**
+ * The gain node used for volume control.
+ *
+ * @type {GainNode}
+ * @readonly
+ */
+ this.gain = this.context.createGain();
+ this.gain.connect( this.context.destination );
+
+ /**
+ * An optional filter.
+ *
+ * Defined via {@link AudioListener#setFilter}.
+ *
+ * @type {?AudioNode}
+ * @default null
+ * @readonly
+ */
+ this.filter = null;
+
+ /**
+ * Time delta values required for `linearRampToValueAtTime()` usage.
+ *
+ * @type {number}
+ * @default 0
+ * @readonly
+ */
+ this.timeDelta = 0;
+
+ // private
+
+ this._timer = new Timer();
+
+ }
+
+ /**
+ * Returns the listener's input node.
+ *
+ * This method is used by other audio nodes to connect to this listener.
+ *
+ * @return {GainNode} The input node.
+ */
+ getInput() {
+
+ return this.gain;
+
+ }
+
+ /**
+ * Removes the current filter from this listener.
+ *
+ * @return {AudioListener} A reference to this listener.
+ */
+ removeFilter() {
+
+ if ( this.filter !== null ) {
+
+ this.gain.disconnect( this.filter );
+ this.filter.disconnect( this.context.destination );
+ this.gain.connect( this.context.destination );
+ this.filter = null;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the current set filter.
+ *
+ * @return {?AudioNode} The filter.
+ */
+ getFilter() {
+
+ return this.filter;
+
+ }
+
+ /**
+ * Sets the given filter to this listener.
+ *
+ * @param {AudioNode} value - The filter to set.
+ * @return {AudioListener} A reference to this listener.
+ */
+ setFilter( value ) {
+
+ if ( this.filter !== null ) {
+
+ this.gain.disconnect( this.filter );
+ this.filter.disconnect( this.context.destination );
+
+ } else {
+
+ this.gain.disconnect( this.context.destination );
+
+ }
+
+ this.filter = value;
+ this.gain.connect( this.filter );
+ this.filter.connect( this.context.destination );
+
+ return this;
+
+ }
+
+ /**
+ * Returns the applications master volume.
+ *
+ * @return {number} The master volume.
+ */
+ getMasterVolume() {
+
+ return this.gain.gain.value;
+
+ }
+
+ /**
+ * Sets the applications master volume. This volume setting affects
+ * all audio nodes in the scene.
+ *
+ * @param {number} value - The master volume to set.
+ * @return {AudioListener} A reference to this listener.
+ */
+ setMasterVolume( value ) {
+
+ this.gain.gain.setTargetAtTime( value, this.context.currentTime, 0.01 );
+
+ return this;
+
+ }
+
+ updateMatrixWorld( force ) {
+
+ super.updateMatrixWorld( force );
+
+ this._timer.update();
+
+ const listener = this.context.listener;
+
+ this.timeDelta = this._timer.getDelta();
+
+ this.matrixWorld.decompose( _position$1, _quaternion$1, _scale$1 );
+
+ // the initial forward and up directions must be orthogonal
+ _forward.set( 0, 0, -1 ).applyQuaternion( _quaternion$1 );
+ _up.set( 0, 1, 0 ).applyQuaternion( _quaternion$1 );
+
+ if ( listener.positionX ) {
+
+ // code path for Chrome (see #14393)
+
+ const endTime = this.context.currentTime + this.timeDelta;
+
+ listener.positionX.linearRampToValueAtTime( _position$1.x, endTime );
+ listener.positionY.linearRampToValueAtTime( _position$1.y, endTime );
+ listener.positionZ.linearRampToValueAtTime( _position$1.z, endTime );
+ listener.forwardX.linearRampToValueAtTime( _forward.x, endTime );
+ listener.forwardY.linearRampToValueAtTime( _forward.y, endTime );
+ listener.forwardZ.linearRampToValueAtTime( _forward.z, endTime );
+ listener.upX.linearRampToValueAtTime( _up.x, endTime );
+ listener.upY.linearRampToValueAtTime( _up.y, endTime );
+ listener.upZ.linearRampToValueAtTime( _up.z, endTime );
+
+ } else {
+
+ listener.setPosition( _position$1.x, _position$1.y, _position$1.z );
+ listener.setOrientation( _forward.x, _forward.y, _forward.z, _up.x, _up.y, _up.z );
+
+ }
+
+ }
+
+}
+
+/**
+ * Represents a non-positional ( global ) audio object.
+ *
+ * This and related audio modules make use of the [Web Audio API](https://www.w3.org/TR/webaudio-1.1/).
+ *
+ * ```js
+ * // create an AudioListener and add it to the camera
+ * const listener = new THREE.AudioListener();
+ * camera.add( listener );
+ *
+ * // create a global audio source
+ * const sound = new THREE.Audio( listener );
+ *
+ * // load a sound and set it as the Audio object's buffer
+ * const audioLoader = new THREE.AudioLoader();
+ * audioLoader.load( 'sounds/ambient.ogg', function( buffer ) {
+ * sound.setBuffer( buffer );
+ * sound.setLoop( true );
+ * sound.setVolume( 0.5 );
+ * sound.play();
+ * });
+ * ```
+ *
+ * @augments Object3D
+ */
+class Audio extends Object3D {
+
+ /**
+ * Constructs a new audio.
+ *
+ * @param {AudioListener} listener - The global audio listener.
+ */
+ constructor( listener ) {
+
+ super();
+
+ this.type = 'Audio';
+
+ /**
+ * The global audio listener.
+ *
+ * @type {AudioListener}
+ * @readonly
+ */
+ this.listener = listener;
+
+ /**
+ * The audio context.
+ *
+ * @type {AudioContext}
+ * @readonly
+ */
+ this.context = listener.context;
+
+ /**
+ * The gain node used for volume control.
+ *
+ * @type {GainNode}
+ * @readonly
+ */
+ this.gain = this.context.createGain();
+ this.gain.connect( listener.getInput() );
+
+ /**
+ * Whether to start playback automatically or not.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.autoplay = false;
+
+ /**
+ * A reference to an audio buffer.
+ *
+ * Defined via {@link Audio#setBuffer}.
+ *
+ * @type {?AudioBuffer}
+ * @default null
+ * @readonly
+ */
+ this.buffer = null;
+
+ /**
+ * Modify pitch, measured in cents. +/- 100 is a semitone.
+ * +/- 1200 is an octave.
+ *
+ * Defined via {@link Audio#setDetune}.
+ *
+ * @type {number}
+ * @default 0
+ * @readonly
+ */
+ this.detune = 0;
+
+ /**
+ * Whether the audio should loop or not.
+ *
+ * Defined via {@link Audio#setLoop}.
+ *
+ * @type {boolean}
+ * @default false
+ * @readonly
+ */
+ this.loop = false;
+
+ /**
+ * Defines where in the audio buffer the replay should
+ * start, in seconds.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.loopStart = 0;
+
+ /**
+ * Defines where in the audio buffer the replay should
+ * stop, in seconds.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.loopEnd = 0;
+
+ /**
+ * An offset to the time within the audio buffer the playback
+ * should begin, in seconds.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.offset = 0;
+
+ /**
+ * Overrides the default duration of the audio.
+ *
+ * @type {undefined|number}
+ * @default undefined
+ */
+ this.duration = undefined;
+
+ /**
+ * The playback speed.
+ *
+ * Defined via {@link Audio#setPlaybackRate}.
+ *
+ * @type {number}
+ * @readonly
+ * @default 1
+ */
+ this.playbackRate = 1;
+
+ /**
+ * Indicates whether the audio is playing or not.
+ *
+ * This flag will be automatically set when using {@link Audio#play},
+ * {@link Audio#pause}, {@link Audio#stop}.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default false
+ */
+ this.isPlaying = false;
+
+ /**
+ * Indicates whether the audio playback can be controlled
+ * with method like {@link Audio#play} or {@link Audio#pause}.
+ *
+ * This flag will be automatically set when audio sources are
+ * defined.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.hasPlaybackControl = true;
+
+ /**
+ * Holds a reference to the current audio source.
+ *
+ * The property is automatically by one of the `set*()` methods.
+ *
+ * @type {?AudioNode}
+ * @readonly
+ * @default null
+ */
+ this.source = null;
+
+ /**
+ * Defines the source type.
+ *
+ * The property is automatically set by one of the `set*()` methods.
+ *
+ * @type {('empty'|'audioNode'|'mediaNode'|'mediaStreamNode'|'buffer')}
+ * @readonly
+ * @default 'empty'
+ */
+ this.sourceType = 'empty';
+
+ this._startedAt = 0;
+ this._progress = 0;
+ this._connected = false;
+
+ /**
+ * Can be used to apply a variety of low-order filters to create
+ * more complex sound effects e.g. via `BiquadFilterNode`.
+ *
+ * The property is automatically set by {@link Audio#setFilters}.
+ *
+ * @type {Array}
+ * @readonly
+ */
+ this.filters = [];
+
+ }
+
+ /**
+ * Returns the output audio node.
+ *
+ * @return {GainNode} The output node.
+ */
+ getOutput() {
+
+ return this.gain;
+
+ }
+
+ /**
+ * Sets the given audio node as the source of this instance.
+ *
+ * {@link Audio#sourceType} is set to `audioNode` and {@link Audio#hasPlaybackControl} to `false`.
+ *
+ * @param {AudioNode} audioNode - The audio node like an instance of `OscillatorNode`.
+ * @return {Audio} A reference to this instance.
+ */
+ setNodeSource( audioNode ) {
+
+ this.hasPlaybackControl = false;
+ this.sourceType = 'audioNode';
+ this.source = audioNode;
+ this.connect();
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given media element as the source of this instance.
+ *
+ * {@link Audio#sourceType} is set to `mediaNode` and {@link Audio#hasPlaybackControl} to `false`.
+ *
+ * @param {HTMLMediaElement} mediaElement - The media element.
+ * @return {Audio} A reference to this instance.
+ */
+ setMediaElementSource( mediaElement ) {
+
+ this.hasPlaybackControl = false;
+ this.sourceType = 'mediaNode';
+ this.source = this.context.createMediaElementSource( mediaElement );
+ this.connect();
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given media stream as the source of this instance.
+ *
+ * {@link Audio#sourceType} is set to `mediaStreamNode` and {@link Audio#hasPlaybackControl} to `false`.
+ *
+ * @param {MediaStream} mediaStream - The media stream.
+ * @return {Audio} A reference to this instance.
+ */
+ setMediaStreamSource( mediaStream ) {
+
+ this.hasPlaybackControl = false;
+ this.sourceType = 'mediaStreamNode';
+ this.source = this.context.createMediaStreamSource( mediaStream );
+ this.connect();
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given audio buffer as the source of this instance.
+ *
+ * {@link Audio#sourceType} is set to `buffer` and {@link Audio#hasPlaybackControl} to `true`.
+ *
+ * @param {AudioBuffer} audioBuffer - The audio buffer.
+ * @return {Audio} A reference to this instance.
+ */
+ setBuffer( audioBuffer ) {
+
+ this.buffer = audioBuffer;
+ this.sourceType = 'buffer';
+
+ if ( this.autoplay ) this.play();
+
+ return this;
+
+ }
+
+ /**
+ * Starts the playback of the audio.
+ *
+ * Can only be used with compatible audio sources that allow playback control.
+ *
+ * @param {number} [delay=0] - The delay, in seconds, at which the audio should start playing.
+ * @return {Audio|undefined} A reference to this instance.
+ */
+ play( delay = 0 ) {
+
+ if ( this.isPlaying === true ) {
+
+ warn( 'Audio: Audio is already playing.' );
+ return;
+
+ }
+
+ if ( this.hasPlaybackControl === false ) {
+
+ warn( 'Audio: this Audio has no playback control.' );
+ return;
+
+ }
+
+ this._startedAt = this.context.currentTime + delay;
+
+ const source = this.context.createBufferSource();
+ source.buffer = this.buffer;
+ source.loop = this.loop;
+ source.loopStart = this.loopStart;
+ source.loopEnd = this.loopEnd;
+ source.onended = this.onEnded.bind( this );
+ source.start( this._startedAt, this._progress + this.offset, this.duration );
+
+ this.isPlaying = true;
+
+ this.source = source;
+
+ this.setDetune( this.detune );
+ this.setPlaybackRate( this.playbackRate );
+
+ return this.connect();
+
+ }
+
+ /**
+ * Pauses the playback of the audio.
+ *
+ * Can only be used with compatible audio sources that allow playback control.
+ *
+ * @return {Audio|undefined} A reference to this instance.
+ */
+ pause() {
+
+ if ( this.hasPlaybackControl === false ) {
+
+ warn( 'Audio: this Audio has no playback control.' );
+ return;
+
+ }
+
+ if ( this.isPlaying === true ) {
+
+ // update current progress
+
+ this._progress += Math.max( this.context.currentTime - this._startedAt, 0 ) * this.playbackRate;
+
+ if ( this.loop === true ) {
+
+ // ensure _progress does not exceed duration with looped audios
+
+ this._progress = this._progress % ( this.duration || this.buffer.duration );
+
+ }
+
+ this.source.stop();
+ this.source.onended = null;
+
+ this.isPlaying = false;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Stops the playback of the audio.
+ *
+ * Can only be used with compatible audio sources that allow playback control.
+ *
+ * @param {number} [delay=0] - The delay, in seconds, at which the audio should stop playing.
+ * @return {Audio|undefined} A reference to this instance.
+ */
+ stop( delay = 0 ) {
+
+ if ( this.hasPlaybackControl === false ) {
+
+ warn( 'Audio: this Audio has no playback control.' );
+ return;
+
+ }
+
+ this._progress = 0;
+
+ if ( this.source !== null ) {
+
+ this.source.stop( this.context.currentTime + delay );
+ this.source.onended = null;
+
+ }
+
+ this.isPlaying = false;
+
+ return this;
+
+ }
+
+ /**
+ * Connects to the audio source. This is used internally on
+ * initialisation and when setting / removing filters.
+ *
+ * @return {Audio} A reference to this instance.
+ */
+ connect() {
+
+ if ( this.filters.length > 0 ) {
+
+ this.source.connect( this.filters[ 0 ] );
+
+ for ( let i = 1, l = this.filters.length; i < l; i ++ ) {
+
+ this.filters[ i - 1 ].connect( this.filters[ i ] );
+
+ }
+
+ this.filters[ this.filters.length - 1 ].connect( this.getOutput() );
+
+ } else {
+
+ this.source.connect( this.getOutput() );
+
+ }
+
+ this._connected = true;
+
+ return this;
+
+ }
+
+ /**
+ * Disconnects to the audio source. This is used internally on
+ * initialisation and when setting / removing filters.
+ *
+ * @return {Audio|undefined} A reference to this instance.
+ */
+ disconnect() {
+
+ if ( this._connected === false ) {
+
+ return;
+
+ }
+
+ if ( this.filters.length > 0 ) {
+
+ this.source.disconnect( this.filters[ 0 ] );
+
+ for ( let i = 1, l = this.filters.length; i < l; i ++ ) {
+
+ this.filters[ i - 1 ].disconnect( this.filters[ i ] );
+
+ }
+
+ this.filters[ this.filters.length - 1 ].disconnect( this.getOutput() );
+
+ } else {
+
+ this.source.disconnect( this.getOutput() );
+
+ }
+
+ this._connected = false;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the current set filters.
+ *
+ * @return {Array} The list of filters.
+ */
+ getFilters() {
+
+ return this.filters;
+
+ }
+
+ /**
+ * Sets an array of filters and connects them with the audio source.
+ *
+ * @param {Array} [value] - A list of filters.
+ * @return {Audio} A reference to this instance.
+ */
+ setFilters( value ) {
+
+ if ( ! value ) value = [];
+
+ if ( this._connected === true ) {
+
+ this.disconnect();
+ this.filters = value.slice();
+ this.connect();
+
+ } else {
+
+ this.filters = value.slice();
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Defines the detuning of oscillation in cents.
+ *
+ * @param {number} value - The detuning of oscillation in cents.
+ * @return {Audio} A reference to this instance.
+ */
+ setDetune( value ) {
+
+ this.detune = value;
+
+ if ( this.isPlaying === true && this.source.detune !== undefined ) {
+
+ this.source.detune.setTargetAtTime( this.detune, this.context.currentTime, 0.01 );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the detuning of oscillation in cents.
+ *
+ * @return {number} The detuning of oscillation in cents.
+ */
+ getDetune() {
+
+ return this.detune;
+
+ }
+
+ /**
+ * Returns the first filter in the list of filters.
+ *
+ * @return {AudioNode|undefined} The first filter in the list of filters.
+ */
+ getFilter() {
+
+ return this.getFilters()[ 0 ];
+
+ }
+
+ /**
+ * Applies a single filter node to the audio.
+ *
+ * @param {AudioNode} [filter] - The filter to set.
+ * @return {Audio} A reference to this instance.
+ */
+ setFilter( filter ) {
+
+ return this.setFilters( filter ? [ filter ] : [] );
+
+ }
+
+ /**
+ * Sets the playback rate.
+ *
+ * Can only be used with compatible audio sources that allow playback control.
+ *
+ * @param {number} [value] - The playback rate to set.
+ * @return {Audio|undefined} A reference to this instance.
+ */
+ setPlaybackRate( value ) {
+
+ if ( this.hasPlaybackControl === false ) {
+
+ warn( 'Audio: this Audio has no playback control.' );
+ return;
+
+ }
+
+ this.playbackRate = value;
+
+ if ( this.isPlaying === true ) {
+
+ this.source.playbackRate.setTargetAtTime( this.playbackRate, this.context.currentTime, 0.01 );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns the current playback rate.
+
+ * @return {number} The playback rate.
+ */
+ getPlaybackRate() {
+
+ return this.playbackRate;
+
+ }
+
+ /**
+ * Automatically called when playback finished.
+ */
+ onEnded() {
+
+ this.isPlaying = false;
+ this._progress = 0;
+
+ }
+
+ /**
+ * Returns the loop flag.
+ *
+ * Can only be used with compatible audio sources that allow playback control.
+ *
+ * @return {boolean} Whether the audio should loop or not.
+ */
+ getLoop() {
+
+ if ( this.hasPlaybackControl === false ) {
+
+ warn( 'Audio: this Audio has no playback control.' );
+ return false;
+
+ }
+
+ return this.loop;
+
+ }
+
+ /**
+ * Sets the loop flag.
+ *
+ * Can only be used with compatible audio sources that allow playback control.
+ *
+ * @param {boolean} value - Whether the audio should loop or not.
+ * @return {Audio|undefined} A reference to this instance.
+ */
+ setLoop( value ) {
+
+ if ( this.hasPlaybackControl === false ) {
+
+ warn( 'Audio: this Audio has no playback control.' );
+ return;
+
+ }
+
+ this.loop = value;
+
+ if ( this.isPlaying === true ) {
+
+ this.source.loop = this.loop;
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the loop start value which defines where in the audio buffer the replay should
+ * start, in seconds.
+ *
+ * @param {number} value - The loop start value.
+ * @return {Audio} A reference to this instance.
+ */
+ setLoopStart( value ) {
+
+ this.loopStart = value;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the loop end value which defines where in the audio buffer the replay should
+ * stop, in seconds.
+ *
+ * @param {number} value - The loop end value.
+ * @return {Audio} A reference to this instance.
+ */
+ setLoopEnd( value ) {
+
+ this.loopEnd = value;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the volume.
+ *
+ * @return {number} The volume.
+ */
+ getVolume() {
+
+ return this.gain.gain.value;
+
+ }
+
+ /**
+ * Sets the volume.
+ *
+ * @param {number} value - The volume to set.
+ * @return {Audio} A reference to this instance.
+ */
+ setVolume( value ) {
+
+ this.gain.gain.setTargetAtTime( value, this.context.currentTime, 0.01 );
+
+ return this;
+
+ }
+
+ copy( source, recursive ) {
+
+ super.copy( source, recursive );
+
+ if ( source.sourceType !== 'buffer' ) {
+
+ warn( 'Audio: Audio source type cannot be copied.' );
+
+ return this;
+
+ }
+
+ this.autoplay = source.autoplay;
+
+ this.buffer = source.buffer;
+ this.detune = source.detune;
+ this.loop = source.loop;
+ this.loopStart = source.loopStart;
+ this.loopEnd = source.loopEnd;
+ this.offset = source.offset;
+ this.duration = source.duration;
+ this.playbackRate = source.playbackRate;
+ this.hasPlaybackControl = source.hasPlaybackControl;
+ this.sourceType = source.sourceType;
+
+ this.filters = source.filters.slice();
+
+ return this;
+
+ }
+
+ clone( recursive ) {
+
+ return new this.constructor( this.listener ).copy( this, recursive );
+
+ }
+
+}
+
+const _position = /*@__PURE__*/ new Vector3();
+const _quaternion = /*@__PURE__*/ new Quaternion();
+const _scale = /*@__PURE__*/ new Vector3();
+const _orientation = /*@__PURE__*/ new Vector3();
+
+/**
+ * Represents a positional audio object.
+ *
+ * ```js
+ * // create an AudioListener and add it to the camera
+ * const listener = new THREE.AudioListener();
+ * camera.add( listener );
+ *
+ * // create the PositionalAudio object (passing in the listener)
+ * const sound = new THREE.PositionalAudio( listener );
+ *
+ * // load a sound and set it as the PositionalAudio object's buffer
+ * const audioLoader = new THREE.AudioLoader();
+ * audioLoader.load( 'sounds/song.ogg', function( buffer ) {
+ * sound.setBuffer( buffer );
+ * sound.setRefDistance( 20 );
+ * sound.play();
+ * });
+ *
+ * // create an object for the sound to play from
+ * const sphere = new THREE.SphereGeometry( 20, 32, 16 );
+ * const material = new THREE.MeshPhongMaterial( { color: 0xff2200 } );
+ * const mesh = new THREE.Mesh( sphere, material );
+ * scene.add( mesh );
+ *
+ * // finally add the sound to the mesh
+ * mesh.add( sound );
+ *
+ * @augments Audio
+ */
+class PositionalAudio extends Audio {
+
+ /**
+ * Constructs a positional audio.
+ *
+ * @param {AudioListener} listener - The global audio listener.
+ */
+ constructor( listener ) {
+
+ super( listener );
+
+ /**
+ * The panner node represents the location, direction, and behavior of an audio
+ * source in 3D space.
+ *
+ * @type {PannerNode}
+ * @readonly
+ */
+ this.panner = this.context.createPanner();
+ this.panner.panningModel = 'HRTF';
+ this.panner.connect( this.gain );
+
+ }
+
+ connect() {
+
+ super.connect();
+
+ this.panner.connect( this.gain );
+
+ return this;
+
+ }
+
+ disconnect() {
+
+ super.disconnect();
+
+ this.panner.disconnect( this.gain );
+
+ return this;
+
+ }
+
+ getOutput() {
+
+ return this.panner;
+
+ }
+
+ /**
+ * Returns the current reference distance.
+ *
+ * @return {number} The reference distance.
+ */
+ getRefDistance() {
+
+ return this.panner.refDistance;
+
+ }
+
+ /**
+ * Defines the reference distance for reducing volume as the audio source moves
+ * further from the listener – i.e. the distance at which the volume reduction
+ * starts taking effect.
+ *
+ * @param {number} value - The reference distance to set.
+ * @return {PositionalAudio} A reference to this instance.
+ */
+ setRefDistance( value ) {
+
+ this.panner.refDistance = value;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the current rolloff factor.
+ *
+ * @return {number} The rolloff factor.
+ */
+ getRolloffFactor() {
+
+ return this.panner.rolloffFactor;
+
+ }
+
+ /**
+ * Defines how quickly the volume is reduced as the source moves away from the listener.
+ *
+ * @param {number} value - The rolloff factor.
+ * @return {PositionalAudio} A reference to this instance.
+ */
+ setRolloffFactor( value ) {
+
+ this.panner.rolloffFactor = value;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the current distance model.
+ *
+ * @return {('linear'|'inverse'|'exponential')} The distance model.
+ */
+ getDistanceModel() {
+
+ return this.panner.distanceModel;
+
+ }
+
+ /**
+ * Defines which algorithm to use to reduce the volume of the audio source
+ * as it moves away from the listener.
+ *
+ * Read [the spec](https://www.w3.org/TR/webaudio-1.1/#enumdef-distancemodeltype)
+ * for more details.
+ *
+ * @param {('linear'|'inverse'|'exponential')} value - The distance model to set.
+ * @return {PositionalAudio} A reference to this instance.
+ */
+ setDistanceModel( value ) {
+
+ this.panner.distanceModel = value;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the current max distance.
+ *
+ * @return {number} The max distance.
+ */
+ getMaxDistance() {
+
+ return this.panner.maxDistance;
+
+ }
+
+ /**
+ * Defines the maximum distance between the audio source and the listener,
+ * after which the volume is not reduced any further.
+ *
+ * This value is used only by the `linear` distance model.
+ *
+ * @param {number} value - The max distance.
+ * @return {PositionalAudio} A reference to this instance.
+ */
+ setMaxDistance( value ) {
+
+ this.panner.maxDistance = value;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the directional cone in which the audio can be listened.
+ *
+ * @param {number} coneInnerAngle - An angle, in degrees, of a cone inside of which there will be no volume reduction.
+ * @param {number} coneOuterAngle - An angle, in degrees, of a cone outside of which the volume will be reduced by a constant value, defined by the `coneOuterGain` parameter.
+ * @param {number} coneOuterGain - The amount of volume reduction outside the cone defined by the `coneOuterAngle`. When set to `0`, no sound can be heard.
+ * @return {PositionalAudio} A reference to this instance.
+ */
+ setDirectionalCone( coneInnerAngle, coneOuterAngle, coneOuterGain ) {
+
+ this.panner.coneInnerAngle = coneInnerAngle;
+ this.panner.coneOuterAngle = coneOuterAngle;
+ this.panner.coneOuterGain = coneOuterGain;
+
+ return this;
+
+ }
+
+ updateMatrixWorld( force ) {
+
+ super.updateMatrixWorld( force );
+
+ if ( this.hasPlaybackControl === true && this.isPlaying === false ) return;
+
+ this.matrixWorld.decompose( _position, _quaternion, _scale );
+
+ _orientation.set( 0, 0, 1 ).applyQuaternion( _quaternion );
+
+ const panner = this.panner;
+
+ if ( panner.positionX ) {
+
+ // code path for Chrome and Firefox (see #14393)
+
+ const endTime = this.context.currentTime + this.listener.timeDelta;
+
+ panner.positionX.linearRampToValueAtTime( _position.x, endTime );
+ panner.positionY.linearRampToValueAtTime( _position.y, endTime );
+ panner.positionZ.linearRampToValueAtTime( _position.z, endTime );
+ panner.orientationX.linearRampToValueAtTime( _orientation.x, endTime );
+ panner.orientationY.linearRampToValueAtTime( _orientation.y, endTime );
+ panner.orientationZ.linearRampToValueAtTime( _orientation.z, endTime );
+
+ } else {
+
+ panner.setPosition( _position.x, _position.y, _position.z );
+ panner.setOrientation( _orientation.x, _orientation.y, _orientation.z );
+
+ }
+
+ }
+
+}
+
+/**
+ * This class can be used to analyse audio data.
+ *
+ * ```js
+ * // create an AudioListener and add it to the camera
+ * const listener = new THREE.AudioListener();
+ * camera.add( listener );
+ *
+ * // create an Audio source
+ * const sound = new THREE.Audio( listener );
+ *
+ * // load a sound and set it as the Audio object's buffer
+ * const audioLoader = new THREE.AudioLoader();
+ * audioLoader.load( 'sounds/ambient.ogg', function( buffer ) {
+ * sound.setBuffer( buffer );
+ * sound.setLoop(true);
+ * sound.setVolume(0.5);
+ * sound.play();
+ * });
+ *
+ * // create an AudioAnalyser, passing in the sound and desired fftSize
+ * const analyser = new THREE.AudioAnalyser( sound, 32 );
+ *
+ * // get the average frequency of the sound
+ * const data = analyser.getAverageFrequency();
+ * ```
+ */
+class AudioAnalyser {
+
+ /**
+ * Constructs a new audio analyzer.
+ *
+ * @param {Audio} audio - The audio to analyze.
+ * @param {number} [fftSize=2048] - The window size in samples that is used when performing a Fast Fourier Transform (FFT) to get frequency domain data.
+ */
+ constructor( audio, fftSize = 2048 ) {
+
+ /**
+ * The global audio listener.
+ *
+ * @type {AnalyserNode}
+ */
+ this.analyser = audio.context.createAnalyser();
+ this.analyser.fftSize = fftSize;
+
+ /**
+ * Holds the analyzed data.
+ *
+ * @type {Uint8Array}
+ */
+ this.data = new Uint8Array( this.analyser.frequencyBinCount );
+
+ audio.getOutput().connect( this.analyser );
+
+ }
+
+ /**
+ * Returns an array with frequency data of the audio.
+ *
+ * Each item in the array represents the decibel value for a specific frequency.
+ * The frequencies are spread linearly from 0 to 1/2 of the sample rate.
+ * For example, for 48000 sample rate, the last item of the array will represent
+ * the decibel value for 24000 Hz.
+ *
+ * @return {Uint8Array} The frequency data.
+ */
+ getFrequencyData() {
+
+ this.analyser.getByteFrequencyData( this.data );
+
+ return this.data;
+
+ }
+
+ /**
+ * Returns the average of the frequencies returned by {@link AudioAnalyser#getFrequencyData}.
+ *
+ * @return {number} The average frequency.
+ */
+ getAverageFrequency() {
+
+ let value = 0;
+ const data = this.getFrequencyData();
+
+ for ( let i = 0; i < data.length; i ++ ) {
+
+ value += data[ i ];
+
+ }
+
+ return value / data.length;
+
+ }
+
+}
+
+/**
+ * Buffered scene graph property that allows weighted accumulation; used internally.
+ */
+class PropertyMixer {
+
+ /**
+ * Constructs a new property mixer.
+ *
+ * @param {PropertyBinding} binding - The property binding.
+ * @param {string} typeName - The keyframe track type name.
+ * @param {number} valueSize - The keyframe track value size.
+ */
+ constructor( binding, typeName, valueSize ) {
+
+ /**
+ * The property binding.
+ *
+ * @type {PropertyBinding}
+ */
+ this.binding = binding;
+
+ /**
+ * The keyframe track value size.
+ *
+ * @type {number}
+ */
+ this.valueSize = valueSize;
+
+ let mixFunction,
+ mixFunctionAdditive,
+ setIdentity;
+
+ // buffer layout: [ incoming | accu0 | accu1 | orig | addAccu | (optional work) ]
+ //
+ // interpolators can use .buffer as their .result
+ // the data then goes to 'incoming'
+ //
+ // 'accu0' and 'accu1' are used frame-interleaved for
+ // the cumulative result and are compared to detect
+ // changes
+ //
+ // 'orig' stores the original state of the property
+ //
+ // 'add' is used for additive cumulative results
+ //
+ // 'work' is optional and is only present for quaternion types. It is used
+ // to store intermediate quaternion multiplication results
+
+ switch ( typeName ) {
+
+ case 'quaternion':
+ mixFunction = this._slerp;
+ mixFunctionAdditive = this._slerpAdditive;
+ setIdentity = this._setAdditiveIdentityQuaternion;
+
+ this.buffer = new Float64Array( valueSize * 6 );
+ this._workIndex = 5;
+ break;
+
+ case 'string':
+ case 'bool':
+ mixFunction = this._select;
+
+ // Use the regular mix function and for additive on these types,
+ // additive is not relevant for non-numeric types
+ mixFunctionAdditive = this._select;
+
+ setIdentity = this._setAdditiveIdentityOther;
+
+ this.buffer = new Array( valueSize * 5 );
+ break;
+
+ default:
+ mixFunction = this._lerp;
+ mixFunctionAdditive = this._lerpAdditive;
+ setIdentity = this._setAdditiveIdentityNumeric;
+
+ this.buffer = new Float64Array( valueSize * 5 );
+
+ }
+
+ this._mixBufferRegion = mixFunction;
+ this._mixBufferRegionAdditive = mixFunctionAdditive;
+ this._setIdentity = setIdentity;
+ this._origIndex = 3;
+ this._addIndex = 4;
+
+ /**
+ * Accumulated weight of the property binding.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.cumulativeWeight = 0;
+
+ /**
+ * Accumulated additive weight of the property binding.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.cumulativeWeightAdditive = 0;
+
+ /**
+ * Number of active keyframe tracks currently using this property binding.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.useCount = 0;
+
+ /**
+ * Number of keyframe tracks referencing this property binding.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.referenceCount = 0;
+
+ }
+
+ /**
+ * Accumulates data in the `incoming` region into `accu`.
+ *
+ * @param {number} accuIndex - The accumulation index.
+ * @param {number} weight - The weight.
+ */
+ accumulate( accuIndex, weight ) {
+
+ // note: happily accumulating nothing when weight = 0, the caller knows
+ // the weight and shouldn't have made the call in the first place
+
+ const buffer = this.buffer,
+ stride = this.valueSize,
+ offset = accuIndex * stride + stride;
+
+ let currentWeight = this.cumulativeWeight;
+
+ if ( currentWeight === 0 ) {
+
+ // accuN := incoming * weight
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ buffer[ offset + i ] = buffer[ i ];
+
+ }
+
+ currentWeight = weight;
+
+ } else {
+
+ // accuN := accuN + incoming * weight
+
+ currentWeight += weight;
+ const mix = weight / currentWeight;
+ this._mixBufferRegion( buffer, offset, 0, mix, stride );
+
+ }
+
+ this.cumulativeWeight = currentWeight;
+
+ }
+
+ /**
+ * Accumulates data in the `incoming` region into `add`.
+ *
+ * @param {number} weight - The weight.
+ */
+ accumulateAdditive( weight ) {
+
+ const buffer = this.buffer,
+ stride = this.valueSize,
+ offset = stride * this._addIndex;
+
+ if ( this.cumulativeWeightAdditive === 0 ) {
+
+ // add = identity
+
+ this._setIdentity();
+
+ }
+
+ // add := add + incoming * weight
+
+ this._mixBufferRegionAdditive( buffer, offset, 0, weight, stride );
+ this.cumulativeWeightAdditive += weight;
+
+ }
+
+ /**
+ * Applies the state of `accu` to the binding when accus differ.
+ *
+ * @param {number} accuIndex - The accumulation index.
+ */
+ apply( accuIndex ) {
+
+ const stride = this.valueSize,
+ buffer = this.buffer,
+ offset = accuIndex * stride + stride,
+
+ weight = this.cumulativeWeight,
+ weightAdditive = this.cumulativeWeightAdditive,
+
+ binding = this.binding;
+
+ this.cumulativeWeight = 0;
+ this.cumulativeWeightAdditive = 0;
+
+ if ( weight < 1 ) {
+
+ // accuN := accuN + original * ( 1 - cumulativeWeight )
+
+ const originalValueOffset = stride * this._origIndex;
+
+ this._mixBufferRegion(
+ buffer, offset, originalValueOffset, 1 - weight, stride );
+
+ }
+
+ if ( weightAdditive > 0 ) {
+
+ // accuN := accuN + additive accuN
+
+ this._mixBufferRegionAdditive( buffer, offset, this._addIndex * stride, 1, stride );
+
+ }
+
+ for ( let i = stride, e = stride + stride; i !== e; ++ i ) {
+
+ if ( buffer[ i ] !== buffer[ i + stride ] ) {
+
+ // value has changed -> update scene graph
+
+ binding.setValue( buffer, offset );
+ break;
+
+ }
+
+ }
+
+ }
+
+
+ /**
+ * Remembers the state of the bound property and copy it to both accus.
+ */
+ saveOriginalState() {
+
+ const binding = this.binding;
+
+ const buffer = this.buffer,
+ stride = this.valueSize,
+
+ originalValueOffset = stride * this._origIndex;
+
+ binding.getValue( buffer, originalValueOffset );
+
+ // accu[0..1] := orig -- initially detect changes against the original
+ for ( let i = stride, e = originalValueOffset; i !== e; ++ i ) {
+
+ buffer[ i ] = buffer[ originalValueOffset + ( i % stride ) ];
+
+ }
+
+ // Add to identity for additive
+ this._setIdentity();
+
+ this.cumulativeWeight = 0;
+ this.cumulativeWeightAdditive = 0;
+
+ }
+
+ /**
+ * Applies the state previously taken via {@link PropertyMixer#saveOriginalState} to the binding.
+ */
+ restoreOriginalState() {
+
+ const originalValueOffset = this.valueSize * 3;
+ this.binding.setValue( this.buffer, originalValueOffset );
+
+ }
+
+ // internals
+
+ _setAdditiveIdentityNumeric() {
+
+ const startIndex = this._addIndex * this.valueSize;
+ const endIndex = startIndex + this.valueSize;
+
+ for ( let i = startIndex; i < endIndex; i ++ ) {
+
+ this.buffer[ i ] = 0;
+
+ }
+
+ }
+
+ _setAdditiveIdentityQuaternion() {
+
+ this._setAdditiveIdentityNumeric();
+ this.buffer[ this._addIndex * this.valueSize + 3 ] = 1;
+
+ }
+
+ _setAdditiveIdentityOther() {
+
+ const startIndex = this._origIndex * this.valueSize;
+ const targetIndex = this._addIndex * this.valueSize;
+
+ for ( let i = 0; i < this.valueSize; i ++ ) {
+
+ this.buffer[ targetIndex + i ] = this.buffer[ startIndex + i ];
+
+ }
+
+ }
+
+
+ // mix functions
+
+ _select( buffer, dstOffset, srcOffset, t, stride ) {
+
+ if ( t >= 0.5 ) {
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ buffer[ dstOffset + i ] = buffer[ srcOffset + i ];
+
+ }
+
+ }
+
+ }
+
+ _slerp( buffer, dstOffset, srcOffset, t ) {
+
+ Quaternion.slerpFlat( buffer, dstOffset, buffer, dstOffset, buffer, srcOffset, t );
+
+ }
+
+ _slerpAdditive( buffer, dstOffset, srcOffset, t, stride ) {
+
+ const workOffset = this._workIndex * stride;
+
+ // Store result in intermediate buffer offset
+ Quaternion.multiplyQuaternionsFlat( buffer, workOffset, buffer, dstOffset, buffer, srcOffset );
+
+ // Slerp to the intermediate result
+ Quaternion.slerpFlat( buffer, dstOffset, buffer, dstOffset, buffer, workOffset, t );
+
+ }
+
+ _lerp( buffer, dstOffset, srcOffset, t, stride ) {
+
+ const s = 1 - t;
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ const j = dstOffset + i;
+
+ buffer[ j ] = buffer[ j ] * s + buffer[ srcOffset + i ] * t;
+
+ }
+
+ }
+
+ _lerpAdditive( buffer, dstOffset, srcOffset, t, stride ) {
+
+ for ( let i = 0; i !== stride; ++ i ) {
+
+ const j = dstOffset + i;
+
+ buffer[ j ] = buffer[ j ] + buffer[ srcOffset + i ] * t;
+
+ }
+
+ }
+
+}
+
+// Characters [].:/ are reserved for track binding syntax.
+const _RESERVED_CHARS_RE = '\\[\\]\\.:\\/';
+const _reservedRe = new RegExp( '[' + _RESERVED_CHARS_RE + ']', 'g' );
+
+// Attempts to allow node names from any language. ES5's `\w` regexp matches
+// only latin characters, and the unicode \p{L} is not yet supported. So
+// instead, we exclude reserved characters and match everything else.
+const _wordChar = '[^' + _RESERVED_CHARS_RE + ']';
+const _wordCharOrDot = '[^' + _RESERVED_CHARS_RE.replace( '\\.', '' ) + ']';
+
+// Parent directories, delimited by '/' or ':'. Currently unused, but must
+// be matched to parse the rest of the track name.
+const _directoryRe = /*@__PURE__*/ /((?:WC+[\/:])*)/.source.replace( 'WC', _wordChar );
+
+// Target node. May contain word characters (a-zA-Z0-9_) and '.' or '-'.
+const _nodeRe = /*@__PURE__*/ /(WCOD+)?/.source.replace( 'WCOD', _wordCharOrDot );
+
+// Object on target node, and accessor. May not contain reserved
+// characters. Accessor may contain any character except closing bracket.
+const _objectRe = /*@__PURE__*/ /(?:\.(WC+)(?:\[(.+)\])?)?/.source.replace( 'WC', _wordChar );
+
+// Property and accessor. May not contain reserved characters. Accessor may
+// contain any non-bracket characters.
+const _propertyRe = /*@__PURE__*/ /\.(WC+)(?:\[(.+)\])?/.source.replace( 'WC', _wordChar );
+
+const _trackRe = new RegExp( ''
+ + '^'
+ + _directoryRe
+ + _nodeRe
+ + _objectRe
+ + _propertyRe
+ + '$'
+);
+
+const _supportedObjectNames = [ 'material', 'materials', 'bones', 'map' ];
+
+class Composite {
+
+ constructor( targetGroup, path, optionalParsedPath ) {
+
+ const parsedPath = optionalParsedPath || PropertyBinding.parseTrackName( path );
+
+ this._targetGroup = targetGroup;
+ this._bindings = targetGroup.subscribe_( path, parsedPath );
+
+ }
+
+ getValue( array, offset ) {
+
+ this.bind(); // bind all binding
+
+ const firstValidIndex = this._targetGroup.nCachedObjects_,
+ binding = this._bindings[ firstValidIndex ];
+
+ // and only call .getValue on the first
+ if ( binding !== undefined ) binding.getValue( array, offset );
+
+ }
+
+ setValue( array, offset ) {
+
+ const bindings = this._bindings;
+
+ for ( let i = this._targetGroup.nCachedObjects_, n = bindings.length; i !== n; ++ i ) {
+
+ bindings[ i ].setValue( array, offset );
+
+ }
+
+ }
+
+ bind() {
+
+ const bindings = this._bindings;
+
+ for ( let i = this._targetGroup.nCachedObjects_, n = bindings.length; i !== n; ++ i ) {
+
+ bindings[ i ].bind();
+
+ }
+
+ }
+
+ unbind() {
+
+ const bindings = this._bindings;
+
+ for ( let i = this._targetGroup.nCachedObjects_, n = bindings.length; i !== n; ++ i ) {
+
+ bindings[ i ].unbind();
+
+ }
+
+ }
+
+}
+
+// Note: This class uses a State pattern on a per-method basis:
+// 'bind' sets 'this.getValue' / 'setValue' and shadows the
+// prototype version of these methods with one that represents
+// the bound state. When the property is not found, the methods
+// become no-ops.
+
+
+/**
+ * This holds a reference to a real property in the scene graph; used internally.
+ */
+class PropertyBinding {
+
+ /**
+ * Constructs a new property binding.
+ *
+ * @param {Object} rootNode - The root node.
+ * @param {string} path - The path.
+ * @param {?Object} [parsedPath] - The parsed path.
+ */
+ constructor( rootNode, path, parsedPath ) {
+
+ /**
+ * The object path to the animated property.
+ *
+ * @type {string}
+ */
+ this.path = path;
+
+ /**
+ * An object holding information about the path.
+ *
+ * @type {Object}
+ */
+ this.parsedPath = parsedPath || PropertyBinding.parseTrackName( path );
+
+ /**
+ * The object owns the animated property.
+ *
+ * @type {?Object}
+ */
+ this.node = PropertyBinding.findNode( rootNode, this.parsedPath.nodeName );
+
+ /**
+ * The root node.
+ *
+ * @type {Object3D|Skeleton}
+ */
+ this.rootNode = rootNode;
+
+ // initial state of these methods that calls 'bind'
+ this.getValue = this._getValue_unbound;
+ this.setValue = this._setValue_unbound;
+
+ }
+
+
+ /**
+ * Factory method for creating a property binding from the given parameters.
+ *
+ * @static
+ * @param {Object} root - The root node.
+ * @param {string} path - The path.
+ * @param {?Object} [parsedPath] - The parsed path.
+ * @return {PropertyBinding|Composite} The created property binding or composite.
+ */
+ static create( root, path, parsedPath ) {
+
+ if ( ! ( root && root.isAnimationObjectGroup ) ) {
+
+ return new PropertyBinding( root, path, parsedPath );
+
+ } else {
+
+ return new PropertyBinding.Composite( root, path, parsedPath );
+
+ }
+
+ }
+
+ /**
+ * Replaces spaces with underscores and removes unsupported characters from
+ * node names, to ensure compatibility with parseTrackName().
+ *
+ * @param {string} name - Node name to be sanitized.
+ * @return {string} The sanitized node name.
+ */
+ static sanitizeNodeName( name ) {
+
+ return name.replace( /\s/g, '_' ).replace( _reservedRe, '' );
+
+ }
+
+ /**
+ * Parses the given track name (an object path to an animated property) and
+ * returns an object with information about the path. Matches strings in the following forms:
+ *
+ * - nodeName.property
+ * - nodeName.property[accessor]
+ * - nodeName.material.property[accessor]
+ * - uuid.property[accessor]
+ * - uuid.objectName[objectIndex].propertyName[propertyIndex]
+ * - parentName/nodeName.property
+ * - parentName/parentName/nodeName.property[index]
+ * - .bone[Armature.DEF_cog].position
+ * - scene:helium_balloon_model:helium_balloon_model.position
+ *
+ * @static
+ * @param {string} trackName - The track name to parse.
+ * @return {Object} The parsed track name as an object.
+ */
+ static parseTrackName( trackName ) {
+
+ const matches = _trackRe.exec( trackName );
+
+ if ( matches === null ) {
+
+ throw new Error( 'THREE.PropertyBinding: Cannot parse trackName: ' + trackName );
+
+ }
+
+ const results = {
+ // directoryName: matches[ 1 ], // (tschw) currently unused
+ nodeName: matches[ 2 ],
+ objectName: matches[ 3 ],
+ objectIndex: matches[ 4 ],
+ propertyName: matches[ 5 ], // required
+ propertyIndex: matches[ 6 ]
+ };
+
+ const lastDot = results.nodeName && results.nodeName.lastIndexOf( '.' );
+
+ if ( lastDot !== undefined && lastDot !== -1 ) {
+
+ const objectName = results.nodeName.substring( lastDot + 1 );
+
+ // Object names must be checked against an allowlist. Otherwise, there
+ // is no way to parse 'foo.bar.baz': 'baz' must be a property, but
+ // 'bar' could be the objectName, or part of a nodeName (which can
+ // include '.' characters).
+ if ( _supportedObjectNames.indexOf( objectName ) !== -1 ) {
+
+ results.nodeName = results.nodeName.substring( 0, lastDot );
+ results.objectName = objectName;
+
+ }
+
+ }
+
+ if ( results.propertyName === null || results.propertyName.length === 0 ) {
+
+ throw new Error( 'THREE.PropertyBinding: can not parse propertyName from trackName: ' + trackName );
+
+ }
+
+ return results;
+
+ }
+
+ /**
+ * Searches for a node in the hierarchy of the given root object by the given
+ * node name.
+ *
+ * @static
+ * @param {Object} root - The root object.
+ * @param {string|number} nodeName - The name of the node.
+ * @return {?Object} The found node. Returns `null` if no object was found.
+ */
+ static findNode( root, nodeName ) {
+
+ if ( nodeName === undefined || nodeName === '' || nodeName === '.' || nodeName === -1 || nodeName === root.name || nodeName === root.uuid ) {
+
+ return root;
+
+ }
+
+ // search into skeleton bones.
+ if ( root.skeleton ) {
+
+ const bone = root.skeleton.getBoneByName( nodeName );
+
+ if ( bone !== undefined ) {
+
+ return bone;
+
+ }
+
+ }
+
+ // search into node subtree.
+ if ( root.children ) {
+
+ const searchNodeSubtree = function ( children ) {
+
+ for ( let i = 0; i < children.length; i ++ ) {
+
+ const childNode = children[ i ];
+
+ if ( childNode.name === nodeName || childNode.uuid === nodeName ) {
+
+ return childNode;
+
+ }
+
+ const result = searchNodeSubtree( childNode.children );
+
+ if ( result ) return result;
+
+ }
+
+ return null;
+
+ };
+
+ const subTreeNode = searchNodeSubtree( root.children );
+
+ if ( subTreeNode ) {
+
+ return subTreeNode;
+
+ }
+
+ }
+
+ return null;
+
+ }
+
+ // these are used to "bind" a nonexistent property
+ _getValue_unavailable() {}
+ _setValue_unavailable() {}
+
+ // Getters
+
+ _getValue_direct( buffer, offset ) {
+
+ buffer[ offset ] = this.targetObject[ this.propertyName ];
+
+ }
+
+ _getValue_array( buffer, offset ) {
+
+ const source = this.resolvedProperty;
+
+ for ( let i = 0, n = source.length; i !== n; ++ i ) {
+
+ buffer[ offset ++ ] = source[ i ];
+
+ }
+
+ }
+
+ _getValue_arrayElement( buffer, offset ) {
+
+ buffer[ offset ] = this.resolvedProperty[ this.propertyIndex ];
+
+ }
+
+ _getValue_toArray( buffer, offset ) {
+
+ this.resolvedProperty.toArray( buffer, offset );
+
+ }
+
+ // Direct
+
+ _setValue_direct( buffer, offset ) {
+
+ this.targetObject[ this.propertyName ] = buffer[ offset ];
+
+ }
+
+ _setValue_direct_setNeedsUpdate( buffer, offset ) {
+
+ this.targetObject[ this.propertyName ] = buffer[ offset ];
+ this.targetObject.needsUpdate = true;
+
+ }
+
+ _setValue_direct_setMatrixWorldNeedsUpdate( buffer, offset ) {
+
+ this.targetObject[ this.propertyName ] = buffer[ offset ];
+ this.targetObject.matrixWorldNeedsUpdate = true;
+
+ }
+
+ // EntireArray
+
+ _setValue_array( buffer, offset ) {
+
+ const dest = this.resolvedProperty;
+
+ for ( let i = 0, n = dest.length; i !== n; ++ i ) {
+
+ dest[ i ] = buffer[ offset ++ ];
+
+ }
+
+ }
+
+ _setValue_array_setNeedsUpdate( buffer, offset ) {
+
+ const dest = this.resolvedProperty;
+
+ for ( let i = 0, n = dest.length; i !== n; ++ i ) {
+
+ dest[ i ] = buffer[ offset ++ ];
+
+ }
+
+ this.targetObject.needsUpdate = true;
+
+ }
+
+ _setValue_array_setMatrixWorldNeedsUpdate( buffer, offset ) {
+
+ const dest = this.resolvedProperty;
+
+ for ( let i = 0, n = dest.length; i !== n; ++ i ) {
+
+ dest[ i ] = buffer[ offset ++ ];
+
+ }
+
+ this.targetObject.matrixWorldNeedsUpdate = true;
+
+ }
+
+ // ArrayElement
+
+ _setValue_arrayElement( buffer, offset ) {
+
+ this.resolvedProperty[ this.propertyIndex ] = buffer[ offset ];
+
+ }
+
+ _setValue_arrayElement_setNeedsUpdate( buffer, offset ) {
+
+ this.resolvedProperty[ this.propertyIndex ] = buffer[ offset ];
+ this.targetObject.needsUpdate = true;
+
+ }
+
+ _setValue_arrayElement_setMatrixWorldNeedsUpdate( buffer, offset ) {
+
+ this.resolvedProperty[ this.propertyIndex ] = buffer[ offset ];
+ this.targetObject.matrixWorldNeedsUpdate = true;
+
+ }
+
+ // HasToFromArray
+
+ _setValue_fromArray( buffer, offset ) {
+
+ this.resolvedProperty.fromArray( buffer, offset );
+
+ }
+
+ _setValue_fromArray_setNeedsUpdate( buffer, offset ) {
+
+ this.resolvedProperty.fromArray( buffer, offset );
+ this.targetObject.needsUpdate = true;
+
+ }
+
+ _setValue_fromArray_setMatrixWorldNeedsUpdate( buffer, offset ) {
+
+ this.resolvedProperty.fromArray( buffer, offset );
+ this.targetObject.matrixWorldNeedsUpdate = true;
+
+ }
+
+ _getValue_unbound( targetArray, offset ) {
+
+ this.bind();
+ this.getValue( targetArray, offset );
+
+ }
+
+ _setValue_unbound( sourceArray, offset ) {
+
+ this.bind();
+ this.setValue( sourceArray, offset );
+
+ }
+
+ /**
+ * Creates a getter / setter pair for the property tracked by this binding.
+ */
+ bind() {
+
+ let targetObject = this.node;
+ const parsedPath = this.parsedPath;
+
+ const objectName = parsedPath.objectName;
+ const propertyName = parsedPath.propertyName;
+ let propertyIndex = parsedPath.propertyIndex;
+
+ if ( ! targetObject ) {
+
+ targetObject = PropertyBinding.findNode( this.rootNode, parsedPath.nodeName );
+
+ this.node = targetObject;
+
+ }
+
+ // set fail state so we can just 'return' on error
+ this.getValue = this._getValue_unavailable;
+ this.setValue = this._setValue_unavailable;
+
+ // ensure there is a value node
+ if ( ! targetObject ) {
+
+ warn( 'PropertyBinding: No target node found for track: ' + this.path + '.' );
+ return;
+
+ }
+
+ if ( objectName ) {
+
+ let objectIndex = parsedPath.objectIndex;
+
+ // special cases were we need to reach deeper into the hierarchy to get the face materials....
+ switch ( objectName ) {
+
+ case 'materials':
+
+ if ( ! targetObject.material ) {
+
+ error( 'PropertyBinding: Can not bind to material as node does not have a material.', this );
+ return;
+
+ }
+
+ if ( ! targetObject.material.materials ) {
+
+ error( 'PropertyBinding: Can not bind to material.materials as node.material does not have a materials array.', this );
+ return;
+
+ }
+
+ targetObject = targetObject.material.materials;
+
+ break;
+
+ case 'bones':
+
+ if ( ! targetObject.skeleton ) {
+
+ error( 'PropertyBinding: Can not bind to bones as node does not have a skeleton.', this );
+ return;
+
+ }
+
+ // potential future optimization: skip this if propertyIndex is already an integer
+ // and convert the integer string to a true integer.
+
+ targetObject = targetObject.skeleton.bones;
+
+ // support resolving morphTarget names into indices.
+ for ( let i = 0; i < targetObject.length; i ++ ) {
+
+ if ( targetObject[ i ].name === objectIndex ) {
+
+ objectIndex = i;
+ break;
+
+ }
+
+ }
+
+ break;
+
+ case 'map':
+
+ if ( 'map' in targetObject ) {
+
+ targetObject = targetObject.map;
+ break;
+
+ }
+
+ if ( ! targetObject.material ) {
+
+ error( 'PropertyBinding: Can not bind to material as node does not have a material.', this );
+ return;
+
+ }
+
+ if ( ! targetObject.material.map ) {
+
+ error( 'PropertyBinding: Can not bind to material.map as node.material does not have a map.', this );
+ return;
+
+ }
+
+ targetObject = targetObject.material.map;
+ break;
+
+ default:
+
+ if ( targetObject[ objectName ] === undefined ) {
+
+ error( 'PropertyBinding: Can not bind to objectName of node undefined.', this );
+ return;
+
+ }
+
+ targetObject = targetObject[ objectName ];
+
+ }
+
+
+ if ( objectIndex !== undefined ) {
+
+ if ( targetObject[ objectIndex ] === undefined ) {
+
+ error( 'PropertyBinding: Trying to bind to objectIndex of objectName, but is undefined.', this, targetObject );
+ return;
+
+ }
+
+ targetObject = targetObject[ objectIndex ];
+
+ }
+
+ }
+
+ // resolve property
+ const nodeProperty = targetObject[ propertyName ];
+
+ if ( nodeProperty === undefined ) {
+
+ const nodeName = parsedPath.nodeName;
+
+ error( 'PropertyBinding: Trying to update property for track: ' + nodeName +
+ '.' + propertyName + ' but it wasn\'t found.', targetObject );
+ return;
+
+ }
+
+ // determine versioning scheme
+ let versioning = this.Versioning.None;
+
+ this.targetObject = targetObject;
+
+ if ( targetObject.isMaterial === true ) {
+
+ versioning = this.Versioning.NeedsUpdate;
+
+ } else if ( targetObject.isObject3D === true ) {
+
+ versioning = this.Versioning.MatrixWorldNeedsUpdate;
+
+ }
+
+ // determine how the property gets bound
+ let bindingType = this.BindingType.Direct;
+
+ if ( propertyIndex !== undefined ) {
+
+ // access a sub element of the property array (only primitives are supported right now)
+
+ if ( propertyName === 'morphTargetInfluences' ) {
+
+ // potential optimization, skip this if propertyIndex is already an integer, and convert the integer string to a true integer.
+
+ // support resolving morphTarget names into indices.
+ if ( ! targetObject.geometry ) {
+
+ error( 'PropertyBinding: Can not bind to morphTargetInfluences because node does not have a geometry.', this );
+ return;
+
+ }
+
+ if ( ! targetObject.geometry.morphAttributes ) {
+
+ error( 'PropertyBinding: Can not bind to morphTargetInfluences because node does not have a geometry.morphAttributes.', this );
+ return;
+
+ }
+
+ if ( targetObject.morphTargetDictionary[ propertyIndex ] !== undefined ) {
+
+ propertyIndex = targetObject.morphTargetDictionary[ propertyIndex ];
+
+ }
+
+ }
+
+ bindingType = this.BindingType.ArrayElement;
+
+ this.resolvedProperty = nodeProperty;
+ this.propertyIndex = propertyIndex;
+
+ } else if ( nodeProperty.fromArray !== undefined && nodeProperty.toArray !== undefined ) {
+
+ // must use copy for Object3D.Euler/Quaternion
+
+ bindingType = this.BindingType.HasFromToArray;
+
+ this.resolvedProperty = nodeProperty;
+
+ } else if ( Array.isArray( nodeProperty ) ) {
+
+ bindingType = this.BindingType.EntireArray;
+
+ this.resolvedProperty = nodeProperty;
+
+ } else {
+
+ this.propertyName = propertyName;
+
+ }
+
+ // select getter / setter
+ this.getValue = this.GetterByBindingType[ bindingType ];
+ this.setValue = this.SetterByBindingTypeAndVersioning[ bindingType ][ versioning ];
+
+ }
+
+ /**
+ * Unbinds the property.
+ */
+ unbind() {
+
+ this.node = null;
+
+ // back to the prototype version of getValue / setValue
+ // note: avoiding to mutate the shape of 'this' via 'delete'
+ this.getValue = this._getValue_unbound;
+ this.setValue = this._setValue_unbound;
+
+ }
+
+}
+
+PropertyBinding.Composite = Composite;
+
+PropertyBinding.prototype.BindingType = {
+ Direct: 0,
+ EntireArray: 1,
+ ArrayElement: 2,
+ HasFromToArray: 3
+};
+
+PropertyBinding.prototype.Versioning = {
+ None: 0,
+ NeedsUpdate: 1,
+ MatrixWorldNeedsUpdate: 2
+};
+
+PropertyBinding.prototype.GetterByBindingType = [
+
+ PropertyBinding.prototype._getValue_direct,
+ PropertyBinding.prototype._getValue_array,
+ PropertyBinding.prototype._getValue_arrayElement,
+ PropertyBinding.prototype._getValue_toArray,
+
+];
+
+PropertyBinding.prototype.SetterByBindingTypeAndVersioning = [
+
+ [
+ // Direct
+ PropertyBinding.prototype._setValue_direct,
+ PropertyBinding.prototype._setValue_direct_setNeedsUpdate,
+ PropertyBinding.prototype._setValue_direct_setMatrixWorldNeedsUpdate,
+
+ ], [
+
+ // EntireArray
+
+ PropertyBinding.prototype._setValue_array,
+ PropertyBinding.prototype._setValue_array_setNeedsUpdate,
+ PropertyBinding.prototype._setValue_array_setMatrixWorldNeedsUpdate,
+
+ ], [
+
+ // ArrayElement
+ PropertyBinding.prototype._setValue_arrayElement,
+ PropertyBinding.prototype._setValue_arrayElement_setNeedsUpdate,
+ PropertyBinding.prototype._setValue_arrayElement_setMatrixWorldNeedsUpdate,
+
+ ], [
+
+ // HasToFromArray
+ PropertyBinding.prototype._setValue_fromArray,
+ PropertyBinding.prototype._setValue_fromArray_setNeedsUpdate,
+ PropertyBinding.prototype._setValue_fromArray_setMatrixWorldNeedsUpdate,
+
+ ]
+
+];
+
+/**
+ * A group of objects that receives a shared animation state.
+ *
+ * Usage:
+ *
+ * - Add objects you would otherwise pass as 'root' to the
+ * constructor or the .clipAction method of AnimationMixer.
+ * - Instead pass this object as 'root'.
+ * - You can also add and remove objects later when the mixer is running.
+ *
+ * Note:
+ *
+ * - Objects of this class appear as one object to the mixer,
+ * so cache control of the individual objects must be done on the group.
+ *
+ * Limitation:
+ *
+ * - The animated properties must be compatible among the all objects in the group.
+ * - A single property can either be controlled through a target group or directly, but not both.
+ */
+class AnimationObjectGroup {
+
+ /**
+ * Constructs a new animation group.
+ *
+ * @param {...Object3D} arguments - An arbitrary number of 3D objects that share the same animation state.
+ */
+ constructor() {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isAnimationObjectGroup = true;
+
+ /**
+ * The UUID of the 3D object.
+ *
+ * @type {string}
+ * @readonly
+ */
+ this.uuid = generateUUID();
+
+ // cached objects followed by the active ones
+ this._objects = Array.prototype.slice.call( arguments );
+
+ this.nCachedObjects_ = 0; // threshold
+ // note: read by PropertyBinding.Composite
+
+ const indices = {};
+ this._indicesByUUID = indices; // for bookkeeping
+
+ for ( let i = 0, n = arguments.length; i !== n; ++ i ) {
+
+ indices[ arguments[ i ].uuid ] = i;
+
+ }
+
+ this._paths = []; // inside: string
+ this._parsedPaths = []; // inside: { we don't care, here }
+ this._bindings = []; // inside: Array< PropertyBinding >
+ this._bindingsIndicesByPath = {}; // inside: indices in these arrays
+
+ const scope = this;
+
+ this.stats = {
+
+ objects: {
+ get total() {
+
+ return scope._objects.length;
+
+ },
+ get inUse() {
+
+ return this.total - scope.nCachedObjects_;
+
+ }
+ },
+ get bindingsPerObject() {
+
+ return scope._bindings.length;
+
+ }
+
+ };
+
+ }
+
+ /**
+ * Adds an arbitrary number of objects to this animation group.
+ *
+ * @param {...Object3D} arguments - The 3D objects to add.
+ */
+ add() {
+
+ const objects = this._objects,
+ indicesByUUID = this._indicesByUUID,
+ paths = this._paths,
+ parsedPaths = this._parsedPaths,
+ bindings = this._bindings,
+ nBindings = bindings.length;
+
+ let knownObject = undefined,
+ nObjects = objects.length,
+ nCachedObjects = this.nCachedObjects_;
+
+ for ( let i = 0, n = arguments.length; i !== n; ++ i ) {
+
+ const object = arguments[ i ],
+ uuid = object.uuid;
+ let index = indicesByUUID[ uuid ];
+
+ if ( index === undefined ) {
+
+ // unknown object -> add it to the ACTIVE region
+
+ index = nObjects ++;
+ indicesByUUID[ uuid ] = index;
+ objects.push( object );
+
+ // accounting is done, now do the same for all bindings
+
+ for ( let j = 0, m = nBindings; j !== m; ++ j ) {
+
+ bindings[ j ].push( new PropertyBinding( object, paths[ j ], parsedPaths[ j ] ) );
+
+ }
+
+ } else if ( index < nCachedObjects ) {
+
+ knownObject = objects[ index ];
+
+ // move existing object to the ACTIVE region
+
+ const firstActiveIndex = -- nCachedObjects,
+ lastCachedObject = objects[ firstActiveIndex ];
+
+ indicesByUUID[ lastCachedObject.uuid ] = index;
+ objects[ index ] = lastCachedObject;
+
+ indicesByUUID[ uuid ] = firstActiveIndex;
+ objects[ firstActiveIndex ] = object;
+
+ // accounting is done, now do the same for all bindings
+
+ for ( let j = 0, m = nBindings; j !== m; ++ j ) {
+
+ const bindingsForPath = bindings[ j ],
+ lastCached = bindingsForPath[ firstActiveIndex ];
+
+ let binding = bindingsForPath[ index ];
+
+ bindingsForPath[ index ] = lastCached;
+
+ if ( binding === undefined ) {
+
+ // since we do not bother to create new bindings
+ // for objects that are cached, the binding may
+ // or may not exist
+
+ binding = new PropertyBinding( object, paths[ j ], parsedPaths[ j ] );
+
+ }
+
+ bindingsForPath[ firstActiveIndex ] = binding;
+
+ }
+
+ } else if ( objects[ index ] !== knownObject ) {
+
+ error( 'AnimationObjectGroup: Different objects with the same UUID ' +
+ 'detected. Clean the caches or recreate your infrastructure when reloading scenes.' );
+
+ } // else the object is already where we want it to be
+
+ } // for arguments
+
+ this.nCachedObjects_ = nCachedObjects;
+
+ }
+
+ /**
+ * Removes an arbitrary number of objects to this animation group
+ *
+ * @param {...Object3D} arguments - The 3D objects to remove.
+ */
+ remove() {
+
+ const objects = this._objects,
+ indicesByUUID = this._indicesByUUID,
+ bindings = this._bindings,
+ nBindings = bindings.length;
+
+ let nCachedObjects = this.nCachedObjects_;
+
+ for ( let i = 0, n = arguments.length; i !== n; ++ i ) {
+
+ const object = arguments[ i ],
+ uuid = object.uuid,
+ index = indicesByUUID[ uuid ];
+
+ if ( index !== undefined && index >= nCachedObjects ) {
+
+ // move existing object into the CACHED region
+
+ const lastCachedIndex = nCachedObjects ++,
+ firstActiveObject = objects[ lastCachedIndex ];
+
+ indicesByUUID[ firstActiveObject.uuid ] = index;
+ objects[ index ] = firstActiveObject;
+
+ indicesByUUID[ uuid ] = lastCachedIndex;
+ objects[ lastCachedIndex ] = object;
+
+ // accounting is done, now do the same for all bindings
+
+ for ( let j = 0, m = nBindings; j !== m; ++ j ) {
+
+ const bindingsForPath = bindings[ j ],
+ firstActive = bindingsForPath[ lastCachedIndex ],
+ binding = bindingsForPath[ index ];
+
+ bindingsForPath[ index ] = firstActive;
+ bindingsForPath[ lastCachedIndex ] = binding;
+
+ }
+
+ }
+
+ } // for arguments
+
+ this.nCachedObjects_ = nCachedObjects;
+
+ }
+
+ /**
+ * Deallocates all memory resources for the passed 3D objects of this animation group.
+ *
+ * @param {...Object3D} arguments - The 3D objects to uncache.
+ */
+ uncache() {
+
+ const objects = this._objects,
+ indicesByUUID = this._indicesByUUID,
+ bindings = this._bindings,
+ nBindings = bindings.length;
+
+ let nCachedObjects = this.nCachedObjects_,
+ nObjects = objects.length;
+
+ for ( let i = 0, n = arguments.length; i !== n; ++ i ) {
+
+ const object = arguments[ i ],
+ uuid = object.uuid,
+ index = indicesByUUID[ uuid ];
+
+ if ( index !== undefined ) {
+
+ delete indicesByUUID[ uuid ];
+
+ if ( index < nCachedObjects ) {
+
+ // object is cached, shrink the CACHED region
+
+ const firstActiveIndex = -- nCachedObjects,
+ lastCachedObject = objects[ firstActiveIndex ],
+ lastIndex = -- nObjects,
+ lastObject = objects[ lastIndex ];
+
+ if ( index !== firstActiveIndex ) {
+
+ // last cached object takes this object's place
+
+ indicesByUUID[ lastCachedObject.uuid ] = index;
+
+ }
+
+ objects[ index ] = lastCachedObject;
+
+ if ( firstActiveIndex !== lastIndex ) {
+
+ // last object goes to the activated slot and pop
+
+ indicesByUUID[ lastObject.uuid ] = firstActiveIndex;
+
+ }
+
+ objects[ firstActiveIndex ] = lastObject;
+ objects.pop();
+
+ // accounting is done, now do the same for all bindings
+
+ for ( let j = 0, m = nBindings; j !== m; ++ j ) {
+
+ const bindingsForPath = bindings[ j ],
+ lastCached = bindingsForPath[ firstActiveIndex ],
+ last = bindingsForPath[ lastIndex ];
+
+ bindingsForPath[ index ] = lastCached;
+ bindingsForPath[ firstActiveIndex ] = last;
+ bindingsForPath.pop();
+
+ }
+
+ } else {
+
+ // object is active, just swap with the last and pop
+
+ const lastIndex = -- nObjects,
+ lastObject = objects[ lastIndex ];
+
+ if ( index !== lastIndex ) {
+
+ indicesByUUID[ lastObject.uuid ] = index;
+
+ }
+
+ objects[ index ] = lastObject;
+ objects.pop();
+
+ // accounting is done, now do the same for all bindings
+
+ for ( let j = 0, m = nBindings; j !== m; ++ j ) {
+
+ const bindingsForPath = bindings[ j ];
+
+ bindingsForPath[ index ] = bindingsForPath[ lastIndex ];
+ bindingsForPath.pop();
+
+ }
+
+ } // cached or active
+
+ } // if object is known
+
+ } // for arguments
+
+ this.nCachedObjects_ = nCachedObjects;
+
+ }
+
+ // Internal interface used by befriended PropertyBinding.Composite:
+
+ subscribe_( path, parsedPath ) {
+
+ // returns an array of bindings for the given path that is changed
+ // according to the contained objects in the group
+
+ const indicesByPath = this._bindingsIndicesByPath;
+ let index = indicesByPath[ path ];
+ const bindings = this._bindings;
+
+ if ( index !== undefined ) return bindings[ index ];
+
+ const paths = this._paths,
+ parsedPaths = this._parsedPaths,
+ objects = this._objects,
+ nObjects = objects.length,
+ nCachedObjects = this.nCachedObjects_,
+ bindingsForPath = new Array( nObjects );
+
+ index = bindings.length;
+
+ indicesByPath[ path ] = index;
+
+ paths.push( path );
+ parsedPaths.push( parsedPath );
+ bindings.push( bindingsForPath );
+
+ for ( let i = nCachedObjects, n = objects.length; i !== n; ++ i ) {
+
+ const object = objects[ i ];
+ bindingsForPath[ i ] = new PropertyBinding( object, path, parsedPath );
+
+ }
+
+ return bindingsForPath;
+
+ }
+
+ unsubscribe_( path ) {
+
+ // tells the group to forget about a property path and no longer
+ // update the array previously obtained with 'subscribe_'
+
+ const indicesByPath = this._bindingsIndicesByPath,
+ index = indicesByPath[ path ];
+
+ if ( index !== undefined ) {
+
+ const paths = this._paths,
+ parsedPaths = this._parsedPaths,
+ bindings = this._bindings,
+ lastBindingsIndex = bindings.length - 1,
+ lastBindings = bindings[ lastBindingsIndex ],
+ lastBindingsPath = paths[ lastBindingsIndex ];
+
+ indicesByPath[ lastBindingsPath ] = index;
+
+ bindings[ index ] = lastBindings;
+ bindings.pop();
+
+ parsedPaths[ index ] = parsedPaths[ lastBindingsIndex ];
+ parsedPaths.pop();
+
+ paths[ index ] = paths[ lastBindingsIndex ];
+ paths.pop();
+
+ }
+
+ }
+
+}
+
+/**
+ * An instance of `AnimationAction` schedules the playback of an animation which is
+ * stored in {@link AnimationClip}.
+ */
+class AnimationAction {
+
+ /**
+ * Constructs a new animation action.
+ *
+ * @param {AnimationMixer} mixer - The mixer that is controlled by this action.
+ * @param {AnimationClip} clip - The animation clip that holds the actual keyframes.
+ * @param {?Object3D} [localRoot=null] - The root object on which this action is performed.
+ * @param {(NormalAnimationBlendMode|AdditiveAnimationBlendMode)} [blendMode] - The blend mode.
+ */
+ constructor( mixer, clip, localRoot = null, blendMode = clip.blendMode ) {
+
+ this._mixer = mixer;
+ this._clip = clip;
+ this._localRoot = localRoot;
+
+ /**
+ * Defines how the animation is blended/combined when two or more animations
+ * are simultaneously played.
+ *
+ * @type {(NormalAnimationBlendMode|AdditiveAnimationBlendMode)}
+ */
+ this.blendMode = blendMode;
+
+ const tracks = clip.tracks,
+ nTracks = tracks.length,
+ interpolants = new Array( nTracks );
+
+ const interpolantSettings = {
+ endingStart: ZeroCurvatureEnding,
+ endingEnd: ZeroCurvatureEnding
+ };
+
+ for ( let i = 0; i !== nTracks; ++ i ) {
+
+ const interpolant = tracks[ i ].createInterpolant( null );
+ interpolants[ i ] = interpolant;
+ interpolant.settings = interpolantSettings;
+
+ }
+
+ this._interpolantSettings = interpolantSettings;
+
+ this._interpolants = interpolants; // bound by the mixer
+
+ // inside: PropertyMixer (managed by the mixer)
+ this._propertyBindings = new Array( nTracks );
+
+ this._cacheIndex = null; // for the memory manager
+ this._byClipCacheIndex = null; // for the memory manager
+
+ this._timeScaleInterpolant = null;
+ this._restoreTimeScale = null;
+ this._weightInterpolant = null;
+
+ /**
+ * The loop mode, set via {@link AnimationAction#setLoop}.
+ *
+ * @type {(LoopRepeat|LoopOnce|LoopPingPong)}
+ * @default LoopRepeat
+ */
+ this.loop = LoopRepeat;
+ this._loopCount = -1;
+
+ // global mixer time when the action is to be started
+ // it's set back to 'null' upon start of the action
+ this._startTime = null;
+
+ /**
+ * The local time of this action (in seconds, starting with `0`).
+ *
+ * The value gets clamped or wrapped to `[0,clip.duration]` (according to the
+ * loop state).
+ *
+ * @type {number}
+ * @default Infinity
+ */
+ this.time = 0;
+
+ /**
+ * Scaling factor for the {@link AnimationAction#time}. A value of `0` causes the
+ * animation to pause. Negative values cause the animation to play backwards.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.timeScale = 1;
+ this._effectiveTimeScale = 1;
+
+ /**
+ * The degree of influence of this action (in the interval `[0, 1]`). Values
+ * between `0` (no impact) and `1` (full impact) can be used to blend between
+ * several actions.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.weight = 1;
+ this._effectiveWeight = 1;
+
+ /**
+ * The number of repetitions of the performed clip over the course of this action.
+ * Can be set via {@link AnimationAction#setLoop}.
+ *
+ * Setting this number has no effect if {@link AnimationAction#loop} is set to
+ * `THREE:LoopOnce`.
+ *
+ * @type {number}
+ * @default Infinity
+ */
+ this.repetitions = Infinity;
+
+ /**
+ * If set to `true`, the playback of the action is paused.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.paused = false;
+
+ /**
+ * If set to `false`, the action is disabled so it has no impact.
+ *
+ * When the action is re-enabled, the animation continues from its current
+ * time (setting `enabled` to `false` doesn't reset the action).
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.enabled = true;
+
+ /**
+ * If set to true the animation will automatically be paused on its last frame.
+ *
+ * If set to false, {@link AnimationAction#enabled} will automatically be switched
+ * to `false` when the last loop of the action has finished, so that this action has
+ * no further impact.
+ *
+ * Note: This member has no impact if the action is interrupted (it
+ * has only an effect if its last loop has really finished).
+ *
+ * @type {boolean}
+ * @default false
+ */
+ this.clampWhenFinished = false;
+
+ /**
+ * Enables smooth interpolation without separate clips for start, loop and end.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.zeroSlopeAtStart = true;
+
+ /**
+ * Enables smooth interpolation without separate clips for start, loop and end.
+ *
+ * @type {boolean}
+ * @default true
+ */
+ this.zeroSlopeAtEnd = true;
+
+ }
+
+ /**
+ * Starts the playback of the animation.
+ *
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ play() {
+
+ this._mixer._activateAction( this );
+
+ return this;
+
+ }
+
+ /**
+ * Stops the playback of the animation.
+ *
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ stop() {
+
+ this._mixer._deactivateAction( this );
+
+ return this.reset();
+
+ }
+
+ /**
+ * Resets the playback of the animation.
+ *
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ reset() {
+
+ this.paused = false;
+ this.enabled = true;
+
+ this.time = 0; // restart clip
+ this._loopCount = -1;// forget previous loops
+ this._startTime = null;// forget scheduling
+
+ return this.stopFading().stopWarping();
+
+ }
+
+ /**
+ * Returns `true` if the animation is running.
+ *
+ * @return {boolean} Whether the animation is running or not.
+ */
+ isRunning() {
+
+ return this.enabled && ! this.paused && this.timeScale !== 0 &&
+ this._startTime === null && this._mixer._isActiveAction( this );
+
+ }
+
+ /**
+ * Returns `true` when {@link AnimationAction#play} has been called.
+ *
+ * @return {boolean} Whether the animation is scheduled or not.
+ */
+ isScheduled() {
+
+ return this._mixer._isActiveAction( this );
+
+ }
+
+ /**
+ * Defines the time when the animation should start.
+ *
+ * @param {number} time - The start time in seconds.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ startAt( time ) {
+
+ this._startTime = time;
+
+ return this;
+
+ }
+
+ /**
+ * Configures the loop settings for this action.
+ *
+ * @param {(LoopRepeat|LoopOnce|LoopPingPong)} mode - The loop mode.
+ * @param {number} repetitions - The number of repetitions.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ setLoop( mode, repetitions ) {
+
+ this.loop = mode;
+ this.repetitions = repetitions;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the effective weight of this action.
+ *
+ * An action has no effect and thus an effective weight of zero when the
+ * action is disabled.
+ *
+ * @param {number} weight - The weight to set.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ setEffectiveWeight( weight ) {
+
+ this.weight = weight;
+
+ // note: same logic as when updated at runtime
+ this._effectiveWeight = this.enabled ? weight : 0;
+
+ return this.stopFading();
+
+ }
+
+ /**
+ * Returns the effective weight of this action.
+ *
+ * @return {number} The effective weight.
+ */
+ getEffectiveWeight() {
+
+ return this._effectiveWeight;
+
+ }
+
+ /**
+ * Fades the animation in by increasing its weight gradually from `0` to `1`,
+ * within the passed time interval.
+ *
+ * @param {number} duration - The duration of the fade.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ fadeIn( duration ) {
+
+ return this._scheduleFading( duration, 0, 1 );
+
+ }
+
+ /**
+ * Fades the animation out by decreasing its weight gradually from `1` to `0`,
+ * within the passed time interval.
+ *
+ * @param {number} duration - The duration of the fade.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ fadeOut( duration ) {
+
+ return this._scheduleFading( duration, 1, 0 );
+
+ }
+
+ /**
+ * Causes this action to fade in and the given action to fade out,
+ * within the passed time interval.
+ *
+ * @param {AnimationAction} fadeOutAction - The animation action to fade out.
+ * @param {number} duration - The duration of the fade.
+ * @param {boolean} [warp=false] - Whether warping should be used or not.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ crossFadeFrom( fadeOutAction, duration, warp = false ) {
+
+ fadeOutAction.fadeOut( duration );
+ this.fadeIn( duration );
+
+ if ( warp === true ) {
+
+ const fadeInDuration = this._clip.duration,
+ fadeOutDuration = fadeOutAction._clip.duration,
+
+ startEndRatio = fadeOutDuration / fadeInDuration,
+ endStartRatio = fadeInDuration / fadeOutDuration;
+
+
+ fadeOutAction._restoreTimeScale = fadeOutAction.timeScale;
+ this._restoreTimeScale = this.timeScale;
+
+ fadeOutAction.warp( 1.0, startEndRatio, duration );
+ this.warp( endStartRatio, 1.0, duration );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Causes this action to fade out and the given action to fade in,
+ * within the passed time interval.
+ *
+ * @param {AnimationAction} fadeInAction - The animation action to fade in.
+ * @param {number} duration - The duration of the fade.
+ * @param {boolean} [warp=false] - Whether warping should be used or not.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ crossFadeTo( fadeInAction, duration, warp = false ) {
+
+ return fadeInAction.crossFadeFrom( this, duration, warp );
+
+ }
+
+ /**
+ * Stops any fading which is applied to this action.
+ *
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ stopFading() {
+
+ const weightInterpolant = this._weightInterpolant;
+
+ if ( weightInterpolant !== null ) {
+
+ this._weightInterpolant = null;
+ this._mixer._takeBackControlInterpolant( weightInterpolant );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the effective time scale of this action.
+ *
+ * An action has no effect and thus an effective time scale of zero when the
+ * action is paused.
+ *
+ * @param {number} timeScale - The time scale to set.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ setEffectiveTimeScale( timeScale ) {
+
+ this.timeScale = timeScale;
+ this._effectiveTimeScale = this.paused ? 0 : timeScale;
+
+ return this.stopWarping();
+
+ }
+
+ /**
+ * Returns the effective time scale of this action.
+ *
+ * @return {number} The effective time scale.
+ */
+ getEffectiveTimeScale() {
+
+ return this._effectiveTimeScale;
+
+ }
+
+ /**
+ * Sets the duration for a single loop of this action.
+ *
+ * @param {number} duration - The duration to set.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ setDuration( duration ) {
+
+ this.timeScale = this._clip.duration / duration;
+
+ return this.stopWarping();
+
+ }
+
+ /**
+ * Synchronizes this action with the passed other action.
+ *
+ * @param {AnimationAction} action - The action to sync with.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ syncWith( action ) {
+
+ this.time = action.time;
+ this.timeScale = action.timeScale;
+
+ return this.stopWarping();
+
+ }
+
+ /**
+ * Decelerates this animation's speed to `0` within the passed time interval.
+ *
+ * @param {number} duration - The duration.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ halt( duration ) {
+
+ return this.warp( this._effectiveTimeScale, 0, duration );
+
+ }
+
+ /**
+ * Changes the playback speed, within the passed time interval, by modifying
+ * {@link AnimationAction#timeScale} gradually from `startTimeScale` to
+ * `endTimeScale`.
+ *
+ * @param {number} startTimeScale - The start time scale.
+ * @param {number} endTimeScale - The end time scale.
+ * @param {number} duration - The duration.
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ warp( startTimeScale, endTimeScale, duration ) {
+
+ const mixer = this._mixer,
+ now = mixer.time,
+ timeScale = this.timeScale;
+
+ let interpolant = this._timeScaleInterpolant;
+
+ if ( interpolant === null ) {
+
+ interpolant = mixer._lendControlInterpolant();
+ this._timeScaleInterpolant = interpolant;
+
+ }
+
+ const times = interpolant.parameterPositions,
+ values = interpolant.sampleValues;
+
+ times[ 0 ] = now;
+ times[ 1 ] = now + duration;
+
+ values[ 0 ] = startTimeScale / timeScale;
+ values[ 1 ] = endTimeScale / timeScale;
+
+ return this;
+
+ }
+
+ /**
+ * Stops any scheduled warping which is applied to this action.
+ *
+ * @return {AnimationAction} A reference to this animation action.
+ */
+ stopWarping() {
+
+ const timeScaleInterpolant = this._timeScaleInterpolant;
+
+ if ( timeScaleInterpolant !== null ) {
+
+ this._timeScaleInterpolant = null;
+ this._mixer._takeBackControlInterpolant( timeScaleInterpolant );
+
+ }
+
+ this._restoreTimeScale = null;
+
+ return this;
+
+ }
+
+ /**
+ * Returns the animation mixer of this animation action.
+ *
+ * @return {AnimationMixer} The animation mixer.
+ */
+ getMixer() {
+
+ return this._mixer;
+
+ }
+
+ /**
+ * Returns the animation clip of this animation action.
+ *
+ * @return {AnimationClip} The animation clip.
+ */
+ getClip() {
+
+ return this._clip;
+
+ }
+
+ /**
+ * Returns the root object of this animation action.
+ *
+ * @return {Object3D} The root object.
+ */
+ getRoot() {
+
+ return this._localRoot || this._mixer._root;
+
+ }
+
+ // Internal
+
+ _update( time, deltaTime, timeDirection, accuIndex ) {
+
+ // called by the mixer
+
+ if ( ! this.enabled ) {
+
+ // call ._updateWeight() to update ._effectiveWeight
+
+ this._updateWeight( time );
+ return;
+
+ }
+
+ const startTime = this._startTime;
+
+ if ( startTime !== null ) {
+
+ // check for scheduled start of action
+
+ const timeRunning = ( time - startTime ) * timeDirection;
+ if ( timeRunning < 0 || timeDirection === 0 ) {
+
+ deltaTime = 0;
+
+ } else {
+
+
+ this._startTime = null; // unschedule
+ deltaTime = timeDirection * timeRunning;
+
+ }
+
+ }
+
+ // apply time scale and advance time
+
+ deltaTime *= this._updateTimeScale( time );
+ const clipTime = this._updateTime( deltaTime );
+
+ // note: _updateTime may disable the action resulting in
+ // an effective weight of 0
+
+ const weight = this._updateWeight( time );
+
+ if ( weight > 0 ) {
+
+ const interpolants = this._interpolants;
+ const propertyMixers = this._propertyBindings;
+
+ switch ( this.blendMode ) {
+
+ case AdditiveAnimationBlendMode:
+
+ for ( let j = 0, m = interpolants.length; j !== m; ++ j ) {
+
+ interpolants[ j ].evaluate( clipTime );
+ propertyMixers[ j ].accumulateAdditive( weight );
+
+ }
+
+ break;
+
+ case NormalAnimationBlendMode:
+ default:
+
+ for ( let j = 0, m = interpolants.length; j !== m; ++ j ) {
+
+ interpolants[ j ].evaluate( clipTime );
+ propertyMixers[ j ].accumulate( accuIndex, weight );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ _updateWeight( time ) {
+
+ let weight = 0;
+
+ if ( this.enabled ) {
+
+ weight = this.weight;
+ const interpolant = this._weightInterpolant;
+
+ if ( interpolant !== null ) {
+
+ const interpolantValue = interpolant.evaluate( time )[ 0 ];
+
+ weight *= interpolantValue;
+
+ if ( time > interpolant.parameterPositions[ 1 ] ) {
+
+ this.stopFading();
+
+ if ( interpolantValue === 0 ) {
+
+ // faded out, disable
+ this.enabled = false;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ this._effectiveWeight = weight;
+ return weight;
+
+ }
+
+ _updateTimeScale( time ) {
+
+ let timeScale = 0;
+
+ if ( ! this.paused ) {
+
+ timeScale = this.timeScale;
+
+ const interpolant = this._timeScaleInterpolant;
+
+ if ( interpolant !== null ) {
+
+ const interpolantValue = interpolant.evaluate( time )[ 0 ];
+
+ timeScale *= interpolantValue;
+
+ if ( time > interpolant.parameterPositions[ 1 ] ) {
+
+ if ( timeScale === 0 ) {
+
+ // motion has halted, pause
+ this.paused = true;
+
+ } else {
+
+ if ( this._restoreTimeScale !== null ) {
+
+ timeScale = this._restoreTimeScale;
+
+ }
+
+ // warp done - apply final time scale
+ this.timeScale = timeScale;
+
+ }
+
+ this.stopWarping();
+
+ }
+
+ }
+
+ }
+
+ this._effectiveTimeScale = timeScale;
+ return timeScale;
+
+ }
+
+ _updateTime( deltaTime ) {
+
+ const duration = this._clip.duration;
+ const loop = this.loop;
+
+ let time = this.time + deltaTime;
+ let loopCount = this._loopCount;
+
+ const pingPong = ( loop === LoopPingPong );
+
+ if ( deltaTime === 0 ) {
+
+ if ( loopCount === -1 ) return time;
+
+ return ( pingPong && ( loopCount & 1 ) === 1 ) ? duration - time : time;
+
+ }
+
+ if ( loop === LoopOnce ) {
+
+ if ( loopCount === -1 ) {
+
+ // just started
+
+ this._loopCount = 0;
+ this._setEndings( true, true, false );
+
+ }
+
+ handle_stop: {
+
+ if ( time >= duration ) {
+
+ time = duration;
+
+ } else if ( time < 0 ) {
+
+ time = 0;
+
+ } else {
+
+ this.time = time;
+
+ break handle_stop;
+
+ }
+
+ if ( this.clampWhenFinished ) this.paused = true;
+ else this.enabled = false;
+
+ this.time = time;
+
+ this._mixer.dispatchEvent( {
+ type: 'finished', action: this,
+ direction: deltaTime < 0 ? -1 : 1
+ } );
+
+ }
+
+ } else { // repetitive Repeat or PingPong
+
+ if ( loopCount === -1 ) {
+
+ // just started
+
+ if ( deltaTime >= 0 ) {
+
+ loopCount = 0;
+
+ this._setEndings( true, this.repetitions === 0, pingPong );
+
+ } else {
+
+ // when looping in reverse direction, the initial
+ // transition through zero counts as a repetition,
+ // so leave loopCount at -1
+
+ this._setEndings( this.repetitions === 0, true, pingPong );
+
+ }
+
+ }
+
+ if ( time >= duration || time < 0 ) {
+
+ // wrap around
+
+ const loopDelta = Math.floor( time / duration ); // signed
+ time -= duration * loopDelta;
+
+ loopCount += Math.abs( loopDelta );
+
+ const pending = this.repetitions - loopCount;
+
+ if ( pending <= 0 ) {
+
+ // have to stop (switch state, clamp time, fire event)
+
+ if ( this.clampWhenFinished ) this.paused = true;
+ else this.enabled = false;
+
+ time = deltaTime > 0 ? duration : 0;
+
+ this.time = time;
+
+ this._mixer.dispatchEvent( {
+ type: 'finished', action: this,
+ direction: deltaTime > 0 ? 1 : -1
+ } );
+
+ } else {
+
+ // keep running
+
+ if ( pending === 1 ) {
+
+ // entering the last round
+
+ const atStart = deltaTime < 0;
+ this._setEndings( atStart, ! atStart, pingPong );
+
+ } else {
+
+ this._setEndings( false, false, pingPong );
+
+ }
+
+ this._loopCount = loopCount;
+
+ this.time = time;
+
+ this._mixer.dispatchEvent( {
+ type: 'loop', action: this, loopDelta: loopDelta
+ } );
+
+ }
+
+ } else {
+
+ this._loopCount = loopCount;
+ this.time = time;
+
+ }
+
+ if ( pingPong && ( loopCount & 1 ) === 1 ) {
+
+ // invert time for the "pong round"
+
+ return duration - time;
+
+ }
+
+ }
+
+ return time;
+
+ }
+
+ _setEndings( atStart, atEnd, pingPong ) {
+
+ const settings = this._interpolantSettings;
+
+ if ( pingPong ) {
+
+ settings.endingStart = ZeroSlopeEnding;
+ settings.endingEnd = ZeroSlopeEnding;
+
+ } else {
+
+ // assuming for LoopOnce atStart == atEnd == true
+
+ if ( atStart ) {
+
+ settings.endingStart = this.zeroSlopeAtStart ? ZeroSlopeEnding : ZeroCurvatureEnding;
+
+ } else {
+
+ settings.endingStart = WrapAroundEnding;
+
+ }
+
+ if ( atEnd ) {
+
+ settings.endingEnd = this.zeroSlopeAtEnd ? ZeroSlopeEnding : ZeroCurvatureEnding;
+
+ } else {
+
+ settings.endingEnd = WrapAroundEnding;
+
+ }
+
+ }
+
+ }
+
+ _scheduleFading( duration, weightNow, weightThen ) {
+
+ const mixer = this._mixer, now = mixer.time;
+ let interpolant = this._weightInterpolant;
+
+ if ( interpolant === null ) {
+
+ interpolant = mixer._lendControlInterpolant();
+ this._weightInterpolant = interpolant;
+
+ }
+
+ const times = interpolant.parameterPositions,
+ values = interpolant.sampleValues;
+
+ times[ 0 ] = now;
+ values[ 0 ] = weightNow;
+ times[ 1 ] = now + duration;
+ values[ 1 ] = weightThen;
+
+ return this;
+
+ }
+
+}
+
+const _controlInterpolantsResultBuffer = new Float32Array( 1 );
+
+/**
+ * `AnimationMixer` is a player for animations on a particular object in
+ * the scene. When multiple objects in the scene are animated independently,
+ * one `AnimationMixer` may be used for each object.
+ */
+class AnimationMixer extends EventDispatcher {
+
+ /**
+ * Constructs a new animation mixer.
+ *
+ * @param {Object3D} root - The object whose animations shall be played by this mixer.
+ */
+ constructor( root ) {
+
+ super();
+
+ this._root = root;
+ this._initMemoryManager();
+ this._accuIndex = 0;
+
+ /**
+ * The global mixer time (in seconds; starting with `0` on the mixer's creation).
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.time = 0;
+
+ /**
+ * A scaling factor for the global time.
+ *
+ * Note: Setting this member to `0` and later back to `1` is a
+ * possibility to pause/unpause all actions that are controlled by this
+ * mixer.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.timeScale = 1.0;
+
+ if ( typeof __THREE_DEVTOOLS__ !== 'undefined' ) {
+
+ __THREE_DEVTOOLS__.dispatchEvent( new CustomEvent( 'observe', { detail: this } ) );
+
+ }
+
+ }
+
+ _bindAction( action, prototypeAction ) {
+
+ const root = action._localRoot || this._root,
+ tracks = action._clip.tracks,
+ nTracks = tracks.length,
+ bindings = action._propertyBindings,
+ interpolants = action._interpolants,
+ rootUuid = root.uuid,
+ bindingsByRoot = this._bindingsByRootAndName;
+
+ let bindingsByName = bindingsByRoot[ rootUuid ];
+
+ if ( bindingsByName === undefined ) {
+
+ bindingsByName = {};
+ bindingsByRoot[ rootUuid ] = bindingsByName;
+
+ }
+
+ for ( let i = 0; i !== nTracks; ++ i ) {
+
+ const track = tracks[ i ],
+ trackName = track.name;
+
+ let binding = bindingsByName[ trackName ];
+
+ if ( binding !== undefined ) {
+
+ ++ binding.referenceCount;
+ bindings[ i ] = binding;
+
+ } else {
+
+ binding = bindings[ i ];
+
+ if ( binding !== undefined ) {
+
+ // existing binding, make sure the cache knows
+
+ if ( binding._cacheIndex === null ) {
+
+ ++ binding.referenceCount;
+ this._addInactiveBinding( binding, rootUuid, trackName );
+
+ }
+
+ continue;
+
+ }
+
+ const path = prototypeAction && prototypeAction.
+ _propertyBindings[ i ].binding.parsedPath;
+
+ binding = new PropertyMixer(
+ PropertyBinding.create( root, trackName, path ),
+ track.ValueTypeName, track.getValueSize() );
+
+ ++ binding.referenceCount;
+ this._addInactiveBinding( binding, rootUuid, trackName );
+
+ bindings[ i ] = binding;
+
+ }
+
+ interpolants[ i ].resultBuffer = binding.buffer;
+
+ }
+
+ }
+
+ _activateAction( action ) {
+
+ if ( ! this._isActiveAction( action ) ) {
+
+ if ( action._cacheIndex === null ) {
+
+ // this action has been forgotten by the cache, but the user
+ // appears to be still using it -> rebind
+
+ const rootUuid = ( action._localRoot || this._root ).uuid,
+ clipUuid = action._clip.uuid,
+ actionsForClip = this._actionsByClip[ clipUuid ];
+
+ this._bindAction( action,
+ actionsForClip && actionsForClip.knownActions[ 0 ] );
+
+ this._addInactiveAction( action, clipUuid, rootUuid );
+
+ }
+
+ const bindings = action._propertyBindings;
+
+ // increment reference counts / sort out state
+ for ( let i = 0, n = bindings.length; i !== n; ++ i ) {
+
+ const binding = bindings[ i ];
+
+ if ( binding.useCount ++ === 0 ) {
+
+ this._lendBinding( binding );
+ binding.saveOriginalState();
+
+ }
+
+ }
+
+ this._lendAction( action );
+
+ }
+
+ }
+
+ _deactivateAction( action ) {
+
+ if ( this._isActiveAction( action ) ) {
+
+ const bindings = action._propertyBindings;
+
+ // decrement reference counts / sort out state
+ for ( let i = 0, n = bindings.length; i !== n; ++ i ) {
+
+ const binding = bindings[ i ];
+
+ if ( -- binding.useCount === 0 ) {
+
+ binding.restoreOriginalState();
+ this._takeBackBinding( binding );
+
+ }
+
+ }
+
+ this._takeBackAction( action );
+
+ }
+
+ }
+
+ // Memory manager
+
+ _initMemoryManager() {
+
+ this._actions = []; // 'nActiveActions' followed by inactive ones
+ this._nActiveActions = 0;
+
+ this._actionsByClip = {};
+ // inside:
+ // {
+ // knownActions: Array< AnimationAction > - used as prototypes
+ // actionByRoot: AnimationAction - lookup
+ // }
+
+
+ this._bindings = []; // 'nActiveBindings' followed by inactive ones
+ this._nActiveBindings = 0;
+
+ this._bindingsByRootAndName = {}; // inside: Map< name, PropertyMixer >
+
+
+ this._controlInterpolants = []; // same game as above
+ this._nActiveControlInterpolants = 0;
+
+ const scope = this;
+
+ this.stats = {
+
+ actions: {
+ get total() {
+
+ return scope._actions.length;
+
+ },
+ get inUse() {
+
+ return scope._nActiveActions;
+
+ }
+ },
+ bindings: {
+ get total() {
+
+ return scope._bindings.length;
+
+ },
+ get inUse() {
+
+ return scope._nActiveBindings;
+
+ }
+ },
+ controlInterpolants: {
+ get total() {
+
+ return scope._controlInterpolants.length;
+
+ },
+ get inUse() {
+
+ return scope._nActiveControlInterpolants;
+
+ }
+ }
+
+ };
+
+ }
+
+ // Memory management for AnimationAction objects
+
+ _isActiveAction( action ) {
+
+ const index = action._cacheIndex;
+ return index !== null && index < this._nActiveActions;
+
+ }
+
+ _addInactiveAction( action, clipUuid, rootUuid ) {
+
+ const actions = this._actions,
+ actionsByClip = this._actionsByClip;
+
+ let actionsForClip = actionsByClip[ clipUuid ];
+
+ if ( actionsForClip === undefined ) {
+
+ actionsForClip = {
+
+ knownActions: [ action ],
+ actionByRoot: {}
+
+ };
+
+ action._byClipCacheIndex = 0;
+
+ actionsByClip[ clipUuid ] = actionsForClip;
+
+ } else {
+
+ const knownActions = actionsForClip.knownActions;
+
+ action._byClipCacheIndex = knownActions.length;
+ knownActions.push( action );
+
+ }
+
+ action._cacheIndex = actions.length;
+ actions.push( action );
+
+ actionsForClip.actionByRoot[ rootUuid ] = action;
+
+ }
+
+ _removeInactiveAction( action ) {
+
+ const actions = this._actions,
+ lastInactiveAction = actions[ actions.length - 1 ],
+ cacheIndex = action._cacheIndex;
+
+ lastInactiveAction._cacheIndex = cacheIndex;
+ actions[ cacheIndex ] = lastInactiveAction;
+ actions.pop();
+
+ action._cacheIndex = null;
+
+
+ const clipUuid = action._clip.uuid,
+ actionsByClip = this._actionsByClip,
+ actionsForClip = actionsByClip[ clipUuid ],
+ knownActionsForClip = actionsForClip.knownActions,
+
+ lastKnownAction =
+ knownActionsForClip[ knownActionsForClip.length - 1 ],
+
+ byClipCacheIndex = action._byClipCacheIndex;
+
+ lastKnownAction._byClipCacheIndex = byClipCacheIndex;
+ knownActionsForClip[ byClipCacheIndex ] = lastKnownAction;
+ knownActionsForClip.pop();
+
+ action._byClipCacheIndex = null;
+
+
+ const actionByRoot = actionsForClip.actionByRoot,
+ rootUuid = ( action._localRoot || this._root ).uuid;
+
+ delete actionByRoot[ rootUuid ];
+
+ if ( knownActionsForClip.length === 0 ) {
+
+ delete actionsByClip[ clipUuid ];
+
+ }
+
+ this._removeInactiveBindingsForAction( action );
+
+ }
+
+ _removeInactiveBindingsForAction( action ) {
+
+ const bindings = action._propertyBindings;
+
+ for ( let i = 0, n = bindings.length; i !== n; ++ i ) {
+
+ const binding = bindings[ i ];
+
+ if ( -- binding.referenceCount === 0 ) {
+
+ this._removeInactiveBinding( binding );
+
+ }
+
+ }
+
+ }
+
+ _lendAction( action ) {
+
+ // [ active actions | inactive actions ]
+ // [ active actions >| inactive actions ]
+ // s a
+ // <-swap->
+ // a s
+
+ const actions = this._actions,
+ prevIndex = action._cacheIndex,
+
+ lastActiveIndex = this._nActiveActions ++,
+
+ firstInactiveAction = actions[ lastActiveIndex ];
+
+ action._cacheIndex = lastActiveIndex;
+ actions[ lastActiveIndex ] = action;
+
+ firstInactiveAction._cacheIndex = prevIndex;
+ actions[ prevIndex ] = firstInactiveAction;
+
+ }
+
+ _takeBackAction( action ) {
+
+ // [ active actions | inactive actions ]
+ // [ active actions |< inactive actions ]
+ // a s
+ // <-swap->
+ // s a
+
+ const actions = this._actions,
+ prevIndex = action._cacheIndex,
+
+ firstInactiveIndex = -- this._nActiveActions,
+
+ lastActiveAction = actions[ firstInactiveIndex ];
+
+ action._cacheIndex = firstInactiveIndex;
+ actions[ firstInactiveIndex ] = action;
+
+ lastActiveAction._cacheIndex = prevIndex;
+ actions[ prevIndex ] = lastActiveAction;
+
+ }
+
+ // Memory management for PropertyMixer objects
+
+ _addInactiveBinding( binding, rootUuid, trackName ) {
+
+ const bindingsByRoot = this._bindingsByRootAndName,
+ bindings = this._bindings;
+
+ let bindingByName = bindingsByRoot[ rootUuid ];
+
+ if ( bindingByName === undefined ) {
+
+ bindingByName = {};
+ bindingsByRoot[ rootUuid ] = bindingByName;
+
+ }
+
+ bindingByName[ trackName ] = binding;
+
+ binding._cacheIndex = bindings.length;
+ bindings.push( binding );
+
+ }
+
+ _removeInactiveBinding( binding ) {
+
+ const bindings = this._bindings,
+ propBinding = binding.binding,
+ rootUuid = propBinding.rootNode.uuid,
+ trackName = propBinding.path,
+ bindingsByRoot = this._bindingsByRootAndName,
+ bindingByName = bindingsByRoot[ rootUuid ],
+
+ lastInactiveBinding = bindings[ bindings.length - 1 ],
+ cacheIndex = binding._cacheIndex;
+
+ lastInactiveBinding._cacheIndex = cacheIndex;
+ bindings[ cacheIndex ] = lastInactiveBinding;
+ bindings.pop();
+
+ delete bindingByName[ trackName ];
+
+ if ( Object.keys( bindingByName ).length === 0 ) {
+
+ delete bindingsByRoot[ rootUuid ];
+
+ }
+
+ }
+
+ _lendBinding( binding ) {
+
+ const bindings = this._bindings,
+ prevIndex = binding._cacheIndex,
+
+ lastActiveIndex = this._nActiveBindings ++,
+
+ firstInactiveBinding = bindings[ lastActiveIndex ];
+
+ binding._cacheIndex = lastActiveIndex;
+ bindings[ lastActiveIndex ] = binding;
+
+ firstInactiveBinding._cacheIndex = prevIndex;
+ bindings[ prevIndex ] = firstInactiveBinding;
+
+ }
+
+ _takeBackBinding( binding ) {
+
+ const bindings = this._bindings,
+ prevIndex = binding._cacheIndex,
+
+ firstInactiveIndex = -- this._nActiveBindings,
+
+ lastActiveBinding = bindings[ firstInactiveIndex ];
+
+ binding._cacheIndex = firstInactiveIndex;
+ bindings[ firstInactiveIndex ] = binding;
+
+ lastActiveBinding._cacheIndex = prevIndex;
+ bindings[ prevIndex ] = lastActiveBinding;
+
+ }
+
+
+ // Memory management of Interpolants for weight and time scale
+
+ _lendControlInterpolant() {
+
+ const interpolants = this._controlInterpolants,
+ lastActiveIndex = this._nActiveControlInterpolants ++;
+
+ let interpolant = interpolants[ lastActiveIndex ];
+
+ if ( interpolant === undefined ) {
+
+ interpolant = new LinearInterpolant(
+ new Float32Array( 2 ), new Float32Array( 2 ),
+ 1, _controlInterpolantsResultBuffer );
+
+ interpolant.__cacheIndex = lastActiveIndex;
+ interpolants[ lastActiveIndex ] = interpolant;
+
+ }
+
+ return interpolant;
+
+ }
+
+ _takeBackControlInterpolant( interpolant ) {
+
+ const interpolants = this._controlInterpolants,
+ prevIndex = interpolant.__cacheIndex,
+
+ firstInactiveIndex = -- this._nActiveControlInterpolants,
+
+ lastActiveInterpolant = interpolants[ firstInactiveIndex ];
+
+ interpolant.__cacheIndex = firstInactiveIndex;
+ interpolants[ firstInactiveIndex ] = interpolant;
+
+ lastActiveInterpolant.__cacheIndex = prevIndex;
+ interpolants[ prevIndex ] = lastActiveInterpolant;
+
+ }
+
+ /**
+ * Returns an instance of {@link AnimationAction} for the passed clip.
+ *
+ * If an action fitting the clip and root parameters doesn't yet exist, it
+ * will be created by this method. Calling this method several times with the
+ * same clip and root parameters always returns the same action.
+ *
+ * @param {AnimationClip|string} clip - An animation clip or alternatively the name of the animation clip.
+ * @param {Object3D} [optionalRoot] - An alternative root object.
+ * @param {(NormalAnimationBlendMode|AdditiveAnimationBlendMode)} [blendMode] - The blend mode.
+ * @return {?AnimationAction} The animation action.
+ */
+ clipAction( clip, optionalRoot, blendMode ) {
+
+ const root = optionalRoot || this._root,
+ rootUuid = root.uuid;
+
+ let clipObject = typeof clip === 'string' ? AnimationClip.findByName( root, clip ) : clip;
+
+ const clipUuid = clipObject !== null ? clipObject.uuid : clip;
+
+ const actionsForClip = this._actionsByClip[ clipUuid ];
+ let prototypeAction = null;
+
+ if ( blendMode === undefined ) {
+
+ if ( clipObject !== null ) {
+
+ blendMode = clipObject.blendMode;
+
+ } else {
+
+ blendMode = NormalAnimationBlendMode;
+
+ }
+
+ }
+
+ if ( actionsForClip !== undefined ) {
+
+ const existingAction = actionsForClip.actionByRoot[ rootUuid ];
+
+ if ( existingAction !== undefined && existingAction.blendMode === blendMode ) {
+
+ return existingAction;
+
+ }
+
+ // we know the clip, so we don't have to parse all
+ // the bindings again but can just copy
+ prototypeAction = actionsForClip.knownActions[ 0 ];
+
+ // also, take the clip from the prototype action
+ if ( clipObject === null )
+ clipObject = prototypeAction._clip;
+
+ }
+
+ // clip must be known when specified via string
+ if ( clipObject === null ) return null;
+
+ // allocate all resources required to run it
+ const newAction = new AnimationAction( this, clipObject, optionalRoot, blendMode );
+
+ this._bindAction( newAction, prototypeAction );
+
+ // and make the action known to the memory manager
+ this._addInactiveAction( newAction, clipUuid, rootUuid );
+
+ return newAction;
+
+ }
+
+ /**
+ * Returns an existing animation action for the passed clip.
+ *
+ * @param {AnimationClip|string} clip - An animation clip or alternatively the name of the animation clip.
+ * @param {Object3D} [optionalRoot] - An alternative root object.
+ * @return {?AnimationAction} The animation action. Returns `null` if no action was found.
+ */
+ existingAction( clip, optionalRoot ) {
+
+ const root = optionalRoot || this._root,
+ rootUuid = root.uuid,
+
+ clipObject = typeof clip === 'string' ?
+ AnimationClip.findByName( root, clip ) : clip,
+
+ clipUuid = clipObject ? clipObject.uuid : clip,
+
+ actionsForClip = this._actionsByClip[ clipUuid ];
+
+ if ( actionsForClip !== undefined ) {
+
+ return actionsForClip.actionByRoot[ rootUuid ] || null;
+
+ }
+
+ return null;
+
+ }
+
+ /**
+ * Deactivates all previously scheduled actions on this mixer.
+ *
+ * @return {AnimationMixer} A reference to this animation mixer.
+ */
+ stopAllAction() {
+
+ const actions = this._actions,
+ nActions = this._nActiveActions;
+
+ for ( let i = nActions - 1; i >= 0; -- i ) {
+
+ actions[ i ].stop();
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Advances the global mixer time and updates the animation.
+ *
+ * This is usually done in the render loop by passing the delta
+ * time from {@link Clock} or {@link Timer}.
+ *
+ * @param {number} deltaTime - The delta time in seconds.
+ * @return {AnimationMixer} A reference to this animation mixer.
+ */
+ update( deltaTime ) {
+
+ deltaTime *= this.timeScale;
+
+ const actions = this._actions,
+ nActions = this._nActiveActions,
+
+ time = this.time += deltaTime,
+ timeDirection = Math.sign( deltaTime ),
+
+ accuIndex = this._accuIndex ^= 1;
+
+ // run active actions
+
+ for ( let i = 0; i !== nActions; ++ i ) {
+
+ const action = actions[ i ];
+
+ action._update( time, deltaTime, timeDirection, accuIndex );
+
+ }
+
+ // update scene graph
+
+ const bindings = this._bindings,
+ nBindings = this._nActiveBindings;
+
+ for ( let i = 0; i !== nBindings; ++ i ) {
+
+ bindings[ i ].apply( accuIndex );
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Sets the global mixer to a specific time and updates the animation accordingly.
+ *
+ * This is useful when you need to jump to an exact time in an animation. The
+ * input parameter will be scaled by {@link AnimationMixer#timeScale}
+ *
+ * @param {number} time - The time to set in seconds.
+ * @return {AnimationMixer} A reference to this animation mixer.
+ */
+ setTime( time ) {
+
+ this.time = 0; // Zero out time attribute for AnimationMixer object;
+ for ( let i = 0; i < this._actions.length; i ++ ) {
+
+ this._actions[ i ].time = 0; // Zero out time attribute for all associated AnimationAction objects.
+
+ }
+
+ return this.update( time ); // Update used to set exact time. Returns "this" AnimationMixer object.
+
+ }
+
+ /**
+ * Returns this mixer's root object.
+ *
+ * @return {Object3D} The mixer's root object.
+ */
+ getRoot() {
+
+ return this._root;
+
+ }
+
+ /**
+ * Deallocates all memory resources for a clip. Before using this method make
+ * sure to call {@link AnimationAction#stop} for all related actions.
+ *
+ * @param {AnimationClip} clip - The clip to uncache.
+ */
+ uncacheClip( clip ) {
+
+ const actions = this._actions,
+ clipUuid = clip.uuid,
+ actionsByClip = this._actionsByClip,
+ actionsForClip = actionsByClip[ clipUuid ];
+
+ if ( actionsForClip !== undefined ) {
+
+ // note: just calling _removeInactiveAction would mess up the
+ // iteration state and also require updating the state we can
+ // just throw away
+
+ const actionsToRemove = actionsForClip.knownActions;
+
+ for ( let i = 0, n = actionsToRemove.length; i !== n; ++ i ) {
+
+ const action = actionsToRemove[ i ];
+
+ this._deactivateAction( action );
+
+ const cacheIndex = action._cacheIndex,
+ lastInactiveAction = actions[ actions.length - 1 ];
+
+ action._cacheIndex = null;
+ action._byClipCacheIndex = null;
+
+ lastInactiveAction._cacheIndex = cacheIndex;
+ actions[ cacheIndex ] = lastInactiveAction;
+ actions.pop();
+
+ this._removeInactiveBindingsForAction( action );
+
+ }
+
+ delete actionsByClip[ clipUuid ];
+
+ }
+
+ }
+
+ /**
+ * Deallocates all memory resources for a root object. Before using this
+ * method make sure to call {@link AnimationAction#stop} for all related
+ * actions or alternatively {@link AnimationMixer#stopAllAction} when the
+ * mixer operates on a single root.
+ *
+ * @param {Object3D} root - The root object to uncache.
+ */
+ uncacheRoot( root ) {
+
+ const rootUuid = root.uuid,
+ actionsByClip = this._actionsByClip;
+
+ for ( const clipUuid in actionsByClip ) {
+
+ const actionByRoot = actionsByClip[ clipUuid ].actionByRoot,
+ action = actionByRoot[ rootUuid ];
+
+ if ( action !== undefined ) {
+
+ this._deactivateAction( action );
+ this._removeInactiveAction( action );
+
+ }
+
+ }
+
+ const bindingsByRoot = this._bindingsByRootAndName,
+ bindingByName = bindingsByRoot[ rootUuid ];
+
+ if ( bindingByName !== undefined ) {
+
+ for ( const trackName in bindingByName ) {
+
+ const binding = bindingByName[ trackName ];
+ binding.restoreOriginalState();
+ this._removeInactiveBinding( binding );
+
+ }
+
+ }
+
+ }
+
+ /**
+ * Deallocates all memory resources for an action. The action is identified by the
+ * given clip and an optional root object. Before using this method make
+ * sure to call {@link AnimationAction#stop} to deactivate the action.
+ *
+ * @param {AnimationClip|string} clip - An animation clip or alternatively the name of the animation clip.
+ * @param {Object3D} [optionalRoot] - An alternative root object.
+ */
+ uncacheAction( clip, optionalRoot ) {
+
+ const action = this.existingAction( clip, optionalRoot );
+
+ if ( action !== null ) {
+
+ this._deactivateAction( action );
+ this._removeInactiveAction( action );
+
+ }
+
+ }
+
+}
+
+/**
+ * Represents a 3D render target.
+ *
+ * @augments RenderTarget
+ */
+class RenderTarget3D extends RenderTarget {
+
+ /**
+ * Constructs a new 3D render target.
+ *
+ * @param {number} [width=1] - The width of the render target.
+ * @param {number} [height=1] - The height of the render target.
+ * @param {number} [depth=1] - The height of the render target.
+ * @param {RenderTarget~Options} [options] - The configuration object.
+ */
+ constructor( width = 1, height = 1, depth = 1, options = {} ) {
+
+ super( width, height, options );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isRenderTarget3D = true;
+
+ this.depth = depth;
+
+ // overwrite attachments with 3D textures
+
+ for ( let i = 0; i < this.textures.length; i ++ ) {
+
+ const texture = new Data3DTexture( null, width, height, depth );
+ texture.isRenderTargetTexture = true;
+ texture.renderTarget = this;
+
+ this.textures[ i ] = texture;
+
+ }
+
+ this._setTextureOptions( options );
+
+ }
+
+}
+
+/**
+ * Represents a uniform which is a global shader variable. They are passed to shader programs.
+ *
+ * When declaring a uniform of a {@link ShaderMaterial}, it is declared by value or by object.
+ * ```js
+ * uniforms: {
+ * time: { value: 1.0 },
+ * resolution: new Uniform( new Vector2() )
+ * };
+ * ```
+ * Since this class can only be used in context of {@link ShaderMaterial}, it is only supported
+ * in {@link WebGLRenderer}.
+ */
+class Uniform {
+
+ /**
+ * Constructs a new uniform.
+ *
+ * @param {any} value - The uniform value.
+ */
+ constructor( value ) {
+
+ /**
+ * The uniform value.
+ *
+ * @type {any}
+ */
+ this.value = value;
+
+ }
+
+ /**
+ * Returns a new uniform with copied values from this instance.
+ * If the value has a `clone()` method, the value is cloned as well.
+ *
+ * @return {Uniform} A clone of this instance.
+ */
+ clone() {
+
+ return new Uniform( this.value.clone === undefined ? this.value : this.value.clone() );
+
+ }
+
+}
+
+let _id = 0;
+
+/**
+ * A class for managing multiple uniforms in a single group. The renderer will process
+ * such a definition as a single UBO.
+ *
+ * Since this class can only be used in context of {@link ShaderMaterial}, it is only supported
+ * in {@link WebGLRenderer}.
+ *
+ * @augments EventDispatcher
+ */
+class UniformsGroup extends EventDispatcher {
+
+ /**
+ * Constructs a new uniforms group.
+ */
+ constructor() {
+
+ super();
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isUniformsGroup = true;
+
+ /**
+ * The ID of the 3D object.
+ *
+ * @name UniformsGroup#id
+ * @type {number}
+ * @readonly
+ */
+ Object.defineProperty( this, 'id', { value: _id ++ } );
+
+ /**
+ * The name of the uniforms group.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The buffer usage.
+ *
+ * @type {(StaticDrawUsage|DynamicDrawUsage|StreamDrawUsage|StaticReadUsage|DynamicReadUsage|StreamReadUsage|StaticCopyUsage|DynamicCopyUsage|StreamCopyUsage)}
+ * @default StaticDrawUsage
+ */
+ this.usage = StaticDrawUsage;
+
+ /**
+ * An array holding the uniforms.
+ *
+ * @type {Array}
+ */
+ this.uniforms = [];
+
+ }
+
+ /**
+ * Adds the given uniform to this uniforms group.
+ *
+ * @param {Uniform} uniform - The uniform to add.
+ * @return {UniformsGroup} A reference to this uniforms group.
+ */
+ add( uniform ) {
+
+ this.uniforms.push( uniform );
+
+ return this;
+
+ }
+
+ /**
+ * Removes the given uniform from this uniforms group.
+ *
+ * @param {Uniform} uniform - The uniform to remove.
+ * @return {UniformsGroup} A reference to this uniforms group.
+ */
+ remove( uniform ) {
+
+ const index = this.uniforms.indexOf( uniform );
+
+ if ( index !== -1 ) this.uniforms.splice( index, 1 );
+
+ return this;
+
+ }
+
+ /**
+ * Sets the name of this uniforms group.
+ *
+ * @param {string} name - The name to set.
+ * @return {UniformsGroup} A reference to this uniforms group.
+ */
+ setName( name ) {
+
+ this.name = name;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the usage of this uniforms group.
+ *
+ * @param {(StaticDrawUsage|DynamicDrawUsage|StreamDrawUsage|StaticReadUsage|DynamicReadUsage|StreamReadUsage|StaticCopyUsage|DynamicCopyUsage|StreamCopyUsage)} value - The usage to set.
+ * @return {UniformsGroup} A reference to this uniforms group.
+ */
+ setUsage( value ) {
+
+ this.usage = value;
+
+ return this;
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance. Call this
+ * method whenever this instance is no longer used in your app.
+ *
+ * @fires Texture#dispose
+ */
+ dispose() {
+
+ this.dispatchEvent( { type: 'dispose' } );
+
+ }
+
+ /**
+ * Copies the values of the given uniforms group to this instance.
+ *
+ * @param {UniformsGroup} source - The uniforms group to copy.
+ * @return {UniformsGroup} A reference to this uniforms group.
+ */
+ copy( source ) {
+
+ this.name = source.name;
+ this.usage = source.usage;
+
+ const uniformsSource = source.uniforms;
+
+ this.uniforms.length = 0;
+
+ for ( let i = 0, l = uniformsSource.length; i < l; i ++ ) {
+
+ const uniforms = Array.isArray( uniformsSource[ i ] ) ? uniformsSource[ i ] : [ uniformsSource[ i ] ];
+
+ for ( let j = 0; j < uniforms.length; j ++ ) {
+
+ this.uniforms.push( uniforms[ j ].clone() );
+
+ }
+
+ }
+
+ return this;
+
+ }
+
+ /**
+ * Returns a new uniforms group with copied values from this instance.
+ *
+ * @return {UniformsGroup} A clone of this instance.
+ */
+ clone() {
+
+ return new this.constructor().copy( this );
+
+ }
+
+}
+
+/**
+ * An instanced version of an interleaved buffer.
+ *
+ * @augments InterleavedBuffer
+ */
+class InstancedInterleavedBuffer extends InterleavedBuffer {
+
+ /**
+ * Constructs a new instanced interleaved buffer.
+ *
+ * @param {TypedArray} array - A typed array with a shared buffer storing attribute data.
+ * @param {number} stride - The number of typed-array elements per vertex.
+ * @param {number} [meshPerAttribute=1] - Defines how often a value of this interleaved buffer should be repeated.
+ */
+ constructor( array, stride, meshPerAttribute = 1 ) {
+
+ super( array, stride );
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isInstancedInterleavedBuffer = true;
+
+ /**
+ * Defines how often a value of this buffer attribute should be repeated,
+ * see {@link InstancedBufferAttribute#meshPerAttribute}.
+ *
+ * @type {number}
+ * @default 1
+ */
+ this.meshPerAttribute = meshPerAttribute;
+
+ }
+
+ copy( source ) {
+
+ super.copy( source );
+
+ this.meshPerAttribute = source.meshPerAttribute;
+
+ return this;
+
+ }
+
+ clone( data ) {
+
+ const ib = super.clone( data );
+
+ ib.meshPerAttribute = this.meshPerAttribute;
+
+ return ib;
+
+ }
+
+ toJSON( data ) {
+
+ const json = super.toJSON( data );
+
+ json.isInstancedInterleavedBuffer = true;
+ json.meshPerAttribute = this.meshPerAttribute;
+
+ return json;
+
+ }
+
+}
+
+/**
+ * An alternative version of a buffer attribute with more control over the VBO.
+ *
+ * The renderer does not construct a VBO for this kind of attribute. Instead, it uses
+ * whatever VBO is passed in constructor and can later be altered via the `buffer` property.
+ *
+ * The most common use case for this class is when some kind of GPGPU calculation interferes
+ * or even produces the VBOs in question.
+ *
+ * Notice that this class can only be used with {@link WebGLRenderer}.
+ */
+class GLBufferAttribute {
+
+ /**
+ * Constructs a new GL buffer attribute.
+ *
+ * @param {WebGLBuffer} buffer - The native WebGL buffer.
+ * @param {number} type - The native data type (e.g. `gl.FLOAT`).
+ * @param {number} itemSize - The item size.
+ * @param {number} elementSize - The corresponding size (in bytes) for the given `type` parameter.
+ * @param {number} count - The expected number of vertices in VBO.
+ * @param {boolean} [normalized=false] - Whether the data are normalized or not.
+ */
+ constructor( buffer, type, itemSize, elementSize, count, normalized = false ) {
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isGLBufferAttribute = true;
+
+ /**
+ * The name of the buffer attribute.
+ *
+ * @type {string}
+ */
+ this.name = '';
+
+ /**
+ * The native WebGL buffer.
+ *
+ * @type {WebGLBuffer}
+ */
+ this.buffer = buffer;
+
+ /**
+ * The native data type.
+ *
+ * @type {number}
+ */
+ this.type = type;
+
+ /**
+ * The item size, see {@link BufferAttribute#itemSize}.
+ *
+ * @type {number}
+ */
+ this.itemSize = itemSize;
+
+ /**
+ * The corresponding size (in bytes) for the given `type` parameter.
+ *
+ * @type {number}
+ */
+ this.elementSize = elementSize;
+
+ /**
+ * The expected number of vertices in VBO.
+ *
+ * @type {number}
+ */
+ this.count = count;
+
+ /**
+ * Applies to integer data only. Indicates how the underlying data in the buffer maps to
+ * the values in the GLSL code. For instance, if `buffer` contains data of `gl.UNSIGNED_SHORT`,
+ * and `normalized` is `true`, the values `0 - +65535` in the buffer data will be mapped to
+ * `0.0f - +1.0f` in the GLSL attribute. If `normalized` is `false`, the values will be converted
+ * to floats unmodified, i.e. `65535` becomes `65535.0f`.
+ *
+ * @type {boolean}
+ */
+ this.normalized = normalized;
+
+ /**
+ * A version number, incremented every time the `needsUpdate` is set to `true`.
+ *
+ * @type {number}
+ */
+ this.version = 0;
+
+ }
+
+ /**
+ * Flag to indicate that this attribute has changed and should be re-sent to
+ * the GPU. Set this to `true` when you modify the value of the array.
+ *
+ * @type {number}
+ * @default false
+ * @param {boolean} value
+ */
+ set needsUpdate( value ) {
+
+ if ( value === true ) this.version ++;
+
+ }
+
+ /**
+ * Sets the given native WebGL buffer.
+ *
+ * @param {WebGLBuffer} buffer - The buffer to set.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setBuffer( buffer ) {
+
+ this.buffer = buffer;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the given native data type and element size.
+ *
+ * @param {number} type - The native data type (e.g. `gl.FLOAT`).
+ * @param {number} elementSize - The corresponding size (in bytes) for the given `type` parameter.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setType( type, elementSize ) {
+
+ this.type = type;
+ this.elementSize = elementSize;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the item size.
+ *
+ * @param {number} itemSize - The item size.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setItemSize( itemSize ) {
+
+ this.itemSize = itemSize;
+
+ return this;
+
+ }
+
+ /**
+ * Sets the count (the expected number of vertices in VBO).
+ *
+ * @param {number} count - The count.
+ * @return {BufferAttribute} A reference to this instance.
+ */
+ setCount( count ) {
+
+ this.count = count;
+
+ return this;
+
+ }
+
+}
+
+const _matrix = /*@__PURE__*/ new Matrix4();
+
+/**
+ * This class is designed to assist with raycasting. Raycasting is used for
+ * mouse picking (working out what objects in the 3d space the mouse is over)
+ * amongst other things.
+ */
+class Raycaster {
+
+ /**
+ * Constructs a new raycaster.
+ *
+ * @param {Vector3} origin - The origin vector where the ray casts from.
+ * @param {Vector3} direction - The (normalized) direction vector that gives direction to the ray.
+ * @param {number} [near=0] - All results returned are further away than near. Near can't be negative.
+ * @param {number} [far=Infinity] - All results returned are closer than far. Far can't be lower than near.
+ */
+ constructor( origin, direction, near = 0, far = Infinity ) {
+
+ /**
+ * The ray used for raycasting.
+ *
+ * @type {Ray}
+ */
+ this.ray = new Ray( origin, direction );
+
+ /**
+ * All results returned are further away than near. Near can't be negative.
+ *
+ * @type {number}
+ * @default 0
+ */
+ this.near = near;
+
+ /**
+ * All results returned are closer than far. Far can't be lower than near.
+ *
+ * @type {number}
+ * @default Infinity
+ */
+ this.far = far;
+
+ /**
+ * The camera to use when raycasting against view-dependent objects such as
+ * billboarded objects like sprites. This field can be set manually or
+ * is set when calling `setFromCamera()`.
+ *
+ * @type {?Camera}
+ * @default null
+ */
+ this.camera = null;
+
+ /**
+ * Allows to selectively ignore 3D objects when performing intersection tests.
+ * The following code example ensures that only 3D objects on layer `1` will be
+ * honored by raycaster.
+ * ```js
+ * raycaster.layers.set( 1 );
+ * object.layers.enable( 1 );
+ * ```
+ *
+ * @type {Layers}
+ */
+ this.layers = new Layers();
+
+
+ /**
+ * A parameter object that configures the raycasting. It has the structure:
+ *
+ * ```
+ * {
+ * Mesh: {},
+ * Line: { threshold: 1 },
+ * LOD: {},
+ * Points: { threshold: 1 },
+ * Sprite: {}
+ * }
+ * ```
+ * Where `threshold` is the precision of the raycaster when intersecting objects, in world units.
+ *
+ * @type {Object}
+ */
+ this.params = {
+ Mesh: {},
+ Line: { threshold: 1 },
+ LOD: {},
+ Points: { threshold: 1 },
+ Sprite: {}
+ };
+
+ }
+
+ /**
+ * Updates the ray with a new origin and direction by copying the values from the arguments.
+ *
+ * @param {Vector3} origin - The origin vector where the ray casts from.
+ * @param {Vector3} direction - The (normalized) direction vector that gives direction to the ray.
+ */
+ set( origin, direction ) {
+
+ // direction is assumed to be normalized (for accurate distance calculations)
+
+ this.ray.set( origin, direction );
+
+ }
+
+ /**
+ * Uses the given coordinates and camera to compute a new origin and direction for the internal ray.
+ *
+ * @param {Vector2} coords - 2D coordinates of the mouse, in normalized device coordinates (NDC).
+ * X and Y components should be between `-1` and `1`.
+ * @param {Camera} camera - The camera from which the ray should originate.
+ */
+ setFromCamera( coords, camera ) {
+
+ if ( camera.isPerspectiveCamera ) {
+
+ this.ray.origin.setFromMatrixPosition( camera.matrixWorld );
+ this.ray.direction.set( coords.x, coords.y, 0.5 ).unproject( camera ).sub( this.ray.origin ).normalize();
+ this.camera = camera;
+
+ } else if ( camera.isOrthographicCamera ) {
+
+ this.ray.origin.set( coords.x, coords.y, camera.projectionMatrix.elements[ 14 ] ).unproject( camera ); // set origin in plane of camera
+ this.ray.direction.set( 0, 0, -1 ).transformDirection( camera.matrixWorld );
+ this.camera = camera;
+
+ } else {
+
+ error( 'Raycaster: Unsupported camera type: ' + camera.type );
+
+ }
+
+ }
+
+ /**
+ * Uses the given WebXR controller to compute a new origin and direction for the internal ray.
+ *
+ * @param {WebXRController} controller - The controller to copy the position and direction from.
+ * @return {Raycaster} A reference to this raycaster.
+ */
+ setFromXRController( controller ) {
+
+ _matrix.identity().extractRotation( controller.matrixWorld );
+
+ this.ray.origin.setFromMatrixPosition( controller.matrixWorld );
+ this.ray.direction.set( 0, 0, -1 ).applyMatrix4( _matrix );
+
+ return this;
+
+ }
+
+ /**
+ * The intersection point of a raycaster intersection test.
+ * @typedef {Object} Raycaster~Intersection
+ * @property {number} distance - The distance from the ray's origin to the intersection point.
+ * @property {number} distanceToRay - Some 3D objects e.g. {@link Points} provide the distance of the
+ * intersection to the nearest point on the ray. For other objects it will be `undefined`.
+ * @property {Vector3} point - The intersection point, in world coordinates.
+ * @property {Object} face - The face that has been intersected.
+ * @property {number} faceIndex - The face index.
+ * @property {Object3D} object - The 3D object that has been intersected.
+ * @property {Vector2} uv - U,V coordinates at point of intersection.
+ * @property {Vector2} uv1 - Second set of U,V coordinates at point of intersection.
+ * @property {Vector3} normal - Interpolated normal vector at point of intersection.
+ * @property {number} instanceId - The index number of the instance where the ray
+ * intersects the {@link InstancedMesh}.
+ */
+
+ /**
+ * Checks all intersection between the ray and the object with or without the
+ * descendants. Intersections are returned sorted by distance, closest first.
+ *
+ * `Raycaster` delegates to the `raycast()` method of the passed 3D object, when
+ * evaluating whether the ray intersects the object or not. This allows meshes to respond
+ * differently to ray casting than lines or points.
+ *
+ * Note that for meshes, faces must be pointed towards the origin of the ray in order
+ * to be detected; intersections of the ray passing through the back of a face will not
+ * be detected. To raycast against both faces of an object, you'll want to set {@link Material#side}
+ * to `THREE.DoubleSide`.
+ *
+ * Note that a ray hitting a triangle mesh exactly along an edge shared by two faces may be
+ * reported by both faces, resulting in two coincident intersections (identical point and
+ * distance) in the returned array.
+ *
+ * @param {Object3D} object - The 3D object to check for intersection with the ray.
+ * @param {boolean} [recursive=true] - If set to `true`, it also checks all descendants.
+ * Otherwise it only checks intersection with the object.
+ * @param {Array