@wodzik/cubecore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +118 -0
- package/analyze/client.d.ts +18 -0
- package/analyze/client.js +42 -0
- package/analyze/index.d.ts +114 -0
- package/analyze/index.js +151 -0
- package/analyze/rotations.d.ts +3 -0
- package/analyze/rotations.js +15 -0
- package/analyze/roux.d.ts +67 -0
- package/analyze/roux.js +95 -0
- package/analyze/worker.d.ts +6 -0
- package/analyze/worker.js +31 -0
- package/analyze/zz.d.ts +47 -0
- package/analyze/zz.js +102 -0
- package/bld/index.d.ts +14 -0
- package/bld/index.js +14 -0
- package/bld/memo.d.ts +66 -0
- package/bld/memo.js +139 -0
- package/bld/scheme.d.ts +30 -0
- package/bld/scheme.js +35 -0
- package/bld/skin.d.ts +26 -0
- package/bld/skin.js +46 -0
- package/bld/tracker.d.ts +68 -0
- package/bld/tracker.js +145 -0
- package/bluetooth/clock.d.ts +22 -0
- package/bluetooth/clock.js +37 -0
- package/bluetooth/cubeSkins.d.ts +31 -0
- package/bluetooth/cubeSkins.js +32 -0
- package/bluetooth/gyro.d.ts +31 -0
- package/bluetooth/gyro.js +46 -0
- package/bluetooth/index.d.ts +5 -0
- package/bluetooth/index.js +5 -0
- package/bluetooth/session.d.ts +140 -0
- package/bluetooth/session.js +201 -0
- package/bluetooth/simulated.d.ts +52 -0
- package/bluetooth/simulated.js +80 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-cube-definitions.d.ts +38 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-cube-definitions.js +51 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-cube-encrypter.d.ts +35 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-cube-encrypter.js +70 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-cube-protocol.d.ts +273 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-cube-protocol.js +846 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-gen1.d.ts +38 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-gen1.js +340 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-gen234-packet-validate.d.ts +11 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-gen234-packet-validate.js +118 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-mac-salt.d.ts +2 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-mac-salt.js +15 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-smart-cube.d.ts +18 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-smart-cube.js +152 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-smart-timer.d.ts +74 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-smart-timer.js +161 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-write-queue.d.ts +7 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gan-write-queue.js +11 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gatt-characteristic-write.d.ts +2 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/gatt-characteristic-write.js +36 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/index.d.ts +4 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/index.js +4 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/address-hints.d.ts +12 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/address-hints.js +122 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/build-picker-options.d.ts +11 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/build-picker-options.js +55 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/default-optional-services.d.ts +21 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/default-optional-services.js +47 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/device-name-scan-filters.d.ts +6 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/device-name-scan-filters.js +24 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/gatt-snapshot.d.ts +6 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/gatt-snapshot.js +57 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/mac-candidates.d.ts +11 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/mac-candidates.js +67 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/mac-probe-moyu32.d.ts +8 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/mac-probe-moyu32.js +98 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/mac-probe-qiyi.d.ts +8 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/mac-probe-qiyi.js +128 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/moyu32-session-crypto.d.ts +5 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/moyu32-session-crypto.js +60 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/normalize-uuid.d.ts +2 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/normalize-uuid.js +8 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/packet-sanity.d.ts +7 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/packet-sanity.js +142 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/profile-rank.d.ts +6 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/profile-rank.js +21 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/types.d.ts +32 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/attachment/types.js +1 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/ble-utils.d.ts +6 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/ble-utils.js +68 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/connect.d.ts +3 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/connect.js +178 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/cubie-cube.d.ts +16 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/cubie-cube.js +141 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/index.d.ts +11 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/index.js +10 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocol.d.ts +26 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocol.js +8 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/gan.d.ts +3 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/gan.js +265 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/giiker.d.ts +3 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/giiker.js +345 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/gocube.d.ts +15 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/gocube.js +382 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/moyu-mhc.d.ts +3 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/moyu-mhc.js +352 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/moyu-v1.d.ts +57 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/moyu-v1.js +243 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/moyu32.d.ts +3 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/moyu32.js +454 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/qiyi.d.ts +3 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/protocols/qiyi.js +424 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/types.d.ts +78 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/smartcube/types.js +1 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/utils.d.ts +45 -0
- package/bluetooth/vendor/smartcube-web-bluetooth/utils.js +150 -0
- package/cfop/caseData.d.ts +16 -0
- package/cfop/caseData.js +88 -0
- package/cfop/cases.d.ts +42 -0
- package/cfop/cases.js +116 -0
- package/cfop/f2l.d.ts +28 -0
- package/cfop/f2l.js +88 -0
- package/cfop/f2lData.d.ts +15 -0
- package/cfop/f2lData.js +49 -0
- package/cfop/index.d.ts +41 -0
- package/cfop/index.js +110 -0
- package/cfop/slots.d.ts +2 -0
- package/cfop/slots.js +1 -0
- package/core/arrows.d.ts +24 -0
- package/core/arrows.js +22 -0
- package/core/bytes.d.ts +27 -0
- package/core/bytes.js +145 -0
- package/core/centers.d.ts +32 -0
- package/core/centers.js +83 -0
- package/core/checks.d.ts +53 -0
- package/core/checks.js +85 -0
- package/core/cubies.d.ts +45 -0
- package/core/cubies.js +169 -0
- package/core/frames.d.ts +61 -0
- package/core/frames.js +126 -0
- package/core/geometry.d.ts +58 -0
- package/core/geometry.js +132 -0
- package/core/index.d.ts +19 -0
- package/core/index.js +19 -0
- package/core/masks.d.ts +32 -0
- package/core/masks.js +39 -0
- package/core/method.d.ts +35 -0
- package/core/method.js +30 -0
- package/core/moves.d.ts +48 -0
- package/core/moves.js +88 -0
- package/core/notation.d.ts +48 -0
- package/core/notation.js +226 -0
- package/core/physical.d.ts +53 -0
- package/core/physical.js +99 -0
- package/core/practice.d.ts +62 -0
- package/core/practice.js +87 -0
- package/core/sequence.d.ts +90 -0
- package/core/sequence.js +157 -0
- package/core/stages.d.ts +90 -0
- package/core/stages.js +21 -0
- package/core/state.d.ts +55 -0
- package/core/state.js +140 -0
- package/core/stateCodec.d.ts +12 -0
- package/core/stateCodec.js +51 -0
- package/core/stream.d.ts +45 -0
- package/core/stream.js +75 -0
- package/core/tracker.d.ts +62 -0
- package/core/tracker.js +121 -0
- package/element/alg.d.ts +45 -0
- package/element/alg.js +150 -0
- package/element/base.d.ts +2 -0
- package/element/base.js +3 -0
- package/element/bld.d.ts +82 -0
- package/element/bld.js +222 -0
- package/element/index.d.ts +8 -0
- package/element/index.js +21 -0
- package/element/model.d.ts +36 -0
- package/element/model.js +64 -0
- package/element/player.d.ts +196 -0
- package/element/player.js +680 -0
- package/element/practice.d.ts +75 -0
- package/element/practice.js +150 -0
- package/element/scramble.d.ts +50 -0
- package/element/scramble.js +107 -0
- package/element/sequence.d.ts +126 -0
- package/element/sequence.js +254 -0
- package/element/styles.d.ts +32 -0
- package/element/styles.js +196 -0
- package/image/cache.d.ts +21 -0
- package/image/cache.js +66 -0
- package/image/index.d.ts +2 -0
- package/image/index.js +2 -0
- package/image/png-node.d.ts +6 -0
- package/image/png-node.js +10 -0
- package/image/svg.d.ts +36 -0
- package/image/svg.js +215 -0
- package/lbl/index.d.ts +10 -0
- package/lbl/index.js +31 -0
- package/methods/index.d.ts +20 -0
- package/methods/index.js +33 -0
- package/package.json +133 -0
- package/petrus/index.d.ts +11 -0
- package/petrus/index.js +27 -0
- package/react/index.d.ts +176 -0
- package/react/index.js +228 -0
- package/render/arrows.d.ts +68 -0
- package/render/arrows.js +190 -0
- package/render/build/attachments.d.ts +35 -0
- package/render/build/attachments.js +94 -0
- package/render/build/models.d.ts +9 -0
- package/render/build/models.js +27 -0
- package/render/build/tiles.d.ts +48 -0
- package/render/build/tiles.js +194 -0
- package/render/gltf.d.ts +10 -0
- package/render/gltf.js +106 -0
- package/render/index.d.ts +12 -0
- package/render/index.js +12 -0
- package/render/layers.d.ts +21 -0
- package/render/layers.js +24 -0
- package/render/pictures.d.ts +46 -0
- package/render/pictures.js +80 -0
- package/render/pieceModels.d.ts +38 -0
- package/render/pieceModels.js +43 -0
- package/render/renderer.d.ts +165 -0
- package/render/renderer.js +647 -0
- package/render/tile.d.ts +114 -0
- package/render/tile.js +390 -0
- package/render/viewports.d.ts +25 -0
- package/render/viewports.js +26 -0
- package/roux/cmll.d.ts +35 -0
- package/roux/cmll.js +121 -0
- package/roux/cmllData.d.ts +12 -0
- package/roux/cmllData.js +49 -0
- package/roux/index.d.ts +52 -0
- package/roux/index.js +175 -0
- package/skin/attachments.d.ts +82 -0
- package/skin/attachments.js +110 -0
- package/skin/index.d.ts +3 -0
- package/skin/index.js +3 -0
- package/skin/shapes.d.ts +112 -0
- package/skin/shapes.js +147 -0
- package/skin/skin.d.ts +911 -0
- package/skin/skin.js +412 -0
- package/solve/client.d.ts +58 -0
- package/solve/client.js +56 -0
- package/solve/handle.d.ts +6 -0
- package/solve/handle.js +38 -0
- package/solve/index.d.ts +8 -0
- package/solve/index.js +7 -0
- package/solve/lse.d.ts +42 -0
- package/solve/lse.js +151 -0
- package/solve/pieces.d.ts +18 -0
- package/solve/pieces.js +24 -0
- package/solve/protocol.d.ts +70 -0
- package/solve/protocol.js +16 -0
- package/solve/scramble.d.ts +76 -0
- package/solve/scramble.js +187 -0
- package/solve/stage.d.ts +108 -0
- package/solve/stage.js +532 -0
- package/solve/twophase.d.ts +36 -0
- package/solve/twophase.js +229 -0
- package/solve/worker.d.ts +6 -0
- package/solve/worker.js +15 -0
- package/timeline/codec.d.ts +23 -0
- package/timeline/codec.js +84 -0
- package/timeline/index.d.ts +7 -0
- package/timeline/index.js +7 -0
- package/timeline/recording.d.ts +19 -0
- package/timeline/recording.js +20 -0
- package/timeline/replay.d.ts +68 -0
- package/timeline/replay.js +145 -0
- package/timeline/segments.d.ts +31 -0
- package/timeline/segments.js +39 -0
- package/timeline/share.d.ts +30 -0
- package/timeline/share.js +62 -0
- package/timeline/stats.d.ts +34 -0
- package/timeline/stats.js +56 -0
- package/timeline/timings.d.ts +30 -0
- package/timeline/timings.js +54 -0
- package/zz/index.d.ts +29 -0
- package/zz/index.js +68 -0
- package/zz/seeds.d.ts +9 -0
- package/zz/seeds.js +29 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CubeRenderer — a three.js view of a cubecore State.
|
|
3
|
+
*
|
|
4
|
+
* - Headless-first: no UI of its own; you drive it (setState / animate /
|
|
5
|
+
* showPosition) and style/build controls however you like.
|
|
6
|
+
* - Renders on demand (nothing is drawn while nothing changes).
|
|
7
|
+
* - Animation: see layers.ts — colours of the state BEFORE the move, its layer
|
|
8
|
+
* rotated by progress × angle.
|
|
9
|
+
* - Gyroscope-ready: `setOrientation(quaternion)` rotates the whole cube
|
|
10
|
+
* (e.g. from a smart cube's gyro), independently of the camera.
|
|
11
|
+
*/
|
|
12
|
+
import { BackSide, Color, FrontSide, Group, HemisphereLight, NeutralToneMapping, DirectionalLight, Mesh, MeshBasicMaterial, Object3D, MeshStandardMaterial, MeshPhysicalMaterial, PMREMGenerator, PerspectiveCamera, Quaternion, Scene, Matrix4, SRGBColorSpace, Vector3, WebGLRenderer, } from "three";
|
|
13
|
+
import { CUBIES, FACELETS, FACE_BASIS, advanceSpins, applyMove, colorAt, maskStateAt, solvedSpins, solvedState, } from "../core/index.js";
|
|
14
|
+
import { SKINS, hintColor, stickerColor, themed } from "../skin/index.js";
|
|
15
|
+
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
|
16
|
+
import { layerTurn, defaultDuration } from "./layers.js";
|
|
17
|
+
import { buildAttachments, placeAttachments } from "./build/attachments.js";
|
|
18
|
+
import { tileKit } from "./build/tiles.js";
|
|
19
|
+
import { loadModel, readyModel } from "./build/models.js";
|
|
20
|
+
import { PLACEMENTS, stickerFaceOf } from "./pieceModels.js";
|
|
21
|
+
import { backPosition, viewports } from "./viewports.js";
|
|
22
|
+
import { ARROW_REACH, ARROW_SPEED, arrowCentre, buildArrows, disposeArrows, moveArrows } from "./arrows.js";
|
|
23
|
+
export class CubeRenderer {
|
|
24
|
+
container;
|
|
25
|
+
canvas;
|
|
26
|
+
renderer;
|
|
27
|
+
scene = new Scene();
|
|
28
|
+
camera;
|
|
29
|
+
/** Opposite the main camera — sees the three faces the main view hides. */
|
|
30
|
+
backCamera;
|
|
31
|
+
backView;
|
|
32
|
+
size = { w: 1, h: 1 };
|
|
33
|
+
root = new Group(); // gyroscope orientation applies here
|
|
34
|
+
cubieGroups = [];
|
|
35
|
+
explode = 0;
|
|
36
|
+
/** Meshes coloured as the sticker at each facelet position (a built-in tile, or a model's sticker-X meshes). */
|
|
37
|
+
paintTargets = [];
|
|
38
|
+
/** Tile frame at each facelet position (z = 0 on the cubie face): where decals and features attach. */
|
|
39
|
+
anchors = [];
|
|
40
|
+
/** Height of the sticker surface above the anchors. */
|
|
41
|
+
surface = null;
|
|
42
|
+
hintMeshes = [];
|
|
43
|
+
kit = null;
|
|
44
|
+
attachments = null;
|
|
45
|
+
/** Cubie group index holding each facelet position. */
|
|
46
|
+
cubieOfFacelet = [];
|
|
47
|
+
materials = new Map();
|
|
48
|
+
/** The skin as shown: the given one with its theme adjustments applied. */
|
|
49
|
+
skin;
|
|
50
|
+
baseSkin;
|
|
51
|
+
theme;
|
|
52
|
+
cam;
|
|
53
|
+
state = solvedState();
|
|
54
|
+
/** Centre spins, tracked through every move so a logo keeps its orientation. */
|
|
55
|
+
spins = solvedSpins();
|
|
56
|
+
mask = null;
|
|
57
|
+
partial = null;
|
|
58
|
+
frameRequested = false;
|
|
59
|
+
resizeObserver;
|
|
60
|
+
queue = [];
|
|
61
|
+
active = null;
|
|
62
|
+
quarterMs;
|
|
63
|
+
disposed = false;
|
|
64
|
+
/** Arrows were shown: the camera keeps room for them (so it doesn't jump each time they come and go). */
|
|
65
|
+
arrowRoom = false;
|
|
66
|
+
arrows = null;
|
|
67
|
+
targetOrientation = new Quaternion();
|
|
68
|
+
orientationSmoothing = 0;
|
|
69
|
+
constructor(container, options = {}) {
|
|
70
|
+
this.container = container;
|
|
71
|
+
this.baseSkin = options.skin ?? SKINS.default;
|
|
72
|
+
this.theme = options.theme ?? "dark";
|
|
73
|
+
this.skin = themed(this.baseSkin, this.theme);
|
|
74
|
+
this.quarterMs = options.quarterTurnMs ?? 120;
|
|
75
|
+
this.cam = { latitude: 30, longitude: 35, distance: "auto", fov: 34, ...options.camera };
|
|
76
|
+
this.renderer = new WebGLRenderer({ antialias: true, alpha: true });
|
|
77
|
+
this.renderer.outputColorSpace = SRGBColorSpace;
|
|
78
|
+
// Lit light colours (white, yellow) would clip to flat white under the lights; this rolls off
|
|
79
|
+
// the highlights instead, so their shading (bevels, domes) still shows. Flat materials opt out.
|
|
80
|
+
this.renderer.toneMapping = NeutralToneMapping;
|
|
81
|
+
this.renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
|
|
82
|
+
this.canvas = this.renderer.domElement;
|
|
83
|
+
this.canvas.style.display = "block";
|
|
84
|
+
this.canvas.style.width = "100%";
|
|
85
|
+
this.canvas.style.height = "100%";
|
|
86
|
+
this.canvas.style.touchAction = "none";
|
|
87
|
+
container.appendChild(this.canvas);
|
|
88
|
+
this.camera = new PerspectiveCamera(this.cam.fov, 1, 0.1, 100);
|
|
89
|
+
this.backCamera = new PerspectiveCamera(this.cam.fov, 1, 0.1, 100);
|
|
90
|
+
this.backView = options.backView ?? "none";
|
|
91
|
+
this.scene.add(new HemisphereLight(0xffffff, 0x444444, 2.2));
|
|
92
|
+
const sun = new DirectionalLight(0xffffff, 1.4);
|
|
93
|
+
sun.position.set(4, 8, 6);
|
|
94
|
+
this.scene.add(sun);
|
|
95
|
+
this.scene.add(this.root);
|
|
96
|
+
this.build();
|
|
97
|
+
this.applyCamera();
|
|
98
|
+
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
99
|
+
this.resizeObserver.observe(container);
|
|
100
|
+
this.resize();
|
|
101
|
+
if (options.dragToRotate !== false)
|
|
102
|
+
this.enableDrag();
|
|
103
|
+
}
|
|
104
|
+
// ─── public API ───
|
|
105
|
+
/**
|
|
106
|
+
* Show a state immediately (cancels running and queued animations). A
|
|
107
|
+
* permutation can't say how centres are turned — pass `spins` (see
|
|
108
|
+
* `spinsAfter`) when a logo's orientation matters; default: all upright.
|
|
109
|
+
*/
|
|
110
|
+
setState(state, spins = solvedSpins()) {
|
|
111
|
+
this.finishAll(false);
|
|
112
|
+
this.state = new Uint8Array(state);
|
|
113
|
+
this.spins = new Uint8Array(spins);
|
|
114
|
+
this.partial = null;
|
|
115
|
+
this.requestRender();
|
|
116
|
+
}
|
|
117
|
+
/** Show `state` with `move` part-way (0..1) — for scrubbing / time-accurate replay. */
|
|
118
|
+
showPartial(state, move, progress = 0, spins = solvedSpins()) {
|
|
119
|
+
this.finishAll(false);
|
|
120
|
+
this.state = new Uint8Array(state);
|
|
121
|
+
this.spins = new Uint8Array(spins);
|
|
122
|
+
this.partial = move && progress > 0 && progress < 1 ? { move, progress } : null;
|
|
123
|
+
this.requestRender();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Animate a move from the current state. When moves come faster than they
|
|
127
|
+
* animate (smart cubes do), the running animation snaps to its end and any
|
|
128
|
+
* backlog is applied instantly — only the newest move animates, so the
|
|
129
|
+
* picture never lags behind the real cube. Awaiting each call instead plays
|
|
130
|
+
* an algorithm move by move.
|
|
131
|
+
*/
|
|
132
|
+
animate(move, ms = defaultDuration(move, this.quarterMs)) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
this.queue.push({ move, ms, resolve });
|
|
135
|
+
// A newer move arrived: the running animation snaps to its end.
|
|
136
|
+
if (this.active)
|
|
137
|
+
this.completeActive();
|
|
138
|
+
this.requestRender();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
get currentSpins() {
|
|
142
|
+
return new Uint8Array(this.spins);
|
|
143
|
+
}
|
|
144
|
+
get currentState() {
|
|
145
|
+
return this.state;
|
|
146
|
+
}
|
|
147
|
+
setMask(mask) {
|
|
148
|
+
this.mask = mask;
|
|
149
|
+
this.requestRender();
|
|
150
|
+
}
|
|
151
|
+
setSkin(skin) {
|
|
152
|
+
this.baseSkin = skin;
|
|
153
|
+
this.skin = themed(skin, this.theme);
|
|
154
|
+
this.build();
|
|
155
|
+
this.applyCamera();
|
|
156
|
+
this.requestRender();
|
|
157
|
+
}
|
|
158
|
+
/** Pull the pieces apart to show their shapes: 0 = assembled, ~0.5 = well apart. */
|
|
159
|
+
setExplode(amount) {
|
|
160
|
+
this.explode = Math.max(0, amount);
|
|
161
|
+
this.requestRender();
|
|
162
|
+
}
|
|
163
|
+
setCamera(camera) {
|
|
164
|
+
this.cam = { ...this.cam, ...camera };
|
|
165
|
+
this.applyCamera();
|
|
166
|
+
this.requestRender();
|
|
167
|
+
}
|
|
168
|
+
/** Show the hidden faces in a second view: side by side, as a corner inset, or not at all. */
|
|
169
|
+
setBackView(mode) {
|
|
170
|
+
this.backView = mode;
|
|
171
|
+
this.applyCamera();
|
|
172
|
+
this.requestRender();
|
|
173
|
+
}
|
|
174
|
+
/** Light or dark page: applies the skin's theme adjustments (masked greys, back stickers, background). */
|
|
175
|
+
setTheme(theme) {
|
|
176
|
+
if (theme === this.theme)
|
|
177
|
+
return;
|
|
178
|
+
this.theme = theme;
|
|
179
|
+
this.setSkin(this.baseSkin);
|
|
180
|
+
}
|
|
181
|
+
get cameraOptions() {
|
|
182
|
+
return { ...this.cam };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Orientation of the whole cube, e.g. from a smart cube's gyroscope.
|
|
186
|
+
* `smoothing` 0..1 eases towards the target each frame (0 = snap).
|
|
187
|
+
* Pass null to reset.
|
|
188
|
+
*/
|
|
189
|
+
setOrientation(q, smoothing = 0) {
|
|
190
|
+
this.targetOrientation.set(q?.x ?? 0, q?.y ?? 0, q?.z ?? 0, q?.w ?? 1).normalize();
|
|
191
|
+
this.orientationSmoothing = Math.min(Math.max(smoothing, 0), 0.99);
|
|
192
|
+
if (this.orientationSmoothing === 0) {
|
|
193
|
+
this.root.quaternion.copy(this.targetOrientation);
|
|
194
|
+
this.placeArrows();
|
|
195
|
+
}
|
|
196
|
+
this.requestRender();
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Arrows for the next turn (core `turnArrow` / a tracker's `nextTurn`): one
|
|
200
|
+
* around each turning layer, on the side facing the camera — they follow
|
|
201
|
+
* the gyro and camera drags. Null to hide.
|
|
202
|
+
*/
|
|
203
|
+
setTurnArrows(arrows, style = {}) {
|
|
204
|
+
if (this.arrows?.group)
|
|
205
|
+
disposeArrows(this.arrows.group);
|
|
206
|
+
this.arrows = arrows?.length ? { arrows: [...arrows], style, group: null, key: "", since: performance.now() } : null;
|
|
207
|
+
if (this.arrows && !this.arrowRoom) {
|
|
208
|
+
this.arrowRoom = true;
|
|
209
|
+
this.applyCamera(); // places the arrows too
|
|
210
|
+
}
|
|
211
|
+
this.placeArrows();
|
|
212
|
+
this.requestRender();
|
|
213
|
+
}
|
|
214
|
+
/** The picture as it is now, drawn right away (e.g. a PNG data URL for an <img>). */
|
|
215
|
+
snapshot(type = "image/png") {
|
|
216
|
+
this.paint();
|
|
217
|
+
this.draw();
|
|
218
|
+
return this.canvas.toDataURL(type);
|
|
219
|
+
}
|
|
220
|
+
dispose() {
|
|
221
|
+
this.disposed = true;
|
|
222
|
+
if (this.arrows?.group)
|
|
223
|
+
disposeArrows(this.arrows.group);
|
|
224
|
+
this.resizeObserver.disconnect();
|
|
225
|
+
this.finishAll(true);
|
|
226
|
+
this.renderer.dispose();
|
|
227
|
+
for (const m of this.materials.values())
|
|
228
|
+
m.dispose();
|
|
229
|
+
this.envMap?.dispose();
|
|
230
|
+
this.kit?.dispose();
|
|
231
|
+
this.attachments?.dispose();
|
|
232
|
+
this.canvas.remove();
|
|
233
|
+
}
|
|
234
|
+
// ─── scene ───
|
|
235
|
+
material(color, hint, opacity = 1) {
|
|
236
|
+
// Tiles of a "plastic" skin are lit; hint stickers always stay flat (they're a see-through aid, not plastic).
|
|
237
|
+
const finish = this.skin.stickers.finish;
|
|
238
|
+
const lit = !hint && (this.skin.stickers.material === "plastic" || finish !== undefined);
|
|
239
|
+
const roughness = finish === "matte" ? 1 : finish === "uv" ? 0.4 : (this.skin.stickers.roughness ?? 0.4);
|
|
240
|
+
const key = `${color}|${hint}|${opacity}|${lit}|${roughness}|${finish ?? ""}`;
|
|
241
|
+
let m = this.materials.get(key);
|
|
242
|
+
if (!m) {
|
|
243
|
+
if (lit && finish === "uv") {
|
|
244
|
+
// UV coat: a clear glossy layer over the colour, reflecting the room — sharp highlights that move as you turn.
|
|
245
|
+
m = new MeshPhysicalMaterial({
|
|
246
|
+
color: new Color(color),
|
|
247
|
+
emissive: new Color(color),
|
|
248
|
+
emissiveIntensity: 0.3,
|
|
249
|
+
roughness,
|
|
250
|
+
metalness: 0,
|
|
251
|
+
clearcoat: 1,
|
|
252
|
+
clearcoatRoughness: 0.04,
|
|
253
|
+
envMap: this.reflections(),
|
|
254
|
+
envMapIntensity: 0.18,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else if (lit) {
|
|
258
|
+
// A little of the tile's own colour as emission keeps shaded faces from going muddy; the highlight stays.
|
|
259
|
+
m = new MeshStandardMaterial({
|
|
260
|
+
color: new Color(color),
|
|
261
|
+
emissive: new Color(color),
|
|
262
|
+
// …less on light colours (they don't go muddy), so their shading stays visible.
|
|
263
|
+
emissiveIntensity: (finish === "matte" ? 0.45 : 0.32) * (1 - 0.6 * lightness(color)),
|
|
264
|
+
roughness,
|
|
265
|
+
metalness: 0,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
m = new MeshBasicMaterial({
|
|
270
|
+
color: new Color(color),
|
|
271
|
+
side: hint ? BackSide : FrontSide,
|
|
272
|
+
transparent: hint,
|
|
273
|
+
opacity: hint ? opacity : 1,
|
|
274
|
+
depthWrite: !hint,
|
|
275
|
+
toneMapped: false,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
this.materials.set(key, m);
|
|
279
|
+
}
|
|
280
|
+
return m;
|
|
281
|
+
}
|
|
282
|
+
envMap = null;
|
|
283
|
+
/** A soft room to reflect (UV-coated stickers), built once. */
|
|
284
|
+
reflections() {
|
|
285
|
+
if (!this.envMap) {
|
|
286
|
+
const pmrem = new PMREMGenerator(this.renderer);
|
|
287
|
+
this.envMap = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
288
|
+
pmrem.dispose();
|
|
289
|
+
}
|
|
290
|
+
return this.envMap;
|
|
291
|
+
}
|
|
292
|
+
build() {
|
|
293
|
+
this.root.clear();
|
|
294
|
+
this.kit?.dispose();
|
|
295
|
+
this.attachments?.dispose();
|
|
296
|
+
this.cubieGroups = [];
|
|
297
|
+
this.paintTargets = Array.from({ length: 54 }, () => []);
|
|
298
|
+
this.anchors = [];
|
|
299
|
+
this.hintMeshes = [];
|
|
300
|
+
const s = this.skin;
|
|
301
|
+
const kit = tileKit(s);
|
|
302
|
+
this.kit = kit;
|
|
303
|
+
const bodyMat = new MeshStandardMaterial({ color: new Color(s.body), roughness: 0.85, metalness: 0 });
|
|
304
|
+
// The mechanism sits still in the middle; layers turn around it.
|
|
305
|
+
if (kit.mechanism)
|
|
306
|
+
this.root.add(new Mesh(kit.mechanism, bodyMat));
|
|
307
|
+
const models = s.models;
|
|
308
|
+
let waiting = false;
|
|
309
|
+
this.surface = models?.surface ?? (models ? kit.thickness : null); // built-in tiles: each at its own thickness
|
|
310
|
+
CUBIES.forEach((cubie, ci) => {
|
|
311
|
+
const g = new Group();
|
|
312
|
+
const place = PLACEMENTS[ci];
|
|
313
|
+
const url = models?.[place.kind];
|
|
314
|
+
const model = readyModel(url);
|
|
315
|
+
if (url && !model)
|
|
316
|
+
waiting = true;
|
|
317
|
+
if (model) {
|
|
318
|
+
// A model for this kind: rotate it onto the cubie, colour its sticker-X meshes.
|
|
319
|
+
const clone = model.clone(true);
|
|
320
|
+
const r = place.rotation;
|
|
321
|
+
clone.matrixAutoUpdate = false;
|
|
322
|
+
clone.matrix.set(r[0][0], r[0][1], r[0][2], cubie.pos[0], r[1][0], r[1][1], r[1][2], cubie.pos[1], r[2][0], r[2][1], r[2][2], cubie.pos[2], 0, 0, 0, 1);
|
|
323
|
+
if (models?.scale && models.scale !== 1)
|
|
324
|
+
clone.matrix.multiply(new Matrix4().makeScale(models.scale, models.scale, models.scale));
|
|
325
|
+
clone.traverse((o) => {
|
|
326
|
+
const m = o;
|
|
327
|
+
if (!m.isMesh)
|
|
328
|
+
return;
|
|
329
|
+
const face = stickerFaceOf((Array.isArray(m.material) ? m.material[0] : m.material)?.name);
|
|
330
|
+
const fi = face ? place.stickers[face] : undefined;
|
|
331
|
+
if (fi !== undefined)
|
|
332
|
+
this.paintTargets[fi].push(m);
|
|
333
|
+
});
|
|
334
|
+
g.add(clone);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const body = new Mesh(cubie.facelets.length === 1 ? kit.centerBody : kit.body, bodyMat);
|
|
338
|
+
body.position.set(...cubie.pos);
|
|
339
|
+
g.add(body);
|
|
340
|
+
}
|
|
341
|
+
for (const fi of cubie.facelets) {
|
|
342
|
+
const f = FACELETS[fi];
|
|
343
|
+
const { a, b, n } = FACE_BASIS[f.face];
|
|
344
|
+
const basis = new Matrix4().makeBasis(new Vector3(...a), new Vector3(...b), new Vector3(...n));
|
|
345
|
+
const normal = new Vector3(...n);
|
|
346
|
+
if (model) {
|
|
347
|
+
// Nothing to draw here: an invisible frame for decals / features.
|
|
348
|
+
const anchor = new Object3D();
|
|
349
|
+
anchor.position.set(...f.pos).addScaledVector(normal, kit.faceOffset);
|
|
350
|
+
anchor.quaternion.setFromRotationMatrix(basis);
|
|
351
|
+
g.add(anchor);
|
|
352
|
+
this.anchors[fi] = anchor;
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
// With stickers on top, the tile is the body's plastic and the sticker takes the colour.
|
|
356
|
+
const sticker = kit.overlay(fi);
|
|
357
|
+
const mesh = new Mesh(kit.tile(fi), sticker ? this.material(s.body, false) : this.material("#000000", false));
|
|
358
|
+
mesh.position.set(...f.pos).addScaledVector(normal, kit.faceOffset);
|
|
359
|
+
mesh.quaternion.setFromRotationMatrix(basis);
|
|
360
|
+
g.add(mesh);
|
|
361
|
+
if (sticker) {
|
|
362
|
+
// A pedestal is plastic with a flat print on top; otherwise the whole sticker is coloured.
|
|
363
|
+
const cap = kit.overlayCap(fi);
|
|
364
|
+
const sm = new Mesh(sticker, cap ? this.material(s.body, false) : this.material("#000000", false));
|
|
365
|
+
sm.position.copy(mesh.position);
|
|
366
|
+
sm.quaternion.copy(mesh.quaternion);
|
|
367
|
+
g.add(sm);
|
|
368
|
+
const painted = cap ? new Mesh(cap, this.material("#000000", false)) : sm;
|
|
369
|
+
if (cap) {
|
|
370
|
+
painted.position.copy(mesh.position);
|
|
371
|
+
painted.quaternion.copy(mesh.quaternion);
|
|
372
|
+
g.add(painted);
|
|
373
|
+
}
|
|
374
|
+
this.paintTargets[fi].push(painted);
|
|
375
|
+
}
|
|
376
|
+
const skirt = kit.skirt(fi);
|
|
377
|
+
if (skirt) {
|
|
378
|
+
const m = new Mesh(skirt, bodyMat);
|
|
379
|
+
m.position.copy(mesh.position);
|
|
380
|
+
m.quaternion.copy(mesh.quaternion);
|
|
381
|
+
g.add(m);
|
|
382
|
+
// Coloured plastic: the piece under the tile is painted like the tile (and follows it through the state).
|
|
383
|
+
if (s.pieces?.colored)
|
|
384
|
+
this.paintTargets[fi].push(m);
|
|
385
|
+
}
|
|
386
|
+
if (!sticker)
|
|
387
|
+
this.paintTargets[fi].push(mesh);
|
|
388
|
+
this.anchors[fi] = mesh;
|
|
389
|
+
}
|
|
390
|
+
this.cubieOfFacelet[fi] = ci;
|
|
391
|
+
if (s.hints.enabled) {
|
|
392
|
+
const hint = new Mesh(kit.hint(fi), this.material("#000000", true));
|
|
393
|
+
hint.position.set(...f.pos).addScaledVector(normal, 0.5 + s.hints.distance);
|
|
394
|
+
hint.quaternion.setFromRotationMatrix(basis);
|
|
395
|
+
g.add(hint);
|
|
396
|
+
this.hintMeshes[fi] = hint;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
this.root.add(g);
|
|
400
|
+
this.cubieGroups.push(g);
|
|
401
|
+
});
|
|
402
|
+
this.attachments = buildAttachments(s, kit, () => this.requestRender());
|
|
403
|
+
// Models still loading: rebuild with them once they're here (if this skin is still the one shown).
|
|
404
|
+
if (waiting && models) {
|
|
405
|
+
const urls = [models.corner, models.edge, models.center].filter((u) => !!u);
|
|
406
|
+
Promise.all(urls.map(loadModel)).then(() => {
|
|
407
|
+
if (this.skin === s && !this.disposed) {
|
|
408
|
+
this.build();
|
|
409
|
+
this.requestRender();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
this.renderer.setClearColor(s.background ? new Color(s.background) : new Color(0x000000), s.background ? 1 : 0);
|
|
414
|
+
}
|
|
415
|
+
paint() {
|
|
416
|
+
for (let i = 0; i < this.paintTargets.length; i++) {
|
|
417
|
+
const st = this.mask ? maskStateAt(this.mask, this.state, i) : "regular";
|
|
418
|
+
const color = stickerColor(this.skin, colorAt(this.state, i), st);
|
|
419
|
+
for (const mesh of this.paintTargets[i]) {
|
|
420
|
+
mesh.visible = color !== null;
|
|
421
|
+
if (color)
|
|
422
|
+
mesh.material = this.material(color, false);
|
|
423
|
+
}
|
|
424
|
+
if (this.anchors[i])
|
|
425
|
+
this.anchors[i].visible = color !== null;
|
|
426
|
+
const hint = this.hintMeshes[i];
|
|
427
|
+
if (hint) {
|
|
428
|
+
hint.visible = color !== null;
|
|
429
|
+
const hc = hintColor(this.skin, colorAt(this.state, i), st);
|
|
430
|
+
if (hc)
|
|
431
|
+
hint.material = this.material(hc, true, st === "regular" || st === "dim" ? this.skin.hints.opacity : this.skin.hints.ignoredOpacity);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (this.attachments)
|
|
435
|
+
placeAttachments(this.attachments, this.state, this.spins, this.anchors, this.surface, this.mask);
|
|
436
|
+
const turn = this.partial ? layerTurn(this.partial.move) : null;
|
|
437
|
+
const axis = turn ? new Vector3(turn.axis === 0 ? 1 : 0, turn.axis === 1 ? 1 : 0, turn.axis === 2 ? 1 : 0) : null;
|
|
438
|
+
CUBIES.forEach((c, i) => {
|
|
439
|
+
const g = this.cubieGroups[i];
|
|
440
|
+
if (turn && axis && turn.turns(c.pos))
|
|
441
|
+
g.quaternion.setFromAxisAngle(axis, turn.angle * this.partial.progress);
|
|
442
|
+
else
|
|
443
|
+
g.quaternion.identity();
|
|
444
|
+
// Exploded view: each piece pushed out along its place's direction (turning with its layer).
|
|
445
|
+
g.position.set(c.pos[0], c.pos[1], c.pos[2]).multiplyScalar(this.explode).applyQuaternion(g.quaternion);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/** (Re)build the arrows when the side facing the camera changed. */
|
|
449
|
+
placeArrows() {
|
|
450
|
+
const a = this.arrows;
|
|
451
|
+
if (!a)
|
|
452
|
+
return;
|
|
453
|
+
const toCube = this.root.quaternion.clone().invert();
|
|
454
|
+
this.camera.updateMatrixWorld();
|
|
455
|
+
const eye = this.camera.position.clone().applyQuaternion(toCube);
|
|
456
|
+
const centres = a.arrows.map((arrow) => arrowCentre(arrow, eye));
|
|
457
|
+
const key = centres.join(",");
|
|
458
|
+
if (a.group && key === a.key && a.group.parent === this.root)
|
|
459
|
+
return;
|
|
460
|
+
if (a.group)
|
|
461
|
+
disposeArrows(a.group);
|
|
462
|
+
a.group = buildArrows(a.arrows, centres, a.style);
|
|
463
|
+
a.key = key;
|
|
464
|
+
this.root.add(a.group);
|
|
465
|
+
}
|
|
466
|
+
applyCamera() {
|
|
467
|
+
const lat = (this.cam.latitude * Math.PI) / 180;
|
|
468
|
+
const lon = (this.cam.longitude * Math.PI) / 180;
|
|
469
|
+
const dir = [Math.cos(lat) * Math.sin(lon), Math.sin(lat), Math.cos(lat) * Math.cos(lon)];
|
|
470
|
+
const { main, back } = viewports(this.backView, this.size.w, this.size.h);
|
|
471
|
+
this.camera.aspect = main.w / main.h;
|
|
472
|
+
// With a corner inset, pull the main cube back a little so the inset covers less of it.
|
|
473
|
+
const inset = this.backView === "top-right" ? 1.18 : 1;
|
|
474
|
+
const d = this.cam.distance === "auto" ? this.fitDistance(this.camera.aspect, this.skin.hints.enabled) * inset : this.cam.distance;
|
|
475
|
+
this.camera.fov = this.cam.fov;
|
|
476
|
+
this.camera.position.set(dir[0] * d, dir[1] * d, dir[2] * d);
|
|
477
|
+
this.camera.lookAt(0, 0, 0);
|
|
478
|
+
this.camera.updateProjectionMatrix();
|
|
479
|
+
if (back) {
|
|
480
|
+
// Back stickers are hidden in the back view (they'd float between it and the cube), so it fits the bare cube.
|
|
481
|
+
this.backCamera.aspect = back.w / back.h;
|
|
482
|
+
this.backCamera.fov = this.cam.fov;
|
|
483
|
+
const bd = this.cam.distance === "auto" ? this.fitDistance(this.backCamera.aspect, false) : this.cam.distance;
|
|
484
|
+
this.backCamera.position.set(...backPosition([dir[0] * bd, dir[1] * bd, dir[2] * bd]));
|
|
485
|
+
this.backCamera.lookAt(0, 0, 0);
|
|
486
|
+
this.backCamera.updateProjectionMatrix();
|
|
487
|
+
}
|
|
488
|
+
this.placeArrows();
|
|
489
|
+
}
|
|
490
|
+
/** Distance at which a sphere around the cube (and its back stickers) fits the narrower field of view, with a margin. */
|
|
491
|
+
fitDistance(aspect, withHints) {
|
|
492
|
+
const reach = withHints ? 1 + 0.5 + this.skin.hints.distance + 0.45 : 0;
|
|
493
|
+
const radius = Math.max(1.5 * Math.sqrt(3), reach, this.arrowRoom ? ARROW_REACH : 0) * 1.06;
|
|
494
|
+
const vfov = (this.cam.fov * Math.PI) / 180;
|
|
495
|
+
const hfov = 2 * Math.atan(Math.tan(vfov / 2) * aspect);
|
|
496
|
+
return radius / Math.sin(Math.min(vfov, hfov) / 2);
|
|
497
|
+
}
|
|
498
|
+
resize() {
|
|
499
|
+
const w = this.container.clientWidth || 1;
|
|
500
|
+
const h = this.container.clientHeight || 1;
|
|
501
|
+
this.renderer.setSize(w, h, false);
|
|
502
|
+
this.size = { w, h };
|
|
503
|
+
this.applyCamera();
|
|
504
|
+
this.requestRender();
|
|
505
|
+
}
|
|
506
|
+
// ─── animation loop (on demand) ───
|
|
507
|
+
requestRender() {
|
|
508
|
+
if (this.frameRequested || this.disposed)
|
|
509
|
+
return;
|
|
510
|
+
this.frameRequested = true;
|
|
511
|
+
requestAnimationFrame((now) => this.frame(now));
|
|
512
|
+
}
|
|
513
|
+
frame(now) {
|
|
514
|
+
this.frameRequested = false;
|
|
515
|
+
if (this.disposed)
|
|
516
|
+
return;
|
|
517
|
+
let again = false;
|
|
518
|
+
// Backlog (e.g. several moves arrived within one frame): apply all but the newest instantly
|
|
519
|
+
// and animate only that one, so the picture never trails the real cube.
|
|
520
|
+
while (!this.active && this.queue.length > 1) {
|
|
521
|
+
const skipped = this.queue.shift();
|
|
522
|
+
this.step(skipped.move);
|
|
523
|
+
skipped.resolve();
|
|
524
|
+
}
|
|
525
|
+
if (!this.active && this.queue.length) {
|
|
526
|
+
const next = this.queue.shift();
|
|
527
|
+
this.active = { ...next, start: now };
|
|
528
|
+
}
|
|
529
|
+
if (this.active) {
|
|
530
|
+
const p = this.active.ms > 0 ? (now - this.active.start) / this.active.ms : 1;
|
|
531
|
+
if (p >= 1) {
|
|
532
|
+
this.completeActive();
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
this.partial = { move: this.active.move, progress: easeInOut(p) };
|
|
536
|
+
}
|
|
537
|
+
again = this.active !== null || this.queue.length > 0;
|
|
538
|
+
}
|
|
539
|
+
if (this.orientationSmoothing > 0 && !this.root.quaternion.equals(this.targetOrientation)) {
|
|
540
|
+
this.root.quaternion.slerp(this.targetOrientation, 1 - this.orientationSmoothing);
|
|
541
|
+
if (this.root.quaternion.angleTo(this.targetOrientation) < 1e-4)
|
|
542
|
+
this.root.quaternion.copy(this.targetOrientation);
|
|
543
|
+
else
|
|
544
|
+
again = true;
|
|
545
|
+
this.placeArrows();
|
|
546
|
+
}
|
|
547
|
+
// Travelling arrows: slide them on every frame while they're shown.
|
|
548
|
+
const a = this.arrows;
|
|
549
|
+
const speed = a?.style.speed ?? ARROW_SPEED;
|
|
550
|
+
if (a?.group && speed > 0) {
|
|
551
|
+
moveArrows(a.group, ((now - a.since) / 1000) * speed);
|
|
552
|
+
again = true;
|
|
553
|
+
}
|
|
554
|
+
this.paint();
|
|
555
|
+
this.draw();
|
|
556
|
+
if (again)
|
|
557
|
+
this.requestRender();
|
|
558
|
+
}
|
|
559
|
+
step(move) {
|
|
560
|
+
this.spins = advanceSpins(this.spins, this.state, move);
|
|
561
|
+
this.state = applyMove(this.state, move);
|
|
562
|
+
}
|
|
563
|
+
draw() {
|
|
564
|
+
const { main, back } = viewports(this.backView, this.size.w, this.size.h);
|
|
565
|
+
if (!back) {
|
|
566
|
+
this.renderer.setScissorTest(false);
|
|
567
|
+
this.renderer.setViewport(0, 0, this.size.w, this.size.h);
|
|
568
|
+
this.renderer.render(this.scene, this.camera);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
this.renderer.setScissorTest(true);
|
|
572
|
+
this.renderer.setViewport(main.x, main.y, main.w, main.h);
|
|
573
|
+
this.renderer.setScissor(main.x, main.y, main.w, main.h);
|
|
574
|
+
this.renderer.render(this.scene, this.camera);
|
|
575
|
+
const shown = this.hintMeshes.map((m) => m?.visible ?? false);
|
|
576
|
+
this.hintMeshes.forEach((m) => m && (m.visible = false));
|
|
577
|
+
this.renderer.setViewport(back.x, back.y, back.w, back.h);
|
|
578
|
+
this.renderer.setScissor(back.x, back.y, back.w, back.h);
|
|
579
|
+
// An inset draws over the main view: clear only depth, so it doesn't punch a hole in the main cube.
|
|
580
|
+
const inset = this.backView === "top-right";
|
|
581
|
+
if (inset) {
|
|
582
|
+
this.renderer.autoClear = false;
|
|
583
|
+
this.renderer.clearDepth();
|
|
584
|
+
}
|
|
585
|
+
this.renderer.render(this.scene, this.backCamera);
|
|
586
|
+
this.renderer.autoClear = true;
|
|
587
|
+
this.hintMeshes.forEach((m, i) => m && (m.visible = shown[i]));
|
|
588
|
+
}
|
|
589
|
+
completeActive() {
|
|
590
|
+
if (!this.active)
|
|
591
|
+
return;
|
|
592
|
+
this.step(this.active.move);
|
|
593
|
+
this.partial = null;
|
|
594
|
+
const done = this.active.resolve;
|
|
595
|
+
this.active = null;
|
|
596
|
+
done();
|
|
597
|
+
}
|
|
598
|
+
finishAll(apply) {
|
|
599
|
+
const pending = [...(this.active ? [this.active] : []), ...this.queue];
|
|
600
|
+
this.active = null;
|
|
601
|
+
this.queue = [];
|
|
602
|
+
for (const p of pending) {
|
|
603
|
+
if (apply)
|
|
604
|
+
this.step(p.move);
|
|
605
|
+
p.resolve();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// ─── camera drag ───
|
|
609
|
+
enableDrag() {
|
|
610
|
+
let last = null;
|
|
611
|
+
this.canvas.addEventListener("pointerdown", (e) => {
|
|
612
|
+
last = { x: e.clientX, y: e.clientY };
|
|
613
|
+
this.canvas.setPointerCapture(e.pointerId);
|
|
614
|
+
});
|
|
615
|
+
this.canvas.addEventListener("pointermove", (e) => {
|
|
616
|
+
if (!last)
|
|
617
|
+
return;
|
|
618
|
+
const dx = e.clientX - last.x, dy = e.clientY - last.y;
|
|
619
|
+
last = { x: e.clientX, y: e.clientY };
|
|
620
|
+
this.setCamera({ longitude: this.cam.longitude - dx * 0.5, latitude: Math.max(-85, Math.min(85, this.cam.latitude + dy * 0.5)) });
|
|
621
|
+
});
|
|
622
|
+
const end = () => (last = null);
|
|
623
|
+
this.canvas.addEventListener("pointerup", end);
|
|
624
|
+
this.canvas.addEventListener("pointercancel", end);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
|
628
|
+
/**
|
|
629
|
+
* Show a replay position: `start` plus the first `applied` moves, with move
|
|
630
|
+
* `active.index` part-way — plug in `positionAt` / `ReplayClock` from
|
|
631
|
+
* @cubecore/timeline without this package depending on it.
|
|
632
|
+
*/
|
|
633
|
+
export function showPosition(renderer, start, moves, position, startSpins = solvedSpins()) {
|
|
634
|
+
let s = start;
|
|
635
|
+
let spins = startSpins;
|
|
636
|
+
for (let i = 0; i < position.applied; i++) {
|
|
637
|
+
spins = advanceSpins(spins, s, moves[i]);
|
|
638
|
+
s = applyMove(s, moves[i]);
|
|
639
|
+
}
|
|
640
|
+
const active = position.active ? moves[position.active.index] : null;
|
|
641
|
+
renderer.showPartial(s, active, position.active?.progress ?? 0, spins);
|
|
642
|
+
}
|
|
643
|
+
/** Relative luminance of a CSS colour, 0 (black) … 1 (white). */
|
|
644
|
+
function lightness(color) {
|
|
645
|
+
const c = new Color(color);
|
|
646
|
+
return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
|
|
647
|
+
}
|