three-base 2.0.196 → 2.0.198

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.
@@ -38,12 +38,6 @@ const selectDepthTestTextureTarget = new WebGLRenderTarget(1024, 1024, {
38
38
  });
39
39
  const blur1TextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
40
40
  const blur2TextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
41
- // 外扩深度临时/结果目标:depthMinBlurShader 把本体深度图随描边一起外扩,
42
- // 外扩区取邻域最近深度(忽略背景 1.0),使外扩区 selD<1.0,depthTest 才能正确判遮挡(不漏光)。
43
- const selectDepthBlurTmpTextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
44
- const selectDepthBlurTextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
45
- // 外扩描边经深度测试后的结果(步骤 5.6):独立 target,避免回写 blur1 触发历史回归 bug
46
- const blurDepthTestTextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
47
41
  const size = new Vector2();
48
42
  const depthTestShader = new ShaderMaterial({
49
43
  uniforms: {
@@ -383,52 +377,6 @@ const strokeShader = new RawShaderMaterial({
383
377
  depthWrite: false,
384
378
  transparent: true
385
379
  });
386
- // 深度 min-blur(最近深度,忽略背景 1.0):随描边一起把深度图向外扩,
387
- // 外扩区 selD = 物体最近深度(<1.0) 而非清屏 1.0,depthTest 在外扩区才能正确判遮挡(不漏光),
388
- // 同时本体区仍靠 depthTestShader 的 EPSILON 容差防自遮挡闪烁(无缝)。separable(direction 控制 H/V)。
389
- const depthMinBlurShader = new ShaderMaterial({
390
- uniforms: {
391
- tDepth: { value: null },
392
- resolution: { value: new Vector2(1 / 512, 1 / 512) },
393
- radius: { value: 1.0 },
394
- direction: { value: new Vector2(1, 0) },
395
- },
396
- vertexShader: `
397
- ${ShaderChunk.common}
398
- ${ShaderChunk.logdepthbuf_pars_vertex}
399
- varying vec2 vUv;
400
- void main() {
401
- vUv = uv;
402
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
403
- ${ShaderChunk.logdepthbuf_vertex}
404
- }
405
- `,
406
- fragmentShader: `
407
- ${ShaderChunk.logdepthbuf_pars_fragment}
408
- uniform sampler2D tDepth;
409
- uniform vec2 resolution;
410
- uniform float radius;
411
- uniform vec2 direction;
412
- varying vec2 vUv;
413
- void main() {
414
- float minD = 1.0;
415
- for (int i = -10; i <= 10; i++) {
416
- float x = float(i);
417
- vec2 off = direction * (x * radius * resolution);
418
- float d = texture2D(tDepth, vUv + off).r;
419
- if (d < 0.9999) {
420
- minD = min(minD, d);
421
- }
422
- }
423
- gl_FragColor = vec4(minD, 0.0, 0.0, 1.0);
424
- ${ShaderChunk.logdepthbuf_fragment}
425
- }
426
- `,
427
- blending: NoBlending,
428
- depthTest: false,
429
- depthWrite: false,
430
- transparent: true,
431
- });
432
380
  const filterSelectShader = new RawShaderMaterial({
433
381
  uniforms: {
434
382
  selectTexture: { value: null },
@@ -508,9 +456,6 @@ export default function stroke_pass(renderer, camera, object3d, baseDepthTexture
508
456
  selectDepthTestTextureTarget.setSize(size.x, size.y);
509
457
  blur1TextureTarget.setSize(size.x, size.y);
510
458
  blur2TextureTarget.setSize(size.x, size.y);
511
- selectDepthBlurTmpTextureTarget.setSize(size.x, size.y);
512
- selectDepthBlurTextureTarget.setSize(size.x, size.y);
513
- blurDepthTestTextureTarget.setSize(size.x, size.y);
514
459
  // 1. 渲染物体本体 mask(灰度)。⭐ 本体深度由同一趟光栅化顺带写进 selectDepthTexture
515
460
  // (挂在 selectTextureTarget 的 depth attachment 上)—— 原先这里还有第 2 趟「只渲深度」,
516
461
  // 同材质/同相机/同几何 ⇒ 深度逐位相同,纯重复遍历,2026-10-11 皇上旨简化时删除。
@@ -532,7 +477,8 @@ export default function stroke_pass(renderer, camera, object3d, baseDepthTexture
532
477
  depthTestShader.uniforms.cameraFar.value = camera.far;
533
478
  depthTestShader.uniforms.uDebugVis.value = (window.__STROKE_DEPTH_VIS) ? 1.0 : 0.0;
534
479
  fsQuad.render(renderer, depthTestShader);
535
- // 4. 从「深度测试后的本体描边」开始外扩(外扩环自动继承本体遮挡判定 → 不漏光),
480
+ // 4. 从「深度测试后的本体描边」开始外扩(外扩的源头是本体判过的结果,被挡掉的本体不会再长出描边;
481
+ // 但外扩出来那圈像素本身不再判深度 ⇒ 贴着遮挡物时会压到遮挡物前面,见步骤 6 的代价点名),
536
482
  // 描边粗细由 stroke_width 控制的迭代次数决定。
537
483
  strokeShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
538
484
  FXAAShaderMaterial.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
@@ -557,42 +503,14 @@ export default function stroke_pass(renderer, camera, object3d, baseDepthTexture
557
503
  renderer.setRenderTarget(blur1TextureTarget);
558
504
  FXAAShaderMaterial.uniforms.tDiffuse.value = blur2TextureTarget.texture;
559
505
  fsQuad.render(renderer, FXAAShaderMaterial);
560
- // 5.5 ⭐ 外扩环深度测试(补齐此前只定义未调用的死代码):
561
- // 本体深度随描边一起外扩(separable H/V,外扩区取邻域最近深度),
562
- // 再用它对外扩后的描边做一次 depthTest。
563
- // 缺了这一步,外扩环(描边的视觉主体)永远不参与遮挡判定 →
564
- // 物体被挡住时描边依旧从遮挡物前方漏出来("深度测试看起来失效"的真根因之一)。
565
- depthMinBlurShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
566
- depthMinBlurShader.uniforms.radius.value = Math.max(1, stroke_width);
567
- // H:直接采 DepthTexture(selectDepthTexture),其 .r 即对数深度
568
- depthMinBlurShader.uniforms.direction.value.set(1, 0);
569
- depthMinBlurShader.uniforms.tDepth.value = selectDepthTexture;
570
- renderer.setRenderTarget(selectDepthBlurTmpTextureTarget);
571
- fsQuad.render(renderer, depthMinBlurShader);
572
- // V
573
- depthMinBlurShader.uniforms.direction.value.set(0, 1);
574
- depthMinBlurShader.uniforms.tDepth.value = selectDepthBlurTmpTextureTarget.texture;
575
- renderer.setRenderTarget(selectDepthBlurTextureTarget);
576
- fsQuad.render(renderer, depthMinBlurShader);
577
- // 调试:记录「外扩后的选区深度」,供面板「深度图(分步)」的步骤 5.5 档读取。
578
- // 它是 RGBA8 目标、对数深度存在 .r 里(与 DepthTexture 取 .r 的读法一致)。
579
- debugDepthState.lastSelectBlurDepth = selectDepthBlurTextureTarget.texture;
580
- // 5.6 对外扩后的描边判遮挡:被更近几何挡住的外扩环像素 discard。
581
- // ⚠️ 输出到独立 target,绝不回写 blur1(历史回归 bug:blur1 被 depthTest 结果覆盖
582
- // 会导致步骤 6 相减归零、描边全不显示)。
583
- renderer.setRenderTarget(blurDepthTestTextureTarget);
584
- renderer.clear();
585
- depthTestShader.uniforms.map.value = blur1TextureTarget.texture;
586
- depthTestShader.uniforms.baseDepth.value = baseDepthTexture;
587
- depthTestShader.uniforms.selectDepth.value = selectDepthBlurTextureTarget.texture;
588
- depthTestShader.uniforms.cameraFar.value = camera.far;
589
- depthTestShader.uniforms.uDebugVis.value = 0.0;
590
- fsQuad.render(renderer, depthTestShader);
591
- // 6. 合成:最终外扩描边(blurDepthTest,已判遮挡) 减去 深度测试后的本体描边
592
- // = 仅保留描边外扩环;本体被减掉。filterSelectShader 用 NormalBlending 叠到场景上。
506
+ // 6. 合成:FXAA 后的外扩描边(blur1) 减去 深度测试后的本体 = 仅保留描边外扩环;本体被减掉。
507
+ // ⚠️ 2026-11-04 决定回到「年初口径」:原先这里有 5.5(本体深度随描边一起 separable min-blur 外扩)
508
+ // 与 5.6(用外扩后的深度再判一次遮挡)两趟,作用是外扩环也参与遮挡判定 ⇒ 物体被挡住时描边不漏光。
509
+ // 撤掉换来的是每个描边对象每帧全屏绘制 10 趟降 7 趟(min-blur 那两趟是 ±10 共 21 次采样/像素,最贵);
510
+ // 代价点名:只有本体参与判遮挡,外扩环不参与 ⇒ 物体贴着遮挡物边缘时,一圈描边会压在遮挡物前面。
593
511
  renderer.setRenderTarget(originRenderTarget);
594
512
  filterSelectShader.uniforms.selectTexture.value = selectDepthTestTextureTarget.texture;
595
- filterSelectShader.uniforms.blurTexture.value = blurDepthTestTextureTarget.texture;
513
+ filterSelectShader.uniforms.blurTexture.value = blur1TextureTarget.texture;
596
514
  filterSelectShader.blending = NormalBlending;
597
515
  fsQuad.render(renderer, filterSelectShader);
598
516
  }
@@ -44,12 +44,6 @@ const selectDepthTestTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(10
44
44
  const blur1TextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
45
45
 
46
46
  const blur2TextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
47
- // 外扩深度临时/结果目标:depthMinBlurShader 把本体深度图随描边一起外扩,
48
- // 外扩区取邻域最近深度(忽略背景 1.0),使外扩区 selD<1.0,depthTest 才能正确判遮挡(不漏光)。
49
- const selectDepthBlurTmpTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
50
- const selectDepthBlurTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
51
- // 外扩描边经深度测试后的结果(步骤 5.6):独立 target,避免回写 blur1 触发历史回归 bug
52
- const blurDepthTestTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
53
47
 
54
48
  const size: Vector2 = new Vector2();
55
49
 
@@ -394,53 +388,6 @@ const strokeShader: RawShaderMaterial = new RawShaderMaterial({
394
388
  transparent: true
395
389
  });
396
390
 
397
- // 深度 min-blur(最近深度,忽略背景 1.0):随描边一起把深度图向外扩,
398
- // 外扩区 selD = 物体最近深度(<1.0) 而非清屏 1.0,depthTest 在外扩区才能正确判遮挡(不漏光),
399
- // 同时本体区仍靠 depthTestShader 的 EPSILON 容差防自遮挡闪烁(无缝)。separable(direction 控制 H/V)。
400
- const depthMinBlurShader: ShaderMaterial = new ShaderMaterial({
401
- uniforms: {
402
- tDepth: { value: null },
403
- resolution: { value: new Vector2(1 / 512, 1 / 512) },
404
- radius: { value: 1.0 },
405
- direction: { value: new Vector2(1, 0) },
406
- },
407
- vertexShader: `
408
- ${ShaderChunk.common}
409
- ${ShaderChunk.logdepthbuf_pars_vertex}
410
- varying vec2 vUv;
411
- void main() {
412
- vUv = uv;
413
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
414
- ${ShaderChunk.logdepthbuf_vertex}
415
- }
416
- `,
417
- fragmentShader: `
418
- ${ShaderChunk.logdepthbuf_pars_fragment}
419
- uniform sampler2D tDepth;
420
- uniform vec2 resolution;
421
- uniform float radius;
422
- uniform vec2 direction;
423
- varying vec2 vUv;
424
- void main() {
425
- float minD = 1.0;
426
- for (int i = -10; i <= 10; i++) {
427
- float x = float(i);
428
- vec2 off = direction * (x * radius * resolution);
429
- float d = texture2D(tDepth, vUv + off).r;
430
- if (d < 0.9999) {
431
- minD = min(minD, d);
432
- }
433
- }
434
- gl_FragColor = vec4(minD, 0.0, 0.0, 1.0);
435
- ${ShaderChunk.logdepthbuf_fragment}
436
- }
437
- `,
438
- blending: NoBlending,
439
- depthTest: false,
440
- depthWrite: false,
441
- transparent: true,
442
- });
443
-
444
391
  const filterSelectShader: RawShaderMaterial = new RawShaderMaterial({
445
392
  uniforms: {
446
393
  selectTexture: { value: null },
@@ -536,9 +483,6 @@ export default function stroke_pass(
536
483
  selectDepthTestTextureTarget.setSize(size.x, size.y);
537
484
  blur1TextureTarget.setSize(size.x, size.y);
538
485
  blur2TextureTarget.setSize(size.x, size.y);
539
- selectDepthBlurTmpTextureTarget.setSize(size.x, size.y);
540
- selectDepthBlurTextureTarget.setSize(size.x, size.y);
541
- blurDepthTestTextureTarget.setSize(size.x, size.y);
542
486
 
543
487
  // 1. 渲染物体本体 mask(灰度)。⭐ 本体深度由同一趟光栅化顺带写进 selectDepthTexture
544
488
  // (挂在 selectTextureTarget 的 depth attachment 上)—— 原先这里还有第 2 趟「只渲深度」,
@@ -563,7 +507,8 @@ export default function stroke_pass(
563
507
  depthTestShader.uniforms.uDebugVis.value = ((window as unknown as { __STROKE_DEPTH_VIS?: boolean }).__STROKE_DEPTH_VIS) ? 1.0 : 0.0;
564
508
  fsQuad.render(renderer, depthTestShader);
565
509
 
566
- // 4. 从「深度测试后的本体描边」开始外扩(外扩环自动继承本体遮挡判定 → 不漏光),
510
+ // 4. 从「深度测试后的本体描边」开始外扩(外扩的源头是本体判过的结果,被挡掉的本体不会再长出描边;
511
+ // 但外扩出来那圈像素本身不再判深度 ⇒ 贴着遮挡物时会压到遮挡物前面,见步骤 6 的代价点名),
567
512
  // 描边粗细由 stroke_width 控制的迭代次数决定。
568
513
  strokeShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
569
514
  FXAAShaderMaterial.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
@@ -590,45 +535,14 @@ export default function stroke_pass(
590
535
  FXAAShaderMaterial.uniforms.tDiffuse.value = blur2TextureTarget.texture;
591
536
  fsQuad.render(renderer, FXAAShaderMaterial);
592
537
 
593
- // 5.5 ⭐ 外扩环深度测试(补齐此前只定义未调用的死代码):
594
- // 本体深度随描边一起外扩(separable H/V,外扩区取邻域最近深度),
595
- // 再用它对外扩后的描边做一次 depthTest。
596
- // 缺了这一步,外扩环(描边的视觉主体)永远不参与遮挡判定 →
597
- // 物体被挡住时描边依旧从遮挡物前方漏出来("深度测试看起来失效"的真根因之一)。
598
- depthMinBlurShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
599
- depthMinBlurShader.uniforms.radius.value = Math.max(1, stroke_width);
600
- // H:直接采 DepthTexture(selectDepthTexture),其 .r 即对数深度
601
- depthMinBlurShader.uniforms.direction.value.set(1, 0);
602
- depthMinBlurShader.uniforms.tDepth.value = selectDepthTexture;
603
- renderer.setRenderTarget(selectDepthBlurTmpTextureTarget);
604
- fsQuad.render(renderer, depthMinBlurShader);
605
- // V
606
- depthMinBlurShader.uniforms.direction.value.set(0, 1);
607
- depthMinBlurShader.uniforms.tDepth.value = selectDepthBlurTmpTextureTarget.texture;
608
- renderer.setRenderTarget(selectDepthBlurTextureTarget);
609
- fsQuad.render(renderer, depthMinBlurShader);
610
-
611
- // 调试:记录「外扩后的选区深度」,供面板「深度图(分步)」的步骤 5.5 档读取。
612
- // 它是 RGBA8 目标、对数深度存在 .r 里(与 DepthTexture 取 .r 的读法一致)。
613
- debugDepthState.lastSelectBlurDepth = selectDepthBlurTextureTarget.texture;
614
-
615
- // 5.6 对外扩后的描边判遮挡:被更近几何挡住的外扩环像素 discard。
616
- // ⚠️ 输出到独立 target,绝不回写 blur1(历史回归 bug:blur1 被 depthTest 结果覆盖
617
- // 会导致步骤 6 相减归零、描边全不显示)。
618
- renderer.setRenderTarget(blurDepthTestTextureTarget);
619
- renderer.clear();
620
- depthTestShader.uniforms.map.value = blur1TextureTarget.texture;
621
- depthTestShader.uniforms.baseDepth.value = baseDepthTexture;
622
- depthTestShader.uniforms.selectDepth.value = selectDepthBlurTextureTarget.texture;
623
- depthTestShader.uniforms.cameraFar.value = (camera as PerspectiveCamera).far;
624
- depthTestShader.uniforms.uDebugVis.value = 0.0;
625
- fsQuad.render(renderer, depthTestShader);
626
-
627
- // 6. 合成:最终外扩描边(blurDepthTest,已判遮挡) 减去 深度测试后的本体描边
628
- // = 仅保留描边外扩环;本体被减掉。filterSelectShader 用 NormalBlending 叠到场景上。
538
+ // 6. 合成:FXAA 后的外扩描边(blur1) 减去 深度测试后的本体 = 仅保留描边外扩环;本体被减掉。
539
+ // ⚠️ 2026-11-04 决定回到「年初口径」:原先这里有 5.5(本体深度随描边一起 separable min-blur 外扩)
540
+ // 与 5.6(用外扩后的深度再判一次遮挡)两趟,作用是外扩环也参与遮挡判定 ⇒ 物体被挡住时描边不漏光。
541
+ // 撤掉换来的是每个描边对象每帧全屏绘制 10 趟降 7 趟(min-blur 那两趟是 ±10 共 21 次采样/像素,最贵);
542
+ // 代价点名:只有本体参与判遮挡,外扩环不参与 ⇒ 物体贴着遮挡物边缘时,一圈描边会压在遮挡物前面。
629
543
  renderer.setRenderTarget(originRenderTarget);
630
544
  filterSelectShader.uniforms.selectTexture.value = selectDepthTestTextureTarget.texture;
631
- filterSelectShader.uniforms.blurTexture.value = blurDepthTestTextureTarget.texture;
545
+ filterSelectShader.uniforms.blurTexture.value = blur1TextureTarget.texture;
632
546
  filterSelectShader.blending = NormalBlending;
633
547
  fsQuad.render(renderer, filterSelectShader);
634
548
  }
package/threeLoader.d.ts CHANGED
@@ -9,6 +9,20 @@ import { PLYLoader } from "three/examples/jsm/Addons";
9
9
  import type { ThreeLoaderEventMap } from "./EventMap";
10
10
  declare class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
11
11
  constructor();
12
+ /** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
13
+ private _failedUrls;
14
+ /** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
15
+ loadErrorCount: number;
16
+ /** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
17
+ * 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
18
+ private _urlStatus;
19
+ /** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
20
+ markUrlStatus(url: string, status: number): void;
21
+ /** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
22
+ reportLoadError(url: string, kind: "asset" | "model", label?: string): void;
23
+ /** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
24
+ * three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
25
+ private _noteErrStatus;
12
26
  getFilename(url: string): string;
13
27
  uuid: string;
14
28
  anisotropy: number;
package/threeLoader.js CHANGED
@@ -40,6 +40,12 @@ class ThreeLoader extends EventDispatcher {
40
40
  DefaultLoadingManager.onLoad = () => {
41
41
  this.onload = true;
42
42
  };
43
+ // 资源加载失败:走 manager 计数的每一条(全景整图/32 片切片、图片平面 4 片、GLB 下载与内嵌贴图)
44
+ // 都只从这里出,页面侧仍只订本类这一个出口(与 onProgress 同规矩,⛔ 页面不许自己占 manager 的槽位)。
45
+ // 同一地址去重:全景/平面那两路失败会每帧重发请求 ⇒ 不去重「失败数」会被刷成无穷大。
46
+ DefaultLoadingManager.onError = (url) => {
47
+ this.reportLoadError(url, "asset");
48
+ };
43
49
  // 资源替换
44
50
  DefaultLoadingManager.setURLModifier((url) => {
45
51
  //全路径匹配资源
@@ -58,6 +64,40 @@ class ThreeLoader extends EventDispatcher {
58
64
  return new_url;
59
65
  });
60
66
  }
67
+ /** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
68
+ _failedUrls = new Set();
69
+ /** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
70
+ loadErrorCount = 0;
71
+ /** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
72
+ * 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
73
+ _urlStatus = new Map();
74
+ /** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
75
+ markUrlStatus(url, status) {
76
+ if (url)
77
+ this._urlStatus.set(url, status);
78
+ }
79
+ /** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
80
+ reportLoadError(url, kind, label) {
81
+ const isNew = !this._failedUrls.has(url);
82
+ if (isNew) {
83
+ this._failedUrls.add(url);
84
+ this.loadErrorCount++;
85
+ }
86
+ const status = this._urlStatus.get(url);
87
+ // 403 与 404 并一档:OSS 私有桶里「key 不存在」回的正是 403(AccessDenied 用来不泄露对象是否存在)。
88
+ const missing = status === 404 || status === 403;
89
+ this.dispatchEvent({ type: "loadError", url, kind, label, status, missing, failed: this.loadErrorCount, isNew });
90
+ }
91
+ /** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
92
+ * three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
93
+ _noteErrStatus(url, err) {
94
+ if (!isString(url))
95
+ return;
96
+ const e = err;
97
+ const raw = e?.status ?? e?.response?.status ?? Number(/(?:responded with|HTTP) (\d{3})/.exec(e?.message ?? "")?.[1]);
98
+ if (Number.isFinite(raw))
99
+ this.markUrlStatus(url, raw);
100
+ }
61
101
  // 从url 中提取文件名
62
102
  getFilename(url) {
63
103
  // 移除URL的查询字符串和哈希部分
@@ -364,7 +404,8 @@ class ThreeLoader extends EventDispatcher {
364
404
  });
365
405
  return res;
366
406
  })
367
- .catch(() => {
407
+ .catch((err) => {
408
+ this._noteErrStatus(url, err);
368
409
  console.error(`${url},加载失败`);
369
410
  return undefined;
370
411
  });
@@ -381,6 +422,7 @@ class ThreeLoader extends EventDispatcher {
381
422
  return res.scene;
382
423
  })
383
424
  .catch((err) => {
425
+ this._noteErrStatus(url, err);
384
426
  // 打印详细错误信息,方便调试
385
427
  console.error(`${url},加载失败`, err);
386
428
  return undefined;
@@ -398,7 +440,8 @@ class ThreeLoader extends EventDispatcher {
398
440
  roughness: 0.5,
399
441
  }));
400
442
  })
401
- .catch(() => {
443
+ .catch((err) => {
444
+ this._noteErrStatus(url, err);
402
445
  console.error(`${url},加载失败`);
403
446
  return undefined;
404
447
  });
@@ -434,7 +477,8 @@ class ThreeLoader extends EventDispatcher {
434
477
  }
435
478
  return plyMesh;
436
479
  })
437
- .catch(() => {
480
+ .catch((err) => {
481
+ this._noteErrStatus(url, err);
438
482
  console.error(`${url},加载失败`);
439
483
  return undefined;
440
484
  });
@@ -462,6 +506,7 @@ class ThreeLoader extends EventDispatcher {
462
506
  return res;
463
507
  })
464
508
  .catch((err) => {
509
+ this._noteErrStatus(url, err);
465
510
  console.error(`${url},加载失败`, err);
466
511
  return undefined;
467
512
  });
package/threeLoader.ts CHANGED
@@ -44,6 +44,12 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
44
44
  DefaultLoadingManager.onLoad = () => {
45
45
  this.onload = true;
46
46
  };
47
+ // 资源加载失败:走 manager 计数的每一条(全景整图/32 片切片、图片平面 4 片、GLB 下载与内嵌贴图)
48
+ // 都只从这里出,页面侧仍只订本类这一个出口(与 onProgress 同规矩,⛔ 页面不许自己占 manager 的槽位)。
49
+ // 同一地址去重:全景/平面那两路失败会每帧重发请求 ⇒ 不去重「失败数」会被刷成无穷大。
50
+ DefaultLoadingManager.onError = (url: string) => {
51
+ this.reportLoadError(url, "asset");
52
+ };
47
53
  // 资源替换
48
54
  DefaultLoadingManager.setURLModifier((url: string) => {
49
55
  //全路径匹配资源
@@ -62,6 +68,37 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
62
68
  return new_url;
63
69
  });
64
70
  }
71
+ /** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
72
+ private _failedUrls: Set<string> = new Set();
73
+ /** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
74
+ loadErrorCount: number = 0;
75
+ /** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
76
+ * 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
77
+ private _urlStatus: Map<string, number> = new Map();
78
+ /** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
79
+ markUrlStatus(url: string, status: number): void {
80
+ if (url) this._urlStatus.set(url, status);
81
+ }
82
+ /** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
83
+ reportLoadError(url: string, kind: "asset" | "model", label?: string): void {
84
+ const isNew = !this._failedUrls.has(url);
85
+ if (isNew) {
86
+ this._failedUrls.add(url);
87
+ this.loadErrorCount++;
88
+ }
89
+ const status = this._urlStatus.get(url);
90
+ // 403 与 404 并一档:OSS 私有桶里「key 不存在」回的正是 403(AccessDenied 用来不泄露对象是否存在)。
91
+ const missing = status === 404 || status === 403;
92
+ this.dispatchEvent({ type: "loadError", url, kind, label, status, missing, failed: this.loadErrorCount, isNew });
93
+ }
94
+ /** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
95
+ * three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
96
+ private _noteErrStatus(url: string | File | Array<File>, err: unknown): void {
97
+ if (!isString(url)) return;
98
+ const e = err as { status?: number; response?: { status?: number }; message?: string };
99
+ const raw = e?.status ?? e?.response?.status ?? Number(/(?:responded with|HTTP) (\d{3})/.exec(e?.message ?? "")?.[1]);
100
+ if (Number.isFinite(raw)) this.markUrlStatus(url, raw);
101
+ }
65
102
  // 从url 中提取文件名
66
103
  getFilename(url: string): string {
67
104
  // 移除URL的查询字符串和哈希部分
@@ -370,7 +407,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
370
407
  });
371
408
  return res;
372
409
  })
373
- .catch(() => {
410
+ .catch((err) => {
411
+ this._noteErrStatus(url, err);
374
412
  console.error(`${url},加载失败`);
375
413
  return undefined;
376
414
  });
@@ -387,6 +425,7 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
387
425
  return res.scene;
388
426
  })
389
427
  .catch((err) => {
428
+ this._noteErrStatus(url, err);
390
429
  // 打印详细错误信息,方便调试
391
430
  console.error(`${url},加载失败`, err);
392
431
  return undefined;
@@ -404,7 +443,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
404
443
  roughness: 0.5,
405
444
  }));
406
445
  })
407
- .catch(() => {
446
+ .catch((err) => {
447
+ this._noteErrStatus(url, err);
408
448
  console.error(`${url},加载失败`);
409
449
  return undefined;
410
450
  });
@@ -440,7 +480,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
440
480
  }
441
481
  return plyMesh;
442
482
  })
443
- .catch(() => {
483
+ .catch((err) => {
484
+ this._noteErrStatus(url, err);
444
485
  console.error(`${url},加载失败`);
445
486
  return undefined;
446
487
  });
@@ -467,6 +508,7 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
467
508
  return res;
468
509
  })
469
510
  .catch((err) => {
511
+ this._noteErrStatus(url, err);
470
512
  console.error(`${url},加载失败`, err);
471
513
  return undefined;
472
514
  });