@pascal-app/viewer 1.1.2 → 1.1.4

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/README.md CHANGED
@@ -353,6 +353,7 @@ import { LiveWalkthroughController, useViewer, Viewer } from '@pascal-app/viewer
353
353
  import {
354
354
  ParametricViewerRooms,
355
355
  SceneLoader,
356
+ ViewerCameraControls,
356
357
  ViewerControlsBar,
357
358
  ViewerSceneHeader,
358
359
  ViewerSelectionManager,
@@ -367,6 +368,7 @@ function ProjectViewer({ ready, name }: { ready: boolean; name: string }) {
367
368
  <div className="relative h-full w-full">
368
369
  <ViewerStage>
369
370
  <Viewer selectionManager="custom">
371
+ <ViewerCameraControls />
370
372
  <LiveWalkthroughController />
371
373
  <ViewerSelectionManager />
372
374
  <ParametricViewerRooms />
@@ -399,6 +401,28 @@ mounted, zones draw no tinted volume: a room shows through its pill and highligh
399
401
  `LiveWalkthroughController` moves the camera. Read `useFirstPersonHud` for the HUD's room and
400
402
  interaction props, as in the live walkthrough example above.
401
403
 
404
+ ### Camera controls and opening poses
405
+
406
+ Mount `ViewerCameraControls` (`ViewerCameraControlsProps`) inside `Viewer` for right-drag orbit,
407
+ middle-drag or Space + left-drag pan, wheel dolly (orthographic zoom), touch controls and the
408
+ controls bar's frame, top-view and 90° orbit events. It synchronizes navigation with the floor
409
+ plan, opens live scenes on the site's saved camera and unmounts its controls during walkthrough.
410
+ Both optional props default to `false`: `glbActive` lets `GlbScene` frame the baked scene, and
411
+ `holdOpening` leaves the opening view to an explicit pose. Distance limits remain 5–100 metres.
412
+
413
+ `parseOpeningCamera(raw: string | null)` reads `px,py,pz,tx,ty,tz[,fov]` or returns `'saved'`;
414
+ invalid input returns `null`. Coordinates are bounded to ±1000 metres, position and target must
415
+ be at least 0.01 metres apart, and optional FOV is 10–120 degrees. `savedSceneCamera(nodes)`
416
+ resolves the site's saved camera, then the building's, returning `OpeningCameraPose | null`.
417
+ `OpeningCameraPose` has `position` and `target` tuples and an optional `fov`.
418
+
419
+ For an explicit pose, pass `holdOpening={true}` to the controls and mount `ViewerOpeningCamera`
420
+ (`ViewerOpeningCameraProps`) inside `Viewer` with `pose`, `ready` and `onApplied`. Once the scene
421
+ is ready and controls exist, it applies the pose on the next animation frame without a transition,
422
+ updates perspective FOV when supplied, then calls `onApplied`. It applies once per mount; keep
423
+ the loader visible until the callback fires. Resolve `'saved'` through `savedSceneCamera` before
424
+ passing a pose. These components and helpers all export from `@pascal-app/viewer/ui`.
425
+
402
426
  ## Asset CDN Helpers
403
427
 
404
428
  ```typescript
@@ -0,0 +1,7 @@
1
+ export type ViewerCameraControlsProps = {
2
+ glbActive?: boolean;
3
+ /** A `?camera=` pose owns the opening view. */
4
+ holdOpening?: boolean;
5
+ };
6
+ export declare const ViewerCameraControls: ({ glbActive, holdOpening, }?: ViewerCameraControlsProps) => import("react").JSX.Element | null;
7
+ //# sourceMappingURL=viewer-camera-controls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer-camera-controls.d.ts","sourceRoot":"","sources":["../../../src/ui/components/viewer-camera-controls.tsx"],"names":[],"mappings":"AAUA,MAAM,MAAM,yBAAyB,GAAG;IACtC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,OAAO,CAAA;CACtB,CAAA;AAED,eAAO,MAAM,oBAAoB,iCAG9B,yBAAyB,uCAyM3B,CAAA"}
@@ -0,0 +1,162 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { emitter, useScene } from '@pascal-app/core';
4
+ import { CameraControls, CameraControlsImpl } from '@react-three/drei';
5
+ import { useThree } from '@react-three/fiber';
6
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
7
+ import useViewer from '../../store/use-viewer';
8
+ import { frameViewerCamera } from '../lib/viewer-selection';
9
+ import { useViewerCameraNavigationSync } from './use-viewer-camera-navigation-sync';
10
+ export const ViewerCameraControls = ({ glbActive = false, holdOpening = false, } = {}) => {
11
+ const controls = useRef(null);
12
+ const site = useScene((s) => Object.values(s.nodes).find((node) => node.type === 'site'));
13
+ const cameraMode = useViewer((s) => s.cameraMode);
14
+ const walkthroughMode = useViewer((s) => s.walkthroughMode);
15
+ const firstLoad = useRef(true);
16
+ const opened = useRef(false);
17
+ const camera = useThree((state) => state.camera);
18
+ const publishNavigationPose = useViewerCameraNavigationSync(controls);
19
+ // Configure mouse buttons - same as editor
20
+ const mouseButtons = useMemo(() => {
21
+ const wheelAction = cameraMode === 'orthographic'
22
+ ? CameraControlsImpl.ACTION.ZOOM
23
+ : CameraControlsImpl.ACTION.DOLLY;
24
+ return {
25
+ left: CameraControlsImpl.ACTION.NONE,
26
+ middle: CameraControlsImpl.ACTION.SCREEN_PAN,
27
+ right: CameraControlsImpl.ACTION.ROTATE,
28
+ wheel: wheelAction,
29
+ };
30
+ }, [cameraMode]);
31
+ const touches = useMemo(() => ({
32
+ one: CameraControlsImpl.ACTION.TOUCH_ROTATE,
33
+ three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
34
+ two: cameraMode === 'orthographic'
35
+ ? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
36
+ : CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK,
37
+ }), [cameraMode]);
38
+ // The parametric scene opens on the site's saved view (the baked one frames
39
+ // itself in `GlbScene`). After that only the navigation moves the camera.
40
+ useEffect(() => {
41
+ if (!controls.current || glbActive)
42
+ return;
43
+ // A default pose first, unless an opening pose places the camera: the saved
44
+ // view then animates from the pose instead of snapping to the default first.
45
+ if (firstLoad.current) {
46
+ firstLoad.current = false;
47
+ if (!holdOpening)
48
+ controls.current.setLookAt(30, 30, 30, 0, 0, 0, false);
49
+ }
50
+ if (opened.current || !site)
51
+ return;
52
+ opened.current = true;
53
+ if (holdOpening || !site.camera)
54
+ return;
55
+ const { position, target } = site.camera;
56
+ requestAnimationFrame(() => {
57
+ controls.current?.setLookAt(position[0], position[1], position[2], target[0], target[1], target[2], true);
58
+ });
59
+ }, [site, glbActive, holdOpening]);
60
+ // The bar, the floor list and the sidebar frame what they select.
61
+ useEffect(() => {
62
+ const onFrame = (event) => {
63
+ const control = controls.current;
64
+ if (!control)
65
+ return;
66
+ frameViewerCamera(control, camera, event);
67
+ };
68
+ emitter.on('camera-controls:frame', onFrame);
69
+ return () => emitter.off('camera-controls:frame', onFrame);
70
+ }, [camera]);
71
+ useEffect(() => {
72
+ const handleTopView = () => {
73
+ if (!controls.current)
74
+ return;
75
+ const currentPolarAngle = controls.current.polarAngle;
76
+ // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
77
+ // Otherwise, go to top view (0°)
78
+ const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0;
79
+ controls.current.rotatePolarTo(targetAngle, true);
80
+ };
81
+ const handleOrbitCW = () => {
82
+ if (!controls.current)
83
+ return;
84
+ const currentAzimuth = controls.current.azimuthAngle;
85
+ const currentPolar = controls.current.polarAngle;
86
+ // Round to nearest 90° increment, then rotate 90° clockwise
87
+ const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2);
88
+ const target = rounded - Math.PI / 2;
89
+ controls.current.rotateTo(target, currentPolar, true);
90
+ };
91
+ const handleOrbitCCW = () => {
92
+ if (!controls.current)
93
+ return;
94
+ const currentAzimuth = controls.current.azimuthAngle;
95
+ const currentPolar = controls.current.polarAngle;
96
+ // Round to nearest 90° increment, then rotate 90° counter-clockwise
97
+ const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2);
98
+ const target = rounded + Math.PI / 2;
99
+ controls.current.rotateTo(target, currentPolar, true);
100
+ };
101
+ emitter.on('camera-controls:top-view', handleTopView);
102
+ emitter.on('camera-controls:orbit-cw', handleOrbitCW);
103
+ emitter.on('camera-controls:orbit-ccw', handleOrbitCCW);
104
+ return () => {
105
+ emitter.off('camera-controls:top-view', handleTopView);
106
+ emitter.off('camera-controls:orbit-cw', handleOrbitCW);
107
+ emitter.off('camera-controls:orbit-ccw', handleOrbitCCW);
108
+ };
109
+ }, []);
110
+ useEffect(() => {
111
+ const keyState = {
112
+ space: false,
113
+ };
114
+ const updateConfig = () => {
115
+ if (!controls.current)
116
+ return;
117
+ const wheelAction = cameraMode === 'orthographic'
118
+ ? CameraControlsImpl.ACTION.ZOOM
119
+ : CameraControlsImpl.ACTION.DOLLY;
120
+ controls.current.mouseButtons.wheel = wheelAction;
121
+ controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN;
122
+ controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE;
123
+ controls.current.mouseButtons.left = keyState.space
124
+ ? CameraControlsImpl.ACTION.SCREEN_PAN
125
+ : CameraControlsImpl.ACTION.NONE;
126
+ };
127
+ const onKeyDown = (event) => {
128
+ if (event.code !== 'Space')
129
+ return;
130
+ keyState.space = true;
131
+ document.body.style.cursor = 'grab';
132
+ updateConfig();
133
+ };
134
+ const onKeyUp = (event) => {
135
+ if (event.code !== 'Space')
136
+ return;
137
+ keyState.space = false;
138
+ document.body.style.cursor = '';
139
+ updateConfig();
140
+ };
141
+ document.addEventListener('keydown', onKeyDown);
142
+ document.addEventListener('keyup', onKeyUp);
143
+ updateConfig();
144
+ return () => {
145
+ document.removeEventListener('keydown', onKeyDown);
146
+ document.removeEventListener('keyup', onKeyUp);
147
+ if (document.body.style.cursor === 'grab') {
148
+ document.body.style.cursor = '';
149
+ }
150
+ };
151
+ }, [cameraMode]);
152
+ const onTransitionStart = useCallback(() => {
153
+ useViewer.getState().setCameraDragging(true);
154
+ }, []);
155
+ const onRest = useCallback(() => {
156
+ useViewer.getState().setCameraDragging(false);
157
+ }, []);
158
+ if (walkthroughMode) {
159
+ return null;
160
+ }
161
+ return (_jsx(CameraControls, { makeDefault: true, maxDistance: 100, maxPolarAngle: Math.PI / 2 - 0.1, minDistance: 5, minPolarAngle: 0, mouseButtons: mouseButtons, onRest: onRest, onSleep: onRest, onTransitionStart: onTransitionStart, onUpdate: publishNavigationPose, ref: controls, restThreshold: 0.01, touches: touches }));
162
+ };
@@ -0,0 +1,14 @@
1
+ import type { OpeningCameraPose } from '../lib/opening-camera';
2
+ export type ViewerOpeningCameraProps = {
3
+ onApplied: () => void;
4
+ pose: OpeningCameraPose;
5
+ ready: boolean;
6
+ };
7
+ /**
8
+ * Puts the camera on the opening pose once the scene is on screen. Both
9
+ * framing paths move the camera first (`ViewerCameraControls` to the site's
10
+ * bookmark, `GlbScene` to the building), so the pose lands a frame after
11
+ * readiness and without a transition: the last move is the one that sticks.
12
+ */
13
+ export declare function ViewerOpeningCamera({ onApplied, pose, ready }: ViewerOpeningCameraProps): null;
14
+ //# sourceMappingURL=viewer-opening-camera.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer-opening-camera.d.ts","sourceRoot":"","sources":["../../../src/ui/components/viewer-opening-camera.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AAc9D,MAAM,MAAM,wBAAwB,GAAG;IACrC,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,IAAI,EAAE,iBAAiB,CAAA;IACvB,KAAK,EAAE,OAAO,CAAA;CACf,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,wBAAwB,QAuBvF"}
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import { useThree } from '@react-three/fiber';
3
+ import { useEffect, useRef } from 'react';
4
+ /**
5
+ * Puts the camera on the opening pose once the scene is on screen. Both
6
+ * framing paths move the camera first (`ViewerCameraControls` to the site's
7
+ * bookmark, `GlbScene` to the building), so the pose lands a frame after
8
+ * readiness and without a transition: the last move is the one that sticks.
9
+ */
10
+ export function ViewerOpeningCamera({ onApplied, pose, ready }) {
11
+ const controls = useThree((state) => state.controls);
12
+ const camera = useThree((state) => state.camera);
13
+ const applied = useRef(false);
14
+ useEffect(() => {
15
+ if (applied.current || !(ready && controls))
16
+ return;
17
+ const frame = requestAnimationFrame(() => {
18
+ applied.current = true;
19
+ const perspective = camera;
20
+ if (pose.fov !== undefined && perspective.isPerspectiveCamera) {
21
+ perspective.fov = pose.fov;
22
+ perspective.updateProjectionMatrix();
23
+ }
24
+ const [px, py, pz] = pose.position;
25
+ const [tx, ty, tz] = pose.target;
26
+ controls.setLookAt(px, py, pz, tx, ty, tz, false);
27
+ onApplied();
28
+ });
29
+ return () => cancelAnimationFrame(frame);
30
+ }, [camera, controls, onApplied, pose, ready]);
31
+ return null;
32
+ }
@@ -11,8 +11,10 @@ export { ParametricViewerRooms, ViewerRooms } from './components/rooms/viewer-ro
11
11
  export { SceneLoader, SceneLoadFailed } from './components/scene-loader';
12
12
  export { openSkyPanel, SKY_DOCK_HEIGHT, SkyControl, useSkyPanel } from './components/sky-control';
13
13
  export { useViewerCameraNavigationSync } from './components/use-viewer-camera-navigation-sync';
14
+ export { ViewerCameraControls, type ViewerCameraControlsProps, } from './components/viewer-camera-controls';
14
15
  export { ViewerControlsBar, type ViewerControlsBarProps } from './components/viewer-controls-bar';
15
16
  export { ViewerHoverLabel, viewerHoverLabel } from './components/viewer-hover-label';
17
+ export { ViewerOpeningCamera, type ViewerOpeningCameraProps, } from './components/viewer-opening-camera';
16
18
  export { type ViewerLinkProps, ViewerSceneHeader, type ViewerSceneHeaderProps, } from './components/viewer-scene-header';
17
19
  export { ViewerSelectionManager } from './components/viewer-selection-manager';
18
20
  export { ViewerStage, type ViewerStageProps } from './components/viewer-stage';
@@ -30,6 +32,7 @@ export { belowGradeShown, followBelowGrade, followGroundWindowBox, gradeStanding
30
32
  export { type CameraPoseApplicationPlan, normalizeCameraPose, planCameraPoseApplication, publishInitialCameraPose, releaseCameraPoseEventSuppression, stepCameraPoseInterpolation, withCameraPoseDistance, } from './lib/camera-pose';
31
33
  export { authoredLevelName, localizedLevelName } from './lib/level-name';
32
34
  export { nodeDisplayLabel } from './lib/node-display-label';
35
+ export { type OpeningCameraPose, parseOpeningCamera, savedSceneCamera, } from './lib/opening-camera';
33
36
  export { formatRoomArea } from './lib/room-area';
34
37
  export { type RoomAssemblyHeights, resolveRoomAssemblyHeights } from './lib/room-assembly-overlay';
35
38
  export { type DescriptorTranslator, displayRoomName, editedRoomName, ROOM_NAME_MESSAGES, } from './lib/room-name-messages';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"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"}
package/dist/ui/index.js CHANGED
@@ -11,8 +11,10 @@ export { ParametricViewerRooms, ViewerRooms } from './components/rooms/viewer-ro
11
11
  export { SceneLoader, SceneLoadFailed } from './components/scene-loader';
12
12
  export { openSkyPanel, SKY_DOCK_HEIGHT, SkyControl, useSkyPanel } from './components/sky-control';
13
13
  export { useViewerCameraNavigationSync } from './components/use-viewer-camera-navigation-sync';
14
+ export { ViewerCameraControls, } from './components/viewer-camera-controls';
14
15
  export { ViewerControlsBar } from './components/viewer-controls-bar';
15
16
  export { ViewerHoverLabel, viewerHoverLabel } from './components/viewer-hover-label';
17
+ export { ViewerOpeningCamera, } from './components/viewer-opening-camera';
16
18
  export { ViewerSceneHeader, } from './components/viewer-scene-header';
17
19
  export { ViewerSelectionManager } from './components/viewer-selection-manager';
18
20
  export { ViewerStage } from './components/viewer-stage';
@@ -30,6 +32,7 @@ export { belowGradeShown, followBelowGrade, followGroundWindowBox, gradeStanding
30
32
  export { normalizeCameraPose, planCameraPoseApplication, publishInitialCameraPose, releaseCameraPoseEventSuppression, stepCameraPoseInterpolation, withCameraPoseDistance, } from './lib/camera-pose';
31
33
  export { authoredLevelName, localizedLevelName } from './lib/level-name';
32
34
  export { nodeDisplayLabel } from './lib/node-display-label';
35
+ export { parseOpeningCamera, savedSceneCamera, } from './lib/opening-camera';
33
36
  export { formatRoomArea } from './lib/room-area';
34
37
  export { resolveRoomAssemblyHeights } from './lib/room-assembly-overlay';
35
38
  export { displayRoomName, editedRoomName, ROOM_NAME_MESSAGES, } from './lib/room-name-messages';
@@ -0,0 +1,22 @@
1
+ import type { AnyNode } from '@pascal-app/core';
2
+ type Vec3 = [number, number, number];
3
+ export type OpeningCameraPose = {
4
+ position: Vec3;
5
+ target: Vec3;
6
+ fov?: number;
7
+ };
8
+ /**
9
+ * Reads `?camera=`: `px,py,pz,tx,ty,tz[,fov]` in scene metres and degrees, or
10
+ * `saved` for the view saved on the scene. Anything else is null, so a
11
+ * malformed link opens on the default view instead of a broken one.
12
+ */
13
+ export declare function parseOpeningCamera(raw: string | null): OpeningCameraPose | 'saved' | null;
14
+ /**
15
+ * The view saved on the scene: the camera captured on the site in the editor,
16
+ * else the one on the building. Both viewer paths already open near it by
17
+ * default, but they disagree on which node wins (the parametric viewer reads
18
+ * the site, the baked one the building); `camera=saved` pins one rule.
19
+ */
20
+ export declare function savedSceneCamera(nodes: Readonly<Record<string, AnyNode>>): OpeningCameraPose | null;
21
+ export {};
22
+ //# sourceMappingURL=opening-camera.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opening-camera.d.ts","sourceRoot":"","sources":["../../../src/ui/lib/opening-camera.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAE/C,KAAK,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;AAEpC,MAAM,MAAM,iBAAiB,GAAG;IAAE,QAAQ,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,IAAI,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAO9E;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,iBAAiB,GAAG,OAAO,GAAG,IAAI,CAqBzF;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACvC,iBAAiB,GAAG,IAAI,CAM1B"}
@@ -0,0 +1,42 @@
1
+ // The viewer camera's far plane is 1000 m; a pose beyond it frames nothing.
2
+ const MAX_COORDINATE = 1000;
3
+ const MIN_FOV = 10;
4
+ const MAX_FOV = 120;
5
+ /**
6
+ * Reads `?camera=`: `px,py,pz,tx,ty,tz[,fov]` in scene metres and degrees, or
7
+ * `saved` for the view saved on the scene. Anything else is null, so a
8
+ * malformed link opens on the default view instead of a broken one.
9
+ */
10
+ export function parseOpeningCamera(raw) {
11
+ if (raw === 'saved')
12
+ return 'saved';
13
+ const parts = raw?.split(',') ?? [];
14
+ if (parts.length !== 6 && parts.length !== 7)
15
+ return null;
16
+ if (parts.some((part) => part.trim() === ''))
17
+ return null;
18
+ const values = parts.map(Number);
19
+ const [px, py, pz, tx, ty, tz, fov] = values;
20
+ if (values.slice(0, 6).some((value) => !(Math.abs(value) <= MAX_COORDINATE)))
21
+ return null;
22
+ if (Math.hypot(px - tx, py - ty, pz - tz) < 0.01)
23
+ return null;
24
+ const pose = { position: [px, py, pz], target: [tx, ty, tz] };
25
+ if (fov === undefined)
26
+ return pose;
27
+ if (!(fov >= MIN_FOV && fov <= MAX_FOV))
28
+ return null;
29
+ return { ...pose, fov };
30
+ }
31
+ /**
32
+ * The view saved on the scene: the camera captured on the site in the editor,
33
+ * else the one on the building. Both viewer paths already open near it by
34
+ * default, but they disagree on which node wins (the parametric viewer reads
35
+ * the site, the baked one the building); `camera=saved` pins one rule.
36
+ */
37
+ export function savedSceneCamera(nodes) {
38
+ const all = Object.values(nodes);
39
+ const saved = all.find((node) => node.type === 'site')?.camera ??
40
+ all.find((node) => node.type === 'building')?.camera;
41
+ return saved ? { position: saved.position, target: saved.target } : null;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/viewer",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "3D viewer component for Pascal building editor",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  "prepublishOnly": "npm run build"
49
49
  },
50
50
  "peerDependencies": {
51
- "@pascal-app/core": "^1.1.2",
51
+ "@pascal-app/core": "^1.1.4",
52
52
  "@react-three/drei": "^10",
53
53
  "@react-three/fiber": "^9",
54
54
  "react": "^19",
@@ -72,7 +72,7 @@
72
72
  "zustand": "^5"
73
73
  },
74
74
  "devDependencies": {
75
- "@pascal-app/core": "^1.1.2",
75
+ "@pascal-app/core": "^1.1.4",
76
76
  "@pascal/typescript-config": "*",
77
77
  "@react-three/test-renderer": "^9.1.1",
78
78
  "@types/howler": "^2.2.13",