@playcraft/cli 0.1.63 → 0.1.65

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 (41) hide show
  1. package/README.md +24 -31
  2. package/SKILLS_REGISTRY.md +1 -1
  3. package/dist/commands/3d.js +41 -33
  4. package/dist/commands/build.js +70 -5
  5. package/dist/commands/create.js +4 -4
  6. package/dist/commands/inspect.js +4 -6
  7. package/dist/index.js +27 -19
  8. package/dist/model-adaptation/node-inspection.js +173 -0
  9. package/dist/model-adaptation/project-template.js +194 -0
  10. package/dist/model-adaptation/project.js +76 -0
  11. package/dist/model-adaptation/source.js +49 -0
  12. package/dist/model-optimization/optimize.js +281 -0
  13. package/dist/model-preparation/bindings.js +35 -0
  14. package/dist/model-preparation/commands.js +28 -0
  15. package/dist/model-preparation/geometry.js +241 -0
  16. package/dist/model-preparation/glb.js +176 -0
  17. package/dist/model-preparation/pipeline-engine.js +157 -0
  18. package/dist/model-preparation/pipeline-files.js +108 -0
  19. package/dist/model-preparation/pipeline-worker.js +32 -0
  20. package/dist/model-preparation/split-rigid.js +302 -0
  21. package/dist/model-preparation/vertex-repairs.js +63 -0
  22. package/dist/workspace-runtime/batch/deliver.js +2 -2
  23. package/dist/workspace-runtime/codex/app-server.js +11 -1
  24. package/dist/workspace-runtime/codex/delivery-tools.js +38 -5
  25. package/dist/workspace-runtime/codex/model-broker.js +19 -1
  26. package/dist/workspace-runtime/previews/publish.js +2 -2
  27. package/dist/workspace-runtime/serve.js +5 -0
  28. package/dist/workspace-runtime/server/directory-access.js +1 -1
  29. package/dist/workspace-runtime/server/dispatch.js +27 -47
  30. package/dist/workspace-runtime/server/http.js +12 -0
  31. package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
  32. package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
  33. package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
  34. package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
  35. package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
  36. package/package.json +6 -4
  37. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  38. package/project-templates/empty/AGENTS.md +1 -1
  39. package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  40. package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  41. package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
@@ -0,0 +1,281 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, readFile, realpath, mkdir, open, link, unlink } from 'node:fs/promises';
3
+ import { basename, dirname, resolve, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { createRequire } from 'node:module';
7
+ import sharp from 'sharp';
8
+ import { NodeIO } from '@gltf-transform/core';
9
+ import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
10
+ import { dedup, prune } from '@gltf-transform/functions';
11
+ import { readModelSourceDocument } from '@playcraft/common/cloud-workspace/runtime';
12
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
13
+ const canonical = (value) => JSON.stringify(value && typeof value === 'object'
14
+ ? Array.isArray(value) ? value.map(item => JSON.parse(canonical(item)))
15
+ : Object.fromEntries(Object.keys(value).sort().map(key => [key, JSON.parse(canonical(value[key]))])) : value);
16
+ const padded = (n) => Math.ceil(n / 4) * 4;
17
+ function parse(bytes) {
18
+ if (bytes.length < 20 || Buffer.from(bytes).readUInt32LE(0) !== 0x46546c67)
19
+ throw Error('optimization_requires_self_contained_glb');
20
+ const { document, binary, extraChunks } = readModelSourceDocument(bytes);
21
+ const doc = document;
22
+ if (!binary || extraChunks?.length || doc.asset?.version !== '2.0' || doc.buffers?.length !== 1 || doc.buffers[0].uri ||
23
+ doc.buffers[0].byteLength > binary.length)
24
+ throw Error('optimization_requires_self_contained_glb');
25
+ const supported = new Set(ALL_EXTENSIONS.map(extension => extension.EXTENSION_NAME));
26
+ for (const name of new Set([...doc.extensionsUsed ?? [], ...doc.extensionsRequired ?? []])) {
27
+ if (!supported.has(name) || ['KHR_draco_mesh_compression', 'EXT_meshopt_compression', 'KHR_texture_basisu'].includes(name))
28
+ throw Error(`optimization_unsupported_extension:${name}`);
29
+ }
30
+ for (const view of doc.bufferViews ?? [])
31
+ if (view.buffer !== 0 || !Number.isSafeInteger(view.byteLength) || view.byteLength < 0 ||
32
+ !Number.isSafeInteger(view.byteOffset ?? 0) || (view.byteOffset ?? 0) < 0 || (view.byteOffset ?? 0) + view.byteLength > doc.buffers[0].byteLength)
33
+ throw Error('optimization_invalid_buffer_view');
34
+ return { doc, binary: Buffer.from(binary) };
35
+ }
36
+ function metrics(bytes) {
37
+ const { doc } = parse(bytes);
38
+ let triangles = 0, vertices = 0;
39
+ for (const mesh of doc.meshes ?? [])
40
+ for (const primitive of mesh.primitives ?? []) {
41
+ const count = doc.accessors?.[primitive.indices ?? primitive.attributes?.POSITION]?.count ?? 0;
42
+ vertices += doc.accessors?.[primitive.attributes?.POSITION]?.count ?? 0;
43
+ triangles += (primitive.mode ?? 4) === 4 ? count / 3 : [5, 6].includes(primitive.mode) ? Math.max(0, count - 2) : 0;
44
+ }
45
+ return { modelBytes: bytes.length, meshes: doc.meshes?.length ?? 0, triangles, vertices, textures: doc.images?.length ?? 0,
46
+ textureBytes: (doc.images ?? []).reduce((n, image) => n + (doc.bufferViews?.[image.bufferView]?.byteLength ?? 0), 0),
47
+ extensionsUsed: [...doc.extensionsUsed ?? []].sort() };
48
+ }
49
+ function extensionValues(doc) {
50
+ const values = [];
51
+ const walk = (value) => {
52
+ if (!value || typeof value !== 'object')
53
+ return;
54
+ if (!Array.isArray(value) && value.extensions)
55
+ for (const [name, payload] of Object.entries(value.extensions))
56
+ values.push(canonical({ name, payload }));
57
+ for (const [key, child] of Object.entries(value))
58
+ if (key !== 'extensions')
59
+ walk(child);
60
+ };
61
+ walk(doc);
62
+ return values.sort();
63
+ }
64
+ function unchangedEvidence(bytes) {
65
+ const { doc, binary } = parse(bytes), images = new Set((doc.images ?? []).map((image) => image.bufferView));
66
+ const semantic = structuredClone(doc);
67
+ for (const buffer of semantic.buffers)
68
+ delete buffer.byteLength;
69
+ for (let i = 0; i < (semantic.bufferViews?.length ?? 0); i++) {
70
+ delete semantic.bufferViews[i].byteOffset;
71
+ if (images.has(i))
72
+ delete semantic.bufferViews[i].byteLength;
73
+ }
74
+ for (const image of semantic.images ?? [])
75
+ delete image.mimeType;
76
+ return { semanticHash: hash(canonical(semantic)), geometryViews: (doc.bufferViews ?? []).flatMap((view, index) => images.has(index) ? []
77
+ : [{ index, sha256: hash(binary.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength)) }]) };
78
+ }
79
+ function requireOpaqueColorUsage(doc, imageIndex) {
80
+ const textureIndices = new Set();
81
+ for (const [index, texture] of (doc.textures ?? []).entries())
82
+ if (texture.source === imageIndex)
83
+ textureIndices.add(index);
84
+ for (const material of doc.materials ?? []) {
85
+ const walk = (value, path) => {
86
+ if (!value || typeof value !== 'object')
87
+ return;
88
+ if (typeof value.index === 'number' && textureIndices.has(value.index) && /Texture$/.test(path)) {
89
+ if ((material.alphaMode ?? 'OPAQUE') !== 'OPAQUE' || !['pbrMetallicRoughness.baseColorTexture', 'emissiveTexture'].includes(path))
90
+ throw Error('optimization_jpeg_requires_opaque_color_texture');
91
+ }
92
+ for (const [key, child] of Object.entries(value))
93
+ walk(child, path ? `${path}.${key}` : key);
94
+ };
95
+ walk(material, '');
96
+ }
97
+ }
98
+ async function rewriteTextures(input, options) {
99
+ const { doc, binary } = parse(input), replacements = new Map();
100
+ const imageViews = new Set();
101
+ for (const [index, image] of (doc.images ?? []).entries()) {
102
+ if (!Number.isInteger(image.bufferView) || image.uri || !['image/png', 'image/jpeg'].includes(image.mimeType))
103
+ throw Error('optimization_embedded_png_or_jpeg_required');
104
+ const view = doc.bufferViews?.[image.bufferView];
105
+ if (!view)
106
+ throw Error('optimization_invalid_image_view');
107
+ if (view.target !== undefined || (doc.accessors ?? []).some((accessor) => accessor.bufferView === image.bufferView ||
108
+ accessor.sparse?.indices?.bufferView === image.bufferView || accessor.sparse?.values?.bufferView === image.bufferView))
109
+ throw Error('optimization_image_view_used_by_geometry');
110
+ const start = view.byteOffset ?? 0, end = start + padded(view.byteLength);
111
+ if (start % 4 || end > binary.length || [...binary.subarray(start + view.byteLength, end)].some(value => value !== 0))
112
+ throw Error('optimization_image_alignment_unsupported');
113
+ for (let i = 0; i < doc.bufferViews.length; i++) {
114
+ const other = doc.bufferViews[i], a = other.byteOffset ?? 0;
115
+ if (i !== image.bufferView && start < a + other.byteLength && a < end)
116
+ throw Error('optimization_shared_image_buffer_view');
117
+ }
118
+ if (imageViews.has(image.bufferView))
119
+ throw Error('optimization_shared_image_buffer_view');
120
+ imageViews.add(image.bufferView);
121
+ const original = binary.subarray(start, start + view.byteLength), decoder = sharp(original, { limitInputPixels: 64 * 1024 * 1024 });
122
+ const meta = await decoder.metadata();
123
+ if (!meta.width || !meta.height)
124
+ throw Error('optimization_texture_dimensions_missing');
125
+ const format = options.textureFormat ?? 'png';
126
+ if (format === 'jpeg') {
127
+ requireOpaqueColorUsage(doc, index);
128
+ if (meta.hasAlpha) {
129
+ const stats = await decoder.clone().stats();
130
+ if (stats.channels.at(-1)?.min !== 255)
131
+ throw Error('optimization_jpeg_would_discard_alpha');
132
+ }
133
+ }
134
+ if (meta.width <= options.textureSize && meta.height <= options.textureSize && options.textureFormat === undefined)
135
+ continue;
136
+ const encoder = decoder.clone().resize(options.textureSize, options.textureSize, { fit: 'inside', withoutEnlargement: true });
137
+ const encoded = format === 'jpeg' ? await encoder.removeAlpha().jpeg({ quality: options.quality }).toBuffer() : await encoder.png().toBuffer();
138
+ image.mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
139
+ replacements.set(image.bufferView, encoded);
140
+ }
141
+ const ordered = [...replacements].sort(([a], [b]) => (doc.bufferViews[a].byteOffset ?? 0) - (doc.bufferViews[b].byteOffset ?? 0));
142
+ const parts = [], shifts = [];
143
+ let cursor = 0;
144
+ for (const [index, encoded] of ordered) {
145
+ const view = doc.bufferViews[index], start = view.byteOffset ?? 0, oldEnd = start + padded(view.byteLength);
146
+ const prefix = binary.subarray(cursor, start);
147
+ parts.push(prefix, encoded, Buffer.alloc(padded(encoded.length) - encoded.length));
148
+ shifts.push({ end: oldEnd, delta: padded(encoded.length) - padded(view.byteLength) });
149
+ view.byteLength = encoded.length;
150
+ cursor = oldEnd;
151
+ }
152
+ parts.push(binary.subarray(cursor));
153
+ for (const view of doc.bufferViews ?? []) {
154
+ const offset = view.byteOffset ?? 0;
155
+ const delta = shifts.filter(shift => shift.end <= offset).reduce((n, shift) => n + shift.delta, 0);
156
+ if (offset + delta !== 0 || view.byteOffset !== undefined)
157
+ view.byteOffset = offset + delta;
158
+ }
159
+ const outputBinary = Buffer.concat(parts);
160
+ doc.buffers[0].byteLength = outputBinary.length;
161
+ const text = Buffer.from(JSON.stringify(doc)), json = Buffer.concat([text, Buffer.alloc(padded(text.length) - text.length, 0x20)]);
162
+ const result = Buffer.alloc(12 + 8 + json.length + 8 + outputBinary.length);
163
+ result.writeUInt32LE(0x46546c67, 0);
164
+ result.writeUInt32LE(2, 4);
165
+ result.writeUInt32LE(result.length, 8);
166
+ result.writeUInt32LE(json.length, 12);
167
+ result.writeUInt32LE(0x4e4f534a, 16);
168
+ json.copy(result, 20);
169
+ result.writeUInt32LE(outputBinary.length, 20 + json.length);
170
+ result.writeUInt32LE(0x004e4942, 24 + json.length);
171
+ outputBinary.copy(result, 28 + json.length);
172
+ if (canonical(unchangedEvidence(input)) !== canonical(unchangedEvidence(result)))
173
+ throw Error('optimization_non_texture_data_changed');
174
+ return result;
175
+ }
176
+ async function packageVersion(name) {
177
+ let path = dirname(name === '@playcraft/cli' ? fileURLToPath(import.meta.url) : createRequire(import.meta.url).resolve(name));
178
+ while (dirname(path) !== path) {
179
+ try {
180
+ const data = JSON.parse(await readFile(join(path, 'package.json'), 'utf8'));
181
+ if (data.name === name)
182
+ return data.version;
183
+ }
184
+ catch { /* Continue to the owning package. */ }
185
+ path = dirname(path);
186
+ }
187
+ throw Error('optimization_tool_version_missing');
188
+ }
189
+ async function publishPair(files) {
190
+ const temporary = [];
191
+ try {
192
+ for (const file of files) {
193
+ await mkdir(dirname(file.path), { recursive: true });
194
+ if (await realpath(dirname(file.path)) !== dirname(file.path))
195
+ throw Error('optimization_output_parent_not_canonical');
196
+ try {
197
+ await lstat(file.path);
198
+ throw Error('optimization_output_exists');
199
+ }
200
+ catch (error) {
201
+ if (error.code !== 'ENOENT')
202
+ throw error;
203
+ }
204
+ }
205
+ for (const file of files) {
206
+ const path = join(dirname(file.path), `.${basename(file.path)}.${randomUUID()}.pending`);
207
+ const handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
208
+ try {
209
+ const meta = await handle.stat();
210
+ temporary.push({ path, destination: file.path, ino: meta.ino, dev: meta.dev, linked: false });
211
+ await handle.writeFile(file.content);
212
+ await handle.sync();
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ }
218
+ for (const file of temporary) {
219
+ await link(file.path, file.destination);
220
+ file.linked = true;
221
+ }
222
+ }
223
+ catch (error) {
224
+ for (const file of temporary.filter(item => item.linked)) {
225
+ const meta = await lstat(file.destination).catch(() => undefined);
226
+ if (meta?.ino === file.ino && meta.dev === file.dev)
227
+ await unlink(file.destination);
228
+ }
229
+ throw error;
230
+ }
231
+ finally {
232
+ for (const file of temporary)
233
+ await unlink(file.path).catch(() => undefined);
234
+ }
235
+ }
236
+ export async function optimizeModel(options) {
237
+ const textureSize = options.textureSize ?? 512, quality = options.quality ?? 85;
238
+ if (!Number.isInteger(textureSize) || textureSize < 1 || textureSize > 16384 || !Number.isInteger(quality) || quality < 1 || quality > 100 ||
239
+ options.textureFormat !== undefined && !['jpeg', 'png'].includes(options.textureFormat) ||
240
+ options.maxOutputBytes !== undefined && (!Number.isSafeInteger(options.maxOutputBytes) || options.maxOutputBytes < 1))
241
+ throw Error('optimization_invalid_options');
242
+ const input = resolve(options.input), output = resolve(options.output), lineagePath = resolve(options.lineagePath ?? `${output}.optimization.json`);
243
+ if (input === output || input === lineagePath || output === lineagePath)
244
+ throw Error('optimization_output_must_be_new');
245
+ const metadata = await lstat(input);
246
+ if (!metadata.isFile() || metadata.nlink !== 1 || await realpath(input) !== input || metadata.size > 64 * 1024 * 1024)
247
+ throw Error('optimization_input_invalid');
248
+ const original = await readFile(input), inputHash = hash(original), before = metrics(original), beforeEvidence = unchangedEvidence(original);
249
+ let result = await rewriteTextures(original, { textureSize, quality, textureFormat: options.textureFormat });
250
+ if (!options.texturesOnly) {
251
+ const io = new NodeIO().registerExtensions(ALL_EXTENSIONS), document = await io.readBinary(result);
252
+ await document.transform(dedup(), prune());
253
+ result = Buffer.from(await io.writeBinary(document));
254
+ }
255
+ const originalDoc = parse(original).doc, resultDoc = parse(result).doc;
256
+ if (canonical(extensionValues(originalDoc)) !== canonical(extensionValues(resultDoc)) ||
257
+ [...originalDoc.extensionsUsed ?? []].some(name => !(resultDoc.extensionsUsed ?? []).includes(name)))
258
+ throw Error('optimization_extension_semantics_changed');
259
+ const afterEvidence = unchangedEvidence(result), geometryUnchanged = canonical(beforeEvidence) === canonical(afterEvidence);
260
+ if (options.texturesOnly && !geometryUnchanged)
261
+ throw Error('optimization_geometry_changed');
262
+ if (options.maxOutputBytes !== undefined && result.length > options.maxOutputBytes)
263
+ throw Error(`optimization_output_budget_exceeded:${result.length}:${options.maxOutputBytes}`);
264
+ const latest = await lstat(input);
265
+ if (latest.ino !== metadata.ino || latest.dev !== metadata.dev || hash(await readFile(input)) !== inputHash)
266
+ throw Error('optimization_source_changed');
267
+ const parameters = { textureSize, textureFormat: options.textureFormat ?? 'png', formatExplicit: options.textureFormat !== undefined, quality,
268
+ texturesOnly: options.texturesOnly === true, maxOutputBytes: options.maxOutputBytes ?? null };
269
+ const nativeBinaries = await Promise.all(Object.keys(createRequire(import.meta.url).cache)
270
+ .filter(path => /[/\\]sharp[^/\\]*\.node$/.test(path)).sort().map(async (path) => ({ name: basename(path), sha256: hash(await readFile(path)) })));
271
+ if (!nativeBinaries.length)
272
+ throw Error('optimization_encoder_binary_identity_missing');
273
+ const tool = { name: 'playcraft-3d-optimize', version: await packageVersion('@playcraft/cli'), scriptHash: hash(await readFile(fileURLToPath(import.meta.url))),
274
+ dependencies: { sharp: await packageVersion('sharp'), gltfTransform: await packageVersion('@gltf-transform/core') }, codecs: sharp.versions, nativeBinaries };
275
+ const lineage = { schemaVersion: 1, parent: { path: input, inputHash, bytes: original.length }, parameters, optionsHash: hash(canonical(parameters)),
276
+ result: { path: output, resultHash: hash(result), bytes: result.length }, tool: { ...tool, manifestHash: hash(canonical(tool)) },
277
+ before, after: metrics(result), preservation: { sourceUnchanged: true, geometryUnchanged, before: beforeEvidence, after: afterEvidence, extensionsPreserved: true },
278
+ offlineChecks: 'passed', browserValidation: 'pending', cloudValidation: 'pending', modelAcceptance: false };
279
+ await publishPair([{ path: output, content: result }, { path: lineagePath, content: Buffer.from(JSON.stringify(lineage, null, 2) + '\n') }]);
280
+ return { output, lineagePath, lineage };
281
+ }
@@ -0,0 +1,35 @@
1
+ import { createModelAdaptationCore, inspectModelAdaptationSource, workspaceModelAdaptationSchema } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { digest } from './glb.js';
3
+ /** Uses an actual caller-owned loader/engine scene. It neither fabricates geometry identities nor draws. */
4
+ export async function inspectPreparedSplitBindings(input) {
5
+ if (digest(input.bytes) !== input.manifest.output.contentHash)
6
+ throw Error('model_preparation_output_hash_mismatch');
7
+ const source = { kind: 'resource', path: input.sourcePath, contentHash: digest(input.bytes), files: [{ path: input.sourcePath, contentHash: digest(input.bytes) }] };
8
+ const inventory = await inspectModelAdaptationSource({ source: input.scene, digestBytes: digest, animationCount: input.animationCount, signal: input.signal });
9
+ const ref = (role) => {
10
+ const expected = input.manifest.output.nodes.find(node => node.role === role);
11
+ const matches = inventory.nodes.filter(node => node.name === expected.name && node.sourceNodeId === expected.sourceNodeId);
12
+ if (matches.length !== 1)
13
+ throw Error('model_preparation_actual_node_identity_mismatch');
14
+ return { sourceNodeId: matches[0].sourceNodeId, geometryHash: matches[0].geometryHash };
15
+ };
16
+ const body = ref('body'), moving = ref('lid');
17
+ const plan = workspaceModelAdaptationSchema.parse({ schemaVersion: 1, source,
18
+ hinges: [{ id: 'primary', movingNodeRef: moving, bodyNodeRefs: [body], pivot: input.manifest.hinge.pivot,
19
+ axis: input.manifest.hinge.axis, openAngleDegrees: input.manifest.hinge.openAngleDegrees }] });
20
+ const factory = await createModelAdaptationCore({ THREE: input.THREE, source: input.scene, sourceIdentity: source, plan,
21
+ digestBytes: digest, animationCount: input.animationCount, signal: input.signal });
22
+ try {
23
+ const instance = factory.create();
24
+ try {
25
+ instance.setHinge('primary', plan.hinges[0].openAngleDegrees);
26
+ }
27
+ finally {
28
+ instance.dispose();
29
+ }
30
+ }
31
+ finally {
32
+ factory.release();
33
+ }
34
+ return { source, inventory, plan, observation: { kind: 'actual-engine-geometry-inspection', rendered: false, browserStarted: false, modelAcceptance: false } };
35
+ }
@@ -0,0 +1,28 @@
1
+ import { lstat, readFile, realpath } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { splitRigidModel } from './split-rigid.js';
4
+ /** The caller registers this under its existing `playcraft 3d` parent. */
5
+ export function registerModelPreparationCommands(parent) {
6
+ parent.command('split-rigid <model>')
7
+ .description('Prepare a new restricted rigid shell derivative; no rendering, inference or acceptance')
8
+ .requiredOption('--source-hash <sha256>', 'Expected immutable source digest')
9
+ .requiredOption('--config <json>', 'Explicit scene-plane, inner-shell and hinge configuration')
10
+ .requiredOption('--output <directory>', 'New output directory; existing directories are rejected')
11
+ .action(async (model, options) => {
12
+ try {
13
+ if (!/^[a-f0-9]{64}$/.test(options.sourceHash))
14
+ throw Error('model_preparation_source_hash_invalid');
15
+ const path = resolve(options.config), stat = await lstat(path);
16
+ if (await realpath(path) !== path || !stat.isFile() || stat.nlink !== 1 || stat.size > 65536)
17
+ throw Error('model_preparation_config_file_invalid');
18
+ const recipe = JSON.parse(await readFile(path, 'utf8'));
19
+ const result = await splitRigidModel({ sourcePath: resolve(model), expectedSourceHash: options.sourceHash, outputRoot: resolve(options.output), options: recipe });
20
+ process.stdout.write(JSON.stringify({ outputRoot: result.outputRoot, output: result.manifest.output, recipeHash: result.manifest.recipeHash,
21
+ geometryChecksPassed: true, visualAcceptance: false, browserAcceptance: false, modelAcceptance: false }, null, 2) + '\n');
22
+ }
23
+ catch (error) {
24
+ process.stderr.write((error instanceof Error ? error.message : 'model_preparation_failed') + '\n');
25
+ process.exitCode = 1;
26
+ }
27
+ });
28
+ }
@@ -0,0 +1,241 @@
1
+ export const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
2
+ export const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
3
+ export const mul = (a, n) => [a[0] * n, a[1] * n, a[2] * n];
4
+ export const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
5
+ export const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
6
+ export const length = (v) => Math.hypot(...v);
7
+ export const unit = (v) => { const n = length(v); if (!Number.isFinite(n) || n < 1e-15)
8
+ throw Error('model_preparation_invalid_direction'); return mul(v, 1 / n); };
9
+ export const f32 = (p) => p.map(v => Math.fround(v) || 0);
10
+ export const pointKey = (p) => f32(p).join(',');
11
+ export const area = (t) => length(cross(sub(t[1], t[0]), sub(t[2], t[0]))) / 2;
12
+ export const volume = (ts) => ts.reduce((sum, t) => sum + dot(t[0], cross(t[1], t[2])) / 6, 0);
13
+ export function bounds(ts) {
14
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
15
+ for (const t of ts)
16
+ for (const p of t)
17
+ for (let k = 0; k < 3; k++) {
18
+ min[k] = Math.min(min[k], p[k]);
19
+ max[k] = Math.max(max[k], p[k]);
20
+ }
21
+ return { min, max };
22
+ }
23
+ export function topology(triangles, epsilon) {
24
+ const vertices = new Map(), edgeFaces = new Map();
25
+ const ids = triangles.map(t => t.map(p => { const key = pointKey(p); if (!vertices.has(key))
26
+ vertices.set(key, vertices.size); return vertices.get(key); }));
27
+ let degenerate = 0;
28
+ triangles.forEach((t, i) => {
29
+ if (new Set(ids[i]).size !== 3 || area(t) <= epsilon * epsilon)
30
+ degenerate++;
31
+ const row = ids[i];
32
+ for (const [a, b] of [[row[0], row[1]], [row[1], row[2]], [row[2], row[0]]]) {
33
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`, edge = edgeFaces.get(key) ?? { count: 0, direction: 0, faces: [] };
34
+ edge.count++;
35
+ edge.direction += a < b ? 1 : -1;
36
+ edge.faces.push(i);
37
+ edgeFaces.set(key, edge);
38
+ }
39
+ });
40
+ const parent = triangles.map((_, i) => i);
41
+ const find = (v) => parent[v] === v ? v : (parent[v] = find(parent[v]));
42
+ for (const e of edgeFaces.values())
43
+ for (const f of e.faces.slice(1))
44
+ parent[find(f)] = find(e.faces[0]);
45
+ const groups = new Map();
46
+ triangles.forEach((_, i) => { const key = find(i), list = groups.get(key) ?? []; list.push(i); groups.set(key, list); });
47
+ return { vertices: vertices.size, edges: edgeFaces.size, faces: triangles.length, degenerate,
48
+ boundaryEdges: [...edgeFaces.values()].filter(e => e.count === 1).length,
49
+ nonManifoldEdges: [...edgeFaces.values()].filter(e => e.count > 2).length,
50
+ inconsistentEdges: [...edgeFaces.values()].filter(e => e.count === 2 && e.direction !== 0).length,
51
+ components: [...groups.values()].sort((a, b) => b.length - a.length), signedVolume: volume(triangles) };
52
+ }
53
+ export function buildBvh(triangles, indices = triangles.map((_, i) => i)) {
54
+ const box = bounds(indices.map(i => triangles[i]));
55
+ if (indices.length <= 8)
56
+ return { ...box, indices };
57
+ const sizes = sub(box.max, box.min), axis = sizes.indexOf(Math.max(...sizes));
58
+ indices.sort((a, b) => triangles[a].reduce((s, p) => s + p[axis], 0) - triangles[b].reduce((s, p) => s + p[axis], 0));
59
+ const middle = Math.floor(indices.length / 2);
60
+ return { ...box, left: buildBvh(triangles, indices.slice(0, middle)), right: buildBvh(triangles, indices.slice(middle)) };
61
+ }
62
+ const boxDistanceSquared = (a, b) => a.min.reduce((sum, _, k) => sum + Math.max(0, a.min[k] - b.max[k], b.min[k] - a.max[k]) ** 2, 0);
63
+ function pointSegmentSquared(p, a, b) {
64
+ const v = sub(b, a), denominator = dot(v, v), t = denominator ? Math.max(0, Math.min(1, dot(sub(p, a), v) / denominator)) : 0;
65
+ const d = sub(p, add(a, mul(v, t)));
66
+ return dot(d, d);
67
+ }
68
+ function insideTriangle(p, t, epsilon) {
69
+ const ab = sub(t[1], t[0]), ac = sub(t[2], t[0]), ap = sub(p, t[0]);
70
+ const aa = dot(ab, ab), bb = dot(ac, ac), abac = dot(ab, ac), denominator = aa * bb - abac * abac;
71
+ if (denominator <= 0)
72
+ return false;
73
+ const u = (bb * dot(ap, ab) - abac * dot(ap, ac)) / denominator;
74
+ const v = (aa * dot(ap, ac) - abac * dot(ap, ab)) / denominator;
75
+ const tolerance = epsilon / Math.max(Math.sqrt(aa), Math.sqrt(bb), epsilon);
76
+ return u >= -tolerance && v >= -tolerance && u + v <= 1 + tolerance;
77
+ }
78
+ function edgePlanePoints(t, other, normal, epsilon) {
79
+ const points = [];
80
+ for (let i = 0; i < 3; i++) {
81
+ const a = t[i], b = t[(i + 1) % 3], da = dot(sub(a, other[0]), normal), db = dot(sub(b, other[0]), normal);
82
+ if (Math.abs(da) <= epsilon && insideTriangle(a, other, epsilon))
83
+ points.push(a);
84
+ if (da * db < 0) {
85
+ const p = add(a, mul(sub(b, a), da / (da - db)));
86
+ if (insideTriangle(p, other, epsilon))
87
+ points.push(p);
88
+ }
89
+ }
90
+ return points;
91
+ }
92
+ function coplanarPoints(a, b, normal, epsilon) {
93
+ const points = [...a.filter(p => insideTriangle(p, b, epsilon)), ...b.filter(p => insideTriangle(p, a, epsilon))];
94
+ const axis = normal.map(Math.abs).indexOf(Math.max(...normal.map(Math.abs))), axes = [0, 1, 2].filter(k => k !== axis);
95
+ const cross2 = (x, y) => x[axes[0]] * y[axes[1]] - x[axes[1]] * y[axes[0]];
96
+ for (let i = 0; i < 3; i++)
97
+ for (let j = 0; j < 3; j++) {
98
+ const p = a[i], q = b[j], u = sub(a[(i + 1) % 3], p), v = sub(b[(j + 1) % 3], q), denominator = cross2(u, v);
99
+ if (Math.abs(denominator) <= epsilon * Math.max(length(u), length(v)))
100
+ continue;
101
+ const delta = sub(q, p), s = cross2(delta, v) / denominator, t = cross2(delta, u) / denominator;
102
+ if (s >= 0 && s <= 1 && t >= 0 && t <= 1)
103
+ points.push(add(p, mul(u, s)));
104
+ }
105
+ return points;
106
+ }
107
+ export function intersectionPoints(a, b, epsilon) {
108
+ const an = unit(cross(sub(a[1], a[0]), sub(a[2], a[0]))), bn = unit(cross(sub(b[1], b[0]), sub(b[2], b[0])));
109
+ const distances = b.map(p => dot(sub(p, a[0]), an));
110
+ if (distances.every(d => d > epsilon) || distances.every(d => d < -epsilon))
111
+ return [];
112
+ if (length(cross(an, bn)) <= 1e-8)
113
+ return distances.every(d => Math.abs(d) <= epsilon) ? coplanarPoints(a, b, an, epsilon) : [];
114
+ return [...edgePlanePoints(a, b, bn, epsilon), ...edgePlanePoints(b, a, an, epsilon)];
115
+ }
116
+ function forbiddenIntersection(a, b, epsilon) {
117
+ const shared = a.filter(p => b.some(q => pointKey(p) === pointKey(q)));
118
+ if (shared.length === 3)
119
+ return true;
120
+ const points = intersectionPoints(a, b, epsilon);
121
+ return points.some(p => shared.length === 0 || (shared.length === 1
122
+ ? length(sub(p, shared[0])) > epsilon * 4
123
+ : pointSegmentSquared(p, shared[0], shared[1]) > (epsilon * 4) ** 2));
124
+ }
125
+ export function findSelfIntersections(triangles, epsilon, maximumPairs = 10000, signal) {
126
+ const tree = buildBvh(triangles), pairs = [];
127
+ let tested = 0, truncated = false;
128
+ const visit = (a, b) => {
129
+ if (pairs.length >= maximumPairs) {
130
+ truncated = true;
131
+ return;
132
+ }
133
+ if (boxDistanceSquared(a, b) > epsilon * epsilon)
134
+ return;
135
+ signal?.throwIfAborted();
136
+ if (a === b && a.left) {
137
+ visit(a.left, a.left);
138
+ visit(a.left, a.right);
139
+ visit(a.right, a.right);
140
+ return;
141
+ }
142
+ if (a.indices && b.indices) {
143
+ for (const i of a.indices)
144
+ for (const j of b.indices) {
145
+ if (a === b && i >= j)
146
+ continue;
147
+ if (boxDistanceSquared(bounds([triangles[i]]), bounds([triangles[j]])) > epsilon * epsilon)
148
+ continue;
149
+ tested++;
150
+ if (forbiddenIntersection(triangles[i], triangles[j], epsilon)) {
151
+ pairs.push([i, j]);
152
+ if (pairs.length >= maximumPairs) {
153
+ truncated = true;
154
+ return;
155
+ }
156
+ }
157
+ }
158
+ }
159
+ else if (!b.indices && (a.indices || length(sub(b.max, b.min)) > length(sub(a.max, a.min)))) {
160
+ visit(a, b.left);
161
+ visit(a, b.right);
162
+ }
163
+ else {
164
+ visit(a.left, b);
165
+ visit(a.right, b);
166
+ }
167
+ };
168
+ visit(tree, tree);
169
+ return { testedPairs: tested, pairs, truncated };
170
+ }
171
+ export function assertNoSelfIntersections(triangles, epsilon, signal) {
172
+ const result = findSelfIntersections(triangles, epsilon, 1, signal);
173
+ if (result.pairs.length)
174
+ throw Error(`model_preparation_self_intersection:${result.pairs[0][0]}:${result.pairs[0][1]}`);
175
+ return { testedPairs: result.testedPairs };
176
+ }
177
+ function pointTriangleSquared(p, t) {
178
+ const n = unit(cross(sub(t[1], t[0]), sub(t[2], t[0]))), distance = dot(sub(p, t[0]), n), projected = sub(p, mul(n, distance));
179
+ if (insideTriangle(projected, t, 0))
180
+ return distance * distance;
181
+ return Math.min(...t.map((a, i) => pointSegmentSquared(p, a, t[(i + 1) % 3])));
182
+ }
183
+ function segmentsSquared(a, b, c, d) {
184
+ const u = sub(b, a), v = sub(d, c), w = sub(a, c), aa = dot(u, u), bb = dot(u, v), cc = dot(v, v), dd = dot(u, w), ee = dot(v, w), determinant = aa * cc - bb * bb;
185
+ let s = determinant > 1e-30 ? Math.max(0, Math.min(1, (bb * ee - cc * dd) / determinant)) : 0;
186
+ let t = cc ? (bb * s + ee) / cc : 0;
187
+ if (t < 0) {
188
+ t = 0;
189
+ s = aa ? Math.max(0, Math.min(1, -dd / aa)) : 0;
190
+ }
191
+ else if (t > 1) {
192
+ t = 1;
193
+ s = aa ? Math.max(0, Math.min(1, (bb - dd) / aa)) : 0;
194
+ }
195
+ const delta = sub(add(a, mul(u, s)), add(c, mul(v, t)));
196
+ return dot(delta, delta);
197
+ }
198
+ function triangleDistanceSquared(a, b, epsilon) {
199
+ if (intersectionPoints(a, b, epsilon).length)
200
+ return 0;
201
+ let value = Math.min(...a.map(p => pointTriangleSquared(p, b)), ...b.map(p => pointTriangleSquared(p, a)));
202
+ for (let i = 0; i < 3; i++)
203
+ for (let j = 0; j < 3; j++)
204
+ value = Math.min(value, segmentsSquared(a[i], a[(i + 1) % 3], b[j], b[(j + 1) % 3]));
205
+ return value;
206
+ }
207
+ export function assertClearance(inner, outer, minimum, epsilon, signal) {
208
+ const tree = buildBvh(outer), threshold = (minimum + epsilon * 4) ** 2;
209
+ let tested = 0;
210
+ for (let i = 0; i < inner.length; i++) {
211
+ signal?.throwIfAborted();
212
+ const a = inner[i], box = bounds([a]);
213
+ const visit = (node) => {
214
+ if (boxDistanceSquared(box, node) >= threshold)
215
+ return;
216
+ if (node.indices)
217
+ for (const j of node.indices) {
218
+ if (boxDistanceSquared(box, bounds([outer[j]])) >= threshold)
219
+ continue;
220
+ tested++;
221
+ if (triangleDistanceSquared(a, outer[j], epsilon) < threshold)
222
+ throw Error(`model_preparation_thickness_unproven:${i}:${j}`);
223
+ }
224
+ else {
225
+ visit(node.left);
226
+ visit(node.right);
227
+ }
228
+ };
229
+ visit(tree);
230
+ }
231
+ return { provenLowerBound: minimum, testedPairs: tested };
232
+ }
233
+ export function windingNumber(point, triangles) {
234
+ let angle = 0;
235
+ for (const t of triangles) {
236
+ const a = sub(t[0], point), b = sub(t[1], point), c = sub(t[2], point);
237
+ const denominator = length(a) * length(b) * length(c) + dot(a, b) * length(c) + dot(b, c) * length(a) + dot(c, a) * length(b);
238
+ angle += 2 * Math.atan2(dot(a, cross(b, c)), denominator);
239
+ }
240
+ return angle / (4 * Math.PI);
241
+ }