@flighthq/materials 0.1.0

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 (60) hide show
  1. package/dist/classicMaterials.d.ts +5 -0
  2. package/dist/classicMaterials.d.ts.map +1 -0
  3. package/dist/classicMaterials.js +39 -0
  4. package/dist/classicMaterials.js.map +1 -0
  5. package/dist/color.d.ts +23 -0
  6. package/dist/color.d.ts.map +1 -0
  7. package/dist/color.js +291 -0
  8. package/dist/color.js.map +1 -0
  9. package/dist/colorTransform.d.ts +18 -0
  10. package/dist/colorTransform.d.ts.map +1 -0
  11. package/dist/colorTransform.js +119 -0
  12. package/dist/colorTransform.js.map +1 -0
  13. package/dist/colorTransformMaterial.d.ts +4 -0
  14. package/dist/colorTransformMaterial.d.ts.map +1 -0
  15. package/dist/colorTransformMaterial.js +18 -0
  16. package/dist/colorTransformMaterial.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +12 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/material.d.ts +6 -0
  22. package/dist/material.d.ts.map +1 -0
  23. package/dist/material.js +73 -0
  24. package/dist/material.js.map +1 -0
  25. package/dist/materialPresets.d.ts +13 -0
  26. package/dist/materialPresets.d.ts.map +1 -0
  27. package/dist/materialPresets.js +118 -0
  28. package/dist/materialPresets.js.map +1 -0
  29. package/dist/materialValidation.d.ts +7 -0
  30. package/dist/materialValidation.d.ts.map +1 -0
  31. package/dist/materialValidation.js +52 -0
  32. package/dist/materialValidation.js.map +1 -0
  33. package/dist/pbrExtensionMaterials.d.ts +9 -0
  34. package/dist/pbrExtensionMaterials.d.ts.map +1 -0
  35. package/dist/pbrExtensionMaterials.js +93 -0
  36. package/dist/pbrExtensionMaterials.js.map +1 -0
  37. package/dist/pbrMaterials.d.ts +6 -0
  38. package/dist/pbrMaterials.d.ts.map +1 -0
  39. package/dist/pbrMaterials.js +134 -0
  40. package/dist/pbrMaterials.js.map +1 -0
  41. package/dist/surfaceMaterial.d.ts +7 -0
  42. package/dist/surfaceMaterial.d.ts.map +1 -0
  43. package/dist/surfaceMaterial.js +41 -0
  44. package/dist/surfaceMaterial.js.map +1 -0
  45. package/dist/unlitMaterials.d.ts +10 -0
  46. package/dist/unlitMaterials.d.ts.map +1 -0
  47. package/dist/unlitMaterials.js +66 -0
  48. package/dist/unlitMaterials.js.map +1 -0
  49. package/package.json +38 -0
  50. package/src/classicMaterials.test.ts +48 -0
  51. package/src/color.test.ts +383 -0
  52. package/src/colorTransform.test.ts +384 -0
  53. package/src/colorTransformMaterial.test.ts +26 -0
  54. package/src/material.test.ts +110 -0
  55. package/src/materialPresets.test.ts +125 -0
  56. package/src/materialValidation.test.ts +130 -0
  57. package/src/pbrExtensionMaterials.test.ts +123 -0
  58. package/src/pbrMaterials.test.ts +91 -0
  59. package/src/surfaceMaterial.test.ts +69 -0
  60. package/src/unlitMaterials.test.ts +127 -0
@@ -0,0 +1,41 @@
1
+ import { BlendMode } from '@flighthq/types';
2
+ import { createMaterial } from './material';
3
+ // Builds a SurfaceMaterial carrying `kind` and the shared trailer at its defaults: opaque,
4
+ // single-sided, straight alpha, Normal blend, a 0.5 mask cutoff. Every 3D material constructor
5
+ // starts from this and adds its own maps and scalars. The result is a plain entity; callers
6
+ // mutate the returned object to set their fields before returning it.
7
+ export function createSurfaceMaterial(kind) {
8
+ const material = createMaterial(kind);
9
+ material.alphaCutoff = DEFAULT_ALPHA_CUTOFF;
10
+ material.alphaMode = DEFAULT_ALPHA_MODE;
11
+ material.alphaType = DEFAULT_ALPHA_TYPE;
12
+ material.blendMode = BlendMode.Normal;
13
+ material.doubleSided = DEFAULT_DOUBLE_SIDED;
14
+ return material;
15
+ }
16
+ // Returns the alpha mode of the material. The alpha mode controls how a material resolves
17
+ // coverage: 'opaque' ignores base-color alpha, 'mask' hard-cuts at `alphaCutoff`, 'blend'
18
+ // alpha-blends. Callers typically branch on this to configure blend state.
19
+ export function getMaterialAlphaMode(source) {
20
+ return source.alphaMode;
21
+ }
22
+ // Returns true when the material's alpha mode is 'blend'. Blended materials require a
23
+ // sorted draw order and a GPU blend equation.
24
+ export function isMaterialBlended(source) {
25
+ return source.alphaMode === 'blend';
26
+ }
27
+ // Returns true when the material's alpha mode is 'mask'. Masked materials discard fragments
28
+ // whose alpha is below `alphaCutoff`; no blend state is required.
29
+ export function isMaterialMasked(source) {
30
+ return source.alphaMode === 'mask';
31
+ }
32
+ // Returns true when the material's alpha mode is 'opaque'. Opaque materials ignore the
33
+ // base-color alpha channel; no blend state or discard is required.
34
+ export function isMaterialOpaque(source) {
35
+ return source.alphaMode === 'opaque';
36
+ }
37
+ const DEFAULT_ALPHA_CUTOFF = 0.5;
38
+ const DEFAULT_ALPHA_MODE = 'opaque';
39
+ const DEFAULT_ALPHA_TYPE = 'straight';
40
+ const DEFAULT_DOUBLE_SIDED = false;
41
+ //# sourceMappingURL=surfaceMaterial.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surfaceMaterial.js","sourceRoot":"","sources":["../src/surfaceMaterial.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE5C,2FAA2F;AAC3F,+FAA+F;AAC/F,4FAA4F;AAC5F,sEAAsE;AACtE,MAAM,UAAU,qBAAqB,CAAC,IAAU;IAC9C,MAAM,QAAQ,GAAG,cAAc,CAAC,IAAI,CAAoB,CAAC;IACzD,QAAQ,CAAC,WAAW,GAAG,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,SAAS,GAAG,kBAAkB,CAAC;IACxC,QAAQ,CAAC,SAAS,GAAG,kBAAkB,CAAC;IACxC,QAAQ,CAAC,SAAS,GAAG,SAAS,CAAC,MAAM,CAAC;IACtC,QAAQ,CAAC,WAAW,GAAG,oBAAoB,CAAC;IAC5C,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,0FAA0F;AAC1F,0FAA0F;AAC1F,2EAA2E;AAC3E,MAAM,UAAU,oBAAoB,CAAC,MAAiC;IACpE,OAAO,MAAM,CAAC,SAAS,CAAC;AAC1B,CAAC;AAED,sFAAsF;AACtF,8CAA8C;AAC9C,MAAM,UAAU,iBAAiB,CAAC,MAAiC;IACjE,OAAO,MAAM,CAAC,SAAS,KAAK,OAAO,CAAC;AACtC,CAAC;AAED,4FAA4F;AAC5F,kEAAkE;AAClE,MAAM,UAAU,gBAAgB,CAAC,MAAiC;IAChE,OAAO,MAAM,CAAC,SAAS,KAAK,MAAM,CAAC;AACrC,CAAC;AAED,uFAAuF;AACvF,mEAAmE;AACnE,MAAM,UAAU,gBAAgB,CAAC,MAAiC;IAChE,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,CAAC;AACvC,CAAC;AAED,MAAM,oBAAoB,GAAG,GAAG,CAAC;AACjC,MAAM,kBAAkB,GAAsB,QAAQ,CAAC;AACvD,MAAM,kBAAkB,GAAc,UAAU,CAAC;AACjD,MAAM,oBAAoB,GAAG,KAAK,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { DepthMaterial, EmissiveMaterial, MatcapMaterial, NormalMaterial, ToonMaterial, UnlitMaterial, VertexColorMaterial, WireframeMaterial } from '@flighthq/types';
2
+ export declare function createDepthMaterial(opts?: Readonly<Partial<DepthMaterial>>): DepthMaterial;
3
+ export declare function createEmissiveMaterial(opts?: Readonly<Partial<EmissiveMaterial>>): EmissiveMaterial;
4
+ export declare function createMatcapMaterial(opts?: Readonly<Partial<MatcapMaterial>>): MatcapMaterial;
5
+ export declare function createNormalMaterial(opts?: Readonly<Partial<NormalMaterial>>): NormalMaterial;
6
+ export declare function createToonMaterial(opts?: Readonly<Partial<ToonMaterial>>): ToonMaterial;
7
+ export declare function createUnlitMaterial(opts?: Readonly<Partial<UnlitMaterial>>): UnlitMaterial;
8
+ export declare function createVertexColorMaterial(opts?: Readonly<Partial<VertexColorMaterial>>): VertexColorMaterial;
9
+ export declare function createWireframeMaterial(opts?: Readonly<Partial<WireframeMaterial>>): WireframeMaterial;
10
+ //# sourceMappingURL=unlitMaterials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unlitMaterials.d.ts","sourceRoot":"","sources":["../src/unlitMaterials.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,YAAY,EACZ,aAAa,EACb,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAgBzB,wBAAgB,mBAAmB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,GAAG,aAAa,CAK1F;AAID,wBAAgB,sBAAsB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC,GAAG,gBAAgB,CAMnG;AAID,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC,GAAG,cAAc,CAK7F;AAGD,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC,GAAG,cAAc,CAK7F;AAID,wBAAgB,kBAAkB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,GAAG,YAAY,CAOvF;AAID,wBAAgB,mBAAmB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,GAAG,aAAa,CAK1F;AAGD,wBAAgB,yBAAyB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC,GAAG,mBAAmB,CAI5G;AAGD,wBAAgB,uBAAuB,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,GAAG,iBAAiB,CAKtG"}
@@ -0,0 +1,66 @@
1
+ import { DepthMaterialKind, EmissiveMaterialKind, MatcapMaterialKind, NormalMaterialKind, ToonMaterialKind, UnlitMaterialKind, VertexColorMaterialKind, WireframeMaterialKind, } from '@flighthq/types';
2
+ import { createSurfaceMaterial } from './surfaceMaterial';
3
+ // Depth-output pass material. `near`/`far` default to a unit range; the depth pass overrides
4
+ // them with the camera's range when used as pass infrastructure.
5
+ export function createDepthMaterial(opts) {
6
+ const material = createSurfaceMaterial(DepthMaterialKind);
7
+ material.far = opts?.far ?? 1;
8
+ material.near = opts?.near ?? 0;
9
+ return material;
10
+ }
11
+ // Self-illuminating, lighting-independent material. `emissive` defaults to white,
12
+ // `emissiveStrength` to 1 (> 1 drives bloom on GPU backends), the map to null.
13
+ export function createEmissiveMaterial(opts) {
14
+ const material = createSurfaceMaterial(EmissiveMaterialKind);
15
+ material.emissive = opts?.emissive ?? 0xffffffff;
16
+ material.emissiveMap = opts?.emissiveMap ?? null;
17
+ material.emissiveStrength = opts?.emissiveStrength ?? 1;
18
+ return material;
19
+ }
20
+ // Material-capture (matcap) material: a prebaked lit sphere sampled by the view-space normal.
21
+ // `matcap` defaults to null, `tint` to white. Lighting-independent.
22
+ export function createMatcapMaterial(opts) {
23
+ const material = createSurfaceMaterial(MatcapMaterialKind);
24
+ material.matcap = opts?.matcap ?? null;
25
+ material.tint = opts?.tint ?? 0xffffffff;
26
+ return material;
27
+ }
28
+ // Normal-output pass material. `normalMap` defaults to null, `normalScale` to 1.
29
+ export function createNormalMaterial(opts) {
30
+ const material = createSurfaceMaterial(NormalMaterialKind);
31
+ material.normalMap = opts?.normalMap ?? null;
32
+ material.normalScale = opts?.normalScale ?? 1;
33
+ return material;
34
+ }
35
+ // Cel-shaded material: diffuse N·L quantized through a 1D ramp into stepped bands. `baseColor`
36
+ // defaults to white, maps to null, `steps` to 3 (the band count used when no ramp is bound).
37
+ export function createToonMaterial(opts) {
38
+ const material = createSurfaceMaterial(ToonMaterialKind);
39
+ material.baseColor = opts?.baseColor ?? 0xffffffff;
40
+ material.baseColorMap = opts?.baseColorMap ?? null;
41
+ material.ramp = opts?.ramp ?? null;
42
+ material.steps = opts?.steps ?? 3;
43
+ return material;
44
+ }
45
+ // Lighting-independent flat-color material. `baseColor` defaults to white, `baseColorMap` to
46
+ // null. Full fidelity on every backend including Canvas2D.
47
+ export function createUnlitMaterial(opts) {
48
+ const material = createSurfaceMaterial(UnlitMaterialKind);
49
+ material.baseColor = opts?.baseColor ?? 0xffffffff;
50
+ material.baseColorMap = opts?.baseColorMap ?? null;
51
+ return material;
52
+ }
53
+ // Uses the mesh's `color0` vertex attribute as unlit surface color. `tint` defaults to white.
54
+ export function createVertexColorMaterial(opts) {
55
+ const material = createSurfaceMaterial(VertexColorMaterialKind);
56
+ material.tint = opts?.tint ?? 0xffffffff;
57
+ return material;
58
+ }
59
+ // Edge-only debug material. `color` defaults to white, `thickness` to 1 pixel. No maps.
60
+ export function createWireframeMaterial(opts) {
61
+ const material = createSurfaceMaterial(WireframeMaterialKind);
62
+ material.color = opts?.color ?? 0xffffffff;
63
+ material.thickness = opts?.thickness ?? 1;
64
+ return material;
65
+ }
66
+ //# sourceMappingURL=unlitMaterials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unlitMaterials.js","sourceRoot":"","sources":["../src/unlitMaterials.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@flighthq/materials",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/entity": "0.1.0",
31
+ "@flighthq/types": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.3.0"
35
+ },
36
+ "description": "Color transform and material utilities",
37
+ "sideEffects": false
38
+ }
@@ -0,0 +1,48 @@
1
+ import { BlinnPhongMaterialKind, LambertMaterialKind, PhongMaterialKind } from '@flighthq/types';
2
+
3
+ import { createBlinnPhongMaterial, createLambertMaterial, createPhongMaterial } from './classicMaterials';
4
+
5
+ describe('createBlinnPhongMaterial', () => {
6
+ it('creates a white Blinn-Phong material with a shininess of 32', () => {
7
+ const material = createBlinnPhongMaterial();
8
+ expect(material.kind).toBe(BlinnPhongMaterialKind);
9
+ expect(material.diffuse).toBe(0xffffffff);
10
+ expect(material.specular).toBe(0xffffffff);
11
+ expect(material.shininess).toBe(32);
12
+ expect(material.normalMap).toBeNull();
13
+ });
14
+
15
+ it('applies overrides', () => {
16
+ expect(createBlinnPhongMaterial({ shininess: 8 }).shininess).toBe(8);
17
+ });
18
+ });
19
+
20
+ describe('createLambertMaterial', () => {
21
+ it('creates a white diffuse material with no emission', () => {
22
+ const material = createLambertMaterial();
23
+ expect(material.kind).toBe(LambertMaterialKind);
24
+ expect(material.diffuse).toBe(0xffffffff);
25
+ expect(material.emissive).toBe(0x000000ff);
26
+ expect(material.diffuseMap).toBeNull();
27
+ expect(material.emissiveMap).toBeNull();
28
+ });
29
+
30
+ it('applies overrides', () => {
31
+ expect(createLambertMaterial({ diffuse: 0xff0000ff }).diffuse).toBe(0xff0000ff);
32
+ });
33
+ });
34
+
35
+ describe('createPhongMaterial', () => {
36
+ it('creates a white Phong material with a shininess of 32', () => {
37
+ const material = createPhongMaterial();
38
+ expect(material.kind).toBe(PhongMaterialKind);
39
+ expect(material.diffuse).toBe(0xffffffff);
40
+ expect(material.specular).toBe(0xffffffff);
41
+ expect(material.shininess).toBe(32);
42
+ expect(material.normalScale).toBe(1);
43
+ });
44
+
45
+ it('applies overrides', () => {
46
+ expect(createPhongMaterial({ specular: 0x808080ff }).specular).toBe(0x808080ff);
47
+ });
48
+ });
@@ -0,0 +1,383 @@
1
+ import {
2
+ computeRgbHexString,
3
+ createHslColor,
4
+ createHsvColor,
5
+ createLinearColor,
6
+ getColorContrastRatio,
7
+ getColorLuminance,
8
+ hslToRgb,
9
+ hsvToRgb,
10
+ lerpColor,
11
+ lerpLinearColor,
12
+ linearChannelToSrgb,
13
+ packColor,
14
+ packLinearToColor,
15
+ premultiplyColorAlpha,
16
+ rgbToHsl,
17
+ rgbToHsv,
18
+ unpackColorRgba,
19
+ unpackColorToLinear,
20
+ unpremultiplyColorAlpha,
21
+ } from './color';
22
+
23
+ describe('computeRgbHexString', () => {
24
+ it('returns a 6-digit hex string of the lower 24 bits', () => {
25
+ expect(computeRgbHexString(0x00ff0000)).toBe('#ff0000');
26
+ expect(computeRgbHexString(0x0000ff00)).toBe('#00ff00');
27
+ expect(computeRgbHexString(0x000000ff)).toBe('#0000ff');
28
+ expect(computeRgbHexString(0x00000000)).toBe('#000000');
29
+ expect(computeRgbHexString(0x00ffffff)).toBe('#ffffff');
30
+ });
31
+ });
32
+
33
+ describe('createHslColor', () => {
34
+ it('allocates a zeroed three-component HSL color', () => {
35
+ expect(createHslColor()).toEqual([0, 0, 0]);
36
+ });
37
+ });
38
+
39
+ describe('createHsvColor', () => {
40
+ it('allocates a zeroed three-component HSV color', () => {
41
+ expect(createHsvColor()).toEqual([0, 0, 0]);
42
+ });
43
+ });
44
+
45
+ describe('createLinearColor', () => {
46
+ it('allocates a zeroed four-component color', () => {
47
+ expect(createLinearColor()).toEqual([0, 0, 0, 0]);
48
+ });
49
+ });
50
+
51
+ describe('getColorContrastRatio', () => {
52
+ it('returns 21 for black on white', () => {
53
+ expect(getColorContrastRatio(0xffffffff, 0x000000ff)).toBeCloseTo(21, 0);
54
+ });
55
+ it('returns 1 for identical colors', () => {
56
+ expect(getColorContrastRatio(0x808080ff, 0x808080ff)).toBeCloseTo(1, 3);
57
+ });
58
+ it('is symmetric — order of arguments does not matter', () => {
59
+ const ratio1 = getColorContrastRatio(0xffffffff, 0x808080ff);
60
+ const ratio2 = getColorContrastRatio(0x808080ff, 0xffffffff);
61
+ expect(ratio1).toBeCloseTo(ratio2, 8);
62
+ });
63
+ });
64
+
65
+ describe('getColorLuminance', () => {
66
+ it('returns 1 for white', () => {
67
+ expect(getColorLuminance(0xffffffff)).toBeCloseTo(1, 5);
68
+ });
69
+ it('returns 0 for black', () => {
70
+ expect(getColorLuminance(0x000000ff)).toBeCloseTo(0, 5);
71
+ });
72
+ it('ignores the alpha channel', () => {
73
+ expect(getColorLuminance(0xffffffff)).toBe(getColorLuminance(0xffffff00));
74
+ });
75
+ it('matches expected Rec. 709 value for mid-gray sRGB', () => {
76
+ // sRGB 0x808080 → linear ≈ 0.21586; Rec.709: 0.2126*R + 0.7152*G + 0.0722*B = R (equal channels)
77
+ expect(getColorLuminance(0x808080ff)).toBeCloseTo(0.21586, 4);
78
+ });
79
+ });
80
+
81
+ describe('hslToRgb', () => {
82
+ it('produces achromatic RGB for saturation = 0', () => {
83
+ const out: [number, number, number, number] = [0, 0, 0, 1];
84
+ hslToRgb(out, 0, 0, 0.5);
85
+ expect(out[0]).toBeCloseTo(0.5);
86
+ expect(out[1]).toBeCloseTo(0.5);
87
+ expect(out[2]).toBeCloseTo(0.5);
88
+ });
89
+ it('round-trips with rgbToHsl for a primary color', () => {
90
+ // Red: hue=0, s=1, l=0.5 → RGB (1, 0, 0)
91
+ const out: [number, number, number, number] = [0, 0, 0, 1];
92
+ hslToRgb(out, 0, 1, 0.5);
93
+ expect(out[0]).toBeCloseTo(1, 5);
94
+ expect(out[1]).toBeCloseTo(0, 5);
95
+ expect(out[2]).toBeCloseTo(0, 5);
96
+ });
97
+ it('does not modify out[3] (alpha)', () => {
98
+ const out: [number, number, number, number] = [0, 0, 0, 0.75];
99
+ hslToRgb(out, 120, 1, 0.5);
100
+ expect(out[3]).toBe(0.75);
101
+ });
102
+ });
103
+
104
+ describe('hsvToRgb', () => {
105
+ it('produces achromatic RGB for saturation = 0', () => {
106
+ const out: [number, number, number, number] = [0, 0, 0, 1];
107
+ hsvToRgb(out, 0, 0, 0.6);
108
+ expect(out[0]).toBeCloseTo(0.6);
109
+ expect(out[1]).toBeCloseTo(0.6);
110
+ expect(out[2]).toBeCloseTo(0.6);
111
+ });
112
+ it('produces pure red for h=0, s=1, v=1', () => {
113
+ const out: [number, number, number, number] = [0, 0, 0, 1];
114
+ hsvToRgb(out, 0, 1, 1);
115
+ expect(out[0]).toBeCloseTo(1, 5);
116
+ expect(out[1]).toBeCloseTo(0, 5);
117
+ expect(out[2]).toBeCloseTo(0, 5);
118
+ });
119
+ it('round-trips with rgbToHsv for a pure green', () => {
120
+ const out: [number, number, number, number] = [0, 0, 0, 1];
121
+ hsvToRgb(out, 120, 1, 1);
122
+ expect(out[0]).toBeCloseTo(0, 5);
123
+ expect(out[1]).toBeCloseTo(1, 5);
124
+ expect(out[2]).toBeCloseTo(0, 5);
125
+ });
126
+ });
127
+
128
+ describe('lerpColor', () => {
129
+ it('returns start at t=0', () => {
130
+ expect(lerpColor(0xff0000ff, 0x0000ffff, 0)).toBe(0xff0000ff);
131
+ });
132
+ it('returns end at t=1', () => {
133
+ expect(lerpColor(0xff0000ff, 0x0000ffff, 1)).toBe(0x0000ffff);
134
+ });
135
+ it('clamps t below 0 to 0', () => {
136
+ expect(lerpColor(0xff0000ff, 0x0000ffff, -1)).toBe(0xff0000ff);
137
+ });
138
+ it('clamps t above 1 to 1', () => {
139
+ expect(lerpColor(0xff0000ff, 0x0000ffff, 2)).toBe(0x0000ffff);
140
+ });
141
+ it('midpoint between black and white is approximately mid-gray', () => {
142
+ // Black (linear 0) and white (linear 1) — midpoint in linear is 0.5, which in sRGB ≈ 0xbc
143
+ const mid = lerpColor(0x000000ff, 0xffffffff, 0.5);
144
+ const r = (mid >>> 24) & 0xff;
145
+ // linear 0.5 → sRGB ≈ 0xbc (188)
146
+ expect(r).toBeCloseTo(0xbc, -1);
147
+ });
148
+ });
149
+
150
+ describe('lerpLinearColor', () => {
151
+ it('returns start at t=0', () => {
152
+ const out = createLinearColor();
153
+ const start: [number, number, number, number] = [1, 0, 0, 1];
154
+ const end: [number, number, number, number] = [0, 0, 1, 1];
155
+ lerpLinearColor(out, start, end, 0);
156
+ expect(out).toEqual(start);
157
+ });
158
+ it('returns end at t=1', () => {
159
+ const out = createLinearColor();
160
+ const start: [number, number, number, number] = [1, 0, 0, 1];
161
+ const end: [number, number, number, number] = [0, 0, 1, 1];
162
+ lerpLinearColor(out, start, end, 1);
163
+ expect(out).toEqual(end);
164
+ });
165
+ it('is alias-safe when out is the same object as start', () => {
166
+ const start: [number, number, number, number] = [1, 0, 0, 1];
167
+ const end: [number, number, number, number] = [0, 0, 1, 1];
168
+ const out = start;
169
+ lerpLinearColor(out, out, end, 0.5);
170
+ expect(out[0]).toBeCloseTo(0.5, 8);
171
+ expect(out[2]).toBeCloseTo(0.5, 8);
172
+ });
173
+ it('returns the out instance', () => {
174
+ const out = createLinearColor();
175
+ const start: [number, number, number, number] = [0, 0, 0, 0];
176
+ const end: [number, number, number, number] = [1, 1, 1, 1];
177
+ expect(lerpLinearColor(out, start, end, 0.5)).toBe(out);
178
+ });
179
+ });
180
+
181
+ describe('linearChannelToSrgb', () => {
182
+ it('maps 0 → 0', () => {
183
+ expect(linearChannelToSrgb(0)).toBe(0);
184
+ });
185
+ it('maps 1 → 1', () => {
186
+ expect(linearChannelToSrgb(1)).toBeCloseTo(1, 8);
187
+ });
188
+ it('is the inverse of the sRGB decode for a round-trip', () => {
189
+ // A known linear value near the breakpoint.
190
+ const linear = 0.5;
191
+ const srgb = linearChannelToSrgb(linear);
192
+ // Re-encode: sRGB(linear(x)) should round-trip within floating-point tolerance.
193
+ const roundTrip = srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
194
+ expect(roundTrip).toBeCloseTo(linear, 5);
195
+ });
196
+ });
197
+
198
+ describe('packColor', () => {
199
+ it('packs white components to 0xffffffff', () => {
200
+ expect(packColor(1, 1, 1, 1)).toBe(0xffffffff);
201
+ });
202
+ it('packs black components to 0x000000ff', () => {
203
+ expect(packColor(0, 0, 0, 1)).toBe(0x000000ff);
204
+ });
205
+ it('clamps out-of-range values', () => {
206
+ expect(packColor(2, -1, 0.5, 1)).toBe(packColor(1, 0, 0.5, 1));
207
+ });
208
+ });
209
+
210
+ describe('packLinearToColor', () => {
211
+ it('is the inverse of unpackColorToLinear for white', () => {
212
+ const out = createLinearColor();
213
+ unpackColorToLinear(out, 0xffffffff);
214
+ expect(packLinearToColor(out)).toBe(0xffffffff);
215
+ });
216
+ it('is the inverse of unpackColorToLinear for black (opaque)', () => {
217
+ const out = createLinearColor();
218
+ unpackColorToLinear(out, 0x000000ff);
219
+ expect(packLinearToColor(out)).toBe(0x000000ff);
220
+ });
221
+ it('passes alpha through without gamma encoding', () => {
222
+ // Alpha 0x80 / 0xff ≈ 0.502; should round-trip through linear-space alpha.
223
+ const color = 0x000000_80 >>> 0;
224
+ const out = createLinearColor();
225
+ unpackColorToLinear(out, color);
226
+ const repacked = packLinearToColor(out);
227
+ expect(repacked & 0xff).toBe(0x80);
228
+ });
229
+ it('round-trips a mid-gray color within 1 LSB', () => {
230
+ const color = 0x808080ff >>> 0;
231
+ const out = createLinearColor();
232
+ unpackColorToLinear(out, color);
233
+ const repacked = packLinearToColor(out);
234
+ const origR = (color >>> 24) & 0xff;
235
+ const repR = (repacked >>> 24) & 0xff;
236
+ expect(Math.abs(repR - origR)).toBeLessThanOrEqual(1);
237
+ });
238
+ });
239
+
240
+ describe('premultiplyColorAlpha', () => {
241
+ it('returns fully-opaque color unchanged (alpha=1)', () => {
242
+ expect(premultiplyColorAlpha(0xff0000ff)).toBe(0xff0000ff);
243
+ });
244
+ it('multiplies RGB by alpha and preserves the alpha channel', () => {
245
+ // Red at 50% alpha: R=255*0.502≈128, A unchanged=0x80
246
+ const result = premultiplyColorAlpha(0xff00007f);
247
+ expect((result >>> 24) & 0xff).toBeCloseTo(0x7f, -1);
248
+ expect(result & 0xff).toBe(0x7f);
249
+ });
250
+ it('returns black-with-alpha-0 for fully transparent color', () => {
251
+ const result = premultiplyColorAlpha(0xff000000);
252
+ expect(result & 0xff).toBe(0);
253
+ expect((result >>> 24) & 0xff).toBe(0);
254
+ });
255
+ it('round-trips with unpremultiplyColorAlpha for fully-opaque', () => {
256
+ const color = 0xab3456ff;
257
+ expect(unpremultiplyColorAlpha(premultiplyColorAlpha(color))).toBe(color);
258
+ });
259
+ });
260
+
261
+ describe('rgbToHsl', () => {
262
+ it('converts pure red to hue=0, s=1, l=0.5', () => {
263
+ const out: [number, number, number] = [0, 0, 0];
264
+ rgbToHsl(out, 0xff0000ff);
265
+ expect(out[0]).toBeCloseTo(0, 3);
266
+ expect(out[1]).toBeCloseTo(1, 5);
267
+ expect(out[2]).toBeCloseTo(0.5, 5);
268
+ });
269
+ it('converts white to hue=0, s=0, l=1', () => {
270
+ const out: [number, number, number] = [0, 0, 0];
271
+ rgbToHsl(out, 0xffffffff);
272
+ expect(out[0]).toBe(0);
273
+ expect(out[1]).toBe(0);
274
+ expect(out[2]).toBeCloseTo(1, 5);
275
+ });
276
+ it('converts black to hue=0, s=0, l=0', () => {
277
+ const out: [number, number, number] = [0, 0, 0];
278
+ rgbToHsl(out, 0x000000ff);
279
+ expect(out[0]).toBe(0);
280
+ expect(out[1]).toBe(0);
281
+ expect(out[2]).toBeCloseTo(0, 5);
282
+ });
283
+ it('returns the out instance', () => {
284
+ const out: [number, number, number] = [0, 0, 0];
285
+ expect(rgbToHsl(out, 0xff0000ff)).toBe(out);
286
+ });
287
+ it('round-trips with hslToRgb within 1 LSB', () => {
288
+ const hsl: [number, number, number] = [0, 0, 0];
289
+ rgbToHsl(hsl, 0x3c8ab5ff);
290
+ const rgb: [number, number, number, number] = [0, 0, 0, 1];
291
+ hslToRgb(rgb, hsl[0], hsl[1], hsl[2]);
292
+ const repacked =
293
+ (Math.round(rgb[0] * 0xff) << 24) | (Math.round(rgb[1] * 0xff) << 16) | (Math.round(rgb[2] * 0xff) << 8) | 0xff;
294
+ const orig = 0x3c8ab5ff >>> 0;
295
+ expect(Math.abs(((repacked >>> 24) & 0xff) - ((orig >>> 24) & 0xff))).toBeLessThanOrEqual(1);
296
+ });
297
+ });
298
+
299
+ describe('rgbToHsv', () => {
300
+ it('converts pure red to hue=0, s=1, v=1', () => {
301
+ const out: [number, number, number] = [0, 0, 0];
302
+ rgbToHsv(out, 0xff0000ff);
303
+ expect(out[0]).toBeCloseTo(0, 3);
304
+ expect(out[1]).toBeCloseTo(1, 5);
305
+ expect(out[2]).toBeCloseTo(1, 5);
306
+ });
307
+ it('converts black to s=0, v=0', () => {
308
+ const out: [number, number, number] = [0, 0, 0];
309
+ rgbToHsv(out, 0x000000ff);
310
+ expect(out[1]).toBe(0);
311
+ expect(out[2]).toBeCloseTo(0, 5);
312
+ });
313
+ it('converts white to s=0, v=1', () => {
314
+ const out: [number, number, number] = [0, 0, 0];
315
+ rgbToHsv(out, 0xffffffff);
316
+ expect(out[1]).toBe(0);
317
+ expect(out[2]).toBeCloseTo(1, 5);
318
+ });
319
+ it('returns the out instance', () => {
320
+ const out: [number, number, number] = [0, 0, 0];
321
+ expect(rgbToHsv(out, 0xff0000ff)).toBe(out);
322
+ });
323
+ });
324
+
325
+ describe('unpackColorRgba', () => {
326
+ it('extracts white as all-ones', () => {
327
+ const out: [number, number, number, number] = [0, 0, 0, 0];
328
+ unpackColorRgba(out, 0xffffffff);
329
+ expect(out[0]).toBeCloseTo(1, 5);
330
+ expect(out[1]).toBeCloseTo(1, 5);
331
+ expect(out[2]).toBeCloseTo(1, 5);
332
+ expect(out[3]).toBeCloseTo(1, 5);
333
+ });
334
+ it('does NOT gamma-decode — mid sRGB stays above the linear midpoint', () => {
335
+ const out: [number, number, number, number] = [0, 0, 0, 0];
336
+ unpackColorRgba(out, 0x808080ff);
337
+ // Without gamma decode, 0x80/0xff ≈ 0.502 — above the linear midpoint.
338
+ expect(out[0]).toBeCloseTo(0x80 / 0xff, 4);
339
+ });
340
+ });
341
+
342
+ describe('unpackColorToLinear', () => {
343
+ it('decodes white and black exactly at the endpoints', () => {
344
+ const out = createLinearColor();
345
+ expect(unpackColorToLinear(out, 0xffffffff)).toEqual([1, 1, 1, 1]);
346
+ expect(unpackColorToLinear(out, 0x000000ff)).toEqual([0, 0, 0, 1]);
347
+ });
348
+
349
+ it('passes alpha through linearly without gamma decode', () => {
350
+ const out = createLinearColor();
351
+ unpackColorToLinear(out, 0x00000080);
352
+ expect(out[3]).toBeCloseTo(0x80 / 0xff, 6);
353
+ });
354
+
355
+ it('gamma-decodes RGB so mid sRgb is below the linear midpoint', () => {
356
+ const out = createLinearColor();
357
+ unpackColorToLinear(out, 0x808080ff);
358
+ expect(out[0]).toBeCloseTo(0.21586, 4);
359
+ expect(out[0]).toBe(out[1]);
360
+ expect(out[1]).toBe(out[2]);
361
+ });
362
+
363
+ it('returns the same out instance it was given', () => {
364
+ const out = createLinearColor();
365
+ expect(unpackColorToLinear(out, 0xff0000ff)).toBe(out);
366
+ });
367
+ });
368
+
369
+ describe('unpremultiplyColorAlpha', () => {
370
+ it('returns fully-opaque color unchanged', () => {
371
+ expect(unpremultiplyColorAlpha(0xff0000ff)).toBe(0xff0000ff);
372
+ });
373
+ it('divides RGB by alpha and preserves the alpha channel', () => {
374
+ // Premultiplied: R=128 (≈0x80), A=0x80 → straight: R ≈ 255
375
+ const premul = premultiplyColorAlpha((0xff0000ff & (0xffffff80 >>> 0)) | 0x80);
376
+ const result = unpremultiplyColorAlpha(premul);
377
+ expect(result & 0xff).toBe(0x80);
378
+ });
379
+ it('returns the input unchanged for alpha=0', () => {
380
+ const color = 0xff000000;
381
+ expect(unpremultiplyColorAlpha(color)).toBe(color);
382
+ });
383
+ });