@playcraft/cli 0.1.63 → 0.1.64

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.
@@ -90,7 +90,7 @@ playcraft skills registry platform push ./playcraft-video-understanding-1.0.1.ta
90
90
  playcraft skills registry platform push ./playcraft-replica-validation-1.0.1.tar.gz --changelog '更新复刻验收指引与评估用例' --json
91
91
  ```
92
92
 
93
- `platform push <source>` 接受完整目录或已准备的压缩包。目录必须已含原生 `manifest.json`;命令保留其文件内容,不补充托管技能默认字段。已有压缩包原样发送,不重打包。可选的 `--ref`、`--version` 必须与清单一致;可用 `--evaluation-template <templateId>` 指定初始评估模板。原生入口当前仅接受上述两个引用,要求平台类型、单一 `sandbox-agent` surface、对齐的名称与版本、无 imports/relationships;服务端执行完整校验与摘要归一化,并返回最终 `artifactHash`。登记不会自动评估、审核或发布。
93
+ `platform push <source>` 接受完整目录或已准备的压缩包。目录必须已含原生 `manifest.json`;命令保留其文件内容,不补充托管技能默认字段。已有压缩包原样发送,不重打包。可选的 `--ref`、`--version` 必须与清单一致;可用 `--evaluation-template <templateId>` 指定初始评估模板。原生入口按公共内置技能表允许引用,包含上述两项视频指南及显式导入的 `remix/playcraft-procedural-3d`、`remix/playcraft-model-adaptation`;要求平台类型、单一 `sandbox-agent` surface、对齐的名称与版本、无 imports/relationships。服务端执行完整校验与摘要归一化,并返回最终 `artifactHash`。登记不会自动评估、审核或发布。模型适配指南的独立源版本与门禁见[发布说明](../skills/docs/model-adaptation-skill-release.md)。
94
94
 
95
95
  此入口要求具有平台管理权限的真实账号凭据;命令复用目标环境登录,不发送本地身份模拟请求头。服务端需已部署允许真实管理员个人访问令牌访问原生登记和评估接口的版本。
96
96
 
@@ -1,6 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, } from 'fs';
2
2
  import { basename, dirname, extname, join, resolve } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import { inspectModelSourceFiles } from '../model-adaptation/source.js';
5
+ import { generateModelAdaptationProject } from '../model-adaptation/project.js';
6
+ import { optimizeModel } from '../model-optimization/optimize.js';
7
+ import { registerModelPreparationCommands } from '../model-preparation/commands.js';
4
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
9
  const PRIMITIVES_DIR = join(__dirname, '../../assets/models/primitives');
6
10
  const PRIMITIVES_METADATA = join(PRIMITIVES_DIR, 'metadata.json');
@@ -302,46 +306,23 @@ function registerOptimizeCmd(parent) {
302
306
  .requiredOption('--input <path>', 'Input GLB file')
303
307
  .requiredOption('--output <path>', 'Output optimized GLB file')
304
308
  .option('--texture-size <n>', 'Max texture dimension (default: 512)', '512')
309
+ .option('--texture-format <format>', 'Explicit texture format: png or jpeg (opaque color textures only)')
310
+ .option('--quality <n>', 'JPEG texture quality, 1–100 (default: 85)', '85')
311
+ .option('--textures-only', 'Preserve geometry and node structure; do not deduplicate or prune')
312
+ .option('--max-output-bytes <n>', 'Reject output larger than this explicit byte budget')
313
+ .option('--lineage <path>', 'New independent optimization receipt (default: <output>.optimization.json)')
305
314
  .option('--target-size <s>', 'Target size hint (e.g. 200KB) — informational only')
306
315
  .action(async (opts) => {
307
316
  try {
308
- const { NodeIO, TextureChannel } = await import('@gltf-transform/core');
309
- const { dedup, prune } = await import('@gltf-transform/functions');
310
- void TextureChannel; // unused but imported for type context
311
- if (!existsSync(opts.input)) {
312
- console.error(`Error: input file not found: ${opts.input}`);
313
- process.exit(1);
314
- }
315
- const origSize = statSync(opts.input).size;
316
- const io = new NodeIO();
317
- const doc = await io.read(opts.input);
318
- const maxTexSize = parseInt(opts.textureSize, 10);
319
- const sharp = (await import('sharp')).default;
320
- // Resize textures that exceed maxTexSize using sharp
321
- const textures = doc.getRoot().listTextures();
322
- for (const tex of textures) {
323
- const imgData = tex.getImage();
324
- if (!imgData)
325
- continue;
326
- const meta = await sharp(Buffer.from(imgData)).metadata();
327
- const w = meta.width ?? 0;
328
- const h = meta.height ?? 0;
329
- if (w > maxTexSize || h > maxTexSize) {
330
- const resized = await sharp(Buffer.from(imgData))
331
- .resize(maxTexSize, maxTexSize, { fit: 'inside', withoutEnlargement: true })
332
- .png()
333
- .toBuffer();
334
- tex.setImage(new Uint8Array(resized)).setMimeType('image/png');
335
- }
336
- }
337
- await doc.transform(dedup(), prune());
338
- ensureOutputDir(opts.output);
339
- await io.write(opts.output, doc);
340
- const outSize = statSync(opts.output).size;
317
+ const optimized = await optimizeModel({ input: opts.input, output: opts.output, textureSize: Number(opts.textureSize),
318
+ textureFormat: opts.textureFormat, quality: Number(opts.quality), texturesOnly: opts.texturesOnly,
319
+ maxOutputBytes: opts.maxOutputBytes === undefined ? undefined : Number(opts.maxOutputBytes), lineagePath: opts.lineage });
320
+ const origSize = optimized.lineage.before.modelBytes, outSize = optimized.lineage.after.modelBytes;
341
321
  const saving = origSize - outSize;
342
322
  const savingPct = ((saving / origSize) * 100).toFixed(1);
343
323
  console.log(`Optimized: ${opts.output}`);
344
324
  console.log(` ${sizeLabel(origSize)} → ${sizeLabel(outSize)} (saved ${sizeLabel(saving)}, ${savingPct}%)`);
325
+ console.log(` Lineage: ${optimized.lineagePath}`);
345
326
  }
346
327
  catch (e) {
347
328
  handleError(e);
@@ -360,4 +341,31 @@ export function register3DCommands(program) {
360
341
  registerApplyTextureCmd(threeD);
361
342
  registerConvertCmd(threeD);
362
343
  registerOptimizeCmd(threeD);
344
+ registerModelPreparationCommands(threeD);
345
+ threeD.command('inspect-source <model>').description('Inspect saved-model dependencies without rendering or inference')
346
+ .option('--project-root <directory>', 'Source project directory', process.cwd()).option('--json', 'Output structured source metadata')
347
+ .action(async (model, options) => {
348
+ try {
349
+ const result = await inspectModelSourceFiles({ projectRoot: options.projectRoot, modelPath: model });
350
+ console.log(JSON.stringify({ source: result.source, packedHash: result.packedHash, metadata: result.metadata, modelInference: false, acceptance: false }, null, 2));
351
+ }
352
+ catch (error) {
353
+ handleError(error);
354
+ }
355
+ });
356
+ threeD.command('adapt-project').description('Prepare a new rigid-model adaptation project without building or publishing')
357
+ .requiredOption('--config <file>', 'Source-bound adaptation configuration').option('--project-root <directory>', 'Source project directory', process.cwd())
358
+ .requiredOption('--output <directory>', 'New output directory').option('--candidate-id <id>', 'Explicit source candidate identity')
359
+ .option('--fixture-only', 'Mark an engineering fixture without granting acceptance')
360
+ .action(async (options) => {
361
+ try {
362
+ const result = await generateModelAdaptationProject({ projectRoot: options.projectRoot, planPath: options.config, outputRoot: options.output,
363
+ candidateId: options.candidateId, fixtureOnly: options.fixtureOnly });
364
+ console.log(JSON.stringify({ outputRoot: result.outputRoot, source: result.manifest.source, coreHash: result.manifest.coreHash,
365
+ packedHash: result.manifest.packedHash, geometryValidation: result.manifest.geometryValidation, modelInference: false, acceptance: false }, null, 2));
366
+ }
367
+ catch (error) {
368
+ handleError(error);
369
+ }
370
+ });
363
371
  }
@@ -0,0 +1,162 @@
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 } 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 = []; async function walk(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) {
141
+ 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');
142
+ } }
143
+ await walk(join(root, 'src')); await walk(join(root, 'source')); await walk(join(root, 'scripts'));
144
+ 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']) {
145
+ 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; }
146
+ }
147
+ inputs.sort((a,b) => a.path.localeCompare(b.path, 'en'));
148
+ const binding = { sourceKind: 'external-model', candidateId: meta.candidateId, sourceHash: plan.source.contentHash,
149
+ adaptationHash: hash(await readFile(join(root, 'src/adapter.js'))), buildHash: hash(canonical(inputs)) };
150
+ const packedEvidence = primaryBytes && plan.source.contentHash === meta.packedHash && primaryBytes.equals(packed)
151
+ ? { packedModelField: 'modelBase64' } : { packedModelBase64: packed.toString('base64') };
152
+ return { ...evidence, binding, configHash: hash(configRaw), packedHash: meta.packedHash, ...packedEvidence };
153
+ }
154
+ `;
155
+ export const modelProjectVite = String.raw `import { defineConfig } from 'vite';
156
+ import { fileURLToPath } from 'node:url';
157
+ import { prepareModelBuild } from './scripts/prepare-build.mjs';
158
+ export default defineConfig({ base: './', plugins: [{ name: 'model-adaptation-frozen-inputs', async transformIndexHtml() {
159
+ const evidence = await prepareModelBuild(fileURLToPath(new URL('./', import.meta.url)));
160
+ return [{ tag: 'script', attrs: { id: 'playcraft-source-evidence', type: 'application/json' }, children: JSON.stringify(evidence).replace(/</g, '\\u003c'), injectTo: 'head' }];
161
+ } }], build: { outDir: 'dist', emptyOutDir: true, assetsInlineLimit: Infinity, sourcemap: false } });
162
+ `;
@@ -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, 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/playable/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
+ }