@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
@@ -153,9 +153,10 @@ import {
153
153
  type PipelineSpec,
154
154
  PipelineSpecError,
155
155
  } from '../pipeline-spec';
156
+ import { POINTS_LINES_MATERIAL_SHADER_ID } from '../points-lines/record';
156
157
  import type { FrameObservationOptions } from '../record/frame';
157
158
  import { SHADOW_CASTER_BUFFER_SIZE } from '../record/shadow-pass';
158
- import { VIEW_UNIFORM_BUFFER_SIZE } from '../record/view-ubo';
159
+ import { POINTS_LINES_VIEW_BUFFER_SIZE, VIEW_UNIFORM_BUFFER_SIZE } from '../record/view-ubo';
159
160
  import type {
160
161
  DrawOwnerOptions,
161
162
  FrameObservationRequest,
@@ -501,7 +502,7 @@ export function selectNoColorPbrVariant(
501
502
  variantSet: string | undefined,
502
503
  ): MaterialShaderManifestEntry['variants'][number] | undefined {
503
504
  const hdrpRequested =
504
- variantSet === '' || variantSet?.includes('CLUSTER_FORWARD_AVAILABLE=true') === true;
505
+ variantSet === '' || (variantSet?.includes('CLUSTER_FORWARD_AVAILABLE=true') ?? false);
505
506
  return manifestEntry?.variants.find(
506
507
  (variant) =>
507
508
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
@@ -694,6 +695,21 @@ export function resolveMaterialShaderVariantSet(
694
695
  : sorted.map(([name, value]) => `${name}=${value}`).join('+');
695
696
  }
696
697
 
698
+ /**
699
+ * Keep a single-source material on its canonical module identity. A manifest
700
+ * entry with no variants cannot consume a synthesized capability or geometry
701
+ * axis, so carrying one into the pipeline cache only creates an uncached lazy
702
+ * compile beside the prewarmed source module.
703
+ */
704
+ export function normalizeMaterialShaderVariantSet(
705
+ requestedVariantSet: string | undefined,
706
+ manifestEntry: MaterialShaderManifestEntry | undefined,
707
+ ): string | undefined {
708
+ return manifestEntry !== undefined && manifestEntry.variants.length === 0
709
+ ? undefined
710
+ : requestedVariantSet;
711
+ }
712
+
697
713
  export async function createRenderer(
698
714
  canvas: unknown,
699
715
  options?: RendererOptions,
@@ -2073,7 +2089,9 @@ async function makeWebGPURenderer(
2073
2089
  // canonical HDRP variant key and would pair an HDRP PSO with the URP mesh
2074
2090
  // bind group. Builtin forgeax:: shaders keep the compatibility request
2075
2091
  // when a reduced test/legacy manifest omits variant metadata.
2076
- let effectiveVariantSet = resolvedVariantSet;
2092
+ // A single-source manifest entry must stay on its canonical module key;
2093
+ // otherwise a synthesized capability axis races the prewarmed module.
2094
+ let effectiveVariantSet = normalizeMaterialShaderVariantSet(resolvedVariantSet, manifestEntry);
2077
2095
  if (
2078
2096
  vertexLayoutProjection === undefined &&
2079
2097
  manifestEntry?.variants.some((variant) => 'VERTEX_COLOR_AVAILABLE' in variant.defines)
@@ -2590,6 +2608,7 @@ async function makeWebGPURenderer(
2590
2608
  features: Object.freeze(
2591
2609
  (internals.featureHost?.features ?? []).map((feature) => feature.identity),
2592
2610
  ),
2611
+ featureDiagnostics: internals.featureHost?.diagnostics() ?? Object.freeze([]),
2593
2612
  frustumStats: Object.freeze({ ...renderSystem.frustumStats }),
2594
2613
  visibilityStats: Object.freeze({ ...renderSystem.visibilityStats }),
2595
2614
  renderScene: renderSystem.renderScene,
@@ -3569,6 +3588,13 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3569
3588
  draw: (request) => {
3570
3589
  const result = renderer.drawFrame(request);
3571
3590
  if (!result.ok) return err(drawError(result.error));
3591
+ emit(
3592
+ Object.freeze({
3593
+ kind: 'frame-submitted',
3594
+ frameId: result.value.frameId,
3595
+ deviceGeneration: result.value.deviceGeneration,
3596
+ }),
3597
+ );
3572
3598
  return ok(result.value);
3573
3599
  },
3574
3600
  inspect: () => renderer.inspect(),
@@ -5082,6 +5108,34 @@ async function buildReadyWebGPU(
5082
5108
  brdfLut: iblBrdfLutEntry.wgsl,
5083
5109
  });
5084
5110
  }
5111
+ // Points/Lines are an engine-owned record path rather than a producer
5112
+ // RenderFeature, so they cannot declare `requiredMaterialShaders`. Their
5113
+ // material pipeline still goes through the same lazy shader-module adapter
5114
+ // as producer features. Prewarm the optional manifest artifact here so a
5115
+ // first frame cannot race async module compilation and silently skip its
5116
+ // draw while the adapter is still pending. Older/minimal manifests may not
5117
+ // carry the optional entry; those hosts retain their existing behavior.
5118
+ const pointsLinesLookup = registry.findMaterialArtifact(POINTS_LINES_MATERIAL_SHADER_ID);
5119
+ if (pointsLinesLookup.ok) {
5120
+ const pointsLinesLabel = `module-${POINTS_LINES_MATERIAL_SHADER_ID}`;
5121
+ const pointsLinesShaderResult = await runShimStep(
5122
+ () =>
5123
+ asyncCreateShaderModule
5124
+ ? asyncCreateShaderModule(rhiDevice, {
5125
+ code: pointsLinesLookup.value.source,
5126
+ label: pointsLinesLabel,
5127
+ })
5128
+ : invokeDeviceCreateShaderModule(rhiDevice, {
5129
+ code: pointsLinesLookup.value.source,
5130
+ label: pointsLinesLabel,
5131
+ }),
5132
+ 'shader-compile-failed',
5133
+ `engine material shader '${POINTS_LINES_MATERIAL_SHADER_ID}' compiled`,
5134
+ `inspect the composed WGSL for '${POINTS_LINES_MATERIAL_SHADER_ID}' and check device.features`,
5135
+ );
5136
+ if (!pointsLinesShaderResult.ok) throw pointsLinesShaderResult.error;
5137
+ seedShaderModule(pointsLinesLabel, pointsLinesShaderResult.value);
5138
+ }
5085
5139
  // Producer-declared material shader modules use the same source and module
5086
5140
  // label as getMaterialShaderPipeline. Prewarming them here keeps the
5087
5141
  // renderer.initialization barrier meaningful for prepared graphics: the first draw
@@ -5899,6 +5953,20 @@ async function buildReadyWebGPU(
5899
5953
  );
5900
5954
  if (!viewUboResult.ok) throw viewUboResult.error;
5901
5955
 
5956
+ const pointsLinesViewBufferResult = runShimSyncStep(
5957
+ () =>
5958
+ rhiDevice.createBuffer({
5959
+ label: 'points-lines-view-ubo',
5960
+ size: POINTS_LINES_VIEW_BUFFER_SIZE,
5961
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
5962
+ mappedAtCreation: false,
5963
+ }),
5964
+ 'webgpu-runtime-error',
5965
+ 'createBuffer (points-lines view ubo) succeeded',
5966
+ 'check device.limits.maxUniformBufferBindingSize',
5967
+ );
5968
+ if (!pointsLinesViewBufferResult.ok) throw pointsLinesViewBufferResult.error;
5969
+
5902
5970
  // feat-20260613-csm-cascaded-shadow-maps M5 / w28: per-pass cascade-index
5903
5971
  // UBO consumed by shadow_caster.wgsl. Stable singleton; the record stage
5904
5972
  // queue.writeBuffer-overwrites the fields immediately before each pass's
@@ -7817,6 +7885,7 @@ async function buildReadyWebGPU(
7817
7885
  materialBindGroupLayout: materialBglResult.value,
7818
7886
  meshBindGroupLayout: meshArrayBglResult.value,
7819
7887
  viewUniformBuffer: viewUboResult.value,
7888
+ pointsLinesViewBuffer: pointsLinesViewBufferResult.value,
7820
7889
  shadowCasterCascadeBuffer: shadowCasterCascadeUboResult.value,
7821
7890
  // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
7822
7891
  // T-M2-05: PipelineState now references the wrapper objects owned by
@@ -39,13 +39,10 @@ function projectPass(
39
39
  export function assembleMaterialProjection(record: CookedMaterialRecord): MaterialRenderProjection {
40
40
  return {
41
41
  materialGuid: record.guid,
42
- specializationKey: record.receipt.inputDigest,
42
+ specializationKey: record.specializationKey ?? record.receipt.identity.programIdentity,
43
43
  artifactHash: record.artifact.digest,
44
44
  passes: record.resolved.passes.map((pass) => projectPass(record, pass)),
45
45
  runtimeValues: record.resolved.values,
46
- staticSelection: record.resolved.parameters
47
- .filter((parameter) => parameter.static)
48
- .map((parameter) => parameter.name)
49
- .sort(),
46
+ staticSelection: [],
50
47
  };
51
48
  }
package/src/authoring.ts CHANGED
@@ -13,4 +13,5 @@ export {
13
13
  type FullscreenRenderFeatureOptions,
14
14
  } from './features/fullscreen';
15
15
  export { SPRITE_PREMULTIPLIED_ALPHA_BLEND } from './materials';
16
+ export { setActiveCamera } from './systems/active-camera';
16
17
  export { TransparentSort } from './systems/transparent-sort-config';
@@ -0,0 +1,219 @@
1
+ import type { MeshAsset, PrimitiveTopology } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ PointsLinesBudgetExceededError,
5
+ PointsLinesInvalidStyleError,
6
+ PointsLinesMaterialUnsupportedError,
7
+ PointsLinesPrepareFailedError,
8
+ PointsLinesStyleUnsupportedError,
9
+ PointsLinesTopologyMismatchError,
10
+ } from '../../errors/render';
11
+ import { Materials } from '../../materials';
12
+ import { admitPointsLines, type PointsLinesAdmissionInput } from '../../points-lines/admission';
13
+
14
+ const unlitMaterial = Materials.unlit([1, 0.5, 0.25, 1], { castShadow: false });
15
+
16
+ function mesh(topology: PrimitiveTopology, count = 4, indexed = false): MeshAsset {
17
+ const vertices = new Float32Array(count * 3);
18
+ return {
19
+ kind: 'mesh',
20
+ vertices,
21
+ ...(indexed ? { indices: new Uint16Array(count) } : {}),
22
+ attributes: { position: vertices },
23
+ aabb: new Float32Array([-1, -1, -1, 1, 1, 1]),
24
+ submeshes: [
25
+ {
26
+ indexOffset: 0,
27
+ indexCount: indexed ? count : 0,
28
+ vertexCount: count,
29
+ topology,
30
+ materialSlot: 0,
31
+ },
32
+ ],
33
+ materialSlots: [{ slotName: 'default' }],
34
+ };
35
+ }
36
+
37
+ function pointsInput(
38
+ overrides: Partial<PointsLinesAdmissionInput> = {},
39
+ ): PointsLinesAdmissionInput {
40
+ return {
41
+ entity: 7,
42
+ points: { sizePx: 4, shape: 0 },
43
+ mesh: mesh('point-list', 3),
44
+ material: unlitMaterial,
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function linesInput(overrides: Partial<PointsLinesAdmissionInput> = {}): PointsLinesAdmissionInput {
50
+ return {
51
+ entity: 8,
52
+ lines: { widthPx: 1 },
53
+ mesh: mesh('line-list', 4),
54
+ material: unlitMaterial,
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ function expectRefusal(
60
+ result: ReturnType<typeof admitPointsLines>,
61
+ code: string,
62
+ detail: Record<string, unknown>,
63
+ ): void {
64
+ expect(result.ok).toBe(false);
65
+ if (result.ok) return;
66
+ if (!('detail' in result.error)) throw new Error('expected a detailed Points/Lines refusal');
67
+ expect(result.error.code).toBe(code);
68
+ expect(result.error.expected).toEqual(expect.any(String));
69
+ expect(result.error.hint).toEqual(expect.any(String));
70
+ expect(result.error.detail).toMatchObject(detail);
71
+ }
72
+
73
+ describe('Points and Lines admission', () => {
74
+ it('accepts indexed and non-indexed point and line-list geometry', () => {
75
+ const indexedPoints = admitPointsLines(pointsInput({ mesh: mesh('point-list', 3, true) }));
76
+ const nonIndexedLines = admitPointsLines(linesInput());
77
+
78
+ expect(indexedPoints.ok).toBe(true);
79
+ expect(nonIndexedLines.ok).toBe(true);
80
+ if (indexedPoints.ok) expect(indexedPoints.value.pointCount).toBe(3);
81
+ if (nonIndexedLines.ok) expect(nonIndexedLines.value.segmentCount).toBe(2);
82
+ });
83
+
84
+ it('rejects component conflicts and invalid numeric styles before topology', () => {
85
+ const conflict = admitPointsLines(pointsInput({ lines: { widthPx: 1 } }));
86
+ const invalidSize = admitPointsLines(pointsInput({ points: { sizePx: Number.NaN, shape: 0 } }));
87
+ const invalidWidth = admitPointsLines(linesInput({ lines: { widthPx: 0 } }));
88
+
89
+ expectRefusal(conflict, 'points-lines-invalid-style', {
90
+ entity: 7,
91
+ component: 'Points/Lines',
92
+ field: 'components',
93
+ });
94
+ expectRefusal(invalidSize, 'points-lines-invalid-style', {
95
+ entity: 7,
96
+ component: 'Points',
97
+ field: 'sizePx',
98
+ value: Number.NaN,
99
+ });
100
+ expectRefusal(invalidWidth, 'points-lines-invalid-style', {
101
+ entity: 8,
102
+ component: 'Lines',
103
+ field: 'widthPx',
104
+ value: 0,
105
+ });
106
+ });
107
+
108
+ it('rejects unsupported topologies, odd line tails, and mixed candidates atomically', () => {
109
+ const strip = admitPointsLines(linesInput({ mesh: mesh('line-strip', 4) }));
110
+ const triangles = admitPointsLines(pointsInput({ mesh: mesh('triangle-list', 3) }));
111
+ const odd = admitPointsLines(linesInput({ mesh: mesh('line-list', 3) }));
112
+ const baseMixedMesh = mesh('line-list', 2);
113
+ const mixedMesh: MeshAsset = {
114
+ ...baseMixedMesh,
115
+ submeshes: [
116
+ ...baseMixedMesh.submeshes,
117
+ {
118
+ indexOffset: 0,
119
+ indexCount: 0,
120
+ vertexCount: 3,
121
+ topology: 'triangle-list',
122
+ materialSlot: 0,
123
+ },
124
+ ],
125
+ };
126
+ const mixed = admitPointsLines(linesInput({ mesh: mixedMesh }));
127
+
128
+ expectRefusal(strip, 'points-lines-style-unsupported', {
129
+ lane: 'admission',
130
+ field: 'topology',
131
+ member: 'line-strip',
132
+ });
133
+ expectRefusal(triangles, 'points-lines-topology-mismatch', {
134
+ entity: 7,
135
+ expected: 'point-list',
136
+ actual: 'triangle-list',
137
+ submesh: 0,
138
+ });
139
+ expectRefusal(odd, 'points-lines-topology-mismatch', {
140
+ entity: 8,
141
+ expected: 'line-list pairs',
142
+ actual: 'line-list odd tail',
143
+ submesh: 0,
144
+ });
145
+ expectRefusal(mixed, 'points-lines-topology-mismatch', {
146
+ entity: 8,
147
+ submesh: 1,
148
+ });
149
+ });
150
+
151
+ it('rejects non-unlit materials and explicit resource budgets', () => {
152
+ const lit = Materials.standard({ baseColor: [1, 1, 1, 1] });
153
+ const material = admitPointsLines(linesInput({ material: lit }));
154
+ const budget = admitPointsLines(pointsInput({ limits: { maxPoints: 2 } }));
155
+
156
+ expectRefusal(material, 'points-lines-material-unsupported', {
157
+ entity: 8,
158
+ pass: 'deferred',
159
+ reason: expect.any(String),
160
+ });
161
+ expectRefusal(budget, 'points-lines-budget-exceeded', {
162
+ lane: 'admission',
163
+ requested: 3,
164
+ limit: 2,
165
+ unit: 'points',
166
+ });
167
+ });
168
+
169
+ it('keeps every closed refusal detail constructible and code-correlated', () => {
170
+ const errors = [
171
+ new PointsLinesInvalidStyleError({
172
+ entity: 1,
173
+ component: 'Points',
174
+ field: 'sizePx',
175
+ value: -1,
176
+ expected: 'finite sizePx > 0',
177
+ }),
178
+ new PointsLinesTopologyMismatchError({
179
+ entity: 1,
180
+ submesh: 0,
181
+ expected: 'point-list',
182
+ actual: 'triangle-list',
183
+ }),
184
+ new PointsLinesStyleUnsupportedError({
185
+ lane: 'admission',
186
+ field: 'topology',
187
+ member: 'line-strip',
188
+ supported: ['line-list'],
189
+ }),
190
+ new PointsLinesMaterialUnsupportedError({
191
+ entity: 1,
192
+ material: 'mat-1',
193
+ pass: 'forward',
194
+ module: 'forgeax_material::standard',
195
+ reason: 'surface lighting is not supported',
196
+ }),
197
+ new PointsLinesBudgetExceededError({
198
+ lane: 'admission',
199
+ requested: 5,
200
+ limit: 4,
201
+ unit: 'points',
202
+ }),
203
+ new PointsLinesPrepareFailedError({
204
+ owner: 'points-lines',
205
+ generation: 2,
206
+ stage: 'prepare',
207
+ cause: 'candidate validation failed',
208
+ lastKnownGood: false,
209
+ }),
210
+ ];
211
+
212
+ for (const error of errors) {
213
+ expect(error.code).toMatch(/^points-lines-/);
214
+ expect(error.expected).toEqual(expect.any(String));
215
+ expect(error.hint).toEqual(expect.any(String));
216
+ expect(error.detail).toBeDefined();
217
+ }
218
+ });
219
+ });
@@ -0,0 +1,56 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import type { PointShape } from '@forgeax/engine-render';
3
+ import { Lines, PointShapeValue, Points, pointShapeFromU32 } from '@forgeax/engine-render';
4
+ import { describe, expect, expectTypeOf, it } from 'vitest';
5
+
6
+ describe('Points and Lines public schema', () => {
7
+ it('creates both components through the public import and inserts them in World', () => {
8
+ const world = new World();
9
+ const pointEntity = world
10
+ .spawn({
11
+ component: Points,
12
+ data: { sizePx: 8, shape: PointShapeValue.circle },
13
+ })
14
+ .unwrap();
15
+ const lineEntity = world.spawn({ component: Lines, data: { widthPx: 4 } }).unwrap();
16
+
17
+ expect(world.get(pointEntity, Points).unwrap()).toMatchObject({
18
+ sizePx: 8,
19
+ shape: PointShapeValue.circle,
20
+ });
21
+ expect(world.get(lineEntity, Lines).unwrap()).toMatchObject({ widthPx: 4 });
22
+ });
23
+
24
+ it('keeps the frozen defaults and closed point shape decoder', () => {
25
+ const world = new World();
26
+ const pointEntity = world.spawn({ component: Points, data: {} }).unwrap();
27
+ const lineEntity = world.spawn({ component: Lines, data: {} }).unwrap();
28
+
29
+ expect(world.get(pointEntity, Points).unwrap()).toMatchObject({
30
+ sizePx: 4,
31
+ shape: PointShapeValue.square,
32
+ });
33
+ expect(world.get(lineEntity, Lines).unwrap().widthPx).toBe(1);
34
+ expect(Points.fields).toEqual({
35
+ sizePx: expect.objectContaining({ type: 'f32', default: 4 }),
36
+ shape: expect.objectContaining({
37
+ type: 'enum',
38
+ default: PointShapeValue.square,
39
+ labels: PointShapeValue,
40
+ }),
41
+ });
42
+ expect(Lines.fields).toEqual({
43
+ widthPx: expect.objectContaining({ type: 'f32', default: 1 }),
44
+ });
45
+ expect(pointShapeFromU32(PointShapeValue.square)).toBe('square');
46
+ expect(pointShapeFromU32(PointShapeValue.circle)).toBe('circle');
47
+ expect(pointShapeFromU32(99)).toBeUndefined();
48
+ });
49
+
50
+ it('exposes only the three first-release style fields', () => {
51
+ type PublicPointShape = PointShape;
52
+ expectTypeOf<PublicPointShape>().toEqualTypeOf<'square' | 'circle'>();
53
+ expectTypeOf<keyof typeof Points.fields>().toEqualTypeOf<'sizePx' | 'shape'>();
54
+ expectTypeOf<keyof typeof Lines.fields>().toEqualTypeOf<'widthPx'>();
55
+ });
56
+ });
@@ -2,10 +2,17 @@ export * from './camera';
2
2
  export * from './directional-light';
3
3
  export { Instances, type InstancesData } from './instances';
4
4
  export { Layer } from './layer';
5
+ export { Lines } from './lines';
5
6
  export * from './mesh-filter';
6
7
  export * from './mesh-renderer';
7
8
  export { PointLight } from './point-light';
8
9
  export { PointLightShadow } from './point-light-shadow';
10
+ export {
11
+ type PointShape,
12
+ PointShapeValue,
13
+ Points,
14
+ pointShapeFromU32,
15
+ } from './points';
9
16
  export { PostProcessParams } from './post-process-params';
10
17
  export {
11
18
  SceneInstance,
@@ -0,0 +1,18 @@
1
+ import { defineComponent } from '@forgeax/engine-ecs';
2
+
3
+ /** Screen-space independent line segment style. */
4
+ export const Lines = defineComponent(
5
+ 'Lines',
6
+ {
7
+ widthPx: { type: 'f32', default: 1 },
8
+ },
9
+ {
10
+ meta: {
11
+ quickStart: 'Attach Lines to render line-list MeshAsset pairs in screen pixels.',
12
+ diagnostics: 'Inspect widthPx at the points-lines admission boundary.',
13
+ recovery: 'Use points-lines-invalid-style for non-finite or non-positive widthPx values.',
14
+ boundaries:
15
+ 'Lines owns raster width only; MeshFilter and MeshRenderer own geometry and material.',
16
+ },
17
+ },
18
+ );
@@ -0,0 +1,40 @@
1
+ import { defineComponent } from '@forgeax/engine-ecs';
2
+
3
+ /** Public point raster shapes backed by the ECS enum column. */
4
+ export type PointShape = keyof typeof PointShapeValue;
5
+
6
+ /** Numeric labels stored by the public Points shape enum field. */
7
+ export const PointShapeValue = Object.freeze({
8
+ square: 0,
9
+ circle: 1,
10
+ } as const);
11
+
12
+ /** Decode the Points shape column without treating an unknown value as valid. */
13
+ export function pointShapeFromU32(value: number): PointShape | undefined {
14
+ switch (value) {
15
+ case PointShapeValue.square:
16
+ return 'square';
17
+ case PointShapeValue.circle:
18
+ return 'circle';
19
+ default:
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ /** Screen-space point style. Geometry and material remain on their own owners. */
25
+ export const Points = defineComponent(
26
+ 'Points',
27
+ {
28
+ sizePx: { type: 'f32', default: 4 },
29
+ shape: { type: 'enum', default: PointShapeValue.square, labels: PointShapeValue },
30
+ },
31
+ {
32
+ meta: {
33
+ quickStart: 'Attach Points to render point-list MeshAsset vertices in screen pixels.',
34
+ diagnostics: 'Inspect sizePx and decode shape with pointShapeFromU32.',
35
+ recovery: 'Use points-lines-invalid-style for non-finite or non-positive sizePx values.',
36
+ boundaries:
37
+ 'Points owns raster size and shape only; MeshFilter and MeshRenderer own geometry and material.',
38
+ },
39
+ },
40
+ );
@@ -1101,6 +1101,143 @@ export type RendererExpectedOperationError = {
1101
1101
  readonly [Code in RendererOperationErrorCode]: RendererOperationError<Code>;
1102
1102
  }[RendererOperationErrorCode];
1103
1103
 
1104
+ // -- Points/Lines admission errors -------------------------------------------
1105
+
1106
+ export interface PointsLinesInvalidStyleDetail {
1107
+ readonly entity: number;
1108
+ readonly component: 'Points' | 'Lines' | 'Points/Lines';
1109
+ readonly field: string;
1110
+ readonly value: number | string | undefined;
1111
+ readonly expected: string;
1112
+ }
1113
+
1114
+ export class PointsLinesInvalidStyleError extends Error {
1115
+ readonly code = 'points-lines-invalid-style' as const;
1116
+ readonly expected: string;
1117
+ readonly hint: string;
1118
+ readonly detail: PointsLinesInvalidStyleDetail;
1119
+
1120
+ constructor(detail: PointsLinesInvalidStyleDetail) {
1121
+ super(`invalid ${detail.component} style at ${detail.field}`);
1122
+ this.name = 'PointsLinesInvalidStyleError';
1123
+ this.expected = detail.expected;
1124
+ this.hint = `repair entity ${detail.entity} ${detail.component}.${detail.field} and retry admission`;
1125
+ this.detail = detail;
1126
+ }
1127
+ }
1128
+
1129
+ export interface PointsLinesTopologyMismatchDetail {
1130
+ readonly entity: number;
1131
+ readonly submesh: number;
1132
+ readonly expected: string;
1133
+ readonly actual: string;
1134
+ }
1135
+
1136
+ export class PointsLinesTopologyMismatchError extends Error {
1137
+ readonly code = 'points-lines-topology-mismatch' as const;
1138
+ readonly expected: string;
1139
+ readonly hint: string;
1140
+ readonly detail: PointsLinesTopologyMismatchDetail;
1141
+
1142
+ constructor(detail: PointsLinesTopologyMismatchDetail) {
1143
+ super(`Points/Lines topology mismatch at submesh ${detail.submesh}`);
1144
+ this.name = 'PointsLinesTopologyMismatchError';
1145
+ this.expected = `submesh ${detail.submesh} topology is ${detail.expected}`;
1146
+ this.hint = `rebuild entity ${detail.entity} with ${detail.expected} geometry before retrying Points/Lines admission`;
1147
+ this.detail = detail;
1148
+ }
1149
+ }
1150
+
1151
+ export interface PointsLinesStyleUnsupportedDetail {
1152
+ readonly lane: string;
1153
+ readonly field: string;
1154
+ readonly member: string;
1155
+ readonly supported: readonly string[];
1156
+ }
1157
+
1158
+ export class PointsLinesStyleUnsupportedError extends Error {
1159
+ readonly code = 'points-lines-style-unsupported' as const;
1160
+ readonly expected: string;
1161
+ readonly hint: string;
1162
+ readonly detail: PointsLinesStyleUnsupportedDetail;
1163
+
1164
+ constructor(detail: PointsLinesStyleUnsupportedDetail) {
1165
+ super(`unsupported Points/Lines ${detail.field} member ${detail.member}`);
1166
+ this.name = 'PointsLinesStyleUnsupportedError';
1167
+ this.expected = `${detail.field} is one of ${detail.supported.join(', ')}`;
1168
+ this.hint = `use a supported Points/Lines ${detail.field} member or wait for a later milestone`;
1169
+ this.detail = detail;
1170
+ }
1171
+ }
1172
+
1173
+ export interface PointsLinesMaterialUnsupportedDetail {
1174
+ readonly entity: number;
1175
+ readonly material: string;
1176
+ readonly pass: string;
1177
+ readonly module: string;
1178
+ readonly reason: string;
1179
+ }
1180
+
1181
+ export class PointsLinesMaterialUnsupportedError extends Error {
1182
+ readonly code = 'points-lines-material-unsupported' as const;
1183
+ readonly expected = 'one engine-owned unlit forward MaterialAsset pass';
1184
+ readonly hint: string;
1185
+ readonly detail: PointsLinesMaterialUnsupportedDetail;
1186
+
1187
+ constructor(detail: PointsLinesMaterialUnsupportedDetail) {
1188
+ super(`Points/Lines material is unsupported on entity ${detail.entity}`);
1189
+ this.name = 'PointsLinesMaterialUnsupportedError';
1190
+ this.hint = `use Materials.unlit without a shadow-caster pass for entity ${detail.entity}`;
1191
+ this.detail = detail;
1192
+ }
1193
+ }
1194
+
1195
+ export interface PointsLinesBudgetExceededDetail {
1196
+ readonly lane: string;
1197
+ readonly requested: number;
1198
+ readonly limit: number;
1199
+ readonly unit: 'points' | 'segments' | 'vertices' | 'indices' | 'bytes' | 'draws';
1200
+ }
1201
+
1202
+ export class PointsLinesBudgetExceededError extends Error {
1203
+ readonly code = 'points-lines-budget-exceeded' as const;
1204
+ readonly expected: string;
1205
+ readonly hint: string;
1206
+ readonly detail: PointsLinesBudgetExceededDetail;
1207
+
1208
+ constructor(detail: PointsLinesBudgetExceededDetail) {
1209
+ super(`Points/Lines ${detail.unit} budget exceeded`);
1210
+ this.name = 'PointsLinesBudgetExceededError';
1211
+ this.expected = `${detail.unit} <= ${detail.limit}`;
1212
+ this.hint = `reduce Points/Lines ${detail.unit} or split the entity into bounded equivalent batches`;
1213
+ this.detail = detail;
1214
+ }
1215
+ }
1216
+
1217
+ export interface PointsLinesPrepareFailedDetail {
1218
+ readonly owner: string;
1219
+ readonly generation: number;
1220
+ readonly stage: 'prepare';
1221
+ readonly cause: string;
1222
+ readonly lastKnownGood: boolean;
1223
+ }
1224
+
1225
+ export class PointsLinesPrepareFailedError extends Error {
1226
+ readonly code = 'points-lines-prepare-failed' as const;
1227
+ readonly expected = 'the complete Points/Lines candidate validates before publication';
1228
+ readonly hint: string;
1229
+ readonly detail: PointsLinesPrepareFailedDetail;
1230
+
1231
+ constructor(detail: PointsLinesPrepareFailedDetail) {
1232
+ super(`Points/Lines prepare failed at generation ${detail.generation}`);
1233
+ this.name = 'PointsLinesPrepareFailedError';
1234
+ this.hint = detail.lastKnownGood
1235
+ ? 'retain the last known good candidate, repair the producer, and retry prepare'
1236
+ : 'repair the producer and retry prepare; the failed first candidate produces zero draw';
1237
+ this.detail = detail;
1238
+ }
1239
+ }
1240
+
1104
1241
  // -- RenderErrorCode / RenderError closed unions --------------------------------
1105
1242
 
1106
1243
  /**
@@ -1138,4 +1275,10 @@ export type RenderError =
1138
1275
  | RenderFeaturePassOrderConflictError
1139
1276
  | RenderFeaturePreparationFailedError
1140
1277
  | RenderFeaturePreparedStateMismatchError
1141
- | RenderFeatureDrawRecordingFailedError;
1278
+ | RenderFeatureDrawRecordingFailedError
1279
+ | PointsLinesInvalidStyleError
1280
+ | PointsLinesTopologyMismatchError
1281
+ | PointsLinesStyleUnsupportedError
1282
+ | PointsLinesMaterialUnsupportedError
1283
+ | PointsLinesBudgetExceededError
1284
+ | PointsLinesPrepareFailedError;