partforge 0.41.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +100 -27
  3. package/docs/AUTHORING-PARTS.md +126 -14
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +243 -0
  10. package/src/framework/animation.js +217 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +9 -40
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +93 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/{testing → framework/oracle}/bvh.js +1 -1
  33. package/src/{testing → framework/oracle}/measure.js +1 -1
  34. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  35. package/src/{testing → framework/oracle}/verify.js +3 -3
  36. package/src/framework/param-deps.js +1 -1
  37. package/src/framework/part-model.js +48 -0
  38. package/src/framework/pick-request/client.js +11 -3
  39. package/src/framework/pick-request/endpoint.js +60 -0
  40. package/src/framework/pick-request/index.js +6 -0
  41. package/src/framework/pick-request/server.js +222 -34
  42. package/src/framework/pick-request/token-store.js +31 -0
  43. package/src/framework/pose-fast-path.js +12 -1
  44. package/src/framework/pose-probe-core.js +129 -0
  45. package/src/framework/pose-probe.js +7 -123
  46. package/src/framework/regen-loop.js +10 -3
  47. package/src/framework/safe-name.js +26 -0
  48. package/src/framework/verify-metrics.js +4 -4
  49. package/src/framework/view-state.js +25 -21
  50. package/src/framework/view-tabs.js +22 -7
  51. package/src/framework/viewer-controls.js +5 -26
  52. package/src/framework/viewer.js +58 -17
  53. package/src/hinged-box-worker.js +3 -0
  54. package/src/index.js +1 -1
  55. package/src/parts/hinged-box.js +94 -0
  56. package/src/testing/render.js +19 -8
  57. package/src/testing.js +15 -8
  58. package/types/derive.d.ts +14 -0
  59. package/types/geometry.d.ts +117 -0
  60. package/types/index.d.ts +240 -0
  61. package/types/kernel.d.ts +409 -0
  62. package/types/lint.d.ts +85 -0
  63. package/types/part.d.ts +381 -0
  64. package/types/testing.d.ts +362 -0
  65. package/types/worker.d.ts +21 -0
  66. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  67. /package/src/{testing → framework/oracle}/cases.js +0 -0
  68. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  69. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  70. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -1,26 +1,155 @@
1
1
  // src/framework/pick-request/server.js
2
2
  // The Node side of request-a-pick: an http+SSE server holding one active batch,
3
3
  // a blocking CLI client (requestPicks), and CLI output formatting. 127.0.0.1 only.
4
+ //
5
+ // Threat model. While this is listening, any page the developer visits can reach
6
+ // http://127.0.0.1:<port> from their browser. Everything here exists to make that
7
+ // harmless:
8
+ // * a per-process bearer token gates every route (the browser gets it through the
9
+ // app URL, the CLI through ~/.partforge/pick-<port>.token),
10
+ // * Origin is never reflected unless it is loopback, and `*` is never emitted, so
11
+ // a foreign page cannot read the SSE stream or any response,
12
+ // * Host must name loopback, so a rebound DNS name pointing at 127.0.0.1 is refused,
13
+ // * bodies are capped, and
14
+ // * /resolve payloads are shape-checked and stripped of control characters before
15
+ // they can reach the agent's stdout — that print is a prompt-injection channel.
4
16
  import { createServer, request as httpRequest } from "node:http";
17
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
18
  import { createBatch, view, resolve, cancel, timeout, result } from "./batch.js";
6
19
  import { formatSelection } from "../selection/format.js";
20
+ import {
21
+ PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS, PICK_SERVER_DEFAULT_HOST,
22
+ isLoopbackOrigin, isLoopbackHost,
23
+ } from "./endpoint.js";
7
24
 
8
- const json = (res, code, obj, origin) => {
9
- res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": origin || "*" });
25
+ export {
26
+ PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS, PICK_SERVER_DEFAULT_HOST,
27
+ PICK_SERVER_DEFAULT_URL,
28
+ } from "./endpoint.js";
29
+
30
+ const MAX_BODY_BYTES = 256 * 1024; // no route needs more; anything bigger is abuse
31
+ const MAX_SELECTION_CHARS = 16 * 1024;
32
+ const MAX_STRING_CHARS = 512;
33
+ const MAX_PARAM_KEYS = 200;
34
+ const MAX_PROMPTS = 32;
35
+ const MAX_PROMPT_CHARS = 2000;
36
+
37
+ // Control characters (plus the Unicode line separators) are what let injected text
38
+ // forge extra CLI lines in the agent's stdout. Fold them to spaces rather than drop
39
+ // them, so "a\nb" cannot silently become the single token "ab".
40
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
41
+ export const sanitizeText = (s, max = MAX_STRING_CHARS) =>
42
+ String(s).replace(CONTROL_CHARS, " ").slice(0, max);
43
+
44
+ export function mintPickToken() {
45
+ return randomBytes(32).toString("base64url");
46
+ }
47
+
48
+ const tokenMatches = (given, expected) => {
49
+ const a = Buffer.from(typeof given === "string" ? given : "");
50
+ const b = Buffer.from(expected);
51
+ return a.length === b.length && timingSafeEqual(a, b);
52
+ };
53
+
54
+ // EventSource cannot set headers, so the token must also be accepted in the query
55
+ // string; POSTs prefer a header. Both are equally secret — the URL never leaves the
56
+ // machine.
57
+ const tokenFrom = (req, query) => {
58
+ const header = req.headers["x-pick-token"];
59
+ if (typeof header === "string" && header) return header;
60
+ const auth = req.headers.authorization;
61
+ if (typeof auth === "string" && /^bearer\s+/i.test(auth)) return auth.replace(/^bearer\s+/i, "").trim();
62
+ return query.get("token") ?? "";
63
+ };
64
+
65
+ const isVec3 = (v) => Array.isArray(v) && v.length === 3
66
+ && v.every((n) => typeof n === "number" && Number.isFinite(n));
67
+
68
+ // Whitelist the Selection shape resolveSelection() produces. Unknown keys are dropped
69
+ // rather than passed through: whatever survives here is printed to the agent, so the
70
+ // set of things that can reach it must be closed, not open.
71
+ function sanitizeSelection(selection) {
72
+ if (!selection || typeof selection !== "object" || Array.isArray(selection)) {
73
+ return { error: "selection must be an object" };
74
+ }
75
+ let encoded;
76
+ try { encoded = JSON.stringify(selection); } catch { return { error: "selection is not serialisable" }; }
77
+ if (!encoded || encoded.length > MAX_SELECTION_CHARS) return { error: "selection too large" };
78
+ if (typeof selection.subPart !== "string") return { error: "selection.subPart must be a string" };
79
+
80
+ const out = { subPart: sanitizeText(selection.subPart) };
81
+ for (const key of ["point", "normal"]) {
82
+ if (selection[key] === undefined) continue;
83
+ if (!isVec3(selection[key])) return { error: `selection.${key} must be 3 finite numbers` };
84
+ out[key] = [...selection[key]];
85
+ }
86
+ if (selection.params !== undefined) {
87
+ const p = selection.params;
88
+ if (!p || typeof p !== "object" || Array.isArray(p)) return { error: "selection.params must be an object" };
89
+ const keys = Object.keys(p);
90
+ if (keys.length > MAX_PARAM_KEYS) return { error: "selection.params has too many keys" };
91
+ out.params = {};
92
+ for (const k of keys) {
93
+ const v = p[k];
94
+ const ok = (typeof v === "number" && Number.isFinite(v)) || typeof v === "boolean" || v === null
95
+ || typeof v === "string";
96
+ if (!ok) return { error: `selection.params.${sanitizeText(k, 40)} must be a primitive` };
97
+ out.params[sanitizeText(k, 80)] = typeof v === "string" ? sanitizeText(v) : v;
98
+ }
99
+ }
100
+ if (selection.feature !== undefined) {
101
+ const f = selection.feature;
102
+ if (!f || typeof f !== "object" || Array.isArray(f) || typeof f.label !== "string") {
103
+ return { error: "selection.feature must be { label }" };
104
+ }
105
+ out.feature = { label: sanitizeText(f.label) };
106
+ }
107
+ return { selection: out };
108
+ }
109
+
110
+ function sanitizePrompts(prompts) {
111
+ if (!Array.isArray(prompts) || prompts.length === 0) return { error: "prompts must be a non-empty array" };
112
+ if (prompts.length > MAX_PROMPTS) return { error: "prompts must be a non-empty array of at most 32 strings" };
113
+ if (!prompts.every((p) => typeof p === "string")) return { error: "prompts must be a non-empty array of strings" };
114
+ return { prompts: prompts.map((p) => sanitizeText(p, MAX_PROMPT_CHARS)) };
115
+ }
116
+
117
+ const corsHeaders = (allowOrigin) => (allowOrigin
118
+ // `vary` because the same URL answers differently per Origin — never let a cache
119
+ // hand a foreign page a response minted for the local app.
120
+ ? { "access-control-allow-origin": allowOrigin, vary: "origin" }
121
+ : { vary: "origin" });
122
+
123
+ const json = (res, code, obj, allowOrigin) => {
124
+ res.writeHead(code, { "content-type": "application/json", ...corsHeaders(allowOrigin) });
10
125
  res.end(JSON.stringify(obj));
11
126
  };
127
+
12
128
  const readBody = (req) => new Promise((resolve_) => {
13
129
  let b = "";
14
- req.on("data", (c) => (b += c));
130
+ let size = 0;
131
+ let settled = false;
132
+ const settle = (v) => { if (!settled) { settled = true; resolve_(v); } };
133
+ req.on("data", (c) => {
134
+ if (settled) return;
135
+ size += c.length;
136
+ if (size > MAX_BODY_BYTES) { settle({ _tooLarge: true }); return; }
137
+ b += c;
138
+ });
15
139
  req.on("end", () => {
16
- if (!b) { resolve_({}); return; }
17
- try { resolve_(JSON.parse(b)); } catch { resolve_({ _parseError: true }); }
140
+ if (!b) { settle({}); return; }
141
+ try { settle(JSON.parse(b)); } catch { settle({ _parseError: true }); }
18
142
  });
143
+ req.on("error", () => settle({ _parseError: true }));
19
144
  });
20
145
 
21
- export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
146
+ export function createPickServer({
147
+ port = PICK_SERVER_DEFAULT_PORT,
148
+ timeoutMs = PICK_SERVER_DEFAULT_TIMEOUT_MS,
149
+ token = mintPickToken(),
150
+ } = {}) {
22
151
  let batch = null; // the one active batch (or null)
23
- let pending = null; // { res, timer } — the held POST /request response
152
+ let pending = null; // { res, timer, allowOrigin } — the held POST /request response
24
153
  const sseClients = new Set();
25
154
  const allSockets = new Set(); // track every socket for forceful teardown
26
155
 
@@ -30,34 +159,66 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
30
159
  const finish = () => { // resolve the held /request with the result
31
160
  if (pending) {
32
161
  clearTimeout(pending.timer);
33
- json(pending.res, 200, result(batch));
162
+ json(pending.res, 200, result(batch), pending.allowOrigin);
34
163
  pending = null;
35
164
  }
36
165
  sse("cleared", {});
37
166
  batch = null;
38
167
  };
39
168
 
169
+ // The body was over the cap. readBody already stopped buffering, so memory is
170
+ // bounded; drain the rest so the 413 reaches the client cleanly instead of being
171
+ // RST'd away by an immediate destroy, and hang up on anyone still streaming after.
172
+ const tooLarge = (req, res, allowOrigin) => {
173
+ req.resume();
174
+ res.writeHead(413, { "content-type": "application/json", connection: "close", ...corsHeaders(allowOrigin) });
175
+ res.end(JSON.stringify({ error: "body too large" }));
176
+ const kill = setTimeout(() => { if (!req.readableEnded) req.destroy(); }, 250);
177
+ kill.unref?.(); // a pending hang-up must never hold the process open
178
+ req.on("end", () => clearTimeout(kill));
179
+ };
180
+
40
181
  const server = createServer(async (req, res) => {
182
+ const bound = server.address()?.port ?? port;
183
+ // 1. DNS-rebinding guard. A foreign name that resolves to 127.0.0.1 still carries
184
+ // its own Host, so this refuses the request before any state is touched.
185
+ if (!isLoopbackHost(req.headers.host, bound)) return json(res, 400, { error: "bad host" }, null);
186
+
187
+ // 2. Origin. Absent means a native client (the CLI); present must be loopback. An
188
+ // arbitrary origin is never reflected, and `*` is never emitted.
41
189
  const origin = req.headers.origin;
190
+ if (origin !== undefined && !isLoopbackOrigin(origin)) {
191
+ return json(res, 403, { error: "origin not allowed" }, null);
192
+ }
193
+ const allowOrigin = origin ?? null;
194
+
195
+ // 3. Preflight is answered before the token check on purpose: browsers never put
196
+ // credentials on an OPTIONS, and a 401 here would mask the real error.
42
197
  if (req.method === "OPTIONS") {
43
198
  res.writeHead(204, {
44
- "access-control-allow-origin": origin || "*",
199
+ ...corsHeaders(allowOrigin),
45
200
  "access-control-allow-methods": "GET,POST,OPTIONS",
46
- "access-control-allow-headers": "content-type",
201
+ "access-control-allow-headers": "content-type,authorization,x-pick-token",
202
+ "access-control-max-age": "600",
47
203
  });
48
204
  return res.end();
49
205
  }
50
- const url = req.url.split("?")[0];
51
206
 
52
- if (req.method === "POST" && url === "/request") {
53
- if (batch) return json(res, 409, { status: "busy" }, origin);
207
+ const [path, rawQuery = ""] = req.url.split("?");
208
+ // 4. Token on every route.
209
+ if (!tokenMatches(tokenFrom(req, new URLSearchParams(rawQuery)), token)) {
210
+ return json(res, 401, { error: "unauthorized" }, allowOrigin);
211
+ }
212
+
213
+ if (req.method === "POST" && path === "/request") {
214
+ if (batch) return json(res, 409, { status: "busy" }, allowOrigin);
54
215
  const body = await readBody(req);
55
- if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
56
- if (!Array.isArray(body.prompts) || body.prompts.length === 0) {
57
- return json(res, 400, { error: "prompts must be a non-empty array" }, origin);
58
- }
59
- batch = createBatch(body.prompts);
60
- pending = { res, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
216
+ if (body._tooLarge) return tooLarge(req, res, allowOrigin);
217
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, allowOrigin);
218
+ const checked = sanitizePrompts(body.prompts);
219
+ if (checked.error) return json(res, 400, { error: checked.error }, allowOrigin);
220
+ batch = createBatch(checked.prompts);
221
+ pending = { res, allowOrigin, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
61
222
  // A dropped client (Ctrl-C'd CLI) must free the slot immediately, not wedge
62
223
  // every new request behind 409-busy until timeoutMs. Drop `pending` first so
63
224
  // finish() never writes to the dead socket. (This also fires after a normal
@@ -71,10 +232,10 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
71
232
  sse("prompt", view(batch));
72
233
  return; // held open until finish()
73
234
  }
74
- if (req.method === "GET" && url === "/events") {
235
+ if (req.method === "GET" && path === "/events") {
75
236
  res.writeHead(200, {
76
237
  "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive",
77
- "access-control-allow-origin": origin || "*",
238
+ ...corsHeaders(allowOrigin),
78
239
  });
79
240
  res.write(": connected\n\n"); // SSE comment — flushes headers, makes fetch() resolve
80
241
  sseClients.add(res);
@@ -82,25 +243,32 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
82
243
  req.on("close", () => sseClients.delete(res));
83
244
  return;
84
245
  }
85
- if (req.method === "POST" && url === "/resolve") {
246
+ if (req.method === "POST" && path === "/resolve") {
86
247
  const body = await readBody(req);
87
- if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
248
+ if (body._tooLarge) return tooLarge(req, res, allowOrigin);
249
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, allowOrigin);
88
250
  const { id, index, selection } = body;
251
+ if (typeof id !== "string" || !Number.isInteger(index) || index < 0) {
252
+ return json(res, 400, { error: "id must be a string and index a non-negative integer" }, allowOrigin);
253
+ }
254
+ const checked = sanitizeSelection(selection);
255
+ if (checked.error) return json(res, 400, { error: checked.error }, allowOrigin);
89
256
  if (batch && id === batch.id) {
90
- resolve(batch, index, selection);
257
+ resolve(batch, index, checked.selection);
91
258
  if (view(batch).status === "collecting") sse("prompt", view(batch));
92
259
  else finish();
93
260
  }
94
- return json(res, 200, { ok: true }, origin);
261
+ return json(res, 200, { ok: true }, allowOrigin);
95
262
  }
96
- if (req.method === "POST" && url === "/cancel") {
263
+ if (req.method === "POST" && path === "/cancel") {
97
264
  const body = await readBody(req);
98
- if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
265
+ if (body._tooLarge) return tooLarge(req, res, allowOrigin);
266
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, allowOrigin);
99
267
  const { id } = body;
100
268
  if (batch && id === batch.id) { cancel(batch); finish(); }
101
- return json(res, 200, { ok: true }, origin);
269
+ return json(res, 200, { ok: true }, allowOrigin);
102
270
  }
103
- return json(res, 404, { error: "not found" }, origin);
271
+ return json(res, 404, { error: "not found" }, allowOrigin);
104
272
  });
105
273
 
106
274
  server.on("connection", (socket) => {
@@ -109,7 +277,8 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
109
277
  });
110
278
 
111
279
  return {
112
- start: () => new Promise((res_) => server.listen(port, "127.0.0.1", () => res_({ port: server.address().port }))),
280
+ token,
281
+ start: () => new Promise((res_) => server.listen(port, PICK_SERVER_DEFAULT_HOST, () => res_({ port: server.address().port }))),
113
282
  stop: () => new Promise((res_) => {
114
283
  // If a batch is active, cancel it and resolve the held /request cleanly
115
284
  // so any awaiting requestPicks() gets a result instead of a socket hang-up.
@@ -124,15 +293,31 @@ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
124
293
 
125
294
  // CLI client: POST the prompts and await the held response (blocks until the batch
126
295
  // reaches a terminal status server-side). Fails fast with a hint if nothing answers.
127
- export function requestPicks({ port = 4518, host = "127.0.0.1", prompts }) {
296
+ export function requestPicks({
297
+ port = PICK_SERVER_DEFAULT_PORT, host = PICK_SERVER_DEFAULT_HOST, prompts, token,
298
+ }) {
128
299
  return new Promise((resolve_, reject) => {
129
300
  const payload = JSON.stringify({ prompts });
130
301
  const req = httpRequest(
131
- { host, port, path: "/request", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } },
302
+ {
303
+ host,
304
+ port,
305
+ path: "/request",
306
+ method: "POST",
307
+ headers: {
308
+ "content-type": "application/json",
309
+ "content-length": Buffer.byteLength(payload),
310
+ ...(token ? { "x-pick-token": token } : {}),
311
+ },
312
+ },
132
313
  (res) => {
133
314
  let b = "";
134
315
  res.on("data", (c) => (b += c));
135
316
  res.on("end", () => {
317
+ if (res.statusCode === 401) {
318
+ reject(new Error(`pick-server on ${host}:${port} rejected the token (start it with \`partforge pick-serve\`, or pass --token)`));
319
+ return;
320
+ }
136
321
  try {
137
322
  resolve_(JSON.parse(b));
138
323
  } catch {
@@ -147,9 +332,12 @@ export function requestPicks({ port = 4518, host = "127.0.0.1", prompts }) {
147
332
  }
148
333
 
149
334
  // Human-readable CLI output: one summary line per pick, then the raw JSON to parse.
335
+ // Every string here originated in the browser, so it is sanitised again on the way
336
+ // out — the server already stripped control characters, but this print is the last
337
+ // gate before text lands in an agent's context.
150
338
  export function formatPickResult({ status, picks }) {
151
339
  const safePicks = Array.isArray(picks) ? picks : [];
152
- const lines = [`status: ${status} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
340
+ const lines = [`status: ${sanitizeText(status, 40)} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
153
341
  for (const { prompt, selection } of safePicks) {
154
342
  let summary;
155
343
  try {
@@ -161,7 +349,7 @@ export function formatPickResult({ status, picks }) {
161
349
  } catch {
162
350
  summary = JSON.stringify(selection);
163
351
  }
164
- lines.push(`• "${prompt}" → ${summary}`);
352
+ lines.push(`• "${sanitizeText(prompt, MAX_PROMPT_CHARS)}" → ${sanitizeText(summary, MAX_SELECTION_CHARS)}`);
165
353
  }
166
354
  lines.push("", JSON.stringify({ status, picks }, null, 2));
167
355
  return lines.join("\n");
@@ -0,0 +1,31 @@
1
+ // Where `partforge pick-serve` leaves the session token so that `partforge pick`,
2
+ // which runs in a *different* process, can authenticate without the agent copying it
3
+ // by hand. Node-only — never imported by the browser client.
4
+ //
5
+ // The file is the token's only at-rest home: 0600 inside a 0700 directory under the
6
+ // user's home, not a world-readable temp dir, and removed when the server stops.
7
+ import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const dir = () => join(homedir(), ".partforge");
12
+ export const pickTokenPath = (port) => join(dir(), `pick-${port}.token`);
13
+
14
+ export function savePickToken(port, token) {
15
+ mkdirSync(dir(), { recursive: true, mode: 0o700 });
16
+ writeFileSync(pickTokenPath(port), token, { mode: 0o600 });
17
+ return pickTokenPath(port);
18
+ }
19
+
20
+ export function loadPickToken(port) {
21
+ try {
22
+ const t = readFileSync(pickTokenPath(port), "utf8").trim();
23
+ return t || null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function clearPickToken(port) {
30
+ try { rmSync(pickTokenPath(port)); } catch { /* already gone — nothing to clean */ }
31
+ }
@@ -4,7 +4,7 @@
4
4
  // unchanged gets its delivered mesh re-posed in the viewer (delta vs. the
5
5
  // delivered pose) and is re-stamped current — no worker job. Everything else
6
6
  // falls through to the normal regen loop.
7
- import { viewSubParts } from "./jobs.js";
7
+ import { viewSubParts } from "./part-model.js";
8
8
  import { probePoses } from "./pose-probe.js";
9
9
  import { poseDelta } from "./geometry/pose.js";
10
10
 
@@ -28,6 +28,17 @@ export function createPoseFastPath(part, viewer, cache, { params, getView, getPa
28
28
  stamps[name] = probeFor().get(name);
29
29
  },
30
30
 
31
+ // Drop a subpart's stamp: the mesh in the viewer is no longer known to
32
+ // correspond to any probed pose. Used when meshes are SHOWN without being
33
+ // recorded — a build delivered stale because animation frames kept bumping
34
+ // the version is displayed best-effort, but its geometry was not built at
35
+ // the live params, so no stamp may describe it. Without this the next edit
36
+ // would re-pose that newer mesh off the PREVIOUS delivery's stamp, i.e.
37
+ // apply a delta measured against geometry that is no longer on screen.
38
+ forget(name) {
39
+ delete stamps[name];
40
+ },
41
+
31
42
  // Re-pose every visible stale subpart whose base geometry is unchanged.
32
43
  // Returns the NAMES repaired (empty = nothing pose-only to do). Names, not a
33
44
  // count: a slider drag repairs the same subpart on every input event, so only
@@ -0,0 +1,129 @@
1
+ // Geometry-free pose probe: run a subpart's build()+place() against a stub kernel
2
+ // whose token solids carry (a) a content-hash chain built with the shared h() and
3
+ // (b) pending rigid pose steps, mirroring the backends' pose-lazy bookkeeping.
4
+ // The fast path compares probe results ACROSS PARAM CHANGES ONLY — probe hashes
5
+ // are never compared to backend hashes, so they only need to be stable and to
6
+ // fold every geometry-affecting argument.
7
+ //
8
+ // Trust model: any query op (boundingBox/volume/…) during a build marks that
9
+ // subpart untrusted — a query result could feed geometry OR pose, and the probe
10
+ // returns dummies, so neither hash stability nor pose values can be believed.
11
+ // A FUNCTION passed as (or nested inside) an op argument is untrusted for the
12
+ // same reason the OCCT backend refuses to hash function selectors (see `selKey`
13
+ // in occt-backend.js): a closure like `(e) => e.inDirection([0,0,p.z])` has the
14
+ // same source text at every value of `p.z`, so hashing it would hold baseHash
15
+ // stable while the real geometry changed — precisely the false-positive the fast
16
+ // path must never make. Untrusted subparts simply take the normal regen path.
17
+ import { h } from "./geometry/solid-hash.js";
18
+ import { addSugar } from "./geometry/solid-sugar.js";
19
+ import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
20
+ import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
21
+
22
+ const NAN3 = () => [NaN, NaN, NaN];
23
+
24
+ function makeProbeSession() {
25
+ const state = { count: 0, queried: false, unhashable: false };
26
+ const tick = () => { if (++state.count > MAX_PROBE_OPS) throw new ProbeRunawayError(`pose probe exceeded ${MAX_PROBE_OPS} ops`); };
27
+
28
+ // Queries return dummies AND poison trust (see module comment).
29
+ const QUERY_DUMMIES = {
30
+ boundingBox: () => ({ min: NAN3(), max: NAN3() }), // addSugar derives center/size
31
+ volume: () => NaN,
32
+ genus: () => NaN,
33
+ isEmpty: () => false,
34
+ area: () => NaN,
35
+ toRegions: () => [],
36
+ simple: () => ({ outer: [[NaN, NaN]], holes: [] }),
37
+ toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(0), triangles: 1, edges: new Float32Array(0) }),
38
+ toSTL: () => new ArrayBuffer(0),
39
+ toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
40
+ };
41
+
42
+ // Operand tokens fold into a hash key by their own (pose-folded) hash; plain
43
+ // data canonicalizes via h(). Functions can't be hashed at all (see the module
44
+ // comment), so they poison trust AND get a per-call unique key — belt and
45
+ // braces, so the hash can't collide even before the trust check is consulted.
46
+ //
47
+ // The walk mirrors h()'s `canon` exactly (array → elements, other object → own
48
+ // enumerable values), because a function nested inside an options object —
49
+ // `fillet({ r, edges: (e) => … })`, the normal calling convention — is reached
50
+ // by canon, not by the top-level argument check.
51
+ let unhashable = 0;
52
+ const argKey = (a) => {
53
+ if (a && a.__poseToken) return a.__folded();
54
+ if (typeof a === "function") { state.unhashable = true; return `fn#${unhashable++}`; }
55
+ if (Array.isArray(a)) return a.map(argKey);
56
+ if (a && typeof a === "object") return Object.fromEntries(Object.keys(a).map((k) => [k, argKey(a[k])]));
57
+ return a;
58
+ };
59
+
60
+ function token(hash, pose) {
61
+ const folded = () => (pose.length ? h("posed", hash, pose) : hash);
62
+ const foldOp = (op) => (...args) => { tick(); return token(h(op, folded(), ...args.map(argKey)), []); };
63
+ const t = {
64
+ __poseToken: true,
65
+ __folded: folded,
66
+ _hash: hash,
67
+ _pose: pose,
68
+ // The rigid vocabulary stays out of the hash: recorded as pending steps,
69
+ // exactly like the OCCT backend's pose-lazy wrap. All transform sugar
70
+ // (rotateAbout/along/at/rotateX…) composes onto these via addSugar.
71
+ translate: (v) => { tick(); return token(hash, [...pose, { t: "translate", v }]); },
72
+ rotate: (deg, center, axis) => { tick(); return token(hash, [...pose, { t: "rotate", deg, center, axis }]); },
73
+ clone: () => t, // tokens are immutable — sharing is safe
74
+ // regions() is a data-returning query in disguise: on a real backend the
75
+ // scission ARRAY LENGTH is param-dependent data, so a build branching on
76
+ // `regions().length` could hold baseHash stable while geometry changed.
77
+ // It therefore poisons trust like any other query; the single token is
78
+ // still returned so op chains on regions()[0] don't crash mid-probe.
79
+ regions: () => { tick(); state.queried = true; return [token(h("regions", folded()), [])]; },
80
+ };
81
+ for (const [op, dummy] of Object.entries(QUERY_DUMMIES))
82
+ t[op] = (...a) => { tick(); state.queried = true; return dummy(...a); };
83
+ // Every other contract op folds pose + args into a fresh hash. Generated from
84
+ // the kernel-contract lists so new ops can never silently drift out of the probe.
85
+ for (const op of [...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS, ...OCCT_ONLY_OPS])
86
+ t[op] ??= foldOp(op);
87
+ return addSugar(t);
88
+ }
89
+
90
+ // Kernel: catch-all factory — any op makes a fresh token hashed from its args.
91
+ const kernelQueries = {
92
+ toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
93
+ cleanup: () => {}, beginSubPart: () => {}, endSubPart: () => {},
94
+ cacheStats: () => ({ hits: 0, misses: 0 }), resetCacheStats: () => {},
95
+ };
96
+ const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
97
+ const kernel = new Proxy({}, {
98
+ get(_t, key) {
99
+ if (ignore(key)) return undefined;
100
+ if (key in kernelQueries) return kernelQueries[key];
101
+ return (...args) => { tick(); return token(h(key, ...args.map(argKey)), []); };
102
+ },
103
+ });
104
+
105
+ return { kernel, state };
106
+ }
107
+
108
+ const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
109
+ const stepsFinite = (steps) => steps.every((st) =>
110
+ st.t === "translate"
111
+ ? finiteVec(st.v)
112
+ : Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
113
+
114
+ // Probe ONE sub-part's build+place at explicit params/purpose. The shared
115
+ // primitive under probePoses (pose-probe.js) and the lint place/animation
116
+ // rules — kept free of jobs.js imports so the lint import closure stays pure
117
+ // (test/lint-purity.test.js). Never throws; a failing/queried/weird sub-part
118
+ // yields { trusted: false }.
119
+ export function probeSubPartPose(sp, { view, purpose = "display", p, d }) {
120
+ try {
121
+ const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
122
+ let s = sp.build(kernel, p, d);
123
+ if (sp.place) s = sp.place(s, { view, purpose, p, d });
124
+ const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
125
+ return ok ? { trusted: true, baseHash: s._hash, pose: s._pose } : { trusted: false };
126
+ } catch {
127
+ return { trusted: false };
128
+ }
129
+ }