@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.
- package/README.md +24 -31
- package/SKILLS_REGISTRY.md +1 -1
- package/dist/commands/3d.js +41 -33
- package/dist/commands/build.js +70 -5
- package/dist/commands/create.js +4 -4
- package/dist/commands/inspect.js +4 -6
- package/dist/index.js +27 -19
- package/dist/model-adaptation/node-inspection.js +173 -0
- package/dist/model-adaptation/project-template.js +194 -0
- package/dist/model-adaptation/project.js +76 -0
- package/dist/model-adaptation/source.js +49 -0
- package/dist/model-optimization/optimize.js +281 -0
- package/dist/model-preparation/bindings.js +35 -0
- package/dist/model-preparation/commands.js +28 -0
- package/dist/model-preparation/geometry.js +241 -0
- package/dist/model-preparation/glb.js +176 -0
- package/dist/model-preparation/pipeline-engine.js +157 -0
- package/dist/model-preparation/pipeline-files.js +108 -0
- package/dist/model-preparation/pipeline-worker.js +32 -0
- package/dist/model-preparation/split-rigid.js +302 -0
- package/dist/model-preparation/vertex-repairs.js +63 -0
- package/dist/workspace-runtime/batch/deliver.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +11 -1
- package/dist/workspace-runtime/codex/delivery-tools.js +38 -5
- package/dist/workspace-runtime/codex/model-broker.js +19 -1
- package/dist/workspace-runtime/previews/publish.js +2 -2
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +27 -47
- package/dist/workspace-runtime/server/http.js +12 -0
- package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
- package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
- package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
- package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
- package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
- package/package.json +6 -4
- package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/project-templates/empty/AGENTS.md +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
- package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
import { canonicalModelAdaptation } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import earcut, { deviation } from 'earcut';
|
|
5
|
+
import { add, area, assertClearance, assertNoSelfIntersections, bounds, cross, dot, f32, length, mul, pointKey, sub, topology, unit, windingNumber } from './geometry.js';
|
|
6
|
+
import { digest, encodeSplitGlb, readRigidGlb } from './glb.js';
|
|
7
|
+
const tris = (fs) => fs.map(f => f.v.map(v => v.p));
|
|
8
|
+
function closed(triangles, epsilon) {
|
|
9
|
+
const result = topology(triangles, epsilon);
|
|
10
|
+
if (result.degenerate || result.boundaryEdges || result.nonManifoldEdges || result.inconsistentEdges || result.signedVolume <= epsilon ** 3)
|
|
11
|
+
throw Error('model_preparation_closed_oriented_surface_required');
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function validateOptions(options) {
|
|
15
|
+
if (Object.keys(options).some(key => !['plane', 'hingeAxis', 'innerScale', 'lidInnerLift', 'minimumThickness', 'hingeBackClearance', 'openAngleDegrees', 'maximumAuxiliaryAreaFraction', 'omitMicroscopicAuxiliary', 'bodyInterior'].includes(key)) ||
|
|
16
|
+
Object.keys(options.plane).some(key => !['normal', 'constant'].includes(key)) ||
|
|
17
|
+
(options.omitMicroscopicAuxiliary !== undefined && typeof options.omitMicroscopicAuxiliary !== 'boolean'))
|
|
18
|
+
throw Error('model_preparation_options_invalid');
|
|
19
|
+
const axis = (v) => v.length === 3 && v.filter(x => Math.abs(x) === 1).length === 1 && v.filter(x => x === 0).length === 2;
|
|
20
|
+
if (!axis(options.plane.normal) || !axis(options.hingeAxis) || dot(options.plane.normal, options.hingeAxis) !== 0)
|
|
21
|
+
throw Error('model_preparation_axis_aligned_scene_plane_required');
|
|
22
|
+
if (![options.plane.constant, ...options.innerScale, options.lidInnerLift, options.minimumThickness, options.hingeBackClearance, options.openAngleDegrees].every(Number.isFinite) ||
|
|
23
|
+
options.innerScale.length !== 3 || options.innerScale.some(x => x <= 0 || x >= 1) || options.lidInnerLift < 0 || options.minimumThickness <= 0 ||
|
|
24
|
+
options.hingeBackClearance <= 0 || options.openAngleDegrees >= 0 || options.openAngleDegrees < -90 ||
|
|
25
|
+
(options.maximumAuxiliaryAreaFraction !== undefined && (!Number.isFinite(options.maximumAuxiliaryAreaFraction) || options.maximumAuxiliaryAreaFraction < 0 || options.maximumAuxiliaryAreaFraction > .0001)))
|
|
26
|
+
throw Error('model_preparation_options_invalid');
|
|
27
|
+
if (options.bodyInterior) {
|
|
28
|
+
const b = options.bodyInterior;
|
|
29
|
+
if (b.kind !== 'smooth-inset' || Object.keys(b).some(k => !['kind', 'minU', 'maxU', 'minV', 'maxV', 'floorHeight'].includes(k)) ||
|
|
30
|
+
![b.minU, b.maxU, b.minV, b.maxV, b.floorHeight].every(Number.isFinite) || b.minU >= b.maxU || b.minV >= b.maxV || b.floorHeight >= options.plane.constant - options.minimumThickness)
|
|
31
|
+
throw Error('model_preparation_body_interior_invalid');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function clipping(primary, normal, height) {
|
|
35
|
+
const shared = new Map();
|
|
36
|
+
const distance = (v) => dot(v.p, normal) - height;
|
|
37
|
+
const intersection = (a, b) => {
|
|
38
|
+
const da = distance(a), db = distance(b), t = da / (da - db), ak = pointKey(a.p), bk = pointKey(b.p), key = ak < bk ? `${ak}/${bk}` : `${bk}/${ak}`;
|
|
39
|
+
let p = shared.get(key);
|
|
40
|
+
if (!p) {
|
|
41
|
+
const first = ak < bk ? a : b, second = ak < bk ? b : a, ratio = distance(first) / (distance(first) - distance(second));
|
|
42
|
+
p = f32(add(first.p, mul(sub(second.p, first.p), ratio)));
|
|
43
|
+
const axis = normal.findIndex(v => v !== 0);
|
|
44
|
+
p[axis] = height * normal[axis];
|
|
45
|
+
shared.set(key, p);
|
|
46
|
+
}
|
|
47
|
+
return { p, n: unit(add(mul(a.n, 1 - t), mul(b.n, t))), uv: [a.uv[0] * (1 - t) + b.uv[0] * t, a.uv[1] * (1 - t) + b.uv[1] * t],
|
|
48
|
+
bary: add(mul(a.bary, 1 - t), mul(b.bary, t)) };
|
|
49
|
+
};
|
|
50
|
+
const half = (positive) => {
|
|
51
|
+
const result = [];
|
|
52
|
+
for (const face of primary) {
|
|
53
|
+
const polygon = [];
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
const a = face.v[i], b = face.v[(i + 1) % 3], ain = positive ? distance(a) >= 0 : distance(a) <= 0, bin = positive ? distance(b) >= 0 : distance(b) <= 0;
|
|
56
|
+
if (ain)
|
|
57
|
+
polygon.push(a);
|
|
58
|
+
if (ain !== bin)
|
|
59
|
+
polygon.push(intersection(a, b));
|
|
60
|
+
}
|
|
61
|
+
const unique = polygon.filter((p, i) => pointKey(p.p) !== pointKey(polygon[(i + polygon.length - 1) % polygon.length].p));
|
|
62
|
+
for (let i = 1; i + 1 < unique.length; i++)
|
|
63
|
+
result.push({ ...face, v: [unique[0], unique[i], unique[i + 1]] });
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
return { body: half(false), lid: half(true) };
|
|
68
|
+
}
|
|
69
|
+
function boundaryLoop(faces, normal, height, epsilon) {
|
|
70
|
+
const edges = new Map();
|
|
71
|
+
for (const f of faces)
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
const from = f.v[i], to = f.v[(i + 1) % 3], a = pointKey(from.p), b = pointKey(to.p), key = a < b ? `${a}/${b}` : `${b}/${a}`, old = edges.get(key);
|
|
74
|
+
if (old)
|
|
75
|
+
old.count++;
|
|
76
|
+
else
|
|
77
|
+
edges.set(key, { from, to, count: 1 });
|
|
78
|
+
}
|
|
79
|
+
const boundary = [...edges.values()].filter(e => e.count === 1), outgoing = new Map(), incoming = new Set();
|
|
80
|
+
for (const edge of boundary) {
|
|
81
|
+
const key = pointKey(edge.from.p), end = pointKey(edge.to.p);
|
|
82
|
+
if (Math.abs(dot(edge.from.p, normal) - height) > epsilon || outgoing.has(key) || incoming.has(end))
|
|
83
|
+
throw Error('model_preparation_simple_cut_loop_required');
|
|
84
|
+
outgoing.set(key, edge);
|
|
85
|
+
incoming.add(end);
|
|
86
|
+
}
|
|
87
|
+
if (boundary.length < 3 || boundary.length > 4096)
|
|
88
|
+
throw Error('model_preparation_cut_loop_bound');
|
|
89
|
+
const ordered = [], start = pointKey(boundary[0].from.p), seen = new Set();
|
|
90
|
+
let key = start;
|
|
91
|
+
while (!seen.has(key)) {
|
|
92
|
+
seen.add(key);
|
|
93
|
+
const e = outgoing.get(key);
|
|
94
|
+
if (!e)
|
|
95
|
+
throw Error('model_preparation_cut_loop_open');
|
|
96
|
+
ordered.push(e);
|
|
97
|
+
key = pointKey(e.to.p);
|
|
98
|
+
}
|
|
99
|
+
if (key !== start || ordered.length !== boundary.length)
|
|
100
|
+
throw Error('model_preparation_single_cut_loop_required');
|
|
101
|
+
return ordered;
|
|
102
|
+
}
|
|
103
|
+
function makeShell(outer, loop, center, basis, scale, lift, epsilon, role) {
|
|
104
|
+
const transform = (p) => f32(add(center, basis.reduce((sum, axis, i) => add(sum, mul(axis, dot(sub(p, center), axis) * scale[i] + (i === 1 ? lift : 0))), [0, 0, 0])));
|
|
105
|
+
const innerVertex = (v) => ({ p: transform(v.p), n: mul(unit(basis.reduce((sum, axis, i) => add(sum, mul(axis, dot(v.n, axis) / scale[i])), [0, 0, 0])), -1),
|
|
106
|
+
uv: [dot(sub(v.p, center), basis[0]), dot(sub(v.p, center), basis[2])], bary: v.bary });
|
|
107
|
+
const inner = outer.map(f => ({ kind: 'interior', sourceTriangle: -1, v: [innerVertex(f.v[0]), innerVertex(f.v[2]), innerVertex(f.v[1])] }));
|
|
108
|
+
const ring = loop.map(e => e.from), rim = makeRim(ring, ring.map(innerVertex), center, basis, epsilon, role);
|
|
109
|
+
return { faces: [...outer, ...inner, ...rim], inner, rim };
|
|
110
|
+
}
|
|
111
|
+
function makeRim(ring, innerRing, center, basis, epsilon, role) {
|
|
112
|
+
const vertices = [...ring, ...innerRing], flat = vertices.flatMap(v => [dot(v.p, basis[0]), dot(v.p, basis[2])]);
|
|
113
|
+
const indices = earcut(flat, [ring.length], 2);
|
|
114
|
+
if (!indices.length || deviation(flat, [ring.length], 2, indices) > 1e-7)
|
|
115
|
+
throw Error('model_preparation_rim_triangulation_area_failed');
|
|
116
|
+
const pending = [];
|
|
117
|
+
for (let i = 0; i < indices.length; i += 3)
|
|
118
|
+
pending.push(indices.slice(i, i + 3));
|
|
119
|
+
const triangles = [];
|
|
120
|
+
// Earcut may remove collinear contour vertices. Reinsert them on every spanning edge to avoid T-junctions.
|
|
121
|
+
while (pending.length) {
|
|
122
|
+
const triangle = pending.pop();
|
|
123
|
+
let split = false;
|
|
124
|
+
for (let edge = 0; edge < 3 && !split; edge++) {
|
|
125
|
+
const a = triangle[edge], b = triangle[(edge + 1) % 3], c = triangle[(edge + 2) % 3], ax = flat[a * 2], ay = flat[a * 2 + 1], dx = flat[b * 2] - ax, dy = flat[b * 2 + 1] - ay, square = dx * dx + dy * dy;
|
|
126
|
+
for (let q = 0; q < vertices.length; q++) {
|
|
127
|
+
if (triangle.includes(q))
|
|
128
|
+
continue;
|
|
129
|
+
const qx = flat[q * 2] - ax, qy = flat[q * 2 + 1] - ay, t = (qx * dx + qy * dy) / square;
|
|
130
|
+
if (t <= epsilon / Math.sqrt(square) || t >= 1 - epsilon / Math.sqrt(square) || Math.abs(dx * qy - dy * qx) > epsilon * Math.sqrt(square))
|
|
131
|
+
continue;
|
|
132
|
+
pending.push([a, q, c], [q, b, c]);
|
|
133
|
+
split = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!split)
|
|
138
|
+
triangles.push(triangle);
|
|
139
|
+
if (triangles.length + pending.length > vertices.length * 8)
|
|
140
|
+
throw Error('model_preparation_rim_triangulation_bound');
|
|
141
|
+
}
|
|
142
|
+
const rim = triangles.map(ids => {
|
|
143
|
+
const corners = ids.map(i => vertices[i]);
|
|
144
|
+
let n = unit(cross(sub(corners[1].p, corners[0].p), sub(corners[2].p, corners[0].p)));
|
|
145
|
+
if (dot(n, basis[1]) * (role === 'lid' ? -1 : 1) < 0) {
|
|
146
|
+
[corners[1], corners[2]] = [corners[2], corners[1]];
|
|
147
|
+
n = mul(n, -1);
|
|
148
|
+
}
|
|
149
|
+
return { kind: 'rim', sourceTriangle: -1, v: corners.map(v => ({ ...v, n, uv: [dot(sub(v.p, center), basis[0]), dot(sub(v.p, center), basis[2])] })) };
|
|
150
|
+
});
|
|
151
|
+
return rim;
|
|
152
|
+
}
|
|
153
|
+
function makeSmoothBody(outer, loop, center, basis, cavity, epsilon) {
|
|
154
|
+
const point = (u, h, v) => ({ p: f32(add(add(mul(basis[0], u), mul(basis[1], h)), mul(basis[2], v))),
|
|
155
|
+
n: [0, 0, 0], uv: [u, v], bary: [0, 0, 0] });
|
|
156
|
+
const rectangle = [[cavity.minU, cavity.minV], [cavity.maxU, cavity.minV], [cavity.maxU, cavity.maxV], [cavity.minU, cavity.maxV]];
|
|
157
|
+
const top = rectangle.map(([u, v]) => point(u, dot(center, basis[1]), v)), bottom = rectangle.map(([u, v]) => point(u, cavity.floorHeight, v));
|
|
158
|
+
const inner = [];
|
|
159
|
+
const face = (v) => {
|
|
160
|
+
const n = unit(cross(sub(v[1].p, v[0].p), sub(v[2].p, v[0].p)));
|
|
161
|
+
inner.push({ kind: 'interior', sourceTriangle: -1, v: v.map(c => ({ ...c, n })) });
|
|
162
|
+
};
|
|
163
|
+
for (let i = 0; i < 4; i++) {
|
|
164
|
+
const j = (i + 1) % 4;
|
|
165
|
+
face([top[j], top[i], bottom[i]]);
|
|
166
|
+
face([top[j], bottom[i], bottom[j]]);
|
|
167
|
+
}
|
|
168
|
+
face([bottom[0], bottom[2], bottom[1]]);
|
|
169
|
+
face([bottom[0], bottom[3], bottom[2]]);
|
|
170
|
+
const rim = makeRim(loop.map(e => e.from), top, center, basis, epsilon, 'body');
|
|
171
|
+
return { faces: [...outer, ...inner, ...rim], inner, rim };
|
|
172
|
+
}
|
|
173
|
+
/** Produces an authored derivative only after proving the supported rigid-shell geometry constraints. */
|
|
174
|
+
export function prepareRigidSplit(bytes, options, signal) {
|
|
175
|
+
validateOptions(options);
|
|
176
|
+
signal?.throwIfAborted();
|
|
177
|
+
const source = readRigidGlb(bytes), inputTriangles = tris(source.faces), box = bounds(inputTriangles);
|
|
178
|
+
const epsilon = Math.max(length(sub(box.max, box.min)) * 1e-8, 1e-10), sourceTopology = topology(inputTriangles, epsilon);
|
|
179
|
+
const groups = sourceTopology.components, mainIds = new Set(groups[0]), main = source.faces.filter((_, i) => mainIds.has(i)), sourceAuxiliary = source.faces.filter((_, i) => !mainIds.has(i));
|
|
180
|
+
const mainTriangles = tris(main), mainArea = mainTriangles.reduce((sum, t) => sum + area(t), 0), auxiliaryArea = tris(sourceAuxiliary).reduce((sum, t) => sum + area(t), 0);
|
|
181
|
+
if (auxiliaryArea > mainArea * (options.maximumAuxiliaryAreaFraction ?? .0001))
|
|
182
|
+
throw Error('model_preparation_significant_disconnected_components');
|
|
183
|
+
const auxiliaryDiagnostics = groups.slice(1).map(ids => {
|
|
184
|
+
const triangles = ids.map(i => inputTriangles[i]), componentBounds = bounds(triangles), extent = sub(componentBounds.max, componentBounds.min);
|
|
185
|
+
return { sourceTriangles: ids, area: triangles.reduce((sum, t) => sum + area(t), 0), bounds: componentBounds, extent,
|
|
186
|
+
maximumExtentFraction: Math.max(...extent) / length(sub(box.max, box.min)) };
|
|
187
|
+
});
|
|
188
|
+
if (options.omitMicroscopicAuxiliary && auxiliaryDiagnostics.some(c => c.maximumExtentFraction > .005))
|
|
189
|
+
throw Error('model_preparation_auxiliary_extent_not_microscopic');
|
|
190
|
+
const auxiliary = options.omitMicroscopicAuxiliary ? [] : sourceAuxiliary;
|
|
191
|
+
const validatedSourceTriangles = options.omitMicroscopicAuxiliary ? mainTriangles : inputTriangles;
|
|
192
|
+
closed(validatedSourceTriangles, epsilon);
|
|
193
|
+
const sourceIntersection = assertNoSelfIntersections(validatedSourceTriangles, epsilon, signal);
|
|
194
|
+
const normal = options.plane.normal, height = Math.fround(options.plane.constant), axis = options.hingeAxis, depth = cross(axis, normal), basis = [axis, normal, depth];
|
|
195
|
+
const heights = main.flatMap(f => f.v.map(v => dot(v.p, normal)));
|
|
196
|
+
if (height <= Math.min(...heights) + epsilon || height >= Math.max(...heights) - epsilon)
|
|
197
|
+
throw Error('model_preparation_cut_outside_source');
|
|
198
|
+
// A plane through an original vertex is intentionally rejected rather than heuristically repairing a branched/zero-area seam.
|
|
199
|
+
if (heights.some(v => Math.abs(v - height) <= epsilon))
|
|
200
|
+
throw Error('model_preparation_plane_touches_vertex');
|
|
201
|
+
const halves = clipping(main, normal, height), loops = { body: boundaryLoop(halves.body, normal, height, epsilon), lid: boundaryLoop(halves.lid, normal, height, epsilon) };
|
|
202
|
+
const coordinates = source.faces.flatMap(f => f.v.map(v => [dot(v.p, axis), dot(v.p, depth)]));
|
|
203
|
+
const centerU = (Math.min(...coordinates.map(p => p[0])) + Math.max(...coordinates.map(p => p[0]))) / 2;
|
|
204
|
+
const centerV = (Math.min(...coordinates.map(p => p[1])) + Math.max(...coordinates.map(p => p[1]))) / 2;
|
|
205
|
+
const center = add(add(mul(axis, centerU), mul(normal, height)), mul(depth, centerV));
|
|
206
|
+
const parts = ['body', 'lid'].map(role => {
|
|
207
|
+
const outer = halves[role], shell = role === 'body' && options.bodyInterior
|
|
208
|
+
? makeSmoothBody(outer, loops[role], center, basis, options.bodyInterior, epsilon)
|
|
209
|
+
: makeShell(outer, loops[role], center, basis, options.innerScale, role === 'lid' ? options.lidInnerLift : 0, epsilon, role);
|
|
210
|
+
const auxiliaries = auxiliary.filter(face => {
|
|
211
|
+
const distances = face.v.map(v => dot(v.p, normal) - height);
|
|
212
|
+
if (Math.min(...distances) < 0 && Math.max(...distances) > 0)
|
|
213
|
+
throw Error('model_preparation_auxiliary_crosses_cut');
|
|
214
|
+
return role === 'body' ? Math.max(...distances) < 0 : Math.min(...distances) > 0;
|
|
215
|
+
}).map(f => ({ ...f, kind: 'auxiliary' }));
|
|
216
|
+
shell.faces.push(...auxiliaries);
|
|
217
|
+
const outputTriangles = tris(shell.faces), checks = closed(outputTriangles, epsilon);
|
|
218
|
+
let selfIntersection;
|
|
219
|
+
try {
|
|
220
|
+
selfIntersection = assertNoSelfIntersections(outputTriangles, epsilon, signal);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const match = /self_intersection:(\d+):(\d+)/.exec(error instanceof Error ? error.message : '');
|
|
224
|
+
if (match)
|
|
225
|
+
Object.assign(error, { preparationPhase: `${role}:shell`, geometryPair: match.slice(1).map(index => {
|
|
226
|
+
const face = shell.faces[Number(index)];
|
|
227
|
+
return { index: Number(index), kind: face.kind, sourceTriangle: face.sourceTriangle, positions: face.v.map(v => v.p) };
|
|
228
|
+
}) });
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
const clearance = assertClearance(tris(shell.inner), validatedSourceTriangles, options.minimumThickness, epsilon, signal);
|
|
232
|
+
const winding = windingNumber(shell.inner[0].v[0].p, mainTriangles);
|
|
233
|
+
if (Math.abs(winding - 1) > 1e-4)
|
|
234
|
+
throw Error('model_preparation_inner_not_contained');
|
|
235
|
+
return { role, ...shell, checks: { ...checks, components: checks.components.map(ids => ids.length), selfIntersection, clearance, innerWindingNumber: winding },
|
|
236
|
+
exteriorFaces: outer.length, auxiliaryFaces: auxiliaries.length, boundaryEdges: loops[role].length };
|
|
237
|
+
});
|
|
238
|
+
const pivot = add(add(mul(axis, centerU), mul(normal, height)), mul(depth, Math.min(...coordinates.map(p => p[1])) - options.hingeBackClearance));
|
|
239
|
+
const body = parts[0], lid = parts[1];
|
|
240
|
+
if (body.faces.some(f => f.v.some(v => dot(v.p, normal) > height + epsilon)) || lid.faces.some(f => f.v.some(v => dot(v.p, normal) < height - epsilon || dot(sub(v.p, pivot), depth) < options.hingeBackClearance - epsilon)))
|
|
241
|
+
throw Error('model_preparation_hinge_separation_unproven');
|
|
242
|
+
const output = encodeSplitGlb(source, parts, digest(bytes));
|
|
243
|
+
const trace = parts.flatMap((part, node) => part.faces.filter(f => ['exterior', 'auxiliary'].includes(f.kind)).map((f, triangle) => ({ node, triangle, sourceTriangle: f.sourceTriangle, barycentric: f.v.map(v => v.bary) })));
|
|
244
|
+
let maxPositionResidual = 0, maxUvResidual = 0, maxNormalResidual = 0;
|
|
245
|
+
for (const part of parts)
|
|
246
|
+
for (const f of part.faces.filter(f => ['exterior', 'auxiliary'].includes(f.kind)))
|
|
247
|
+
for (const v of f.v) {
|
|
248
|
+
const original = source.faces[f.sourceTriangle], expected = original.v.reduce((sum, c, i) => add(sum, mul(c.p, v.bary[i])), [0, 0, 0]);
|
|
249
|
+
maxPositionResidual = Math.max(maxPositionResidual, length(sub(expected, v.p)));
|
|
250
|
+
for (let k = 0; k < 2; k++)
|
|
251
|
+
maxUvResidual = Math.max(maxUvResidual, Math.abs(Math.fround(v.uv[k]) - original.v.reduce((sum, c, i) => sum + c.uv[k] * v.bary[i], 0)));
|
|
252
|
+
const expectedNormal = unit(original.v.reduce((sum, c, i) => add(sum, mul(c.n, v.bary[i])), [0, 0, 0]));
|
|
253
|
+
maxNormalResidual = Math.max(maxNormalResidual, length(sub(f32(v.n), expectedNormal)));
|
|
254
|
+
}
|
|
255
|
+
if (maxPositionResidual > epsilon * 8 || maxUvResidual > 1e-6 || maxNormalResidual > 1e-6)
|
|
256
|
+
throw Error('model_preparation_exterior_trace_failed');
|
|
257
|
+
const recipe = { algorithm: 'restricted-rigid-shell-v1', parentContentHash: digest(bytes), options: { ...options, plane: { normal, constant: height },
|
|
258
|
+
maximumAuxiliaryAreaFraction: options.maximumAuxiliaryAreaFraction ?? .0001, maximumOmittedAuxiliaryExtentFraction: .005, omitMicroscopicAuxiliary: options.omitMicroscopicAuxiliary === true } };
|
|
259
|
+
const manifest = { schemaVersion: 1, kind: 'restricted-rigid-shell-v1', recipeHash: digest(canonicalModelAdaptation(recipe)), recipe,
|
|
260
|
+
source: { contentHash: digest(bytes), bytes: bytes.length, worldMatrix: source.sourceWorldMatrix },
|
|
261
|
+
options: { ...options, plane: { normal, constant: height } }, sourceTopology: { ...sourceTopology, components: groups.map(ids => ids.length), sourceIntersection },
|
|
262
|
+
output: { contentHash: digest(output.bytes), bytes: output.bytes.length, nodes: parts.map((part, node) => ({ role: part.role, name: `split_${part.role}`, nodeIndex: node,
|
|
263
|
+
sourceNodeId: `root/${node}`, meshIndex: node, children: ['outer', 'interior'], runtimeGeometryHash: null, runtimeGeometryStatus: 'pending-actual-loader',
|
|
264
|
+
primitiveFaceCounts: output.primitiveFaceCounts[node], exteriorFaces: part.exteriorFaces, auxiliaryFaces: part.auxiliaryFaces, rimFaces: part.rim.length, cutEdges: part.boundaryEdges, checks: part.checks })) },
|
|
265
|
+
exteriorTrace: { triangles: trace.length, maxPositionResidual, maxUvResidual, maxNormalResidual, sourceSurfaceRegenerated: false,
|
|
266
|
+
omittedMicroscopicAuxiliary: options.omitMicroscopicAuxiliary ? { sourceTriangles: sourceAuxiliary.map(f => f.sourceTriangle), area: auxiliaryArea, areaFraction: auxiliaryArea / mainArea } : null },
|
|
267
|
+
sourceRepair: { omissionExplicitlyEnabled: options.omitMicroscopicAuxiliary === true, auxiliaryDiagnostics,
|
|
268
|
+
omittedComponents: options.omitMicroscopicAuxiliary ? auxiliaryDiagnostics : [], originalSourceModified: false },
|
|
269
|
+
textures: source.textures.map(t => ({ contentHash: t.contentHash, bytes: t.bytes.length, preserved: true })),
|
|
270
|
+
interior: { provenance: 'authored interior geometry with bridged rim and new untextured material; not recovered supplier interior',
|
|
271
|
+
body: options.bodyInterior ?? { kind: 'homothetic', scale: options.innerScale }, lid: { kind: 'homothetic', scale: options.innerScale },
|
|
272
|
+
bodyFloorLift: options.bodyInterior ? options.bodyInterior.floorHeight - Math.min(...heights) : (height - Math.min(...heights)) * (1 - options.innerScale[1]),
|
|
273
|
+
lidInnerLift: options.lidInnerLift, thicknessProvenLowerBound: options.minimumThickness },
|
|
274
|
+
hinge: { axis, pivot, openAngleDegrees: options.openAngleDegrees, proof: 'body at/below scene plane; lid at/above plane and wholly ahead of external back pivot; rotation -90..0 keeps lid above body plane',
|
|
275
|
+
lockAssignment: 'source surface above plane moves with lid, below plane stays with body; lock disengagement and appearance remain unverified', unlockAcceptance: false },
|
|
276
|
+
physicalHingeConnectionAcceptance: false,
|
|
277
|
+
geometryChecksPassed: true, visualAcceptance: false, browserAcceptance: false, modelAcceptance: false, publicationAcceptance: false };
|
|
278
|
+
return { bytes: output.bytes, manifest, trace, parts };
|
|
279
|
+
}
|
|
280
|
+
export async function splitRigidModel(input) {
|
|
281
|
+
input.signal?.throwIfAborted();
|
|
282
|
+
const sourcePath = resolve(input.sourcePath), info = await lstat(sourcePath);
|
|
283
|
+
if (await realpath(sourcePath) !== sourcePath || !info.isFile() || info.nlink !== 1 || info.size > 64 * 1024 * 1024 || !/\.glb$/i.test(sourcePath))
|
|
284
|
+
throw Error('model_preparation_source_file_invalid');
|
|
285
|
+
const sourceBytes = new Uint8Array(await readFile(sourcePath));
|
|
286
|
+
if (digest(sourceBytes) !== input.expectedSourceHash)
|
|
287
|
+
throw Error('model_preparation_source_hash_mismatch');
|
|
288
|
+
const outputRoot = resolve(input.outputRoot);
|
|
289
|
+
if (await realpath(dirname(outputRoot)) !== dirname(outputRoot))
|
|
290
|
+
throw Error('model_preparation_output_parent_invalid');
|
|
291
|
+
// Geometry is fully proved before creating any output directory. An existing destination is never reused.
|
|
292
|
+
const result = prepareRigidSplit(sourceBytes, input.options, input.signal), traceRaw = JSON.stringify(result.trace) + '\n';
|
|
293
|
+
const manifest = { ...result.manifest, parentFile: `parent/${basename(sourcePath)}`, exteriorTrace: { ...result.manifest.exteriorTrace, path: 'exterior-trace.json', contentHash: digest(traceRaw) } };
|
|
294
|
+
input.signal?.throwIfAborted();
|
|
295
|
+
await mkdir(outputRoot);
|
|
296
|
+
await mkdir(resolve(outputRoot, 'parent'));
|
|
297
|
+
await writeFile(resolve(outputRoot, 'parent', basename(sourcePath)), sourceBytes, { flag: 'wx' });
|
|
298
|
+
await writeFile(resolve(outputRoot, 'model.glb'), result.bytes, { flag: 'wx' });
|
|
299
|
+
await writeFile(resolve(outputRoot, 'exterior-trace.json'), traceRaw, { flag: 'wx' });
|
|
300
|
+
await writeFile(resolve(outputRoot, 'split-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx' });
|
|
301
|
+
return { outputRoot, manifest };
|
|
302
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { canonicalModelAdaptation } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { add, bounds, f32, length, mul, sub } from './geometry.js';
|
|
3
|
+
import { digest, readRigidGlb } from './glb.js';
|
|
4
|
+
export function rigidWeldedPositions(source) {
|
|
5
|
+
const positions = new Map();
|
|
6
|
+
source.localPositions.forEach((p, index) => {
|
|
7
|
+
const key = p.join(','), entry = positions.get(key) ?? { local: p, indices: [] };
|
|
8
|
+
entry.indices.push(index);
|
|
9
|
+
positions.set(key, entry);
|
|
10
|
+
});
|
|
11
|
+
return [...positions.values()].sort((a, b) => a.local[0] - b.local[0] || a.local[1] - b.local[1] || a.local[2] - b.local[2])
|
|
12
|
+
.map((entry, weldedPositionId) => ({ ...entry, weldedPositionId }));
|
|
13
|
+
}
|
|
14
|
+
/** Explicit bounded derivative repair. It patches only POSITION bytes, moving every UV/normal split copy. */
|
|
15
|
+
export function applyRigidVertexRepairs(bytes, plan) {
|
|
16
|
+
if (plan.schemaVersion !== 1 || plan.parentContentHash !== digest(bytes) || plan.weldedPositionIdentity !== 'exact-local-float32-lexicographic-v1' ||
|
|
17
|
+
!Number.isFinite(plan.maximumDisplacementFraction) || plan.maximumDisplacementFraction <= 0 || plan.maximumDisplacementFraction > .0015 ||
|
|
18
|
+
!Array.isArray(plan.vertexRepairs) || !plan.vertexRepairs.length || plan.vertexRepairs.length > 64 ||
|
|
19
|
+
new Set(plan.vertexRepairs.map(r => r.weldedPositionId)).size !== plan.vertexRepairs.length ||
|
|
20
|
+
Object.keys(plan).some(k => !['schemaVersion', 'parentContentHash', 'weldedPositionIdentity', 'maximumDisplacementFraction', 'vertexRepairs'].includes(k)))
|
|
21
|
+
throw Error('model_preparation_repair_plan_invalid');
|
|
22
|
+
const source = readRigidGlb(bytes), welded = rigidWeldedPositions(source), sourceBounds = bounds(source.faces.map(f => f.v.map(v => v.p)));
|
|
23
|
+
const diagonal = length(sub(sourceBounds.max, sourceBounds.min)), matrix = source.sourceWorldMatrix;
|
|
24
|
+
const axes = [[matrix[0], matrix[1], matrix[2]], [matrix[4], matrix[5], matrix[6]], [matrix[8], matrix[9], matrix[10]]];
|
|
25
|
+
const output = bytes.slice(), view = new DataView(output.buffer), allowedBytes = new Set(), affected = new Set();
|
|
26
|
+
const localBounds = source.localPositions.reduce((b, p) => ({ min: b.min.map((x, k) => Math.min(x, p[k])),
|
|
27
|
+
max: b.max.map((x, k) => Math.max(x, p[k])) }), { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] });
|
|
28
|
+
const records = plan.vertexRepairs.map(repair => {
|
|
29
|
+
const vertex = welded[repair.weldedPositionId];
|
|
30
|
+
if (!Number.isSafeInteger(repair.weldedPositionId) || !vertex || repair.beforeLocal.length !== 3 || repair.afterLocal.length !== 3 ||
|
|
31
|
+
![...repair.beforeLocal, ...repair.afterLocal].every(Number.isFinite) || repair.beforeLocal.some((v, i) => v !== vertex.local[i]) ||
|
|
32
|
+
Object.keys(repair).some(k => !['weldedPositionId', 'beforeLocal', 'afterLocal'].includes(k)))
|
|
33
|
+
throw Error('model_preparation_repair_vertex_identity_mismatch');
|
|
34
|
+
const after = f32(repair.afterLocal), delta = sub(after, vertex.local), worldDelta = axes.reduce((sum, axis, i) => add(sum, mul(axis, delta[i])), [0, 0, 0]);
|
|
35
|
+
const distance = length(worldDelta);
|
|
36
|
+
if (!distance || distance > diagonal * plan.maximumDisplacementFraction || after.some((v, k) => v < localBounds.min[k] || v > localBounds.max[k]))
|
|
37
|
+
throw Error('model_preparation_repair_displacement_bound');
|
|
38
|
+
for (const id of vertex.indices)
|
|
39
|
+
for (let k = 0; k < 3; k++) {
|
|
40
|
+
const offset = source.positionByteOffsets[id] + k * 4;
|
|
41
|
+
view.setFloat32(offset, after[k], true);
|
|
42
|
+
for (let j = 0; j < 4; j++)
|
|
43
|
+
allowedBytes.add(offset + j);
|
|
44
|
+
}
|
|
45
|
+
const indexSet = new Set(vertex.indices);
|
|
46
|
+
source.sourceIndices.forEach((id, index) => { if (indexSet.has(id))
|
|
47
|
+
affected.add(Math.floor(index / 3)); });
|
|
48
|
+
return { weldedPositionId: vertex.weldedPositionId, beforeLocal: vertex.local, afterLocal: after, localDelta: delta, worldDelta,
|
|
49
|
+
worldDistance: distance, positionAccessorIndices: vertex.indices };
|
|
50
|
+
});
|
|
51
|
+
let changedBytes = 0;
|
|
52
|
+
for (let i = 0; i < bytes.length; i++)
|
|
53
|
+
if (bytes[i] !== output[i]) {
|
|
54
|
+
changedBytes++;
|
|
55
|
+
if (!allowedBytes.has(i))
|
|
56
|
+
throw Error('model_preparation_repair_nonposition_mutation');
|
|
57
|
+
}
|
|
58
|
+
return { bytes: output, manifest: { schemaVersion: 1, kind: 'explicit-bounded-vertex-repair-v1', parentContentHash: digest(bytes), contentHash: digest(output),
|
|
59
|
+
recipeHash: digest(canonicalModelAdaptation(plan)), recipe: plan, sourceDiagonal: diagonal, maximumDisplacement: Math.max(...records.map(r => r.worldDistance)),
|
|
60
|
+
vertexRepairs: records, affectedSourceTriangles: [...affected].sort((a, b) => a - b), changedBytes,
|
|
61
|
+
preserved: { originalSource: true, indices: true, normalAttribute: true, textureCoordinates: true, embeddedImages: true, allNonPositionBytes: true },
|
|
62
|
+
geometryChecks: 'pending-strict-source-and-shell-validation', visualAcceptance: false, modelAcceptance: false } };
|
|
63
|
+
}
|
|
@@ -9,10 +9,10 @@ import { workspaceRoot } from '../workspaces/registry.js';
|
|
|
9
9
|
import { processHasExited, runProcessGroup, stopProcessGroup } from './processes.js';
|
|
10
10
|
import { stopMemberWriters } from './writer-proof.js';
|
|
11
11
|
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
|
|
12
|
-
/** Base build writes dist/index.html. Channel packaging
|
|
12
|
+
/** Base build writes dist/index.html. Channel packaging is done by platform export. */
|
|
13
13
|
function buildCommand() {
|
|
14
14
|
return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
|
|
15
|
-
?? 'umask 002 && CI=1 playcraft build --
|
|
15
|
+
?? 'umask 002 && CI=1 playcraft build --base --output dist .');
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* The batch build runs as the runtime identity while the agent wrote the tree as the development
|
|
@@ -577,12 +577,22 @@ export function createCodexAppServerAdapter(options) {
|
|
|
577
577
|
}
|
|
578
578
|
}
|
|
579
579
|
if (method === 'turn/completed') {
|
|
580
|
-
const
|
|
580
|
+
const turn = asRecord(payload.turn);
|
|
581
|
+
const turnId = turn.id;
|
|
581
582
|
if (typeof payload.threadId !== 'string' && typeof turnId === 'string') {
|
|
582
583
|
const matches = [...threads].filter(([, thread]) => thread.lastTurnId === turnId);
|
|
583
584
|
if (matches.length === 1)
|
|
584
585
|
payload.threadId = matches[0][0];
|
|
585
586
|
}
|
|
587
|
+
const rootThreadId = typeof payload.threadId === 'string' ? owningThreadId(payload.threadId) : undefined;
|
|
588
|
+
const rootThread = rootThreadId ? threads.get(rootThreadId) : undefined;
|
|
589
|
+
if (turn.status === 'failed' && rootThread?.cwd) {
|
|
590
|
+
const admissionReason = options.modelBroker?.consumeAdmissionReason(rootThread.cwd, rootThread.sessionId);
|
|
591
|
+
if (admissionReason) {
|
|
592
|
+
const error = asRecord(turn.error);
|
|
593
|
+
payload.turn = { ...turn, error: { ...error, admissionReason } };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
586
596
|
const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
|
|
587
597
|
const currentTurn = thread && typeof turnId === 'string' && thread.lastTurnId === turnId;
|
|
588
598
|
if (currentTurn) {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { DELIVERY_CHANNELS, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { DELIVERY_CHANNELS, deliveryExportOptionsSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
3
|
import { rejectTool, toolContent } from './tool-response.js';
|
|
4
4
|
export const WORKSPACE_DELIVERY_TOOLS = [
|
|
5
5
|
{
|
|
6
6
|
type: 'function',
|
|
7
7
|
name: 'playcraft_build_delivery',
|
|
8
|
-
description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit. Each channel uses the same fixed HTML or ZIP choice as the Export button for that channel and the project engine.
|
|
8
|
+
description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit. Each channel uses the same fixed HTML or ZIP choice as the Export button for that channel and the project engine. Build options given in `options` apply to every requested channel and replace that setting from the channel\'s latest export on this branch; options not given reuse that export, or the defaults when it has none. Store links not given come from the project\'s remembered export links, then from the channel\'s latest export. A later export without `options` keeps the settings this one used. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
|
|
9
9
|
inputSchema: {
|
|
10
10
|
type: 'object',
|
|
11
11
|
properties: {
|
|
@@ -13,9 +13,35 @@ export const WORKSPACE_DELIVERY_TOOLS = [
|
|
|
13
13
|
type: 'array',
|
|
14
14
|
items: { type: 'string', enum: DELIVERY_CHANNELS },
|
|
15
15
|
minItems: 1,
|
|
16
|
-
maxItems:
|
|
16
|
+
maxItems: DELIVERY_CHANNELS.length,
|
|
17
17
|
uniqueItems: true,
|
|
18
18
|
},
|
|
19
|
+
options: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
description: 'Optional. Set only the options to change; each one left out keeps its current setting. Each option affects the size and quality of the channel packages.',
|
|
22
|
+
properties: {
|
|
23
|
+
compressImages: { type: 'boolean', description: 'Lossy palette compression (pngquant) of PNG images.' },
|
|
24
|
+
imageQuality: { type: 'integer', minimum: 40, maximum: 95, description: 'Quality of lossy image compression, 40 to 95; lower is smaller and blurrier. Used with compressImages.' },
|
|
25
|
+
convertToWebP: { type: 'boolean', description: 'Convert PNG and JPG images to WebP, usually much smaller; on by default.' },
|
|
26
|
+
compressModels: { type: 'boolean', description: 'Compress 3D model geometry.' },
|
|
27
|
+
modelCompression: { type: 'string', enum: ['draco', 'meshopt'], description: 'Model compression method: draco is widely supported, meshopt compresses further. Used with compressModels.' },
|
|
28
|
+
jsMinify: { type: 'boolean', description: 'Minify and obfuscate JavaScript; on by default.' },
|
|
29
|
+
cssMinify: { type: 'boolean', description: 'Minify CSS; on by default.' },
|
|
30
|
+
selectedScenes: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 100, uniqueItems: true, description: 'Package just these scene names; scenes left out are not in the package.' },
|
|
31
|
+
esmMode: { type: 'string', enum: ['auto', 'enabled', 'disabled'], description: 'How ES modules are bundled; auto detects them.' },
|
|
32
|
+
storeUrls: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
description: 'App store links the call-to-action opens. A link given is used for that store; a store not given keeps the project\'s remembered link, or the channel\'s latest export link.',
|
|
35
|
+
properties: {
|
|
36
|
+
ios: { type: 'string', format: 'uri', description: 'iOS App Store URL (http or https).' },
|
|
37
|
+
android: { type: 'string', format: 'uri', description: 'Google Play URL (http or https).' },
|
|
38
|
+
},
|
|
39
|
+
minProperties: 1,
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
19
45
|
},
|
|
20
46
|
additionalProperties: false,
|
|
21
47
|
},
|
|
@@ -70,18 +96,24 @@ export class WorkspaceDeliveryTools {
|
|
|
70
96
|
await this.reject(client, requestId, 'permission_denied');
|
|
71
97
|
return;
|
|
72
98
|
}
|
|
73
|
-
const unknown = Object.keys(input).filter(key => key !== 'channels');
|
|
99
|
+
const unknown = Object.keys(input).filter(key => key !== 'channels' && key !== 'options');
|
|
74
100
|
if (unknown.length) {
|
|
75
101
|
await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
|
|
76
102
|
return;
|
|
77
103
|
}
|
|
78
104
|
const channels = input.channels === undefined ? ['playcraft'] : input.channels;
|
|
79
|
-
if (!Array.isArray(channels) || channels.length < 1 || channels.length >
|
|
105
|
+
if (!Array.isArray(channels) || channels.length < 1 || channels.length > DELIVERY_CHANNELS.length
|
|
80
106
|
|| channels.some(channel => !DELIVERY_CHANNELS.includes(channel))
|
|
81
107
|
|| new Set(channels).size !== channels.length) {
|
|
82
108
|
await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
|
|
83
109
|
return;
|
|
84
110
|
}
|
|
111
|
+
const options = input.options === undefined ? { success: true, data: undefined }
|
|
112
|
+
: deliveryExportOptionsSchema.safeParse(input.options);
|
|
113
|
+
if (!options.success || (options.data?.selectedScenes && new Set(options.data.selectedScenes).size !== options.data.selectedScenes.length)) {
|
|
114
|
+
await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
85
117
|
try {
|
|
86
118
|
const result = await this.handlers.build({
|
|
87
119
|
...context,
|
|
@@ -89,6 +121,7 @@ export class WorkspaceDeliveryTools {
|
|
|
89
121
|
? payload.callId
|
|
90
122
|
: randomUUID(),
|
|
91
123
|
channels: channels,
|
|
124
|
+
...(options.data ? { options: options.data } : {}),
|
|
92
125
|
});
|
|
93
126
|
await this.respond(client, requestId, result);
|
|
94
127
|
}
|
|
@@ -106,6 +106,7 @@ export class WorkspaceModelBroker {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
route.lastAdmissionReason = undefined;
|
|
109
110
|
route.selected = { entry, model: credential.model,
|
|
110
111
|
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, readOnly, refsBlocked,
|
|
111
112
|
...(originCwd ? { originCwd } : {}) } } : {}) };
|
|
@@ -278,8 +279,17 @@ export class WorkspaceModelBroker {
|
|
|
278
279
|
});
|
|
279
280
|
if (upstream.status === 401 || upstream.status === 403)
|
|
280
281
|
entry.credential = undefined;
|
|
282
|
+
const admissionReason = upstream.headers.get('x-model-admission-reason')?.trim();
|
|
283
|
+
const retryAfter = upstream.headers.get('retry-after')?.trim();
|
|
284
|
+
if (upstream.status < 400)
|
|
285
|
+
route.lastAdmissionReason = undefined;
|
|
286
|
+
else if (admissionReason)
|
|
287
|
+
route.lastAdmissionReason = admissionReason;
|
|
281
288
|
response.writeHead(upstream.status, {
|
|
282
|
-
'content-type': upstream.headers.get('content-type') ?? 'application/json',
|
|
289
|
+
'content-type': upstream.headers.get('content-type') ?? 'application/json',
|
|
290
|
+
'cache-control': 'no-store',
|
|
291
|
+
...(admissionReason ? { 'x-model-admission-reason': admissionReason } : {}),
|
|
292
|
+
...(retryAfter ? { 'retry-after': retryAfter } : {}),
|
|
283
293
|
});
|
|
284
294
|
if (upstream.body)
|
|
285
295
|
await pipeline(Readable.fromWeb(upstream.body), response);
|
|
@@ -316,6 +326,14 @@ export class WorkspaceModelBroker {
|
|
|
316
326
|
for (const request of this.sessionRequests.get(route) ?? [])
|
|
317
327
|
request.abort();
|
|
318
328
|
}
|
|
329
|
+
consumeAdmissionReason(cwd, sessionId) {
|
|
330
|
+
const routeKey = `${/^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
|
|
331
|
+
const route = this.routes.get(routeKey);
|
|
332
|
+
const reason = route?.lastAdmissionReason;
|
|
333
|
+
if (route)
|
|
334
|
+
route.lastAdmissionReason = undefined;
|
|
335
|
+
return reason;
|
|
336
|
+
}
|
|
319
337
|
async close() {
|
|
320
338
|
const server = this.server;
|
|
321
339
|
this.server = undefined;
|
|
@@ -12,7 +12,7 @@ import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
|
|
|
12
12
|
import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
|
|
13
13
|
import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
|
|
14
14
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
15
|
-
import { isolateWorkspaceForBuild } from '../workspaces/
|
|
15
|
+
import { isolateWorkspaceForBuild } from '../workspaces/build-isolation.js';
|
|
16
16
|
import { openScratchToDevelopment } from '../workspaces/directory-access.js';
|
|
17
17
|
import { injectPreviewDiagnostics } from './diagnostics.js';
|
|
18
18
|
import { OperationLogStore } from '../workspaces/operation-log.js';
|
|
@@ -27,7 +27,7 @@ const BUILDER_TEMP_FOLDER = '.playcraft-temp';
|
|
|
27
27
|
* The generic single-file build. `--mode full` keeps the builder from asking which stages to run.
|
|
28
28
|
* Group-writable output lets the control user, a member of the development group, remove it.
|
|
29
29
|
*/
|
|
30
|
-
const BUILD_COMMAND = 'umask 002 && PLAYCRAFT_BUILD_ISOLATED=1 playcraft build
|
|
30
|
+
const BUILD_COMMAND = 'umask 002 && PLAYCRAFT_BUILD_ISOLATED=1 playcraft build';
|
|
31
31
|
class PreviewFailure extends Error {
|
|
32
32
|
code;
|
|
33
33
|
constructor(code) {
|
|
@@ -36,6 +36,7 @@ import { platformExportTarget, readPlatformExport, startPlatformExport } from '.
|
|
|
36
36
|
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
37
37
|
import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
|
|
38
38
|
import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
|
|
39
|
+
import { recoverModelPreparationOperations, stopModelPreparations } from './workspaces/model-preparation.js';
|
|
39
40
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
40
41
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
41
42
|
const negotiated = negotiateProtocol(config);
|
|
@@ -84,6 +85,9 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
84
85
|
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
|
|
85
86
|
...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
86
87
|
? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) });
|
|
88
|
+
await recoverModelPreparationOperations(state, { identityMatches: previousIdentityMatches,
|
|
89
|
+
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
|
|
90
|
+
...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET ? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) });
|
|
87
91
|
const recoveredChanges = [];
|
|
88
92
|
const recoveredWrites = [];
|
|
89
93
|
const recoverLibraryImports = async (root) => {
|
|
@@ -316,6 +320,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
316
320
|
async close() {
|
|
317
321
|
const serverClosed = closeServer(server);
|
|
318
322
|
await stopParameterVerifications(state);
|
|
323
|
+
await stopModelPreparations(state);
|
|
319
324
|
await closeCliCredentials(state);
|
|
320
325
|
await gitCredentials?.close();
|
|
321
326
|
try {
|
|
@@ -8,7 +8,7 @@ const instances = new WeakMap();
|
|
|
8
8
|
const executing = new WeakMap();
|
|
9
9
|
const freezing = new WeakMap();
|
|
10
10
|
const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
|
|
11
|
-
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', '
|
|
11
|
+
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'model.preview', 'model.prepare', 'directory.create',
|
|
12
12
|
'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
|
|
13
13
|
'checkpoint.create', 'checkpoint.restore']);
|
|
14
14
|
export function managedDirectoryScope(state, value) {
|