@dice-o-rolla/dice-renderer-three 0.1.1 → 0.2.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ThreeMaterialFactory, type DiceMaterialStyle, type FaceLabel, type FaceMaterialOptions, } from './material-factory.js';
2
- export { createPolyhedronGeometry, createFaceUvs, DEFAULT_THREE_THEME, getFaceLabel, ThreeDiceMeshFactory, type ThreeDiceMesh, } from './mesh-factory.js';
2
+ export { createPolyhedronGeometry, createFaceUvs, DEFAULT_THREE_THEME, getFaceLabel, ThreeDiceMeshFactory, type ThreeDiceMesh, type FaceMaterialContext, type FaceMaterialResource, type ThreeFaceMaterialProvider, } from './mesh-factory.js';
3
3
  export { ThreeCamera, ThreeLighting, ThreeScene } from './scene.js';
4
4
  export { TopDownCamera, type TopDownCameraOptions } from './top-down-camera.js';
5
5
  export { TopDownDiceRenderer, type TopDownDiceRendererOptions } from './top-down-dice-renderer.js';
@@ -1,18 +1,32 @@
1
1
  import type { PolygonDefinition, PolyhedronDefinition } from '@dice-o-rolla/dice-geometry';
2
- import type { RendererTheme } from '@dice-o-rolla/dice-renderer';
2
+ import type { RendererTheme, VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
3
3
  import { BufferGeometry, Mesh, type MeshStandardMaterial } from 'three';
4
- import { ThreeMaterialFactory } from './material-factory.js';
5
4
  type Vector2Tuple = readonly [u: number, v: number];
6
5
  export declare const DEFAULT_THREE_THEME: RendererTheme;
7
6
  export interface ThreeDiceMesh {
8
7
  readonly mesh: Mesh<BufferGeometry, MeshStandardMaterial[]>;
9
8
  dispose(): void;
10
9
  }
10
+ export interface FaceMaterialContext {
11
+ readonly label: string | number | readonly number[];
12
+ readonly faceValue: number;
13
+ readonly theme: RendererTheme;
14
+ readonly preset?: VisualPresetDescriptor;
15
+ readonly labelScale?: number;
16
+ }
17
+ export interface FaceMaterialResource {
18
+ readonly material: MeshStandardMaterial;
19
+ dispose(): void;
20
+ }
21
+ export interface ThreeFaceMaterialProvider {
22
+ createFace(context: FaceMaterialContext): FaceMaterialResource;
23
+ dispose?(): void;
24
+ }
11
25
  export declare function createPolyhedronGeometry(definition: PolyhedronDefinition, scale?: number): BufferGeometry;
12
26
  export declare class ThreeDiceMeshFactory {
13
27
  #private;
14
- constructor(materials?: ThreeMaterialFactory);
15
- create(definition: PolyhedronDefinition, theme?: RendererTheme, scale?: number, faceLabels?: Readonly<Record<number, string | number>>): ThreeDiceMesh;
28
+ constructor(materials?: ThreeFaceMaterialProvider);
29
+ create(definition: PolyhedronDefinition, theme?: RendererTheme, scale?: number, faceLabels?: Readonly<Record<number, string | number>>, preset?: VisualPresetDescriptor): ThreeDiceMesh;
16
30
  createD6(theme?: RendererTheme, scale?: number): ThreeDiceMesh;
17
31
  }
18
32
  export declare function createFaceUvs(definition: PolyhedronDefinition, face: PolygonDefinition): readonly Vector2Tuple[];
@@ -42,12 +42,19 @@ export function createPolyhedronGeometry(definition, scale = 1) {
42
42
  }
43
43
  export class ThreeDiceMeshFactory {
44
44
  #materials;
45
- constructor(materials = new ThreeMaterialFactory()) {
45
+ constructor(materials = defaultMaterialProvider()) {
46
46
  this.#materials = materials;
47
47
  }
48
- create(definition, theme = DEFAULT_THREE_THEME, scale = 1, faceLabels) {
48
+ create(definition, theme = DEFAULT_THREE_THEME, scale = 1, faceLabels, preset) {
49
49
  const geometry = createPolyhedronGeometry(definition, scale);
50
- const materials = definition.faces.map((face) => this.#materials.createFace(getFaceLabel(definition, face, faceLabels), theme, definition.id === 'd10' ? { labelScale: D10_LABEL_SCALE } : undefined));
50
+ const resources = definition.faces.map((face) => this.#materials.createFace({
51
+ label: getFaceLabel(definition, face, faceLabels),
52
+ faceValue: face.value,
53
+ theme,
54
+ ...(preset === undefined ? {} : { preset }),
55
+ ...(definition.id === 'd10' ? { labelScale: D10_LABEL_SCALE } : {}),
56
+ }));
57
+ const materials = resources.map(({ material }) => material);
51
58
  const mesh = new Mesh(geometry, materials);
52
59
  mesh.castShadow = true;
53
60
  mesh.receiveShadow = true;
@@ -55,10 +62,8 @@ export class ThreeDiceMeshFactory {
55
62
  mesh,
56
63
  dispose() {
57
64
  geometry.dispose();
58
- for (const material of materials) {
59
- material.map?.dispose();
60
- material.dispose();
61
- }
65
+ for (const resource of resources)
66
+ resource.dispose();
62
67
  },
63
68
  };
64
69
  }
@@ -66,6 +71,21 @@ export class ThreeDiceMeshFactory {
66
71
  return this.create(D6_DEFINITION, theme, scale);
67
72
  }
68
73
  }
74
+ function defaultMaterialProvider() {
75
+ const factory = new ThreeMaterialFactory();
76
+ return {
77
+ createFace(context) {
78
+ const material = factory.createFace(context.label, context.theme, context.labelScale === undefined ? {} : { labelScale: context.labelScale });
79
+ return {
80
+ material,
81
+ dispose() {
82
+ material.map?.dispose();
83
+ material.dispose();
84
+ },
85
+ };
86
+ },
87
+ };
88
+ }
69
89
  export function createFaceUvs(definition, face) {
70
90
  if (definition.id !== 'd10') {
71
91
  return face.indices.map((_, index) => {
@@ -1,15 +1,20 @@
1
- import type { DiceRenderer, RenderDieState, RendererTheme, RendererViewport } from '@dice-o-rolla/dice-renderer';
1
+ import type { DiceRenderer, RenderDieState, RendererTheme, RendererViewport, VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
2
+ import { WebGLRenderer } from 'three';
3
+ import { type ThreeFaceMaterialProvider } from './mesh-factory.js';
2
4
  export interface ThreeDiceRendererOptions extends Partial<RendererTheme> {
3
5
  readonly antialias?: boolean;
4
6
  readonly observeResize?: boolean;
5
7
  readonly maxPixelRatio?: number;
6
8
  readonly maxViewportDimension?: number;
7
9
  readonly maxFramebufferPixels?: number;
10
+ readonly materialProvider?: ThreeFaceMaterialProvider | ((renderer: WebGLRenderer) => ThreeFaceMaterialProvider);
8
11
  }
9
12
  export declare class ThreeDiceRenderer implements DiceRenderer {
10
13
  #private;
11
14
  constructor(container: HTMLElement, options?: ThreeDiceRendererOptions);
12
15
  initialize(): void;
16
+ registerPreset(preset: VisualPresetDescriptor): void;
17
+ unregisterPreset(id: string): void;
13
18
  createDie(state: RenderDieState): void;
14
19
  updateDie(state: RenderDieState): void;
15
20
  removeDie(id: string): void;
@@ -1,13 +1,15 @@
1
1
  import { getDieGeometry, getRegisteredDieTypes } from '@dice-o-rolla/dice-geometry';
2
2
  import { PCFShadowMap, WebGLRenderer } from 'three';
3
- import { DEFAULT_THREE_THEME, ThreeDiceMeshFactory } from './mesh-factory.js';
3
+ import { DEFAULT_THREE_THEME, ThreeDiceMeshFactory, } from './mesh-factory.js';
4
4
  import { ThreeCamera, ThreeScene } from './scene.js';
5
5
  import { applyInterpolatedTransform } from './transform.js';
6
6
  import { fitViewportToLimits, resolveViewportLimits, validateViewport, } from './viewport-limits.js';
7
7
  function copyState(state) {
8
8
  return {
9
9
  id: state.id,
10
+ presetId: state.presetId,
10
11
  geometryId: state.geometryId,
12
+ scale: state.scale,
11
13
  ...(state.faceLabels === undefined ? {} : { faceLabels: { ...state.faceLabels } }),
12
14
  previous: {
13
15
  position: { ...state.previous.position },
@@ -22,8 +24,11 @@ function copyState(state) {
22
24
  export class ThreeDiceRenderer {
23
25
  #container;
24
26
  #options;
25
- #meshFactory = new ThreeDiceMeshFactory();
27
+ #meshFactory;
28
+ #materialProvider;
26
29
  #entries = new Map();
30
+ #presets = new Map();
31
+ #pendingPresetRemovals = new Set();
27
32
  #viewportLimits;
28
33
  #theme;
29
34
  #scene;
@@ -34,6 +39,9 @@ export class ThreeDiceRenderer {
34
39
  constructor(container, options = {}) {
35
40
  this.#container = container;
36
41
  this.#options = options;
42
+ this.#materialProvider =
43
+ typeof options.materialProvider === 'function' ? undefined : options.materialProvider;
44
+ this.#meshFactory = new ThreeDiceMeshFactory(this.#materialProvider);
37
45
  this.#viewportLimits = resolveViewportLimits(options);
38
46
  this.#theme = {
39
47
  material: options.material ?? DEFAULT_THREE_THEME.material,
@@ -54,6 +62,10 @@ export class ThreeDiceRenderer {
54
62
  alpha: true,
55
63
  powerPreference: 'high-performance',
56
64
  });
65
+ if (typeof this.#options.materialProvider === 'function') {
66
+ this.#materialProvider = this.#options.materialProvider(renderer);
67
+ this.#meshFactory = new ThreeDiceMeshFactory(this.#materialProvider);
68
+ }
57
69
  renderer.shadowMap.enabled = true;
58
70
  renderer.shadowMap.type = PCFShadowMap;
59
71
  this.#container.append(renderer.domElement);
@@ -66,11 +78,24 @@ export class ThreeDiceRenderer {
66
78
  this.#resizeObserver.observe(this.#container);
67
79
  }
68
80
  }
81
+ registerPreset(preset) {
82
+ this.#assertAlive();
83
+ this.#pendingPresetRemovals.delete(preset.id);
84
+ this.#presets.set(preset.id, preset);
85
+ }
86
+ unregisterPreset(id) {
87
+ this.#assertAlive();
88
+ if ([...this.#entries.values()].some((entry) => entry.state.presetId === id)) {
89
+ this.#pendingPresetRemovals.add(id);
90
+ return;
91
+ }
92
+ this.#presets.delete(id);
93
+ }
69
94
  createDie(state) {
70
95
  this.#assertInitialized();
71
96
  if (this.#entries.has(state.id))
72
97
  throw new Error(`A render die with id "${state.id}" already exists`);
73
- const resource = this.#createResource(state.geometryId, state.faceLabels);
98
+ const resource = this.#createResource(state);
74
99
  const ownedState = copyState(state);
75
100
  applyInterpolatedTransform(resource.mesh, ownedState, 1);
76
101
  this.#scene?.value.add(resource.mesh);
@@ -84,6 +109,9 @@ export class ThreeDiceRenderer {
84
109
  if (state.geometryId !== entry.state.geometryId) {
85
110
  throw new Error('A die geometry cannot be changed after creation');
86
111
  }
112
+ if (state.presetId !== entry.state.presetId || state.scale !== entry.state.scale) {
113
+ throw new Error('A die visual preset cannot be changed after creation');
114
+ }
87
115
  if (!haveEqualFaceLabels(state.faceLabels, entry.state.faceLabels)) {
88
116
  throw new Error('Die face labels cannot be changed after creation');
89
117
  }
@@ -97,6 +125,7 @@ export class ThreeDiceRenderer {
97
125
  this.#scene?.value.remove(entry.resource.mesh);
98
126
  entry.resource.dispose();
99
127
  this.#entries.delete(id);
128
+ this.#releasePendingPreset(entry.state.presetId);
100
129
  }
101
130
  render(alpha) {
102
131
  this.#assertInitialized();
@@ -116,7 +145,7 @@ export class ThreeDiceRenderer {
116
145
  this.#assertAlive();
117
146
  this.#theme = { ...theme };
118
147
  for (const entry of this.#entries.values()) {
119
- const replacement = this.#createResource(entry.state.geometryId, entry.state.faceLabels);
148
+ const replacement = this.#createResource(entry.state);
120
149
  applyInterpolatedTransform(replacement.mesh, entry.state, 1);
121
150
  this.#scene?.value.remove(entry.resource.mesh);
122
151
  entry.resource.dispose();
@@ -144,6 +173,10 @@ export class ThreeDiceRenderer {
144
173
  this.#scene = undefined;
145
174
  this.#camera = undefined;
146
175
  this.#renderer = undefined;
176
+ this.#presets.clear();
177
+ this.#pendingPresetRemovals.clear();
178
+ this.#materialProvider?.dispose?.();
179
+ this.#materialProvider = undefined;
147
180
  this.#destroyed = true;
148
181
  }
149
182
  #resizeToContainer() {
@@ -156,11 +189,25 @@ export class ThreeDiceRenderer {
156
189
  pixelRatio: globalThis.devicePixelRatio || 1,
157
190
  }, this.#viewportLimits));
158
191
  }
159
- #createResource(geometryId, faceLabels) {
160
- const type = getRegisteredDieTypes().find((registered) => registered === geometryId);
192
+ #createResource(state) {
193
+ const preset = this.#presets.get(state.presetId);
194
+ if (preset === undefined)
195
+ throw new Error(`Unknown visual preset: ${state.presetId}`);
196
+ if (preset.geometryId !== state.geometryId || (preset.scale ?? 1) !== state.scale) {
197
+ throw new Error(`Render state does not match visual preset "${state.presetId}"`);
198
+ }
199
+ const type = getRegisteredDieTypes().find((registered) => registered === state.geometryId);
161
200
  if (type === undefined)
162
- throw new Error(`Unsupported geometry: ${geometryId}`);
163
- return this.#meshFactory.create(getDieGeometry(type), this.#theme, 1, faceLabels);
201
+ throw new Error(`Unsupported geometry: ${state.geometryId}`);
202
+ return this.#meshFactory.create(getDieGeometry(type), this.#theme, state.scale, state.faceLabels, preset);
203
+ }
204
+ #releasePendingPreset(presetId) {
205
+ if (!this.#pendingPresetRemovals.has(presetId))
206
+ return;
207
+ if ([...this.#entries.values()].some((entry) => entry.state.presetId === presetId))
208
+ return;
209
+ this.#pendingPresetRemovals.delete(presetId);
210
+ this.#presets.delete(presetId);
164
211
  }
165
212
  #assertAlive() {
166
213
  if (this.#destroyed)
@@ -1,4 +1,4 @@
1
- import type { DiceRenderer, RenderDieState, RendererTheme, RendererViewport } from '@dice-o-rolla/dice-renderer';
1
+ import type { DiceRenderer, RenderDieState, RendererTheme, RendererViewport, VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
2
2
  import { type TopDownCameraOptions } from './top-down-camera.js';
3
3
  import { type ViewportLimitOptions } from './viewport-limits.js';
4
4
  export interface TopDownDiceRendererOptions extends Partial<RendererTheme>, ViewportLimitOptions, TopDownCameraOptions {
@@ -9,6 +9,8 @@ export declare class TopDownDiceRenderer implements DiceRenderer {
9
9
  #private;
10
10
  constructor(container: HTMLElement, options?: TopDownDiceRendererOptions);
11
11
  initialize(): void;
12
+ registerPreset(preset: VisualPresetDescriptor): void;
13
+ unregisterPreset(id: string): void;
12
14
  createDie(state: RenderDieState): void;
13
15
  updateDie(state: RenderDieState): void;
14
16
  removeDie(id: string): void;
@@ -9,6 +9,8 @@ export class TopDownDiceRenderer {
9
9
  #options;
10
10
  #meshFactory = new ThreeDiceMeshFactory();
11
11
  #entries = new Map();
12
+ #presets = new Map();
13
+ #pendingPresetRemovals = new Set();
12
14
  #viewportLimits;
13
15
  #trayWidth;
14
16
  #trayDepth;
@@ -79,12 +81,25 @@ export class TopDownDiceRenderer {
79
81
  this.#resizeObserver.observe(this.#container);
80
82
  }
81
83
  }
84
+ registerPreset(preset) {
85
+ this.#assertAlive();
86
+ this.#pendingPresetRemovals.delete(preset.id);
87
+ this.#presets.set(preset.id, preset);
88
+ }
89
+ unregisterPreset(id) {
90
+ this.#assertAlive();
91
+ if ([...this.#entries.values()].some((entry) => entry.state.presetId === id)) {
92
+ this.#pendingPresetRemovals.add(id);
93
+ return;
94
+ }
95
+ this.#presets.delete(id);
96
+ }
82
97
  createDie(state) {
83
98
  this.#assertInitialized();
84
99
  if (this.#entries.has(state.id)) {
85
100
  throw new Error(`A render die with id "${state.id}" already exists`);
86
101
  }
87
- const resource = this.#createResource(state.geometryId, state.faceLabels);
102
+ const resource = this.#createResource(state);
88
103
  const ownedState = copyState(state);
89
104
  applyInterpolatedTransform(resource.mesh, ownedState, 1);
90
105
  this.#scene?.add(resource.mesh);
@@ -98,6 +113,9 @@ export class TopDownDiceRenderer {
98
113
  if (entry.state.geometryId !== state.geometryId) {
99
114
  throw new Error('A die geometry cannot be changed after creation');
100
115
  }
116
+ if (state.presetId !== entry.state.presetId || state.scale !== entry.state.scale) {
117
+ throw new Error('A die visual preset cannot be changed after creation');
118
+ }
101
119
  if (!haveEqualFaceLabels(entry.state.faceLabels, state.faceLabels)) {
102
120
  throw new Error('Die face labels cannot be changed after creation');
103
121
  }
@@ -111,6 +129,7 @@ export class TopDownDiceRenderer {
111
129
  this.#scene?.remove(entry.resource.mesh);
112
130
  entry.resource.dispose();
113
131
  this.#entries.delete(id);
132
+ this.#releasePendingPreset(entry.state.presetId);
114
133
  }
115
134
  render(alpha) {
116
135
  this.#assertInitialized();
@@ -130,7 +149,7 @@ export class TopDownDiceRenderer {
130
149
  this.#assertAlive();
131
150
  this.#theme = { ...theme };
132
151
  for (const entry of this.#entries.values()) {
133
- const replacement = this.#createResource(entry.state.geometryId, entry.state.faceLabels);
152
+ const replacement = this.#createResource(entry.state);
134
153
  applyInterpolatedTransform(replacement.mesh, entry.state, 1);
135
154
  this.#scene?.remove(entry.resource.mesh);
136
155
  entry.resource.dispose();
@@ -164,6 +183,8 @@ export class TopDownDiceRenderer {
164
183
  this.#floorMaterial = undefined;
165
184
  this.#keyLight = undefined;
166
185
  this.#resizeObserver = undefined;
186
+ this.#presets.clear();
187
+ this.#pendingPresetRemovals.clear();
167
188
  this.#destroyed = true;
168
189
  }
169
190
  #resizeToContainer() {
@@ -176,11 +197,25 @@ export class TopDownDiceRenderer {
176
197
  pixelRatio: globalThis.devicePixelRatio || 1,
177
198
  }, this.#viewportLimits));
178
199
  }
179
- #createResource(geometryId, faceLabels) {
180
- const type = getRegisteredDieTypes().find((registered) => registered === geometryId);
200
+ #createResource(state) {
201
+ const preset = this.#presets.get(state.presetId);
202
+ if (preset === undefined)
203
+ throw new Error(`Unknown visual preset: ${state.presetId}`);
204
+ if (preset.geometryId !== state.geometryId || (preset.scale ?? 1) !== state.scale) {
205
+ throw new Error(`Render state does not match visual preset "${state.presetId}"`);
206
+ }
207
+ const type = getRegisteredDieTypes().find((registered) => registered === state.geometryId);
181
208
  if (type === undefined)
182
- throw new Error(`Unsupported geometry: ${geometryId}`);
183
- return this.#meshFactory.create(getDieGeometry(type), this.#theme, 1, faceLabels);
209
+ throw new Error(`Unsupported geometry: ${state.geometryId}`);
210
+ return this.#meshFactory.create(getDieGeometry(type), this.#theme, state.scale, state.faceLabels);
211
+ }
212
+ #releasePendingPreset(presetId) {
213
+ if (!this.#pendingPresetRemovals.has(presetId))
214
+ return;
215
+ if ([...this.#entries.values()].some((entry) => entry.state.presetId === presetId))
216
+ return;
217
+ this.#pendingPresetRemovals.delete(presetId);
218
+ this.#presets.delete(presetId);
184
219
  }
185
220
  #assertAlive() {
186
221
  if (this.#destroyed)
@@ -196,7 +231,9 @@ export class TopDownDiceRenderer {
196
231
  function copyState(state) {
197
232
  return {
198
233
  id: state.id,
234
+ presetId: state.presetId,
199
235
  geometryId: state.geometryId,
236
+ scale: state.scale,
200
237
  ...(state.faceLabels === undefined ? {} : { faceLabels: { ...state.faceLabels } }),
201
238
  previous: {
202
239
  position: { ...state.previous.position },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dice-o-rolla/dice-renderer-three",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Three.js WebGL renderers for animated physical dice.",
5
5
  "keywords": [
6
6
  "dice",
@@ -40,8 +40,9 @@
40
40
  "registry": "https://registry.npmjs.org/"
41
41
  },
42
42
  "dependencies": {
43
- "@dice-o-rolla/dice-geometry": "0.1.1",
44
- "@dice-o-rolla/dice-renderer": "0.1.1",
43
+ "@dice-o-rolla/dice-geometry": "0.2.0",
44
+ "@dice-o-rolla/dice-renderer": "0.2.0",
45
+ "@types/three": "^0.185.0",
45
46
  "three": "^0.185.0"
46
47
  },
47
48
  "engines": {