@scenography/plugin-focus 0.3.0 → 0.3.1

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
@@ -75,6 +75,10 @@ var CameraController = class {
75
75
  isAnimating() {
76
76
  return this.tween !== null;
77
77
  }
78
+ /** True once a `focusArtwork` framing lands and the piece stays selected. */
79
+ isFocused() {
80
+ return this.watch !== null;
81
+ }
78
82
  focusArtwork(id, options = {}) {
79
83
  this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true, options.minDistance ?? 0);
80
84
  }
@@ -106,6 +110,7 @@ var CameraController = class {
106
110
  duration: 700,
107
111
  watch: false
108
112
  };
113
+ this.emit("camera:reset");
109
114
  }
110
115
  start(id, centerX, widthFraction, duration, watch, minDistance) {
111
116
  const roam = this.getRoam();
@@ -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 /** 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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"}
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 /** True once a `focusArtwork` framing lands and the piece stays selected. */\n isFocused(): boolean {\n return this.watch !== 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 // Going home ends any selection. Overlays keyed to a piece (the info panel)\n // close on this, whether or not a focus tween was still in flight.\n this.emit(\"camera:reset\");\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
@@ -45,6 +45,8 @@ declare class CameraController implements CameraApi {
45
45
  });
46
46
  /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
47
47
  isAnimating(): boolean;
48
+ /** True once a `focusArtwork` framing lands and the piece stays selected. */
49
+ isFocused(): boolean;
48
50
  focusArtwork(id: string, options?: FocusOptions): void;
49
51
  centerArtwork(id: string, options?: FocusOptions): void;
50
52
  release(): void;
package/dist/index.d.mts CHANGED
@@ -45,6 +45,8 @@ declare class CameraController implements CameraApi {
45
45
  });
46
46
  /** True while a framing tween is in progress (e.g. to suppress click-to-select). */
47
47
  isAnimating(): boolean;
48
+ /** True once a `focusArtwork` framing lands and the piece stays selected. */
49
+ isFocused(): boolean;
48
50
  focusArtwork(id: string, options?: FocusOptions): void;
49
51
  centerArtwork(id: string, options?: FocusOptions): void;
50
52
  release(): void;
package/dist/index.mjs CHANGED
@@ -74,6 +74,10 @@ var CameraController = class {
74
74
  isAnimating() {
75
75
  return this.tween !== null;
76
76
  }
77
+ /** True once a `focusArtwork` framing lands and the piece stays selected. */
78
+ isFocused() {
79
+ return this.watch !== null;
80
+ }
77
81
  focusArtwork(id, options = {}) {
78
82
  this.start(id, options.centerX ?? .5, options.widthFraction ?? 1, options.duration ?? 900, true, options.minDistance ?? 0);
79
83
  }
@@ -105,6 +109,7 @@ var CameraController = class {
105
109
  duration: 700,
106
110
  watch: false
107
111
  };
112
+ this.emit("camera:reset");
108
113
  }
109
114
  start(id, centerX, widthFraction, duration, watch, minDistance) {
110
115
  const roam = this.getRoam();
@@ -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 /** 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":";;;;;;;;AAKA,SAAgB,YAAY,KAAa,KAAa,KAAa,KAAoC;CACrG,MAAM,KAAK,IAAI;CACf,MAAM,KAAK,KAAK,MAAM,OAAO;CAC7B,MAAM,KAAK,IAAI,KAAK;CACpB,MAAM,KAAK,IAAI;CACf,MAAM,KAAK,KAAK,MAAM,OAAO;CAC7B,MAAM,KAAK,IAAI,KAAK;CAEpB,MAAM,WAAW,QAAwB,KAAK,IAAI,MAAM,IAAI,MAAM;CAClE,MAAM,WAAW,QAAwB,KAAK,IAAI,MAAM,IAAI,MAAM;CAClE,MAAM,UAAU,OAAuB,IAAI,KAAK,IAAI,IAAI,MAAM,IAAI;CAGlE,MAAM,UAAU,MAAsB;EACpC,IAAI,IAAI;EACR,KAAK,IAAI,IAAI,GAAG,IAAI,GAAG,KAAK;GAC1B,MAAM,KAAK,QAAQ,CAAC,IAAI;GACxB,IAAI,KAAK,IAAI,EAAE,IAAI,MAAM,OAAO;GAChC,MAAM,IAAI,OAAO,CAAC;GAClB,IAAI,KAAK,IAAI,CAAC,IAAI,MAAM;GACxB,KAAK,KAAK;EACZ;EACA,OAAO;CACT;CAEA,QAAQ,MAAsB;EAC5B,IAAI,KAAK,GAAG,OAAO;EACnB,IAAI,KAAK,GAAG,OAAO;EACnB,OAAO,QAAQ,OAAO,CAAC,CAAC;CAC1B;AACF;;AAGA,MAAa,YAAY,YAAY,KAAM,GAAG,KAAM,CAAC;;;;AChCrD,MAAM,YAAY,KAAK,KAAK;AAE5B,MAAM,MAAM,KAAK,KAAK;AACtB,MAAM,aAAa;AAEnB,MAAM,oBAAoB;AAC1B,MAAM,eAAe,KAAK,KAAK;AAE/B,MAAM,iBAAiB,WAA8B,QAAQ,MAAO,MAAM,KAAK,MAAM,MAAO,KAAK;AACjG,MAAM,SAAS,GAAW,GAAW,MAAsB,KAAK,IAAI,KAAK,IAAI,GAAG,CAAC,GAAG,CAAC;;;;;;;;;AAwBrF,IAAa,mBAAb,MAAmD;CAK9B;CAEA;CACA;CACA;CAEA;CAEA;CAZnB,QAA8B;CAC9B,QAAkE;CAElE,YACE,QAEA,SACA,SACA,MAEA,cAEA,UACA;EATiB,KAAA,SAAA;EAEA,KAAA,UAAA;EACA,KAAA,UAAA;EACA,KAAA,OAAA;EAEA,KAAA,eAAA;EAEA,KAAA,WAAA;CAChB;;CAGH,cAAuB;EACrB,OAAO,KAAK,UAAU;CACxB;CAEA,aAAa,IAAY,UAAwB,CAAC,GAAS;EACzD,KAAK,MAAM,IAAI,QAAQ,WAAW,IAAK,QAAQ,iBAAiB,GAAG,QAAQ,YAAY,KAAK,MAAM,QAAQ,eAAe,CAAC;CAC5H;CAEA,cAAc,IAAY,UAAwB,CAAC,GAAS;EAC1D,KAAK,MAAM,IAAI,IAAK,GAAG,QAAQ,YAAY,KAAK,OAAO,QAAQ,eAAe,CAAC;CACjF;CAEA,UAAgB;EACd,KAAK,QAAQ;CACf;;CAGA,QAAc;EACZ,MAAM,OAAO,KAAK,QAAQ;EAC1B,IAAI,CAAC,MAAM;EACX,MAAM,EAAE,UAAU,QAAQ,KAAK,SAAS;EACxC,MAAM,CAAC,KAAK,KAAK,KAAK,KAAK;EAG3B,MAAM,QAAQ,IAAI,QAAQ,IAAI,KAAK,aAAa,IAAI,EAAE,IAAI,KAAK,aAAa,GAAG,EAAE;EACjF,MAAM,OAAO,KAAK,QAAQ;EAC1B,KAAK,WAAW,KAAK;EACrB,KAAK,QAAQ;EACb,KAAK,QAAQ;GACX,IAAI;GACJ,SAAS,KAAK;GACd;GACA,SAAS,KAAK;GACd,MAAM,cAAc,MAAM,KAAK,GAAG;GAClC,WAAW,KAAK;GAChB,SAAS;GACT,SAAS;GACT,UAAU;GACV,OAAO;EACT;CACF;CAEA,MACE,IACA,SACA,eACA,UACA,OACA,aACM;EAEN,MAAM,OAAO,KAAK,QAAQ;EAC1B,IAAI,CAAC,MAAM;EACX,MAAM,SAAS,KAAK,QAAQ,EAAE;EAC9B,IAAI,CAAC,QAAQ;EAEb,MAAM,CAAC,GAAG,KADI,OAAO,SAAS,QAAyC,CAAC,KAAK,GAAG;EAGhF,MAAM,MAAM,OAAO,SAAS;EAC5B,MAAM,IAAI,IAAI,QAAQ,KAAK,IAAI,GAAG,GAAG,GAAG,KAAK,IAAI,GAAG,CAAC;EACrD,MAAM,SAAS,OAAO;EAEtB,MAAM,OAAO,KAAK,IAAM,KAAK,OAAO,MAAM,KAAK,KAAM,MAAO,CAAC;EAC7D,MAAM,OAAO,OAAO,KAAK,OAAO;EAChC,MAAM,SAAS,KAAK,gBAAgB,IAAI,OAAO;EAC/C,MAAM,UAAU,KAAK,IAAI,OAAO;EAGhC,MAAM,WAAW,KAAK,IAAI,QAAQ,SAAS,WAAW;EAKtD,MAAM,UAAU,IAAI,QAAQ,EAAE,GAAG,GAAG,CAAC,EAAE,CAAC;EAExC,MAAM,UAAU,GADF,UAAU,MAAO,KACP,WAAW;EAKnC,MAAM,KAAK,OAAO,IAAI,EAAE,IAAI,WAAW,QAAQ,IAAI;EACnD,MAAM,KAAK,OAAO,IAAI,EAAE,IAAI,WAAW,QAAQ,IAAI;EACnD,MAAM,QAAQ,IAAI,QAAQ,IAAI,KAAK,aAAa,IAAI,EAAE,IAAI,KAAK,aAAa,GAAG,EAAE;EAKjF,MAAM,IAAI,IADS,QAAQ,OAAO,IAAI,QAAQ,IAAI,SAAS,OAAO,GAAG,OAAO,IAAI,QAAQ,IAAI,OAC7E,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,UAAU;EACtC,MAAM,UAAU,MAAM,KAAK,KAAK,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,WAAW,SAAS;EACzE,MAAM,QAAQ,KAAK,MAAM,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;EAEnC,MAAM,OAAO,KAAK,QAAQ;EAC1B,KAAK,WAAW,KAAK;EACrB,KAAK,QAAQ;EACb,KAAK,QAAQ;GACX;GACA,SAAS,KAAK;GACd;GACA,SAAS,KAAK;GACd,MAAM,cAAc,QAAQ,KAAK,GAAG;GACpC,WAAW,KAAK;GAChB;GACA,SAAS;GACT;GACA;EACF;CACF;;CAGA,OAAO,SAAuB;EAC5B,MAAM,OAAO,KAAK,QAAQ;EAC1B,MAAM,QAAQ,KAAK;EACnB,IAAI,OAAO;GACT,MAAM,WAAW;GACjB,MAAM,IAAI,MAAM,MAAM,UAAU,MAAM,UAAU,GAAG,CAAC;GACpD,MAAM,IAAI,UAAU,CAAC;GACrB,MAAM,MAAM,MAAM,UAAU,MAAM,OAAO;GACzC,MAAM,QAAQ,MAAM,aAAa,MAAM,UAAU,MAAM,aAAa;GAGpE,MAAM,MAAM,MAAM,QAAQ,MAAM,CAAC,CAAC,KAAK,MAAM,OAAO,CAAC;GACrD,MAAM,QAAQ,KAAK,KAAK,KAAK;GAC7B,KAAK,UAAU,KAAK,KAAK,KAAK;GAE9B,IAAI,KAAK,GAAG;IACV,MAAM,WAAW,IAAI;IACrB,KAAK,QAAQ,MAAM,QAAQ;KAAE,IAAI,MAAM;KAAI,KAAK,MAAM,MAAM,MAAM;KAAG;IAAI,IAAI;IAC7E,KAAK,QAAQ;GACf;GACA;EACF;EAEA,IAAI,KAAK,SAAS,MAAM;GACtB,MAAM,OAAO,KAAK,QAAQ;GAC1B,MAAM,QAAQ,KAAK,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,IAAI,GAAG,KAAK,SAAS,IAAI,KAAK,MAAM,IAAI,CAAC;GAC/F,MAAM,SAAS,KAAK,IAAI,cAAc,KAAK,MAAM,KAAK,MAAM,GAAG,CAAC;GAChE,IAAI,QAAQ,qBAAqB,SAAS,cAAc;IACtD,MAAM,KAAK,KAAK,MAAM;IACtB,KAAK,QAAQ;IACb,KAAK,KAAK,oBAAoB,EAAE,GAAG,CAAC;GACtC;EACF;CACF;;CAGA,UAAkB,UAAmB,KAAa,OAAqB;EACrE,KAAK,OAAO,SAAS,KAAK,QAAQ;EAClC,MAAM,UAAU,IAAI,QAAQ,CAAC,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,GAAG,GAAG,KAAK,IAAI,KAAK,GAAG,CAAC,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,GAAG,CAAC;EAC/G,KAAK,OAAO,OAAO,SAAS,MAAM,CAAC,CAAC,IAAI,OAAO,CAAC;CAClD;AACF;;;;;;;;;;;AC3LA,MAAa,eAAwC;CACnD,MAAM;CACN,QAAQ,QAAQ;EACd,MAAM,EAAE,QAAQ,cAAc,MAAM,YAAY;EAChD,MAAM,gBAAgB,GAAW,MAC/B,aAAa,IAAuB,OAAO,CAAC,EAAE,SAAS,GAAG,CAAC,KAAK;EAGlE,IAAI,QAAQ,IAAI,MAAM,OAAO;EAC7B,MAAM,aAAa,IAAI,iBACrB,cACM,aAAa,IAAiB,YAAY,GAChD,SACA,MACA,qBACO;GAAE,UAAU,MAAM;GAAU,KAAK,MAAM,OAAO;EAAE,EACzD;EAEA,OAAO;GACL,SAAS,YAAY,WAAW,OAAO,OAAO;GAC9C,WAAW,MAAM;IACf,QAAQ,EAAE,OAAO;GACnB;GACA,KAAK;EACP;CACF;AACF"}
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 /** True once a `focusArtwork` framing lands and the piece stays selected. */\n isFocused(): boolean {\n return this.watch !== 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 // Going home ends any selection. Overlays keyed to a piece (the info panel)\n // close on this, whether or not a focus tween was still in flight.\n this.emit(\"camera:reset\");\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.3.0",
3
+ "version": "0.3.1",
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.3.0"
26
+ "@scenography/scene": "0.3.1"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/three": "^0.160.0",
30
30
  "three": "^0.160.0",
31
- "@scenography/engine": "0.3.0"
31
+ "@scenography/engine": "0.3.1"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "three": ">=0.160.0",
35
- "@scenography/engine": "0.3.0"
35
+ "@scenography/engine": "0.3.1"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsdown",