@scenography/plugin-focus 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/dist/index.cjs ADDED
@@ -0,0 +1,193 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _scenography_engine = require("@scenography/engine");
3
+ let three = require("three");
4
+ //#region src/easing.ts
5
+ /**
6
+ * Sample a CSS-style cubic-bezier easing `(p1x, p1y, p2x, p2y)` with anchors at
7
+ * (0,0) and (1,1). Returns a function mapping linear progress `x` in `[0,1]` to
8
+ * eased progress `y`. Used to give camera moves a smooth ease-in-out curve.
9
+ */
10
+ function cubicBezier(p1x, p1y, p2x, p2y) {
11
+ const cx = 3 * p1x;
12
+ const bx = 3 * (p2x - p1x) - cx;
13
+ const ax = 1 - cx - bx;
14
+ const cy = 3 * p1y;
15
+ const by = 3 * (p2y - p1y) - cy;
16
+ const ay = 1 - cy - by;
17
+ const sampleX = (t) => ((ax * t + bx) * t + cx) * t;
18
+ const sampleY = (t) => ((ay * t + by) * t + cy) * t;
19
+ const slopeX = (t) => (3 * ax * t + 2 * bx) * t + cx;
20
+ const solveT = (x) => {
21
+ let t = x;
22
+ for (let i = 0; i < 8; i++) {
23
+ const dx = sampleX(t) - x;
24
+ if (Math.abs(dx) < 1e-5) return t;
25
+ const d = slopeX(t);
26
+ if (Math.abs(d) < 1e-6) break;
27
+ t -= dx / d;
28
+ }
29
+ return t;
30
+ };
31
+ return (x) => {
32
+ if (x <= 0) return 0;
33
+ if (x >= 1) return 1;
34
+ return sampleY(solveT(x));
35
+ };
36
+ }
37
+ /** Standard smooth ease-in-out, the default for camera framing moves. */
38
+ const easeInOut = cubicBezier(.42, 0, .58, 1);
39
+ //#endregion
40
+ //#region src/CameraController.ts
41
+ /** Max up/down framing pitch (radians) — keeps the framed view from flipping. */
42
+ const MAX_PITCH = Math.PI / 3;
43
+ const TAU = Math.PI * 2;
44
+ const FIT_MARGIN = .85;
45
+ const TAKEOVER_DISTANCE = 20;
46
+ const TAKEOVER_YAW = Math.PI / 2;
47
+ const shortestAngle = (delta) => (delta % TAU + TAU + Math.PI) % TAU - Math.PI;
48
+ const clamp = (x, a, b) => Math.min(Math.max(x, a), b);
49
+ /**
50
+ * Drives the camera for artwork framing: it computes a target pose that fits a
51
+ * piece into a region of the screen, then tweens there with an ease-in-out
52
+ * curve. The eye height is read live from the free-roam controls — only XZ
53
+ * position and rotation change. While a tween runs, free-roam input is disabled;
54
+ * when it lands, control returns to the user and (for `focusArtwork`) the
55
+ * controller watches for the user moving/turning away, emitting `artwork:released`.
56
+ */
57
+ var CameraController = class {
58
+ camera;
59
+ getRoam;
60
+ resolve;
61
+ emit;
62
+ groundHeight;
63
+ tween = null;
64
+ watch = null;
65
+ constructor(camera, getRoam, resolve, emit, groundHeight) {
66
+ this.camera = camera;
67
+ this.getRoam = getRoam;
68
+ this.resolve = resolve;
69
+ this.emit = emit;
70
+ this.groundHeight = groundHeight;
71
+ }
72
+ /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
73
+ isAnimating() {
74
+ return this.tween !== null;
75
+ }
76
+ focusArtwork(id, options = {}) {
77
+ this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true);
78
+ }
79
+ centerArtwork(id, options = {}) {
80
+ this.start(id, .5, 1, options.duration ?? 900, false);
81
+ }
82
+ release() {
83
+ this.watch = null;
84
+ }
85
+ start(id, centerX, widthFraction, duration, watch) {
86
+ const roam = this.getRoam();
87
+ if (!roam) return;
88
+ const object = this.resolve(id);
89
+ if (!object) return;
90
+ const [w, h] = object.userData.size ?? [100, 100];
91
+ const yaw = object.rotation.y;
92
+ const n = new three.Vector3(Math.sin(yaw), 0, Math.cos(yaw));
93
+ const center = object.position;
94
+ const tanV = Math.tan(this.camera.fov * Math.PI / 180 / 2);
95
+ const tanH = tanV * this.camera.aspect;
96
+ const dWidth = w / (widthFraction * 2 * tanH * FIT_MARGIN);
97
+ const dHeight = h / (2 * tanV * FIT_MARGIN);
98
+ const distance = Math.max(dWidth, dHeight);
99
+ const tangent = new three.Vector3(n.z, 0, -n.x);
100
+ const lateral = -((centerX - .5) * 2) * distance * tanH;
101
+ const px = center.x + n.x * distance + tangent.x * lateral;
102
+ const pz = center.z + n.z * distance + tangent.z * lateral;
103
+ const toPos = new three.Vector3(px, this.groundHeight(px, pz) + roam.getEyeHeight(), pz);
104
+ const f = new three.Vector3(center.x + tangent.x * lateral, center.y, center.z + tangent.z * lateral).sub(toPos).normalize();
105
+ const toTheta = clamp(Math.asin(clamp(f.y, -1, 1)), -MAX_PITCH, MAX_PITCH);
106
+ const toPhi = Math.atan2(-f.x, -f.z);
107
+ const from = roam.getPose();
108
+ roam.setEnabled(false);
109
+ this.watch = null;
110
+ this.tween = {
111
+ id,
112
+ fromPos: from.position,
113
+ toPos,
114
+ fromPhi: from.phi,
115
+ dPhi: shortestAngle(toPhi - from.phi),
116
+ fromTheta: from.theta,
117
+ toTheta,
118
+ elapsed: 0,
119
+ duration,
120
+ watch
121
+ };
122
+ }
123
+ /** Advance any running tween, and watch for user takeover once landed. */
124
+ update(deltaMs) {
125
+ const roam = this.getRoam();
126
+ const tween = this.tween;
127
+ if (tween) {
128
+ tween.elapsed += deltaMs;
129
+ const t = clamp(tween.elapsed / tween.duration, 0, 1);
130
+ const k = easeInOut(t);
131
+ const phi = tween.fromPhi + tween.dPhi * k;
132
+ const theta = tween.fromTheta + (tween.toTheta - tween.fromTheta) * k;
133
+ const pos = tween.fromPos.clone().lerp(tween.toPos, k);
134
+ roam?.setPose(pos, phi, theta);
135
+ this.applyPose(pos, phi, theta);
136
+ if (t >= 1) {
137
+ roam?.setEnabled(true);
138
+ this.watch = tween.watch ? {
139
+ id: tween.id,
140
+ pos: tween.toPos.clone(),
141
+ phi
142
+ } : null;
143
+ this.tween = null;
144
+ }
145
+ return;
146
+ }
147
+ if (this.watch && roam) {
148
+ const pose = roam.getPose();
149
+ const moved = Math.hypot(pose.position.x - this.watch.pos.x, pose.position.z - this.watch.pos.z);
150
+ const turned = Math.abs(shortestAngle(pose.phi - this.watch.phi));
151
+ if (moved > TAKEOVER_DISTANCE || turned > TAKEOVER_YAW) {
152
+ const id = this.watch.id;
153
+ this.watch = null;
154
+ this.emit("artwork:released", { id });
155
+ }
156
+ }
157
+ }
158
+ /** Write a pose straight to the camera (used while we own it during a tween). */
159
+ applyPose(position, phi, theta) {
160
+ this.camera.position.copy(position);
161
+ const forward = new three.Vector3(-Math.cos(theta) * Math.sin(phi), Math.sin(theta), -Math.cos(theta) * Math.cos(phi));
162
+ this.camera.lookAt(position.clone().add(forward));
163
+ }
164
+ };
165
+ //#endregion
166
+ //#region src/plugin.ts
167
+ /**
168
+ * Camera framing — a *system* plugin. On selecting an artwork it animates the
169
+ * camera to frame the piece (offset beside an info panel), and centers it when
170
+ * the panel closes, handing control back to free-roam afterwards. Its api
171
+ * surfaces as `ctx.camera`. Cooperates with the roaming camera through the
172
+ * `roamControl` capability (suspends it during a tween); frames on the terrain
173
+ * via `terrain`.
174
+ */
175
+ const focus = () => ({
176
+ name: "focus",
177
+ setup: (ctx) => {
178
+ const { camera, capabilities, emit, resolve } = ctx;
179
+ const groundHeight = (x, z) => capabilities.get(_scenography_engine.TERRAIN)?.heightAt(x, z) ?? 0;
180
+ const controller = new CameraController(camera, () => capabilities.get(_scenography_engine.ROAM_CONTROL), resolve, emit, groundHeight);
181
+ return {
182
+ update: (deltaMs) => controller.update(deltaMs),
183
+ api: controller
184
+ };
185
+ }
186
+ });
187
+ //#endregion
188
+ exports.CameraController = CameraController;
189
+ exports.cubicBezier = cubicBezier;
190
+ exports.easeInOut = easeInOut;
191
+ exports.focus = focus;
192
+
193
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["Vector3","TERRAIN","ROAM_CONTROL"],"sources":["../src/easing.ts","../src/CameraController.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Sample a CSS-style cubic-bezier easing `(p1x, p1y, p2x, p2y)` with anchors at\n * (0,0) and (1,1). Returns a function mapping linear progress `x` in `[0,1]` to\n * eased progress `y`. Used to give camera moves a smooth ease-in-out curve.\n */\nexport function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number): (x: number) => number {\n const cx = 3 * p1x;\n const bx = 3 * (p2x - p1x) - cx;\n const ax = 1 - cx - bx;\n const cy = 3 * p1y;\n const by = 3 * (p2y - p1y) - cy;\n const ay = 1 - cy - by;\n\n const sampleX = (t: number): number => ((ax * t + bx) * t + cx) * t;\n const sampleY = (t: number): number => ((ay * t + by) * t + cy) * t;\n const slopeX = (t: number): number => (3 * ax * t + 2 * bx) * t + cx;\n\n // Solve x(t) = x for t via Newton-Raphson, then evaluate y(t).\n const solveT = (x: number): number => {\n let t = x;\n for (let i = 0; i < 8; i++) {\n const dx = sampleX(t) - x;\n if (Math.abs(dx) < 1e-5) return t;\n const d = slopeX(t);\n if (Math.abs(d) < 1e-6) break;\n t -= dx / d;\n }\n return t;\n };\n\n return (x: number): number => {\n if (x <= 0) return 0;\n if (x >= 1) return 1;\n return sampleY(solveT(x));\n };\n}\n\n/** Standard smooth ease-in-out, the default for camera framing moves. */\nexport const easeInOut = cubicBezier(0.42, 0, 0.58, 1);\n","import { Vector3, type Object3D, type PerspectiveCamera } from \"three\";\nimport type { CameraApi, FocusOptions, RoamControl } from \"@scenography/engine\";\n\nimport { easeInOut } from \"./easing\";\n\n/** Max up/down framing pitch (radians) — keeps the framed view from flipping. */\nconst MAX_PITCH = Math.PI / 3;\n\nconst TAU = Math.PI * 2;\nconst FIT_MARGIN = 0.85;\n// Thresholds past which the user is considered to have taken control back.\nconst TAKEOVER_DISTANCE = 20; // world units of XZ movement\nconst TAKEOVER_YAW = Math.PI / 2; // 90° of turn\n\nconst shortestAngle = (delta: number): number => ((((delta % TAU) + TAU + Math.PI) % TAU) - Math.PI);\nconst clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\ninterface Tween {\n id: string;\n fromPos: Vector3;\n toPos: Vector3;\n fromPhi: number;\n dPhi: number;\n fromTheta: number;\n toTheta: number;\n elapsed: number;\n duration: number;\n /** Whether to watch for user takeover once the tween lands. */\n watch: boolean;\n}\n\n/**\n * Drives the camera for artwork framing: it computes a target pose that fits a\n * piece into a region of the screen, then tweens there with an ease-in-out\n * curve. The eye height is read live from the free-roam controls — only XZ\n * position and rotation change. While a tween runs, free-roam input is disabled;\n * when it lands, control returns to the user and (for `focusArtwork`) the\n * controller watches for the user moving/turning away, emitting `artwork:released`.\n */\nexport class CameraController implements CameraApi {\n private tween: Tween | null = null;\n private watch: { id: string; pos: Vector3; phi: number } | null = null;\n\n constructor(\n private readonly camera: PerspectiveCamera,\n /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */\n private readonly getRoam: () => RoamControl | undefined,\n private readonly resolve: (id: string) => Object3D | undefined,\n private readonly emit: (event: string, payload?: unknown) => void,\n /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */\n private readonly groundHeight: (x: number, z: number) => number,\n ) {}\n\n /** True while a framing tween is in progress (e.g. to suppress click-to-select). */\n isAnimating(): boolean {\n return this.tween !== null;\n }\n\n focusArtwork(id: string, options: FocusOptions = {}): void {\n this.start(id, options.centerX ?? 0.5, options.widthFraction ?? 1, options.duration ?? 900, true);\n }\n\n centerArtwork(id: string, options: FocusOptions = {}): void {\n this.start(id, 0.5, 1, options.duration ?? 900, false);\n }\n\n release(): void {\n this.watch = null;\n }\n\n private start(id: string, centerX: number, widthFraction: number, duration: number, watch: boolean): void {\n // No roaming camera to hand off from/to: nothing to frame.\n const roam = this.getRoam();\n if (!roam) return;\n const object = this.resolve(id);\n if (!object) return;\n const size = (object.userData.size as [number, number] | undefined) ?? [100, 100];\n const [w, h] = size;\n\n const yaw = object.rotation.y;\n const n = new Vector3(Math.sin(yaw), 0, Math.cos(yaw)); // outward normal\n const center = object.position;\n\n const tanV = Math.tan(((this.camera.fov * Math.PI) / 180) / 2);\n const tanH = tanV * this.camera.aspect;\n const dWidth = w / (widthFraction * 2 * tanH * FIT_MARGIN);\n const dHeight = h / (2 * tanV * FIT_MARGIN);\n const distance = Math.max(dWidth, dHeight);\n\n // Offset the camera *position* sideways (not its aim) so the view stays\n // square-on to the wall and the piece lands at `centerX` on screen. Moving\n // the look target instead would yaw the camera and leave it skewed.\n const tangent = new Vector3(n.z, 0, -n.x);\n const ndcX = (centerX - 0.5) * 2;\n const lateral = -ndcX * distance * tanH;\n // Eye stands a fixed height above the ground at the framing spot, so it\n // follows the (possibly uneven) terrain instead of a fixed world Y. The eye\n // height is read live from the controls: it may have been recalibrated\n // after the floor's heightmap loaded.\n const px = center.x + n.x * distance + tangent.x * lateral;\n const pz = center.z + n.z * distance + tangent.z * lateral;\n const toPos = new Vector3(px, this.groundHeight(px, pz) + roam.getEyeHeight(), pz);\n\n // Aim straight ahead horizontally (perpendicular to the wall), tilting only\n // to center the piece vertically since the eye height is locked.\n const target = new Vector3(center.x + tangent.x * lateral, center.y, center.z + tangent.z * lateral);\n const f = target.sub(toPos).normalize();\n const toTheta = clamp(Math.asin(clamp(f.y, -1, 1)), -MAX_PITCH, MAX_PITCH);\n const toPhi = Math.atan2(-f.x, -f.z);\n\n const from = roam.getPose();\n roam.setEnabled(false);\n this.watch = null;\n this.tween = {\n id,\n fromPos: from.position,\n toPos,\n fromPhi: from.phi,\n dPhi: shortestAngle(toPhi - from.phi),\n fromTheta: from.theta,\n toTheta,\n elapsed: 0,\n duration,\n watch,\n };\n }\n\n /** Advance any running tween, and watch for user takeover once landed. */\n update(deltaMs: number): void {\n const roam = this.getRoam();\n const tween = this.tween;\n if (tween) {\n tween.elapsed += deltaMs;\n const t = clamp(tween.elapsed / tween.duration, 0, 1);\n const k = easeInOut(t);\n const phi = tween.fromPhi + tween.dPhi * k;\n const theta = tween.fromTheta + (tween.toTheta - tween.fromTheta) * k;\n // Y interpolates between two terrain-relative heights (start → target);\n // don't pin it to a fixed eye height or uphill framing flies in flat.\n const pos = tween.fromPos.clone().lerp(tween.toPos, k);\n roam?.setPose(pos, phi, theta);\n this.applyPose(pos, phi, theta);\n\n if (t >= 1) {\n roam?.setEnabled(true);\n this.watch = tween.watch ? { id: tween.id, pos: tween.toPos.clone(), phi } : null;\n this.tween = null;\n }\n return;\n }\n\n if (this.watch && roam) {\n const pose = roam.getPose();\n const moved = Math.hypot(pose.position.x - this.watch.pos.x, pose.position.z - this.watch.pos.z);\n const turned = Math.abs(shortestAngle(pose.phi - this.watch.phi));\n if (moved > TAKEOVER_DISTANCE || turned > TAKEOVER_YAW) {\n const id = this.watch.id;\n this.watch = null;\n this.emit(\"artwork:released\", { id });\n }\n }\n }\n\n /** Write a pose straight to the camera (used while we own it during a tween). */\n private applyPose(position: Vector3, phi: number, theta: number): void {\n this.camera.position.copy(position);\n const forward = new Vector3(-Math.cos(theta) * Math.sin(phi), Math.sin(theta), -Math.cos(theta) * Math.cos(phi));\n this.camera.lookAt(position.clone().add(forward));\n }\n}\n","import {\n ROAM_CONTROL,\n TERRAIN,\n type CameraApi,\n type RoamControl,\n type SystemPlugin,\n type TerrainCapability,\n} from \"@scenography/engine\";\n\nimport { CameraController } from \"./CameraController\";\n\n/**\n * Camera framing — a *system* plugin. On selecting an artwork it animates the\n * camera to frame the piece (offset beside an info panel), and centers it when\n * the panel closes, handing control back to free-roam afterwards. Its api\n * surfaces as `ctx.camera`. Cooperates with the roaming camera through the\n * `roamControl` capability (suspends it during a tween); frames on the terrain\n * via `terrain`.\n */\nexport const focus = (): SystemPlugin<CameraApi> => ({\n name: \"focus\",\n setup: (ctx) => {\n const { camera, capabilities, emit, resolve } = ctx;\n const groundHeight = (x: number, z: number): number =>\n capabilities.get<TerrainCapability>(TERRAIN)?.heightAt(x, z) ?? 0;\n\n const controller = new CameraController(\n camera,\n () => capabilities.get<RoamControl>(ROAM_CONTROL),\n resolve,\n emit,\n groundHeight,\n );\n\n return {\n update: (deltaMs) => controller.update(deltaMs),\n api: controller,\n };\n },\n});\n"],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ import { CameraApi, FocusOptions, RoamControl, SystemPlugin } from "@scenography/engine";
2
+ import { Object3D, PerspectiveCamera } from "three";
3
+
4
+ //#region src/plugin.d.ts
5
+ /**
6
+ * Camera framing — a *system* plugin. On selecting an artwork it animates the
7
+ * camera to frame the piece (offset beside an info panel), and centers it when
8
+ * the panel closes, handing control back to free-roam afterwards. Its api
9
+ * surfaces as `ctx.camera`. Cooperates with the roaming camera through the
10
+ * `roamControl` capability (suspends it during a tween); frames on the terrain
11
+ * via `terrain`.
12
+ */
13
+ declare const focus: () => SystemPlugin<CameraApi>;
14
+ //#endregion
15
+ //#region src/CameraController.d.ts
16
+ /**
17
+ * Drives the camera for artwork framing: it computes a target pose that fits a
18
+ * piece into a region of the screen, then tweens there with an ease-in-out
19
+ * curve. The eye height is read live from the free-roam controls — only XZ
20
+ * position and rotation change. While a tween runs, free-roam input is disabled;
21
+ * when it lands, control returns to the user and (for `focusArtwork`) the
22
+ * controller watches for the user moving/turning away, emitting `artwork:released`.
23
+ */
24
+ declare class CameraController implements CameraApi {
25
+ private readonly camera;
26
+ /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */
27
+ private readonly getRoam;
28
+ private readonly resolve;
29
+ private readonly emit;
30
+ /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */
31
+ private readonly groundHeight;
32
+ private tween;
33
+ private watch;
34
+ constructor(camera: PerspectiveCamera, /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */
35
+
36
+ getRoam: () => RoamControl | undefined, resolve: (id: string) => Object3D | undefined, emit: (event: string, payload?: unknown) => void, /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */
37
+
38
+ groundHeight: (x: number, z: number) => number);
39
+ /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
40
+ isAnimating(): boolean;
41
+ focusArtwork(id: string, options?: FocusOptions): void;
42
+ centerArtwork(id: string, options?: FocusOptions): void;
43
+ release(): void;
44
+ private start;
45
+ /** Advance any running tween, and watch for user takeover once landed. */
46
+ update(deltaMs: number): void;
47
+ /** Write a pose straight to the camera (used while we own it during a tween). */
48
+ private applyPose;
49
+ }
50
+ //#endregion
51
+ //#region src/easing.d.ts
52
+ /**
53
+ * Sample a CSS-style cubic-bezier easing `(p1x, p1y, p2x, p2y)` with anchors at
54
+ * (0,0) and (1,1). Returns a function mapping linear progress `x` in `[0,1]` to
55
+ * eased progress `y`. Used to give camera moves a smooth ease-in-out curve.
56
+ */
57
+ declare function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number): (x: number) => number;
58
+ /** Standard smooth ease-in-out, the default for camera framing moves. */
59
+ declare const easeInOut: (x: number) => number;
60
+ //#endregion
61
+ export { CameraController, cubicBezier, easeInOut, focus };
62
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1,62 @@
1
+ import { CameraApi, FocusOptions, RoamControl, SystemPlugin } from "@scenography/engine";
2
+ import { Object3D, PerspectiveCamera } from "three";
3
+
4
+ //#region src/plugin.d.ts
5
+ /**
6
+ * Camera framing — a *system* plugin. On selecting an artwork it animates the
7
+ * camera to frame the piece (offset beside an info panel), and centers it when
8
+ * the panel closes, handing control back to free-roam afterwards. Its api
9
+ * surfaces as `ctx.camera`. Cooperates with the roaming camera through the
10
+ * `roamControl` capability (suspends it during a tween); frames on the terrain
11
+ * via `terrain`.
12
+ */
13
+ declare const focus: () => SystemPlugin<CameraApi>;
14
+ //#endregion
15
+ //#region src/CameraController.d.ts
16
+ /**
17
+ * Drives the camera for artwork framing: it computes a target pose that fits a
18
+ * piece into a region of the screen, then tweens there with an ease-in-out
19
+ * curve. The eye height is read live from the free-roam controls — only XZ
20
+ * position and rotation change. While a tween runs, free-roam input is disabled;
21
+ * when it lands, control returns to the user and (for `focusArtwork`) the
22
+ * controller watches for the user moving/turning away, emitting `artwork:released`.
23
+ */
24
+ declare class CameraController implements CameraApi {
25
+ private readonly camera;
26
+ /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */
27
+ private readonly getRoam;
28
+ private readonly resolve;
29
+ private readonly emit;
30
+ /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */
31
+ private readonly groundHeight;
32
+ private tween;
33
+ private watch;
34
+ constructor(camera: PerspectiveCamera, /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */
35
+
36
+ getRoam: () => RoamControl | undefined, resolve: (id: string) => Object3D | undefined, emit: (event: string, payload?: unknown) => void, /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */
37
+
38
+ groundHeight: (x: number, z: number) => number);
39
+ /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
40
+ isAnimating(): boolean;
41
+ focusArtwork(id: string, options?: FocusOptions): void;
42
+ centerArtwork(id: string, options?: FocusOptions): void;
43
+ release(): void;
44
+ private start;
45
+ /** Advance any running tween, and watch for user takeover once landed. */
46
+ update(deltaMs: number): void;
47
+ /** Write a pose straight to the camera (used while we own it during a tween). */
48
+ private applyPose;
49
+ }
50
+ //#endregion
51
+ //#region src/easing.d.ts
52
+ /**
53
+ * Sample a CSS-style cubic-bezier easing `(p1x, p1y, p2x, p2y)` with anchors at
54
+ * (0,0) and (1,1). Returns a function mapping linear progress `x` in `[0,1]` to
55
+ * eased progress `y`. Used to give camera moves a smooth ease-in-out curve.
56
+ */
57
+ declare function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number): (x: number) => number;
58
+ /** Standard smooth ease-in-out, the default for camera framing moves. */
59
+ declare const easeInOut: (x: number) => number;
60
+ //#endregion
61
+ export { CameraController, cubicBezier, easeInOut, focus };
62
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,189 @@
1
+ import { ROAM_CONTROL, TERRAIN } from "@scenography/engine";
2
+ import { Vector3 } from "three";
3
+ //#region src/easing.ts
4
+ /**
5
+ * Sample a CSS-style cubic-bezier easing `(p1x, p1y, p2x, p2y)` with anchors at
6
+ * (0,0) and (1,1). Returns a function mapping linear progress `x` in `[0,1]` to
7
+ * eased progress `y`. Used to give camera moves a smooth ease-in-out curve.
8
+ */
9
+ function cubicBezier(p1x, p1y, p2x, p2y) {
10
+ const cx = 3 * p1x;
11
+ const bx = 3 * (p2x - p1x) - cx;
12
+ const ax = 1 - cx - bx;
13
+ const cy = 3 * p1y;
14
+ const by = 3 * (p2y - p1y) - cy;
15
+ const ay = 1 - cy - by;
16
+ const sampleX = (t) => ((ax * t + bx) * t + cx) * t;
17
+ const sampleY = (t) => ((ay * t + by) * t + cy) * t;
18
+ const slopeX = (t) => (3 * ax * t + 2 * bx) * t + cx;
19
+ const solveT = (x) => {
20
+ let t = x;
21
+ for (let i = 0; i < 8; i++) {
22
+ const dx = sampleX(t) - x;
23
+ if (Math.abs(dx) < 1e-5) return t;
24
+ const d = slopeX(t);
25
+ if (Math.abs(d) < 1e-6) break;
26
+ t -= dx / d;
27
+ }
28
+ return t;
29
+ };
30
+ return (x) => {
31
+ if (x <= 0) return 0;
32
+ if (x >= 1) return 1;
33
+ return sampleY(solveT(x));
34
+ };
35
+ }
36
+ /** Standard smooth ease-in-out, the default for camera framing moves. */
37
+ const easeInOut = cubicBezier(.42, 0, .58, 1);
38
+ //#endregion
39
+ //#region src/CameraController.ts
40
+ /** Max up/down framing pitch (radians) — keeps the framed view from flipping. */
41
+ const MAX_PITCH = Math.PI / 3;
42
+ const TAU = Math.PI * 2;
43
+ const FIT_MARGIN = .85;
44
+ const TAKEOVER_DISTANCE = 20;
45
+ const TAKEOVER_YAW = Math.PI / 2;
46
+ const shortestAngle = (delta) => (delta % TAU + TAU + Math.PI) % TAU - Math.PI;
47
+ const clamp = (x, a, b) => Math.min(Math.max(x, a), b);
48
+ /**
49
+ * Drives the camera for artwork framing: it computes a target pose that fits a
50
+ * piece into a region of the screen, then tweens there with an ease-in-out
51
+ * curve. The eye height is read live from the free-roam controls — only XZ
52
+ * position and rotation change. While a tween runs, free-roam input is disabled;
53
+ * when it lands, control returns to the user and (for `focusArtwork`) the
54
+ * controller watches for the user moving/turning away, emitting `artwork:released`.
55
+ */
56
+ var CameraController = class {
57
+ camera;
58
+ getRoam;
59
+ resolve;
60
+ emit;
61
+ groundHeight;
62
+ tween = null;
63
+ watch = null;
64
+ constructor(camera, getRoam, resolve, emit, groundHeight) {
65
+ this.camera = camera;
66
+ this.getRoam = getRoam;
67
+ this.resolve = resolve;
68
+ this.emit = emit;
69
+ this.groundHeight = groundHeight;
70
+ }
71
+ /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
72
+ isAnimating() {
73
+ return this.tween !== null;
74
+ }
75
+ focusArtwork(id, options = {}) {
76
+ this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true);
77
+ }
78
+ centerArtwork(id, options = {}) {
79
+ this.start(id, .5, 1, options.duration ?? 900, false);
80
+ }
81
+ release() {
82
+ this.watch = null;
83
+ }
84
+ start(id, centerX, widthFraction, duration, watch) {
85
+ const roam = this.getRoam();
86
+ if (!roam) return;
87
+ const object = this.resolve(id);
88
+ if (!object) return;
89
+ const [w, h] = object.userData.size ?? [100, 100];
90
+ const yaw = object.rotation.y;
91
+ const n = new Vector3(Math.sin(yaw), 0, Math.cos(yaw));
92
+ const center = object.position;
93
+ const tanV = Math.tan(this.camera.fov * Math.PI / 180 / 2);
94
+ const tanH = tanV * this.camera.aspect;
95
+ const dWidth = w / (widthFraction * 2 * tanH * FIT_MARGIN);
96
+ const dHeight = h / (2 * tanV * FIT_MARGIN);
97
+ const distance = Math.max(dWidth, dHeight);
98
+ const tangent = new Vector3(n.z, 0, -n.x);
99
+ const lateral = -((centerX - .5) * 2) * distance * tanH;
100
+ const px = center.x + n.x * distance + tangent.x * lateral;
101
+ const pz = center.z + n.z * distance + tangent.z * lateral;
102
+ const toPos = new Vector3(px, this.groundHeight(px, pz) + roam.getEyeHeight(), pz);
103
+ const f = new Vector3(center.x + tangent.x * lateral, center.y, center.z + tangent.z * lateral).sub(toPos).normalize();
104
+ const toTheta = clamp(Math.asin(clamp(f.y, -1, 1)), -MAX_PITCH, MAX_PITCH);
105
+ const toPhi = Math.atan2(-f.x, -f.z);
106
+ const from = roam.getPose();
107
+ roam.setEnabled(false);
108
+ this.watch = null;
109
+ this.tween = {
110
+ id,
111
+ fromPos: from.position,
112
+ toPos,
113
+ fromPhi: from.phi,
114
+ dPhi: shortestAngle(toPhi - from.phi),
115
+ fromTheta: from.theta,
116
+ toTheta,
117
+ elapsed: 0,
118
+ duration,
119
+ watch
120
+ };
121
+ }
122
+ /** Advance any running tween, and watch for user takeover once landed. */
123
+ update(deltaMs) {
124
+ const roam = this.getRoam();
125
+ const tween = this.tween;
126
+ if (tween) {
127
+ tween.elapsed += deltaMs;
128
+ const t = clamp(tween.elapsed / tween.duration, 0, 1);
129
+ const k = easeInOut(t);
130
+ const phi = tween.fromPhi + tween.dPhi * k;
131
+ const theta = tween.fromTheta + (tween.toTheta - tween.fromTheta) * k;
132
+ const pos = tween.fromPos.clone().lerp(tween.toPos, k);
133
+ roam?.setPose(pos, phi, theta);
134
+ this.applyPose(pos, phi, theta);
135
+ if (t >= 1) {
136
+ roam?.setEnabled(true);
137
+ this.watch = tween.watch ? {
138
+ id: tween.id,
139
+ pos: tween.toPos.clone(),
140
+ phi
141
+ } : null;
142
+ this.tween = null;
143
+ }
144
+ return;
145
+ }
146
+ if (this.watch && roam) {
147
+ const pose = roam.getPose();
148
+ const moved = Math.hypot(pose.position.x - this.watch.pos.x, pose.position.z - this.watch.pos.z);
149
+ const turned = Math.abs(shortestAngle(pose.phi - this.watch.phi));
150
+ if (moved > TAKEOVER_DISTANCE || turned > TAKEOVER_YAW) {
151
+ const id = this.watch.id;
152
+ this.watch = null;
153
+ this.emit("artwork:released", { id });
154
+ }
155
+ }
156
+ }
157
+ /** Write a pose straight to the camera (used while we own it during a tween). */
158
+ applyPose(position, phi, theta) {
159
+ this.camera.position.copy(position);
160
+ const forward = new Vector3(-Math.cos(theta) * Math.sin(phi), Math.sin(theta), -Math.cos(theta) * Math.cos(phi));
161
+ this.camera.lookAt(position.clone().add(forward));
162
+ }
163
+ };
164
+ //#endregion
165
+ //#region src/plugin.ts
166
+ /**
167
+ * Camera framing — a *system* plugin. On selecting an artwork it animates the
168
+ * camera to frame the piece (offset beside an info panel), and centers it when
169
+ * the panel closes, handing control back to free-roam afterwards. Its api
170
+ * surfaces as `ctx.camera`. Cooperates with the roaming camera through the
171
+ * `roamControl` capability (suspends it during a tween); frames on the terrain
172
+ * via `terrain`.
173
+ */
174
+ const focus = () => ({
175
+ name: "focus",
176
+ setup: (ctx) => {
177
+ const { camera, capabilities, emit, resolve } = ctx;
178
+ const groundHeight = (x, z) => capabilities.get(TERRAIN)?.heightAt(x, z) ?? 0;
179
+ const controller = new CameraController(camera, () => capabilities.get(ROAM_CONTROL), resolve, emit, groundHeight);
180
+ return {
181
+ update: (deltaMs) => controller.update(deltaMs),
182
+ api: controller
183
+ };
184
+ }
185
+ });
186
+ //#endregion
187
+ export { CameraController, cubicBezier, easeInOut, focus };
188
+
189
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/easing.ts","../src/CameraController.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Sample a CSS-style cubic-bezier easing `(p1x, p1y, p2x, p2y)` with anchors at\n * (0,0) and (1,1). Returns a function mapping linear progress `x` in `[0,1]` to\n * eased progress `y`. Used to give camera moves a smooth ease-in-out curve.\n */\nexport function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number): (x: number) => number {\n const cx = 3 * p1x;\n const bx = 3 * (p2x - p1x) - cx;\n const ax = 1 - cx - bx;\n const cy = 3 * p1y;\n const by = 3 * (p2y - p1y) - cy;\n const ay = 1 - cy - by;\n\n const sampleX = (t: number): number => ((ax * t + bx) * t + cx) * t;\n const sampleY = (t: number): number => ((ay * t + by) * t + cy) * t;\n const slopeX = (t: number): number => (3 * ax * t + 2 * bx) * t + cx;\n\n // Solve x(t) = x for t via Newton-Raphson, then evaluate y(t).\n const solveT = (x: number): number => {\n let t = x;\n for (let i = 0; i < 8; i++) {\n const dx = sampleX(t) - x;\n if (Math.abs(dx) < 1e-5) return t;\n const d = slopeX(t);\n if (Math.abs(d) < 1e-6) break;\n t -= dx / d;\n }\n return t;\n };\n\n return (x: number): number => {\n if (x <= 0) return 0;\n if (x >= 1) return 1;\n return sampleY(solveT(x));\n };\n}\n\n/** Standard smooth ease-in-out, the default for camera framing moves. */\nexport const easeInOut = cubicBezier(0.42, 0, 0.58, 1);\n","import { Vector3, type Object3D, type PerspectiveCamera } from \"three\";\nimport type { CameraApi, FocusOptions, RoamControl } from \"@scenography/engine\";\n\nimport { easeInOut } from \"./easing\";\n\n/** Max up/down framing pitch (radians) — keeps the framed view from flipping. */\nconst MAX_PITCH = Math.PI / 3;\n\nconst TAU = Math.PI * 2;\nconst FIT_MARGIN = 0.85;\n// Thresholds past which the user is considered to have taken control back.\nconst TAKEOVER_DISTANCE = 20; // world units of XZ movement\nconst TAKEOVER_YAW = Math.PI / 2; // 90° of turn\n\nconst shortestAngle = (delta: number): number => ((((delta % TAU) + TAU + Math.PI) % TAU) - Math.PI);\nconst clamp = (x: number, a: number, b: number): number => Math.min(Math.max(x, a), b);\n\ninterface Tween {\n id: string;\n fromPos: Vector3;\n toPos: Vector3;\n fromPhi: number;\n dPhi: number;\n fromTheta: number;\n toTheta: number;\n elapsed: number;\n duration: number;\n /** Whether to watch for user takeover once the tween lands. */\n watch: boolean;\n}\n\n/**\n * Drives the camera for artwork framing: it computes a target pose that fits a\n * piece into a region of the screen, then tweens there with an ease-in-out\n * curve. The eye height is read live from the free-roam controls — only XZ\n * position and rotation change. While a tween runs, free-roam input is disabled;\n * when it lands, control returns to the user and (for `focusArtwork`) the\n * controller watches for the user moving/turning away, emitting `artwork:released`.\n */\nexport class CameraController implements CameraApi {\n private tween: Tween | null = null;\n private watch: { id: string; pos: Vector3; phi: number } | null = null;\n\n constructor(\n private readonly camera: PerspectiveCamera,\n /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */\n private readonly getRoam: () => RoamControl | undefined,\n private readonly resolve: (id: string) => Object3D | undefined,\n private readonly emit: (event: string, payload?: unknown) => void,\n /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */\n private readonly groundHeight: (x: number, z: number) => number,\n ) {}\n\n /** True while a framing tween is in progress (e.g. to suppress click-to-select). */\n isAnimating(): boolean {\n return this.tween !== null;\n }\n\n focusArtwork(id: string, options: FocusOptions = {}): void {\n this.start(id, options.centerX ?? 0.5, options.widthFraction ?? 1, options.duration ?? 900, true);\n }\n\n centerArtwork(id: string, options: FocusOptions = {}): void {\n this.start(id, 0.5, 1, options.duration ?? 900, false);\n }\n\n release(): void {\n this.watch = null;\n }\n\n private start(id: string, centerX: number, widthFraction: number, duration: number, watch: boolean): void {\n // No roaming camera to hand off from/to: nothing to frame.\n const roam = this.getRoam();\n if (!roam) return;\n const object = this.resolve(id);\n if (!object) return;\n const size = (object.userData.size as [number, number] | undefined) ?? [100, 100];\n const [w, h] = size;\n\n const yaw = object.rotation.y;\n const n = new Vector3(Math.sin(yaw), 0, Math.cos(yaw)); // outward normal\n const center = object.position;\n\n const tanV = Math.tan(((this.camera.fov * Math.PI) / 180) / 2);\n const tanH = tanV * this.camera.aspect;\n const dWidth = w / (widthFraction * 2 * tanH * FIT_MARGIN);\n const dHeight = h / (2 * tanV * FIT_MARGIN);\n const distance = Math.max(dWidth, dHeight);\n\n // Offset the camera *position* sideways (not its aim) so the view stays\n // square-on to the wall and the piece lands at `centerX` on screen. Moving\n // the look target instead would yaw the camera and leave it skewed.\n const tangent = new Vector3(n.z, 0, -n.x);\n const ndcX = (centerX - 0.5) * 2;\n const lateral = -ndcX * distance * tanH;\n // Eye stands a fixed height above the ground at the framing spot, so it\n // follows the (possibly uneven) terrain instead of a fixed world Y. The eye\n // height is read live from the controls: it may have been recalibrated\n // after the floor's heightmap loaded.\n const px = center.x + n.x * distance + tangent.x * lateral;\n const pz = center.z + n.z * distance + tangent.z * lateral;\n const toPos = new Vector3(px, this.groundHeight(px, pz) + roam.getEyeHeight(), pz);\n\n // Aim straight ahead horizontally (perpendicular to the wall), tilting only\n // to center the piece vertically since the eye height is locked.\n const target = new Vector3(center.x + tangent.x * lateral, center.y, center.z + tangent.z * lateral);\n const f = target.sub(toPos).normalize();\n const toTheta = clamp(Math.asin(clamp(f.y, -1, 1)), -MAX_PITCH, MAX_PITCH);\n const toPhi = Math.atan2(-f.x, -f.z);\n\n const from = roam.getPose();\n roam.setEnabled(false);\n this.watch = null;\n this.tween = {\n id,\n fromPos: from.position,\n toPos,\n fromPhi: from.phi,\n dPhi: shortestAngle(toPhi - from.phi),\n fromTheta: from.theta,\n toTheta,\n elapsed: 0,\n duration,\n watch,\n };\n }\n\n /** Advance any running tween, and watch for user takeover once landed. */\n update(deltaMs: number): void {\n const roam = this.getRoam();\n const tween = this.tween;\n if (tween) {\n tween.elapsed += deltaMs;\n const t = clamp(tween.elapsed / tween.duration, 0, 1);\n const k = easeInOut(t);\n const phi = tween.fromPhi + tween.dPhi * k;\n const theta = tween.fromTheta + (tween.toTheta - tween.fromTheta) * k;\n // Y interpolates between two terrain-relative heights (start → target);\n // don't pin it to a fixed eye height or uphill framing flies in flat.\n const pos = tween.fromPos.clone().lerp(tween.toPos, k);\n roam?.setPose(pos, phi, theta);\n this.applyPose(pos, phi, theta);\n\n if (t >= 1) {\n roam?.setEnabled(true);\n this.watch = tween.watch ? { id: tween.id, pos: tween.toPos.clone(), phi } : null;\n this.tween = null;\n }\n return;\n }\n\n if (this.watch && roam) {\n const pose = roam.getPose();\n const moved = Math.hypot(pose.position.x - this.watch.pos.x, pose.position.z - this.watch.pos.z);\n const turned = Math.abs(shortestAngle(pose.phi - this.watch.phi));\n if (moved > TAKEOVER_DISTANCE || turned > TAKEOVER_YAW) {\n const id = this.watch.id;\n this.watch = null;\n this.emit(\"artwork:released\", { id });\n }\n }\n }\n\n /** Write a pose straight to the camera (used while we own it during a tween). */\n private applyPose(position: Vector3, phi: number, theta: number): void {\n this.camera.position.copy(position);\n const forward = new Vector3(-Math.cos(theta) * Math.sin(phi), Math.sin(theta), -Math.cos(theta) * Math.cos(phi));\n this.camera.lookAt(position.clone().add(forward));\n }\n}\n","import {\n ROAM_CONTROL,\n TERRAIN,\n type CameraApi,\n type RoamControl,\n type SystemPlugin,\n type TerrainCapability,\n} from \"@scenography/engine\";\n\nimport { CameraController } from \"./CameraController\";\n\n/**\n * Camera framing — a *system* plugin. On selecting an artwork it animates the\n * camera to frame the piece (offset beside an info panel), and centers it when\n * the panel closes, handing control back to free-roam afterwards. Its api\n * surfaces as `ctx.camera`. Cooperates with the roaming camera through the\n * `roamControl` capability (suspends it during a tween); frames on the terrain\n * via `terrain`.\n */\nexport const focus = (): SystemPlugin<CameraApi> => ({\n name: \"focus\",\n setup: (ctx) => {\n const { camera, capabilities, emit, resolve } = ctx;\n const groundHeight = (x: number, z: number): number =>\n capabilities.get<TerrainCapability>(TERRAIN)?.heightAt(x, z) ?? 0;\n\n const controller = new CameraController(\n camera,\n () => capabilities.get<RoamControl>(ROAM_CONTROL),\n resolve,\n emit,\n groundHeight,\n );\n\n return {\n update: (deltaMs) => controller.update(deltaMs),\n api: controller,\n };\n },\n});\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@scenography/plugin-focus",
3
+ "version": "0.2.0",
4
+ "description": "Focus plugin for scenography: animates the camera to frame an artwork on selection and center it when its info panel closes. A system plugin exposing ctx.camera.",
5
+ "license": "MIT",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "type": "module",
10
+ "main": "./dist/index.cjs",
11
+ "module": "./dist/index.mjs",
12
+ "types": "./dist/index.d.cts",
13
+ "exports": {
14
+ ".": {
15
+ "import": {
16
+ "types": "./dist/index.d.mts",
17
+ "default": "./dist/index.mjs"
18
+ },
19
+ "require": {
20
+ "types": "./dist/index.d.cts",
21
+ "default": "./dist/index.cjs"
22
+ }
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@scenography/scene": "0.2.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/three": "^0.160.0",
30
+ "three": "^0.160.0",
31
+ "@scenography/engine": "0.2.0"
32
+ },
33
+ "peerDependencies": {
34
+ "three": ">=0.160.0",
35
+ "@scenography/engine": "0.2.0"
36
+ },
37
+ "scripts": {
38
+ "build": "tsdown",
39
+ "dev": "tsdown --watch",
40
+ "typecheck": "tsc --noEmit"
41
+ }
42
+ }