@forgeax/engine-render 0.1.6 → 0.1.7

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.
Files changed (151) hide show
  1. package/README.md +139 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/gpu-driven-indirect-raster-evidence.d.ts.map +1 -1
  4. package/dist/assembly/factory.d.ts +7 -0
  5. package/dist/assembly/factory.d.ts.map +1 -1
  6. package/dist/assembly/material/assembly.d.ts.map +1 -1
  7. package/dist/authoring.d.ts +1 -0
  8. package/dist/authoring.d.ts.map +1 -1
  9. package/dist/authoring.mjs +1 -1
  10. package/dist/{chunk-QC4VW3XJ.mjs → chunk-HCOGPHKS.mjs} +17 -3
  11. package/dist/chunk-HCOGPHKS.mjs.map +1 -0
  12. package/dist/chunk-YEJIUW7D.mjs +1152 -0
  13. package/dist/chunk-YEJIUW7D.mjs.map +1 -0
  14. package/dist/components/__tests__/points-lines-admission.unit.test.d.ts +2 -0
  15. package/dist/components/__tests__/points-lines-admission.unit.test.d.ts.map +1 -0
  16. package/dist/components/__tests__/points-lines-schema.unit.test.d.ts +2 -0
  17. package/dist/components/__tests__/points-lines-schema.unit.test.d.ts.map +1 -0
  18. package/dist/components/index.d.ts +2 -0
  19. package/dist/components/index.d.ts.map +1 -1
  20. package/dist/components/lines.d.ts +5 -0
  21. package/dist/components/lines.d.ts.map +1 -0
  22. package/dist/components/points.d.ts +15 -0
  23. package/dist/components/points.d.ts.map +1 -0
  24. package/dist/construct-renderer.mjs +952 -497
  25. package/dist/construct-renderer.mjs.map +1 -1
  26. package/dist/errors/render.d.ts +82 -1
  27. package/dist/errors/render.d.ts.map +1 -1
  28. package/dist/features/render-graph-raster.d.ts +5 -1
  29. package/dist/features/render-graph-raster.d.ts.map +1 -1
  30. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.mjs +4 -2
  34. package/dist/index.mjs.map +1 -1
  35. package/dist/inspection-types.d.ts +3 -0
  36. package/dist/inspection-types.d.ts.map +1 -1
  37. package/dist/pbr-pipeline.d.ts +2 -0
  38. package/dist/pbr-pipeline.d.ts.map +1 -1
  39. package/dist/plugin.d.ts.map +1 -1
  40. package/dist/points-lines/__tests__/bounds.unit.test.d.ts +2 -0
  41. package/dist/points-lines/__tests__/bounds.unit.test.d.ts.map +1 -0
  42. package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts +2 -0
  43. package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts.map +1 -0
  44. package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts +2 -0
  45. package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts.map +1 -0
  46. package/dist/points-lines/__tests__/inspection.unit.test.d.ts +2 -0
  47. package/dist/points-lines/__tests__/inspection.unit.test.d.ts.map +1 -0
  48. package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts +2 -0
  49. package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts.map +1 -0
  50. package/dist/points-lines/__tests__/points-lines.bench.d.ts +2 -0
  51. package/dist/points-lines/__tests__/points-lines.bench.d.ts.map +1 -0
  52. package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts +2 -0
  53. package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts.map +1 -0
  54. package/dist/points-lines/__tests__/snapshot.unit.test.d.ts +2 -0
  55. package/dist/points-lines/__tests__/snapshot.unit.test.d.ts.map +1 -0
  56. package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts +2 -0
  57. package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts.map +1 -0
  58. package/dist/points-lines/admission.d.ts +36 -0
  59. package/dist/points-lines/admission.d.ts.map +1 -0
  60. package/dist/points-lines/bounds.d.ts +8 -0
  61. package/dist/points-lines/bounds.d.ts.map +1 -0
  62. package/dist/points-lines/expansion-cache.d.ts +28 -0
  63. package/dist/points-lines/expansion-cache.d.ts.map +1 -0
  64. package/dist/points-lines/inspection.d.ts +53 -0
  65. package/dist/points-lines/inspection.d.ts.map +1 -0
  66. package/dist/points-lines/prepare.d.ts +67 -0
  67. package/dist/points-lines/prepare.d.ts.map +1 -0
  68. package/dist/points-lines/record.d.ts +60 -0
  69. package/dist/points-lines/record.d.ts.map +1 -0
  70. package/dist/points-lines/snapshot.d.ts +49 -0
  71. package/dist/points-lines/snapshot.d.ts.map +1 -0
  72. package/dist/record/frame-lighting.d.ts.map +1 -1
  73. package/dist/record/frame.d.ts +1 -1
  74. package/dist/record/frame.d.ts.map +1 -1
  75. package/dist/record/main-pass-geometry.d.ts +7 -0
  76. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  77. package/dist/record/main-pass.d.ts.map +1 -1
  78. package/dist/record/render-context.d.ts +17 -0
  79. package/dist/record/render-context.d.ts.map +1 -1
  80. package/dist/record/shadow-pass.d.ts.map +1 -1
  81. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  82. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  83. package/dist/record/view-ubo.d.ts +6 -0
  84. package/dist/record/view-ubo.d.ts.map +1 -1
  85. package/dist/render-contract.d.ts +10 -2
  86. package/dist/render-contract.d.ts.map +1 -1
  87. package/dist/render-system-extract.d.ts +3 -0
  88. package/dist/render-system-extract.d.ts.map +1 -1
  89. package/dist/render-system.d.ts +4 -1
  90. package/dist/render-system.d.ts.map +1 -1
  91. package/dist/scene/render-scene-types.d.ts +3 -0
  92. package/dist/scene/render-scene-types.d.ts.map +1 -1
  93. package/dist/scene/render-scene.d.ts +7 -0
  94. package/dist/scene/render-scene.d.ts.map +1 -1
  95. package/package.json +19 -19
  96. package/src/__tests__/authoring-path.unit.test.ts +9 -0
  97. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +12 -2
  98. package/src/__tests__/material-cooked-projection.unit.test.ts +29 -7
  99. package/src/__tests__/material-static-dynamic-route.unit.test.ts +28 -28
  100. package/src/__tests__/render-error-code-owner.test-d.ts +6 -0
  101. package/src/__tests__/render-error-exhaustive.test-d.ts +19 -0
  102. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +38 -0
  103. package/src/assembly/factory.ts +72 -3
  104. package/src/assembly/material/assembly.ts +2 -5
  105. package/src/authoring.ts +1 -0
  106. package/src/components/__tests__/points-lines-admission.unit.test.ts +219 -0
  107. package/src/components/__tests__/points-lines-schema.unit.test.ts +56 -0
  108. package/src/components/index.ts +7 -0
  109. package/src/components/lines.ts +18 -0
  110. package/src/components/points.ts +40 -0
  111. package/src/errors/render.ts +144 -1
  112. package/src/features/render-graph-raster.ts +22 -3
  113. package/src/gpu-driven/production-raster.ts +6 -1
  114. package/src/index.ts +17 -0
  115. package/src/inspection-types.ts +4 -0
  116. package/src/pbr-pipeline.ts +8 -1
  117. package/src/plugin.ts +4 -0
  118. package/src/points-lines/__tests__/bounds.unit.test.ts +21 -0
  119. package/src/points-lines/__tests__/cache-recovery.unit.test.ts +157 -0
  120. package/src/points-lines/__tests__/inspection-lanes.integration.test.ts +146 -0
  121. package/src/points-lines/__tests__/inspection.unit.test.ts +173 -0
  122. package/src/points-lines/__tests__/lane-structural.integration.test.ts +122 -0
  123. package/src/points-lines/__tests__/points-lines.bench.ts +400 -0
  124. package/src/points-lines/__tests__/raster-lane.integration.test.ts +44 -0
  125. package/src/points-lines/__tests__/snapshot.unit.test.ts +101 -0
  126. package/src/points-lines/__tests__/view-ubo.unit.test.ts +73 -0
  127. package/src/points-lines/admission.ts +238 -0
  128. package/src/points-lines/bounds.ts +23 -0
  129. package/src/points-lines/expansion-cache.ts +215 -0
  130. package/src/points-lines/inspection.ts +97 -0
  131. package/src/points-lines/prepare.ts +177 -0
  132. package/src/points-lines/record.ts +125 -0
  133. package/src/points-lines/snapshot.ts +130 -0
  134. package/src/record/frame-lighting.ts +14 -2
  135. package/src/record/frame.ts +12 -1
  136. package/src/record/main-pass-geometry.ts +92 -0
  137. package/src/record/main-pass-sprite-draws.ts +1 -1
  138. package/src/record/main-pass.ts +6 -3
  139. package/src/record/render-context.ts +20 -0
  140. package/src/record/shadow-pass.ts +16 -5
  141. package/src/record/skybox-post-pass.ts +2 -1
  142. package/src/record/typed-frame-graph.ts +17 -16
  143. package/src/record/view-ubo.ts +47 -0
  144. package/src/render-contract.ts +13 -2
  145. package/src/render-system-extract.ts +71 -8
  146. package/src/render-system.ts +304 -2
  147. package/src/scene/render-scene-types.ts +3 -0
  148. package/src/scene/render-scene.ts +57 -2
  149. package/dist/chunk-QC4VW3XJ.mjs.map +0 -1
  150. package/dist/chunk-SCRSAK5B.mjs +0 -480
  151. package/dist/chunk-SCRSAK5B.mjs.map +0 -1
@@ -27,12 +27,18 @@ import {
27
27
  type Handle,
28
28
  type MaterialAsset,
29
29
  type MaterialTextureValue,
30
+ type MeshAsset,
30
31
  type RenderPipelineAsset,
31
32
  RenderQueue,
32
33
  type SamplerAsset,
34
+ toShared,
33
35
  } from '@forgeax/engine-types';
34
36
  import { createClusterBinScratch } from './cluster-binner';
35
- import type { ObservationUnavailableError, RenderError } from './errors/render';
37
+ import {
38
+ type ObservationUnavailableError,
39
+ PointsLinesMaterialUnsupportedError,
40
+ type RenderError,
41
+ } from './errors/render';
36
42
  import {
37
43
  type RenderFeaturePreparedGraphicsResolverInput,
38
44
  runRenderFeatureFrame,
@@ -51,10 +57,30 @@ import {
51
57
  } from './fullscreen-post-process-pass';
52
58
  import { GpuDrivenProduction } from './gpu-driven/production-raster';
53
59
  import type { RenderSceneInspection } from './inspection-types';
60
+ import { admitPointsLines } from './points-lines/admission';
61
+ import { PointsLinesExpansionCache } from './points-lines/expansion-cache';
62
+ import {
63
+ inspectPointsLines,
64
+ type PointsLinesInspection,
65
+ type PointsLinesSourceError,
66
+ } from './points-lines/inspection';
67
+ import {
68
+ createPointsLinesLanePreparationAdapter,
69
+ type PointsLinesLanePreparationAdapter,
70
+ } from './points-lines/prepare';
71
+ import { createPointsLinesLaneAdapter, type PointsLinesBackend } from './points-lines/record';
72
+ import type { PointsLinesRetainedSnapshot } from './points-lines/snapshot';
73
+ import type { PointsLinesRecordOwner, PointsLinesRecordSubmission } from './record/render-context';
54
74
 
55
75
  export type { RenderSceneInspection } from './inspection-types';
56
76
 
57
- import { GPU_BUFFER_USAGE_COPY_DST, GPU_BUFFER_USAGE_UNIFORM } from './gpu-usage';
77
+ import { deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
78
+ import {
79
+ GPU_BUFFER_USAGE_COPY_DST,
80
+ GPU_BUFFER_USAGE_INDEX,
81
+ GPU_BUFFER_USAGE_UNIFORM,
82
+ GPU_BUFFER_USAGE_VERTEX,
83
+ } from './gpu-usage';
58
84
  import { assembleMaterialWithSkylightEntries } from './ibl/skylight-bind-group';
59
85
  import { disposeInstanceBuffers, disposeTransientInstanceBuffers } from './instance-buffer-cache';
60
86
  import type { MeshMaterialBindingObservation } from './mesh-material-bindings';
@@ -283,6 +309,8 @@ export interface RenderSystem {
283
309
  readonly visibilityStats: { explicitlyHidden: number };
284
310
  /** Persistent scene maintenance evidence from the ordinary single-World path. */
285
311
  readonly renderScene: RenderSceneInspection;
312
+ /** Retained Points/Lines authoring facts from the single scene projection. */
313
+ readonly pointsLinesSnapshots: readonly PointsLinesRetainedSnapshot[];
286
314
  /** Current mesh-slot provenance and active diagnostics from the last frame. */
287
315
  readonly meshMaterialBindings: readonly MeshMaterialBindingObservation[];
288
316
  /**
@@ -398,6 +426,273 @@ function makePreparedPipelinePendingError(): RhiError {
398
426
  });
399
427
  }
400
428
 
429
+ type PointsLinesGpuResources = {
430
+ readonly vertexBuffer: Buffer;
431
+ readonly indexBuffer: Buffer;
432
+ };
433
+ type PointsLinesPreparationAdapter = PointsLinesLanePreparationAdapter<PointsLinesGpuResources>;
434
+
435
+ /**
436
+ * The Standard renderer's single Points/Lines prepare owner.
437
+ *
438
+ * The retained snapshot is the identity boundary; this owner only resolves
439
+ * the source assets, runs admission, and publishes the existing preparation
440
+ * and record contracts to the main geometry loop. The expansion cache is
441
+ * shared across lanes and this owner keeps the one prepared vertex/index
442
+ * resource pair for each retained identity.
443
+ */
444
+ class StandardPointsLinesOwner implements PointsLinesRecordOwner {
445
+ private readonly cache = new PointsLinesExpansionCache();
446
+ private readonly preparations = new Map<string, PointsLinesPreparationAdapter>();
447
+ private readonly active = new Map<string, PointsLinesInspection>();
448
+ private readonly layoutProjection = deriveVertexLayoutProjection({
449
+ position: new Float32Array(0),
450
+ normal: new Float32Array(0),
451
+ uv: new Float32Array(0),
452
+ tangent: new Float32Array(0),
453
+ });
454
+
455
+ constructor(private readonly internals: RenderSystemInternals) {}
456
+
457
+ beginFrame(): void {
458
+ this.active.clear();
459
+ }
460
+
461
+ prepare(
462
+ entry: import('./record/frame-snapshot').ValidatedRenderable,
463
+ clustered: boolean,
464
+ ): PointsLinesRecordSubmission | undefined {
465
+ const snapshot = entry.source.pointsLines;
466
+ if (snapshot === undefined || snapshot.component === undefined || entry.world === undefined) {
467
+ return undefined;
468
+ }
469
+ const key = `${snapshot.worldId}:${snapshot.entityKey}`;
470
+ const topology = snapshot.component === 'Points' ? 'point-list' : 'line-list';
471
+ const backend = this.backend();
472
+ const lane = backend === 'wgpu-webgl2' ? 'cpu-webgl2' : clustered ? 'clustered' : 'direct';
473
+ const meshResult = resolveAssetHandle<MeshAsset>(
474
+ entry.world,
475
+ toShared<'MeshAsset'>(entry.source.assetHandle),
476
+ );
477
+ const materialResult = resolveAssetHandle<MaterialAsset>(
478
+ entry.world,
479
+ toShared<'MaterialAsset'>(snapshot.materialHandle),
480
+ );
481
+ if (!meshResult.ok || !materialResult.ok) {
482
+ this.publishRefusal(
483
+ snapshot,
484
+ new PointsLinesMaterialUnsupportedError({
485
+ entity: snapshot.entityKey,
486
+ material: 'unresolved',
487
+ pass: 'forward',
488
+ module: 'asset-resolution',
489
+ reason: 'source MeshAsset or MaterialAsset could not be resolved',
490
+ }),
491
+ );
492
+ return undefined;
493
+ }
494
+ const admission = admitPointsLines({
495
+ entity: snapshot.entityKey,
496
+ mesh: meshResult.value,
497
+ material: materialResult.value,
498
+ ...(snapshot.style?.kind === 'points'
499
+ ? {
500
+ points: {
501
+ sizePx: snapshot.style.sizePx,
502
+ shape: snapshot.style.shape === 'circle' ? 1 : 0,
503
+ },
504
+ }
505
+ : { lines: { widthPx: snapshot.style?.widthPx ?? 1 } }),
506
+ });
507
+ if (!admission.ok) {
508
+ this.publishRefusal(snapshot, admission.error);
509
+ return undefined;
510
+ }
511
+ const geometryKey = this.cache.getOrCreate(snapshot, meshResult.value).key;
512
+ const prepKey = `${geometryKey}:${lane}:${backend}`;
513
+ let preparation = this.preparations.get(prepKey);
514
+ if (preparation === undefined) {
515
+ preparation = createPointsLinesLanePreparationAdapter(lane, backend, {
516
+ cache: this.cache,
517
+ adapter: {
518
+ create: (geometry) => {
519
+ const vertex = this.internals.device.createBuffer({
520
+ label: `points-lines-vertices:${geometryKey}`,
521
+ size: Math.max(4, geometry.vertices.byteLength),
522
+ usage: GPU_BUFFER_USAGE_VERTEX | GPU_BUFFER_USAGE_COPY_DST,
523
+ mappedAtCreation: false,
524
+ });
525
+ if (!vertex.ok) return err(vertex.error);
526
+ const index = this.internals.device.createBuffer({
527
+ label: `points-lines-indices:${geometryKey}`,
528
+ size: Math.max(4, geometry.indices.byteLength),
529
+ usage: GPU_BUFFER_USAGE_INDEX | GPU_BUFFER_USAGE_COPY_DST,
530
+ mappedAtCreation: false,
531
+ });
532
+ if (!index.ok) {
533
+ this.internals.device.destroyBuffer(vertex.value);
534
+ return err(index.error);
535
+ }
536
+ return ok({ vertexBuffer: vertex.value, indexBuffer: index.value });
537
+ },
538
+ upload: (resource, geometry) => {
539
+ if (geometry.vertices.byteLength > 0) {
540
+ const vertexWrite = this.internals.device.queue.writeBuffer(
541
+ resource.vertexBuffer,
542
+ 0,
543
+ geometry.vertices,
544
+ );
545
+ if (!vertexWrite.ok) return err(vertexWrite.error);
546
+ }
547
+ if (geometry.indices.byteLength > 0) {
548
+ const indexWrite = this.internals.device.queue.writeBuffer(
549
+ resource.indexBuffer,
550
+ 0,
551
+ geometry.indices,
552
+ );
553
+ if (!indexWrite.ok) return err(indexWrite.error);
554
+ }
555
+ return ok(geometry.derivedBytes);
556
+ },
557
+ validate: (_resource, geometry) =>
558
+ geometry.expandedVertexCount > 0 && geometry.expandedIndexCount > 0
559
+ ? ok(undefined)
560
+ : err(new Error('Points/Lines expansion contains no drawable triangles')),
561
+ destroy: (resource) => {
562
+ this.internals.device.destroyBuffer(resource.vertexBuffer);
563
+ this.internals.device.destroyBuffer(resource.indexBuffer);
564
+ },
565
+ },
566
+ });
567
+ this.preparations.set(prepKey, preparation);
568
+ }
569
+ const prepared = preparation.prepare(snapshot, meshResult.value);
570
+ if (!prepared.ok) {
571
+ const lkg = preparation.lastKnownGood();
572
+ if (lkg !== undefined) {
573
+ const lkgPlan = createPointsLinesLaneAdapter(lane, backend).createRecordPlan(
574
+ lkg.snapshot,
575
+ lkg.geometry,
576
+ );
577
+ const state = preparation.inspect();
578
+ this.active.set(
579
+ key,
580
+ inspectPointsLines({
581
+ snapshot,
582
+ topology,
583
+ lane,
584
+ pointCount: lkg.geometry.pointCount,
585
+ segmentCount: lkg.geometry.segmentCount,
586
+ sourceBytes: lkg.geometry.sourceBytes,
587
+ derivedBytes: lkg.geometry.derivedBytes,
588
+ cache: { hit: true, rebuilds: state.rebuilds, evictions: 0 },
589
+ drawCount: lkgPlan.drawCount,
590
+ uploadBytes: 0,
591
+ lastKnownGood: true,
592
+ refusal: {
593
+ code: prepared.error.code,
594
+ expected: prepared.error.expected,
595
+ hint: prepared.error.hint,
596
+ detail: prepared.error.detail,
597
+ generation: prepared.error.detail.generation,
598
+ lastKnownGood: true,
599
+ },
600
+ }),
601
+ );
602
+ return {
603
+ plan: lkgPlan,
604
+ vertexBuffer: lkg.resource.vertexBuffer,
605
+ indexBuffer: lkg.resource.indexBuffer,
606
+ layoutProjection: this.layoutProjection,
607
+ };
608
+ }
609
+ this.publishRefusal(snapshot, prepared.error);
610
+ return undefined;
611
+ }
612
+ const plan = createPointsLinesLaneAdapter(lane, backend).createRecordPlan(
613
+ snapshot,
614
+ prepared.value.geometry,
615
+ );
616
+ const state = preparation.inspect();
617
+ this.active.set(
618
+ key,
619
+ inspectPointsLines({
620
+ snapshot,
621
+ topology,
622
+ lane,
623
+ pointCount: prepared.value.geometry.pointCount,
624
+ segmentCount: prepared.value.geometry.segmentCount,
625
+ sourceBytes: prepared.value.geometry.sourceBytes,
626
+ derivedBytes: prepared.value.geometry.derivedBytes,
627
+ cache: {
628
+ hit: prepared.value.uploadedBytes === 0,
629
+ rebuilds: state.rebuilds,
630
+ evictions: 0,
631
+ },
632
+ drawCount: plan.drawCount,
633
+ uploadBytes: prepared.value.uploadedBytes,
634
+ lastKnownGood: prepared.value.lastKnownGood,
635
+ }),
636
+ );
637
+ return {
638
+ plan,
639
+ vertexBuffer: prepared.value.resource.vertexBuffer,
640
+ indexBuffer: prepared.value.resource.indexBuffer,
641
+ layoutProjection: this.layoutProjection,
642
+ };
643
+ }
644
+
645
+ resetForDeviceLoss(): void {
646
+ for (const preparation of this.preparations.values()) preparation.resetForDeviceLoss();
647
+ this.active.clear();
648
+ }
649
+
650
+ inspections(): readonly PointsLinesInspection[] {
651
+ return [...this.active.values()];
652
+ }
653
+
654
+ private backend(): PointsLinesBackend {
655
+ switch (this.internals.device.caps.backendKind) {
656
+ case 'wgpu-webgl2':
657
+ return 'wgpu-webgl2';
658
+ case 'null':
659
+ return 'null';
660
+ default:
661
+ return 'webgpu';
662
+ }
663
+ }
664
+
665
+ private publishRefusal(
666
+ snapshot: PointsLinesRetainedSnapshot,
667
+ error: PointsLinesSourceError,
668
+ ): void {
669
+ this.active.set(
670
+ `${snapshot.worldId}:${snapshot.entityKey}`,
671
+ inspectPointsLines({
672
+ snapshot,
673
+ topology: snapshot.component === 'Points' ? 'point-list' : 'line-list',
674
+ lane: 'refused',
675
+ pointCount: 0,
676
+ segmentCount: 0,
677
+ sourceBytes: 0,
678
+ derivedBytes: 0,
679
+ cache: { hit: false, rebuilds: 0, evictions: 0 },
680
+ drawCount: 0,
681
+ uploadBytes: 0,
682
+ lastKnownGood: false,
683
+ refusal: {
684
+ code: error.code,
685
+ expected: error.expected,
686
+ hint: error.hint,
687
+ detail: error.detail,
688
+ generation: snapshot.meshGeneration,
689
+ lastKnownGood: false,
690
+ },
691
+ }),
692
+ );
693
+ }
694
+ }
695
+
401
696
  export function createRenderSystem(internals: RenderSystemInternals): RenderSystem {
402
697
  const phaseCatalogRegistration = internals.profiler?.registerPhaseCatalog(
403
698
  'render',
@@ -406,6 +701,7 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
406
701
  let releaseProfilerCatalog =
407
702
  phaseCatalogRegistration?.ok === true ? phaseCatalogRegistration.value : undefined;
408
703
  let preparedWorlds: readonly World[] = [];
704
+ const pointsLinesOwner = new StandardPointsLinesOwner(internals);
409
705
  const persistentRenderScene = new PersistentRenderScene({
410
706
  getDevice: () => internals.device,
411
707
  onGpuError: (error) => internals.errorRegistry.fire(error),
@@ -1071,6 +1367,9 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
1071
1367
  gpuDriven: gpuDrivenProduction.inspect(),
1072
1368
  };
1073
1369
  },
1370
+ get pointsLinesSnapshots(): readonly PointsLinesRetainedSnapshot[] {
1371
+ return persistentRenderScene.pointsLinesSnapshots();
1372
+ },
1074
1373
  detachScene(world: World): void {
1075
1374
  persistentRenderScene.detach(world);
1076
1375
  },
@@ -1091,6 +1390,7 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
1091
1390
  }
1092
1391
  }
1093
1392
  try {
1393
+ pointsLinesOwner.beginFrame();
1094
1394
  // cameraOwner drives the surfaced cameras + frustum
1095
1395
  // cull; resourceOwner drives skylight/skybox/postProcess + per-world
1096
1396
  // record config.
@@ -1333,8 +1633,10 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
1333
1633
  },
1334
1634
  renderReadLeases,
1335
1635
  featureGraphCandidate,
1636
+ pointsLinesOwner,
1336
1637
  ),
1337
1638
  );
1639
+ persistentRenderScene.setPointsLinesInspections(pointsLinesOwner.inspections());
1338
1640
  if (internals.featureHost !== undefined && preparedResourceBatches.length > 0) {
1339
1641
  const batches = preparedResourceBatches;
1340
1642
  if (submitted) {
@@ -1,4 +1,5 @@
1
1
  import type { RenderSceneResyncReason } from '../inspection-types';
2
+ import type { PointsLinesRetainedSnapshot } from '../points-lines/snapshot';
2
3
  import type { RenderableSnapshot } from '../render-system-extract';
3
4
 
4
5
  export type { RenderSceneResyncReason } from '../inspection-types';
@@ -23,6 +24,8 @@ export interface RenderSceneRecord extends RenderSceneIdentity {
23
24
 
24
25
  export interface RenderSceneSlot extends RenderSceneRecord {
25
26
  readonly snapshot: RenderableSnapshot;
27
+ /** Optional detached projection carried with the retained render slot. */
28
+ readonly pointsLines?: PointsLinesRetainedSnapshot;
26
29
  }
27
30
 
28
31
  export interface RenderSceneApplyResult {
@@ -29,6 +29,8 @@ import {
29
29
  } from '../gpu-driven/batch-topology';
30
30
  import { GpuScene } from '../gpu-scene';
31
31
  import type { PersistentRenderSceneInspection } from '../inspection-types';
32
+ import type { PointsLinesInspection } from '../points-lines/inspection';
33
+ import type { PointsLinesRetainedSnapshot } from '../points-lines/snapshot';
32
34
  import type { CameraSnapshot } from '../render-contract';
33
35
  import type {
34
36
  DispatchEntry,
@@ -95,6 +97,21 @@ function ownSnapshot(snapshot: RenderableSnapshot): RenderableSnapshot {
95
97
  regions: new Float32Array(snapshot.spriteInstances.regions),
96
98
  },
97
99
  }),
100
+ ...(snapshot.pointsLines === undefined
101
+ ? {}
102
+ : { pointsLines: ownPointsLinesSnapshot(snapshot.pointsLines) }),
103
+ };
104
+ }
105
+
106
+ function ownPointsLinesSnapshot(
107
+ snapshot: PointsLinesRetainedSnapshot,
108
+ ): PointsLinesRetainedSnapshot {
109
+ return {
110
+ ...snapshot,
111
+ style: snapshot.style === undefined ? undefined : { ...snapshot.style },
112
+ sourceBounds: new Float32Array(snapshot.sourceBounds),
113
+ viewport: { ...snapshot.viewport },
114
+ projection: new Float32Array(snapshot.projection),
98
115
  };
99
116
  }
100
117
 
@@ -156,6 +173,10 @@ export class RenderScene {
156
173
  private readonly freeSlots: number[] = [];
157
174
  private readonly slotsByWorld = new Map<number, Map<number, number>>();
158
175
  private readonly slotsByMaterial = new Map<number, Set<number>>();
176
+ // World bounds are a pure projection of each immutable snapshot. Cache the
177
+ // derived value by snapshot identity so repeated view queries do not redo
178
+ // the eight-corner transform for every view in the same frame.
179
+ private readonly worldBoundsCache = new WeakMap<RenderableSnapshot, RenderSceneBounds | null>();
159
180
  private orderedSlots: number[] = [];
160
181
  private materialized: readonly RenderableSnapshot[] | undefined;
161
182
  private slotSnapshot: readonly RenderSceneSlot[] | undefined;
@@ -365,6 +386,18 @@ export class RenderScene {
365
386
  return this.lookup(worldId, entityKey)?.snapshot;
366
387
  }
367
388
 
389
+ pointsLinesSnapshots(): readonly PointsLinesRetainedSnapshot[] {
390
+ const snapshots: PointsLinesRetainedSnapshot[] = [];
391
+ for (const slot of this.orderedSlots) {
392
+ const record = this.slots[slot];
393
+ const snapshot = record?.snapshot.pointsLines;
394
+ if (record !== undefined && snapshot !== undefined) {
395
+ snapshots.push(ownPointsLinesSnapshot({ ...snapshot, worldId: record.worldId }));
396
+ }
397
+ }
398
+ return Object.freeze(snapshots);
399
+ }
400
+
368
401
  slotsForMaterial(materialHandle: number): readonly RenderSceneSlot[] {
369
402
  const slots = this.slotsByMaterial.get(materialHandle);
370
403
  if (slots === undefined) return [];
@@ -381,8 +414,14 @@ export class RenderScene {
381
414
  for (const slot of this.orderedSlots) {
382
415
  const record = this.slots[slot];
383
416
  if (record === undefined) continue;
384
- const candidate = worldBounds(record.snapshot);
385
- if (candidate !== undefined && intersects(candidate, bounds)) records.push(record);
417
+ const snapshot = record.snapshot;
418
+ let candidate = this.worldBoundsCache.get(snapshot);
419
+ if (candidate === undefined && !this.worldBoundsCache.has(snapshot)) {
420
+ candidate = worldBounds(snapshot) ?? null;
421
+ this.worldBoundsCache.set(snapshot, candidate);
422
+ }
423
+ if (candidate === null || candidate === undefined) continue;
424
+ if (intersects(candidate, bounds)) records.push(record);
386
425
  }
387
426
  return records;
388
427
  }
@@ -654,6 +693,7 @@ export class PersistentRenderScene {
654
693
  private gpuDevice: RhiDevice | undefined;
655
694
  private gpuStatus: 'inactive' | 'unsupported' | 'resident' | 'rebuild-pending' | 'error' =
656
695
  'inactive';
696
+ private pointsLinesInspections: readonly PointsLinesInspection[] = [];
657
697
 
658
698
  constructor(private readonly options: PersistentRenderSceneOptions = {}) {}
659
699
 
@@ -841,6 +881,14 @@ export class PersistentRenderScene {
841
881
  this.gpuStatus = this.options.getDevice === undefined ? 'inactive' : 'rebuild-pending';
842
882
  }
843
883
 
884
+ setPointsLinesInspections(inspections: readonly PointsLinesInspection[]): void {
885
+ this.pointsLinesInspections = inspections.map((inspection) => ({
886
+ ...inspection,
887
+ cache: { ...inspection.cache },
888
+ ...(inspection.refusal === undefined ? {} : { refusal: { ...inspection.refusal } }),
889
+ }));
890
+ }
891
+
844
892
  dispose(): void {
845
893
  this.gpuScene?.dispose();
846
894
  this.gpuScene = undefined;
@@ -848,10 +896,12 @@ export class PersistentRenderScene {
848
896
  this.gpuDevice = undefined;
849
897
  this.gpuStatus = 'inactive';
850
898
  this.composition = undefined;
899
+ this.pointsLinesInspections = [];
851
900
  }
852
901
 
853
902
  inspect(): PersistentRenderSceneInspection {
854
903
  const entry = this.composition;
904
+ const pointsLines = this.pointsLinesInspections;
855
905
  return {
856
906
  worldEntitiesScanned: this.worldEntitiesScanned,
857
907
  fullRebuilds: this.fullRebuilds,
@@ -871,6 +921,7 @@ export class PersistentRenderScene {
871
921
  ineligible: 0,
872
922
  } satisfies BatchTopologyInspection),
873
923
  gpu: this.inspectGpu(),
924
+ pointsLines,
874
925
  };
875
926
  }
876
927
 
@@ -886,6 +937,10 @@ export class PersistentRenderScene {
886
937
  };
887
938
  }
888
939
 
940
+ pointsLinesSnapshots(): readonly PointsLinesRetainedSnapshot[] {
941
+ return this.composition?.projection.pointsLinesSnapshots() ?? [];
942
+ }
943
+
889
944
  private inspectGpu(): PersistentGpuSceneInspection {
890
945
  if (this.gpuStatus === 'resident' && this.gpuScene !== undefined) {
891
946
  return { status: 'resident', ...this.gpuScene.inspect() };
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/glyph-text.ts","../src/components/sprite-playback-mode.ts","../src/systems/transparent-sort-config.ts"],"names":[],"mappings":";;;;;AAkDO,IAAM,SAAA,GAAY,gBAAgB,WAAA,EAAa;AAAA;AAAA;AAAA,EAGpD,UAAA,EAAY;AAAA,IACV,IAAA,EAAM,mBAAA;AAAA,IACN,OAAA,EAAS,CAAA;AAAA,IACT,mBAAA,EAAqB;AAAA,GACvB;AAAA,EACA,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,SAAS,EAAA,EAAG;AAAA,EACpC,QAAA,EAAU,EAAE,IAAA,EAAM,KAAA,EAAO,SAAS,EAAA,EAAG;AAAA;AAAA;AAAA,EAGrC,KAAA,EAAO,EAAE,IAAA,EAAM,eAAA,EAAiB,OAAA,EAAS,IAAI,YAAA,CAAa,CAAC,CAAA,EAAG,CAAA,EAAG,CAAA,EAAG,CAAC,CAAC,CAAA;AACxE,CAAC;;;ACXM,IAAM,yBAAA,GAA4B,CAAA;AAGlC,IAAM,0BAAA,GAA6B,CAAA;AAGnC,IAAM,cAAA,GAAiB,OAAO,MAAA,CAAO;AAAA,EAC1C,IAAA,EAAM,yBAAA;AAAA,EACN,KAAA,EAAO;AACT,CAAU;AAeH,SAAS,0BAA0B,KAAA,EAAmC;AAC3E,EAAA,OAAO,KAAA,KAAU,6BAA6B,OAAA,GAAU,MAAA;AAC1D;ACoBO,IAAM,2BAAA,GAA8B,uBAAA;AAGpC,IAAM,6BAAA,GAAgC;AAGtC,IAAM,6BAAA,GAAgC;AAGtC,IAAM,8BAAA,GAAiC;AAGvC,IAAM,8BAAA,GAAiC;AAGvC,IAAM,eAAA,GAAkB,OAAO,MAAA,CAAO;AAAA,EAC3C,MAAA,EAAQ,6BAAA;AAAA,EACR,MAAA,EAAQ,6BAAA;AAAA,EACR,OAAA,EAAS,8BAAA;AAAA,EACT,QAAA,EAAU,8BAAA;AAAA,EACV,SAAA,EAAW;AACb,CAAU;AAMV,IAAM,cAAA,GAAwC,OAAO,MAAA,CAAO;AAAA,EAC1D,IAAA,EAAM,6BAAA;AAAA,EACN,OAAA,EAAS;AACX,CAAC,CAAA;AAED,IAAM,WAAA,uBAAuC,GAAA,CAAY;AAAA,EACvD,6BAAA;AAAA,EACA,6BAAA;AAAA,EACA,8BAAA;AAAA,EACA;AACF,CAAC,CAAA;AAOD,IAAM,aAAA,GAAgB,0BAAA;AACtB,IAAM,SAAA,GAAY,8CAAA;AA2BX,SAAS,yBAAyB,KAAA,EAAqC;AAC5E,EAAA,IAAI,CAAC,KAAA,CAAM,WAAA,CAAY,2BAA2B,CAAA,EAAG;AACnD,IAAA,OAAO,cAAA;AAAA,EACT;AACA,EAAA,OAAO,KAAA,CAAM,YAAmC,2BAA2B,CAAA;AAC7E;AAwCO,SAAS,wBAAA,CACd,OACA,GAAA,EACyC;AACzC,EAAA,IAAI,CAAC,WAAA,CAAY,GAAA,CAAI,GAAA,CAAI,IAAI,CAAA,EAAG;AAC9B,IAAA,OAAO,GAAA;AAAA,MACL,IAAI,yBAAA,CAA0B,aAAA,EAAe,SAAA,EAAW;AAAA,QACtD,cAAc,GAAA,CAAI,IAAA;AAAA,QAClB,WAAA,EAAa;AAAA,OACd;AAAA,KACH;AAAA,EACF;AACA,EAAA,KAAA,CAAM,eAAsC,2BAAA,EAA6B;AAAA,IACvE,MAAM,GAAA,CAAI,IAAA;AAAA,IACV,SAAS,GAAA,CAAI;AAAA,GACd,CAAA;AACD,EAAA,OAAO,GAAG,MAAS,CAAA;AACrB","file":"chunk-QC4VW3XJ.mjs","sourcesContent":["// @forgeax/engine-runtime - GlyphText component (feat-20260531-world-space-msdf-text-rendering M4 / w14).\n//\n// `GlyphText` is the authoring source component for world-space MSDF text\n// (requirements AC-06 / §domain model). It carries ONLY authoring data; the\n// glyph quad baking + MeshFilter / MeshRenderer attachment is the job of the\n// `glyphTextLayoutSystem` (plan-strategy D-2: GlyphText is pure authoring\n// data, baking is a system responsibility). There is NO `TextLayoutAsset`\n// intermediate (OOS-5) -- layout output lives directly in a baked MeshAsset.\n//\n// Naming: single-semantic component drops the `Component` suffix\n// (AGENTS.md §Component naming rule #1 -- Transform / Camera / GlyphText).\n//\n// Schema vocab:\n// - `fontHandle: 'shared<FontAsset>'` -> `Handle<'FontAsset', 'shared'>`\n// (u32-stored; AssetRegistry owns the FontAsset lifecycle). AI users\n// obtain the handle via `assets.loadByGuid<FontAsset>(guid)`.\n// - `text: 'string'` -> native JS string (UniqueRefStore-backed, same\n// dispatch as `Name.value`).\n// - `fontSize: 'f32'` -> layout scale applied to the FontAsset metrics.\n// - `color: 'array<f32, 4>'` -> linear-space rgba tint (feat-20260709 M3:\n// collapsed from four `colorR/G/B/A` scalar columns into one inline\n// stride-4 SoA column, mirroring the DirectionalLight direction/color\n// idiom). Explicit layer-2 default [1,1,1,1] (opaque white).\n//\n// charter mapping: P1 (single import surface from `@forgeax/engine-render`,\n// co-located with `glyphTextLayoutSystem` that consumes it) + P3\n// (machine-readable schema literal) + P5 (consistent abstraction: same\n// `'shared<T>'` idiom as MeshFilter.assetHandle / MeshRenderer.materials).\n\nimport { defineComponent } from '@forgeax/engine-ecs';\n\n/**\n * Glyph text authoring component (world-space MSDF text).\n *\n * Spawn an entity with a `GlyphText` and the `glyphTextLayoutSystem`\n * (auto-wired by `createRenderer` / `createApp`) lays out the glyph quads,\n * bakes a `MeshAsset`, and attaches `MeshFilter` + `MeshRenderer` on the\n * next frame (Commands-deferred). Mutating `text` / `fontSize` / `color`\n * re-bakes the mesh in place (plan-strategy D-1 updateMesh; registry size\n * unchanged, AC-08).\n *\n * @example Spawn a world-space label:\n * import { GlyphText } from '@forgeax/engine-render/authoring';\n * const font = (await assets.loadByGuid(fontGuid)).unwrap();\n * world.spawn({\n * component: GlyphText,\n * data: { fontHandle: font, text: 'Hello', fontSize: 32,\n * color: [1, 1, 1, 1] },\n * });\n */\nexport const GlyphText = defineComponent('GlyphText', {\n // The layout/render owner re-resolves the font asset on the target world;\n // authoring text and style remain portable simulation state.\n fontHandle: {\n type: 'shared<FontAsset>',\n default: 0 as never,\n simulationTransient: true,\n },\n text: { type: 'string', default: '' },\n fontSize: { type: 'f32', default: 16 },\n // color carries an explicit layer-2 default [1,1,1,1] (opaque white); the\n // array layer-3 fallback is all-zero, so the default MUST be explicit (D-5).\n color: { type: 'array<f32, 4>', default: new Float32Array([1, 1, 1, 1]) },\n});\n","// @forgeax/engine-runtime - SpritePlaybackMode (u32 column encoding + mapper).\n//\n// SSOT for the SpriteAnimation.playbackMode ECS column (M2 T-12) and the\n// runtime tick-system branch selector (M4 T-23). The shape mirrors the M1\n// `Tonemap` block in `./camera.ts:72-90` (TONEMAP_NONE = 0 /\n// TONEMAP_REINHARD_EXTENDED = 1 + `type Tonemap = 'none' |\n// 'reinhard-extended'` + `tonemapFromF32`) for charter P4 consistent\n// abstraction — AI users keep one mental model across all u32-encoded\n// closed-union schema columns.\n//\n// Why `'u32'` + constant + mapper instead of a string-literal column?\n// ECS schema whitelist `SchemaFieldType` (packages/ecs/src/component.ts\n// section schema-field-type) does not accept string-literal unions\n// (research F-2 + F-5). Storing a u32 + translating to a closed\n// `'loop' | 'clamp'` literal union at the tick-system seam preserves\n// AI-user-facing narrowing (`switch (mode) { case 'loop': ... }`)\n// without forcing the ECS column to learn a new field-type vocabulary.\n//\n// Naming convention (plan-strategy section 8.command naming):\n// SPRITE_PLAYBACK_MODE_LOOP / SPRITE_PLAYBACK_MODE_CLAMP mirror M1\n// TONEMAP_* / TRANSPARENT_SORT_MODE_*. `spritePlaybackModeFromU32`\n// mirrors `tonemapFromF32` / `cameraProjectionFromF32`. AI users\n// discover the trio via single-import barrel re-export from\n// `@forgeax/engine-runtime` (wired in by M2 T-13).\n//\n// Anchors: plan-strategy section 2 D-5 + section 3.1 PR block SPM +\n// section 4 risk R-SCHEMA-1 reaction; research F-2 + F-5;\n// requirements section AC-02 + section 2.3 playbackMode row;\n// plan-tasks.json T-07 acceptanceCheck (rg\n// \"SPRITE_PLAYBACK_MODE_LOOP|SPRITE_PLAYBACK_MODE_CLAMP|\n// spritePlaybackModeFromU32\" >= 3 hits in this file).\n\n/**\n * Playback mode discriminator literal union (requirements section AC-02 +\n * section 2.3). Two members for the MVP:\n *\n * `'loop'` — `currentFrame = (currentFrame + 1) % frameCount`; the\n * sprite-animation-tick system wraps the index when\n * `accumDt >= frameDuration` (default for hello-sprite-\n * atlas demo \"100 sprites synchronised walk cycle\").\n * `'clamp'` — `currentFrame = min(currentFrame + 1, frameCount - 1)`;\n * holds the last frame for death / terminator-animation\n * style sequences (requirements section 2.5 q8 lock).\n *\n * Future modes (`'pingpong'`, reverse playback, arbitrary frame index\n * jumps) are deferred per requirements OOS-03. The closed union shape\n * leaves room for additive growth without breaking the u32 enum encoding\n * (plan-strategy section 2 D-5).\n */\nexport type SpritePlaybackMode = 'loop' | 'clamp';\n\n/** Numeric encoding of the loop playback mode (schema value for `playbackMode`). */\nexport const SPRITE_PLAYBACK_MODE_LOOP = 0 as const;\n\n/** Numeric encoding of the clamp playback mode (schema value for `playbackMode`). */\nexport const SPRITE_PLAYBACK_MODE_CLAMP = 1 as const;\n\n/** Grouped authoring values; the numeric ECS encoding stays owner-local. */\nexport const SpritePlayback = Object.freeze({\n loop: SPRITE_PLAYBACK_MODE_LOOP,\n clamp: SPRITE_PLAYBACK_MODE_CLAMP,\n} as const);\n\n/**\n * Map a `SpriteAnimation.playbackMode` numeric column value to the closed\n * `SpritePlaybackMode` string-literal union. The defensive fallback mirrors\n * `cameraProjectionFromF32` / `tonemapFromF32` precedent — a value other\n * than `SPRITE_PLAYBACK_MODE_CLAMP` (1) maps to `'loop'`, so stale or\n * uninitialised entities surface a predictable playback shape through the\n * tick-system query (rather than throwing or returning `undefined`).\n * Validation of `playbackMode` happens at schema-write time, not here.\n *\n * The tick-system in M4 T-23 (`spriteAnimationTickSystem`) consumes the\n * return value via `switch (mode) { case 'loop': ...; case 'clamp': ... }`\n * to pick the per-branch frame-advance arithmetic.\n */\nexport function spritePlaybackModeFromU32(value: number): SpritePlaybackMode {\n return value === SPRITE_PLAYBACK_MODE_CLAMP ? 'clamp' : 'loop';\n}\n","// @forgeax/engine-runtime - TransparentSortConfig KV resource + helpers.\n//\n// feat-20260520-2d-sprite-layer-mvp M-2 w14 / requirements AC-08 + AC-18\n// path (2)/(3) + AC-19 derivation audit (5).\n//\n// Surface:\n// - interface TransparentSortConfig { mode: number; yzAlpha: number }\n// - constant TRANSPARENT_SORT_CONFIG_KEY = 'TransparentSortConfig'\n// - constants TRANSPARENT_SORT_MODE_LAYER_Z = 0 (horizontal-z)\n// TRANSPARENT_SORT_MODE_LAYER_Y = 1 (Y-sort)\n// TRANSPARENT_SORT_MODE_LAYER_YZ = 2 (Y-Z blend)\n// - helper getTransparentSortConfig(world): TransparentSortConfig\n// - helper setTransparentSortConfig(world, cfg):\n// Result<void, ResourceInvalidValueError>\n//\n// 5-view selection table (charter F1 progressive disclosure for AI users\n// reading IDE hover on this header; same SSOT mirrored in\n// apps/hello/sprite/README.md M-4 w33):\n//\n// | view | mode | yzAlpha | sortValue formula |\n// |:------------------|:----:|:-------:|:--------------------------------|\n// | horizontal (side) | 0 | --- | posZ |\n// | top-down | 1 | --- | -(posY - pivot.y * size.y) |\n// | Don't Starve | 2 | 1.0 | (posY - pivot.y * size.y) + posZ|\n// | isometric | 2 | 0.5 | (posY - pivot.y * size.y) + 0.5*posZ |\n// | JRPG (foot pivot) | 1 | --- | -(posY - pivot.y * size.y) |\n//\n// AC-18 path (2): @fallback getTransparentSortConfig KV missing returns\n// default; no warn; no throw (legal default state, not an error state).\n// AC-18 path (3): @fallback setTransparentSortConfig mode out-of-range\n// does NOT silently coerce to mode=0 — returns Result.err with the\n// 4 SSOT fields locked by plan-strategy D-4 so AI users can self-repair\n// via .code / .expected / .hint / .detail property access (charter P3).\n// AC-18 path (3) carve-out: yzAlpha range is intentionally NOT validated;\n// mode=0 / mode=1 ignore the field entirely, mode=2 reads it, mode=3\n// (distance) also reads it for compat but ignores it (squared-distance\n// formula does not use yzAlpha). The silent-ignore is the documented\n// charter P3 \"legal default\" boundary.\n//\n// @new-surface KV resource: ECS has no defineResource factory at this\n// feat's commit time; the TS POD interface + string KV key form is the\n// minimum-new-surface route. The world resource store\n// (insertResource / getResource / hasResource) is reused unchanged\n// (requirements §2.2.G derivation row 5).\n// @derives world.hasResource / world.insertResource / world.getResource\n// KV API (packages/ecs/src/resource.ts + world.ts: insertResource l.490,\n// getResource l.498, hasResource l.503).\n// @fallback getTransparentSortConfig KV missing returns\n// { mode: 0, yzAlpha: 1.0 }; no warn; no throw.\n// @fallback setTransparentSortConfig mode out-of-range returns Result.err\n// with code='resource-invalid-value'; never silently coerced.\n//\n// charter mapping: F1 (single-import barrel + 5-view JSDoc table on\n// hover) + P3 (structured failure on mode out-of-range; legal-default\n// fallback distinguished from error fallback) + P4 (consistent\n// abstraction — same world.{has,get,insert}Resource KV API as every\n// other engine resource consumer).\n\nimport type { World } from '@forgeax/engine-ecs';\nimport { ResourceInvalidValueError } from '@forgeax/engine-ecs/projection';\nimport { err, ok, type Result } from '@forgeax/engine-types';\n\n// ────────────────────────────────────────────────────────────────────────────\n// POD interface + KV key + 3 named mode constants\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Transparent-bucket sort configuration (plain-data POD). Lives as a\n * world-level resource keyed by `TRANSPARENT_SORT_CONFIG_KEY`.\n *\n * Fields:\n * - `mode \\u2208 {0, 1, 2, 3}` selects the sort formula\n * (`TRANSPARENT_SORT_MODE_LAYER_Z` / `_LAYER_Y` / `_LAYER_YZ` / `_DISTANCE`).\n * - `yzAlpha` (mode=2 only) blends Y-axis into Z-axis: 1.0 weights\n * posZ equally with `posY - pivot.y * size.y` (Don't-Starve style),\n * 0.5 weights posZ at half (isometric). mode=0 / mode=1 ignore the\n * field (documented charter P3 legal-default boundary).\n */\nexport interface TransparentSortConfig {\n readonly mode: number;\n readonly yzAlpha: number;\n}\n\n/** Extract-stage POD consumed by the transparent-sort owner. */\nexport interface TransparentEntry {\n readonly entityIndex: number;\n readonly materialHandle: number;\n readonly layer: number;\n readonly posX: number;\n readonly posY: number;\n readonly posZ: number;\n readonly pivotY: number;\n readonly sizeY: number;\n readonly sortKey?: number | undefined;\n readonly renderableIndex?: number | undefined;\n}\n\n/** World resource key for `TransparentSortConfig` KV entry. */\nexport const TRANSPARENT_SORT_CONFIG_KEY = 'TransparentSortConfig' as const;\n\n/** horizontal / side-scroller: sortValue = posZ. */\nexport const TRANSPARENT_SORT_MODE_LAYER_Z = 0;\n\n/** top-down / JRPG: sortValue = -(posY - pivot.y * size.y) (foot pivot Y-sort). */\nexport const TRANSPARENT_SORT_MODE_LAYER_Y = 1;\n\n/** Don't-Starve / isometric: sortValue = (posY - pivot.y * size.y) + yzAlpha * posZ. */\nexport const TRANSPARENT_SORT_MODE_LAYER_YZ = 2;\n\n/** 3D distance: sortValue = -(dist^2) to cameraPos, back-to-front (far first). */\nexport const TRANSPARENT_SORT_MODE_DISTANCE = 3;\n\n/** Grouped authoring values and the single configuration operation. */\nexport const TransparentSort = Object.freeze({\n layerZ: TRANSPARENT_SORT_MODE_LAYER_Z,\n layerY: TRANSPARENT_SORT_MODE_LAYER_Y,\n layerYZ: TRANSPARENT_SORT_MODE_LAYER_YZ,\n distance: TRANSPARENT_SORT_MODE_DISTANCE,\n configure: setTransparentSortConfig,\n} as const);\n\n// ────────────────────────────────────────────────────────────────────────────\n// Internal: silent default + mode validity check\n// ────────────────────────────────────────────────────────────────────────────\n\nconst DEFAULT_CONFIG: TransparentSortConfig = Object.freeze({\n mode: TRANSPARENT_SORT_MODE_LAYER_Z,\n yzAlpha: 1.0,\n});\n\nconst VALID_MODES: ReadonlySet<number> = new Set<number>([\n TRANSPARENT_SORT_MODE_LAYER_Z,\n TRANSPARENT_SORT_MODE_LAYER_Y,\n TRANSPARENT_SORT_MODE_LAYER_YZ,\n TRANSPARENT_SORT_MODE_DISTANCE,\n]);\n\n// The expected / hint literals are the SSOT locked by plan-strategy D-4.\n// They MUST round-trip byte-for-byte into ResourceInvalidValueError so AI\n// users can read err.expected / err.hint for self-repair (charter P3).\n// `\\u2208` is the math \"is-element-of\" symbol; the ASCII source escape\n// keeps the file English-only per AGENTS.md §Conventions.\nconst EXPECTED_MODE = 'mode \\u2208 {0, 1, 2, 3}';\nconst HINT_MODE = '0=layer-z, 1=layer-y, 2=layer-yz, 3=distance';\n\n// ────────────────────────────────────────────────────────────────────────────\n// Helpers\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Read the world's `TransparentSortConfig` resource.\n *\n * @fallback KV missing returns `{ mode: 0, yzAlpha: 1.0 }`\n * (`TRANSPARENT_SORT_MODE_LAYER_Z`, horizontal-z safe default).\n * NO warn, NO throw — KV missing is a legal state (not an error state),\n * mirroring how a sprite-free scene is a legal state for the renderer\n * even though render-system has a transparent bucket. plan-strategy D-2:\n * `hasResource` guard precedes the read so this helper never trips\n * `ResourceNotFoundError` from `world.getResource`.\n *\n * @example\n * const cfg = getTransparentSortConfig(world);\n * // cfg.mode === 0, cfg.yzAlpha === 1.0 when no resource inserted.\n *\n * @example\n * world.insertResource(TRANSPARENT_SORT_CONFIG_KEY,\n * { mode: TRANSPARENT_SORT_MODE_LAYER_Y, yzAlpha: 1.0 });\n * const cfg = getTransparentSortConfig(world);\n * // cfg.mode === 1, cfg.yzAlpha === 1.0 (yzAlpha ignored by mode=1).\n */\nexport function getTransparentSortConfig(world: World): TransparentSortConfig {\n if (!world.hasResource(TRANSPARENT_SORT_CONFIG_KEY)) {\n return DEFAULT_CONFIG;\n }\n return world.getResource<TransparentSortConfig>(TRANSPARENT_SORT_CONFIG_KEY);\n}\n\n/**\n * Write the world's `TransparentSortConfig` resource, validating `mode`.\n *\n * Returns `Result<void, ResourceInvalidValueError>`:\n *\n * - `ok` — mode is in `{0, 1, 2, 3}`; the resource is inserted /\n * overwritten via `world.insertResource`.\n * - `err` — mode is out of range; the resource is NOT mutated;\n * `err.error` is a `ResourceInvalidValueError` instance carrying:\n * `.code === 'resource-invalid-value'`\n * `.expected === 'mode \\u2208 {0, 1, 2, 3}'`\n * `.hint === '0=layer-z, 1=layer-y, 2=layer-yz, 3=distance'`\n * `.detail.receivedMode === <the rejected mode>`\n *\n * @fallback mode out-of-range is NEVER silently coerced to mode=0 —\n * `Result.err` is the only fail-mode (charter P3 structured failure).\n * AI users read `.code / .expected / .hint / .detail` properties to\n * self-repair, NOT the human-readable `.message` string.\n *\n * @fallback yzAlpha range is intentionally not validated; mode=0 / mode=1\n * ignore the field, mode=2 reads it. The silent-ignore is the\n * documented charter P3 \"legal default\" boundary (plan-strategy D-4 +\n * AC-18 path (3) carve-out).\n *\n * @example success path:\n * const r = setTransparentSortConfig(world,\n * { mode: TRANSPARENT_SORT_MODE_LAYER_YZ, yzAlpha: 0.5 });\n * if (!r.ok) return r;\n *\n * @example failure path:\n * const r = setTransparentSortConfig(world, { mode: 99, yzAlpha: 1.0 });\n * if (!r.ok) {\n * // r.error.code === 'resource-invalid-value'\n * // r.error.expected === 'mode \\u2208 {0, 1, 2, 3}'\n * // r.error.hint === '0=layer-z, 1=layer-y, 2=layer-yz, 3=distance'\n * // r.error.detail.receivedMode === 99\n * }\n */\nexport function setTransparentSortConfig(\n world: World,\n cfg: TransparentSortConfig,\n): Result<void, ResourceInvalidValueError> {\n if (!VALID_MODES.has(cfg.mode)) {\n return err(\n new ResourceInvalidValueError(EXPECTED_MODE, HINT_MODE, {\n receivedMode: cfg.mode,\n receivedKey: TRANSPARENT_SORT_CONFIG_KEY,\n }),\n );\n }\n world.insertResource<TransparentSortConfig>(TRANSPARENT_SORT_CONFIG_KEY, {\n mode: cfg.mode,\n yzAlpha: cfg.yzAlpha,\n });\n return ok(undefined);\n}\n"]}