@driftengine/assets 3.61.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +9 -0
- package/dist/assetPath.d.ts +51 -0
- package/dist/assetPath.js +59 -0
- package/dist/coarseLevel.d.ts +94 -0
- package/dist/coarseLevel.js +606 -0
- package/dist/dds.d.ts +41 -0
- package/dist/dds.js +425 -0
- package/dist/drawKey.d.ts +64 -0
- package/dist/drawKey.js +42 -0
- package/dist/drftLoader.d.ts +420 -0
- package/dist/drftLoader.js +996 -0
- package/dist/drftTextures.d.ts +87 -0
- package/dist/drftTextures.js +138 -0
- package/dist/fbx.d.ts +86 -0
- package/dist/fbx.js +792 -0
- package/dist/fbxHarness.d.ts +68 -0
- package/dist/fbxHarness.js +185 -0
- package/dist/fbxInflate.d.ts +60 -0
- package/dist/fbxInflate.js +204 -0
- package/dist/fbxMaterial.d.ts +87 -0
- package/dist/fbxMaterial.js +345 -0
- package/dist/fbxRig.d.ts +130 -0
- package/dist/fbxRig.js +601 -0
- package/dist/gltf.d.ts +242 -0
- package/dist/gltf.js +904 -0
- package/dist/gltfAccessor.d.ts +2 -0
- package/dist/gltfAccessor.js +106 -0
- package/dist/gltfSkin.d.ts +32 -0
- package/dist/gltfSkin.js +144 -0
- package/dist/iesPhotometry.d.ts +44 -0
- package/dist/iesPhotometry.js +94 -0
- package/dist/imageInfo.d.ts +31 -0
- package/dist/imageInfo.js +119 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +132 -0
- package/dist/kn5.d.ts +158 -0
- package/dist/kn5.js +557 -0
- package/dist/loadProgress.d.ts +118 -0
- package/dist/loadProgress.js +1 -0
- package/dist/localise.d.ts +35 -0
- package/dist/localise.js +253 -0
- package/dist/obj.d.ts +54 -0
- package/dist/obj.js +335 -0
- package/dist/orient.d.ts +104 -0
- package/dist/orient.js +241 -0
- package/dist/radianceHdr.d.ts +36 -0
- package/dist/radianceHdr.js +172 -0
- package/dist/readModel.d.ts +153 -0
- package/dist/readModel.js +386 -0
- package/dist/recognise.d.ts +38 -0
- package/dist/recognise.js +97 -0
- package/dist/stl.d.ts +22 -0
- package/dist/stl.js +131 -0
- package/dist/tangentOrder.d.ts +24 -0
- package/dist/tangentOrder.js +31 -0
- package/dist/threemf.d.ts +46 -0
- package/dist/threemf.js +254 -0
- package/dist/uploadBudget.d.ts +82 -0
- package/dist/uploadBudget.js +86 -0
- package/dist/usd.d.ts +49 -0
- package/dist/usd.js +542 -0
- package/dist/weld.d.ts +44 -0
- package/dist/weld.js +237 -0
- package/dist/zip.d.ts +35 -0
- package/dist/zip.js +110 -0
- package/package.json +61 -0
- package/src/assetPath.ts +79 -0
- package/src/coarseLevel.ts +720 -0
- package/src/dds.ts +507 -0
- package/src/drawKey.ts +91 -0
- package/src/drftLoader.ts +1197 -0
- package/src/drftTextures.ts +153 -0
- package/src/fbx.ts +934 -0
- package/src/fbxHarness.ts +215 -0
- package/src/fbxInflate.ts +258 -0
- package/src/fbxMaterial.ts +410 -0
- package/src/fbxRig.ts +652 -0
- package/src/gltf.ts +1161 -0
- package/src/gltfAccessor.ts +127 -0
- package/src/gltfSkin.ts +210 -0
- package/src/iesPhotometry.ts +136 -0
- package/src/imageInfo.ts +130 -0
- package/src/index.ts +162 -0
- package/src/kn5.ts +705 -0
- package/src/loadProgress.ts +131 -0
- package/src/localise.ts +275 -0
- package/src/obj.ts +386 -0
- package/src/orient.ts +262 -0
- package/src/radianceHdr.ts +195 -0
- package/src/readModel.ts +515 -0
- package/src/recognise.ts +124 -0
- package/src/stl.ts +147 -0
- package/src/tangentOrder.ts +32 -0
- package/src/threemf.ts +303 -0
- package/src/uploadBudget.ts +90 -0
- package/src/usd.ts +642 -0
- package/src/weld.ts +264 -0
- package/src/zip.ts +130 -0
package/src/kn5.ts
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assetto Corsa's `.kn5`. Tier 2: a real attempt, and explicitly not a guarantee.
|
|
3
|
+
*
|
|
4
|
+
* The format has no published specification. What is implemented here was verified against three
|
|
5
|
+
* files from a production car mod, each of which parses to *exact EOF* — a binary walk that
|
|
6
|
+
* consumes a 44 MB file to its last byte has no field misread anywhere in it, because a single
|
|
7
|
+
* wrong width desynchronises everything after it. That is the evidence this reader rests on, and
|
|
8
|
+
* `scripts/kn5-check.mjs` re-establishes it on demand.
|
|
9
|
+
*
|
|
10
|
+
* **It needs nothing from its host.** A `.kn5` embeds every texture it uses, so unlike a `.gltf`
|
|
11
|
+
* naming external buffers or an `.fbx` storing deflated arrays, there is no capability to inject
|
|
12
|
+
* and no sidecar to resolve. It is the one binary format here that reads from bytes alone.
|
|
13
|
+
*
|
|
14
|
+
* It reports what the file says and corrects none of it: the frame comes back as `declaredHand`
|
|
15
|
+
* and `orient.ts` is what would transform it, for the same reason every other reader here reports
|
|
16
|
+
* `declaredUp` instead of standing its own models up.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DrftError } from '@driftengine/drft';
|
|
20
|
+
import type { DrftMaterial, DrftNode, MeshData } from '@driftengine/drft';
|
|
21
|
+
import type { AssetReference } from './assetPath.ts';
|
|
22
|
+
import type { Handedness, UpAxis } from './orient.ts';
|
|
23
|
+
|
|
24
|
+
/** A texture the file carried inline. Every `.kn5` texture is inline; none are named only. */
|
|
25
|
+
export interface Kn5Texture {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly bytes: Uint8Array;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One entry of the shared material palette, as the file states it. */
|
|
31
|
+
export interface Kn5Material {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly shader: string;
|
|
34
|
+
/**
|
|
35
|
+
* 0 opaque, 1 alpha blended, 2 alpha to coverage. **The file's own statement about the pass.**
|
|
36
|
+
*
|
|
37
|
+
* Read where the walk previously skipped two bytes it called "the flags". Measured across a
|
|
38
|
+
* shipped car and its three levels of detail plus its collider, it takes only the values the
|
|
39
|
+
* format defines — 61 and 9 in the full model, 33/6, 26/3, 16/2, and one opaque collider — and
|
|
40
|
+
* it agrees with the *node's* transparency flag on 171 of 172 meshes, which is the cross-check
|
|
41
|
+
* that says the field is being read and not merely decoded.
|
|
42
|
+
*/
|
|
43
|
+
readonly blendMode: number;
|
|
44
|
+
/** Whether `ksAlphaRef` is a live alpha test. Zero in every file measured here. */
|
|
45
|
+
readonly alphaTested: boolean;
|
|
46
|
+
/** `ksDiffuse`, `ksSpecularEXP`, `ksAlphaRef` and the rest, unmapped. */
|
|
47
|
+
readonly props: ReadonlyMap<string, number>;
|
|
48
|
+
/** Sampler name to texture name: `txDiffuse`, `txNormal`, `txMaps`, `txDetail`. */
|
|
49
|
+
readonly textures: ReadonlyMap<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `Kn5Material.blendMode` when the surface belongs in the blended pass. */
|
|
53
|
+
export const KN5_ALPHA_BLEND = 1;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A position in the file that refuses instead of running off the end.
|
|
57
|
+
*
|
|
58
|
+
* Every read goes through here so that a malformed file fails with an offset somebody can look
|
|
59
|
+
* at, which is the whole difference between a tier 2 refusal and a parser crash.
|
|
60
|
+
*/
|
|
61
|
+
export class Cursor {
|
|
62
|
+
readonly view: DataView;
|
|
63
|
+
readonly bytes: Uint8Array;
|
|
64
|
+
at = 0;
|
|
65
|
+
|
|
66
|
+
constructor(buffer: ArrayBuffer) {
|
|
67
|
+
this.view = new DataView(buffer);
|
|
68
|
+
this.bytes = new Uint8Array(buffer);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Refuses when `count` bytes are not there, naming where it ran out. */
|
|
72
|
+
need(count: number, what: string): void {
|
|
73
|
+
if (count < 0 || this.at + count > this.bytes.length) {
|
|
74
|
+
throw new DrftError(
|
|
75
|
+
`kn5: ${what} runs past the end of the file at offset ${this.at} ` +
|
|
76
|
+
`(wanted ${count} bytes, ${this.bytes.length - this.at} remain)`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
i32(what = 'an integer'): number {
|
|
82
|
+
this.need(4, what);
|
|
83
|
+
const value = this.view.getInt32(this.at, true);
|
|
84
|
+
this.at += 4;
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
u32(what = 'an integer'): number {
|
|
89
|
+
this.need(4, what);
|
|
90
|
+
const value = this.view.getUint32(this.at, true);
|
|
91
|
+
this.at += 4;
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
f32(what = 'a float'): number {
|
|
96
|
+
this.need(4, what);
|
|
97
|
+
const value = this.view.getFloat32(this.at, true);
|
|
98
|
+
this.at += 4;
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
u16(what = 'a short'): number {
|
|
103
|
+
this.need(2, what);
|
|
104
|
+
const value = this.view.getUint16(this.at, true);
|
|
105
|
+
this.at += 2;
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
u8(what = 'a byte'): number {
|
|
110
|
+
this.need(1, what);
|
|
111
|
+
return this.bytes[this.at++] as number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
skip(count: number, what: string): void {
|
|
115
|
+
this.need(count, what);
|
|
116
|
+
this.at += count;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
take(count: number, what: string): Uint8Array {
|
|
120
|
+
this.need(count, what);
|
|
121
|
+
const slice = this.bytes.subarray(this.at, this.at + count);
|
|
122
|
+
this.at += count;
|
|
123
|
+
return slice;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** i32 length, then that many UTF-8 bytes. */
|
|
127
|
+
str(what = 'a name'): string {
|
|
128
|
+
const length = this.i32(`the length of ${what}`);
|
|
129
|
+
return new TextDecoder().decode(this.take(length, what));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Bytes each material property carries after its value, and does not explain. */
|
|
134
|
+
const PROPERTY_TRAILER = 36;
|
|
135
|
+
|
|
136
|
+
/** What the file says before its node tree begins. */
|
|
137
|
+
export interface Kn5Header {
|
|
138
|
+
readonly version: number;
|
|
139
|
+
readonly textures: Kn5Texture[];
|
|
140
|
+
readonly materials: Kn5Material[];
|
|
141
|
+
/** Where the node tree starts, so the caller carries on from here. */
|
|
142
|
+
readonly cursor: Cursor;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The magic, the version, the texture block and the material palette.
|
|
147
|
+
*
|
|
148
|
+
* Split from the node walk because it is the half that is pure data: no recursion, no transforms,
|
|
149
|
+
* and nothing that needs a `MeshData` to exist.
|
|
150
|
+
*/
|
|
151
|
+
export function readKn5Header(buffer: ArrayBuffer): Kn5Header {
|
|
152
|
+
const cursor = new Cursor(buffer);
|
|
153
|
+
cursor.need(10, 'the kn5 header');
|
|
154
|
+
const magic = new TextDecoder().decode(cursor.take(6, 'the magic'));
|
|
155
|
+
if (magic !== 'sc6969') {
|
|
156
|
+
throw new DrftError(`kn5: this is not a kn5 file — it begins "${magic}" and not "sc6969"`);
|
|
157
|
+
}
|
|
158
|
+
const version = cursor.i32('the version');
|
|
159
|
+
/*
|
|
160
|
+
* Version 6 carries one more word here than version 5 did. It reads 0 in every file measured,
|
|
161
|
+
* and it is skipped by name instead of interpreted: a field whose meaning is unknown is better
|
|
162
|
+
* passed over deliberately than guessed at.
|
|
163
|
+
*/
|
|
164
|
+
if (version > 5) cursor.i32('the version 6 extra word');
|
|
165
|
+
|
|
166
|
+
const textures: Kn5Texture[] = [];
|
|
167
|
+
const textureCount = cursor.i32('the texture count');
|
|
168
|
+
for (let i = 0; i < textureCount; i++) {
|
|
169
|
+
cursor.i32(`the type of texture ${i}`);
|
|
170
|
+
const name = cursor.str(`the name of texture ${i}`);
|
|
171
|
+
const size = cursor.i32(`the size of texture "${name}"`);
|
|
172
|
+
textures.push({ name, bytes: cursor.take(size, `the bytes of texture "${name}"`) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const materials: Kn5Material[] = [];
|
|
176
|
+
const materialCount = cursor.i32('the material count');
|
|
177
|
+
for (let i = 0; i < materialCount; i++) {
|
|
178
|
+
const name = cursor.str(`the name of material ${i}`);
|
|
179
|
+
const shader = cursor.str(`the shader of material "${name}"`);
|
|
180
|
+
const blendMode = cursor.u8(`the blend mode of material "${name}"`);
|
|
181
|
+
const alphaTested = cursor.u8(`the alpha test flag of material "${name}"`) !== 0;
|
|
182
|
+
if (version > 4) cursor.i32(`the depth mode of material "${name}"`);
|
|
183
|
+
|
|
184
|
+
const props = new Map<string, number>();
|
|
185
|
+
const propCount = cursor.i32(`the property count of material "${name}"`);
|
|
186
|
+
for (let p = 0; p < propCount; p++) {
|
|
187
|
+
const property = cursor.str(`a property name of material "${name}"`);
|
|
188
|
+
props.set(property, cursor.f32(`the value of "${property}"`));
|
|
189
|
+
cursor.skip(PROPERTY_TRAILER, `the trailer of property "${property}"`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const textureSlots = new Map<string, string>();
|
|
193
|
+
const slotCount = cursor.i32(`the sampler count of material "${name}"`);
|
|
194
|
+
for (let s = 0; s < slotCount; s++) {
|
|
195
|
+
const sampler = cursor.str(`a sampler name of material "${name}"`);
|
|
196
|
+
cursor.i32(`the slot of sampler "${sampler}"`);
|
|
197
|
+
textureSlots.set(sampler, cursor.str(`the texture bound to "${sampler}"`));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
materials.push({ name, shader, blendMode, alphaTested, props, textures: textureSlots });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { version, textures, materials, cursor };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Node types the format defines. Anything else is a refusal, not a skip. */
|
|
207
|
+
const NODE_DUMMY = 1;
|
|
208
|
+
const NODE_MESH = 2;
|
|
209
|
+
const NODE_ANIMATED = 3;
|
|
210
|
+
|
|
211
|
+
/** pos[3] normal[3] uv[2] tangent[3], all f32. */
|
|
212
|
+
const MESH_VERTEX_BYTES = 44;
|
|
213
|
+
/** The same, plus weights[4] and bone indices[4]. */
|
|
214
|
+
const ANIMATED_VERTEX_BYTES = 76;
|
|
215
|
+
/** layer:u32 lodIn:f32 lodOut:f32 sphere[3]:f32 radius:f32 renderable:u8. */
|
|
216
|
+
const MESH_TRAILER = 29;
|
|
217
|
+
/** An animated mesh states a layer and a lod window and stops there. */
|
|
218
|
+
const ANIMATED_TRAILER = 12;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* One node of the file's own graph.
|
|
222
|
+
*
|
|
223
|
+
* `matrix` is the node's **local** transform, row-major with the translation in row 3, which is
|
|
224
|
+
* the row-vector convention the format inherits from DirectX. The world matrix is not stored
|
|
225
|
+
* here: it is accumulated during the walk and applied to the geometry, because `MESH` is
|
|
226
|
+
* world-space by the rule in the design's §5.1.
|
|
227
|
+
*/
|
|
228
|
+
export interface Kn5Node {
|
|
229
|
+
readonly type: number;
|
|
230
|
+
readonly name: string;
|
|
231
|
+
readonly parent: number;
|
|
232
|
+
readonly matrix: Float32Array;
|
|
233
|
+
/** Index into the returned meshes, or -1 for a node that draws nothing. */
|
|
234
|
+
readonly mesh: number;
|
|
235
|
+
readonly castShadows: boolean;
|
|
236
|
+
readonly isVisible: boolean;
|
|
237
|
+
/** The author's own statement that this surface belongs in the blended pass. */
|
|
238
|
+
readonly isTransparent: boolean;
|
|
239
|
+
/** Zero in every car measured; non-zero in track models. Carried, never relied upon. */
|
|
240
|
+
readonly lodIn: number;
|
|
241
|
+
readonly lodOut: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Geometry as the file stores it, already carried into world space. */
|
|
245
|
+
export interface Kn5Mesh {
|
|
246
|
+
readonly positions: Float32Array;
|
|
247
|
+
readonly normals: Float32Array;
|
|
248
|
+
readonly uvs: Float32Array;
|
|
249
|
+
readonly tangents: Float32Array;
|
|
250
|
+
readonly indices: Uint32Array;
|
|
251
|
+
/** Index into the material palette from `readKn5Header`. */
|
|
252
|
+
readonly material: number;
|
|
253
|
+
/** Index into the returned nodes, so a mesh can name the node that placed it. */
|
|
254
|
+
readonly node: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
258
|
+
|
|
259
|
+
/** Row-vector multiply: `out = a * b`, matching how the format composes a hierarchy. */
|
|
260
|
+
function multiply(a: Float32Array, b: Float32Array): Float32Array {
|
|
261
|
+
const out = new Float32Array(16);
|
|
262
|
+
for (let row = 0; row < 4; row++) {
|
|
263
|
+
for (let column = 0; column < 4; column++) {
|
|
264
|
+
let sum = 0;
|
|
265
|
+
for (let k = 0; k < 4; k++) sum += (a[row * 4 + k] as number) * (b[k * 4 + column] as number);
|
|
266
|
+
out[row * 4 + column] = sum;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A point through a row-vector matrix, translation included. */
|
|
273
|
+
function transformPoint(
|
|
274
|
+
m: Float32Array,
|
|
275
|
+
x: number,
|
|
276
|
+
y: number,
|
|
277
|
+
z: number,
|
|
278
|
+
): [number, number, number] {
|
|
279
|
+
return [
|
|
280
|
+
x * (m[0] as number) + y * (m[4] as number) + z * (m[8] as number) + (m[12] as number),
|
|
281
|
+
x * (m[1] as number) + y * (m[5] as number) + z * (m[9] as number) + (m[13] as number),
|
|
282
|
+
x * (m[2] as number) + y * (m[6] as number) + z * (m[10] as number) + (m[14] as number),
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A direction through the same matrix: no translation, so normals and tangents share it. */
|
|
287
|
+
function transformDirection(
|
|
288
|
+
m: Float32Array,
|
|
289
|
+
x: number,
|
|
290
|
+
y: number,
|
|
291
|
+
z: number,
|
|
292
|
+
): [number, number, number] {
|
|
293
|
+
return [
|
|
294
|
+
x * (m[0] as number) + y * (m[4] as number) + z * (m[8] as number),
|
|
295
|
+
x * (m[1] as number) + y * (m[5] as number) + z * (m[9] as number),
|
|
296
|
+
x * (m[2] as number) + y * (m[6] as number) + z * (m[10] as number),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Walk the node tree depth first, which is the order the file stores it in.
|
|
302
|
+
*
|
|
303
|
+
* **Every node is kept, including the `AC_`-prefixed ones.** The community converter skips them,
|
|
304
|
+
* correctly for its purpose, since they carry no geometry an OBJ can hold. They are placement
|
|
305
|
+
* anchors and convention markers, and this reader exists to preserve exactly that.
|
|
306
|
+
*/
|
|
307
|
+
export function readKn5Nodes(header: Kn5Header): { nodes: Kn5Node[]; meshes: Kn5Mesh[] } {
|
|
308
|
+
const { cursor } = header;
|
|
309
|
+
const nodes: Kn5Node[] = [];
|
|
310
|
+
const meshes: Kn5Mesh[] = [];
|
|
311
|
+
const worlds: Float32Array[] = [];
|
|
312
|
+
|
|
313
|
+
const readNode = (parent: number): void => {
|
|
314
|
+
const startedAt = cursor.at;
|
|
315
|
+
const type = cursor.i32('a node type');
|
|
316
|
+
const name = cursor.str('a node name');
|
|
317
|
+
const childCount = cursor.i32(`the child count of "${name}"`);
|
|
318
|
+
cursor.u8(`the active flag of "${name}"`);
|
|
319
|
+
|
|
320
|
+
let matrix = IDENTITY;
|
|
321
|
+
let mesh = -1;
|
|
322
|
+
let flags = { castShadows: false, isVisible: true, isTransparent: false };
|
|
323
|
+
|
|
324
|
+
if (type === NODE_DUMMY) {
|
|
325
|
+
const local = new Float32Array(16);
|
|
326
|
+
for (let i = 0; i < 16; i++) local[i] = cursor.f32(`the transform of "${name}"`);
|
|
327
|
+
matrix = local;
|
|
328
|
+
} else if (type === NODE_MESH || type === NODE_ANIMATED) {
|
|
329
|
+
flags = {
|
|
330
|
+
castShadows: cursor.u8(`the shadow flag of "${name}"`) !== 0,
|
|
331
|
+
isVisible: cursor.u8(`the visibility flag of "${name}"`) !== 0,
|
|
332
|
+
isTransparent: cursor.u8(`the transparency flag of "${name}"`) !== 0,
|
|
333
|
+
};
|
|
334
|
+
if (type === NODE_ANIMATED) {
|
|
335
|
+
/*
|
|
336
|
+
* Bones are read past by name and count, not interpreted. No file measured contains one,
|
|
337
|
+
* so a skin built from these would be a claim nothing here can support — the geometry is
|
|
338
|
+
* imported and the rig is left for a file that can prove it.
|
|
339
|
+
*/
|
|
340
|
+
const boneCount = cursor.i32(`the bone count of "${name}"`);
|
|
341
|
+
for (let b = 0; b < boneCount; b++) {
|
|
342
|
+
const bone = cursor.str(`a bone name of "${name}"`);
|
|
343
|
+
cursor.skip(64, `the bind matrix of bone "${bone}"`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
mesh = meshes.length;
|
|
347
|
+
} else {
|
|
348
|
+
throw new DrftError(
|
|
349
|
+
`kn5: node type ${type} is not one this reader understands, at "${name}", ` +
|
|
350
|
+
`offset ${startedAt}. Known types are 1 (dummy), 2 (mesh) and 3 (animated mesh).`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const index = nodes.length;
|
|
355
|
+
const world = parent < 0 ? matrix : multiply(matrix, worlds[parent] as Float32Array);
|
|
356
|
+
worlds.push(world);
|
|
357
|
+
nodes.push({ type, name, parent, matrix, mesh, ...flags, lodIn: 0, lodOut: 0 });
|
|
358
|
+
|
|
359
|
+
if (type === NODE_MESH || type === NODE_ANIMATED) {
|
|
360
|
+
const stride = type === NODE_MESH ? MESH_VERTEX_BYTES : ANIMATED_VERTEX_BYTES;
|
|
361
|
+
const vertexCount = cursor.i32(`the vertex count of "${name}"`);
|
|
362
|
+
cursor.need(vertexCount * stride, `the vertices of "${name}"`);
|
|
363
|
+
const positions = new Float32Array(vertexCount * 3);
|
|
364
|
+
const normals = new Float32Array(vertexCount * 3);
|
|
365
|
+
const uvs = new Float32Array(vertexCount * 2);
|
|
366
|
+
const tangents = new Float32Array(vertexCount * 4);
|
|
367
|
+
for (let v = 0; v < vertexCount; v++) {
|
|
368
|
+
const [px, py, pz] = transformPoint(world, cursor.f32(), cursor.f32(), cursor.f32());
|
|
369
|
+
positions[v * 3] = px;
|
|
370
|
+
positions[v * 3 + 1] = py;
|
|
371
|
+
positions[v * 3 + 2] = pz;
|
|
372
|
+
const [nx, ny, nz] = transformDirection(world, cursor.f32(), cursor.f32(), cursor.f32());
|
|
373
|
+
normals[v * 3] = nx;
|
|
374
|
+
normals[v * 3 + 1] = ny;
|
|
375
|
+
normals[v * 3 + 2] = nz;
|
|
376
|
+
uvs[v * 2] = cursor.f32();
|
|
377
|
+
/* kn5 stores v with the opposite origin from every other format here. */
|
|
378
|
+
uvs[v * 2 + 1] = 1 - cursor.f32();
|
|
379
|
+
const [tx, ty, tz] = transformDirection(world, cursor.f32(), cursor.f32(), cursor.f32());
|
|
380
|
+
tangents[v * 4] = tx;
|
|
381
|
+
tangents[v * 4 + 1] = ty;
|
|
382
|
+
tangents[v * 4 + 2] = tz;
|
|
383
|
+
/*
|
|
384
|
+
* The file states no bitangent sign. +1 is the convention every exporter in this pipeline
|
|
385
|
+
* writes, and `orient.ts` flips it where a mirror makes it wrong.
|
|
386
|
+
*/
|
|
387
|
+
tangents[v * 4 + 3] = 1;
|
|
388
|
+
if (type === NODE_ANIMATED) cursor.skip(32, `the skin weights of "${name}"`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const indexCount = cursor.i32(`the index count of "${name}"`);
|
|
392
|
+
const indices = new Uint32Array(indexCount);
|
|
393
|
+
for (let i = 0; i < indexCount; i++) indices[i] = cursor.u16(`an index of "${name}"`);
|
|
394
|
+
const material = cursor.i32(`the material of "${name}"`);
|
|
395
|
+
|
|
396
|
+
let lodIn = 0;
|
|
397
|
+
let lodOut = 0;
|
|
398
|
+
if (type === NODE_MESH) {
|
|
399
|
+
cursor.need(MESH_TRAILER, `the trailer of "${name}"`);
|
|
400
|
+
cursor.u32();
|
|
401
|
+
lodIn = cursor.f32();
|
|
402
|
+
lodOut = cursor.f32();
|
|
403
|
+
cursor.skip(16, `the bounding sphere of "${name}"`);
|
|
404
|
+
cursor.u8();
|
|
405
|
+
} else {
|
|
406
|
+
cursor.need(ANIMATED_TRAILER, `the trailer of "${name}"`);
|
|
407
|
+
cursor.u32();
|
|
408
|
+
lodIn = cursor.f32();
|
|
409
|
+
lodOut = cursor.f32();
|
|
410
|
+
}
|
|
411
|
+
nodes[index] = { ...(nodes[index] as Kn5Node), lodIn, lodOut };
|
|
412
|
+
meshes.push({ positions, normals, uvs, tangents, indices, material, node: index });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
for (let c = 0; c < childCount; c++) readNode(index);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
readNode(-1);
|
|
419
|
+
return { nodes, meshes };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Shaders this reader maps deliberately. Anything else warns once, by name.
|
|
424
|
+
*
|
|
425
|
+
* **A list of names, and it decides nothing.** It used to carry a second list beside it — the
|
|
426
|
+
* shaders whose surfaces blend — and that list was wrong about a shipped car: all four of its
|
|
427
|
+
* tyres are `ksTyres`, which the list called transparent, while the file states `alphaBlendMode`
|
|
428
|
+
* 0 on the material and no transparency on the node. The file is right, it says so in two places,
|
|
429
|
+
* and a name is a guess where a field is a fact. So blending is read from the material now and
|
|
430
|
+
* this stays only for the warning, which is the thing a name genuinely can answer: a shader
|
|
431
|
+
* nobody here has seen is still geometry somebody wants.
|
|
432
|
+
*/
|
|
433
|
+
const KNOWN_SHADERS = new Set([
|
|
434
|
+
'ksPerPixelAlpha',
|
|
435
|
+
'ksWindscreen',
|
|
436
|
+
'ksTyres',
|
|
437
|
+
'ksTree',
|
|
438
|
+
'ksGrass',
|
|
439
|
+
'ksFlags',
|
|
440
|
+
'ksSkidMark',
|
|
441
|
+
'ksPerPixel',
|
|
442
|
+
'ksPerPixelNM',
|
|
443
|
+
'ksPerPixelNM_UVMult',
|
|
444
|
+
'ksPerPixelReflection',
|
|
445
|
+
'ksPerPixelMultiMap',
|
|
446
|
+
'ksPerPixelMultiMap_NMDetail',
|
|
447
|
+
'ksPerPixelSimpleRefl',
|
|
448
|
+
'ksMultilayer',
|
|
449
|
+
'GL',
|
|
450
|
+
]);
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The opacity a blended surface takes, since `.kn5` states none of its own.
|
|
454
|
+
*
|
|
455
|
+
* `MATL` says a surface blends by carrying an opacity below 1, and that is the only channel the
|
|
456
|
+
* container has for the fact. A `.kn5` material states *whether* it blends and never by how much:
|
|
457
|
+
* the alpha is per texel, in the diffuse map, and the shader multiplies `uOpacity * coverage` —
|
|
458
|
+
* so a value just under 1 puts the surface in the blended pass and leaves the picture to the
|
|
459
|
+
* texture, which is what the file means. It is deliberately not 0.999 arrived at from anything in
|
|
460
|
+
* the file: the number is a flag, and this constant is where that is admitted.
|
|
461
|
+
*/
|
|
462
|
+
const BLENDED_OPACITY = 0.999;
|
|
463
|
+
|
|
464
|
+
/** A Blinn-Phong exponent as a roughness, which is the standard inverse. */
|
|
465
|
+
function roughnessFromExponent(exponent: number): number {
|
|
466
|
+
if (!(exponent > 0)) return 1;
|
|
467
|
+
return Math.min(1, Math.max(0, Math.sqrt(2 / (exponent + 2))));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** What a `.kn5` yields, in the shape every other reader in this directory returns. */
|
|
471
|
+
export interface Kn5Import {
|
|
472
|
+
meshes: MeshData[];
|
|
473
|
+
materials: DrftMaterial[];
|
|
474
|
+
textures: AssetReference[];
|
|
475
|
+
nodes: Kn5Node[];
|
|
476
|
+
warnings: string[];
|
|
477
|
+
declaredUp: UpAxis;
|
|
478
|
+
declaredHand: Handedness;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Read a `.kn5` into meshes, materials and the hierarchy that placed them.
|
|
483
|
+
*
|
|
484
|
+
* Materials are duplicated per mesh because `MATL` is parallel to `MESH` by ordinal while `.kn5`
|
|
485
|
+
* addresses a shared palette — 70 entries against 172 meshes in the car this was built against.
|
|
486
|
+
* The palette entry keeps its name, so nothing is lost by the duplication.
|
|
487
|
+
*/
|
|
488
|
+
export function kn5ToMeshes(buffer: ArrayBuffer): Kn5Import {
|
|
489
|
+
const head = readKn5Header(buffer);
|
|
490
|
+
const { nodes, meshes: sources } = readKn5Nodes(head);
|
|
491
|
+
|
|
492
|
+
const warnings: string[] = [];
|
|
493
|
+
const textures: AssetReference[] = head.textures.map((texture) => ({
|
|
494
|
+
name: texture.name,
|
|
495
|
+
bytes: texture.bytes,
|
|
496
|
+
}));
|
|
497
|
+
const textureIndex = new Map(head.textures.map((texture, at) => [texture.name, at]));
|
|
498
|
+
|
|
499
|
+
/** A sampler's texture as an ordinal, or -1, warning once for a name the file did not carry. */
|
|
500
|
+
const bind = (material: Kn5Material, sampler: string): number => {
|
|
501
|
+
const named = material.textures.get(sampler);
|
|
502
|
+
if (named === undefined) return -1;
|
|
503
|
+
const at = textureIndex.get(named);
|
|
504
|
+
if (at === undefined) {
|
|
505
|
+
warnings.push(
|
|
506
|
+
`kn5: material "${material.name}" binds ${sampler} to "${named}", which the file does ` +
|
|
507
|
+
'not carry. That surface loads without the map.',
|
|
508
|
+
);
|
|
509
|
+
return -1;
|
|
510
|
+
}
|
|
511
|
+
return at;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const warned = new Set<string>();
|
|
515
|
+
const meshes: MeshData[] = [];
|
|
516
|
+
const materials: DrftMaterial[] = [];
|
|
517
|
+
|
|
518
|
+
for (const source of sources) {
|
|
519
|
+
const node = nodes[source.node] as Kn5Node;
|
|
520
|
+
const material = head.materials[source.material];
|
|
521
|
+
if (material === undefined) {
|
|
522
|
+
throw new DrftError(
|
|
523
|
+
`kn5: "${node.name}" names material ${source.material}, and the file carries ` +
|
|
524
|
+
`${head.materials.length}.`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!KNOWN_SHADERS.has(material.shader) && !warned.has(material.shader)) {
|
|
529
|
+
warned.add(material.shader);
|
|
530
|
+
warnings.push(
|
|
531
|
+
`kn5: shader "${material.shader}" is not one this reader maps, so surfaces using it take ` +
|
|
532
|
+
'a default. Their geometry and textures are unaffected.',
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
if (material.textures.has('txDetail') && !warned.has('txDetail')) {
|
|
536
|
+
warned.add('txDetail');
|
|
537
|
+
warnings.push(
|
|
538
|
+
'kn5: this model uses detail maps, a second UV layer the container carries one of. ' +
|
|
539
|
+
'Those surfaces lose their close-range texture.',
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
/*
|
|
543
|
+
* Said once, because a texture that is read past in silence is the failure this reader keeps
|
|
544
|
+
* making. `txMaps` is carried by nearly every AC material and none of it reaches the container.
|
|
545
|
+
*/
|
|
546
|
+
if (material.textures.has('txMaps') && !warned.has('txMaps')) {
|
|
547
|
+
warned.add('txMaps');
|
|
548
|
+
warnings.push(
|
|
549
|
+
"kn5: this model's materials carry `txMaps`, which is not read: its channels are specular " +
|
|
550
|
+
'intensity, reflection sharpness and reflection intensity, and the container has no ' +
|
|
551
|
+
'packing for those. Each surface keeps the roughness its own `ksSpecularEXP` states.',
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const property = (name: string, fallback: number): number =>
|
|
556
|
+
material.props.get(name) ?? fallback;
|
|
557
|
+
/*
|
|
558
|
+
* Both statements are the file's own, so both are honoured: the material's blend mode is the
|
|
559
|
+
* surface's, the node's flag is this instance's, and they agree on 171 of 172 meshes in the
|
|
560
|
+
* car measured. Neither of them is `ksAlphaRef`, which is what this used to read.
|
|
561
|
+
*/
|
|
562
|
+
const blended = material.blendMode === KN5_ALPHA_BLEND || node.isTransparent;
|
|
563
|
+
const diffuse = property('ksDiffuse', 0.6);
|
|
564
|
+
|
|
565
|
+
meshes.push({
|
|
566
|
+
positions: source.positions,
|
|
567
|
+
normals: source.normals,
|
|
568
|
+
uvs: source.uvs,
|
|
569
|
+
tangents: source.tangents,
|
|
570
|
+
indices: source.indices,
|
|
571
|
+
colors: new Float32Array(source.positions.length).fill(1),
|
|
572
|
+
emissive: new Float32Array(source.positions.length / 3).fill(property('ksEmissive', 0)),
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
materials.push({
|
|
576
|
+
name: material.name,
|
|
577
|
+
color: [diffuse, diffuse, diffuse],
|
|
578
|
+
specular: property('ksSpecular', 0.9),
|
|
579
|
+
roughness: roughnessFromExponent(property('ksSpecularEXP', 1)),
|
|
580
|
+
emissive: property('ksEmissive', 0),
|
|
581
|
+
emissiveColor: [-1, -1, -1],
|
|
582
|
+
opacity: blended ? BLENDED_OPACITY : 1,
|
|
583
|
+
/*
|
|
584
|
+
* **`ksAlphaRef` is an alpha test and was being read as an opacity**, so a transparent
|
|
585
|
+
* material stating the 0 that means "test nothing" came out invisible: nine meshes of a
|
|
586
|
+
* hundred and two on the car this was reported from, including all four tyres and all four
|
|
587
|
+
* rims. It is a threshold below which a fragment is discarded, which is what `cutout` is,
|
|
588
|
+
* and it only applies where the material says it is tested.
|
|
589
|
+
*
|
|
590
|
+
* **No file measured here sets that flag**, so this path is carried on the format's word
|
|
591
|
+
* rather than on evidence — stated plainly for the same reason the bone block is read past
|
|
592
|
+
* rather than interpreted. What the change fixes is not this line but the one above it.
|
|
593
|
+
*/
|
|
594
|
+
cutout: material.alphaTested ? property('ksAlphaRef', 0) : 0,
|
|
595
|
+
albedo: bind(material, 'txDiffuse'),
|
|
596
|
+
normalMap: bind(material, 'txNormal'),
|
|
597
|
+
/*
|
|
598
|
+
* **`txMaps` is deliberately not bound here, and that is the correction of a real defect.**
|
|
599
|
+
*
|
|
600
|
+
* It was bound as `ormMap` for as long as this reader has existed, and the cost was measured
|
|
601
|
+
* from outside: every AC car's paint rendered at roughness 1.0 — the widest probe blur there
|
|
602
|
+
* is — while the file said 0.180. glTF's ORM is occlusion in R, roughness in G and metallic
|
|
603
|
+
* in B. Two independent references give AC's `txMaps` as specular intensity in R, reflection
|
|
604
|
+
* and specular *sharpness* in G, and reflection intensity in B. Sharpness is the inverse of
|
|
605
|
+
* roughness, so a G pinned at 255 — which is what three bundles' paint maps hold — is a file
|
|
606
|
+
* asking for the sharpest reflection available and was being read as the bluntest.
|
|
607
|
+
*
|
|
608
|
+
* **The half of that which needed no reference** is the line above: `roughness` is derived
|
|
609
|
+
* from `ksSpecularEXP` and is right, and the shader *replaces* it with the map's G rather
|
|
610
|
+
* than scaling it. So this reader computed the correct number and then discarded it for a
|
|
611
|
+
* channel whose meaning `occlusionStrength: 0` already said was not established — about R
|
|
612
|
+
* alone, while G and B were consumed as if the packing were known.
|
|
613
|
+
*
|
|
614
|
+
* Not binding it is not a claim about what `txMaps` means. It is declining to assert a
|
|
615
|
+
* packing that is not there, which is `recognise.ts`'s principle and this file's own.
|
|
616
|
+
*
|
|
617
|
+
* **What would reverse it:** a shader reference for `ksPerPixelMultiMap` settling the curve
|
|
618
|
+
* between sharpness and roughness, at which point the map's per-texel detail is worth
|
|
619
|
+
* carrying properly. `docs/IMPROVEMENTS.md` holds the measurements and both routes.
|
|
620
|
+
*/
|
|
621
|
+
ormMap: -1,
|
|
622
|
+
emissiveMap: -1,
|
|
623
|
+
roughnessScale: 1,
|
|
624
|
+
metallicScale: 1,
|
|
625
|
+
/* Nothing is bound above, so there is no occlusion to scale either. */
|
|
626
|
+
occlusionStrength: 0,
|
|
627
|
+
reflectivity: property('fresnelMaxLevel', property('fresnelC', 0)),
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/*
|
|
632
|
+
* **Right-handed, and this was measured rather than assumed.** The format is Assetto Corsa's and
|
|
633
|
+
* the simulator is a DirectX title, which is why this reader first declared the frame left-handed
|
|
634
|
+
* and mirrored every model it read. A consumer importing a second car found the mirror by eye —
|
|
635
|
+
* a badge reading backwards — and nothing numeric had caught it, because a mirrored car has the
|
|
636
|
+
* same bounds, the same triangle count and the same consistent winding as an unmirrored one.
|
|
637
|
+
*
|
|
638
|
+
* What settles it: the rear badge of a shipped car, rasterised out of the file with no conversion
|
|
639
|
+
* at all and viewed from behind, spells the model's name forwards, and spells it backwards
|
|
640
|
+
* through a negate-X. The rest of the file agrees. Up is `+y`; the front bumper, the headlights and the indicators
|
|
641
|
+
* are at `+z` and the tail lamps at `−z`; and the steering wheel, the driver's seat, the door
|
|
642
|
+
* the author named for the left side, the left mirror, and the driver's eye position that the
|
|
643
|
+
* simulator itself reads out of the car's own configuration are all at `+x`. In a right-handed
|
|
644
|
+
* frame with that up and that forward, `+x` is the car's left — so the file, read exactly as
|
|
645
|
+
* stored, is a left-hand-drive car.
|
|
646
|
+
*/
|
|
647
|
+
return { meshes, materials, textures, nodes, warnings, declaredUp: '+y', declaredHand: 'right' };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Decompose each node's row-vector 4x4 into the translation, rotation and scale `DrftNode` holds.
|
|
652
|
+
*
|
|
653
|
+
* The container stores a hierarchy as TRS and not as a matrix, which is what lets a consumer
|
|
654
|
+
* animate one channel without disturbing the other two — a wheel spins about its own axis while
|
|
655
|
+
* its suspension travel writes only the translation.
|
|
656
|
+
*/
|
|
657
|
+
export function toDrftNodes(nodes: readonly Kn5Node[]): DrftNode[] {
|
|
658
|
+
return nodes.map((node) => {
|
|
659
|
+
const m = node.matrix;
|
|
660
|
+
const axis = (row: number): [number, number, number] => [
|
|
661
|
+
m[row * 4] as number,
|
|
662
|
+
m[row * 4 + 1] as number,
|
|
663
|
+
m[row * 4 + 2] as number,
|
|
664
|
+
];
|
|
665
|
+
const length = (v: [number, number, number]): number => Math.hypot(v[0], v[1], v[2]);
|
|
666
|
+
const scale: [number, number, number] = [length(axis(0)), length(axis(1)), length(axis(2))];
|
|
667
|
+
/*
|
|
668
|
+
* The rotation is read out of the scale-normalised basis. A zero-length axis leaves that row
|
|
669
|
+
* as identity: a degenerate basis has no rotation to recover, and inventing one would be
|
|
670
|
+
* worse than the identity it collapses to.
|
|
671
|
+
*/
|
|
672
|
+
const basis = [0, 1, 2].map((row) => {
|
|
673
|
+
const v = axis(row);
|
|
674
|
+
const l = scale[row] as number;
|
|
675
|
+
if (l === 0) return [row === 0 ? 1 : 0, row === 1 ? 1 : 0, row === 2 ? 1 : 0] as const;
|
|
676
|
+
return [v[0] / l, v[1] / l, v[2] / l] as const;
|
|
677
|
+
});
|
|
678
|
+
const [x0, x1, x2] = basis[0] as readonly [number, number, number];
|
|
679
|
+
const [y0, y1, y2] = basis[1] as readonly [number, number, number];
|
|
680
|
+
const [z0, z1, z2] = basis[2] as readonly [number, number, number];
|
|
681
|
+
const trace = x0 + y1 + z2;
|
|
682
|
+
let rotation: [number, number, number, number];
|
|
683
|
+
if (trace > 0) {
|
|
684
|
+
const s = Math.sqrt(trace + 1) * 2;
|
|
685
|
+
rotation = [(y2 - z1) / s, (z0 - x2) / s, (x1 - y0) / s, s / 4];
|
|
686
|
+
} else if (x0 > y1 && x0 > z2) {
|
|
687
|
+
const s = Math.sqrt(1 + x0 - y1 - z2) * 2;
|
|
688
|
+
rotation = [s / 4, (y0 + x1) / s, (z0 + x2) / s, (y2 - z1) / s];
|
|
689
|
+
} else if (y1 > z2) {
|
|
690
|
+
const s = Math.sqrt(1 + y1 - x0 - z2) * 2;
|
|
691
|
+
rotation = [(y0 + x1) / s, s / 4, (z1 + y2) / s, (z0 - x2) / s];
|
|
692
|
+
} else {
|
|
693
|
+
const s = Math.sqrt(1 + z2 - x0 - y1) * 2;
|
|
694
|
+
rotation = [(z0 + x2) / s, (z1 + y2) / s, s / 4, (x1 - y0) / s];
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
parent: node.parent,
|
|
698
|
+
translation: [m[12] as number, m[13] as number, m[14] as number],
|
|
699
|
+
rotation,
|
|
700
|
+
scale,
|
|
701
|
+
mesh: node.mesh,
|
|
702
|
+
name: node.name,
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
}
|