@aethervtt/shard-controls 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/components.d.ts +59 -0
- package/dist/components.js +110 -0
- package/dist/controls.d.ts +42 -0
- package/dist/controls.js +444 -0
- package/dist/drag.d.ts +91 -0
- package/dist/drag.js +230 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +56 -0
- package/dist/plugin.d.ts +85 -0
- package/dist/plugin.js +72 -0
- package/dist/sync.d.ts +10 -0
- package/dist/sync.js +46 -0
- package/dist/view.d.ts +39 -0
- package/dist/view.js +132 -0
- package/package.json +36 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aethervtt/shard-controls
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Infer } from '@aethervtt/shard-core';
|
|
2
|
+
/**
|
|
3
|
+
* Orbit, pan and dolly around a target, on a perspective camera (0060). The fields are where the
|
|
4
|
+
* camera is going: input changes them, a host may write them (to restore a saved view), and with
|
|
5
|
+
* `smoothing` the camera eases toward them.
|
|
6
|
+
*/
|
|
7
|
+
export declare const OrbitControls: import("@aethervtt/shard-core").ComponentDef<{
|
|
8
|
+
readonly target: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec3, "f32">;
|
|
9
|
+
readonly distance: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
10
|
+
readonly yaw: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
11
|
+
readonly pitch: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
12
|
+
readonly minPitch: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
13
|
+
readonly maxPitch: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
14
|
+
readonly minDistance: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
15
|
+
readonly maxDistance: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
16
|
+
readonly orbitButton: import("@aethervtt/shard-core").FieldType<"left" | "middle" | "right", "u8">;
|
|
17
|
+
readonly panButton: import("@aethervtt/shard-core").FieldType<"left" | "middle" | "right", "u8">;
|
|
18
|
+
readonly leftDrag: import("@aethervtt/shard-core").FieldType<"none" | "orbit" | "pan", "u8">;
|
|
19
|
+
readonly rotateSpeed: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
20
|
+
readonly zoomSpeed: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
21
|
+
readonly autoRotate: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
22
|
+
readonly smoothing: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
23
|
+
readonly enabled: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
|
|
24
|
+
}>;
|
|
25
|
+
export type OrbitControlsValue = Infer<typeof OrbitControls>;
|
|
26
|
+
/**
|
|
27
|
+
* Pan and zoom-to-cursor on an orthographic camera over a floor (0060): the Map view, looking
|
|
28
|
+
* down (or tilted by `pitch`), or a 2D world on the xy plane. The fields are where the camera is
|
|
29
|
+
* going, as for `OrbitControls`.
|
|
30
|
+
*/
|
|
31
|
+
export declare const MapControls: import("@aethervtt/shard-core").ComponentDef<{
|
|
32
|
+
readonly target: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec3, "f32">;
|
|
33
|
+
readonly zoom: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
34
|
+
readonly minZoom: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
35
|
+
readonly maxZoom: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
36
|
+
readonly height: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
37
|
+
readonly elevation: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
38
|
+
readonly pitch: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
39
|
+
readonly plane: import("@aethervtt/shard-core").FieldType<"xy" | "xz", "u8">;
|
|
40
|
+
readonly bounded: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
|
|
41
|
+
readonly boundsMin: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec2, "f32">;
|
|
42
|
+
readonly boundsMax: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec2, "f32">;
|
|
43
|
+
readonly panButton: import("@aethervtt/shard-core").FieldType<"left" | "middle" | "right", "u8">;
|
|
44
|
+
readonly leftDrag: import("@aethervtt/shard-core").FieldType<"none" | "pan", "u8">;
|
|
45
|
+
readonly zoomSpeed: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
46
|
+
readonly smoothing: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
47
|
+
readonly enabled: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
|
|
48
|
+
}>;
|
|
49
|
+
export type MapControlsValue = Infer<typeof MapControls>;
|
|
50
|
+
export interface ControlsSettingsValue {
|
|
51
|
+
/** Snap instead of easing, whatever a control's `smoothing` (the host's reduced-motion setting). */
|
|
52
|
+
reducedMotion: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The view size in CSS pixels for cameras that haven't rendered yet (headless runs): rendered
|
|
55
|
+
* cameras use their target's size.
|
|
56
|
+
*/
|
|
57
|
+
viewport: [number, number];
|
|
58
|
+
}
|
|
59
|
+
export declare const ControlsSettings: import("@aethervtt/shard-core").ResourceDef<ControlsSettingsValue>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { defineComponent, defineResource, t } from "@aethervtt/shard-core";
|
|
2
|
+
import { Camera3d } from "@aethervtt/shard-render";
|
|
3
|
+
const BUTTONS = ["left", "middle", "right"];
|
|
4
|
+
const OrbitControls = defineComponent(
|
|
5
|
+
"controls/OrbitControls",
|
|
6
|
+
{
|
|
7
|
+
target: t.vec3({ unit: "m", description: "The point the camera orbits and looks at." }),
|
|
8
|
+
distance: t.f32({ default: 10, min: 1e-4, unit: "m", description: "From the target." }),
|
|
9
|
+
yaw: t.f32({
|
|
10
|
+
unit: "deg",
|
|
11
|
+
description: "Turn about +Y: 0 looks from +Z toward -Z, 90 from +X."
|
|
12
|
+
}),
|
|
13
|
+
pitch: t.f32({
|
|
14
|
+
default: 45,
|
|
15
|
+
unit: "deg",
|
|
16
|
+
description: "Height above the horizon: 90 looks straight down."
|
|
17
|
+
}),
|
|
18
|
+
minPitch: t.f32({ default: 15, min: -89, max: 89, unit: "deg" }),
|
|
19
|
+
maxPitch: t.f32({ default: 89, min: -89, max: 89, unit: "deg" }),
|
|
20
|
+
minDistance: t.f32({ default: 1, min: 1e-4, unit: "m" }),
|
|
21
|
+
maxDistance: t.f32({ default: 100, min: 1e-4, unit: "m" }),
|
|
22
|
+
orbitButton: t.enum(BUTTONS, {
|
|
23
|
+
default: "right",
|
|
24
|
+
description: "Drags with it orbit; with Shift held they pan (trackpads have no middle)."
|
|
25
|
+
}),
|
|
26
|
+
panButton: t.enum(BUTTONS, { default: "middle", description: "Drags with it pan." }),
|
|
27
|
+
leftDrag: t.enum(["orbit", "pan", "none"], {
|
|
28
|
+
default: "orbit",
|
|
29
|
+
description: "What a left or one-finger drag nothing else claimed does: the host's drags (tokens) come first."
|
|
30
|
+
}),
|
|
31
|
+
rotateSpeed: t.f32({ default: 0.3, min: 0, unit: "deg", description: "Orbit per CSS pixel." }),
|
|
32
|
+
zoomSpeed: t.f32({ default: 1, min: 0, description: "Wheel zoom rate multiplier." }),
|
|
33
|
+
autoRotate: t.f32({
|
|
34
|
+
unit: "deg",
|
|
35
|
+
description: "Yaw per second while nothing drags the camera (a turntable). 0: still."
|
|
36
|
+
}),
|
|
37
|
+
smoothing: t.f32({
|
|
38
|
+
min: 0,
|
|
39
|
+
unit: "s",
|
|
40
|
+
description: "Time constant the camera eases toward the fields with. 0 (and reduced motion): at once."
|
|
41
|
+
}),
|
|
42
|
+
enabled: t.bool({ default: true, description: "Takes input. The camera must be active too." })
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
description: "Orbit (right drag), pan (middle drag, Shift+right), dolly to the cursor (wheel), and pinch, two-finger pan and twist on touch, on a perspective camera.",
|
|
46
|
+
requires: [Camera3d]
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
const MapControls = defineComponent(
|
|
50
|
+
"controls/MapControls",
|
|
51
|
+
{
|
|
52
|
+
target: t.vec3({ unit: "m", description: "The floor point at the center of the view." }),
|
|
53
|
+
zoom: t.f32({ default: 1, min: 1e-4, description: "The view is `height / zoom` tall." }),
|
|
54
|
+
minZoom: t.f32({ default: 0.1, min: 1e-4 }),
|
|
55
|
+
maxZoom: t.f32({ default: 8, min: 1e-4 }),
|
|
56
|
+
height: t.f32({
|
|
57
|
+
default: 20,
|
|
58
|
+
min: 1e-4,
|
|
59
|
+
unit: "m",
|
|
60
|
+
description: "The view's height at zoom 1 (Camera3d.orthoHeight = height / zoom)."
|
|
61
|
+
}),
|
|
62
|
+
elevation: t.f32({
|
|
63
|
+
default: 50,
|
|
64
|
+
min: 1e-4,
|
|
65
|
+
unit: "m",
|
|
66
|
+
description: "How far from the target the camera sits, along its view: keep it inside near..far."
|
|
67
|
+
}),
|
|
68
|
+
pitch: t.f32({
|
|
69
|
+
default: 90,
|
|
70
|
+
min: 1,
|
|
71
|
+
max: 90,
|
|
72
|
+
unit: "deg",
|
|
73
|
+
description: "Tilt on the xz floor: 90 looks straight down (screen-up -Z); less looks from the south, so wall faces show."
|
|
74
|
+
}),
|
|
75
|
+
plane: t.enum(["xz", "xy"], {
|
|
76
|
+
default: "xz",
|
|
77
|
+
description: "The ground it pans over: the xz floor (3D), or the xy plane of a 2D world, seen along -Z."
|
|
78
|
+
}),
|
|
79
|
+
bounded: t.bool({
|
|
80
|
+
description: "Keep the target within boundsMin..boundsMax (x, z; x, y on the xy plane)."
|
|
81
|
+
}),
|
|
82
|
+
boundsMin: t.vec2({ unit: "m" }),
|
|
83
|
+
boundsMax: t.vec2({ unit: "m" }),
|
|
84
|
+
panButton: t.enum(BUTTONS, { default: "middle", description: "Drags with it pan." }),
|
|
85
|
+
leftDrag: t.enum(["pan", "none"], {
|
|
86
|
+
default: "pan",
|
|
87
|
+
description: "What a left or one-finger drag nothing else claimed does: the host's drags (tokens) come first."
|
|
88
|
+
}),
|
|
89
|
+
zoomSpeed: t.f32({ default: 1, min: 0, description: "Wheel zoom rate multiplier." }),
|
|
90
|
+
smoothing: t.f32({
|
|
91
|
+
min: 0,
|
|
92
|
+
unit: "s",
|
|
93
|
+
description: "Time constant the camera eases toward the fields with. 0 (and reduced motion): at once."
|
|
94
|
+
}),
|
|
95
|
+
enabled: t.bool({ default: true, description: "Takes input. The camera must be active too." })
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
description: "Pan (middle drag, or left drag on empty floor) and zoom to the cursor (wheel, pinch) on an orthographic camera over a floor, or a 2D world.",
|
|
99
|
+
requires: [Camera3d]
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
const ControlsSettings = defineResource("controls/Settings", {
|
|
103
|
+
description: "Camera controls settings: reduced motion, and the headless view size.",
|
|
104
|
+
init: () => ({ reducedMotion: false, viewport: [1280, 720] })
|
|
105
|
+
});
|
|
106
|
+
export {
|
|
107
|
+
ControlsSettings,
|
|
108
|
+
MapControls,
|
|
109
|
+
OrbitControls
|
|
110
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Entity, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import { type GestureEvent } from '@aethervtt/shard-input';
|
|
3
|
+
type Kind = 'orbit' | 'map';
|
|
4
|
+
export interface ControlLive {
|
|
5
|
+
entity: Entity;
|
|
6
|
+
kind: Kind;
|
|
7
|
+
/** The owner name it claims gestures with. */
|
|
8
|
+
owner: string;
|
|
9
|
+
/** Where the camera is now (eased toward the component's fields). */
|
|
10
|
+
target: Float64Array;
|
|
11
|
+
/** Orbit: distance. Map: zoom. */
|
|
12
|
+
scale: number;
|
|
13
|
+
yaw: number;
|
|
14
|
+
pitch: number;
|
|
15
|
+
/** The one-finger drag it owns (0: none), what it does, and the floor point it grabbed. */
|
|
16
|
+
dragId: number;
|
|
17
|
+
dragMode: 'pan' | 'orbit';
|
|
18
|
+
grab: Float64Array;
|
|
19
|
+
/** The two-finger gesture it owns (0: none). */
|
|
20
|
+
twoId: number;
|
|
21
|
+
moving: boolean;
|
|
22
|
+
active: boolean;
|
|
23
|
+
/** The buttons its pointer policy takes (bits), -1 when it has none set. */
|
|
24
|
+
policyBits: number;
|
|
25
|
+
seen: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ControlsStateValue {
|
|
28
|
+
live: Map<Entity, ControlLive>;
|
|
29
|
+
frame: number;
|
|
30
|
+
}
|
|
31
|
+
export declare const ControlsState: import("@aethervtt/shard-core").ResourceDef<ControlsStateValue>;
|
|
32
|
+
/** The size in CSS pixels `camera` is shown at: its target's once rendered, else the setting. */
|
|
33
|
+
export declare function viewportOf(world: World, camera: Entity, out: [number, number]): [number, number];
|
|
34
|
+
/** Reads gestures into the controls and places their cameras, before transform propagation. */
|
|
35
|
+
export declare const updateControls: import("@aethervtt/shard-core").SystemDef<{
|
|
36
|
+
reader: import("@aethervtt/shard-core").EventReader<GestureEvent>;
|
|
37
|
+
orbit: import("@aethervtt/shard-core").Query;
|
|
38
|
+
map: import("@aethervtt/shard-core").Query;
|
|
39
|
+
}>;
|
|
40
|
+
/** Jumps a control's camera to its fields, skipping the easing (after a host sets a view). */
|
|
41
|
+
export declare function snapControls(world: World, entity: Entity): void;
|
|
42
|
+
export {};
|
package/dist/controls.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineResource,
|
|
3
|
+
defineSystem,
|
|
4
|
+
quat
|
|
5
|
+
} from "@aethervtt/shard-core";
|
|
6
|
+
import { Gesture, Gestures } from "@aethervtt/shard-input";
|
|
7
|
+
import { MODIFIERS } from "@aethervtt/shard-platform";
|
|
8
|
+
import { Camera3d, Cameras } from "@aethervtt/shard-render";
|
|
9
|
+
import { FrameDemand, Time } from "@aethervtt/shard-runtime";
|
|
10
|
+
import { Transform } from "@aethervtt/shard-transform";
|
|
11
|
+
import { ControlsSettings, MapControls, OrbitControls } from "./components.js";
|
|
12
|
+
import { createView, mapView, orbitView, viewToPlane } from "./view.js";
|
|
13
|
+
const BUTTON_NAMES = ["left", "middle", "right"];
|
|
14
|
+
const BUTTON_BITS = { left: 1, middle: 2, right: 4, back: 0, forward: 0 };
|
|
15
|
+
const DEMAND = "controls";
|
|
16
|
+
const MAX_HIT = 50;
|
|
17
|
+
const WHEEL_RATE = 15e-4;
|
|
18
|
+
const ControlsState = defineResource("controls/State", {
|
|
19
|
+
description: "Each control's eased camera state, and the gestures it owns.",
|
|
20
|
+
init: () => ({ live: /* @__PURE__ */ new Map(), frame: 0 })
|
|
21
|
+
});
|
|
22
|
+
const goal = {
|
|
23
|
+
target: new Float64Array(3),
|
|
24
|
+
scale: 1,
|
|
25
|
+
yaw: 0,
|
|
26
|
+
pitch: 0,
|
|
27
|
+
minScale: 0,
|
|
28
|
+
maxScale: 1,
|
|
29
|
+
minPitch: 0,
|
|
30
|
+
maxPitch: 0,
|
|
31
|
+
height: 1,
|
|
32
|
+
elevation: 1,
|
|
33
|
+
fovY: 60,
|
|
34
|
+
axis: 1,
|
|
35
|
+
mapPitch: 90,
|
|
36
|
+
bounded: false,
|
|
37
|
+
boundsMin0: 0,
|
|
38
|
+
boundsMin1: 0,
|
|
39
|
+
boundsMax0: 0,
|
|
40
|
+
boundsMax1: 0
|
|
41
|
+
};
|
|
42
|
+
const view = createView();
|
|
43
|
+
const hitA = new Float64Array(3);
|
|
44
|
+
const hitB = new Float64Array(3);
|
|
45
|
+
const grab2 = new Float64Array(3);
|
|
46
|
+
const viewport = [1, 1];
|
|
47
|
+
const scratchQuat = new Float32Array(4);
|
|
48
|
+
const back = new Float64Array(3);
|
|
49
|
+
const eye = new Float64Array(3);
|
|
50
|
+
function viewportOf(world, camera, out) {
|
|
51
|
+
const cam = world.tryResource(Cameras)?.get(camera);
|
|
52
|
+
if (cam && cam.frames > 0) {
|
|
53
|
+
out[0] = cam.displayWidth / cam.pixelRatio;
|
|
54
|
+
out[1] = cam.displayHeight / cam.pixelRatio;
|
|
55
|
+
} else {
|
|
56
|
+
const v = world.resource(ControlsSettings).viewport;
|
|
57
|
+
out[0] = v[0];
|
|
58
|
+
out[1] = v[1];
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function clampGoal(kind, g) {
|
|
63
|
+
if (!(g.scale >= g.minScale)) g.scale = g.minScale;
|
|
64
|
+
if (g.scale > g.maxScale) g.scale = Math.max(g.minScale, g.maxScale);
|
|
65
|
+
if (kind === "orbit") {
|
|
66
|
+
if (!(g.pitch >= g.minPitch)) g.pitch = g.minPitch;
|
|
67
|
+
if (g.pitch > g.maxPitch) g.pitch = Math.max(g.minPitch, g.maxPitch);
|
|
68
|
+
}
|
|
69
|
+
if (g.bounded) {
|
|
70
|
+
const t = g.target;
|
|
71
|
+
t[0] = Math.min(Math.max(t[0], g.boundsMin0), Math.max(g.boundsMin0, g.boundsMax0));
|
|
72
|
+
const b = g.axis === 1 ? 2 : 1;
|
|
73
|
+
t[b] = Math.min(Math.max(t[b], g.boundsMin1), Math.max(g.boundsMin1, g.boundsMax1));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function goalView(kind, g, aspect) {
|
|
77
|
+
return kind === "orbit" ? orbitView(view, g.target, g.scale, g.yaw, g.pitch, g.fovY, aspect) : mapView(view, g.target, g.elevation, g.mapPitch, g.height / g.scale, aspect, planeOf(g));
|
|
78
|
+
}
|
|
79
|
+
function planeOf(g) {
|
|
80
|
+
return g.axis === 1 ? "xz" : "xy";
|
|
81
|
+
}
|
|
82
|
+
function viewSize(kind, g) {
|
|
83
|
+
return kind === "orbit" ? g.scale : g.height / g.scale;
|
|
84
|
+
}
|
|
85
|
+
function zoomAt(kind, g, x, y, factor) {
|
|
86
|
+
if (!Number.isFinite(factor) || factor <= 0) return;
|
|
87
|
+
const before = g.scale;
|
|
88
|
+
const hit = grabAt(kind, g, x, y, hitA);
|
|
89
|
+
g.scale = kind === "orbit" ? before * factor : before / factor;
|
|
90
|
+
clampGoal(kind, g);
|
|
91
|
+
if (!hit) return;
|
|
92
|
+
const k = kind === "orbit" ? g.scale / before : before / g.scale;
|
|
93
|
+
const t = g.target;
|
|
94
|
+
const b = g.axis === 1 ? 2 : 1;
|
|
95
|
+
t[0] = hitA[0] + (t[0] - hitA[0]) * k;
|
|
96
|
+
t[b] = hitA[b] + (t[b] - hitA[b]) * k;
|
|
97
|
+
clampGoal(kind, g);
|
|
98
|
+
}
|
|
99
|
+
function panTo(kind, g, grab, x, y) {
|
|
100
|
+
const v = goalView(kind, g, viewport[0] / viewport[1]);
|
|
101
|
+
const max = MAX_HIT * viewSize(kind, g);
|
|
102
|
+
const a = g.axis;
|
|
103
|
+
if (!viewToPlane(v, x, y, viewport[0], viewport[1], g.target[a], hitB, max, a)) return;
|
|
104
|
+
const b = a === 1 ? 2 : 1;
|
|
105
|
+
g.target[0] = g.target[0] + grab[0] - hitB[0];
|
|
106
|
+
g.target[b] = g.target[b] + grab[b] - hitB[b];
|
|
107
|
+
clampGoal(kind, g);
|
|
108
|
+
}
|
|
109
|
+
function grabAt(kind, g, x, y, out) {
|
|
110
|
+
const v = goalView(kind, g, viewport[0] / viewport[1]);
|
|
111
|
+
return viewToPlane(
|
|
112
|
+
v,
|
|
113
|
+
x,
|
|
114
|
+
y,
|
|
115
|
+
viewport[0],
|
|
116
|
+
viewport[1],
|
|
117
|
+
g.target[g.axis],
|
|
118
|
+
out,
|
|
119
|
+
MAX_HIT * viewSize(kind, g),
|
|
120
|
+
g.axis
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const buttons = { orbit: void 0, pan: "middle", left: "none" };
|
|
124
|
+
function dragMode(g) {
|
|
125
|
+
if (g.pointer !== "touch") {
|
|
126
|
+
if (buttons.orbit !== void 0 && g.button === buttons.orbit)
|
|
127
|
+
return (g.modifiers & MODIFIERS.shift) !== 0 ? "pan" : "orbit";
|
|
128
|
+
if (g.button === buttons.pan) return "pan";
|
|
129
|
+
if (g.button !== "left") return "none";
|
|
130
|
+
}
|
|
131
|
+
return buttons.left;
|
|
132
|
+
}
|
|
133
|
+
function handle(kind, g, live, e, gestures, rotateSpeed, zoomSpeed) {
|
|
134
|
+
switch (e.kind) {
|
|
135
|
+
case "drag-start":
|
|
136
|
+
case "drag": {
|
|
137
|
+
if (live.dragId === e.id) {
|
|
138
|
+
if (gestures.owner(e.id) !== live.owner) {
|
|
139
|
+
live.dragId = 0;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
if (live.dragId !== 0 || !gestures.free(e.id)) return;
|
|
144
|
+
const mode = dragMode(e);
|
|
145
|
+
if (mode === "none") return;
|
|
146
|
+
if (mode === "pan" && !grabAt(kind, g, e.startX, e.startY, live.grab)) return;
|
|
147
|
+
if (!gestures.claim(e.id, live.owner)) return;
|
|
148
|
+
live.dragId = e.id;
|
|
149
|
+
live.dragMode = mode;
|
|
150
|
+
if (mode === "orbit") {
|
|
151
|
+
g.yaw -= (e.x - e.startX - e.dx) * rotateSpeed;
|
|
152
|
+
g.pitch += (e.y - e.startY - e.dy) * rotateSpeed;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (live.dragMode === "pan") panTo(kind, g, live.grab, e.x, e.y);
|
|
156
|
+
else if (kind === "orbit") {
|
|
157
|
+
g.yaw -= e.dx * rotateSpeed;
|
|
158
|
+
g.pitch += e.dy * rotateSpeed;
|
|
159
|
+
clampGoal(kind, g);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
case "drag-end":
|
|
164
|
+
if (live.dragId === e.id) live.dragId = 0;
|
|
165
|
+
return;
|
|
166
|
+
case "wheel":
|
|
167
|
+
zoomAt(kind, g, e.x, e.y, Math.exp(e.dy * WHEEL_RATE * zoomSpeed));
|
|
168
|
+
return;
|
|
169
|
+
case "pinch":
|
|
170
|
+
case "pan2":
|
|
171
|
+
case "twist": {
|
|
172
|
+
if (e.id !== 0 && live.twoId !== e.id) {
|
|
173
|
+
if (!gestures.free(e.id) || !gestures.claim(e.id, live.owner)) return;
|
|
174
|
+
live.twoId = e.id;
|
|
175
|
+
} else if (e.id !== 0 && gestures.owner(e.id) !== live.owner) return;
|
|
176
|
+
if (e.kind === "pinch") zoomAt(kind, g, e.x, e.y, 1 / e.scale);
|
|
177
|
+
else if (e.kind === "pan2") {
|
|
178
|
+
if (grabAt(kind, g, e.x - e.dx, e.y - e.dy, grab2)) panTo(kind, g, grab2, e.x, e.y);
|
|
179
|
+
} else if (kind === "orbit") {
|
|
180
|
+
const anchored = grabAt(kind, g, e.x, e.y, grab2);
|
|
181
|
+
g.yaw += e.angle * 180 / Math.PI;
|
|
182
|
+
if (anchored) panTo(kind, g, grab2, e.x, e.y);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function setPolicy(gestures, live, bits) {
|
|
189
|
+
if (live.policyBits === bits) return;
|
|
190
|
+
live.policyBits = bits;
|
|
191
|
+
if (bits < 0) {
|
|
192
|
+
gestures.setPolicy(live.owner, void 0);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const list = [];
|
|
196
|
+
for (let i = 0; i < 3; i++) if ((bits & 1 << i) !== 0) list.push(BUTTON_NAMES[i]);
|
|
197
|
+
const policy = { buttons: list, wheel: true, touch: true };
|
|
198
|
+
gestures.setPolicy(live.owner, policy);
|
|
199
|
+
}
|
|
200
|
+
function releaseAll(gestures, live) {
|
|
201
|
+
if (live.dragId !== 0) gestures.release(live.dragId, live.owner);
|
|
202
|
+
if (live.twoId !== 0) gestures.release(live.twoId, live.owner);
|
|
203
|
+
live.dragId = 0;
|
|
204
|
+
live.twoId = 0;
|
|
205
|
+
setPolicy(gestures, live, -1);
|
|
206
|
+
}
|
|
207
|
+
function createLive(entity, kind, g) {
|
|
208
|
+
return {
|
|
209
|
+
entity,
|
|
210
|
+
kind,
|
|
211
|
+
owner: `controls:${entity}`,
|
|
212
|
+
target: Float64Array.from(g.target),
|
|
213
|
+
scale: g.scale,
|
|
214
|
+
yaw: g.yaw,
|
|
215
|
+
pitch: g.pitch,
|
|
216
|
+
dragId: 0,
|
|
217
|
+
dragMode: "pan",
|
|
218
|
+
grab: new Float64Array(3),
|
|
219
|
+
twoId: 0,
|
|
220
|
+
moving: false,
|
|
221
|
+
active: false,
|
|
222
|
+
policyBits: -1,
|
|
223
|
+
seen: 0
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function stepTo(from, to, alpha, eps) {
|
|
227
|
+
const next = from + (to - from) * alpha;
|
|
228
|
+
return Math.abs(to - next) <= eps ? to : next;
|
|
229
|
+
}
|
|
230
|
+
function ease(live, g, alpha) {
|
|
231
|
+
const eps = Math.max(1e-6, viewSize(live.kind, g)) * 1e-5;
|
|
232
|
+
const t = live.target;
|
|
233
|
+
t[0] = stepTo(t[0], g.target[0], alpha, eps);
|
|
234
|
+
t[1] = stepTo(t[1], g.target[1], alpha, eps);
|
|
235
|
+
t[2] = stepTo(t[2], g.target[2], alpha, eps);
|
|
236
|
+
live.scale = stepTo(live.scale, g.scale, alpha, Math.max(1e-6, g.scale * 1e-5));
|
|
237
|
+
live.yaw = stepTo(live.yaw, g.yaw, alpha, 1e-4);
|
|
238
|
+
live.pitch = stepTo(live.pitch, g.pitch, alpha, 1e-4);
|
|
239
|
+
return t[0] !== g.target[0] || t[1] !== g.target[1] || t[2] !== g.target[2] || live.scale !== g.scale || live.yaw !== g.yaw || live.pitch !== g.pitch;
|
|
240
|
+
}
|
|
241
|
+
function put(column, index, value) {
|
|
242
|
+
const v = Math.fround(value);
|
|
243
|
+
if (column[index] === v) return false;
|
|
244
|
+
column[index] = v;
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
function writeCamera(table, row, kind, live, g) {
|
|
248
|
+
const aspect = viewport[0] / viewport[1];
|
|
249
|
+
const v = kind === "orbit" ? orbitView(view, live.target, live.scale, live.yaw, live.pitch, g.fovY, aspect) : mapView(
|
|
250
|
+
view,
|
|
251
|
+
live.target,
|
|
252
|
+
g.elevation,
|
|
253
|
+
g.mapPitch,
|
|
254
|
+
g.height / live.scale,
|
|
255
|
+
aspect,
|
|
256
|
+
planeOf(g)
|
|
257
|
+
);
|
|
258
|
+
back[0] = -v.forward[0];
|
|
259
|
+
back[1] = -v.forward[1];
|
|
260
|
+
back[2] = -v.forward[2];
|
|
261
|
+
quat.fromBasis(scratchQuat, v.right, v.up, back);
|
|
262
|
+
eye.set(v.eye);
|
|
263
|
+
const translation = table.column(Transform, "translation");
|
|
264
|
+
const rotation = table.column(Transform, "rotation");
|
|
265
|
+
let changed = false;
|
|
266
|
+
for (let k = 0; k < 3; k++) {
|
|
267
|
+
const value = Math.fround(eye[k]);
|
|
268
|
+
if (translation[row * 3 + k] !== value) {
|
|
269
|
+
translation[row * 3 + k] = value;
|
|
270
|
+
changed = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (let k = 0; k < 4; k++) {
|
|
274
|
+
if (rotation[row * 4 + k] !== scratchQuat[k]) {
|
|
275
|
+
rotation[row * 4 + k] = scratchQuat[k];
|
|
276
|
+
changed = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (changed) table.markChanged(Transform, row);
|
|
280
|
+
if (kind === "map") {
|
|
281
|
+
const height = table.column(Camera3d, "orthoHeight");
|
|
282
|
+
const value = Math.fround(g.height / live.scale);
|
|
283
|
+
if (height[row] !== value) {
|
|
284
|
+
height[row] = value;
|
|
285
|
+
table.markChanged(Camera3d, row);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const ORBIT_LEFT = ["orbit", "pan", "none"];
|
|
290
|
+
function run(world, events, kind, table, state, reduced, dt) {
|
|
291
|
+
const o = kind === "orbit";
|
|
292
|
+
const def = o ? OrbitControls : MapControls;
|
|
293
|
+
const gestures = world.resource(Gestures);
|
|
294
|
+
const active = table.column(Camera3d, "active");
|
|
295
|
+
const fov = table.column(Camera3d, "fovY");
|
|
296
|
+
const target = o ? table.column(OrbitControls, "target") : table.column(MapControls, "target");
|
|
297
|
+
const enabled = o ? table.column(OrbitControls, "enabled") : table.column(MapControls, "enabled");
|
|
298
|
+
const smoothing = o ? table.column(OrbitControls, "smoothing") : table.column(MapControls, "smoothing");
|
|
299
|
+
const zoomSpeed = o ? table.column(OrbitControls, "zoomSpeed") : table.column(MapControls, "zoomSpeed");
|
|
300
|
+
const panButton = o ? table.column(OrbitControls, "panButton") : table.column(MapControls, "panButton");
|
|
301
|
+
const leftDrag = o ? table.column(OrbitControls, "leftDrag") : table.column(MapControls, "leftDrag");
|
|
302
|
+
const distance = o ? table.column(OrbitControls, "distance") : void 0;
|
|
303
|
+
const yaw = o ? table.column(OrbitControls, "yaw") : void 0;
|
|
304
|
+
const pitch = o ? table.column(OrbitControls, "pitch") : void 0;
|
|
305
|
+
const minPitch = o ? table.column(OrbitControls, "minPitch") : void 0;
|
|
306
|
+
const maxPitch = o ? table.column(OrbitControls, "maxPitch") : void 0;
|
|
307
|
+
const minScale = o ? table.column(OrbitControls, "minDistance") : table.column(MapControls, "minZoom");
|
|
308
|
+
const maxScale = o ? table.column(OrbitControls, "maxDistance") : table.column(MapControls, "maxZoom");
|
|
309
|
+
const orbitButton = o ? table.column(OrbitControls, "orbitButton") : void 0;
|
|
310
|
+
const rotateSpeed = o ? table.column(OrbitControls, "rotateSpeed") : void 0;
|
|
311
|
+
const autoRotate = o ? table.column(OrbitControls, "autoRotate") : void 0;
|
|
312
|
+
const zoom = o ? void 0 : table.column(MapControls, "zoom");
|
|
313
|
+
const height = o ? void 0 : table.column(MapControls, "height");
|
|
314
|
+
const elevation = o ? void 0 : table.column(MapControls, "elevation");
|
|
315
|
+
const bounded = o ? void 0 : table.column(MapControls, "bounded");
|
|
316
|
+
const mapPitch = o ? void 0 : table.column(MapControls, "pitch");
|
|
317
|
+
const plane = o ? void 0 : table.column(MapControls, "plane");
|
|
318
|
+
const boundsMin = o ? void 0 : table.column(MapControls, "boundsMin");
|
|
319
|
+
const boundsMax = o ? void 0 : table.column(MapControls, "boundsMax");
|
|
320
|
+
let moving = false;
|
|
321
|
+
for (let i = 0; i < table.count; i++) {
|
|
322
|
+
const entity = table.entities[i];
|
|
323
|
+
goal.target[0] = target[i * 3];
|
|
324
|
+
goal.target[1] = target[i * 3 + 1];
|
|
325
|
+
goal.target[2] = target[i * 3 + 2];
|
|
326
|
+
goal.scale = o ? distance[i] : zoom[i];
|
|
327
|
+
goal.yaw = o ? yaw[i] : 0;
|
|
328
|
+
goal.pitch = o ? pitch[i] : 90;
|
|
329
|
+
goal.minScale = minScale[i];
|
|
330
|
+
goal.maxScale = maxScale[i];
|
|
331
|
+
goal.minPitch = o ? minPitch[i] : 90;
|
|
332
|
+
goal.maxPitch = o ? maxPitch[i] : 90;
|
|
333
|
+
goal.fovY = fov[i];
|
|
334
|
+
goal.height = o ? 1 : height[i];
|
|
335
|
+
goal.elevation = o ? 1 : elevation[i];
|
|
336
|
+
goal.bounded = o ? false : bounded[i] !== 0;
|
|
337
|
+
goal.axis = o || plane[i] === 0 ? 1 : 2;
|
|
338
|
+
goal.mapPitch = o ? 90 : mapPitch[i];
|
|
339
|
+
goal.boundsMin0 = o ? 0 : boundsMin[i * 2];
|
|
340
|
+
goal.boundsMin1 = o ? 0 : boundsMin[i * 2 + 1];
|
|
341
|
+
goal.boundsMax0 = o ? 0 : boundsMax[i * 2];
|
|
342
|
+
goal.boundsMax1 = o ? 0 : boundsMax[i * 2 + 1];
|
|
343
|
+
let live = state.live.get(entity);
|
|
344
|
+
if (!live || live.kind !== kind) {
|
|
345
|
+
clampGoal(kind, goal);
|
|
346
|
+
live = createLive(entity, kind, goal);
|
|
347
|
+
state.live.set(entity, live);
|
|
348
|
+
}
|
|
349
|
+
live.seen = state.frame;
|
|
350
|
+
live.active = active[i] !== 0 && enabled[i] !== 0;
|
|
351
|
+
if (!live.active) {
|
|
352
|
+
releaseAll(gestures, live);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
viewportOf(world, entity, viewport);
|
|
356
|
+
buttons.orbit = o ? BUTTON_NAMES[orbitButton[i]] : void 0;
|
|
357
|
+
buttons.pan = BUTTON_NAMES[panButton[i]];
|
|
358
|
+
buttons.left = o ? ORBIT_LEFT[leftDrag[i]] : leftDrag[i] === 0 ? "pan" : "none";
|
|
359
|
+
setPolicy(
|
|
360
|
+
gestures,
|
|
361
|
+
live,
|
|
362
|
+
(buttons.orbit ? BUTTON_BITS[buttons.orbit] : 0) | BUTTON_BITS[buttons.pan] | (buttons.left !== "none" ? BUTTON_BITS.left : 0)
|
|
363
|
+
);
|
|
364
|
+
clampGoal(kind, goal);
|
|
365
|
+
const rs = o ? rotateSpeed[i] : 0;
|
|
366
|
+
for (let k = 0; k < events.length; k++)
|
|
367
|
+
handle(kind, goal, live, events[k], gestures, rs, zoomSpeed[i]);
|
|
368
|
+
const turning = o && autoRotate[i] !== 0 && live.dragId === 0 && live.twoId === 0;
|
|
369
|
+
if (turning) goal.yaw += autoRotate[i] * dt;
|
|
370
|
+
clampGoal(kind, goal);
|
|
371
|
+
let changed = put(target, i * 3, goal.target[0]);
|
|
372
|
+
if (put(target, i * 3 + 1, goal.target[1])) changed = true;
|
|
373
|
+
if (put(target, i * 3 + 2, goal.target[2])) changed = true;
|
|
374
|
+
if (o) {
|
|
375
|
+
if (put(distance, i, goal.scale)) changed = true;
|
|
376
|
+
if (put(yaw, i, goal.yaw)) changed = true;
|
|
377
|
+
if (put(pitch, i, goal.pitch)) changed = true;
|
|
378
|
+
} else if (put(zoom, i, goal.scale)) changed = true;
|
|
379
|
+
if (changed) table.markChanged(def, i);
|
|
380
|
+
goal.target[0] = target[i * 3];
|
|
381
|
+
goal.target[1] = target[i * 3 + 1];
|
|
382
|
+
goal.target[2] = target[i * 3 + 2];
|
|
383
|
+
goal.scale = o ? distance[i] : zoom[i];
|
|
384
|
+
goal.yaw = o ? yaw[i] : 0;
|
|
385
|
+
goal.pitch = o ? pitch[i] : 90;
|
|
386
|
+
const s = smoothing[i];
|
|
387
|
+
const alpha = reduced || !(s > 0) ? 1 : 1 - Math.exp(-dt / s);
|
|
388
|
+
live.moving = ease(live, goal, alpha) || turning;
|
|
389
|
+
if (live.moving) moving = true;
|
|
390
|
+
writeCamera(table, i, kind, live, goal);
|
|
391
|
+
}
|
|
392
|
+
return moving;
|
|
393
|
+
}
|
|
394
|
+
const updateControls = defineSystem({
|
|
395
|
+
name: "controls/update",
|
|
396
|
+
description: "Orbit and Map controls: gestures move each active control, which eases and places its camera.",
|
|
397
|
+
setup: (world) => ({
|
|
398
|
+
reader: world.reader(Gesture),
|
|
399
|
+
orbit: world.query({ with: [OrbitControls, Camera3d, Transform] }),
|
|
400
|
+
map: world.query({ with: [MapControls, Camera3d, Transform] })
|
|
401
|
+
}),
|
|
402
|
+
run: ({ reader, orbit, map }, world) => {
|
|
403
|
+
const events = reader.read();
|
|
404
|
+
const state = world.resource(ControlsState);
|
|
405
|
+
state.frame++;
|
|
406
|
+
const dt = world.resource(Time).delta;
|
|
407
|
+
const reduced = world.resource(ControlsSettings).reducedMotion;
|
|
408
|
+
let moving = false;
|
|
409
|
+
for (const table of orbit.tables)
|
|
410
|
+
if (run(world, events, "orbit", table, state, reduced, dt)) moving = true;
|
|
411
|
+
for (const table of map.tables)
|
|
412
|
+
if (run(world, events, "map", table, state, reduced, dt)) moving = true;
|
|
413
|
+
let live = 0;
|
|
414
|
+
for (const table of orbit.tables) live += table.count;
|
|
415
|
+
for (const table of map.tables) live += table.count;
|
|
416
|
+
if (live !== state.live.size) {
|
|
417
|
+
const gestures = world.resource(Gestures);
|
|
418
|
+
for (const [entity, l] of state.live) {
|
|
419
|
+
if (l.seen === state.frame) continue;
|
|
420
|
+
releaseAll(gestures, l);
|
|
421
|
+
state.live.delete(entity);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
world.resource(FrameDemand).set(DEMAND, moving);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
function snapControls(world, entity) {
|
|
428
|
+
const live = world.resource(ControlsState).live.get(entity);
|
|
429
|
+
if (!live) return;
|
|
430
|
+
const orbit = world.tryGet(entity, OrbitControls);
|
|
431
|
+
const map = orbit ? void 0 : world.tryGet(entity, MapControls);
|
|
432
|
+
const value = orbit ?? map;
|
|
433
|
+
if (!value) return;
|
|
434
|
+
live.target.set(value.target);
|
|
435
|
+
live.scale = orbit ? orbit.distance : map.zoom;
|
|
436
|
+
live.yaw = orbit ? orbit.yaw : 0;
|
|
437
|
+
live.pitch = orbit ? orbit.pitch : 90;
|
|
438
|
+
}
|
|
439
|
+
export {
|
|
440
|
+
ControlsState,
|
|
441
|
+
snapControls,
|
|
442
|
+
updateControls,
|
|
443
|
+
viewportOf
|
|
444
|
+
};
|