@combeenation/3d-viewer 14.0.1-rc1 → 15.0.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 (81) hide show
  1. package/README.md +9 -9
  2. package/dist/lib-cjs/buildinfo.json +3 -3
  3. package/dist/lib-cjs/commonjs.tsconfig.tsbuildinfo +1 -1
  4. package/dist/lib-cjs/index.d.ts +51 -62
  5. package/dist/lib-cjs/index.js +84 -94
  6. package/dist/lib-cjs/index.js.map +1 -1
  7. package/dist/lib-cjs/internal/cbn-custom-babylon-loader-plugin.d.ts +10 -10
  8. package/dist/lib-cjs/internal/cbn-custom-babylon-loader-plugin.js +131 -131
  9. package/dist/lib-cjs/internal/cbn-custom-babylon-loader-plugin.js.map +1 -1
  10. package/dist/lib-cjs/internal/cloning-helper.d.ts +19 -19
  11. package/dist/lib-cjs/internal/cloning-helper.js +163 -163
  12. package/dist/lib-cjs/internal/device-helper.d.ts +9 -9
  13. package/dist/lib-cjs/internal/device-helper.js +24 -24
  14. package/dist/lib-cjs/internal/geometry-helper.d.ts +21 -21
  15. package/dist/lib-cjs/internal/geometry-helper.js +145 -145
  16. package/dist/lib-cjs/internal/metadata-helper.d.ts +26 -26
  17. package/dist/lib-cjs/internal/metadata-helper.js +50 -50
  18. package/dist/lib-cjs/internal/paintable-helper.d.ts +40 -40
  19. package/dist/lib-cjs/internal/paintable-helper.js +234 -286
  20. package/dist/lib-cjs/internal/paintable-helper.js.map +1 -1
  21. package/dist/lib-cjs/internal/svg-helper.d.ts +4 -0
  22. package/dist/lib-cjs/internal/svg-helper.js +67 -0
  23. package/dist/lib-cjs/internal/svg-helper.js.map +1 -0
  24. package/dist/lib-cjs/internal/tags-helper.d.ts +12 -12
  25. package/dist/lib-cjs/internal/tags-helper.js +39 -37
  26. package/dist/lib-cjs/internal/tags-helper.js.map +1 -1
  27. package/dist/lib-cjs/internal/texture-parameter-helper.d.ts +37 -0
  28. package/dist/lib-cjs/internal/texture-parameter-helper.js +287 -0
  29. package/dist/lib-cjs/internal/texture-parameter-helper.js.map +1 -0
  30. package/dist/lib-cjs/manager/camera-manager.d.ts +110 -110
  31. package/dist/lib-cjs/manager/camera-manager.js +209 -206
  32. package/dist/lib-cjs/manager/camera-manager.js.map +1 -1
  33. package/dist/lib-cjs/manager/debug-manager.d.ts +60 -60
  34. package/dist/lib-cjs/manager/debug-manager.js +217 -217
  35. package/dist/lib-cjs/manager/event-manager.d.ts +52 -52
  36. package/dist/lib-cjs/manager/event-manager.js +71 -71
  37. package/dist/lib-cjs/manager/gltf-export-manager.d.ts +75 -84
  38. package/dist/lib-cjs/manager/gltf-export-manager.js +286 -290
  39. package/dist/lib-cjs/manager/gltf-export-manager.js.map +1 -1
  40. package/dist/lib-cjs/manager/material-manager.d.ts +35 -35
  41. package/dist/lib-cjs/manager/material-manager.js +125 -125
  42. package/dist/lib-cjs/manager/model-manager.d.ts +145 -145
  43. package/dist/lib-cjs/manager/model-manager.js +382 -382
  44. package/dist/lib-cjs/manager/parameter-manager.d.ts +228 -210
  45. package/dist/lib-cjs/manager/parameter-manager.js +573 -514
  46. package/dist/lib-cjs/manager/parameter-manager.js.map +1 -1
  47. package/dist/lib-cjs/manager/scene-manager.d.ts +45 -45
  48. package/dist/lib-cjs/manager/scene-manager.js +64 -64
  49. package/dist/lib-cjs/manager/texture-manager.d.ts +12 -12
  50. package/dist/lib-cjs/manager/texture-manager.js +43 -43
  51. package/dist/lib-cjs/viewer-error.d.ts +49 -48
  52. package/dist/lib-cjs/viewer-error.js +61 -60
  53. package/dist/lib-cjs/viewer-error.js.map +1 -1
  54. package/dist/lib-cjs/viewer.d.ts +115 -115
  55. package/dist/lib-cjs/viewer.js +217 -217
  56. package/dist/lib-cjs/viewer.js.map +1 -1
  57. package/package.json +94 -91
  58. package/src/buildinfo.json +3 -3
  59. package/src/dev.ts +47 -47
  60. package/src/global-types.d.ts +39 -39
  61. package/src/index.ts +71 -81
  62. package/src/internal/cbn-custom-babylon-loader-plugin.ts +159 -159
  63. package/src/internal/cloning-helper.ts +225 -225
  64. package/src/internal/device-helper.ts +25 -25
  65. package/src/internal/geometry-helper.ts +181 -181
  66. package/src/internal/metadata-helper.ts +63 -63
  67. package/src/internal/paintable-helper.ts +258 -310
  68. package/src/internal/svg-helper.ts +52 -0
  69. package/src/internal/tags-helper.ts +43 -41
  70. package/src/internal/texture-parameter-helper.ts +353 -0
  71. package/src/manager/camera-manager.ts +368 -365
  72. package/src/manager/debug-manager.ts +245 -245
  73. package/src/manager/event-manager.ts +72 -72
  74. package/src/manager/gltf-export-manager.ts +356 -357
  75. package/src/manager/material-manager.ts +135 -135
  76. package/src/manager/model-manager.ts +458 -458
  77. package/src/manager/parameter-manager.ts +730 -652
  78. package/src/manager/scene-manager.ts +101 -101
  79. package/src/manager/texture-manager.ts +32 -32
  80. package/src/viewer-error.ts +69 -68
  81. package/src/viewer.ts +290 -290
@@ -1,310 +1,258 @@
1
- import {
2
- DynamicTexture,
3
- Material,
4
- PBRMaterial,
5
- ParameterValue,
6
- Scene,
7
- StandardMaterial,
8
- Texture,
9
- ViewerError,
10
- ViewerErrorIds,
11
- } from '../index';
12
- import isSvg from 'is-svg';
13
- import { isString } from 'lodash-es';
14
-
15
- type PaintableValue = {
16
- src: string;
17
- options?: PaintableOptions;
18
- };
19
-
20
- type PaintableOptions = {
21
- uScale?: number;
22
- uOffset?: number;
23
- vScale?: number;
24
- vOffset?: number;
25
- };
26
-
27
- /**
28
- * Observer implementation for "paintable" parameter.
29
- * Basically creates a dynamic texture from the source image and assigns it as albedo (or diffuse) texture.
30
- */
31
- export async function paintableParameterObserver(
32
- newValue: ParameterValue,
33
- materials: Material[],
34
- scene: Scene
35
- ): Promise<void> {
36
- const paintable = parsePaintable(newValue);
37
-
38
- // check if value is svg or image source, do the conversion accordingly
39
- const srcIsSvg = isSvg(paintable.src);
40
- if (!srcIsSvg && paintable.src.includes('<svg') && paintable.src.includes('</svg>')) {
41
- // seems like the user tried to use a SVG string, as <svg> tags are used
42
- // inform the user that this is not a valid SVG string
43
- throw new ViewerError({
44
- id: ViewerErrorIds.InvalidParameterValue,
45
- message: `Invalid value for parameter "paintable" given:\nsource string is no valid SVG string\nGiven value: ${paintable.src}`,
46
- });
47
- }
48
-
49
- let imageSource: CanvasImageSource;
50
- try {
51
- imageSource = srcIsSvg ? await _createImageFromSvg(paintable.src) : await _createImageFromImgSrc(paintable.src);
52
- } catch {
53
- // SVG might be invalid, even if it passes `isSvg` check
54
- // in this case the image can't be created and will throw an error, which should be handled by the viewer and
55
- // Combeenation viewer control
56
- throw new ViewerError({
57
- id: ViewerErrorIds.InvalidParameterValue,
58
- message: `Invalid value for parameter "paintable" given:\nimage can't be created from source string\nGiven value: ${paintable.src}`,
59
- });
60
- }
61
-
62
- // apply image source on desired material(s)
63
- for (const material of materials) {
64
- _drawPaintableOnMaterial(material, imageSource, scene, paintable.options);
65
- }
66
- }
67
-
68
- /**
69
- * Parser for paintable value.
70
- * ATM this is only used internally, but it could theoretically be used to create ones own paintable implementation.
71
- *
72
- * @param value The value to parse. Examples:
73
- * ```ts
74
- * // Default definition as JSON object:
75
- * '{ "src": "https://path.to/image.jpg", "uScale": 0.5 }'
76
- *
77
- * // Short hand definition, only contains source string:
78
- * 'https://path.to/image.jpg'
79
- *
80
- * // Full content, paintable texture is flipped in both directions
81
- * '{ "src": "https://path.to/image.jpg", "uScale": -1, "vScale": -1, "uOffset": 0, "vOffset": 0 }'
82
- *
83
- * // SVG content can be used directly:
84
- * '<svg>...</svg>'
85
- *
86
- * // SVG in src property works as well:
87
- * '{ "src": "<svg>...</svg>", "uScale": 0.5 }'
88
- * ```
89
- */
90
- export function parsePaintable(value: ParameterValue): PaintableValue {
91
- if (!isString(value)) {
92
- throw new ViewerError({
93
- id: ViewerErrorIds.InvalidParameterValue,
94
- message: `Unable to parse paintable value: not a string\nGiven value: ${value}`,
95
- });
96
- }
97
-
98
- const paintableValue: PaintableValue = { src: '' };
99
- let valObj: { [key: string]: any } | null = null;
100
-
101
- try {
102
- valObj = JSON.parse(value);
103
- } catch {
104
- // use string directly
105
- paintableValue.src = value;
106
-
107
- if (value.startsWith('{')) {
108
- // seems like the user tried to use a JSON string, as the input starts with {
109
- throw new ViewerError({
110
- id: ViewerErrorIds.InvalidParameterValue,
111
- message: `Unable to parse paintable value: not a valid JSON string\nGiven value: ${value}`,
112
- });
113
- }
114
- }
115
-
116
- if (valObj) {
117
- // input string is JSON, src attribute is required
118
- if (!valObj.src) {
119
- throw new ViewerError({
120
- id: ViewerErrorIds.InvalidParameterValue,
121
- message: `Unable to parse paintable value: property "src" is missing\nGiven value: ${value}`,
122
- });
123
- }
124
-
125
- if (!isString(valObj.src)) {
126
- throw new ViewerError({
127
- id: ViewerErrorIds.InvalidParameterValue,
128
- message: `Unable to parse paintable value: property "src" is not a string\nGiven value: ${value}`,
129
- });
130
- }
131
-
132
- // split src and options
133
- const { src, ...options } = valObj;
134
- paintableValue.src = src;
135
-
136
- // only forward valid paintable options
137
- const validOptionKeys = ['uScale', 'vScale', 'uOffset', 'vOffset'];
138
- const { validOptions, invalidKeys } = Object.entries(options).reduce(
139
- (accRes, [curKey, curValue]) => {
140
- const isValidKey = validOptionKeys.includes(curKey);
141
- if (isValidKey) {
142
- accRes.validOptions[curKey] = curValue;
143
- } else {
144
- accRes.invalidKeys.push(curKey);
145
- }
146
-
147
- return accRes;
148
- },
149
- { validOptions: {} as { [key: string]: any }, invalidKeys: [] as string[] }
150
- );
151
-
152
- if (invalidKeys.length) {
153
- console.warn('Invalid paintable options provided: ' + invalidKeys.toString());
154
- }
155
-
156
- paintableValue.options = validOptions;
157
- }
158
-
159
- return paintableValue;
160
- }
161
-
162
- function _drawPaintableOnMaterial(
163
- material: Material,
164
- imageSource: HTMLImageElement,
165
- scene: Scene,
166
- options?: PaintableOptions
167
- ): void {
168
- // always take width and height from image source, scaling is done with uvScale properties
169
- const widthAndHeight = {
170
- width: imageSource.width,
171
- height: imageSource.height,
172
- };
173
-
174
- const texture = new DynamicTexture(`${material.id}.paintable_texture`, widthAndHeight, scene);
175
-
176
- // draw image on texture
177
- const ctx = texture.getContext();
178
- ctx.drawImage(imageSource, 0, 0);
179
- texture.update();
180
-
181
- // apply settings from paintable options to tweak position and scaling of image on the texture
182
- texture.uScale = options?.uScale ?? texture.uScale;
183
- texture.vScale = options?.vScale ?? texture.vScale;
184
- texture.uOffset = options?.uOffset ?? texture.uOffset;
185
- texture.vOffset = options?.vOffset ?? texture.vOffset;
186
-
187
- // wrap mode is preferred, as it will always show the texture, no matter which position offset is currently chosen
188
- // clamp mode requires more knowledge (and patience) when adjusting the uv scale and offset values
189
- texture.wrapU = Texture.WRAP_ADDRESSMODE;
190
- texture.wrapV = Texture.WRAP_ADDRESSMODE;
191
-
192
- // apply the paintable texture on the dedicated material type
193
- const materialCls = material.getClassName();
194
- switch (materialCls) {
195
- case 'PBRMaterial':
196
- (material as PBRMaterial).albedoTexture = texture;
197
- break;
198
- case 'StandardMaterial':
199
- (material as StandardMaterial).diffuseTexture = texture;
200
- break;
201
- default:
202
- throw new Error(`Setting paintable texture for material of instance "${materialCls}" not implemented (yet)`);
203
- }
204
- }
205
-
206
- /**
207
- * Creates a HTML image element based on a SVG string, whereas all the embedded assets in the SVG (eg: fonts, images)
208
- * are already loaded and exchanged by their base64 representation.\
209
- * There the output image can exist as "standalone" image and may be used for example as a paintable.
210
- *
211
- * !!CAUTION!!: The used functions within this code section are very well evaluated since most alternatives
212
- * somehow don't work in Safari, as mentioned in the following BJS forum entries:
213
- * - https://forum.babylonjs.com/t/drawing-svg-content-text-into-dynamictexture-doesnt-work-in-safari-v15/25048
214
- * - https://forum.babylonjs.com/t/texture-createfrombase64string-doesnt-seem-to-work-for-ios-devices-initially/25502
215
- */
216
- async function _createImageFromSvg(svgSrc: string): Promise<HTMLImageElement> {
217
- // replace assets with their base64 versions in svg source code
218
- const svgWithAssetsEmbedded = await _embedAssets(svgSrc);
219
-
220
- // create data string which can be used as an image source
221
- const svgEncoded = 'data:image/svg+xml,' + encodeURIComponent(svgWithAssetsEmbedded);
222
-
223
- return _createImageFromImgSrc(svgEncoded);
224
- }
225
-
226
- /**
227
- * Creates an HTML image element from a dedicated image source.\
228
- * Also waits until the image has loaded.
229
- *
230
- * !!CAUTION!!: The `setTimeout` after loading is finished is required due to a Safari bug:
231
- * - https://bugs.webkit.org/show_bug.cgi?id=39059
232
- * - https://bugs.webkit.org/show_bug.cgi?id=219770
233
- *
234
- * It's not 100% ensured that the timeout solves the issue in every case, but there is no other way unfortunately.\
235
- * => Keep an eye on it in future projects
236
- *
237
- * @param imgSrc Theoretically every source is valid which is also supported by
238
- * [HTMLImageElement.src](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/src).\
239
- * Known exceptions are SVGs with embedded assets that are provided as object URL. See comments in
240
- * {@link createImageFromSvg} for further details.
241
- */
242
- async function _createImageFromImgSrc(imgSrc: string): Promise<HTMLImageElement> {
243
- const img = new Image();
244
-
245
- await new Promise((resolve, reject) => {
246
- img.onload = (): void => {
247
- setTimeout(resolve, 0);
248
- };
249
- img.onerror = (): void => {
250
- reject();
251
- };
252
- img.crossOrigin = 'anonymous';
253
- img.src = imgSrc;
254
- });
255
-
256
- return img;
257
- }
258
-
259
- /**
260
- * Replaces all supported image & font URLs in the given SVG with their base64 representation.
261
- */
262
- async function _embedAssets(svgSrc: string): Promise<string> {
263
- const _imageExtensions = ['png', 'gif', 'jpg', 'jpeg', 'svg', 'bmp'];
264
- const _fontExtensions = ['woff2', 'woff', 'ttf', 'otf'];
265
- const _assetExtensions = [..._imageExtensions, ..._fontExtensions];
266
- // Regex copied from https://stackoverflow.com/a/8943487/1273551, not "stress tested"...
267
- const urlRegex = /(\bhttps?:\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi;
268
- const allUrls = svgSrc.match(urlRegex) as string[];
269
-
270
- const assetUrls = allUrls.filter(url => {
271
- const indexParam = url.indexOf('?');
272
- // remove url parameter to recognize extension
273
- if (indexParam > -1) {
274
- url = url.substring(0, indexParam);
275
- }
276
- return _assetExtensions.some(extension => url.toLowerCase().endsWith(`.${extension}`));
277
- });
278
- const assetBase64Fetcher = assetUrls.map(_fetchBase64AssetUrl);
279
- const assetFetcherResults = await Promise.all(assetBase64Fetcher);
280
- return assetFetcherResults.reduce((svgSrc, x) => svgSrc.replace(x.url, x.base64), svgSrc);
281
- }
282
-
283
- /**
284
- * Fetch asset (image or font) and convert it to base64 string representation.
285
- */
286
- async function _fetchBase64AssetUrl(assetUrl: string): Promise<{ url: string; base64: string }> {
287
- // TODO WTT: Cache known base64 representation and only fetch/convert when not already known.
288
- // Usually the fetch shouldn't hit the network but the browser cache since the SVG was already drawn..
289
- const resp = await fetch(assetUrl);
290
- const blob = await resp.blob();
291
-
292
- return new Promise((resolve, reject) => {
293
- const reader = new FileReader();
294
- reader.onloadend = (event): void => {
295
- const target = event.target;
296
- if (!target) {
297
- return reject(`Asset with URL "${assetUrl}" could not be loaded.`);
298
- }
299
- const result = target.result;
300
- if (!result) {
301
- return reject(`Asset with URL "${assetUrl}" returned an empty result.`);
302
- }
303
- resolve({
304
- url: assetUrl,
305
- base64: result.toString() as string,
306
- });
307
- };
308
- reader.readAsDataURL(blob);
309
- });
310
- }
1
+ import {
2
+ DynamicTexture,
3
+ Material,
4
+ PBRMaterial,
5
+ ParameterValue,
6
+ Scene,
7
+ StandardMaterial,
8
+ Texture,
9
+ ViewerError,
10
+ ViewerErrorIds,
11
+ } from '../index';
12
+ import { embedAssets } from './svg-helper';
13
+ import isSvg from 'is-svg';
14
+ import { isString } from 'lodash-es';
15
+
16
+ type PaintableValue = {
17
+ src: string;
18
+ options?: PaintableOptions;
19
+ };
20
+
21
+ type PaintableOptions = {
22
+ uScale?: number;
23
+ uOffset?: number;
24
+ vScale?: number;
25
+ vOffset?: number;
26
+ };
27
+
28
+ /**
29
+ * Observer implementation for "paintable" parameter.
30
+ * Basically creates a dynamic texture from the source image and assigns it as albedo (or diffuse) texture.
31
+ */
32
+ export async function paintableParameterObserver(
33
+ newValue: ParameterValue,
34
+ materials: Material[],
35
+ scene: Scene
36
+ ): Promise<void> {
37
+ const paintable = parsePaintable(newValue);
38
+
39
+ // check if value is svg or image source, do the conversion accordingly
40
+ const srcIsSvg = isSvg(paintable.src);
41
+ if (!srcIsSvg && paintable.src.includes('<svg') && paintable.src.includes('</svg>')) {
42
+ // seems like the user tried to use a SVG string, as <svg> tags are used
43
+ // inform the user that this is not a valid SVG string
44
+ throw new ViewerError({
45
+ id: ViewerErrorIds.InvalidParameterValue,
46
+ message: `Invalid value for parameter "paintable" given:\nsource string is no valid SVG string\nGiven value: ${paintable.src}`,
47
+ });
48
+ }
49
+
50
+ let imageSource: CanvasImageSource;
51
+ try {
52
+ imageSource = srcIsSvg ? await _createImageFromSvg(paintable.src) : await _createImageFromImgSrc(paintable.src);
53
+ } catch {
54
+ // SVG might be invalid, even if it passes `isSvg` check
55
+ // in this case the image can't be created and will throw an error, which should be handled by the viewer and
56
+ // Combeenation viewer control
57
+ throw new ViewerError({
58
+ id: ViewerErrorIds.InvalidParameterValue,
59
+ message: `Invalid value for parameter "paintable" given:\nimage can't be created from source string\nGiven value: ${paintable.src}`,
60
+ });
61
+ }
62
+
63
+ // apply image source on desired material(s)
64
+ for (const material of materials) {
65
+ _drawPaintableOnMaterial(material, imageSource, scene, paintable.options);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Parser for paintable value.
71
+ * ATM this is only used internally, but it could theoretically be used to create ones own paintable implementation.
72
+ *
73
+ * @param value The value to parse. Examples:
74
+ * ```ts
75
+ * // Default definition as JSON object:
76
+ * '{ "src": "https://path.to/image.jpg", "uScale": 0.5 }'
77
+ *
78
+ * // Short hand definition, only contains source string:
79
+ * 'https://path.to/image.jpg'
80
+ *
81
+ * // Full content, paintable texture is flipped in both directions
82
+ * '{ "src": "https://path.to/image.jpg", "uScale": -1, "vScale": -1, "uOffset": 0, "vOffset": 0 }'
83
+ *
84
+ * // SVG content can be used directly:
85
+ * '<svg>...</svg>'
86
+ *
87
+ * // SVG in src property works as well:
88
+ * '{ "src": "<svg>...</svg>", "uScale": 0.5 }'
89
+ * ```
90
+ */
91
+ export function parsePaintable(value: ParameterValue): PaintableValue {
92
+ if (!isString(value)) {
93
+ throw new ViewerError({
94
+ id: ViewerErrorIds.InvalidParameterValue,
95
+ message: `Unable to parse paintable value: not a string\nGiven value: ${value}`,
96
+ });
97
+ }
98
+
99
+ const paintableValue: PaintableValue = { src: '' };
100
+ let valObj: { [key: string]: any } | null = null;
101
+
102
+ try {
103
+ valObj = JSON.parse(value);
104
+ } catch {
105
+ // use string directly
106
+ paintableValue.src = value;
107
+
108
+ if (value.startsWith('{')) {
109
+ // seems like the user tried to use a JSON string, as the input starts with {
110
+ throw new ViewerError({
111
+ id: ViewerErrorIds.InvalidParameterValue,
112
+ message: `Unable to parse paintable value: not a valid JSON string\nGiven value: ${value}`,
113
+ });
114
+ }
115
+ }
116
+
117
+ if (valObj) {
118
+ // input string is JSON, src attribute is required
119
+ if (!valObj.src) {
120
+ throw new ViewerError({
121
+ id: ViewerErrorIds.InvalidParameterValue,
122
+ message: `Unable to parse paintable value: property "src" is missing\nGiven value: ${value}`,
123
+ });
124
+ }
125
+
126
+ if (!isString(valObj.src)) {
127
+ throw new ViewerError({
128
+ id: ViewerErrorIds.InvalidParameterValue,
129
+ message: `Unable to parse paintable value: property "src" is not a string\nGiven value: ${value}`,
130
+ });
131
+ }
132
+
133
+ // split src and options
134
+ const { src, ...options } = valObj;
135
+ paintableValue.src = src;
136
+
137
+ // only forward valid paintable options
138
+ const validOptionKeys = ['uScale', 'vScale', 'uOffset', 'vOffset'];
139
+ const { validOptions, invalidKeys } = Object.entries(options).reduce(
140
+ (accRes, [curKey, curValue]) => {
141
+ const isValidKey = validOptionKeys.includes(curKey);
142
+ if (isValidKey) {
143
+ accRes.validOptions[curKey] = curValue;
144
+ } else {
145
+ accRes.invalidKeys.push(curKey);
146
+ }
147
+
148
+ return accRes;
149
+ },
150
+ { validOptions: {} as { [key: string]: any }, invalidKeys: [] as string[] }
151
+ );
152
+
153
+ if (invalidKeys.length) {
154
+ console.warn('Invalid paintable options provided: ' + invalidKeys.toString());
155
+ }
156
+
157
+ paintableValue.options = validOptions;
158
+ }
159
+
160
+ return paintableValue;
161
+ }
162
+
163
+ function _drawPaintableOnMaterial(
164
+ material: Material,
165
+ imageSource: HTMLImageElement,
166
+ scene: Scene,
167
+ options?: PaintableOptions
168
+ ): void {
169
+ // always take width and height from image source, scaling is done with uvScale properties
170
+ const widthAndHeight = {
171
+ width: imageSource.width,
172
+ height: imageSource.height,
173
+ };
174
+
175
+ const texture = new DynamicTexture(`${material.id}.paintable_texture`, widthAndHeight, scene);
176
+
177
+ // draw image on texture
178
+ const ctx = texture.getContext();
179
+ ctx.drawImage(imageSource, 0, 0);
180
+ texture.update();
181
+
182
+ // apply settings from paintable options to tweak position and scaling of image on the texture
183
+ texture.uScale = options?.uScale ?? texture.uScale;
184
+ texture.vScale = options?.vScale ?? texture.vScale;
185
+ texture.uOffset = options?.uOffset ?? texture.uOffset;
186
+ texture.vOffset = options?.vOffset ?? texture.vOffset;
187
+
188
+ // wrap mode is preferred, as it will always show the texture, no matter which position offset is currently chosen
189
+ // clamp mode requires more knowledge (and patience) when adjusting the uv scale and offset values
190
+ texture.wrapU = Texture.WRAP_ADDRESSMODE;
191
+ texture.wrapV = Texture.WRAP_ADDRESSMODE;
192
+
193
+ // apply the paintable texture on the dedicated material type
194
+ const materialCls = material.getClassName();
195
+ switch (materialCls) {
196
+ case 'PBRMaterial':
197
+ (material as PBRMaterial).albedoTexture = texture;
198
+ break;
199
+ case 'StandardMaterial':
200
+ (material as StandardMaterial).diffuseTexture = texture;
201
+ break;
202
+ default:
203
+ throw new Error(`Setting paintable texture for material of instance "${materialCls}" not implemented (yet)`);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Creates a HTML image element based on a SVG string, whereas all the embedded assets in the SVG (eg: fonts, images)
209
+ * are already loaded and exchanged by their base64 representation.\
210
+ * There the output image can exist as "standalone" image and may be used for example as a paintable.
211
+ *
212
+ * !!CAUTION!!: The used functions within this code section are very well evaluated since most alternatives
213
+ * somehow don't work in Safari, as mentioned in the following BJS forum entries:
214
+ * - https://forum.babylonjs.com/t/drawing-svg-content-text-into-dynamictexture-doesnt-work-in-safari-v15/25048
215
+ * - https://forum.babylonjs.com/t/texture-createfrombase64string-doesnt-seem-to-work-for-ios-devices-initially/25502
216
+ */
217
+ async function _createImageFromSvg(svgSrc: string): Promise<HTMLImageElement> {
218
+ // replace assets with their base64 versions in svg source code
219
+ const svgWithAssetsEmbedded = await embedAssets(svgSrc);
220
+
221
+ // create data string which can be used as an image source
222
+ const svgEncoded = 'data:image/svg+xml,' + encodeURIComponent(svgWithAssetsEmbedded);
223
+
224
+ return _createImageFromImgSrc(svgEncoded);
225
+ }
226
+
227
+ /**
228
+ * Creates an HTML image element from a dedicated image source.\
229
+ * Also waits until the image has loaded.
230
+ *
231
+ * !!CAUTION!!: The `setTimeout` after loading is finished is required due to a Safari bug:
232
+ * - https://bugs.webkit.org/show_bug.cgi?id=39059
233
+ * - https://bugs.webkit.org/show_bug.cgi?id=219770
234
+ *
235
+ * It's not 100% ensured that the timeout solves the issue in every case, but there is no other way unfortunately.\
236
+ * => Keep an eye on it in future projects
237
+ *
238
+ * @param imgSrc Theoretically every source is valid which is also supported by
239
+ * [HTMLImageElement.src](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/src).\
240
+ * Known exceptions are SVGs with embedded assets that are provided as object URL. See comments in
241
+ * {@link createImageFromSvg} for further details.
242
+ */
243
+ async function _createImageFromImgSrc(imgSrc: string): Promise<HTMLImageElement> {
244
+ const img = new Image();
245
+
246
+ await new Promise((resolve, reject) => {
247
+ img.onload = (): void => {
248
+ setTimeout(resolve, 0);
249
+ };
250
+ img.onerror = (): void => {
251
+ reject();
252
+ };
253
+ img.crossOrigin = 'anonymous';
254
+ img.src = imgSrc;
255
+ });
256
+
257
+ return img;
258
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Replaces all supported image & font URLs in the given SVG with their base64 representation.
3
+ */
4
+ export async function embedAssets(svgSrc: string): Promise<string> {
5
+ const _imageExtensions = ['png', 'gif', 'jpg', 'jpeg', 'svg', 'bmp'];
6
+ const _fontExtensions = ['woff2', 'woff', 'ttf', 'otf'];
7
+ const _assetExtensions = [..._imageExtensions, ..._fontExtensions];
8
+ // Regex copied from https://stackoverflow.com/a/8943487/1273551, not "stress tested"...
9
+ const urlRegex = /(\bhttps?:\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi;
10
+ const allUrls = svgSrc.match(urlRegex) as string[];
11
+
12
+ const assetUrls = allUrls.filter(url => {
13
+ const indexParam = url.indexOf('?');
14
+ // remove url parameter to recognize extension
15
+ if (indexParam > -1) {
16
+ url = url.substring(0, indexParam);
17
+ }
18
+ return _assetExtensions.some(extension => url.toLowerCase().endsWith(`.${extension}`));
19
+ });
20
+ const assetBase64Fetcher = assetUrls.map(_fetchBase64AssetUrl);
21
+ const assetFetcherResults = await Promise.all(assetBase64Fetcher);
22
+ return assetFetcherResults.reduce((svgSrc, x) => svgSrc.replace(x.url, x.base64), svgSrc);
23
+ }
24
+
25
+ /**
26
+ * Fetch asset (image or font) and convert it to base64 string representation.
27
+ */
28
+ async function _fetchBase64AssetUrl(assetUrl: string): Promise<{ url: string; base64: string }> {
29
+ // TODO WTT: Cache known base64 representation and only fetch/convert when not already known.
30
+ // Usually the fetch shouldn't hit the network but the browser cache since the SVG was already drawn..
31
+ const resp = await fetch(assetUrl);
32
+ const blob = await resp.blob();
33
+
34
+ return new Promise((resolve, reject) => {
35
+ const reader = new FileReader();
36
+ reader.onloadend = (event): void => {
37
+ const target = event.target;
38
+ if (!target) {
39
+ return reject(`Asset with URL "${assetUrl}" could not be loaded.`);
40
+ }
41
+ const result = target.result;
42
+ if (!result) {
43
+ return reject(`Asset with URL "${assetUrl}" returned an empty result.`);
44
+ }
45
+ resolve({
46
+ url: assetUrl,
47
+ base64: result.toString() as string,
48
+ });
49
+ };
50
+ reader.readAsDataURL(blob);
51
+ });
52
+ }