@forgeax/engine-graphics-extras 0.0.0-dev.8d955ade1c79

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 (48) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +165 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/tile-bits.test.d.ts +2 -0
  5. package/dist/__tests__/tile-bits.test.d.ts.map +1 -0
  6. package/dist/__tests__/video-capability-backend-kind-owner.test-d.d.ts +2 -0
  7. package/dist/__tests__/video-capability-backend-kind-owner.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/video-capability-probe.unit.test.d.ts +2 -0
  9. package/dist/__tests__/video-capability-probe.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/video-player-component.unit.test.d.ts +2 -0
  11. package/dist/__tests__/video-player-component.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/video-player-multi-entity.unit.test.d.ts +2 -0
  13. package/dist/__tests__/video-player-multi-entity.unit.test.d.ts.map +1 -0
  14. package/dist/glyph-layout.d.ts +48 -0
  15. package/dist/glyph-layout.d.ts.map +1 -0
  16. package/dist/glyph-mesh-bake.d.ts +30 -0
  17. package/dist/glyph-mesh-bake.d.ts.map +1 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.mjs +306 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/tile-bits.d.ts +21 -0
  23. package/dist/tile-bits.d.ts.map +1 -0
  24. package/dist/tileset-decoder.d.ts +4 -0
  25. package/dist/tileset-decoder.d.ts.map +1 -0
  26. package/dist/video-element-provider.d.ts +41 -0
  27. package/dist/video-element-provider.d.ts.map +1 -0
  28. package/dist/video-loader.d.ts +4 -0
  29. package/dist/video-loader.d.ts.map +1 -0
  30. package/dist/video-player-system.d.ts +36 -0
  31. package/dist/video-player-system.d.ts.map +1 -0
  32. package/dist/video-player.d.ts +23 -0
  33. package/dist/video-player.d.ts.map +1 -0
  34. package/package.json +62 -0
  35. package/src/__tests__/tile-bits.test.ts +88 -0
  36. package/src/__tests__/video-capability-backend-kind-owner.test-d.ts +7 -0
  37. package/src/__tests__/video-capability-probe.unit.test.ts +130 -0
  38. package/src/__tests__/video-player-component.unit.test.ts +73 -0
  39. package/src/__tests__/video-player-multi-entity.unit.test.ts +75 -0
  40. package/src/glyph-layout.ts +208 -0
  41. package/src/glyph-mesh-bake.ts +151 -0
  42. package/src/index.ts +37 -0
  43. package/src/tile-bits.ts +77 -0
  44. package/src/tileset-decoder.ts +64 -0
  45. package/src/video-element-provider.ts +72 -0
  46. package/src/video-loader.ts +80 -0
  47. package/src/video-player-system.ts +63 -0
  48. package/src/video-player.ts +51 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * VideoPlayer — attaches video play state to an entity.
3
+ *
4
+ * Fields:
5
+ * - `clip: shared<VideoAsset>` — handle to the VideoAsset describing the
6
+ * source URL (mirrors `AudioSource.clip`). Resolved into an
7
+ * HTMLVideoElement at frame time via the host `VideoElementProvider`
8
+ * (the engine never decodes video bytes — D-1).
9
+ * - `playing: bool` — whether the clip advances this frame (default false).
10
+ * - `loop: bool` — whether the clip restarts at end (default false).
11
+ * - `currentTime: f32` — playback head in seconds (default 0).
12
+ *
13
+ * Multiple entities may reference the same `clip` GUID with distinct
14
+ * play state — each entity's playing / loop / currentTime occupy independent
15
+ * archetype column slots (AC-05).
16
+ */
17
+ export declare const VideoPlayer: import("@forgeax/engine-ecs").Component<"VideoPlayer", {
18
+ readonly clip: "shared<VideoAsset>";
19
+ readonly playing: "bool";
20
+ readonly loop: "bool";
21
+ readonly currentTime: "f32";
22
+ }>;
23
+ //# sourceMappingURL=video-player.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"video-player.d.ts","sourceRoot":"","sources":["../src/video-player.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW;;;;;EAOtB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@forgeax/engine-graphics-extras",
3
+ "version": "0.0.0-dev.8d955ade1c79",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Pure-logic graphics-adjacent modules -- text glyph layout + mesh bake, tilemap bit encoding, and video playback -- for forgeax-engine (Tier 2.3 -- extracted from @forgeax/engine-runtime).",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "main": "./dist/index.mjs",
17
+ "types": "./dist/index.d.ts",
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "dependencies": {
25
+ "@forgeax/engine-ecs": "0.0.0-dev.8d955ade1c79",
26
+ "@forgeax/engine-geometry": "0.0.0-dev.8d955ade1c79",
27
+ "@forgeax/engine-rhi": "0.0.0-dev.8d955ade1c79",
28
+ "@forgeax/engine-types": "0.0.0-dev.8d955ade1c79"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^20.14.0"
32
+ },
33
+ "forgeax": {
34
+ "metrics": {
35
+ "bundle-size": {
36
+ "enabled": true,
37
+ "path": "dist/index.mjs",
38
+ "compression": "gzip"
39
+ },
40
+ "fps": {
41
+ "enabled": false,
42
+ "reason": "pure-logic glyph/tilemap/video modules, no runtime canvas; fps reported by hello-* apps that consume them"
43
+ },
44
+ "bench": {
45
+ "enabled": false,
46
+ "reason": "glyph layout + mesh bake run at text-change time, tile-bit codec is O(1) per cell; no per-frame hot path warranting a CI micro-benchmark"
47
+ },
48
+ "gate": {
49
+ "enabled": false,
50
+ "reason": "no package-level binary gate; smoke gate covered by tilemap-object-layer / video-texture / text hello-* apps that consume these modules"
51
+ },
52
+ "spike-report": {
53
+ "enabled": false,
54
+ "reason": "not a spike package; graphics-extras cluster extracted from runtime in feat-20260705-runtime-tier2-decomposition"
55
+ }
56
+ }
57
+ },
58
+ "scripts": {
59
+ "build": "tsup",
60
+ "test": "vitest run"
61
+ }
62
+ }
@@ -0,0 +1,88 @@
1
+ // tile-bits.test - encodeTileBits / decodeTileBits round-trip + boundary tests
2
+ // (feat-20260608 M0 baseline rebuild).
3
+ //
4
+ // Tile id encoding (Tiled .tmj wire format compatibility, feat-20260604 D-2):
5
+ // - low 28 bits: tileId (0..0x0FFFFFFF).
6
+ // - high 4 bits, MSB -> LSB: flipHorizontal, flipVertical, flipDiagonal,
7
+ // flipHex120 (the latter is the Tiled hex 120deg flip slot; preserved for
8
+ // wire fidelity even though M0 does not consume it).
9
+ // - tileId === 0 means "empty cell" downstream in TileLayer.tiles (sentinel).
10
+ //
11
+ // Anchors: requirements integration points (engine-runtime); plan-tasks m0-t3.
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import { decodeTileBits, encodeTileBits } from '../tile-bits';
15
+
16
+ const FLIP_VARIANTS: ReadonlyArray<readonly [boolean, boolean, boolean, boolean]> = [
17
+ [false, false, false, false],
18
+ [true, false, false, false],
19
+ [false, true, false, false],
20
+ [false, false, true, false],
21
+ [false, false, false, true],
22
+ [true, true, false, false],
23
+ [true, false, true, false],
24
+ [true, true, true, true],
25
+ ];
26
+
27
+ const TILE_ID_BOUNDARIES: readonly number[] = [0, 1, 0x0fffffff - 1, 0x0fffffff];
28
+
29
+ describe('encodeTileBits / decodeTileBits round-trip (M0 baseline)', () => {
30
+ for (const tileId of TILE_ID_BOUNDARIES) {
31
+ for (const [flipH, flipV, flipDiagonal, flipHex120] of FLIP_VARIANTS) {
32
+ it(`tileId=${tileId} flipH=${flipH} flipV=${flipV} flipD=${flipDiagonal} flipHex120=${flipHex120}`, () => {
33
+ const packed = encodeTileBits(tileId, flipH, flipV, flipDiagonal, flipHex120);
34
+ const decoded = decodeTileBits(packed);
35
+ expect(decoded.tileId).toBe(tileId);
36
+ expect(decoded.flipH).toBe(flipH);
37
+ expect(decoded.flipV).toBe(flipV);
38
+ expect(decoded.flipDiagonal).toBe(flipDiagonal);
39
+ expect(decoded.flipHex120).toBe(flipHex120);
40
+ });
41
+ }
42
+ }
43
+ });
44
+
45
+ describe('encodeTileBits — high-bit wire layout (MSB H / V / D / Hex120)', () => {
46
+ it('flipH sets bit 31 (MSB)', () => {
47
+ const packed = encodeTileBits(0, true, false, false, false);
48
+ expect(packed >>> 31).toBe(1);
49
+ expect((packed >>> 30) & 0x1).toBe(0);
50
+ });
51
+
52
+ it('flipV sets bit 30', () => {
53
+ const packed = encodeTileBits(0, false, true, false, false);
54
+ expect((packed >>> 31) & 0x1).toBe(0);
55
+ expect((packed >>> 30) & 0x1).toBe(1);
56
+ expect((packed >>> 29) & 0x1).toBe(0);
57
+ });
58
+
59
+ it('flipDiagonal sets bit 29', () => {
60
+ const packed = encodeTileBits(0, false, false, true, false);
61
+ expect((packed >>> 29) & 0x1).toBe(1);
62
+ expect((packed >>> 28) & 0x1).toBe(0);
63
+ });
64
+
65
+ it('flipHex120 sets bit 28', () => {
66
+ const packed = encodeTileBits(0, false, false, false, true);
67
+ expect((packed >>> 28) & 0x1).toBe(1);
68
+ });
69
+
70
+ it('tileId fits in the low 28 bits', () => {
71
+ const packed = encodeTileBits(0x0fffffff, false, false, false, false);
72
+ expect(packed >>> 0).toBe(0x0fffffff);
73
+ });
74
+ });
75
+
76
+ describe('encodeTileBits — overflow RangeError (charter P3)', () => {
77
+ it('tileId === 0x10000000 throws RangeError', () => {
78
+ expect(() => encodeTileBits(0x10000000, false, false, false, false)).toThrow(RangeError);
79
+ });
80
+
81
+ it('tileId < 0 throws RangeError', () => {
82
+ expect(() => encodeTileBits(-1, false, false, false, false)).toThrow(RangeError);
83
+ });
84
+
85
+ it('non-integer tileId throws RangeError', () => {
86
+ expect(() => encodeTileBits(1.5, false, false, false, false)).toThrow(RangeError);
87
+ });
88
+ });
@@ -0,0 +1,7 @@
1
+ import type { RhiCaps } from '@forgeax/engine-rhi';
2
+ import { expectTypeOf } from 'vitest';
3
+ import type { VideoCapabilityDevice } from '../video-player-system';
4
+
5
+ expectTypeOf<VideoCapabilityDevice['caps']['backendKind']>().toEqualTypeOf<
6
+ RhiCaps['backendKind']
7
+ >();
@@ -0,0 +1,130 @@
1
+ // feat-20260623-world-space-video-asset M4 / w17 — AC-09 capability probe.
2
+ //
3
+ // AC-09 / D-2: the high-perf GPUExternalTexture upload path is left in place as
4
+ // an EXPLICIT, grep-able capability-probe branch (not a TODO comment); the
5
+ // general copyExternalImageToTexture path is the one actually wired (w16). This
6
+ // test pins the probe's truth table + the source-level guarantee that the
7
+ // reserved hook is a real code branch keyed on backendKind +
8
+ // importExternalTexture presence.
9
+ //
10
+ // OOS-5: the high-perf upload body is intentionally NOT implemented; the probe
11
+ // returns false for every device the engine produces today (no RHI exposes
12
+ // `importExternalTexture`). The probe flips on automatically the day a future
13
+ // feat adds that entry point on a WebGPU backend.
14
+
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ import { describe, expect, it, vi } from 'vitest';
19
+ import { videoLoader } from '../video-loader';
20
+ import { probeVideoHighPerfUpload, type VideoCapabilityDevice } from '../video-player-system';
21
+
22
+ const SYS_SRC = fileURLToPath(new URL('../video-player-system.ts', import.meta.url));
23
+
24
+ describe('AC-09 — probeVideoHighPerfUpload truth table (M4 / w17)', () => {
25
+ it('rejects a malformed durable URL descriptor without fetching', () => {
26
+ expect(
27
+ videoLoader.load({ kind: 'video', url: 'not a url' }, undefined, {} as never),
28
+ ).toBeUndefined();
29
+ });
30
+
31
+ it.each([
32
+ ['/cutscene.webm', '/cutscene.webm'],
33
+ ['cutscene.webm', 'cutscene.webm'],
34
+ ['http://cdn.example/video.mp4', 'http://cdn.example/video.mp4'],
35
+ ['https://cdn.example/video.mp4', 'https://cdn.example/video.mp4'],
36
+ ] as const)('accepts browser-resolvable URL %s without normalizing it', (input, output) => {
37
+ expect(videoLoader.load({ kind: 'video', url: input }, undefined, {} as never)).toEqual({
38
+ kind: 'video',
39
+ url: output,
40
+ });
41
+ });
42
+
43
+ it('returns a valid URL descriptor without invoking network fetch', () => {
44
+ const fetch = vi.fn(() => Promise.reject(new Error('video Cook must not fetch')));
45
+ vi.stubGlobal('fetch', fetch);
46
+ try {
47
+ expect(
48
+ videoLoader.load(
49
+ { kind: 'video', url: 'https://cdn.example/video.mp4' },
50
+ undefined,
51
+ {} as never,
52
+ ),
53
+ ).toEqual({ kind: 'video', url: 'https://cdn.example/video.mp4' });
54
+ expect(fetch).not.toHaveBeenCalled();
55
+ } finally {
56
+ vi.unstubAllGlobals();
57
+ }
58
+ });
59
+
60
+ it.each([
61
+ 'javascript:alert(1)',
62
+ 'data:video/webm;base64,AAAA',
63
+ 'file:///tmp/cutscene.webm',
64
+ '//cdn.example/cutscene.webm',
65
+ 'cut\nscene.webm',
66
+ '',
67
+ ' ',
68
+ ])('rejects unsafe or empty URL %j', (url) => {
69
+ expect(videoLoader.load({ kind: 'video', url }, undefined, {} as never)).toBeUndefined();
70
+ });
71
+
72
+ it.each([undefined, null, 42, {}, []])('rejects non-string URL %j', (url) => {
73
+ expect(videoLoader.load({ kind: 'video', url }, undefined, {} as never)).toBeUndefined();
74
+ });
75
+
76
+ it('keeps descriptor loading independent from the optional GPU capability', () => {
77
+ expect(probeVideoHighPerfUpload(undefined)).toBe(false);
78
+ expect(
79
+ videoLoader.load(
80
+ { kind: 'video', url: 'https://cdn.example/cutscene.webm' },
81
+ undefined,
82
+ {} as never,
83
+ ),
84
+ ).toEqual({ kind: 'video', url: 'https://cdn.example/cutscene.webm' });
85
+ });
86
+
87
+ it('returns false when no device is wired', () => {
88
+ expect(probeVideoHighPerfUpload(undefined)).toBe(false);
89
+ });
90
+
91
+ it('returns false on a WebGPU backend without importExternalTexture (today, OOS-5)', () => {
92
+ const device: VideoCapabilityDevice = { caps: { backendKind: 'webgpu' } };
93
+ expect(probeVideoHighPerfUpload(device)).toBe(false);
94
+ });
95
+
96
+ it('returns false on non-WebGPU backends even if importExternalTexture existed', () => {
97
+ const native: VideoCapabilityDevice = {
98
+ caps: { backendKind: 'wgpu-native' },
99
+ importExternalTexture: () => undefined,
100
+ };
101
+ const webgl2: VideoCapabilityDevice = {
102
+ caps: { backendKind: 'wgpu-webgl2' },
103
+ importExternalTexture: () => undefined,
104
+ };
105
+ expect(probeVideoHighPerfUpload(native)).toBe(false);
106
+ expect(probeVideoHighPerfUpload(webgl2)).toBe(false);
107
+ });
108
+
109
+ it('returns true ONLY when a WebGPU backend exposes importExternalTexture (future hook)', () => {
110
+ // Simulates the day a future feat lands the RHI entry point. Proves the
111
+ // reserved branch is live (not dead code) and flips on without touching the
112
+ // call sites — the AC-09 "two paths left in place" guarantee.
113
+ const future: VideoCapabilityDevice = {
114
+ caps: { backendKind: 'webgpu' },
115
+ importExternalTexture: () => undefined,
116
+ };
117
+ expect(probeVideoHighPerfUpload(future)).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe('AC-09 — reserved high-perf hook is an explicit code branch (M4 / w17)', () => {
122
+ it('the probe source references GPUExternalTexture import as a real condition, not a comment', () => {
123
+ const src = readFileSync(SYS_SRC, 'utf8');
124
+ const stripped = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
125
+ // Executable code (comments stripped) must test importExternalTexture
126
+ // presence and gate on the WebGPU backend — the grep-able two-path boundary.
127
+ expect(stripped.includes('importExternalTexture')).toBe(true);
128
+ expect(stripped.includes('backendKind')).toBe(true);
129
+ });
130
+ });
@@ -0,0 +1,73 @@
1
+ // feat-20260623-world-space-video-asset M3 / w6 — AC-04: VideoPlayer ECS
2
+ // component registration + mount + read-back.
3
+ //
4
+ // AC-04 (requirements.md:66): a new `VideoPlayer` component must register
5
+ // through `defineComponent`, mount on a spawned entity, and read back its
6
+ // field values via `world.get`. The reference field `clip` must be a handle
7
+ // type (NOT a bare GUID string); the field set must include at least
8
+ // playing / loop / currentTime.
9
+ //
10
+ // Decision anchors:
11
+ // - plan-strategy D-4 (clip: Handle<'VideoAsset','shared'>, brand string
12
+ // 'VideoAsset' aligns with AudioSource.clip: Handle<'AudioClipAsset',
13
+ // 'shared'>).
14
+ // - research Finding 5 (ECS schema vocab is closed — VideoPlayer fields use
15
+ // only shared<T> / bool / f32, no opaque/object field type).
16
+ // - charter P4 (consistent abstraction: same defineComponent pattern as
17
+ // AudioSource / Transform / Camera).
18
+
19
+ import { World } from '@forgeax/engine-ecs';
20
+ import type { Handle } from '@forgeax/engine-types';
21
+ import { toShared } from '@forgeax/engine-types';
22
+ import { describe, expect, it } from 'vitest';
23
+
24
+ import { VideoPlayer } from '../video-player';
25
+
26
+ describe('AC-04 — VideoPlayer component registration + mount + read-back', () => {
27
+ it('VideoPlayer is a schema token owned by the importing package', () => {
28
+ expect(VideoPlayer.name).toBe('VideoPlayer');
29
+ });
30
+
31
+ it('spawn entity with VideoPlayer + read back clip/playing/loop/currentTime', () => {
32
+ const world = new World();
33
+ const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(42);
34
+
35
+ const e = world
36
+ .spawn({
37
+ component: VideoPlayer,
38
+ data: { clip, playing: true, loop: true, currentTime: 3.5 },
39
+ })
40
+ .unwrap();
41
+
42
+ const r = world.get(e, VideoPlayer).unwrap();
43
+ // clip is the raw u32 carried inside the Handle brand (43 in handle terms).
44
+ expect(r.clip).toBe(42);
45
+ expect(r.playing).toBe(true);
46
+ expect(r.loop).toBe(true);
47
+ expect(r.currentTime).toBe(3.5);
48
+ });
49
+
50
+ it('clip is a handle type (numeric u32), not a bare GUID string', () => {
51
+ const world = new World();
52
+ const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(7);
53
+
54
+ const e = world.spawn({ component: VideoPlayer, data: { clip } }).unwrap();
55
+
56
+ const r = world.get(e, VideoPlayer).unwrap();
57
+ // AC-04 red line: the reference field is a handle (number), never a GUID
58
+ // string. A bare GUID string would fail the typeof check below.
59
+ expect(typeof r.clip).toBe('number');
60
+ expect(r.clip).toBe(7);
61
+ });
62
+
63
+ it('playing/loop/currentTime default to false/false/0 when omitted at spawn', () => {
64
+ const world = new World();
65
+ const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(1);
66
+
67
+ const e = world.spawn({ component: VideoPlayer, data: { clip } }).unwrap();
68
+ const r = world.get(e, VideoPlayer).unwrap();
69
+ expect(r.playing).toBe(false);
70
+ expect(r.loop).toBe(false);
71
+ expect(r.currentTime).toBe(0);
72
+ });
73
+ });
@@ -0,0 +1,75 @@
1
+ // feat-20260623-world-space-video-asset M3 / w8 — AC-05: multiple entities
2
+ // share one VideoAsset GUID with independent play state.
3
+ //
4
+ // AC-05 (requirements.md:67): two entities referencing the SAME VideoAsset
5
+ // GUID, each with its own VideoPlayer, may hold different loop / currentTime
6
+ // without crosstalk. Per-entity playing / loop / currentTime occupy
7
+ // independent archetype column slots (research Finding 6 — AudioSource already
8
+ // proves this pattern).
9
+ //
10
+ // Decision anchors:
11
+ // - requirements AC-05 (same GUID, different play state, no crosstalk).
12
+ // - research Finding 6 (ECS archetype columns isolate per-entity fields).
13
+
14
+ import { World } from '@forgeax/engine-ecs';
15
+ import type { Handle } from '@forgeax/engine-types';
16
+ import { toShared } from '@forgeax/engine-types';
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ import { VideoPlayer } from '../video-player';
20
+
21
+ describe('AC-05 — multi-entity VideoPlayer independent play state', () => {
22
+ it('two entities share one VideoAsset GUID with distinct loop/currentTime', () => {
23
+ const world = new World();
24
+ // Same VideoAsset handle (one GUID) referenced by both entities.
25
+ const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(99);
26
+
27
+ const a = world
28
+ .spawn({
29
+ component: VideoPlayer,
30
+ data: { clip, playing: true, loop: true, currentTime: 5 },
31
+ })
32
+ .unwrap();
33
+ const b = world
34
+ .spawn({
35
+ component: VideoPlayer,
36
+ data: { clip, playing: false, loop: false, currentTime: 10 },
37
+ })
38
+ .unwrap();
39
+
40
+ const ra = world.get(a, VideoPlayer).unwrap();
41
+ const rb = world.get(b, VideoPlayer).unwrap();
42
+
43
+ // Both reference the identical clip GUID.
44
+ expect(ra.clip).toBe(99);
45
+ expect(rb.clip).toBe(99);
46
+
47
+ // Play state is per-entity, no crosstalk.
48
+ expect(ra.loop).toBe(true);
49
+ expect(rb.loop).toBe(false);
50
+ expect(ra.currentTime).toBe(5);
51
+ expect(rb.currentTime).toBe(10);
52
+ expect(ra.playing).toBe(true);
53
+ expect(rb.playing).toBe(false);
54
+ });
55
+
56
+ it('mutating entity A play state does not leak into entity B', () => {
57
+ const world = new World();
58
+ const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(42);
59
+
60
+ const a = world.spawn({ component: VideoPlayer, data: { clip, currentTime: 0 } }).unwrap();
61
+ const b = world.spawn({ component: VideoPlayer, data: { clip, currentTime: 0 } }).unwrap();
62
+
63
+ // Advance only A's playhead.
64
+ world.set(a, VideoPlayer, { currentTime: 7.25, loop: true });
65
+
66
+ const ra = world.get(a, VideoPlayer).unwrap();
67
+ const rb = world.get(b, VideoPlayer).unwrap();
68
+
69
+ expect(ra.currentTime).toBe(7.25);
70
+ expect(ra.loop).toBe(true);
71
+ // B untouched — independent column slot.
72
+ expect(rb.currentTime).toBe(0);
73
+ expect(rb.loop).toBe(false);
74
+ });
75
+ });
@@ -0,0 +1,208 @@
1
+ // @forgeax/engine-graphics-extras - glyph layout algorithm
2
+ // (feat-20260531-world-space-msdf-text-rendering M4 / w15).
3
+ //
4
+ // Pure functions: lay out a `GlyphText` string against a `FontAsset`'s glyph
5
+ // metrics into per-glyph quad vertices (position + uv) and indices, plus a
6
+ // conservative bounding-sphere radius for pick (plan-strategy D-5). NO ECS,
7
+ // NO GPU -- the mesh baking (12-float stride fill + register) is the job of
8
+ // `glyph-mesh-bake.ts` (w17); this module produces only the geometry data.
9
+ //
10
+ // Layout model (plan-strategy D-2 / D-4 / D-5):
11
+ // - left-aligned: the pen advances by `metric.advance * fontSize` per glyph.
12
+ // - baseline at local y = 0 on the first line; the local space is Y-up so a
13
+ // glyph quad's top edge sits at `penY - bearingY*s + size.h*s` and its
14
+ // bottom edge at `penY - bearingY*s` (BMFont yoffset measures DOWN from
15
+ // the line top; we negate into Y-up so higher bearingY -> lower top).
16
+ // - `\n` resets penX to 0 and drops penY by `lineHeight * fontSize`
17
+ // (line-2 baseline = -lineHeight, AC-21).
18
+ // - missing codepoint -> notdef TOFU fallback; the glyph still counts and
19
+ // emits a quad (AC-14). A codepoint with neither a metric nor a notdef
20
+ // emits no quad but still advances by the notdef-or-zero advance.
21
+ // - empty string -> zero vertices / zero indices / radius 0 (the bake
22
+ // helper registers a 0-vertex mesh, which is legal; pick skips it).
23
+ //
24
+ // Vertex layout produced here is the 12-float canonical stride
25
+ // (PROCEDURAL_FLOATS_PER_VERTEX): position(vec3) + normal(vec3) + uv(vec2) +
26
+ // tangent(vec4). This module writes position + uv as real values and leaves
27
+ // normal/tangent as placeholder constants so the buffer is register-ready
28
+ // without a second pass (R-2: 12-float stride is a hard register gate). The
29
+ // per-vertex offsets are exported so the bake helper + tests share one SSOT.
30
+
31
+ import { PROCEDURAL_FLOATS_PER_VERTEX } from '@forgeax/engine-geometry';
32
+ import type { FontAsset, GlyphMetric } from '@forgeax/engine-types';
33
+ import { TextError } from '@forgeax/engine-types';
34
+
35
+ /**
36
+ * Canonical 12-float vertex stride (position vec3 + normal vec3 + uv vec2 +
37
+ * tangent vec4) owned by `PROCEDURAL_FLOATS_PER_VERTEX` in
38
+ * `@forgeax/engine-geometry`. R-2: the baked mesh must satisfy this stride or `register`
39
+ * fail-fasts with `mesh-vertex-stride-mismatch`.
40
+ */
41
+ /** Byte-free float offsets within a single 12-float vertex. */
42
+ export const VERTEX_OFFSET = {
43
+ position: 0, // vec3
44
+ normal: 3, // vec3 (placeholder (0,0,1))
45
+ uv: 6, // vec2
46
+ tangent: 8, // vec4 (placeholder (0,0,0,1))
47
+ } as const;
48
+
49
+ /** Soft per-frame concurrent-font ceiling (plan-strategy D-8 / AC-20). */
50
+ export const FONT_CONCURRENCY_LIMIT = 8;
51
+
52
+ /** Layout output: per-glyph quad geometry + conservative sphere radius. */
53
+ export interface GlyphLayoutResult {
54
+ /** 12-float-stride interleaved vertices (4 vertices per glyph). */
55
+ readonly vertices: Float32Array;
56
+ /** Triangle indices (6 per glyph: two triangles). */
57
+ readonly indices: Uint16Array;
58
+ /**
59
+ * Conservative bounding-sphere radius from the anchor (local origin) to the
60
+ * farthest glyph quad corner (plan-strategy D-5). The bake helper turns this
61
+ * into a cube AABB (half-side = radius) so pick is orientation-independent.
62
+ */
63
+ readonly radius: number;
64
+ }
65
+
66
+ // Module-level set tracking distinct FontAsset handle ids active in the
67
+ // current frame. The layout system resets this at the top of each frame
68
+ // (resetFontConcurrency) and calls trackFontConcurrency once per distinct
69
+ // font; the 9th distinct font throws a structured TextError (D-8 rejects
70
+ // silently evicting the oldest font).
71
+ const activeFontIds = new Set<number>();
72
+
73
+ /** Reset the per-frame concurrent-font tracker (call once at frame start). */
74
+ export function resetFontConcurrency(): void {
75
+ activeFontIds.clear();
76
+ }
77
+
78
+ /**
79
+ * Track one distinct FontAsset handle id as active this frame. Re-tracking an
80
+ * already-active id is a no-op; the (N+1)th distinct id beyond
81
+ * {@link FONT_CONCURRENCY_LIMIT} throws `TextError('font-concurrency-exceeded')`
82
+ * (plan-strategy D-8 / AC-20).
83
+ */
84
+ export function trackFontConcurrency(fontId: number): void {
85
+ if (activeFontIds.has(fontId)) return;
86
+ if (activeFontIds.size >= FONT_CONCURRENCY_LIMIT) {
87
+ throw new TextError({
88
+ code: 'font-concurrency-exceeded',
89
+ expected: String(FONT_CONCURRENCY_LIMIT),
90
+ hint: 'reuse a shared FontAsset across labels, or split text into fewer distinct fonts per frame',
91
+ detail: { active: activeFontIds.size, limit: FONT_CONCURRENCY_LIMIT, rejected: fontId },
92
+ });
93
+ }
94
+ activeFontIds.add(fontId);
95
+ }
96
+
97
+ const NEWLINE = '\n'.codePointAt(0) as number;
98
+
99
+ /**
100
+ * Lay out `text` against `font` at `fontSize`, producing per-glyph quad
101
+ * geometry (12-float stride) + indices + the conservative sphere radius.
102
+ *
103
+ * @param font The resolved FontAsset (glyph metrics + common block).
104
+ * @param text The authoring string (`\n` starts a new line).
105
+ * @param fontSize Uniform scale applied to all metric units.
106
+ */
107
+ export function layoutGlyphText(
108
+ font: FontAsset,
109
+ text: string,
110
+ fontSize: number,
111
+ ): GlyphLayoutResult {
112
+ const s = fontSize;
113
+ const { atlasWidth, atlasHeight, lineHeight } = font.common;
114
+
115
+ // First pass over code points: collect the renderable glyph quads.
116
+ const quads: Array<{ x0: number; y0: number; x1: number; y1: number; m: GlyphMetric }> = [];
117
+ let penX = 0;
118
+ let penY = 0;
119
+ let maxCornerDist = 0;
120
+
121
+ // Iterate by code point so surrogate pairs count as one glyph.
122
+ for (const ch of text) {
123
+ const cp = ch.codePointAt(0) as number;
124
+ if (cp === NEWLINE) {
125
+ penX = 0;
126
+ penY -= lineHeight * s;
127
+ continue;
128
+ }
129
+ const metric = font.glyphs[cp] ?? font.notdef;
130
+ if (metric === undefined) {
131
+ // Neither a glyph nor a notdef -> nothing to render; advance by zero so
132
+ // the cursor does not jump (rare: a font with no notdef and missing cp).
133
+ continue;
134
+ }
135
+ // Quad corners in Y-up local space (baseline at penY).
136
+ const x0 = penX + metric.bearingX * s;
137
+ const yTop = penY - metric.bearingY * s + metric.size.h * s;
138
+ const yBot = penY - metric.bearingY * s;
139
+ const x1 = x0 + metric.size.w * s;
140
+ quads.push({ x0, y0: yBot, x1, y1: yTop, m: metric });
141
+ maxCornerDist = Math.max(
142
+ maxCornerDist,
143
+ Math.hypot(x0, yBot),
144
+ Math.hypot(x1, yBot),
145
+ Math.hypot(x0, yTop),
146
+ Math.hypot(x1, yTop),
147
+ );
148
+ penX += metric.advance * s;
149
+ }
150
+
151
+ const glyphCount = quads.length;
152
+ const vertices = new Float32Array(glyphCount * 4 * PROCEDURAL_FLOATS_PER_VERTEX);
153
+ const indices = new Uint16Array(glyphCount * 6);
154
+
155
+ for (let g = 0; g < glyphCount; g++) {
156
+ const q = quads[g] as (typeof quads)[number];
157
+ const { region } = q.m;
158
+ // Atlas UV (top-left origin) normalized into [0,1].
159
+ const u0 = region.x / atlasWidth;
160
+ const u1 = (region.x + region.w) / atlasWidth;
161
+ const v0 = region.y / atlasHeight;
162
+ const v1 = (region.y + region.h) / atlasHeight;
163
+ // 4 corners: TL, TR, BR, BL (CCW); position z = 0 (billboard before).
164
+ // uv pairs the top edge (y1) with v0 and the bottom edge (y0) with v1.
165
+ writeVertex(vertices, g * 4 + 0, q.x0, q.y1, u0, v0);
166
+ writeVertex(vertices, g * 4 + 1, q.x1, q.y1, u1, v0);
167
+ writeVertex(vertices, g * 4 + 2, q.x1, q.y0, u1, v1);
168
+ writeVertex(vertices, g * 4 + 3, q.x0, q.y0, u0, v1);
169
+ const vbase = g * 4;
170
+ const ibase = g * 6;
171
+ indices[ibase + 0] = vbase + 0;
172
+ indices[ibase + 1] = vbase + 1;
173
+ indices[ibase + 2] = vbase + 2;
174
+ indices[ibase + 3] = vbase + 0;
175
+ indices[ibase + 4] = vbase + 2;
176
+ indices[ibase + 5] = vbase + 3;
177
+ }
178
+
179
+ return { vertices, indices, radius: maxCornerDist };
180
+ }
181
+
182
+ /** Write one 12-float vertex (position + placeholder normal + uv + placeholder tangent). */
183
+ function writeVertex(
184
+ out: Float32Array,
185
+ vertexIndex: number,
186
+ x: number,
187
+ y: number,
188
+ u: number,
189
+ v: number,
190
+ ): void {
191
+ const o = vertexIndex * PROCEDURAL_FLOATS_PER_VERTEX;
192
+ // position (vec3)
193
+ out[o + VERTEX_OFFSET.position + 0] = x;
194
+ out[o + VERTEX_OFFSET.position + 1] = y;
195
+ out[o + VERTEX_OFFSET.position + 2] = 0;
196
+ // normal placeholder (0,0,1)
197
+ out[o + VERTEX_OFFSET.normal + 0] = 0;
198
+ out[o + VERTEX_OFFSET.normal + 1] = 0;
199
+ out[o + VERTEX_OFFSET.normal + 2] = 1;
200
+ // uv (vec2)
201
+ out[o + VERTEX_OFFSET.uv + 0] = u;
202
+ out[o + VERTEX_OFFSET.uv + 1] = v;
203
+ // tangent placeholder (0,0,0,1)
204
+ out[o + VERTEX_OFFSET.tangent + 0] = 0;
205
+ out[o + VERTEX_OFFSET.tangent + 1] = 0;
206
+ out[o + VERTEX_OFFSET.tangent + 2] = 0;
207
+ out[o + VERTEX_OFFSET.tangent + 3] = 1;
208
+ }