@carbonenginejs/runtime-utils 0.1.0 → 0.1.2

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.
@@ -0,0 +1,286 @@
1
+ import { isPlainObject } from "../is.js";
2
+
3
+ const
4
+ ERROR_CODE_PATTERN = /^CJS_[A-Z][A-Z0-9]*(?:_[A-Z][A-Z0-9]*)*$/u,
5
+ DEFAULT_CANCELLATION_MESSAGE = "The operation was cancelled.";
6
+
7
+ export const CJS_OPERATION_CANCELLED = "CJS_OPERATION_CANCELLED";
8
+
9
+ /**
10
+ * Represents one structured operational failure with a stable CarbonEngineJS code.
11
+ *
12
+ * Programmer-contract violations should continue to use native error classes
13
+ * such as `TypeError`, `RangeError`, and `SyntaxError`.
14
+ */
15
+ export class CjsError extends Error
16
+ {
17
+
18
+ /**
19
+ * Creates an operational error.
20
+ *
21
+ * Details are detached and deeply frozen JSON-safe data. Cause identity is
22
+ * preserved through the native `Error` cause property when supplied.
23
+ *
24
+ * @param {string} code Stable uppercase `CJS_*` machine-readable code.
25
+ * @param {string} message Human-readable failure description.
26
+ * @param {{cause?: *, details?: object|null}} [options]
27
+ */
28
+ constructor(code, message, options = {})
29
+ {
30
+ const
31
+ normalizedCode = CjsError.#NormalizeCode(code),
32
+ normalizedMessage = CjsError.#NormalizeMessage(message),
33
+ normalizedOptions = CjsError.#NormalizeOptions(options),
34
+ errorOptions = Object.hasOwn(normalizedOptions, "cause")
35
+ ? { cause: normalizedOptions.cause }
36
+ : undefined;
37
+
38
+ super(normalizedMessage, errorOptions);
39
+
40
+ this.name = new.target.name;
41
+
42
+ Object.defineProperties(this, {
43
+ code: {
44
+ value: normalizedCode,
45
+ enumerable: true,
46
+ configurable: false,
47
+ writable: false
48
+ },
49
+ details: {
50
+ value: CjsError.#NormalizeDetails(normalizedOptions.details),
51
+ enumerable: true,
52
+ configurable: false,
53
+ writable: false
54
+ }
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Checks a structured or legacy error for an exact stable code.
60
+ *
61
+ * Invalid candidate codes and inaccessible error properties return false
62
+ * so this helper remains safe inside failure handling.
63
+ */
64
+ static hasCode(error, code)
65
+ {
66
+ if (!CjsError.#IsCode(code) || error === null || error === undefined)
67
+ {
68
+ return false;
69
+ }
70
+
71
+ try
72
+ {
73
+ return error.code === code;
74
+ }
75
+ catch
76
+ {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /** Reports whether a value is one valid stable error code. */
82
+ static #IsCode(value)
83
+ {
84
+ return typeof value === "string" && ERROR_CODE_PATTERN.test(value);
85
+ }
86
+
87
+ /** Validates and returns one stable error code. */
88
+ static #NormalizeCode(value)
89
+ {
90
+ if (!CjsError.#IsCode(value))
91
+ {
92
+ throw new TypeError("code must be an uppercase CJS_* identifier.");
93
+ }
94
+
95
+ return value;
96
+ }
97
+
98
+ /** Validates and returns one non-empty error message. */
99
+ static #NormalizeMessage(value)
100
+ {
101
+ if (typeof value !== "string" || value.trim() === "")
102
+ {
103
+ throw new TypeError("message must be a non-empty string.");
104
+ }
105
+
106
+ return value;
107
+ }
108
+
109
+ /** Validates and returns one plain constructor-options record. */
110
+ static #NormalizeOptions(value)
111
+ {
112
+ if (!isPlainObject(value))
113
+ {
114
+ throw new TypeError("options must be a plain object.");
115
+ }
116
+
117
+ return value;
118
+ }
119
+
120
+ /** Converts optional details into detached deeply frozen data. */
121
+ static #NormalizeDetails(value)
122
+ {
123
+ if (value === undefined || value === null)
124
+ {
125
+ return null;
126
+ }
127
+
128
+ if (!isPlainObject(value))
129
+ {
130
+ throw new TypeError("details must be a JSON-safe plain object or null.");
131
+ }
132
+
133
+ return CjsError.#CloneDetailsValue(value, "details", new Set());
134
+ }
135
+
136
+ /** Clones one JSON-safe details value while detecting active cycles. */
137
+ static #CloneDetailsValue(value, path, active)
138
+ {
139
+ if (value === null || typeof value === "string" || typeof value === "boolean")
140
+ {
141
+ return value;
142
+ }
143
+
144
+ if (typeof value === "number")
145
+ {
146
+ if (!Number.isFinite(value))
147
+ {
148
+ throw new TypeError(`${path} must contain only finite numbers.`);
149
+ }
150
+
151
+ return value;
152
+ }
153
+
154
+ if (!Array.isArray(value) && !isPlainObject(value))
155
+ {
156
+ throw new TypeError(`${path} must contain only JSON-safe values.`);
157
+ }
158
+
159
+ if (active.has(value))
160
+ {
161
+ throw new TypeError(`${path} must not contain a circular reference.`);
162
+ }
163
+
164
+ active.add(value);
165
+
166
+ const clone = Array.isArray(value)
167
+ ? CjsError.#CloneDetailsArray(value, path, active)
168
+ : CjsError.#CloneDetailsRecord(value, path, active);
169
+
170
+ active.delete(value);
171
+ return Object.freeze(clone);
172
+ }
173
+
174
+ /** Clones one dense JSON-safe details array. */
175
+ static #CloneDetailsArray(value, path, active)
176
+ {
177
+ const clone = [];
178
+
179
+ for (const key of Reflect.ownKeys(value))
180
+ {
181
+ if (key === "length")
182
+ {
183
+ continue;
184
+ }
185
+
186
+ if (typeof key !== "string"
187
+ || !/^(?:0|[1-9]\d*)$/u.test(key)
188
+ || Number(key) >= value.length)
189
+ {
190
+ throw new TypeError(`${path} arrays must contain only indexed values.`);
191
+ }
192
+ }
193
+
194
+ for (let i = 0; i < value.length; i++)
195
+ {
196
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
197
+
198
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value"))
199
+ {
200
+ throw new TypeError(`${path} arrays must contain dense data properties.`);
201
+ }
202
+
203
+ clone.push(CjsError.#CloneDetailsValue(
204
+ descriptor.value,
205
+ `${path}[${i}]`,
206
+ active
207
+ ));
208
+ }
209
+
210
+ return clone;
211
+ }
212
+
213
+ /** Clones one plain JSON-safe details record. */
214
+ static #CloneDetailsRecord(value, path, active)
215
+ {
216
+ const clone = {};
217
+
218
+ for (const key of Reflect.ownKeys(value))
219
+ {
220
+ if (typeof key !== "string")
221
+ {
222
+ throw new TypeError(`${path} must not contain symbol keys.`);
223
+ }
224
+
225
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
226
+
227
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value"))
228
+ {
229
+ throw new TypeError(`${path} must contain only enumerable data properties.`);
230
+ }
231
+
232
+ Object.defineProperty(clone, key, {
233
+ value: CjsError.#CloneDetailsValue(
234
+ descriptor.value,
235
+ `${path}[${JSON.stringify(key)}]`,
236
+ active
237
+ ),
238
+ enumerable: true,
239
+ configurable: false,
240
+ writable: false
241
+ });
242
+ }
243
+
244
+ return clone;
245
+ }
246
+
247
+ }
248
+
249
+ /** Represents one cancelled operation using Web-compatible abort identity. */
250
+ export class CjsCancellationError extends CjsError
251
+ {
252
+
253
+ /**
254
+ * Creates a cancellation error with stable code `CJS_OPERATION_CANCELLED`.
255
+ *
256
+ * @param {string} [message]
257
+ * @param {{cause?: *, details?: object|null}} [options]
258
+ */
259
+ constructor(message = DEFAULT_CANCELLATION_MESSAGE, options = {})
260
+ {
261
+ super(CJS_OPERATION_CANCELLED, message, options);
262
+ this.name = "AbortError";
263
+ }
264
+
265
+ /**
266
+ * Checks for this cancellation type, its stable code, or a platform
267
+ * `AbortError` name.
268
+ */
269
+ static is(error)
270
+ {
271
+ if (CjsError.hasCode(error, CJS_OPERATION_CANCELLED))
272
+ {
273
+ return true;
274
+ }
275
+
276
+ try
277
+ {
278
+ return error?.name === "AbortError";
279
+ }
280
+ catch
281
+ {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ CJS_OPERATION_CANCELLED,
3
+ CjsCancellationError,
4
+ CjsError
5
+ } from "./CjsError.js";
package/src/index.js CHANGED
@@ -11,6 +11,8 @@ export * as json from "./json.js";
11
11
  export * from "./json.js";
12
12
  export * as lookup from "./lookup.js";
13
13
  export * from "./lookup.js";
14
+ export * as object from "./object.js";
15
+ export * from "./object.js";
14
16
  export * as path from "./path.js";
15
17
  export * from "./path.js";
16
18
  export * as text from "./text.js";
@@ -21,6 +23,8 @@ export {
21
23
  assertNonEmptyString,
22
24
  assertSupportedVersion
23
25
  } from "./validation.js";
26
+ export * as errors from "./errors/index.js";
27
+ export * from "./errors/index.js";
24
28
 
25
29
  export * as constants from "./constants/index.js";
26
30
  export * from "./constants/index.js";
package/src/is.js CHANGED
@@ -143,15 +143,77 @@ export function isError(a)
143
143
  * @param {*} a
144
144
  * @returns {Boolean}
145
145
  */
146
- export function isNumber(a)
147
- {
148
- return isTag(a, "[object Number]");
149
- }
150
-
151
- /**
152
- * Checks if a value is a function
153
- * @param {*} a
154
- * @returns {Boolean}
146
+ export function isNumber(a)
147
+ {
148
+ return isTag(a, "[object Number]");
149
+ }
150
+
151
+ /**
152
+ * Checks if a value fits a signed 8-bit integer.
153
+ * @param {*} value
154
+ * @returns {Boolean}
155
+ */
156
+ export function isInt8(value)
157
+ {
158
+ return Number.isInteger(value) && value >= -0x80 && value <= 0x7f;
159
+ }
160
+
161
+ /**
162
+ * Checks if a value fits an unsigned 8-bit integer.
163
+ * @param {*} value
164
+ * @returns {Boolean}
165
+ */
166
+ export function isUint8(value)
167
+ {
168
+ return Number.isInteger(value) && value >= 0 && value <= 0xff;
169
+ }
170
+
171
+ /**
172
+ * Checks if a value fits a signed 16-bit integer.
173
+ * @param {*} value
174
+ * @returns {Boolean}
175
+ */
176
+ export function isInt16(value)
177
+ {
178
+ return Number.isInteger(value) && value >= -0x8000 && value <= 0x7fff;
179
+ }
180
+
181
+ /**
182
+ * Checks if a value fits an unsigned 16-bit integer.
183
+ * @param {*} value
184
+ * @returns {Boolean}
185
+ */
186
+ export function isUint16(value)
187
+ {
188
+ return Number.isInteger(value) && value >= 0 && value <= 0xffff;
189
+ }
190
+
191
+ /**
192
+ * Checks if a value fits a signed 32-bit integer.
193
+ * @param {*} value
194
+ * @returns {Boolean}
195
+ */
196
+ export function isInt32(value)
197
+ {
198
+ return Number.isInteger(value)
199
+ && value >= -0x80000000
200
+ && value <= 0x7fffffff;
201
+ }
202
+
203
+ /**
204
+ * Checks if a value fits an unsigned 32-bit integer.
205
+ * @param {*} value
206
+ * @returns {Boolean}
207
+ */
208
+ export function isUint32(value)
209
+ {
210
+ return Number.isInteger(value) && value >= 0 && value <= 0xffffffff;
211
+ }
212
+
213
+ /**
214
+ * Checks if a value is a function
215
+ * @param {*} a
216
+ * @returns {Boolean}
155
217
  */
156
218
  export function isFunction(a)
157
219
  {
package/src/mesh.js CHANGED
@@ -45,13 +45,42 @@ function validateIndices(indices, vertexCount)
45
45
  */
46
46
  export function triangleNormal(a, b, c)
47
47
  {
48
- const normal = [ 0, 0, 0 ];
49
- cross(
50
- normal,
51
- [ b[0] - a[0], b[1] - a[1], b[2] - a[2] ],
52
- [ c[0] - a[0], c[1] - a[1], c[2] - a[2] ]
53
- );
54
- return normalize(normal, normal);
48
+ return triangleNormalTo([ 0, 0, 0 ], a, b, c);
49
+ }
50
+
51
+ /**
52
+ * Calculate a unit face normal into caller-owned storage.
53
+ *
54
+ * @param {ArrayLike<number>} out Receiving xyz vector.
55
+ * @param {ArrayLike<number>} a First xyz vertex.
56
+ * @param {ArrayLike<number>} b Second xyz vertex.
57
+ * @param {ArrayLike<number>} c Third xyz vertex.
58
+ * @returns {ArrayLike<number>} The receiving vector.
59
+ */
60
+ export function triangleNormalTo(out, a, b, c)
61
+ {
62
+ const
63
+ abX = b[0] - a[0],
64
+ abY = b[1] - a[1],
65
+ abZ = b[2] - a[2],
66
+ acX = c[0] - a[0],
67
+ acY = c[1] - a[1],
68
+ acZ = c[2] - a[2];
69
+
70
+ out[0] = abY * acZ - abZ * acY;
71
+ out[1] = abZ * acX - abX * acZ;
72
+ out[2] = abX * acY - abY * acX;
73
+
74
+ const length = Math.hypot(out[0], out[1], out[2]);
75
+
76
+ if (length > 0)
77
+ {
78
+ out[0] /= length;
79
+ out[1] /= length;
80
+ out[2] /= length;
81
+ }
82
+
83
+ return out;
55
84
  }
56
85
 
57
86
  /**
@@ -383,6 +412,7 @@ export function generateBiNormals(normals, tangents, options = {})
383
412
 
384
413
  export const mesh = Object.freeze({
385
414
  triangleNormal,
415
+ triangleNormalTo,
386
416
  triangleArea2,
387
417
  isDegenerateTriangle,
388
418
  computeBoundsFromPositions,
@@ -13,6 +13,15 @@ const MAX_UPDATE_PASSES = 32;
13
13
  */
14
14
  export class CjsModel extends CjsEventEmitter
15
15
  {
16
+ /**
17
+ * Identifies this class as a schema-backed model.
18
+ *
19
+ * Declared statically so CjsSchema can recognise a model class from a field
20
+ * declaration alone, without importing CjsModel - which it cannot do, since
21
+ * this module already imports CjsSchema. Mirrors `CjsResource.isResource`.
22
+ */
23
+ static isModel = true;
24
+
16
25
  /**
17
26
  * Creates a schema-backed model with initialized runtime state.
18
27
  */
@@ -208,16 +217,18 @@ export class CjsModel extends CjsEventEmitter
208
217
 
209
218
  if (descend)
210
219
  {
211
- const fields = getModelFields(model);
212
- const start = reverse ? fields.length - 1 : 0;
213
- const end = reverse ? -1 : fields.length;
220
+ // Only the fields declared to hold child models, precomputed per
221
+ // class - not every field, type-tested per value per visit.
222
+ const children = CjsSchema.getSchema(model.constructor).children;
223
+ const start = reverse ? children.length - 1 : 0;
224
+ const end = reverse ? -1 : children.length;
214
225
  const step = reverse ? -1 : 1;
215
226
 
216
227
  for (let i = start; i !== end; i += step)
217
228
  {
218
- const field = fields[i];
219
- if (options.ownedOnly === true && field.io?.ownership !== "owned") continue;
220
- const value = model[field.name];
229
+ const child = children[i];
230
+ if (options.ownedOnly === true && !child.owned) continue;
231
+ const value = model[child.name];
221
232
 
222
233
  if (Array.isArray(value))
223
234
  {
@@ -242,19 +253,47 @@ export class CjsModel extends CjsEventEmitter
242
253
  /**
243
254
  * Collects unique resources reported by this model graph into an array.
244
255
  *
256
+ * Every model in the graph is visited: reporting resources does not hide a
257
+ * model's descendants, because an under-reported dependency set would let
258
+ * readiness checks pass while a child's resources were still loading.
259
+ *
260
+ * Resources held in schema fields are collected automatically - they are
261
+ * already declared, as `@type.objectRef("TriGeometryRes")` and friends, so
262
+ * restating them in a hook would be the hand-written relay chain this
263
+ * traversal exists to replace.
264
+ *
265
+ * `OnGetResources()` is the escape hatch for resources a model holds
266
+ * outside its schema, such as private fields. It takes no arguments and
267
+ * always returns an iterable of resources - never a bare resource and never
268
+ * nothing. Most models do not implement it.
269
+ *
245
270
  * @param {Array<*>} [out=[]] Output array, whose contents are replaced.
246
271
  * @returns {Array<*>} The supplied output array.
247
272
  */
248
273
  GetResources(out = [])
249
274
  {
250
275
  const resources = new Set();
251
- AddResources(resources, out);
252
276
 
253
277
  this.Traverse(model =>
254
278
  {
255
- if (typeof model.OnGetResources !== "function") return true;
256
- AddResources(resources, model.OnGetResources(resources));
257
- return false;
279
+ for (const field of CjsSchema.getSchema(model.constructor).resources)
280
+ {
281
+ const value = model[field.name];
282
+ if (Array.isArray(value))
283
+ {
284
+ for (const item of value) AddResource(resources, item);
285
+ }
286
+ else
287
+ {
288
+ AddResource(resources, value);
289
+ }
290
+ }
291
+
292
+ if (typeof model.OnGetResources === "function")
293
+ {
294
+ AddResources(resources, model.OnGetResources());
295
+ }
296
+ return true;
258
297
  });
259
298
 
260
299
  out.length = 0;
@@ -776,17 +815,23 @@ function initializeOwnedGraph(root, options = {})
776
815
  return root;
777
816
  }
778
817
 
818
+ function AddResource(target, value)
819
+ {
820
+ if (value?.isResource === true) target.add(value);
821
+ }
822
+
823
+
779
824
  function AddResources(target, values)
780
825
  {
781
- if (values === null || values === undefined) return;
782
- if (values?.isResource === true)
826
+ if (typeof values === "string" || typeof values?.[Symbol.iterator] !== "function")
783
827
  {
784
- target.add(values);
785
- return;
828
+ throw new TypeError("CjsModel.OnGetResources must return an iterable of resources.");
786
829
  }
787
- if (typeof values !== "string" && typeof values[Symbol.iterator] === "function")
830
+
831
+ // Empty slots are the model's own unset fields, not a contract violation.
832
+ for (const value of values)
788
833
  {
789
- for (const value of values) AddResources(target, value);
834
+ if (value !== null && value !== undefined) target.add(value);
790
835
  }
791
836
  }
792
837
 
package/src/object.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Invokes handlers for keys explicitly owned by a source object.
3
+ *
4
+ * @param {object|Function} source Source values.
5
+ * @param {object} handlers Property-keyed handler functions.
6
+ * @param {*} [context=null] Optional `this` value for handlers.
7
+ * @returns {number} Number of invoked handlers.
8
+ */
9
+ export function hasOwnThen(source, handlers, context = null)
10
+ {
11
+ if (!source || (typeof source !== "object" && typeof source !== "function"))
12
+ {
13
+ throw new TypeError("hasOwnThen source must be an object or function.");
14
+ }
15
+
16
+ if (!handlers || typeof handlers !== "object" || Array.isArray(handlers))
17
+ {
18
+ throw new TypeError("hasOwnThen handlers must be an object.");
19
+ }
20
+
21
+ let invoked = 0;
22
+
23
+ for (const property of Reflect.ownKeys(handlers))
24
+ {
25
+ if (!Object.hasOwn(source, property)) continue;
26
+
27
+ const handler = handlers[property];
28
+
29
+ if (typeof handler !== "function")
30
+ {
31
+ throw new TypeError(`hasOwnThen handler must be a function: ${String(property)}.`);
32
+ }
33
+
34
+ handler.call(context, source[property], source, property);
35
+ invoked++;
36
+ }
37
+
38
+ return invoked;
39
+ }
package/src/path.js CHANGED
@@ -19,3 +19,35 @@ export function normalizePath(value, options = {})
19
19
 
20
20
  return options.lowerCase ? result.toLowerCase() : result;
21
21
  }
22
+
23
+ /** Normalizes a case-insensitive URI-style resource path. */
24
+ export function normalizeResourcePath(value)
25
+ {
26
+ return normalizePath(value, { lowerCase: true });
27
+ }
28
+
29
+ /** Returns the normalized extension of a URI-style resource path. */
30
+ export function getResourceExtension(value)
31
+ {
32
+ const path = normalizeResourcePath(value);
33
+ const queryIndex = path.search(/[?#]/u);
34
+ const cleanPath = queryIndex === -1 ? path : path.slice(0, queryIndex);
35
+ const slashIndex = cleanPath.lastIndexOf("/");
36
+ const dotIndex = cleanPath.lastIndexOf(".");
37
+
38
+ if (dotIndex === -1 || dotIndex < slashIndex)
39
+ {
40
+ return "";
41
+ }
42
+
43
+ return cleanPath.slice(dotIndex + 1);
44
+ }
45
+
46
+ /** Normalizes a resource extension without its optional leading dot. */
47
+ export function normalizeResourceExtension(value)
48
+ {
49
+ return String(value ?? "")
50
+ .trim()
51
+ .replace(/^\./u, "")
52
+ .toLowerCase();
53
+ }