@scenography/plugin-focus 0.2.0 → 0.3.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 CHANGED
@@ -60,29 +60,54 @@ var CameraController = class {
60
60
  resolve;
61
61
  emit;
62
62
  groundHeight;
63
+ getStart;
63
64
  tween = null;
64
65
  watch = null;
65
- constructor(camera, getRoam, resolve, emit, groundHeight) {
66
+ constructor(camera, getRoam, resolve, emit, groundHeight, getStart) {
66
67
  this.camera = camera;
67
68
  this.getRoam = getRoam;
68
69
  this.resolve = resolve;
69
70
  this.emit = emit;
70
71
  this.groundHeight = groundHeight;
72
+ this.getStart = getStart;
71
73
  }
72
74
  /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
73
75
  isAnimating() {
74
76
  return this.tween !== null;
75
77
  }
76
78
  focusArtwork(id, options = {}) {
77
- this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true);
79
+ this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true, options.minDistance ?? 0);
78
80
  }
79
81
  centerArtwork(id, options = {}) {
80
- this.start(id, .5, 1, options.duration ?? 900, false);
82
+ this.start(id, .5, 1, options.duration ?? 900, false, options.minDistance ?? 0);
81
83
  }
82
84
  release() {
83
85
  this.watch = null;
84
86
  }
85
- start(id, centerX, widthFraction, duration, watch) {
87
+ /** Tween the camera home — back to the scene's start position and orientation. */
88
+ reset() {
89
+ const roam = this.getRoam();
90
+ if (!roam) return;
91
+ const { position, yaw } = this.getStart();
92
+ const [sx = 0, , sz = 0] = position;
93
+ const toPos = new three.Vector3(sx, this.groundHeight(sx, sz) + roam.getEyeHeight(), sz);
94
+ const from = roam.getPose();
95
+ roam.setEnabled(false);
96
+ this.watch = null;
97
+ this.tween = {
98
+ id: "",
99
+ fromPos: from.position,
100
+ toPos,
101
+ fromPhi: from.phi,
102
+ dPhi: shortestAngle(yaw - from.phi),
103
+ fromTheta: from.theta,
104
+ toTheta: 0,
105
+ elapsed: 0,
106
+ duration: 700,
107
+ watch: false
108
+ };
109
+ }
110
+ start(id, centerX, widthFraction, duration, watch, minDistance) {
86
111
  const roam = this.getRoam();
87
112
  if (!roam) return;
88
113
  const object = this.resolve(id);
@@ -95,7 +120,7 @@ var CameraController = class {
95
120
  const tanH = tanV * this.camera.aspect;
96
121
  const dWidth = w / (widthFraction * 2 * tanH * FIT_MARGIN);
97
122
  const dHeight = h / (2 * tanV * FIT_MARGIN);
98
- const distance = Math.max(dWidth, dHeight);
123
+ const distance = Math.max(dWidth, dHeight, minDistance);
99
124
  const tangent = new three.Vector3(n.z, 0, -n.x);
100
125
  const lateral = -((centerX - .5) * 2) * distance * tanH;
101
126
  const px = center.x + n.x * distance + tangent.x * lateral;
@@ -177,9 +202,16 @@ const focus = () => ({
177
202
  setup: (ctx) => {
178
203
  const { camera, capabilities, emit, resolve } = ctx;
179
204
  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);
205
+ let start = ctx.scene.camera.start;
206
+ const controller = new CameraController(camera, () => capabilities.get(_scenography_engine.ROAM_CONTROL), resolve, emit, groundHeight, () => ({
207
+ position: start.position,
208
+ yaw: start.yaw ?? 0
209
+ }));
181
210
  return {
182
211
  update: (deltaMs) => controller.update(deltaMs),
212
+ setScene: (s) => {
213
+ start = s.camera.start;
214
+ },
183
215
  api: controller
184
216
  };
185
217
  }
@@ -1 +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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"}
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 /** The scene's start pose, read live (it can change on a scene hot-swap). */\n private readonly getStart: () => { position: number[]; yaw: 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, options.minDistance ?? 0);\n }\n\n centerArtwork(id: string, options: FocusOptions = {}): void {\n this.start(id, 0.5, 1, options.duration ?? 900, false, options.minDistance ?? 0);\n }\n\n release(): void {\n this.watch = null;\n }\n\n /** Tween the camera home — back to the scene's start position and orientation. */\n reset(): void {\n const roam = this.getRoam();\n if (!roam) return;\n const { position, yaw } = this.getStart();\n const [sx = 0, , sz = 0] = position;\n // Land on the terrain at the start XZ (its authored Y may predate the floor's\n // heightmap); roam re-derives Y afterwards anyway.\n const toPos = new Vector3(sx, this.groundHeight(sx, sz) + roam.getEyeHeight(), sz);\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(yaw - from.phi),\n fromTheta: from.theta,\n toTheta: 0,\n elapsed: 0,\n duration: 700,\n watch: false,\n };\n }\n\n private start(\n id: string,\n centerX: number,\n widthFraction: number,\n duration: number,\n watch: boolean,\n minDistance: number,\n ): 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 // Fit the piece, but never closer than `minDistance` (e.g. so closing the\n // panel on a small piece doesn't leave the visitor pinned to the wall).\n const distance = Math.max(dWidth, dHeight, minDistance);\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 // The start pose backs `reset()`; keep it live so a scene hot-swap moves home.\n let start = ctx.scene.camera.start;\n const controller = new CameraController(\n camera,\n () => capabilities.get<RoamControl>(ROAM_CONTROL),\n resolve,\n emit,\n groundHeight,\n () => ({ position: start.position, yaw: start.yaw ?? 0 }),\n );\n\n return {\n update: (deltaMs) => controller.update(deltaMs),\n setScene: (s) => {\n start = s.camera.start;\n },\n api: controller,\n };\n },\n});\n"],"mappings":"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"}
package/dist/index.d.cts CHANGED
@@ -29,18 +29,27 @@ declare class CameraController implements CameraApi {
29
29
  private readonly emit;
30
30
  /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */
31
31
  private readonly groundHeight;
32
+ /** The scene's start pose, read live (it can change on a scene hot-swap). */
33
+ private readonly getStart;
32
34
  private tween;
33
35
  private watch;
34
36
  constructor(camera: PerspectiveCamera, /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */
35
37
 
36
38
  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
39
 
38
- groundHeight: (x: number, z: number) => number);
40
+ groundHeight: (x: number, z: number) => number, /** The scene's start pose, read live (it can change on a scene hot-swap). */
41
+
42
+ getStart: () => {
43
+ position: number[];
44
+ yaw: number;
45
+ });
39
46
  /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
40
47
  isAnimating(): boolean;
41
48
  focusArtwork(id: string, options?: FocusOptions): void;
42
49
  centerArtwork(id: string, options?: FocusOptions): void;
43
50
  release(): void;
51
+ /** Tween the camera home — back to the scene's start position and orientation. */
52
+ reset(): void;
44
53
  private start;
45
54
  /** Advance any running tween, and watch for user takeover once landed. */
46
55
  update(deltaMs: number): void;
package/dist/index.d.mts CHANGED
@@ -29,18 +29,27 @@ declare class CameraController implements CameraApi {
29
29
  private readonly emit;
30
30
  /** Ground height at `(x, z)` — so framing sits on the terrain, not a fixed Y. */
31
31
  private readonly groundHeight;
32
+ /** The scene's start pose, read live (it can change on a scene hot-swap). */
33
+ private readonly getStart;
32
34
  private tween;
33
35
  private watch;
34
36
  constructor(camera: PerspectiveCamera, /** The roaming camera to suspend/resume while framing — from the `roamControl` capability. */
35
37
 
36
38
  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
39
 
38
- groundHeight: (x: number, z: number) => number);
40
+ groundHeight: (x: number, z: number) => number, /** The scene's start pose, read live (it can change on a scene hot-swap). */
41
+
42
+ getStart: () => {
43
+ position: number[];
44
+ yaw: number;
45
+ });
39
46
  /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
40
47
  isAnimating(): boolean;
41
48
  focusArtwork(id: string, options?: FocusOptions): void;
42
49
  centerArtwork(id: string, options?: FocusOptions): void;
43
50
  release(): void;
51
+ /** Tween the camera home — back to the scene's start position and orientation. */
52
+ reset(): void;
44
53
  private start;
45
54
  /** Advance any running tween, and watch for user takeover once landed. */
46
55
  update(deltaMs: number): void;
package/dist/index.mjs CHANGED
@@ -59,29 +59,54 @@ var CameraController = class {
59
59
  resolve;
60
60
  emit;
61
61
  groundHeight;
62
+ getStart;
62
63
  tween = null;
63
64
  watch = null;
64
- constructor(camera, getRoam, resolve, emit, groundHeight) {
65
+ constructor(camera, getRoam, resolve, emit, groundHeight, getStart) {
65
66
  this.camera = camera;
66
67
  this.getRoam = getRoam;
67
68
  this.resolve = resolve;
68
69
  this.emit = emit;
69
70
  this.groundHeight = groundHeight;
71
+ this.getStart = getStart;
70
72
  }
71
73
  /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
72
74
  isAnimating() {
73
75
  return this.tween !== null;
74
76
  }
75
77
  focusArtwork(id, options = {}) {
76
- this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true);
78
+ this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true, options.minDistance ?? 0);
77
79
  }
78
80
  centerArtwork(id, options = {}) {
79
- this.start(id, .5, 1, options.duration ?? 900, false);
81
+ this.start(id, .5, 1, options.duration ?? 900, false, options.minDistance ?? 0);
80
82
  }
81
83
  release() {
82
84
  this.watch = null;
83
85
  }
84
- start(id, centerX, widthFraction, duration, watch) {
86
+ /** Tween the camera home — back to the scene's start position and orientation. */
87
+ reset() {
88
+ const roam = this.getRoam();
89
+ if (!roam) return;
90
+ const { position, yaw } = this.getStart();
91
+ const [sx = 0, , sz = 0] = position;
92
+ const toPos = new Vector3(sx, this.groundHeight(sx, sz) + roam.getEyeHeight(), sz);
93
+ const from = roam.getPose();
94
+ roam.setEnabled(false);
95
+ this.watch = null;
96
+ this.tween = {
97
+ id: "",
98
+ fromPos: from.position,
99
+ toPos,
100
+ fromPhi: from.phi,
101
+ dPhi: shortestAngle(yaw - from.phi),
102
+ fromTheta: from.theta,
103
+ toTheta: 0,
104
+ elapsed: 0,
105
+ duration: 700,
106
+ watch: false
107
+ };
108
+ }
109
+ start(id, centerX, widthFraction, duration, watch, minDistance) {
85
110
  const roam = this.getRoam();
86
111
  if (!roam) return;
87
112
  const object = this.resolve(id);
@@ -94,7 +119,7 @@ var CameraController = class {
94
119
  const tanH = tanV * this.camera.aspect;
95
120
  const dWidth = w / (widthFraction * 2 * tanH * FIT_MARGIN);
96
121
  const dHeight = h / (2 * tanV * FIT_MARGIN);
97
- const distance = Math.max(dWidth, dHeight);
122
+ const distance = Math.max(dWidth, dHeight, minDistance);
98
123
  const tangent = new Vector3(n.z, 0, -n.x);
99
124
  const lateral = -((centerX - .5) * 2) * distance * tanH;
100
125
  const px = center.x + n.x * distance + tangent.x * lateral;
@@ -176,9 +201,16 @@ const focus = () => ({
176
201
  setup: (ctx) => {
177
202
  const { camera, capabilities, emit, resolve } = ctx;
178
203
  const groundHeight = (x, z) => capabilities.get(TERRAIN)?.heightAt(x, z) ?? 0;
179
- const controller = new CameraController(camera, () => capabilities.get(ROAM_CONTROL), resolve, emit, groundHeight);
204
+ let start = ctx.scene.camera.start;
205
+ const controller = new CameraController(camera, () => capabilities.get(ROAM_CONTROL), resolve, emit, groundHeight, () => ({
206
+ position: start.position,
207
+ yaw: start.yaw ?? 0
208
+ }));
180
209
  return {
181
210
  update: (deltaMs) => controller.update(deltaMs),
211
+ setScene: (s) => {
212
+ start = s.camera.start;
213
+ },
182
214
  api: controller
183
215
  };
184
216
  }
@@ -1 +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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"}
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 /** The scene's start pose, read live (it can change on a scene hot-swap). */\n private readonly getStart: () => { position: number[]; yaw: 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, options.minDistance ?? 0);\n }\n\n centerArtwork(id: string, options: FocusOptions = {}): void {\n this.start(id, 0.5, 1, options.duration ?? 900, false, options.minDistance ?? 0);\n }\n\n release(): void {\n this.watch = null;\n }\n\n /** Tween the camera home — back to the scene's start position and orientation. */\n reset(): void {\n const roam = this.getRoam();\n if (!roam) return;\n const { position, yaw } = this.getStart();\n const [sx = 0, , sz = 0] = position;\n // Land on the terrain at the start XZ (its authored Y may predate the floor's\n // heightmap); roam re-derives Y afterwards anyway.\n const toPos = new Vector3(sx, this.groundHeight(sx, sz) + roam.getEyeHeight(), sz);\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(yaw - from.phi),\n fromTheta: from.theta,\n toTheta: 0,\n elapsed: 0,\n duration: 700,\n watch: false,\n };\n }\n\n private start(\n id: string,\n centerX: number,\n widthFraction: number,\n duration: number,\n watch: boolean,\n minDistance: number,\n ): 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 // Fit the piece, but never closer than `minDistance` (e.g. so closing the\n // panel on a small piece doesn't leave the visitor pinned to the wall).\n const distance = Math.max(dWidth, dHeight, minDistance);\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 // The start pose backs `reset()`; keep it live so a scene hot-swap moves home.\n let start = ctx.scene.camera.start;\n const controller = new CameraController(\n camera,\n () => capabilities.get<RoamControl>(ROAM_CONTROL),\n resolve,\n emit,\n groundHeight,\n () => ({ position: start.position, yaw: start.yaw ?? 0 }),\n );\n\n return {\n update: (deltaMs) => controller.update(deltaMs),\n setScene: (s) => {\n start = s.camera.start;\n },\n api: controller,\n };\n },\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenography/plugin-focus",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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
5
  "license": "MIT",
6
6
  "files": [
@@ -23,16 +23,16 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
- "@scenography/scene": "0.2.0"
26
+ "@scenography/scene": "0.3.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/three": "^0.160.0",
30
30
  "three": "^0.160.0",
31
- "@scenography/engine": "0.2.0"
31
+ "@scenography/engine": "0.3.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "three": ">=0.160.0",
35
- "@scenography/engine": "0.2.0"
35
+ "@scenography/engine": "0.3.0"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsdown",