Needle Engine

Changes between version 3.32.17-alpha and 3.32.18-alpha
Files changed (17) hide show
  1. plugins/vite/config.js +1 -0
  2. plugins/vite/copyfiles.js +4 -1
  3. plugins/vite/defines.js +4 -1
  4. plugins/vite/dependency-watcher.js +22 -17
  5. plugins/vite/index.js +2 -0
  6. plugins/vite/meta.js +2 -0
  7. src/engine-components/AnimatorController.ts +11 -3
  8. src/engine-components/Duplicatable.ts +4 -3
  9. src/engine/engine_constants.ts +5 -2
  10. src/engine/engine_serialization_builtin_serializer.ts +11 -2
  11. src/engine/engine_serialization_core.ts +0 -1
  12. src/engine/xr/NeedleXRSession.ts +3 -3
  13. src/engine-components/export/usdz/USDZExporter.ts +2 -1
  14. plugins/types/userconfig.d.ts +3 -0
  15. src/engine-components/webxr/WebARSessionRoot.ts +32 -1
  16. plugins/common/buildinfo.js +56 -0
  17. plugins/vite/buildinfo.js +23 -0
plugins/vite/config.js CHANGED
@@ -68,6 +68,7 @@
68
68
  return "assets";
69
69
  }
70
70
 
71
+ /** @returns the fullpath of the build */
71
72
  export function getOutputDirectory() {
72
73
  const projectConfig = tryLoadProjectConfig();
73
74
  return process.cwd() + "/" + (projectConfig?.buildDirectory || "dist");
plugins/vite/copyfiles.js CHANGED
@@ -36,7 +36,10 @@
36
36
  const needleConfig = tryLoadProjectConfig();
37
37
  if (needleConfig) {
38
38
  assetsDirName = needleConfig.assetsDirectory;
39
- while(assetsDirName.startsWith('/')) assetsDirName = assetsDirName.substring(1);
39
+ while (assetsDirName.startsWith('/')) assetsDirName = assetsDirName.substring(1);
40
+
41
+ if (needleConfig.buildDirectory)
42
+ outdirName = needleConfig.buildDirectory;
40
43
  }
41
44
 
42
45
  if (copyIncludesFromEngine !== false) {
plugins/vite/defines.js CHANGED
@@ -26,7 +26,7 @@
26
26
  // console.log("Update vite defines -------------------------------------------");
27
27
  if (!viteConfig.define) viteConfig.define = {};
28
28
  const version = tryGetNeedleEngineVersion();
29
- console.log("Needle Engine Version:", version, needleEngineConfig?.generator);
29
+ console.log("Needle Engine Version: " + version, needleEngineConfig?.generator ?? "(unknown generator)");
30
30
  if (version)
31
31
  viteConfig.define.NEEDLE_ENGINE_VERSION = "\"" + version + "\"";
32
32
  if (needleEngineConfig)
@@ -42,6 +42,9 @@
42
42
  if (viteConfig.define.NEEDLE_USE_RAPIER === undefined) {
43
43
  viteConfig.define.NEEDLE_USE_RAPIER = useRapier;
44
44
  }
45
+
46
+ // this gives a timestamp containing the timezone
47
+ viteConfig.define.NEEDLE_PROJECT_BUILD_TIME = "\"" + new Date().toString() + "\"";
45
48
  }
46
49
  }
47
50
  }
plugins/vite/dependency-watcher.js CHANGED
@@ -39,11 +39,14 @@
39
39
  });
40
40
  }
41
41
 
42
- function triggerReloadOnClients() {
43
- log("Triggering reload on clients (todo)", currentClients.size)
44
- // for (const client of currentClients) {
45
- // client.send(JSON.stringify({ type: "full-reload" }));
46
- // }
42
+ async function triggerReloadOnClients() {
43
+ log(`Triggering reload on ${currentClients.size} clients...`)
44
+ for (const client of currentClients) {
45
+ client.send(JSON.stringify({ type: "full-reload" }));
46
+ }
47
+ return new Promise((resolve) => {
48
+ setTimeout(resolve, 100);
49
+ });
47
50
  }
48
51
 
49
52
 
@@ -81,9 +84,6 @@
81
84
  modified = true;
82
85
  }
83
86
  if (modified || requireInstall) {
84
- if (modified) {
85
- log("package.json has changed. Require install?", requireInstall)
86
- }
87
87
 
88
88
  let requireReload = false;
89
89
  if (!requireInstall) {
@@ -95,7 +95,7 @@
95
95
  if (newPackageJson.dependencies) {
96
96
  for (const key in newPackageJson.dependencies) {
97
97
  if (packageJson.dependencies[key] !== newPackageJson.dependencies[key] && newPackageJson.dependencies[key] !== undefined) {
98
- log("Dependency added", key)
98
+ log("Detected new dependency: " + key)
99
99
  requireReload = true;
100
100
  requireInstall = true;
101
101
  }
@@ -104,13 +104,16 @@
104
104
  if (packageJson.devDependencies) {
105
105
  for (const key in packageJson.devDependencies) {
106
106
  if (packageJson.devDependencies[key] !== newPackageJson.devDependencies[key] && newPackageJson.devDependencies[key] !== undefined) {
107
- log("DevDependency added", key)
107
+ log("Detected new devDependency: " + key)
108
108
  requireReload = true;
109
109
  requireInstall = true;
110
110
  }
111
111
  }
112
112
  }
113
113
 
114
+ if (modified) {
115
+ log("package.json has changed. Require install: " + (requireInstall ? "yes" : "no"))
116
+ }
114
117
 
115
118
  packageJsonSize = packageJsonStat.size;
116
119
  lastEditTime = packageJsonStat.mtime;
@@ -119,7 +122,7 @@
119
122
  restart(server, projectDir, cachePath);
120
123
  }
121
124
  }
122
- }, 1000);
125
+ }, 2000);
123
126
  }
124
127
 
125
128
  function testIfInstallIsRequired(projectDir, packageJson) {
@@ -142,7 +145,7 @@
142
145
  }
143
146
  }
144
147
  }
145
- log("Dependency not installed", key)
148
+ log("Dependency not installed: " + key)
146
149
  return true;
147
150
  }
148
151
  else {
@@ -162,8 +165,9 @@
162
165
  if (!isRange) {
163
166
  const packageJsonPath = path.join(depPath, "package.json");
164
167
  const installedVersion = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
165
- if (expectedVersion !== installedVersion) {
166
- log(`Dependency ${key} is installed but version is not correct. Expected ${expectedVersion} but got ${installedVersion}`)
168
+ // fix check for cases where the version contains a alias e.g. npm:[email protected]
169
+ if (installedVersion.endsWith(expectedVersion) == false) {
170
+ log(`Dependency ${key} is installed but version is not the right one. Expected ${expectedVersion} but got ${installedVersion}`)
167
171
  return true;
168
172
  }
169
173
  }
@@ -194,13 +198,13 @@
194
198
  }
195
199
 
196
200
  if (id !== restartId) return;
197
- if (Date.now() - lastRestartTime < 1000) return;
201
+ if (Date.now() - lastRestartTime < 3000) return;
198
202
  log("Restarting server...")
199
203
  lastRestartTime = Date.now();
200
204
  requireInstall = false;
201
205
  if (existsSync(cachePath))
202
206
  rmSync(cachePath, { recursive: true, force: true });
203
- triggerReloadOnClients();
207
+ await triggerReloadOnClients();
204
208
 
205
209
  // touch vite config to trigger reload
206
210
  // const viteConfigPath = path.join(projectDir, "vite.config.js");
@@ -212,8 +216,9 @@
212
216
  // }
213
217
 
214
218
  // check if server is running
215
- if (server.httpServer.listening)
219
+ if (server.httpServer.listening) {
216
220
  server.restart();
221
+ }
217
222
  isRunningRestart = false;
218
223
  console.log("-----------------------------------------------")
219
224
  }
plugins/vite/index.js CHANGED
@@ -42,6 +42,7 @@
42
42
 
43
43
  import { vite_4_4_hack } from "./vite-4.4-hack.js";
44
44
  import { needleImportsLogger } from "./imports-logger.js";
45
+ import { needleBuildInfo } from "./buildinfo.js";
45
46
 
46
47
 
47
48
  export * from "./gzip.js";
@@ -69,6 +70,7 @@
69
70
  needlePoster(command, config, userSettings),
70
71
  needleReload(command, config, userSettings),
71
72
  needleBuild(command, config, userSettings),
73
+ needleBuildInfo(command, config, userSettings),
72
74
  needleCopyFiles(command, config, userSettings),
73
75
  needleTransformCodegen(command, config, userSettings),
74
76
  needleDrop(command, config, userSettings),
plugins/vite/meta.js CHANGED
@@ -109,6 +109,8 @@
109
109
  }
110
110
  else console.log("WARN: could not find needle engine package.json")
111
111
 
112
+ tags.push({ tag: 'meta', attrs: { name: 'needle:buildtime', content: new Date().toISOString() } });
113
+
112
114
  return { html, tags }
113
115
  },
114
116
  }
src/engine-components/AnimatorController.ts CHANGED
@@ -213,6 +213,7 @@
213
213
  console.warn("AnimatorController has not been resolved, can not create model from string", this.model);
214
214
  return null;
215
215
  }
216
+ if (debug) console.warn("AnimatorController clone()", this.model);
216
217
  // clone runtime controller but dont clone clip or action
217
218
  const clonedModel = deepClone(this.model, (_owner, _key, _value) => {
218
219
  if (_value === null || _value === undefined) return true;
@@ -225,6 +226,8 @@
225
226
  }
226
227
  // dont clone AnimationClip
227
228
  if (_value["tracks"] !== undefined) return false;
229
+ // when assigned __concreteInstance during serialization
230
+ if (_value instanceof AnimatorController) return false;
228
231
  return true;
229
232
  }) as AnimatorControllerModel;
230
233
  console.assert(clonedModel !== this.model);
@@ -582,7 +585,7 @@
582
585
  }
583
586
 
584
587
  private createActions(_animator: Animator) {
585
- // console.trace(this.model, _animator);
588
+ if (debug) console.log("AnimatorController createActions", this.model);
586
589
  for (const layer of this.model.layers) {
587
590
  const sm = layer.stateMachine;
588
591
  for (let index = 0; index < sm.states.length; index++) {
@@ -609,8 +612,13 @@
609
612
  if (this.animator && state.motion.clips) {
610
613
  // TODO: we have to compare by name because on instantiate we clone objects but not the node object
611
614
  const mapping = state.motion.clips?.find(e => e.node.name === this.animator?.gameObject?.name);
612
- // console.log(state.name, mapping?.clip);
613
- state.motion.clip = mapping?.clip;
615
+ if (!mapping) {
616
+ if (debug || isDevEnvironment()) {
617
+ console.warn("Could not find clip for animator \"" + this.animator?.gameObject?.name + "\"", state.motion.clips.map(c => c.node.name));
618
+ }
619
+ }
620
+ else
621
+ state.motion.clip = mapping.clip;
614
622
  }
615
623
 
616
624
  // ensure we have a clip to blend to
src/engine-components/Duplicatable.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { Object3D,Quaternion, Vector3 } from "three";
1
+ import { Object3D, Quaternion, Vector3 } from "three";
2
2
 
3
+ import { isDevEnvironment } from "../engine/debug/index.js";
3
4
  import { InstantiateOptions } from "../engine/engine_gameobject.js";
4
5
  import { serializable } from "../engine/engine_serialization_decorator.js";
5
6
  import { Behaviour, GameObject } from "./Component.js";
@@ -45,10 +46,10 @@
45
46
  // legacy – DragControls was required for duplication and so often the component is still there; we work around that by disabling it here
46
47
  const dragControls = this.gameObject.getComponent(DragControls);
47
48
  if (dragControls) {
48
- console.warn("Please remove DragControls from object with Duplicatable component, it's not needed anymore.");
49
+ if (isDevEnvironment()) console.warn(`Please remove DragControls from \"${dragControls.name}\": it's not needed anymore when the object also has a Duplicatable component`);
49
50
  dragControls.enabled = false;
50
51
  }
51
-
52
+
52
53
  if (!this.gameObject.getComponentInParent(ObjectRaycaster))
53
54
  this.gameObject.addNewComponent(ObjectRaycaster);
54
55
 
src/engine/engine_constants.ts CHANGED
@@ -8,19 +8,22 @@
8
8
 
9
9
  tryEval(`if(!globalThis["NEEDLE_ENGINE_VERSION"]) globalThis["NEEDLE_ENGINE_VERSION"] = "0.0.0";`)
10
10
  tryEval(`if(!globalThis["NEEDLE_ENGINE_GENERATOR"]) globalThis["NEEDLE_ENGINE_GENERATOR"] = "unknown";`)
11
+ tryEval(`if(!globalThis["NEEDLE_PROJECT_BUILD_TIME"]) globalThis["NEEDLE_PROJECT_BUILD_TIME"] = "unknown";`)
11
12
 
12
13
  declare const NEEDLE_ENGINE_VERSION: string
13
14
  declare const NEEDLE_ENGINE_GENERATOR: string;
15
+ declare const NEEDLE_PROJECT_BUILD_TIME: string;
14
16
 
15
17
  // Make sure to wrap the new global this define in underscores to prevent the bundler from replacing it with the actual value
16
18
  tryEval(`globalThis["__NEEDLE_ENGINE_VERSION__"] = "` + NEEDLE_ENGINE_VERSION + `";`)
17
19
  tryEval(`globalThis["__NEEDLE_ENGINE_GENERATOR__"] = "` + NEEDLE_ENGINE_GENERATOR + `";`)
20
+ tryEval(`globalThis["__NEEDLE_PROJECT_BUILD_TIME__"] = "` + NEEDLE_PROJECT_BUILD_TIME + `";`)
18
21
 
19
22
 
20
-
21
23
  export const VERSION = NEEDLE_ENGINE_VERSION;
22
24
  export const GENERATOR = NEEDLE_ENGINE_GENERATOR;
23
- if (debug) console.log(`Engine version: ${VERSION} (generator: ${GENERATOR})`);
25
+ const BUILD_TIME = NEEDLE_PROJECT_BUILD_TIME;
26
+ if (debug) console.log(`Engine version: ${VERSION} (generator: ${GENERATOR})\nProject built at ${BUILD_TIME}`);
24
27
 
25
28
  export const activeInHierarchyFieldName = "needle_isActiveInHierarchy";
26
29
  export const builtinComponentKeyName = "builtin_components";
src/engine/engine_serialization_builtin_serializer.ts CHANGED
@@ -157,6 +157,14 @@
157
157
 
158
158
  onDeserialize(data: any, context: SerializationContext) {
159
159
  if (data?.guid) {
160
+
161
+ // it's a workaround for VolumeParameter having a guid as well. Generally we will probably never have to resolve a component in the scene when it's coming from the persistent asset extension (like in the case for postprocessing volume parameters)
162
+ if (data.___persistentAsset) {
163
+ if(debugExtension) console.log("Skipping component deserialization because it's a persistent asset", data);
164
+ return undefined;
165
+ }
166
+
167
+ const currentPath = context.path;
160
168
  // TODO: need to serialize some identifier for referenced components as well, maybe just guid?
161
169
  // because here the components are created but dont have their former guid assigned
162
170
  // and will later in the stack just get a newly generated guid
@@ -174,8 +182,9 @@
174
182
  res = this.findObjectForGuid(data.guid, context.context?.scene);
175
183
  if (res) return res;
176
184
  }
177
- if (isDevEnvironment() || debugExtension)
178
- console.warn("Could not resolve component reference", context.path, data, context.target);
185
+ if (isDevEnvironment() || debugExtension) {
186
+ console.warn("Could not resolve component reference: \"" + currentPath + "\" using guid " + data.guid, context.target);
187
+ }
179
188
  data["could_not_resolve"] = true;
180
189
  return undefined;
181
190
  }
src/engine/engine_serialization_core.ts CHANGED
@@ -360,7 +360,6 @@
360
360
  obj.onAfterDeserialize(serializedData, context);
361
361
  }
362
362
 
363
- context.path = undefined;
364
363
  return true;
365
364
  }
366
365
 
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -180,9 +180,9 @@
180
180
  static get xrSystem(): XRSystem | undefined {
181
181
  return ('xr' in navigator) ? navigator.xr : undefined;
182
182
  }
183
- static isXRSupported() { return Promise.all([this.isVRSupported(), this.isARSupported()]).then(res => res.some(e => e)) }
184
- static isVRSupported() { return this.xrSystem?.isSessionSupported('immersive-vr') ?? Promise.resolve(false); }
185
- static isARSupported() { return this.xrSystem?.isSessionSupported('immersive-ar') ?? Promise.resolve(false); }
183
+ static isXRSupported() { return Promise.all([this.isVRSupported(), this.isARSupported()]).then(res => res.some(e => e)).catch(() => false); }
184
+ static isVRSupported() { return this.xrSystem?.isSessionSupported('immersive-vr').catch(err => { if (debug) console.error(err); return false }) ?? Promise.resolve(false); }
185
+ static isARSupported() { return this.xrSystem?.isSessionSupported('immersive-ar').catch(err => { if (debug) console.error(err); return false }) ?? Promise.resolve(false); }
186
186
 
187
187
  private static _currentSessionRequest?: Promise<XRSession>;
188
188
  private static _activeSession: NeedleXRSession | null;
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -115,7 +115,8 @@
115
115
  const ios = isiOS()
116
116
  const safari = isSafari();
117
117
  if (debug || (ios && safari)) {
118
- this.button = this.createQuicklookButton();
118
+ if (this.allowCreateQuicklookButton)
119
+ this.button = this.createQuicklookButton();
119
120
 
120
121
  this.lastCallback = this.quicklookCallback.bind(this);
121
122
  this.link = ensureQuicklookLinkIsCreated(this.context);
plugins/types/userconfig.d.ts CHANGED
@@ -25,6 +25,9 @@
25
25
  /** Set to true to create an imports.log file that shows all module imports. The file is generated when stopping the server. */
26
26
  logModuleImportChains: boolean;
27
27
 
28
+ /** Set to true to disable generating the buildinfo.json file in your output directory */
29
+ noBuildInfo: boolean;
30
+
28
31
  /** required for @serializable https://github.com/vitejs/vite/issues/13736 */
29
32
  vite44Hack: boolean;
30
33
 
src/engine-components/webxr/WebARSessionRoot.ts CHANGED
@@ -257,6 +257,12 @@
257
257
  // if (this.invertForward) {
258
258
  // reticle.rotateY(Math.PI);
259
259
  // }
260
+
261
+ // Workaround: For a custom reticle we apply the view based transform during placement preview
262
+ // See NE-4161 for context
263
+ if (this.customReticle)
264
+ this.applyViewBasedTransform(reticle);
265
+
260
266
  reticle.updateMatrix();
261
267
  reticle.visible = true;
262
268
  if (reticle.parent !== this.context.scene)
@@ -350,8 +356,32 @@
350
356
  }
351
357
  }
352
358
 
359
+ private applyViewBasedTransform(reticle: Object3D) {
360
+ // Make reticle face the user to unify the placement experience across devices.
361
+ // The pose that we're receiving from the hit test varies between devices:
362
+ // - Quest: currently aligned to the mesh that was hit (depends on room setup), has changed a couple times
363
+ // - Android WebXR: looking at the camera, but pretty random when on a wall
364
+ // - Mozilla WebXR Viewer: aligned to the start of the session
365
+ const camGo = this.context.mainCamera as Object3D as GameObject;
366
+ const reticleGo = reticle as GameObject;
367
+ const camWP = camGo.worldPosition;
368
+ const reticleWp = reticleGo.worldPosition;
369
+ // const distance = camWP.distanceTo(reticleWp);
370
+ camWP.y = reticleWp.y;
371
+ reticle.lookAt(camWP);
372
+
373
+ // TODO: ability to scale the reticle so that we can fit the scene depending on the view angle or distance to the reticle.
374
+ // Currently, doing this leads to wrong placement of the scene.
375
+ /*
376
+ const rigScale = NeedleXRSession.active?.rigScale || 1;
377
+ const scale = distance * rigScale;
378
+ reticle.scale.set(scale, scale, scale);
379
+ */
380
+ }
381
+
353
382
  private onApplyPose(reticle: Object3D) {
354
383
  const rigObject = NeedleXRSession.active?.rig?.gameObject;
384
+ const rigScale = NeedleXRSession.active?.rigScale || 1;
355
385
  if (rigObject) {
356
386
  // save the previous rig parent
357
387
  const previousParent = rigObject.parent || this.context.scene;
@@ -366,6 +396,7 @@
366
396
  this._rigPlacementMatrix = rigObject.matrix.clone();
367
397
  }
368
398
 
399
+ this.applyViewBasedTransform(reticle);
369
400
  reticle.updateMatrix();
370
401
  // attach rig to reticle (since the reticle is in rig space it's a easy way to place the rig where we want it relative to the reticle)
371
402
  this.context.scene.add(reticle);
@@ -373,7 +404,7 @@
373
404
  reticle.removeFromParent();
374
405
 
375
406
  // move rig now relative to the reticle
376
- // apply scale
407
+ // TODO support scaled reticle
377
408
  rigObject.scale.set(this.arScale, this.arScale, this.arScale);
378
409
  rigObject.position.multiplyScalar(this.arScale);
379
410
 
plugins/common/buildinfo.js ADDED
@@ -0,0 +1,56 @@
1
+ import fs from 'fs';
2
+
3
+
4
+ /** Create a file containing information about the build inside the build directory
5
+ * @param {String} buildDirectory
6
+ */
7
+ export function createBuildInfoFile(buildDirectory) {
8
+ if (!buildDirectory) {
9
+ console.warn("WARN: Can not create build info file because \"buildDirectory\" is not defined");
10
+ return;
11
+ }
12
+ // start creating the buildinfo
13
+ const buildInfo = {
14
+ time: new Date().toISOString(),
15
+ totalsize: 0,
16
+ files: []
17
+ };
18
+ console.log("[needle-buildinfo] - Collect files in " + buildDirectory);
19
+ recursivelyCollectFiles(buildDirectory, "", buildInfo);
20
+ const buildInfoPath = `${buildDirectory}/needle.buildinfo.json`;
21
+ const totalSizeInMB = buildInfo.totalsize / 1024 / 1024;
22
+ console.log(`[needle-buildinfo] - Collected ${buildInfo.files.length} files (${totalSizeInMB.toFixed(2)} MB). Write info to \"${buildInfoPath}\"`);
23
+ fs.writeFileSync(buildInfoPath, JSON.stringify(buildInfo));
24
+ }
25
+
26
+ /** Recursively collect all files in a directory
27
+ * @param {String} directory to search
28
+ * @param {{ files: Array<string>, totalsize:number }} info
29
+ */
30
+ function recursivelyCollectFiles(directory, path, info) {
31
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
32
+ for (const entry of entries) {
33
+ if (entry.isDirectory()) {
34
+ // make sure we never collect files inside node_modules
35
+ if (entry.name === "node_modules") {
36
+ console.warn("WARN: Skipping node_modules directory at " + path);
37
+ continue;
38
+ }
39
+ const newPath = path?.length <= 0 ? entry.name : `${path}/${entry.name}`;
40
+ const newDirectory = `${directory}/${entry.name}`;
41
+ console.log("[needle-buildinfo] - Collect files in " + newPath);
42
+ recursivelyCollectFiles(newDirectory, newPath, info);
43
+ } else {
44
+ const relpath = `${path}/${entry.name}`;
45
+ info.files.push(relpath);
46
+ try {
47
+ const fullpath = `${directory}/${entry.name}`;
48
+ const stats = fs.statSync(fullpath);
49
+ info.totalsize += stats.size;
50
+ }
51
+ catch {
52
+ //ignore
53
+ }
54
+ }
55
+ }
56
+ }
plugins/vite/buildinfo.js ADDED
@@ -0,0 +1,23 @@
1
+ import { createBuildInfoFile } from '../common/buildinfo.js';
2
+ import { getOutputDirectory } from './config.js';
3
+
4
+
5
+
6
+ /** Create a buildinfo file in the build directory
7
+ * @param {import('../types').userSettings} userSettings
8
+ * @returns {import('vite').Plugin}
9
+ */
10
+ export const needleBuildInfo = (command, config, userSettings) => {
11
+
12
+ if (userSettings?.noBuildInfo) return;
13
+
14
+ return {
15
+ name: 'needle-buildinfo',
16
+ apply: "build",
17
+ enforce: "post",
18
+ closeBundle: () => {
19
+ const buildDirectory = getOutputDirectory();
20
+ createBuildInfoFile(buildDirectory);
21
+ }
22
+ }
23
+ }