@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,173 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import sharp from 'sharp';
7
+ import { canonicalModelAdaptation, createModelAdaptationCore, inspectModelAdaptationSource, readModelSourceDocument, workspaceModelAdaptationSchema, } from '@playcraft/common/cloud-workspace/runtime';
8
+ const require = createRequire(import.meta.url);
9
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
10
+ let serialized = Promise.resolve();
11
+ function assert(condition, code) { if (!condition)
12
+ throw Error(code); }
13
+ /** Actual engine loading and decoded pixels, with no renderer, browser or project code execution. */
14
+ export function inspectRigidModel(input) {
15
+ const result = serialized.then(() => inspect(input));
16
+ serialized = result.catch(() => undefined);
17
+ return result;
18
+ }
19
+ async function inspect(input) {
20
+ input.signal?.throwIfAborted();
21
+ const parsed = readModelSourceDocument(input.bytes), document = parsed.document;
22
+ const { binary, extraChunks } = parsed;
23
+ assert(binary && !extraChunks?.length && !(document.buffers ?? []).some((value) => value.uri)
24
+ && !(document.images ?? []).some((value) => value.uri), 'model_prepare_self_contained_glb_required');
25
+ assert(!(document.extensionsRequired ?? []).some((name) => ['KHR_draco_mesh_compression', 'KHR_texture_basisu', 'EXT_meshopt_compression'].includes(name)), 'model_prepare_decoder_not_configured');
26
+ assert((document.images ?? []).length <= 64, 'model_prepare_image_bound');
27
+ const threeEntry = require.resolve('three'), threeRoot = dirname(dirname(threeEntry));
28
+ const threePackage = JSON.parse(await readFile(join(threeRoot, 'package.json'), 'utf8'));
29
+ assert(threePackage.version === '0.183.2', 'model_prepare_loader_version_mismatch');
30
+ // Variable module URLs avoid inventing a second public engine type contract.
31
+ const THREE = await import(pathToFileURL(join(threeRoot, 'build/three.module.js')).href);
32
+ const loaderPath = require.resolve('three/examples/jsm/loaders/GLTFLoader.js');
33
+ const { GLTFLoader } = await import(pathToFileURL(loaderPath).href);
34
+ const descriptors = new Map(['self', 'ProgressEvent', 'createImageBitmap'].map(name => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
35
+ const pending = new Set(), decoded = [];
36
+ let loaded, factory;
37
+ const errors = [];
38
+ let decodedBytes = 0;
39
+ try {
40
+ Object.defineProperty(globalThis, 'self', { configurable: true, value: globalThis });
41
+ Object.defineProperty(globalThis, 'ProgressEvent', { configurable: true, value: class extends Event {
42
+ lengthComputable;
43
+ loaded;
44
+ total;
45
+ constructor(type, init = {}) { super(type); this.lengthComputable = Boolean(init.lengthComputable); this.loaded = init.loaded ?? 0; this.total = init.total ?? 0; }
46
+ } });
47
+ Object.defineProperty(globalThis, 'createImageBitmap', { configurable: true, value: (blob, options = {}) => {
48
+ const task = (async () => {
49
+ assert(blob instanceof Blob && blob.size <= 32 * 1024 * 1024, 'model_prepare_image_bound');
50
+ const bytes = Buffer.from(await blob.arrayBuffer());
51
+ let decoder = sharp(bytes, { limitInputPixels: 16_777_216 });
52
+ if (options.imageOrientation === 'flipY')
53
+ decoder = decoder.flip();
54
+ const metadata = await decoder.metadata(), allocation = (metadata.width ?? 0) * (metadata.height ?? 0) * 4;
55
+ assert(allocation > 0 && decodedBytes + allocation <= 128 * 1024 * 1024, 'model_prepare_decoded_image_budget');
56
+ decodedBytes += allocation;
57
+ const raw = await decoder.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
58
+ assert(raw.info.channels === 4 && raw.data.length === raw.info.width * raw.info.height * 4, 'model_prepare_image_decode_failed');
59
+ const image = { width: raw.info.width, height: raw.info.height, data: new Uint8ClampedArray(raw.data), close() { this.data = null; } };
60
+ decoded.push({ image, encodedHash: hash(bytes), rgbaHash: hash(raw.data), width: image.width, height: image.height });
61
+ return image;
62
+ })();
63
+ pending.add(task);
64
+ void task.then(() => pending.delete(task), () => pending.delete(task));
65
+ return task;
66
+ } });
67
+ const manager = new THREE.LoadingManager();
68
+ manager.onError = () => errors.push('embedded-resource-load-failed');
69
+ loaded = await new GLTFLoader(manager).parseAsync(input.bytes.buffer.slice(input.bytes.byteOffset, input.bytes.byteOffset + input.bytes.byteLength), '');
70
+ await Promise.all([...pending]);
71
+ input.signal?.throwIfAborted();
72
+ assert(!errors.length, 'model_prepare_resource_load_failed');
73
+ const inventory = await inspectModelAdaptationSource({ source: loaded.scene, animationCount: loaded.animations.length, digestBytes: hash, signal: input.signal });
74
+ assert(!inventory.unsupported.length, 'model_prepare_rigid_source_required');
75
+ let triangles = 0;
76
+ loaded.scene.traverse((node) => {
77
+ if (node.isMesh && node.geometry) {
78
+ const count = node.geometry.index?.count ?? node.geometry.getAttribute('position')?.count ?? 0;
79
+ assert(Number.isSafeInteger(count) && count % 3 === 0, 'model_prepare_triangles_required');
80
+ triangles += count / 3;
81
+ }
82
+ });
83
+ assert(triangles > 0, 'model_prepare_empty_model');
84
+ let plan = workspaceModelAdaptationSchema.parse(input.adaptation ? { ...input.adaptation, source: input.source } : { schemaVersion: 1, source: input.source });
85
+ if (input.split) {
86
+ const ref = (role) => {
87
+ const expected = input.split.output.nodes.filter(node => node.role === role);
88
+ assert(expected.length === 1, 'model_prepare_split_roles_invalid');
89
+ const matches = inventory.nodes.filter(node => node.sourceNodeId === expected[0].sourceNodeId && node.name === expected[0].name);
90
+ assert(matches.length === 1 && matches[0].geometryHash, 'model_prepare_split_node_identity_mismatch');
91
+ return { sourceNodeId: matches[0].sourceNodeId, geometryHash: matches[0].geometryHash };
92
+ };
93
+ plan = workspaceModelAdaptationSchema.parse({ ...plan, hinges: [{ id: 'primary', movingNodeRef: ref('lid'), bodyNodeRefs: [ref('body')],
94
+ pivot: input.split.hinge.pivot, axis: input.split.hinge.axis, openAngleDegrees: input.split.hinge.openAngleDegrees }] });
95
+ }
96
+ assert(input.requiredCapability !== 'rigid-hinge' || plan.hinges.length > 0, 'model_prepare_needs_part_mapping');
97
+ const points = (node) => {
98
+ const result = [];
99
+ node.updateMatrixWorld(true);
100
+ node.traverse((item) => {
101
+ const position = item.isMesh && item.geometry?.getAttribute('position');
102
+ if (position)
103
+ for (let index = 0; index < position.count; index++) {
104
+ if (result.length >= 4_000_000)
105
+ throw Error('model_prepare_geometry_bound');
106
+ result.push(new THREE.Vector3().fromBufferAttribute(position, index).applyMatrix4(item.matrixWorld).toArray());
107
+ }
108
+ });
109
+ return result;
110
+ };
111
+ const distance = (before, after) => {
112
+ assert(before.length === after.length, 'model_prepare_geometry_changed');
113
+ return before.reduce((maximum, point, index) => Math.max(maximum, Math.hypot(...point.map((value, axis) => value - after[index][axis]))), 0);
114
+ };
115
+ const original = points(loaded.scene);
116
+ factory = await createModelAdaptationCore({ THREE, source: loaded.scene, sourceIdentity: input.source, plan,
117
+ animationCount: loaded.animations.length, digestBytes: hash, signal: input.signal });
118
+ const instance = factory.create(), samples = [];
119
+ try {
120
+ for (const declared of plan.hinges) {
121
+ const hinge = instance.getHinge(declared.id);
122
+ const body = hinge.body.map(node => points(node)), moving = points(hinge.moving);
123
+ for (const fraction of [.25, .5, .75, 1]) {
124
+ input.signal?.throwIfAborted();
125
+ const angleDegrees = declared.openAngleDegrees * fraction;
126
+ instance.setHinge(declared.id, angleDegrees);
127
+ const bodyDisplacement = Math.max(...hinge.body.map((node, index) => distance(body[index], points(node))));
128
+ const movingDisplacement = distance(moving, points(hinge.moving));
129
+ assert(bodyDisplacement <= 1e-8 && movingDisplacement > 1e-8, 'model_prepare_hinge_motion_failed');
130
+ samples.push({ hingeId: declared.id, angleDegrees, bodyDisplacement, movingDisplacement });
131
+ }
132
+ instance.setHinge(declared.id, 0);
133
+ assert(distance(moving, points(hinge.moving)) <= 1e-8, 'model_prepare_closed_recovery_failed');
134
+ }
135
+ assert(distance(original, points(loaded.scene)) === 0, 'model_prepare_source_mutated');
136
+ }
137
+ finally {
138
+ instance.dispose();
139
+ }
140
+ const dependencies = await Promise.all([['three', join(threeRoot, 'build/three.module.js')], ['three-core', join(threeRoot, 'build/three.core.js')], ['loader', loaderPath]]
141
+ .map(async ([name, path]) => ({ name: name, contentHash: hash(await readFile(path)) })));
142
+ return { schemaVersion: 1, kind: 'actual-engine-geometry-inspection', source: input.source, plan, inventory, samples, triangles,
143
+ decodedTextures: decoded.map(({ image: _image, ...value }) => value),
144
+ tool: { threeVersion: threePackage.version, sharpVersions: sharp.versions, nodeVersion: process.versions.node, dependencies,
145
+ dependencyHash: hash(canonicalModelAdaptation(dependencies)) },
146
+ rendered: false, browserStarted: false, modelAcceptance: false };
147
+ }
148
+ finally {
149
+ await Promise.allSettled([...pending]);
150
+ factory?.release();
151
+ const disposed = new Set();
152
+ loaded?.scene?.traverse((node) => {
153
+ for (const item of [node.geometry, ...(Array.isArray(node.material) ? node.material : node.material ? [node.material] : [])]) {
154
+ if (!item || disposed.has(item))
155
+ continue;
156
+ disposed.add(item);
157
+ for (const value of Object.values(item))
158
+ if (value?.isTexture && !disposed.has(value)) {
159
+ disposed.add(value);
160
+ value.dispose();
161
+ }
162
+ item.dispose();
163
+ }
164
+ });
165
+ for (const entry of decoded)
166
+ entry.image.close();
167
+ for (const [name, descriptor] of descriptors)
168
+ if (descriptor)
169
+ Object.defineProperty(globalThis, name, descriptor);
170
+ else
171
+ delete globalThis[name];
172
+ }
173
+ }
@@ -0,0 +1,194 @@
1
+ export const modelProjectHtml = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title></title></head><body><main><h1 id="model-title"></h1><p id="status" role="status"></p><div id="stage"></div><section id="controls"></section><p id="model-notice"></p></main><script type="module" src="/src/main.js"></script></body></html>\n`;
2
+ export const modelProjectStrings = { en: { title: 'Adapted model', loading: 'Loading model…', ready: 'Ready', open: 'Open ', close: 'Close ',
3
+ invalidParameter: 'Invalid parameter', staticModel: 'Static model; no opening capability is declared.',
4
+ notice: 'Temporary preview controls. Save production values in the workspace parameter panel, then rebuild.', parameterLabels: { scale: 'Model scale', unitScale: 'Unit scale' } },
5
+ zh: { title: '已有模型适配', loading: '正在加载模型…', ready: '已就绪', open: '打开 ', close: '关闭 ', invalidParameter: '参数无效',
6
+ staticModel: '静态模型,未声明开合能力。', notice: '这里的调整仅用于当前预览;生产默认值请在工作区参数面板保存后重新构建。', parameterLabels: { scale: '模型缩放', unitScale: '单位比例' } } };
7
+ export const modelProjectAdapter = String.raw `import * as THREE from 'three';
8
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
9
+ import { sha256 } from '@noble/hashes/sha2.js';
10
+ import { bytesToHex } from '@noble/hashes/utils.js';
11
+ import { createModelAdaptationCore } from './runtime/model-adaptation-core.js';
12
+ const digestBytes = bytes => bytesToHex(sha256(bytes));
13
+ function disposeSource(scene) {
14
+ const resources = new Set(), images = new Set();
15
+ scene.traverse(node => { if (node.geometry) resources.add(node.geometry);
16
+ for (const material of [node.material].flat().filter(Boolean)) { resources.add(material);
17
+ for (const value of Object.values(material)) if (value?.isTexture) { resources.add(value); if (value.image?.close) images.add(value.image); } }
18
+ });
19
+ for (const resource of resources) resource.dispose(); for (const image of images) image.close();
20
+ }
21
+ export function resolvePackedModelBytes(evidence) {
22
+ const alias = evidence.packedModelField;
23
+ if (alias !== undefined && (alias !== 'modelBase64' || Object.hasOwn(evidence, 'packedModelBase64') ||
24
+ evidence.source?.contentHash !== evidence.packedHash || evidence.binding?.sourceHash !== evidence.packedHash)) throw Error('model_packed_alias_invalid');
25
+ const encoded = alias === undefined ? evidence.packedModelBase64 : evidence.modelBase64;
26
+ if (typeof encoded !== 'string' || encoded.length % 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw Error('model_packed_encoding_invalid');
27
+ const bytes = Uint8Array.from(atob(encoded), char => char.charCodeAt(0));
28
+ if (digestBytes(bytes) !== evidence.packedHash) throw Error('model_packed_bytes_changed');
29
+ return bytes;
30
+ }
31
+ export async function loadAdaptedModel(evidence, { signal } = {}) {
32
+ signal?.throwIfAborted(); const plan = JSON.parse(evidence.configRaw);
33
+ if (evidence.loaderRevision && THREE.REVISION !== evidence.loaderRevision) throw Error('model_loader_version_changed');
34
+ const bytes = resolvePackedModelBytes(evidence);
35
+ const gltf = await new GLTFLoader().parseAsync(bytes.buffer, '');
36
+ let factory, released = false;
37
+ const release = () => { if (released) return; released = true; factory?.release(); disposeSource(gltf.scene); };
38
+ try {
39
+ signal?.throwIfAborted();
40
+ factory = await createModelAdaptationCore({ THREE, source: gltf.scene, sourceIdentity: evidence.source,
41
+ plan, digestBytes, animationCount: gltf.animations.length, signal });
42
+ signal?.throwIfAborted();
43
+ return Object.freeze({ factory, plan, THREE, release });
44
+ } catch (error) { release(); throw error; }
45
+ }
46
+ `;
47
+ export const modelProjectMain = String.raw `import { loadAdaptedModel } from './adapter.js';
48
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
49
+ import strings from './strings.json';
50
+ import './style.css';
51
+ const evidence = JSON.parse(document.getElementById('playcraft-source-evidence').textContent);
52
+ const stage = document.getElementById('stage'), status = document.getElementById('status'), controlsArea = document.getElementById('controls');
53
+ const locale = navigator.language.startsWith('zh') ? 'zh' : 'en', text = strings[locale]; document.documentElement.lang = locale;
54
+ document.title = text.title; document.getElementById('model-title').textContent = text.title; document.getElementById('model-notice').textContent = text.notice; status.textContent = text.loading;
55
+ const abort = new AbortController(); let stopped = false, loaded, renderer, orbit, instance, observer, renderFailed = false;
56
+ let resolveReady, rejectReady; const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); ready.catch(() => {});
57
+ const release = () => { if (stopped) return; stopped = true; abort.abort(); observer?.disconnect(); orbit?.dispose(); loaded?.release(); renderer?.dispose(); };
58
+ const fail = error => { renderFailed = true; rejectReady(error); status.textContent = String(error.message); console.error('model_render_failed', error); parent.postMessage({ type: 'playcraft:game-error' }, '*'); release(); };
59
+ addEventListener('pagehide', release, { once: true });
60
+ try {
61
+ loaded = await loadAdaptedModel(evidence, { signal: abort.signal });
62
+ if (stopped) { loaded.release(); throw Error('model_closed'); }
63
+ const { THREE, factory, plan } = loaded, opened = new Set(), probes = new WeakMap();
64
+ const defaults = { scale: plan.transform.scale, unitScale: plan.normalization.unitScale };
65
+ for (const hinge of plan.hinges) defaults['hinge:' + hinge.id] = hinge.openAngleDegrees;
66
+ const materialKey = (field, value) => field + ':' + value.nodeRef.sourceNodeId + ':' + value.materialIndex;
67
+ for (const override of plan.materialOverrides) for (const key of ['tint', 'roughness', 'metalness']) if (override[key] !== undefined) defaults[materialKey(key, override)] = override[key];
68
+ let parameters = { ...defaults };
69
+ const apply = (target, values, open = opened) => {
70
+ target.setRootTransform({ ...plan.transform, scale: values.scale }); target.setNormalization({ ...plan.normalization, unitScale: values.unitScale });
71
+ for (const override of plan.materialOverrides) { const current = { ...override };
72
+ for (const key of ['tint', 'roughness', 'metalness']) if (override[key] !== undefined) current[key] = values[materialKey(key, override)]; target.setMaterialOverride(current); }
73
+ for (const hinge of plan.hinges) target.setHinge(hinge.id, open.has(hinge.id) ? values['hinge:' + hinge.id] : 0);
74
+ };
75
+ instance = factory.create(); const model = instance.root, scene = new THREE.Scene(); scene.background = new THREE.Color('#dbe3e8'); scene.add(model);
76
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x697788, 2)); const light = new THREE.DirectionalLight(0xffffff, 3); light.position.set(3, 5, 4); scene.add(light);
77
+ instance.setRootTransform({ ...plan.transform, scale: 1 });
78
+ const framing = new THREE.Box3().setFromObject(model);
79
+ for (const hinge of plan.hinges) {
80
+ for (const fraction of [.25, .5, .75, 1]) { instance.setHinge(hinge.id, hinge.openAngleDegrees * fraction); framing.union(new THREE.Box3().setFromObject(model)); }
81
+ instance.setHinge(hinge.id, 0);
82
+ }
83
+ const sphere = framing.getBoundingSphere(new THREE.Sphere()); apply(instance, parameters);
84
+ if (!Number.isFinite(sphere.radius) || sphere.radius <= 0) throw Error('model_empty_bounds');
85
+ const camera = new THREE.PerspectiveCamera(42, 1, Math.max(sphere.radius / 1000, .000001), sphere.radius * 1000);
86
+ camera.position.copy(sphere.center).add(new THREE.Vector3(3, 2, 5).normalize().multiplyScalar(sphere.radius * 4.5)); camera.lookAt(sphere.center);
87
+ renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.debug.checkShaderErrors = true;
88
+ renderer.debug.onShaderError = () => { renderFailed = true; }; stage.append(renderer.domElement);
89
+ renderer.domElement.addEventListener('webglcontextlost', event => { event.preventDefault(); fail(Error('model_context_lost')); });
90
+ orbit = new OrbitControls(camera, renderer.domElement); orbit.target.copy(sphere.center); orbit.update();
91
+ let width = 0, height = 0;
92
+ const resize = () => { const box = stage.getBoundingClientRect(); if (box.width > 0 && box.height > 0 && (box.width !== width || box.height !== height)) {
93
+ width = box.width; height = box.height; renderer.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); } };
94
+ observer = new ResizeObserver(resize); observer.observe(stage); resize();
95
+ for (const hinge of plan.hinges) {
96
+ const button = document.createElement('button'); button.type = 'button'; button.textContent = text.open + hinge.id; button.dataset.hingeId = hinge.id;
97
+ button.addEventListener('click', () => { if (opened.has(hinge.id)) opened.delete(hinge.id); else opened.add(hinge.id); apply(instance, parameters); button.textContent = (opened.has(hinge.id) ? text.close : text.open) + hinge.id; }); controlsArea.append(button);
98
+ }
99
+ for (const [name, value] of Object.entries(defaults)) {
100
+ const label = document.createElement('label'), input = document.createElement('input'); label.textContent = (text.parameterLabels[name] ?? name) + ' '; input.dataset.modelParameter = name;
101
+ input.type = typeof value === 'number' ? 'number' : 'text'; if (input.type === 'number') input.step = 'any'; input.value = String(value);
102
+ input.addEventListener('input', () => { const next = { ...parameters, [name]: typeof value === 'number' ? Number(input.value) : input.value };
103
+ try { apply(instance, next); parameters = next; input.setCustomValidity(''); } catch { apply(instance, parameters); input.setCustomValidity(text.invalidParameter); } }); label.append(input); controlsArea.append(label);
104
+ }
105
+ if (!plan.hinges.length) status.textContent = text.staticModel;
106
+ const render = () => { if (stopped || renderFailed) throw Error('model_render_stopped'); resize(); renderer.render(scene, camera); const gl = renderer.getContext();
107
+ if (renderFailed || gl.isContextLost() || gl.getError() !== gl.NO_ERROR) throw Error('model_render_failed'); };
108
+ Object.defineProperty(window, '__PLAYCRAFT_ACCEPTANCE__', { value: Object.freeze({ schemaVersion: 1, evidenceKind: evidence.fixtureOnly ? 'fixture' : 'real-asset', binding: evidence.binding, ready,
109
+ THREE, renderer, scene, camera, model, sourceInspection: factory.inspection, objectsBySourceNodeId: instance.objectsBySourceNodeId, getHinge: instance.getHinge,
110
+ get parameters() { return Object.freeze({ ...parameters }); }, get defaultParameters() { return Object.freeze({ ...defaults }); },
111
+ persistence: Object.freeze({ configHash: evidence.configHash }), render,
112
+ create: values => { const probe = factory.create(); try { apply(probe, { ...defaults, ...values }); probes.set(probe.root, probe); return probe.root; } catch (error) { probe.dispose(); throw error; } },
113
+ dispose: root => { const probe = probes.get(root); if (probe) { probe.dispose(); probes.delete(root); } } }) });
114
+ let first = true; const draw = () => { if (stopped) return; try { render(); const box = renderer.domElement.getBoundingClientRect(), gl = renderer.getContext();
115
+ if (first && box.width > 0 && box.height > 0 && gl.drawingBufferWidth > 0 && gl.drawingBufferHeight > 0 && renderer.info.render.calls > 0) { first = false; resolveReady(); if (plan.hinges.length) status.textContent = text.ready; parent.postMessage({ type: 'playcraft:game-ready' }, '*'); }
116
+ requestAnimationFrame(draw);
117
+ } catch (error) { fail(error); } }; requestAnimationFrame(draw);
118
+ } catch (error) { if (!stopped) fail(error); }
119
+ `;
120
+ export const modelProjectStyle = `body{margin:0;background:#eee9e0;color:#202d38;font:16px system-ui}main{max-width:1120px;margin:auto;padding:24px}#stage{height:62vh;min-height:240px;border:1px solid #c2ccd1;border-radius:8px;overflow:hidden}canvas{display:block;width:100%;height:100%}#controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:16px}button,input{padding:10px 18px;border:1px solid currentColor;border-radius:4px;background:#f8f4ec;color:inherit}button{cursor:pointer}label{display:inline-flex;align-items:center;gap:8px}input{width:110px}
121
+ `;
122
+ export const modelProjectPrepare = String.raw `import { createHash } from 'node:crypto';
123
+ import { readFile, readdir, lstat } from 'node:fs/promises';
124
+ import { join, relative } from 'node:path';
125
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
126
+ const canonical = value => Array.isArray(value) ? '[' + value.map(canonical).join(',') + ']' : value && typeof value === 'object'
127
+ ? '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}' : JSON.stringify(value);
128
+ export async function prepareModelBuild(root) {
129
+ const configRaw = await readFile(join(root, '.playcraft/model-adaptation.json'), 'utf8'), plan = JSON.parse(configRaw);
130
+ const meta = JSON.parse(await readFile(join(root, '.playcraft/model-adaptation-lineage.json'), 'utf8'));
131
+ if (canonical(plan.source) !== canonical(meta.derivedSource)) throw Error('model_source_identity_changed');
132
+ const sourceFiles = [], evidence = { source: plan.source, configRaw, sourceFiles, fixtureOnly: meta.fixtureOnly, loaderRevision: meta.loaderRevision }; let primaryBytes;
133
+ for (let index = 0; index < plan.source.files.length; index++) {
134
+ const file = plan.source.files[index], bytes = await readFile(join(root, file.path)); if (hash(bytes) !== file.contentHash) throw Error('model_source_bytes_changed');
135
+ const field = file.path === plan.source.path ? 'modelBase64' : 'sourceFile' + index; evidence[field] = bytes.toString('base64'); sourceFiles.push({ ...file, field });
136
+ if (file.path === plan.source.path) primaryBytes = bytes;
137
+ }
138
+ const packed = await readFile(join(root, 'src/assets/packed-model.glb')); if (hash(packed) !== meta.packedHash) throw Error('model_packed_bytes_changed');
139
+ const core = await readFile(join(root, 'src/runtime/model-adaptation-core.js')); if (hash(core) !== meta.coreHash) throw Error('model_core_changed');
140
+ const inputs = [];
141
+ if (meta.buildBindingVersion === 2) {
142
+ const declaration = JSON.parse(await readFile(join(root, '.playcraft/model-build-inputs.json'), 'utf8'));
143
+ const model = declaration.models?.[0];
144
+ if (declaration.schemaVersion !== 1 || !Array.isArray(declaration.models) || declaration.models.length !== 1 ||
145
+ model?.source?.kind !== 'resource' || canonical(model.source) !== canonical(plan.source) || !Array.isArray(model.adaptationFiles)) throw Error('model_build_manifest_invalid');
146
+ const paths = new Map();
147
+ for (const file of [...model.source.files, ...model.adaptationFiles]) {
148
+ if (!file || typeof file.path !== 'string' || !file.path || file.path.length > 512 || /[\\\0:]/.test(file.path) || file.path.startsWith('/') ||
149
+ file.path.split('/').some(part => !part || part === '.' || part === '..') ||
150
+ file.contentHash !== undefined && !/^[a-f0-9]{64}$/.test(file.contentHash)) throw Error('model_build_manifest_invalid');
151
+ const previous = paths.get(file.path); if (previous?.contentHash && file.contentHash && previous.contentHash !== file.contentHash) throw Error('model_build_input_conflict');
152
+ paths.set(file.path, previous?.contentHash ? previous : file);
153
+ }
154
+ if (paths.size > 256) throw Error('model_build_input_limit');
155
+ const readInput = async path => {
156
+ let current = root; for (const [index, part] of path.split('/').entries()) {
157
+ current = join(current, part); const stat = await lstat(current);
158
+ if (stat.isSymbolicLink() || (index < path.split('/').length - 1 ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024 * 1024)) throw Error('model_input_not_regular');
159
+ }
160
+ return readFile(current);
161
+ };
162
+ for (const path of ['package.json','pnpm-lock.yaml','package-lock.json','yarn.lock']) {
163
+ try { const bytes = await readInput(path); if (!paths.has(path)) inputs.push({ path, contentHash: hash(bytes) }); }
164
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
165
+ }
166
+ for (const file of paths.values()) {
167
+ const contentHash = hash(await readInput(file.path)); if (file.contentHash && contentHash !== file.contentHash) throw Error('model_build_input_changed');
168
+ inputs.push({ path: file.path, contentHash });
169
+ }
170
+ } else if (meta.buildBindingVersion === undefined || meta.buildBindingVersion === 1) {
171
+ async function walk(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) {
172
+ const path = join(directory, entry.name); if (entry.isDirectory()) await walk(path); else if (entry.isFile()) inputs.push({ path: relative(root, path).split('\\').join('/'), contentHash: hash(await readFile(path)) }); else throw Error('model_input_not_regular');
173
+ } }
174
+ await walk(join(root, 'src')); await walk(join(root, 'source')); await walk(join(root, 'scripts'));
175
+ for (const path of ['index.html','package.json','vite.config.mjs','.playcraft/workspace.json','.playcraft/model-adaptation.json','.playcraft/model-adaptation-lineage.json','package-lock.json','pnpm-lock.yaml']) {
176
+ try { inputs.push({ path, contentHash: hash(await readFile(join(root, path))) }); } catch (error) { if (error.code !== 'ENOENT' || !path.endsWith('lock.json') && path !== 'pnpm-lock.yaml') throw error; }
177
+ }
178
+ } else throw Error('model_build_binding_version_unsupported');
179
+ inputs.sort((a,b) => a.path.localeCompare(b.path, 'en'));
180
+ const binding = { sourceKind: 'external-model', candidateId: meta.candidateId, sourceHash: plan.source.contentHash,
181
+ adaptationHash: hash(await readFile(join(root, 'src/adapter.js'))), buildHash: hash(canonical(inputs)) };
182
+ const packedEvidence = primaryBytes && plan.source.contentHash === meta.packedHash && primaryBytes.equals(packed)
183
+ ? { packedModelField: 'modelBase64' } : { packedModelBase64: packed.toString('base64') };
184
+ return { ...evidence, binding, configHash: hash(configRaw), packedHash: meta.packedHash, ...packedEvidence };
185
+ }
186
+ `;
187
+ export const modelProjectVite = String.raw `import { defineConfig } from 'vite';
188
+ import { fileURLToPath } from 'node:url';
189
+ import { prepareModelBuild } from './scripts/prepare-build.mjs';
190
+ export default defineConfig({ base: './', plugins: [{ name: 'model-adaptation-frozen-inputs', async transformIndexHtml() {
191
+ const evidence = await prepareModelBuild(fileURLToPath(new URL('./', import.meta.url)));
192
+ return [{ tag: 'script', attrs: { id: 'playcraft-source-evidence', type: 'application/json' }, children: JSON.stringify(evidence).replace(/</g, '\\u003c'), injectTo: 'head' }];
193
+ } }], build: { outDir: 'dist', emptyOutDir: true, assetsInlineLimit: Infinity, sourcemap: false } });
194
+ `;
@@ -0,0 +1,76 @@
1
+ import { lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { canonicalModelAdaptation, workspaceModelAdaptationSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { inspectModelSourceFiles, modelSourceDigest } from './source.js';
6
+ import { modelProjectAdapter, modelProjectHtml, modelProjectMain, modelProjectPrepare, modelProjectStrings, modelProjectStyle, modelProjectVite } from './project-template.js';
7
+ export async function generateModelAdaptationProject(input) {
8
+ input.signal?.throwIfAborted();
9
+ const root = await realpath(resolve(input.projectRoot)), planPath = resolve(root, input.planPath), info = await lstat(planPath);
10
+ if (!info.isFile() || info.nlink !== 1 || await realpath(planPath) !== planPath || info.size > 1024 * 1024)
11
+ throw new Error('model_adaptation_plan_file_invalid');
12
+ const parentRaw = await readFile(planPath), parentPlan = workspaceModelAdaptationSchema.parse(JSON.parse(parentRaw.toString('utf8')));
13
+ const source = await inspectModelSourceFiles({ projectRoot: root, modelPath: parentPlan.source.path, signal: input.signal });
14
+ const identity = (value) => ({ ...value, files: [...value.files].sort((a, b) => a.path.localeCompare(b.path, 'en')) });
15
+ if (canonicalModelAdaptation(identity(source.source)) !== canonicalModelAdaptation(identity(parentPlan.source)))
16
+ throw new Error('model_adaptation_source_identity_mismatch');
17
+ if (source.metadata.unsupported.length)
18
+ throw new Error(`model_adaptation_rigid_source_required:${source.metadata.unsupported.join(',')}`);
19
+ const commonEntry = createRequire(import.meta.url).resolve('@playcraft/common/cloud-workspace/runtime');
20
+ const core = await readFile(join(dirname(commonEntry), 'model-adaptation-core.js')), coreHash = modelSourceDigest(core);
21
+ const coreMap = await readFile(join(dirname(commonEntry), 'model-adaptation-core.js.map'));
22
+ if (/^\s*import\s/m.test(core.toString('utf8')))
23
+ throw new Error('model_adaptation_core_must_be_self_contained');
24
+ const derivedSource = { ...parentPlan.source, path: `source/${parentPlan.source.path}`,
25
+ files: parentPlan.source.files.map(file => ({ ...file, path: `source/${file.path}` })) };
26
+ const plan = workspaceModelAdaptationSchema.parse({ ...parentPlan, source: derivedSource });
27
+ const candidateId = input.candidateId ?? `source-${parentPlan.source.contentHash.slice(0, 24)}`;
28
+ if (!/^[a-zA-Z0-9:_-]{1,128}$/.test(candidateId))
29
+ throw new Error('model_adaptation_candidate_id_invalid');
30
+ const outputRoot = resolve(input.outputRoot);
31
+ if (await realpath(dirname(outputRoot)) !== dirname(outputRoot))
32
+ throw new Error('model_adaptation_output_parent_must_be_canonical');
33
+ const json = (value) => JSON.stringify(value, null, 2) + '\n';
34
+ const files = {
35
+ 'package.json': json({ name: 'playcraft-adapted-model-project', version: '0.0.0', private: true, type: 'module',
36
+ scripts: { dev: 'vite --host 0.0.0.0', build: 'vite build' }, dependencies: { three: '0.183.2', '@noble/hashes': '2.0.1' }, devDependencies: { vite: '7.1.3' } }),
37
+ 'index.html': modelProjectHtml, 'vite.config.mjs': modelProjectVite, 'scripts/prepare-build.mjs': modelProjectPrepare,
38
+ 'src/main.js': modelProjectMain, 'src/adapter.js': modelProjectAdapter, 'src/style.css': modelProjectStyle, 'src/strings.json': json(modelProjectStrings),
39
+ 'src/runtime/model-adaptation-core.js': core, 'src/runtime/model-adaptation-core.js.map': coreMap, 'src/assets/packed-model.glb': source.packed,
40
+ '.playcraft/workspace.json': json({ parameters: { schemaPath: 'src/theme/theme.schema.json', dataPath: '.playcraft/model-adaptation.json', themeId: 'default' } }),
41
+ '.playcraft/model-adaptation.json': json(plan), '.playcraft/parent-model-adaptation.json': parentRaw,
42
+ '.playcraft/model-adaptation-lineage.json': json({ schemaVersion: 1, buildBindingVersion: 2, fixtureOnly: input.fixtureOnly === true, parentSource: parentPlan.source,
43
+ parentPlanHash: modelSourceDigest(parentRaw), derivedSource, derivedPlanHash: modelSourceDigest(json(plan)), coreHash, packedHash: source.packedHash,
44
+ candidateId, loaderVersion: '0.183.2', loaderRevision: '183', geometryValidation: 'pending-runtime', modelInference: false, modelAcceptance: false }),
45
+ 'src/theme/theme.schema.json': json({ type: 'object', additionalProperties: false, properties: {
46
+ schemaVersion: { type: 'number', readOnly: true }, source: { type: 'object', readOnly: true },
47
+ normalization: { type: 'object', properties: { unitScale: { type: 'number', minimum: .000000001, maximum: 1000000000, title: 'Unit scale' }, rotationDegrees: { type: 'array', readOnly: true } } },
48
+ transform: { type: 'object', properties: { scale: { type: 'number', minimum: .000001, maximum: 1000000, title: 'Model scale' }, position: { type: 'array', readOnly: true }, rotationDegrees: { type: 'array', readOnly: true } } },
49
+ materialOverrides: { type: 'array', readOnly: true }, visibility: { type: 'array', readOnly: true }, hinges: { type: 'array', readOnly: true },
50
+ } }),
51
+ 'AGENTS.md': '# Adapted model project\n\nSource files and the parent adaptation bytes are immutable copies. Save derived configuration through normal workspace operations. Final deliveries belong in root dist/, including dist/preview/index.html; all delivery dependencies must be packaged there. Private inputs and failed evidence stay outside dist/. A static model has no inferred opening capability.\n',
52
+ 'README.md': '# Saved-model adaptation project\n\nThis prepares source without installation, building, inference or acceptance. Install pinned dependencies and build normally. The workspace parameter panel edits scalar production values; structural mappings and arrays belong to the adaptation panel. Both save .playcraft/model-adaptation.json. Original source/ files and exact parent plan are retained. Runtime geometry identities are checked by the same core as the model viewer. Skin, animation, morph targets and instancing are unsupported; merged parts need a real split. Preview controls are temporary.\n',
53
+ };
54
+ const adaptationPaths = Object.keys(files).filter(path => path.startsWith('src/') || path.startsWith('scripts/') ||
55
+ ['index.html', 'package.json', 'vite.config.mjs', '.playcraft/workspace.json', '.playcraft/model-adaptation.json', '.playcraft/model-adaptation-lineage.json'].includes(path));
56
+ if (derivedSource.files.length + adaptationPaths.length > 256 || adaptationPaths.length > 64)
57
+ throw new Error('model_adaptation_build_input_bound');
58
+ files['.playcraft/model-build-inputs.json'] = json({ schemaVersion: 1, models: [{ source: derivedSource, adaptationFiles: adaptationPaths.map(path => ({ path })) }] });
59
+ for (const [path, bytes] of source.originalFiles)
60
+ files[`source/${path}`] = bytes;
61
+ const manifest = { schemaVersion: 1, fixtureOnly: input.fixtureOnly === true, source: parentPlan.source, derivedSource,
62
+ parentPlanHash: modelSourceDigest(parentRaw), coreHash, packedHash: source.packedHash,
63
+ files: Object.entries(files).map(([path, bytes]) => ({ path, contentHash: modelSourceDigest(bytes), bytes: typeof bytes === 'string' ? Buffer.byteLength(bytes) : bytes.byteLength }))
64
+ .sort((a, b) => a.path.localeCompare(b.path, 'en')),
65
+ geometryValidation: 'pending-runtime', modelInference: false, modelAcceptance: false, publicationAcceptance: false };
66
+ files['model-adaptation-source-manifest.json'] = json(manifest);
67
+ input.signal?.throwIfAborted();
68
+ await mkdir(outputRoot, { recursive: false });
69
+ for (const [path, bytes] of Object.entries(files)) {
70
+ input.signal?.throwIfAborted();
71
+ const absolute = join(outputRoot, path);
72
+ await mkdir(dirname(absolute), { recursive: true });
73
+ await writeFile(absolute, bytes, { flag: 'wx' });
74
+ }
75
+ return { outputRoot, manifest };
76
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { packModelSource, readModelSourceDocument, resolveModelDependencyPath, workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ export const modelSourceDigest = (bytes) => createHash('sha256').update(bytes).digest('hex');
6
+ export async function readModelSourceFile(root, path) {
7
+ workspaceRelativePathSchema.parse(path);
8
+ if (path.split('/').some(part => ['.git', '.playcraft', '.ssh', 'node_modules'].includes(part) || part === '.env' || part.startsWith('.env.')))
9
+ throw new Error('model_source_path_unsafe');
10
+ const absolute = resolve(root, path), rel = relative(root, absolute);
11
+ if (rel.startsWith(`..${sep}`) || isAbsolute(rel) || await realpath(absolute) !== absolute)
12
+ throw new Error('model_source_path_unsafe');
13
+ const stat = await lstat(absolute);
14
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024 * 1024)
15
+ throw new Error('model_source_file_bound');
16
+ return new Uint8Array(await readFile(absolute));
17
+ }
18
+ /** Source metadata only: runtime geometry identities always come from the shared actual-engine inspector. */
19
+ export async function inspectModelSourceFiles(input) {
20
+ const root = await realpath(resolve(input.projectRoot)), path = relative(root, resolve(root, input.modelPath)).split(sep).join('/');
21
+ workspaceRelativePathSchema.parse(path);
22
+ if (!/\.(?:glb|gltf)$/i.test(path))
23
+ throw new Error('model_source_format_unsupported');
24
+ const bytes = await readModelSourceFile(root, path), sourceDocument = readModelSourceDocument(bytes).document;
25
+ const originalFiles = new Map([[path, bytes]]);
26
+ const packed = await packModelSource({ path, bytes, signal: input.signal, digestBytes: modelSourceDigest,
27
+ readDependency: async (uri) => {
28
+ const dependencyPath = resolveModelDependencyPath(uri, path), data = await readModelSourceFile(root, dependencyPath);
29
+ originalFiles.set(dependencyPath, data);
30
+ return { path: dependencyPath, bytes: data };
31
+ } });
32
+ const source = { kind: 'resource', path, contentHash: modelSourceDigest(bytes), files: packed.sourceFiles };
33
+ const list = (value) => Array.isArray(value) ? value : [];
34
+ const rawNodes = list(sourceDocument.nodes);
35
+ const meshes = list(sourceDocument.meshes);
36
+ const unsupported = [
37
+ ...(list(sourceDocument.animations).length ? ['animation'] : []), ...(list(sourceDocument.skins).length ? ['skinned_mesh'] : []),
38
+ ...(rawNodes.some(node => node.extensions?.EXT_mesh_gpu_instancing) ? ['instanced_mesh'] : []),
39
+ ...(meshes.some(mesh => mesh.primitives?.some(primitive => primitive.targets?.length)) ? ['morph_targets'] : []),
40
+ ...(rawNodes.length > 4096 ? ['node_bound'] : []),
41
+ ];
42
+ const metadata = { format: /\.glb$/i.test(path) ? 'glb' : 'gltf', nodes: list(sourceDocument.nodes).length,
43
+ meshes: list(sourceDocument.meshes).length, materials: list(sourceDocument.materials).length,
44
+ animations: list(sourceDocument.animations).length, skins: list(sourceDocument.skins).length,
45
+ extensionsUsed: list(sourceDocument.extensionsUsed), unsupported, geometryIdentity: 'requires-runtime-inspection',
46
+ sourceNodes: rawNodes.slice(0, 4096).map((node, gltfNodeIndex) => ({ gltfNodeIndex, name: node.name ?? '', mesh: node.mesh, children: node.children ?? [] })) };
47
+ return { source, packed: packed.glb, packedHash: packed.packedHash, originalFiles, metadata,
48
+ modelInference: false, acceptance: false };
49
+ }