@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.
@@ -0,0 +1,176 @@
1
+ import { readModelSourceDocument } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { createHash } from 'node:crypto';
3
+ import { add, cross, dot, f32, length, mul, unit } from './geometry.js';
4
+ export const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
5
+ const identity = () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
6
+ function matrixMultiply(a, b) {
7
+ return Array.from({ length: 16 }, (_, i) => { const r = i % 4, c = Math.floor(i / 4); return [0, 1, 2, 3].reduce((sum, k) => sum + a[k * 4 + r] * b[c * 4 + k], 0); });
8
+ }
9
+ function nodeMatrix(node) {
10
+ if (node.matrix) {
11
+ if (!Array.isArray(node.matrix) || node.matrix.length !== 16 || !node.matrix.every(Number.isFinite))
12
+ throw Error('model_preparation_transform_invalid');
13
+ return node.matrix;
14
+ }
15
+ const t = node.translation ?? [0, 0, 0], s = node.scale ?? [1, 1, 1], q = node.rotation ?? [0, 0, 0, 1];
16
+ if (t.length !== 3 || s.length !== 3 || q.length !== 4 || ![...t, ...s, ...q].every(Number.isFinite) || Math.abs(Math.hypot(...q) - 1) > 1e-4)
17
+ throw Error('model_preparation_transform_invalid');
18
+ const [x, y, z, w] = q;
19
+ return [(1 - 2 * (y * y + z * z)) * s[0], 2 * (x * y + z * w) * s[0], 2 * (x * z - y * w) * s[0], 0,
20
+ 2 * (x * y - z * w) * s[1], (1 - 2 * (x * x + z * z)) * s[1], 2 * (y * z + x * w) * s[1], 0,
21
+ 2 * (x * z + y * w) * s[2], 2 * (y * z - x * w) * s[2], (1 - 2 * (x * x + y * y)) * s[2], 0,
22
+ t[0], t[1], t[2], 1];
23
+ }
24
+ export function readRigidGlb(bytes) {
25
+ const parsed = readModelSourceDocument(bytes), d = parsed.document, binary = parsed.binary;
26
+ if (!binary || parsed.extraChunks?.length || d.asset?.version !== '2.0' || d.buffers?.length !== 1 || d.buffers.some((b) => b.uri) ||
27
+ d.animations?.length || d.skins?.length || d.meshes?.length !== 1 || !Array.isArray(d.nodes) || d.nodes.length > 256)
28
+ throw Error('model_preparation_single_rigid_glb_required');
29
+ const primitive = d.meshes[0].primitives?.[0];
30
+ if (d.meshes[0].primitives?.length !== 1 || !primitive || primitive.targets || primitive.extensions || (primitive.mode ?? 4) !== 4 ||
31
+ Object.keys(primitive.attributes ?? {}).some(key => !['POSITION', 'NORMAL', 'TEXCOORD_0'].includes(key)))
32
+ throw Error('model_preparation_attributes_unsupported');
33
+ let world;
34
+ const visited = new Set();
35
+ const walk = (id, parent) => {
36
+ if (!Number.isSafeInteger(id) || visited.has(id) || !d.nodes[id])
37
+ throw Error('model_preparation_node_graph_invalid');
38
+ visited.add(id);
39
+ const node = d.nodes[id];
40
+ if (node.skin !== undefined || node.extensions)
41
+ throw Error('model_preparation_node_unsupported');
42
+ const matrix = matrixMultiply(parent, nodeMatrix(node));
43
+ if (node.mesh !== undefined) {
44
+ if (world || node.mesh !== 0)
45
+ throw Error('model_preparation_single_instance_required');
46
+ world = matrix;
47
+ }
48
+ for (const child of node.children ?? [])
49
+ walk(child, matrix);
50
+ };
51
+ for (const node of d.scenes?.[d.scene ?? 0]?.nodes ?? [])
52
+ walk(node, identity());
53
+ if (!world)
54
+ throw Error('model_preparation_empty_scene');
55
+ const m = world, axes = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]], sizes = axes.map(length);
56
+ const determinant = dot(axes[0], cross(axes[1], axes[2]));
57
+ if (!m.every(Number.isFinite) || Math.abs(m[3]) + Math.abs(m[7]) + Math.abs(m[11]) + Math.abs(m[15] - 1) > 1e-9 || determinant <= 0 ||
58
+ Math.min(...sizes) < 1e-12 || Math.max(...sizes) / Math.min(...sizes) > 1.00001 ||
59
+ Math.abs(dot(axes[0], axes[1])) + Math.abs(dot(axes[1], axes[2])) + Math.abs(dot(axes[2], axes[0])) > sizes[0] ** 2 * 1e-5)
60
+ throw Error('model_preparation_positive_rigid_transform_required');
61
+ function accessor(id, components, allowed) {
62
+ const a = d.accessors?.[id], v = d.bufferViews?.[a?.bufferView], bytesPer = a?.componentType === 5126 || a?.componentType === 5125 ? 4 : 2;
63
+ if (!a || a.sparse || a.normalized || !allowed.includes(a.componentType) || a.type !== (components === 3 ? 'VEC3' : components === 2 ? 'VEC2' : 'SCALAR') ||
64
+ !Number.isSafeInteger(a.count) || a.count < 1 || a.count > 600000 || !v || v.buffer !== 0 || v.extensions)
65
+ throw Error('model_preparation_accessor_unsupported');
66
+ const start = (v.byteOffset ?? 0) + (a.byteOffset ?? 0), stride = v.byteStride ?? components * bytesPer;
67
+ if (![start, stride, v.byteLength].every(Number.isSafeInteger) || start < 0 || stride < components * bytesPer ||
68
+ start + (a.count - 1) * stride + components * bytesPer > (v.byteOffset ?? 0) + v.byteLength || (v.byteOffset ?? 0) + v.byteLength > binary.length)
69
+ throw Error('model_preparation_accessor_bound');
70
+ const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
71
+ return Array.from({ length: a.count }, (_, i) => Array.from({ length: components }, (_, k) => {
72
+ const offset = start + i * stride + k * bytesPer, value = a.componentType === 5126 ? view.getFloat32(offset, true) : bytesPer === 4 ? view.getUint32(offset, true) : view.getUint16(offset, true);
73
+ if (!Number.isFinite(value))
74
+ throw Error('model_preparation_nonfinite_accessor');
75
+ return value;
76
+ }));
77
+ }
78
+ const p = accessor(primitive.attributes.POSITION, 3, [5126]), n = accessor(primitive.attributes.NORMAL, 3, [5126]), uv = accessor(primitive.attributes.TEXCOORD_0, 2, [5126]);
79
+ const indices = accessor(primitive.indices, 1, [5123, 5125]).flat();
80
+ if (p.length !== n.length || p.length !== uv.length || indices.length % 3 || indices.length / 3 > 100000 || indices.some(i => i >= p.length))
81
+ throw Error('model_preparation_primitive_bound');
82
+ const transformVector = (v) => add(add(mul(axes[0], v[0]), mul(axes[1], v[1])), mul(axes[2], v[2]));
83
+ const faces = [];
84
+ for (let i = 0; i < indices.length; i += 3)
85
+ faces.push({ sourceTriangle: i / 3, kind: 'exterior', v: [0, 1, 2].map(k => {
86
+ const id = indices[i + k];
87
+ return { p: f32(add(transformVector(p[id]), [m[12], m[13], m[14]])), n: unit(transformVector(n[id])), uv: uv[id],
88
+ bary: [+(k === 0), +(k === 1), +(k === 2)] };
89
+ }) });
90
+ const textures = (d.images ?? []).map((image) => {
91
+ const v = d.bufferViews?.[image.bufferView];
92
+ if (image.uri || !v || v.buffer !== 0 || !['image/png', 'image/jpeg', 'image/webp'].includes(image.mimeType) || v.byteOffset < 0 || v.byteOffset + v.byteLength > binary.length)
93
+ throw Error('model_preparation_embedded_image_required');
94
+ const data = binary.slice(v.byteOffset ?? 0, (v.byteOffset ?? 0) + v.byteLength);
95
+ return { image, bytes: data, contentHash: digest(data) };
96
+ });
97
+ if (!d.materials?.[primitive.material])
98
+ throw Error('model_preparation_material_required');
99
+ const positionAccessor = d.accessors[primitive.attributes.POSITION], positionView = d.bufferViews[positionAccessor.bufferView];
100
+ const binaryOffset = 28 + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(12, true);
101
+ const positionByteOffsets = p.map((_, i) => binaryOffset + (positionView.byteOffset ?? 0) + (positionAccessor.byteOffset ?? 0) + i * (positionView.byteStride ?? 12));
102
+ return { faces, document: d, textures, materialIndex: primitive.material, sourceWorldMatrix: m,
103
+ localPositions: p, sourceIndices: indices, positionByteOffsets };
104
+ }
105
+ export function encodeSplitGlb(source, parts, parentHash) {
106
+ const chunks = [], bufferViews = [], accessors = [];
107
+ let byteOffset = 0;
108
+ const append = (bytes, target) => {
109
+ const id = bufferViews.length;
110
+ bufferViews.push({ buffer: 0, byteOffset, byteLength: bytes.length, ...(target ? { target } : {}) });
111
+ const padded = new Uint8Array(Math.ceil(bytes.length / 4) * 4);
112
+ padded.set(bytes);
113
+ chunks.push(padded);
114
+ byteOffset += padded.length;
115
+ return id;
116
+ };
117
+ const images = source.textures.map(t => ({ ...t.image, bufferView: append(t.bytes) }));
118
+ const materials = [...source.document.materials, { name: 'authored_untextured_wood_interior', doubleSided: false,
119
+ pbrMetallicRoughness: { baseColorFactor: [.18, .095, .035, 1], metallicFactor: 0, roughnessFactor: .9 } }];
120
+ const attribute = (array, type, components, target) => {
121
+ const id = accessors.length, view = append(new Uint8Array(array.buffer), target), count = array.length / components;
122
+ const min = Array.from({ length: components }, () => Infinity), max = min.map(() => -Infinity);
123
+ for (let i = 0; i < array.length; i++) {
124
+ const k = i % components;
125
+ min[k] = Math.min(min[k], array[i]);
126
+ max[k] = Math.max(max[k], array[i]);
127
+ }
128
+ accessors.push({ bufferView: view, componentType: array instanceof Float32Array ? 5126 : 5125, count, type, min, max });
129
+ return id;
130
+ };
131
+ const primitiveFaceCounts = [];
132
+ const meshes = parts.map(part => {
133
+ const groups = [part.faces.filter(f => ['exterior', 'auxiliary'].includes(f.kind)), part.faces.filter(f => ['interior', 'rim'].includes(f.kind))];
134
+ primitiveFaceCounts.push({ exterior: groups[0].length, interiorAndRim: groups[1].length });
135
+ return { name: `split_${part.role}_mesh`, primitives: groups.filter(fs => fs.length).map((fs, group) => {
136
+ const positions = [], normals = [], uvs = [], indices = [], seen = new Map();
137
+ for (const face of fs)
138
+ for (const v of face.v) {
139
+ const p = f32(v.p), n = f32(v.n), uv = v.uv.map(Math.fround), key = [...p, ...n, ...uv].join(',');
140
+ let id = seen.get(key);
141
+ if (id === undefined) {
142
+ id = positions.length / 3;
143
+ seen.set(key, id);
144
+ positions.push(...p);
145
+ normals.push(...n);
146
+ uvs.push(...uv);
147
+ }
148
+ indices.push(id);
149
+ }
150
+ return { attributes: { POSITION: attribute(new Float32Array(positions), 'VEC3', 3, 34962), NORMAL: attribute(new Float32Array(normals), 'VEC3', 3, 34962),
151
+ TEXCOORD_0: attribute(new Float32Array(uvs), 'VEC2', 2, 34962) }, indices: attribute(new Uint32Array(indices), 'SCALAR', 1, 34963), material: group ? materials.length - 1 : source.materialIndex };
152
+ }) };
153
+ });
154
+ const document = { asset: { ...source.document.asset, generator: 'PlayCraft restricted rigid shell preparation' }, buffers: [{ byteLength: byteOffset }], bufferViews, accessors,
155
+ images, textures: source.document.textures ?? [], samplers: source.document.samplers ?? [], materials, meshes,
156
+ nodes: parts.map((part, mesh) => ({ name: `split_${part.role}`, mesh, extras: { playcraftPart: part.role } })), scenes: [{ nodes: parts.map((_, i) => i) }], scene: 0,
157
+ ...(source.document.extensionsUsed ? { extensionsUsed: source.document.extensionsUsed } : {}),
158
+ ...(source.document.extensionsRequired ? { extensionsRequired: source.document.extensionsRequired } : {}), extras: { playcraftParentSourceHash: parentHash, preparation: 'restricted-rigid-shell-v1' } };
159
+ const json = new TextEncoder().encode(JSON.stringify(document)), jsonLength = Math.ceil(json.length / 4) * 4;
160
+ const output = new Uint8Array(12 + 8 + jsonLength + 8 + byteOffset), view = new DataView(output.buffer);
161
+ view.setUint32(0, 0x46546c67, true);
162
+ view.setUint32(4, 2, true);
163
+ view.setUint32(8, output.length, true);
164
+ view.setUint32(12, jsonLength, true);
165
+ view.setUint32(16, 0x4e4f534a, true);
166
+ output.fill(32, 20, 20 + jsonLength);
167
+ output.set(json, 20);
168
+ view.setUint32(20 + jsonLength, byteOffset, true);
169
+ view.setUint32(24 + jsonLength, 0x004e4942, true);
170
+ let offset = 28 + jsonLength;
171
+ for (const chunk of chunks) {
172
+ output.set(chunk, offset);
173
+ offset += chunk.length;
174
+ }
175
+ return { bytes: output, document, primitiveFaceCounts };
176
+ }
@@ -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
+ }
@@ -577,12 +577,22 @@ export function createCodexAppServerAdapter(options) {
577
577
  }
578
578
  }
579
579
  if (method === 'turn/completed') {
580
- const turnId = asRecord(payload.turn).id;
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) {
@@ -13,7 +13,7 @@ export const WORKSPACE_DELIVERY_TOOLS = [
13
13
  type: 'array',
14
14
  items: { type: 'string', enum: DELIVERY_CHANNELS },
15
15
  minItems: 1,
16
- maxItems: 8,
16
+ maxItems: DELIVERY_CHANNELS.length,
17
17
  uniqueItems: true,
18
18
  },
19
19
  },
@@ -76,7 +76,7 @@ export class WorkspaceDeliveryTools {
76
76
  return;
77
77
  }
78
78
  const channels = input.channels === undefined ? ['playcraft'] : input.channels;
79
- if (!Array.isArray(channels) || channels.length < 1 || channels.length > 8
79
+ if (!Array.isArray(channels) || channels.length < 1 || channels.length > DELIVERY_CHANNELS.length
80
80
  || channels.some(channel => !DELIVERY_CHANNELS.includes(channel))
81
81
  || new Set(channels).size !== channels.length) {
82
82
  await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
@@ -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', 'cache-control': 'no-store',
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;