@ericchen1990/pano-view 2.0.0 → 2.1.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/README.md CHANGED
@@ -7,10 +7,13 @@ Try the components in the [live playground](https://pano-view-playground.vercel.
7
7
  ## krpano-compatible tile output
8
8
 
9
9
  Use this package when you want a React-native viewer around equirectangular
10
- images or an existing krpano cube-tile pyramid. `Tile` understands the common
11
- krpano-style multires directory layout by default, while keeping rendering,
12
- controls, hotspots, and scene transitions in your React application. It is
13
- compatible with that tile output format but is not affiliated with krpano.
10
+ images or an existing krpano cube-tile pyramid. Copy the krpano `<cube>` `url`
11
+ and `multires` attributes into `Tile`'s `urlTemplate` and `multires`, and copy
12
+ `<preview url>` into `previewUrl`; krpano's on-disk naming is not the same as
13
+ this package's defaults, so omitting them loads the wrong tiles or preview.
14
+ Rendering, controls, hotspots, and scene transitions stay in your React
15
+ application. This package is compatible with that tile output format but is
16
+ not affiliated with krpano.
14
17
 
15
18
  ## Install
16
19
 
@@ -120,7 +123,7 @@ export function ControlledExample() {
120
123
  style={{ height: 560 }}
121
124
  >
122
125
  <AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
123
- <Sphere src="/panoramas/room.webp" />
126
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
124
127
  </PanoViewer>
125
128
  </>
126
129
  );
@@ -151,6 +154,12 @@ The canvas cursor defaults to `grab`, becomes `grabbing` while dragging with the
151
154
 
152
155
  `Sphere` expects a 2:1 equirectangular image. Use `yawOffset` when the source's forward direction needs horizontal adjustment.
153
156
 
157
+ `previewUrl` is required and shows a low-resolution 2:1 image while `src` loads.
158
+ Relative paths resolve against the directory of `src`; root-absolute and
159
+ `http(s)` URLs are used as-is. For krpano sphere scenes, copy
160
+ [`<preview url>`](https://krpano.com/docu/xml/#preview) into `previewUrl`. That
161
+ preview must be equirectangular, not a cube strip.
162
+
154
163
  ```tsx
155
164
  "use client";
156
165
 
@@ -159,7 +168,10 @@ import { PanoViewer, Sphere } from "@ericchen1990/pano-view";
159
168
  export function SphereExample() {
160
169
  return (
161
170
  <PanoViewer style={{ width: "100%", height: 560 }}>
162
- <Sphere src="/panoramas/room.webp" />
171
+ <Sphere
172
+ src="/panoramas/room.webp"
173
+ previewUrl="preview.webp"
174
+ />
163
175
  </PanoViewer>
164
176
  );
165
177
  }
@@ -240,13 +252,13 @@ that are not same-origin should set `crossOrigin` (usually `"anonymous"`).
240
252
 
241
253
  ## Tile
242
254
 
243
- `Tile` renders six inward-facing cube faces and loads only tiles around the current view. The default layout matches krpano-style output:
255
+ `Tile` renders six inward-facing cube faces and loads only tiles around the current view. When `urlTemplate` is omitted, paths relative to `baseUrl` use this package default:
244
256
 
245
257
  ```text
246
- tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp
258
+ tiles/%s/l%l/%v/l%l_%s_%h_%v.webp
247
259
  ```
248
260
 
249
- Faces are `f`, `r`, `b`, `l`, `u`, and `d`; rows and columns are 1-based.
261
+ which expands to `tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp`. Faces are `f`, `r`, `b`, `l`, `u`, and `d`; rows and columns are 1-based.
250
262
 
251
263
  ```tsx
252
264
  import { PanoViewer, Tile } from "@ericchen1990/pano-view";
@@ -257,33 +269,81 @@ export function TileExample() {
257
269
  <Tile
258
270
  baseUrl="https://cdn.example.com/panoramas/room"
259
271
  multires="512,500,1000,2000"
272
+ previewUrl="previews/cube-vertical.webp"
260
273
  />
261
274
  </PanoViewer>
262
275
  );
263
276
  }
264
277
  ```
265
278
 
266
- The first `multires` value is the tile size. Remaining values are ascending cube-face sizes for `l1`, `l2`, and later levels. The default preview is `${baseUrl}/previews/cube-vertical.webp`, with six square faces stacked from top to bottom as `l/f/r/b/u/d`. Use `previewFaceOrder` when an atlas uses a different order:
279
+ krpano cube-tile output does not follow that default. Read `url`, `multires`,
280
+ and the preview path from the krpano XML
281
+ ([`<cube>`](https://krpano.com/docu/xml/#image.cube),
282
+ [`<preview>`](https://krpano.com/docu/xml/#preview)) and pass them through. If
283
+ those values are missing or do not match the files on disk, tiles resolve to
284
+ the wrong face, level, or row/column and the panorama looks scrambled.
285
+
286
+ | krpano | `Tile` / `TileScene` prop |
287
+ |---|---|
288
+ | `<cube url>` | `urlTemplate` |
289
+ | `<cube multires>` | `multires` |
290
+ | `<preview url>` | `previewUrl` |
291
+ | `<preview striporder>` | `previewFaceOrder` |
292
+
293
+ `Sphere` uses the same `<preview url>` → `previewUrl` mapping; that file must be a 2:1 equirectangular image, not a cube strip.
294
+
295
+ krpano's documented short syntax:
296
+
297
+ ```xml
298
+ <preview url="preview.jpg" />
299
+ <image>
300
+ <cube url="pano_%s_%l_%v_%h.jpg"
301
+ multires="512,1024,2048,4096"
302
+ />
303
+ </image>
304
+ ```
305
+
306
+ maps to:
307
+
308
+ ```tsx
309
+ <Tile
310
+ baseUrl="/panoramas/room"
311
+ urlTemplate="pano_%s_%l_%v_%h.jpg"
312
+ multires="512,1024,2048,4096"
313
+ previewUrl="preview.jpg"
314
+ />
315
+ ```
316
+
317
+ A MAKE PANO (MULTIRES) folder layout is often
318
+ `tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg` with `preview.jpg` at the scene root. That
319
+ filename is `%v_%h` (row then column). This package's default tile template is
320
+ `%h_%v` (column then row). Copy the XML values instead of assuming the two
321
+ conventions match.
322
+
323
+ The first `multires` value is the tile size. Remaining values are ascending cube-face sizes for `l1`, `l2`, and later levels. `previewUrl` is required. Relative paths resolve against `baseUrl`; root-absolute and `http(s)` URLs are used as-is. A typical krpano MAKE PANO (MULTIRES) scene looks like this:
267
324
 
268
325
  ```tsx
269
326
  <Tile
270
327
  baseUrl="/panoramas/room"
328
+ urlTemplate="tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg"
271
329
  multires="512,1000,2000"
272
- previewUrl="/panoramas/room/previews/cube-vertical.webp"
330
+ previewUrl="preview.jpg"
273
331
  previewFaceOrder={["l", "f", "r", "b", "u", "d"]}
274
332
  />
275
333
  ```
276
334
 
277
- `previewFaceOrder` must list the six face codes in their top-to-bottom atlas order: `f`, `r`, `b`, `l`, `u`, and `d`.
335
+ `previewFaceOrder` must list the six face codes in their top-to-bottom atlas order: `f`, `r`, `b`, `l`, `u`, and `d`. The preview image is a vertical 1×6 cubestrip. krpano `thumb.jpg` is a small thumbnail and is not this atlas.
278
336
 
279
337
  During rapid rotation or zoom, loaded tiles remain visible while newly visible tiles use their parent level or the preview as a local fallback.
280
338
 
281
- Override storage conventions with either a krpano placeholder template or a resolver. Both return paths relative to `baseUrl`:
339
+ For layouts that are not krpano XML, override the default with a placeholder
340
+ template or a resolver. Both return paths relative to `baseUrl`:
282
341
 
283
342
  ```tsx
284
343
  <Tile
285
344
  baseUrl="/panoramas/room"
286
345
  multires={{ tileSize: 512, levels: [500, 1000, 2000] }}
346
+ previewUrl="previews/cube-vertical.webp"
287
347
  urlTemplate="assets/%s/%l/%v_%h.webp"
288
348
  resolveTileUrl={({ face, level, row, col }) =>
289
349
  `api/tile/${face}/${level}/${row}/${col}`
@@ -304,7 +364,7 @@ a single non-stereo cube panorama.
304
364
  ## Scenes
305
365
 
306
366
  `Scenes` switches controlled sphere and cube-tile scenes with GPU-only
307
- snapshot blending. The target scene loads its sphere image or tile preview first;
367
+ snapshot blending. The target scene loads its sphere or tile preview first;
308
368
  once ready, the current framebuffer becomes a temporary GPU texture, its source
309
369
  textures are released, and the target scene blends in. This avoids holding two
310
370
  high-resolution tile scenes in WebGL memory at once.
@@ -317,12 +377,14 @@ import {
317
377
  } from "@ericchen1990/pano-view";
318
378
 
319
379
  const scenes: Scene[] = [
320
- { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp" },
380
+ { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp", previewUrl: "preview.webp" },
321
381
  {
322
382
  id: "terrace",
323
383
  type: "tile",
324
384
  baseUrl: "/panoramas/terrace",
325
385
  multires: "512,1000,2000",
386
+ urlTemplate: "tiles/%s/l%l/%v/l%l_%s_%v_%h.webp",
387
+ previewUrl: "previews/cube-vertical.webp",
326
388
  },
327
389
  ];
328
390
 
@@ -368,7 +430,7 @@ to leave the source unfiltered.
368
430
  ```tsx
369
431
  <PanoViewer style={{ height: 560 }}>
370
432
  <PanoFilter preset="pencil" intensity={0.85} />
371
- <Sphere src="/panoramas/room.webp" />
433
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
372
434
  <ImageHotspot
373
435
  id="door"
374
436
  position={{ yaw: 24, pitch: -6 }}
@@ -402,7 +464,7 @@ Auto-rotation is off by default. Render `AutoRotate` inside `PanoViewer` to enab
402
464
  ```tsx
403
465
  <PanoViewer style={{ height: 560 }}>
404
466
  <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
405
- <Sphere src="/panoramas/room.webp" />
467
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
406
468
  </PanoViewer>
407
469
  ```
408
470
 
@@ -451,7 +513,7 @@ export function GyroExample() {
451
513
  touchMode="full"
452
514
  onDenied={() => setEnabled(false)}
453
515
  />
454
- <Sphere src="/panoramas/room.webp" />
516
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
455
517
  </PanoViewer>
456
518
  </>
457
519
  );
@@ -508,7 +570,7 @@ export function WebVRExample() {
508
570
  mobileVr
509
571
  onEnterVR={(mode) => console.log("Entered", mode)}
510
572
  />
511
- <Sphere src="/panoramas/room.webp" />
573
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
512
574
  </PanoViewer>
513
575
  );
514
576
  }
@@ -551,7 +613,7 @@ interactive through the default WebXR controller rays.
551
613
  }}
552
614
  style={{ height: 560 }}
553
615
  >
554
- <Sphere src="/panoramas/room.webp" />
616
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
555
617
  </PanoViewer>
556
618
  ```
557
619
 
@@ -649,7 +711,7 @@ export function PlacementExample() {
649
711
  onPanoramaPointerMove={({ position }) => console.log("move", position)}
650
712
  style={{ height: 560 }}
651
713
  >
652
- <Sphere src="/panoramas/room.webp" />
714
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
653
715
  </PanoViewer>
654
716
  );
655
717
  }
@@ -690,7 +752,7 @@ import {
690
752
  onAutoRotateOneRound={() => console.log("full turn")}
691
753
  />
692
754
  <AutoRotate enabled />
693
- <Sphere src="/panoramas/room.webp" />
755
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
694
756
  </PanoViewer>;
695
757
  ```
696
758
 
@@ -723,7 +785,7 @@ separator between them.
723
785
 
724
786
  ```tsx
725
787
  <PanoViewer style={{ height: 560 }}>
726
- <Sphere src="/panoramas/room.webp" />
788
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
727
789
  </PanoViewer>
728
790
  ```
729
791
 
@@ -731,7 +793,7 @@ Disable the default menu (restore the browser menu, or mount your own):
731
793
 
732
794
  ```tsx
733
795
  <PanoViewer contextMenu={false}>
734
- <Sphere src="/panoramas/room.webp" />
796
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
735
797
  </PanoViewer>
736
798
  ```
737
799
 
@@ -739,7 +801,7 @@ Tune appearance while keeping the default items:
739
801
 
740
802
  ```tsx
741
803
  <PanoViewer contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
742
- <Sphere src="/panoramas/room.webp" />
804
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
743
805
  </PanoViewer>
744
806
  ```
745
807
 
@@ -766,7 +828,7 @@ only add your own:
766
828
  }}
767
829
  style={{ height: 560 }}
768
830
  >
769
- <Sphere src="/panoramas/room.webp" />
831
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
770
832
  </PanoViewer>
771
833
  ```
772
834
 
@@ -800,7 +862,7 @@ reimplementing fullscreen enter/exit state:
800
862
  }}
801
863
  style={{ height: 560 }}
802
864
  >
803
- <Sphere src="/panoramas/room.webp" />
865
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
804
866
  </PanoViewer>
805
867
  ```
806
868
 
@@ -931,7 +993,7 @@ are angular degrees, so they remain independent of the canvas resolution.
931
993
  import { ImageHotspot, PanoViewer, Sphere } from "@ericchen1990/pano-view";
932
994
 
933
995
  <PanoViewer style={{ height: 560 }}>
934
- <Sphere src="/panoramas/room.webp" />
996
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
935
997
  <ImageHotspot
936
998
  id="gallery"
937
999
  ariaLabel="Open gallery"
package/dist/index.cjs CHANGED
@@ -6364,6 +6364,27 @@ var import_fiber15 = require("@react-three/fiber");
6364
6364
  var import_react31 = require("react");
6365
6365
  var import_three9 = require("three");
6366
6366
 
6367
+ // src/asset-url.ts
6368
+ function isAbsoluteAssetUrl(url) {
6369
+ return url.startsWith("/") || url.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(url);
6370
+ }
6371
+ function resolveRelativeAssetUrl(baseUrl, relativePath) {
6372
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
6373
+ const normalizedPath = relativePath.replace(/^\/+/, "");
6374
+ return normalizedBaseUrl ? `${normalizedBaseUrl}/${normalizedPath}` : normalizedPath;
6375
+ }
6376
+ function resolveAssetUrl(baseUrl, path) {
6377
+ return isAbsoluteAssetUrl(path) ? path : resolveRelativeAssetUrl(baseUrl, path);
6378
+ }
6379
+ function resolveUrlAgainstFile(fileUrl, path) {
6380
+ if (isAbsoluteAssetUrl(path)) {
6381
+ return path;
6382
+ }
6383
+ const slash = fileUrl.lastIndexOf("/");
6384
+ const directory = slash === -1 ? "" : fileUrl.slice(0, slash);
6385
+ return resolveRelativeAssetUrl(directory, path);
6386
+ }
6387
+
6367
6388
  // src/pano-filter/pano-basic-material.tsx
6368
6389
  var import_react30 = require("react");
6369
6390
 
@@ -6580,25 +6601,22 @@ function PanoBasicMaterial({
6580
6601
 
6581
6602
  // src/sphere.tsx
6582
6603
  var import_jsx_runtime15 = require("react/jsx-runtime");
6583
- function Sphere({
6584
- src,
6585
- yawOffset = 0,
6586
- onLoad,
6587
- onError,
6588
- visible = true
6589
- }) {
6590
- const gl = (0, import_fiber15.useThree)((state) => state.gl);
6604
+ function useEquirectTexture(url, anisotropy, generateMipmaps, onLoad, onError) {
6591
6605
  const [texture, setTexture] = (0, import_react31.useState)(null);
6592
6606
  const onLoadRef = (0, import_react31.useRef)(onLoad);
6593
6607
  const onErrorRef = (0, import_react31.useRef)(onError);
6594
6608
  onLoadRef.current = onLoad;
6595
6609
  onErrorRef.current = onError;
6596
6610
  (0, import_react31.useEffect)(() => {
6611
+ if (!url) {
6612
+ setTexture(null);
6613
+ return;
6614
+ }
6597
6615
  let active = true;
6598
6616
  const loader = new import_three9.TextureLoader();
6599
6617
  setTexture(null);
6600
6618
  loader.load(
6601
- src,
6619
+ url,
6602
6620
  (loadedTexture) => {
6603
6621
  if (!active) {
6604
6622
  loadedTexture.dispose();
@@ -6608,10 +6626,10 @@ function Sphere({
6608
6626
  loadedTexture.wrapS = import_three9.RepeatWrapping;
6609
6627
  loadedTexture.repeat.x = -1;
6610
6628
  loadedTexture.offset.x = 1;
6611
- loadedTexture.anisotropy = gl.capabilities.getMaxAnisotropy();
6629
+ loadedTexture.anisotropy = anisotropy;
6612
6630
  loadedTexture.magFilter = import_three9.LinearFilter;
6613
- loadedTexture.minFilter = import_three9.LinearMipmapLinearFilter;
6614
- loadedTexture.generateMipmaps = true;
6631
+ loadedTexture.minFilter = generateMipmaps ? import_three9.LinearMipmapLinearFilter : import_three9.LinearFilter;
6632
+ loadedTexture.generateMipmaps = generateMipmaps;
6615
6633
  loadedTexture.needsUpdate = true;
6616
6634
  setTexture(loadedTexture);
6617
6635
  onLoadRef.current?.(loadedTexture);
@@ -6619,6 +6637,7 @@ function Sphere({
6619
6637
  void 0,
6620
6638
  (error) => {
6621
6639
  if (active) {
6640
+ setTexture(null);
6622
6641
  onErrorRef.current?.(error);
6623
6642
  }
6624
6643
  }
@@ -6626,14 +6645,58 @@ function Sphere({
6626
6645
  return () => {
6627
6646
  active = false;
6628
6647
  };
6629
- }, [gl, src]);
6648
+ }, [anisotropy, generateMipmaps, url]);
6630
6649
  (0, import_react31.useEffect)(
6631
6650
  () => () => {
6632
6651
  texture?.dispose();
6633
6652
  },
6634
6653
  [texture]
6635
6654
  );
6636
- if (!texture) {
6655
+ return texture;
6656
+ }
6657
+ function Sphere({
6658
+ src,
6659
+ previewUrl,
6660
+ yawOffset = 0,
6661
+ onReady,
6662
+ onLoad,
6663
+ onError,
6664
+ onPreviewError,
6665
+ visible = true
6666
+ }) {
6667
+ const gl = (0, import_fiber15.useThree)((state) => state.gl);
6668
+ const anisotropy = gl.capabilities.getMaxAnisotropy();
6669
+ const resolvedPreviewUrl = (0, import_react31.useMemo)(
6670
+ () => resolveUrlAgainstFile(src, previewUrl),
6671
+ [previewUrl, src]
6672
+ );
6673
+ const texture = useEquirectTexture(
6674
+ src,
6675
+ anisotropy,
6676
+ true,
6677
+ onLoad,
6678
+ onError
6679
+ );
6680
+ const previewTexture = useEquirectTexture(
6681
+ texture ? null : resolvedPreviewUrl,
6682
+ anisotropy,
6683
+ false,
6684
+ void 0,
6685
+ onPreviewError
6686
+ );
6687
+ const displayTexture = texture ?? previewTexture;
6688
+ const readyRef = (0, import_react31.useRef)(false);
6689
+ (0, import_react31.useEffect)(() => {
6690
+ readyRef.current = false;
6691
+ }, [resolvedPreviewUrl, src]);
6692
+ (0, import_react31.useEffect)(() => {
6693
+ if (!displayTexture || readyRef.current) {
6694
+ return;
6695
+ }
6696
+ readyRef.current = true;
6697
+ onReady?.();
6698
+ }, [displayTexture, onReady]);
6699
+ if (!displayTexture) {
6637
6700
  return null;
6638
6701
  }
6639
6702
  return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
@@ -6646,7 +6709,7 @@ function Sphere({
6646
6709
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
6647
6710
  PanoBasicMaterial,
6648
6711
  {
6649
- map: texture,
6712
+ map: displayTexture,
6650
6713
  side: import_three9.BackSide,
6651
6714
  toneMapped: false
6652
6715
  }
@@ -8436,13 +8499,12 @@ function parseMultires(multires) {
8436
8499
  );
8437
8500
  return { tileSize, levels };
8438
8501
  }
8502
+ var DEFAULT_TILE_URL_TEMPLATE = "tiles/%s/l%l/%v/l%l_%s_%h_%v.webp";
8439
8503
  function buildDefaultTileUrlTemplate() {
8440
- return "tiles/%s/l%l/%v/l%l_%s_%h_%v.webp";
8504
+ return DEFAULT_TILE_URL_TEMPLATE;
8441
8505
  }
8442
8506
  function resolveRelativeTileUrl(baseUrl, relativePath) {
8443
- const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
8444
- const normalizedPath = relativePath.replace(/^\/+/, "");
8445
- return normalizedBaseUrl ? `${normalizedBaseUrl}/${normalizedPath}` : normalizedPath;
8507
+ return resolveRelativeAssetUrl(baseUrl, relativePath);
8446
8508
  }
8447
8509
  function resolveTemplateUrl(template, address) {
8448
8510
  return template.replace(/%s/g, address.face).replace(/%l/g, String(address.level)).replace(/%(0*)([hxucvywr])/g, (_match, zeroes, placeholder) => {
@@ -8808,7 +8870,7 @@ function Tile({
8808
8870
  [levels, tileSize]
8809
8871
  );
8810
8872
  const anisotropy = gl.capabilities.getMaxAnisotropy();
8811
- const effectivePreviewUrl = previewUrl === void 0 ? `${baseUrl.replace(/\/$/, "")}/previews/cube-vertical.webp` : previewUrl;
8873
+ const effectivePreviewUrl = resolveAssetUrl(baseUrl, previewUrl);
8812
8874
  const previewTexture = usePreviewTexture(
8813
8875
  effectivePreviewUrl,
8814
8876
  anisotropy,
@@ -9006,9 +9068,10 @@ function SceneSource({
9006
9068
  Sphere,
9007
9069
  {
9008
9070
  src: scene.src,
9071
+ previewUrl: scene.previewUrl,
9009
9072
  yawOffset: scene.yawOffset,
9010
9073
  visible,
9011
- onLoad: onReady,
9074
+ onReady,
9012
9075
  onError
9013
9076
  }
9014
9077
  );