@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
@@ -43,12 +43,19 @@ export type RenderFeatureGraphTargetResolver = (
43
43
  resource: string | RenderFeatureTargetHandle,
44
44
  ) => RenderFeatureGraphTarget | undefined;
45
45
 
46
+ export type RenderFeatureGraphBindingsResolution =
47
+ | BindGroup
48
+ | {
49
+ readonly handle: BindGroup;
50
+ readonly dynamicOffsets?: readonly number[];
51
+ };
52
+
46
53
  export type RenderFeatureGraphBindingsResolver<FrameCtx extends RenderGraphFrame> = (input: {
47
54
  readonly frame: FrameCtx;
48
55
  readonly binding: Extract<PreparedGraphicsResolvedResource, { readonly kind: 'bindings' }>;
49
56
  readonly resources: GraphResourceResolver;
50
57
  readonly resolveTarget: RenderFeatureGraphTargetResolver;
51
- }) => BindGroup | undefined;
58
+ }) => RenderFeatureGraphBindingsResolution | undefined;
52
59
 
53
60
  interface ResolvedDrawBuffers {
54
61
  readonly vertex: ReadonlyMap<object, GraphBuffer>;
@@ -255,12 +262,24 @@ export class RenderFeatureRasterGraphProjection<FrameCtx extends RenderGraphFram
255
262
  for (const [group, reference] of draw.bindings.entries()) {
256
263
  const binding = resolved(snapshot, reference);
257
264
  if (binding?.kind !== 'bindings') throw missing(featureIdentity, order);
258
- const handle =
265
+ const resolvedBindings =
259
266
  binding.handle ??
260
267
  this.resolveBindings?.({ frame, binding, resources, resolveTarget: this.resolveTarget });
268
+ const handle =
269
+ resolvedBindings !== undefined &&
270
+ typeof resolvedBindings === 'object' &&
271
+ 'handle' in resolvedBindings
272
+ ? resolvedBindings.handle
273
+ : resolvedBindings;
261
274
  if (handle === undefined) throw missing(featureIdentity, order);
262
275
  const targetGroup = binding.descriptor?.values.group ?? group;
263
- pass.setBindGroup(targetGroup, handle, binding.dynamicOffsets);
276
+ const dynamicOffsets =
277
+ resolvedBindings !== undefined &&
278
+ typeof resolvedBindings === 'object' &&
279
+ 'handle' in resolvedBindings
280
+ ? resolvedBindings.dynamicOffsets
281
+ : binding.dynamicOffsets;
282
+ pass.setBindGroup(targetGroup, handle, dynamicOffsets);
264
283
  }
265
284
  for (const vertex of draw.vertexData) {
266
285
  const handle = buffers.vertex.get(vertex.resource as object);
@@ -606,7 +606,12 @@ export class GpuDrivenProduction {
606
606
  return ok({
607
607
  accesses,
608
608
  encode: (viewBindGroup, pass, resources: GraphResourceResolver) => {
609
- pass.setBindGroup(0, viewBindGroup);
609
+ // pbr-view-bgl carries the vertex-only Points/Lines UBO at binding
610
+ // 10 as a dynamic uniform buffer. The production view group is
611
+ // shared by the GPU-driven raster pipeline, so even rigid draws
612
+ // must provide the binding-10 offset (zero selects the frame
613
+ // default slot).
614
+ pass.setBindGroup(0, viewBindGroup, [0]);
610
615
  let currentPipeline: RenderPipeline | undefined;
611
616
  let currentVertex: GraphBuffer | undefined;
612
617
  let currentIndex: GraphBuffer | undefined;
package/src/index.ts CHANGED
@@ -38,10 +38,17 @@ export {
38
38
  export * from './components/directional-light';
39
39
  export { Instances, type InstancesData } from './components/instances';
40
40
  export { Layer } from './components/layer';
41
+ export { Lines } from './components/lines';
41
42
  export * from './components/mesh-filter';
42
43
  export * from './components/mesh-renderer';
43
44
  export { PointLight } from './components/point-light';
44
45
  export { PointLightShadow } from './components/point-light-shadow';
46
+ export {
47
+ type PointShape,
48
+ PointShapeValue,
49
+ Points,
50
+ pointShapeFromU32,
51
+ } from './components/points';
45
52
  export { PostProcessParams } from './components/post-process-params';
46
53
  export { SceneInstance } from './components/scene-instance';
47
54
  export {
@@ -91,6 +98,16 @@ export { Materials } from './materials';
91
98
  // pipeline helpers remain behind the package boundary.
92
99
  export { DEFAULT_STANDARD_PROFILE } from './pipeline/standard-profile';
93
100
  export { renderComponentsPlugin } from './plugin';
101
+ export {
102
+ admitPointsLines,
103
+ type LinesStyleInput,
104
+ type PointsLinesAdmission,
105
+ type PointsLinesAdmissionError,
106
+ type PointsLinesAdmissionInput,
107
+ type PointsLinesAdmissionLimits,
108
+ type PointsStyleInput,
109
+ } from './points-lines/admission';
110
+ export type { PointsLinesInspection } from './points-lines/inspection';
94
111
  // Runtime contract: leases, frame receipts, detached inspection, profile,
95
112
  // lifecycle state, and the single renderer event stream.
96
113
  export type {
@@ -4,6 +4,8 @@
4
4
  * extract/record dependency graph.
5
5
  */
6
6
 
7
+ import type { PointsLinesInspection } from './points-lines/inspection';
8
+
7
9
  export interface BatchTopologyInspection {
8
10
  readonly revision: number;
9
11
  readonly batchCount: number;
@@ -69,6 +71,8 @@ export interface PersistentRenderSceneInspection {
69
71
  | ({ readonly status: 'resident' } & GpuSceneInspection)
70
72
  | { readonly status: 'rebuild-pending' }
71
73
  | { readonly status: 'error' };
74
+ /** Bounded retained authoring observations; no graph or backend handles. */
75
+ readonly pointsLines: readonly PointsLinesInspection[];
72
76
  }
73
77
 
74
78
  export type RenderSceneInspection = PersistentRenderSceneInspection & {
@@ -347,6 +347,8 @@ export interface PbrCaps {
347
347
  * (always-on bindings 5/6 paired with the unconditional `POINT_SHADOW_AVAILABLE`
348
348
  * define registered in vite-plugin-shader) +
349
349
  * feat-20260613-csm-cascaded-shadow-maps M5 / w28 (binding 7 cascade UBO).
350
+ * binding 10 (Points/Lines viewport UBO; always bound for the shared view
351
+ * layout and consumed only by the dedicated points-lines shader).
350
352
  * Isolated here so M4 round-4 tests can recreate the layout.
351
353
  *
352
354
  * feat-20260526-pbr-uniform-fallback-no-storage-buffer M3 / w9:
@@ -448,6 +450,11 @@ export function buildPbrViewBglEntries(caps: PbrCaps): GPUBindGroupLayoutEntry[]
448
450
  visibility: GPU_SHADER_STAGE_FRAGMENT,
449
451
  texture: { sampleType: 'depth', viewDimension: '2d' },
450
452
  },
453
+ {
454
+ binding: 10,
455
+ visibility: GPU_SHADER_STAGE_VERTEX,
456
+ buffer: { type: 'uniform', hasDynamicOffset: true },
457
+ },
451
458
  ];
452
459
  }
453
460
 
@@ -1005,7 +1012,7 @@ export function buildBindGroupLayoutDescriptor(
1005
1012
  const desc = createHdrpBindGroupLayoutDescriptor(caps.storageBuffer);
1006
1013
  return {
1007
1014
  label: desc.label ?? 'hdrp-unified-bgl-group2',
1008
- entries: desc.entries ?? [],
1015
+ entries: [...(desc.entries ?? [])],
1009
1016
  };
1010
1017
  }
1011
1018
  case 'fullscreen-post': {
package/src/plugin.ts CHANGED
@@ -4,10 +4,12 @@ import { Camera } from './components/camera';
4
4
  import { DirectionalLight } from './components/directional-light';
5
5
  import { Instances } from './components/instances';
6
6
  import { Layer } from './components/layer';
7
+ import { Lines } from './components/lines';
7
8
  import { MeshFilter } from './components/mesh-filter';
8
9
  import { MeshRenderer } from './components/mesh-renderer';
9
10
  import { PointLight } from './components/point-light';
10
11
  import { PointLightShadow } from './components/point-light-shadow';
12
+ import { Points } from './components/points';
11
13
  import { PostProcessParams } from './components/post-process-params';
12
14
  import { SceneInstance } from './components/scene-instance';
13
15
  import { SkyboxBackground } from './components/skybox-background';
@@ -30,6 +32,8 @@ const RENDER_COMPONENTS: readonly Component[] = [
30
32
  MeshRenderer,
31
33
  PointLight,
32
34
  PointLightShadow,
35
+ Points,
36
+ Lines,
33
37
  PostProcessParams,
34
38
  SceneInstance,
35
39
  SkyboxBackground,
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { expandPointsLinesBounds } from '../bounds';
3
+
4
+ describe('Points/Lines conservative transformed culling bounds', () => {
5
+ it('retains a physical-pixel raster margin around transformed edge geometry', () => {
6
+ const expanded = expandPointsLinesBounds(new Float32Array([10, 20, -1, 30, 40, 1]), {
7
+ kind: 'points',
8
+ sizePx: 16,
9
+ shape: 'circle',
10
+ });
11
+ expect([...expanded]).toEqual([2, 12, -9, 38, 48, 9]);
12
+ });
13
+
14
+ it('uses line width as the conservative envelope for off-edge segments', () => {
15
+ const expanded = expandPointsLinesBounds([-1, -1, 0, 1, 1, 0], {
16
+ kind: 'lines',
17
+ widthPx: 4,
18
+ });
19
+ expect([...expanded]).toEqual([-3, -3, -2, 3, 3, 2]);
20
+ });
21
+ });
@@ -0,0 +1,157 @@
1
+ import { err, type MeshAsset, ok } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { type PointsLinesExpandedGeometry, PointsLinesExpansionCache } from '../expansion-cache';
4
+ import { type PointsLinesGpuResourceAdapter, PointsLinesPreparation } from '../prepare';
5
+ import { createPointsLinesLaneContract, createPointsLinesRecordPlan } from '../record';
6
+ import { createPointsLinesSnapshot } from '../snapshot';
7
+
8
+ function mesh(generation = 1): MeshAsset {
9
+ return {
10
+ kind: 'mesh',
11
+ vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]),
12
+ indices: new Uint16Array([0, 1, 2, 3]),
13
+ attributes: { position: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]) },
14
+ aabb: new Float32Array([0, 0, 0, 1, 1, 0]),
15
+ submeshes: [
16
+ { indexOffset: 0, indexCount: 4, vertexCount: 4, topology: 'line-list', materialSlot: 0 },
17
+ ],
18
+ materialSlots: [{ slotName: `lines-${generation}` }],
19
+ };
20
+ }
21
+
22
+ function snapshot(meshGeneration = 1, materialGeneration = 1) {
23
+ return createPointsLinesSnapshot({
24
+ worldId: 0,
25
+ entityKey: 4,
26
+ component: 'Lines',
27
+ meshHandle: 9,
28
+ meshGeneration,
29
+ materialHandle: 12,
30
+ materialGeneration,
31
+ style: { kind: 'lines', widthPx: 3 },
32
+ layer: 0,
33
+ visible: true,
34
+ sourceBounds: new Float32Array([0, 0, 0, 1, 1, 0]),
35
+ viewport: { width: 320, height: 200, dpr: 1 },
36
+ projection: new Float32Array(16),
37
+ });
38
+ }
39
+
40
+ describe('Points/Lines expansion cache and LKG recovery', () => {
41
+ it('reuses geometry when style changes and keeps style out of the cache key', () => {
42
+ const cache = new PointsLinesExpansionCache();
43
+ const first = cache.getOrCreate(snapshot(), mesh());
44
+ const styled = cache.getOrCreate(
45
+ createPointsLinesSnapshot({
46
+ ...snapshot(),
47
+ style: { kind: 'lines', widthPx: 9 },
48
+ }),
49
+ mesh(),
50
+ );
51
+ const material = cache.getOrCreate(snapshot(1, 2), mesh());
52
+
53
+ expect(first).toBe(styled);
54
+ expect(first).toBe(material);
55
+ expect(first.key).toContain('mesh:9:1');
56
+ expect(styled.key).not.toContain('width=9');
57
+ expect(styled.vertices).toEqual(first.vertices);
58
+ expect(styled.positions).toEqual(first.positions);
59
+ expect(first).toEqual<PointsLinesExpandedGeometry>(
60
+ expect.objectContaining({ sourceVertexCount: 4, segmentCount: 2 }),
61
+ );
62
+ });
63
+
64
+ it('destroys only a failed candidate and retains the previous live resource', () => {
65
+ const calls: string[] = [];
66
+ let failValidation = false;
67
+ const adapter: PointsLinesGpuResourceAdapter<string> = {
68
+ create: () => {
69
+ calls.push('create');
70
+ return ok(`resource-${calls.filter((call) => call === 'create').length}`);
71
+ },
72
+ upload: (resource) => {
73
+ calls.push(`upload:${resource}`);
74
+ return ok(64);
75
+ },
76
+ validate: (resource) => {
77
+ calls.push(`validate:${resource}`);
78
+ return failValidation ? err(new Error('candidate validation fault')) : ok(undefined);
79
+ },
80
+ destroy: (resource) => calls.push(`destroy:${resource}`),
81
+ };
82
+ const preparation = new PointsLinesPreparation({
83
+ cache: new PointsLinesExpansionCache(),
84
+ adapter,
85
+ });
86
+
87
+ const first = preparation.prepare(snapshot(), mesh());
88
+ expect(first.ok).toBe(true);
89
+ failValidation = true;
90
+ const failed = preparation.prepare(snapshot(2), mesh(2));
91
+
92
+ expect(failed.ok).toBe(false);
93
+ expect(calls).toContain('destroy:resource-2');
94
+ expect(preparation.inspect()).toMatchObject({
95
+ status: 'resident',
96
+ lastKnownGood: true,
97
+ liveResource: 'resource-1',
98
+ candidateBytes: 0,
99
+ });
100
+ expect(preparation.inspect().liveBytes).toBe(
101
+ preparation.lastKnownGood()?.geometry.derivedBytes,
102
+ );
103
+ expect(preparation.lastKnownGood()?.resource).toBe('resource-1');
104
+ });
105
+
106
+ it('reuses geometry for a changed style and records its new physical-pixel draw plan', () => {
107
+ let uploads = 0;
108
+ const adapter: PointsLinesGpuResourceAdapter<string> = {
109
+ create: (geometry) => ok(geometry.key),
110
+ upload: (_resource, geometry) => {
111
+ uploads += 1;
112
+ return ok(geometry.derivedBytes);
113
+ },
114
+ validate: () => ok(undefined),
115
+ destroy: () => undefined,
116
+ };
117
+ const preparation = new PointsLinesPreparation({
118
+ cache: new PointsLinesExpansionCache(),
119
+ adapter,
120
+ });
121
+ const first = preparation.prepare(snapshot(), mesh()).unwrap();
122
+ const changedSnapshot = createPointsLinesSnapshot({
123
+ ...snapshot(),
124
+ style: { kind: 'lines', widthPx: 9 },
125
+ });
126
+ const changed = preparation.prepare(changedSnapshot, mesh()).unwrap();
127
+ const plan = createPointsLinesRecordPlan(
128
+ changedSnapshot,
129
+ changed.geometry,
130
+ createPointsLinesLaneContract('direct', 'webgpu'),
131
+ );
132
+
133
+ expect(uploads).toBe(1);
134
+ expect(changed.geometry).toBe(first.geometry);
135
+ expect(plan.conservativeMarginPx).toBe(4.5);
136
+ expect(changed.geometry.vertices).toEqual(first.geometry.vertices);
137
+ });
138
+
139
+ it('rebuilds from the retained snapshot after device loss without stale bytes', () => {
140
+ const adapter: PointsLinesGpuResourceAdapter<string> = {
141
+ create: () => ok('fresh'),
142
+ upload: () => ok(64),
143
+ validate: () => ok(undefined),
144
+ destroy: () => undefined,
145
+ };
146
+ const preparation = new PointsLinesPreparation({
147
+ cache: new PointsLinesExpansionCache(),
148
+ adapter,
149
+ });
150
+
151
+ expect(preparation.prepare(snapshot(), mesh()).ok).toBe(true);
152
+ preparation.resetForDeviceLoss();
153
+ expect(preparation.inspect()).toMatchObject({ status: 'rebuild-pending', liveBytes: 0 });
154
+ expect(preparation.prepare(snapshot(), mesh()).ok).toBe(true);
155
+ expect(preparation.inspect()).toMatchObject({ status: 'resident', lastKnownGood: true });
156
+ });
157
+ });
@@ -0,0 +1,146 @@
1
+ import { type MeshAsset, ok } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { PointsLinesMaterialUnsupportedError } from '../../errors/render';
4
+ import { PointsLinesExpansionCache } from '../expansion-cache';
5
+ import { inspectPointsLines, pointsLinesInspectionToJson } from '../inspection';
6
+ import { type PointsLinesGpuResourceAdapter, PointsLinesPreparation } from '../prepare';
7
+ import { createPointsLinesLaneContract, createPointsLinesRecordPlan } from '../record';
8
+ import { createPointsLinesSnapshot } from '../snapshot';
9
+
10
+ function mesh(): MeshAsset {
11
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]);
12
+ return {
13
+ kind: 'mesh',
14
+ vertices: positions,
15
+ indices: new Uint16Array([0, 1, 2, 3]),
16
+ attributes: { position: positions },
17
+ aabb: new Float32Array([0, 0, 0, 1, 1, 0]),
18
+ submeshes: [
19
+ { indexOffset: 0, indexCount: 4, vertexCount: 4, topology: 'line-list', materialSlot: 0 },
20
+ ],
21
+ materialSlots: [{ slotName: 'points-lines' }],
22
+ };
23
+ }
24
+
25
+ function snapshot(viewport = { width: 320, height: 200, dpr: 1 }) {
26
+ return createPointsLinesSnapshot({
27
+ worldId: 2,
28
+ entityKey: 7,
29
+ component: 'Lines',
30
+ meshHandle: 9,
31
+ meshGeneration: 3,
32
+ materialHandle: 12,
33
+ materialGeneration: 4,
34
+ style: { kind: 'lines', widthPx: 4 },
35
+ layer: 0,
36
+ visible: true,
37
+ sourceBounds: new Float32Array([0, 0, 0, 1, 1, 0]),
38
+ viewport,
39
+ projection: new Float32Array(16),
40
+ });
41
+ }
42
+
43
+ describe('Points/Lines inspection and recovery integration', () => {
44
+ it('publishes typed and JSON inspection for each shared lane', () => {
45
+ const geometry = new PointsLinesExpansionCache().getOrCreate(snapshot(), mesh());
46
+ for (const lane of ['direct', 'clustered', 'cpu-webgl2'] as const) {
47
+ const plan = createPointsLinesRecordPlan(
48
+ snapshot(),
49
+ geometry,
50
+ createPointsLinesLaneContract(lane, 'webgpu'),
51
+ );
52
+ const inspection = inspectPointsLines({
53
+ snapshot: snapshot(),
54
+ topology: 'line-list',
55
+ lane,
56
+ pointCount: geometry.pointCount,
57
+ segmentCount: geometry.segmentCount,
58
+ sourceBytes: geometry.sourceBytes,
59
+ derivedBytes: geometry.derivedBytes,
60
+ cache: { hit: false, rebuilds: 1, evictions: 0 },
61
+ drawCount: plan.drawCount,
62
+ uploadBytes: geometry.derivedBytes,
63
+ lastKnownGood: true,
64
+ });
65
+ const json = JSON.parse(pointsLinesInspectionToJson(inspection)) as typeof inspection;
66
+ expect(json).toMatchObject({
67
+ entityKey: 7,
68
+ worldId: 2,
69
+ topology: 'line-list',
70
+ lane,
71
+ segmentCount: 2,
72
+ drawCount: 1,
73
+ lastKnownGood: true,
74
+ });
75
+ expect(json.derivedBytes).toBeGreaterThan(json.sourceBytes);
76
+ }
77
+ });
78
+
79
+ it('keeps the same retained expansion through resize and recovers one LKG', () => {
80
+ const resources: string[] = [];
81
+ const adapter: PointsLinesGpuResourceAdapter<string> = {
82
+ create: () => {
83
+ const resource = `resource-${resources.length + 1}`;
84
+ resources.push(resource);
85
+ return ok(resource);
86
+ },
87
+ upload: () => ok(648),
88
+ validate: () => ok(undefined),
89
+ destroy: (resource) => resources.splice(resources.indexOf(resource), 1),
90
+ };
91
+ const preparation = new PointsLinesPreparation({
92
+ cache: new PointsLinesExpansionCache(),
93
+ adapter,
94
+ });
95
+ const first = preparation.prepare(snapshot(), mesh()).unwrap();
96
+ const resized = preparation
97
+ .prepare(snapshot({ width: 640, height: 360, dpr: 2 }), mesh())
98
+ .unwrap();
99
+
100
+ expect(resized.geometry).toBe(first.geometry);
101
+ expect(preparation.inspect()).toMatchObject({
102
+ status: 'resident',
103
+ lastKnownGood: true,
104
+ rebuilds: 1,
105
+ });
106
+ preparation.resetForDeviceLoss();
107
+ expect(preparation.inspect()).toMatchObject({ status: 'rebuild-pending', liveBytes: 0 });
108
+ preparation.prepare(snapshot({ width: 640, height: 360, dpr: 2 }), mesh()).unwrap();
109
+ expect(preparation.inspect()).toMatchObject({
110
+ status: 'resident',
111
+ lastKnownGood: true,
112
+ rebuilds: 2,
113
+ });
114
+ expect(resources).toEqual(['resource-1']);
115
+ });
116
+
117
+ it('keeps executable refusal hints on the same inspection surface', () => {
118
+ const inspection = inspectPointsLines({
119
+ snapshot: snapshot(),
120
+ topology: 'line-list',
121
+ lane: 'refused',
122
+ pointCount: 0,
123
+ segmentCount: 0,
124
+ sourceBytes: 0,
125
+ derivedBytes: 0,
126
+ cache: { hit: false, rebuilds: 0, evictions: 0 },
127
+ drawCount: 0,
128
+ uploadBytes: 0,
129
+ lastKnownGood: false,
130
+ refusal: {
131
+ ...new PointsLinesMaterialUnsupportedError({
132
+ entity: 7,
133
+ material: 'mat',
134
+ pass: 'forward',
135
+ module: 'test',
136
+ reason: 'unsupported test material',
137
+ }),
138
+ generation: 4,
139
+ lastKnownGood: false,
140
+ },
141
+ });
142
+ expect(JSON.parse(pointsLinesInspectionToJson(inspection)).refusal.hint).toContain(
143
+ 'Materials.unlit',
144
+ );
145
+ });
146
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ PointsLinesBudgetExceededError,
4
+ PointsLinesInvalidStyleError,
5
+ PointsLinesMaterialUnsupportedError,
6
+ PointsLinesPrepareFailedError,
7
+ PointsLinesStyleUnsupportedError,
8
+ PointsLinesTopologyMismatchError,
9
+ } from '../../errors/render';
10
+ import {
11
+ inspectPointsLines,
12
+ type PointsLinesInspection,
13
+ pointsLinesInspectionToJson,
14
+ } from '../inspection';
15
+ import { createPointsLinesSnapshot } from '../snapshot';
16
+
17
+ function baseInput(): Parameters<typeof inspectPointsLines>[0] {
18
+ return {
19
+ snapshot: createPointsLinesSnapshot({
20
+ worldId: 2,
21
+ entityKey: 15,
22
+ component: 'Lines',
23
+ meshHandle: 5,
24
+ meshGeneration: 3,
25
+ materialHandle: 7,
26
+ materialGeneration: 4,
27
+ style: { kind: 'lines', widthPx: 2 },
28
+ layer: 1,
29
+ visible: true,
30
+ sourceBounds: new Float32Array([0, 0, 0, 1, 1, 0]),
31
+ viewport: { width: 640, height: 480, dpr: 2 },
32
+ projection: new Float32Array(16),
33
+ }),
34
+ topology: 'line-list',
35
+ lane: 'pending',
36
+ pointCount: 0,
37
+ segmentCount: 2,
38
+ sourceBytes: 64,
39
+ derivedBytes: 48,
40
+ cache: { hit: false, rebuilds: 1, evictions: 0 },
41
+ drawCount: 1,
42
+ uploadBytes: 48,
43
+ lastKnownGood: true,
44
+ };
45
+ }
46
+
47
+ describe('bounded Points/Lines inspection', () => {
48
+ it('projects normal typed facts and a JSON-safe equivalent', () => {
49
+ const inspection = inspectPointsLines(baseInput());
50
+
51
+ expect(inspection).toEqual<PointsLinesInspection>({
52
+ entityKey: 15,
53
+ worldId: 2,
54
+ component: 'Lines',
55
+ meshHandle: 5,
56
+ meshGeneration: 3,
57
+ materialHandle: 7,
58
+ materialGeneration: 4,
59
+ style: { kind: 'lines', widthPx: 2 },
60
+ topology: 'line-list',
61
+ lane: 'pending',
62
+ pointCount: 0,
63
+ segmentCount: 2,
64
+ sourceBytes: 64,
65
+ derivedBytes: 48,
66
+ cache: { hit: false, rebuilds: 1, evictions: 0 },
67
+ drawCount: 1,
68
+ uploadBytes: 48,
69
+ lastKnownGood: true,
70
+ });
71
+ expect(JSON.parse(pointsLinesInspectionToJson(inspection))).toEqual(inspection);
72
+ });
73
+
74
+ it('keeps refusal and recovery provenance bounded and typed', () => {
75
+ const refusal = inspectPointsLines({
76
+ ...baseInput(),
77
+ lane: 'refused',
78
+ lastKnownGood: false,
79
+ drawCount: 0,
80
+ uploadBytes: 0,
81
+ refusal: {
82
+ ...new PointsLinesPrepareFailedError({
83
+ owner: 'test',
84
+ generation: 3,
85
+ stage: 'prepare',
86
+ cause: 'test failure',
87
+ lastKnownGood: false,
88
+ }),
89
+ generation: 3,
90
+ lastKnownGood: false,
91
+ },
92
+ });
93
+ const recovery = inspectPointsLines({
94
+ ...baseInput(),
95
+ lane: 'pending',
96
+ cache: { hit: true, rebuilds: 2, evictions: 1 },
97
+ lastKnownGood: true,
98
+ });
99
+
100
+ expect(refusal.refusal?.code).toBe('points-lines-prepare-failed');
101
+ expect(refusal.drawCount).toBe(0);
102
+ expect(recovery.cache).toEqual({ hit: true, rebuilds: 2, evictions: 1 });
103
+ expect(pointsLinesInspectionToJson(refusal)).not.toContain('RHI');
104
+ });
105
+
106
+ it('preserves source detail for all six Points/Lines refusal paths', () => {
107
+ const errors = [
108
+ new PointsLinesInvalidStyleError({
109
+ entity: 15,
110
+ component: 'Lines',
111
+ field: 'widthPx',
112
+ value: 0,
113
+ expected: 'widthPx > 0',
114
+ }),
115
+ new PointsLinesTopologyMismatchError({
116
+ entity: 15,
117
+ submesh: 0,
118
+ expected: 'line-list',
119
+ actual: 'triangle-list',
120
+ }),
121
+ new PointsLinesStyleUnsupportedError({
122
+ lane: 'direct',
123
+ field: 'shape',
124
+ member: 'diamond',
125
+ supported: ['square', 'circle'],
126
+ }),
127
+ new PointsLinesMaterialUnsupportedError({
128
+ entity: 15,
129
+ material: 'mat',
130
+ pass: 'forward',
131
+ module: 'test',
132
+ reason: 'unsupported test material',
133
+ }),
134
+ new PointsLinesBudgetExceededError({
135
+ lane: 'direct',
136
+ requested: 9,
137
+ limit: 8,
138
+ unit: 'draws',
139
+ }),
140
+ new PointsLinesPrepareFailedError({
141
+ owner: 'test',
142
+ generation: 3,
143
+ stage: 'prepare',
144
+ cause: 'test failure',
145
+ lastKnownGood: false,
146
+ }),
147
+ ];
148
+
149
+ for (const error of errors) {
150
+ const inspection = inspectPointsLines({
151
+ ...baseInput(),
152
+ lane: 'refused',
153
+ drawCount: 0,
154
+ uploadBytes: 0,
155
+ lastKnownGood: false,
156
+ refusal: {
157
+ ...error,
158
+ generation: 3,
159
+ lastKnownGood: false,
160
+ },
161
+ });
162
+ expect(inspection.refusal).toMatchObject({
163
+ code: error.code,
164
+ expected: error.expected,
165
+ hint: error.hint,
166
+ detail: error.detail,
167
+ });
168
+ expect(JSON.parse(pointsLinesInspectionToJson(inspection)).refusal.detail).toEqual(
169
+ error.detail,
170
+ );
171
+ }
172
+ });
173
+ });