partforge 0.3.3 → 0.5.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.
@@ -0,0 +1,21 @@
1
+ // Stable content hash for cached solids. Serializes scalar args canonically and
2
+ // folds via FNV-1a → base36. Solid operands are passed as their own (already
3
+ // computed) short `_hash` string, so composing two solids stays O(1) and the
4
+ // resulting key length stays bounded no matter how deep the build graph is.
5
+ export function h(...parts) {
6
+ return fnv(parts.map(canon).join("|"));
7
+ }
8
+
9
+ function canon(x) {
10
+ if (Array.isArray(x)) return "[" + x.map(canon).join(",") + "]";
11
+ if (x && typeof x === "object") return "{" + Object.keys(x).sort().map((k) => k + ":" + canon(x[k])).join(",") + "}";
12
+ return String(x);
13
+ }
14
+
15
+ // FNV-1a folded to 32-bit space (collision risk acceptable: retained only for one
16
+ // sub-part's build graph ~3–15 nodes, rebuilt each round, no accumulation).
17
+ function fnv(s) {
18
+ let hsh = 0x811c9dc5;
19
+ for (let i = 0; i < s.length; i++) { hsh ^= s.charCodeAt(i); hsh = Math.imul(hsh, 0x01000193); }
20
+ return (hsh >>> 0).toString(36);
21
+ }
@@ -42,13 +42,20 @@ export async function handle(kernel, part, msg, post) {
42
42
  try {
43
43
  if (msg.type === "generate") {
44
44
  const t0 = Date.now();
45
+ const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
45
46
  const meshes = [];
47
+ kernel.resetCacheStats?.(); // count hits/misses for just this job
46
48
  for (const name of msg.subparts) {
47
- const m = buildPosed(name, "display", msg.view).toMesh({ quality: "preview" });
48
- meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
49
- kernel.cleanup?.();
49
+ if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
50
+ try {
51
+ const m = buildPosed(name, "display", msg.view).toMesh({ quality: "preview" });
52
+ meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
53
+ } finally {
54
+ if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
55
+ kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
56
+ }
50
57
  }
51
- post({ type: "meshes", meshes, ms: Date.now() - t0 });
58
+ post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() });
52
59
  } else if (msg.type === "export-stl") {
53
60
  const out = [];
54
61
  for (const name of exportSubParts(part, msg.view, p)) {
@@ -3,10 +3,13 @@ import { zipSync } from "fflate";
3
3
  import { createViewer } from "./viewer.js";
4
4
  import { loadRotating, saveRotating, loadCamera, saveCamera, loadView, saveView } from "./view-state.js";
5
5
  import { buildControls } from "./controls.js";
6
- import { relevantParamKeys } from "./param-deps.js";
6
+ import { relevantParamKeys, subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
7
7
  import { createGeometryService } from "./geometry-service.js";
8
8
  import { viewSubParts } from "./jobs.js";
9
9
  import { detectBackend } from "./geometry/probe.js";
10
+ import { createDebugOverlay } from "./debug-overlay.js";
11
+ import { attachPicker, formatSelection } from "./selection/index.js";
12
+ import { createPickRequestClient } from "./pick-request/index.js";
10
13
 
11
14
  // Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
12
15
  // panel + the two geometry workers + the auto-regenerating view/cache loop +
@@ -24,6 +27,15 @@ export function mount(part, { createWorker, container = document.getElementById(
24
27
  if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
25
28
  const backendFor = () => forcedBackend ?? detectBackend(part, params);
26
29
 
30
+ // ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
31
+ const qs = new URLSearchParams(location.search);
32
+ const debug = qs.has("debug");
33
+ let cachingOn = !(debug && qs.has("nocache"));
34
+ let lastGen = { skipped: 0, rebuilt: 0 }; // Layer-1 counts for the most recent generate
35
+ const dbg = debug
36
+ ? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
37
+ : null;
38
+
27
39
  const statusEl = document.getElementById("status");
28
40
  const dlBtn = document.getElementById("download");
29
41
  const dlStepBtn = document.getElementById("download-step");
@@ -56,16 +68,88 @@ export function mount(part, { createWorker, container = document.getElementById(
56
68
  const savedBtn = savedView ? [...partSeg.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === savedView) : null;
57
69
  let view = savedBtn ? savedView : defaultView;
58
70
  if (savedBtn) for (const b of partSeg.children) b.classList.toggle("on", b === savedBtn);
71
+
72
+ // ?pick enables click-to-select: a toggle button + a transient toast. Off by
73
+ // default — no button, no listener, no behavior change. Deleting this block and
74
+ // the selection/ dir reverts the app exactly.
75
+ if (qs.has("pick")) {
76
+ const btn = document.createElement("button");
77
+ btn.id = "pf-pick";
78
+ btn.textContent = "Pick";
79
+ btn.title = "Click a surface to copy a selection token";
80
+ Object.assign(btn.style, {
81
+ position: "fixed", left: "12px", bottom: "12px", zIndex: 9999,
82
+ font: "12px system-ui, sans-serif", padding: "6px 10px", cursor: "pointer",
83
+ });
84
+ document.body.appendChild(btn);
85
+
86
+ const toast = document.createElement("div");
87
+ Object.assign(toast.style, {
88
+ position: "fixed", left: "12px", bottom: "48px", zIndex: 9999, maxWidth: "60ch",
89
+ font: "12px ui-monospace, monospace", padding: "6px 10px", borderRadius: "4px",
90
+ background: "rgba(20,24,29,0.92)", color: "#d8e0ea", display: "none",
91
+ whiteSpace: "pre-wrap", wordBreak: "break-word",
92
+ });
93
+ document.body.appendChild(toast);
94
+
95
+ const picker = attachPicker(viewer, {
96
+ part,
97
+ getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
98
+ onPick: (selection) => {
99
+ const token = formatSelection(selection, { style: "token" });
100
+ navigator.clipboard?.writeText(token);
101
+ toast.textContent = `copied: ${token}`;
102
+ toast.style.display = "block";
103
+ setTimeout(() => { toast.style.display = "none"; }, 4000);
104
+ },
105
+ });
106
+
107
+ btn.addEventListener("click", () => {
108
+ const isActive = btn.classList.toggle("on");
109
+ picker.setActive(isActive);
110
+ btn.style.outline = isActive ? "2px solid #ffcc33" : "";
111
+ });
112
+ } else if (qs.has("pickserver")) {
113
+ // Agent-driven mode: arm the picker only when the local pick-server asks for a
114
+ // click. Mutually exclusive with the clipboard ?pick toggle (else-if), so only one
115
+ // click listener is ever live. `?pickserver` or `?pickserver=http://host:port`.
116
+ const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
117
+ ? qs.get("pickserver") : "http://127.0.0.1:4518";
118
+ createPickRequestClient({
119
+ serverUrl,
120
+ viewer,
121
+ part,
122
+ getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
123
+ });
124
+ }
125
+
59
126
  let framedView = null; // the view the camera was last framed to (null until first show)
60
127
  let cameraRestored = false; // saved camera applied once, on the first frame after load
61
128
  let generating = false;
62
129
  let paramsVersion = 0; // bumped on every settings edit
63
130
  let genVersion = -1; // the params version the in-flight generate is building
64
131
  let genTimer = null; // debounce timer for auto-regenerate
65
- const cacheVersion = Object.fromEntries(names.map((n) => [n, -1])); // params version each was built at
132
+ const cacheHash = {}; // n -> relevance hash each sub-part's cached mesh was built at
133
+
134
+ // Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
135
+ // runs probe builds, so we compute it once per change, not per sub-part.
136
+ let _readsKey = null, _readsMap = null;
137
+ const readsFor = () => {
138
+ const key = `${paramsVersion}|${view}`;
139
+ if (_readsKey !== key) { _readsKey = key; _readsMap = subPartReadKeys(part, view, params); }
140
+ return _readsMap;
141
+ };
142
+ // The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
143
+ // over ALL params, so any edit invalidates it — the safe fallback).
144
+ const hashFor = (n) => {
145
+ if (!cachingOn) return `v${paramsVersion}`; // caching off: any edit invalidates every sub-part (Layer 1 off)
146
+ const reads = readsFor();
147
+ const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(n) ?? Object.keys(params))];
148
+ return relevanceHash(keys, params);
149
+ };
66
150
 
67
- // A cached sub-part is current only if it was built at the latest params version.
68
- const isCurrent = (n) => viewer._subCache[n] && cacheVersion[n] === paramsVersion;
151
+ // A cached sub-part is current only if its relevance hash is unchanged.
152
+ const isCurrent = (n) => !!viewer._subCache[n] && cacheHash[n] === hashFor(n);
69
153
  const missingParts = () => viewSubParts(part, view, params).filter((n) => !isCurrent(n));
70
154
 
71
155
  // Reflect the active view. If every needed part is current, show it and enable
@@ -138,13 +222,14 @@ export function mount(part, { createWorker, container = document.getElementById(
138
222
  for (const m of data.meshes) {
139
223
  if (viewer._subCache[m.name]) { viewer._subCache[m.name].userData.edges?.dispose(); viewer._subCache[m.name].dispose(); }
140
224
  viewer.setSubGeometry(m.name, m);
141
- cacheVersion[m.name] = genVersion;
225
+ cacheHash[m.name] = hashFor(m.name);
142
226
  }
143
227
  hideBusy();
144
228
  refreshView();
145
229
  if (data.ms && missingParts().length === 0) {
146
230
  setStatus(`${statusEl.textContent} · ${(data.ms / 1000).toFixed(1)} s`);
147
231
  }
232
+ dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
148
233
  maybeGenerate(); // active view may still need parts (tab switched during build)
149
234
  break;
150
235
  }
@@ -194,12 +279,22 @@ export function mount(part, { createWorker, container = document.getElementById(
194
279
  // Build whatever the active view is missing — automatic, no Generate button.
195
280
  function maybeGenerate() {
196
281
  if (!kernelReady || generating) return; // retried when the current build finishes
197
- const missing = missingParts();
282
+ const needed = viewSubParts(part, view, params);
283
+ const missing = needed.filter((n) => !isCurrent(n));
198
284
  if (missing.length === 0) return;
199
285
  generating = true;
200
286
  genVersion = paramsVersion;
287
+ lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
201
288
  showBusy("generating");
202
- service.generate({ type: "generate", subparts: missing, view, params }, backendFor());
289
+ service.generate({ type: "generate", subparts: missing, view, params, cache: cachingOn }, backendFor());
290
+ }
291
+
292
+ // Re-run the active view under the current caching setting, so toggling the
293
+ // ?debug switch updates the readout for the same design without a param change.
294
+ function forceRegen() {
295
+ for (const n of viewSubParts(part, view, params)) delete cacheHash[n];
296
+ refreshView();
297
+ maybeGenerate();
203
298
  }
204
299
 
205
300
  partSeg.addEventListener("click", (e) => {
@@ -48,3 +48,34 @@ export function relevantParamKeys(part, view, params) {
48
48
  return RELEVANT_ALL; // couldn't analyze — treat everything as relevant
49
49
  }
50
50
  }
51
+
52
+ // Per-sub-part version of relevantParamKeys: which raw params each ON-SCREEN
53
+ // sub-part of the active view reads. Used by Layer 1 (mount.js) to skip
54
+ // regenerating sub-parts whose inputs are unchanged. Errs to RELEVANT_ALL on any
55
+ // analysis failure (caller then treats every param as relevant — safe, just slower).
56
+ export function subPartReadKeys(part, view, params) {
57
+ try {
58
+ const deriveInputs = new Set();
59
+ const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
60
+ const { kernel } = createProbeKernel();
61
+ const map = new Map();
62
+ for (const name of viewSubParts(part, view, params)) {
63
+ const sp = part.parts[name];
64
+ const reads = new Set();
65
+ const dSeen = new Set();
66
+ if (sp.enabled) sp.enabled(recorder(params, reads)); // gate params change presence too
67
+ sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
68
+ if (dSeen.size > 0) for (const k of deriveInputs) reads.add(k);
69
+ map.set(name, reads);
70
+ }
71
+ return map;
72
+ } catch {
73
+ return RELEVANT_ALL;
74
+ }
75
+ }
76
+
77
+ // Stable string of the given param keys' current values — the cache-validity key
78
+ // for one sub-part. Sorted so key order never affects the result.
79
+ export function relevanceHash(keys, params) {
80
+ return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]));
81
+ }
@@ -0,0 +1,42 @@
1
+ // Pure state machine for one batch of click requests: ordered prompts in,
2
+ // ordered picks out. No http, no DOM, no timers (the server owns the clock).
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ export function createBatch(prompts) {
6
+ return { id: randomUUID(), prompts: [...prompts], picks: [], index: 0, status: "collecting" };
7
+ }
8
+
9
+ export function view(batch) {
10
+ const collecting = batch.status === "collecting";
11
+ return {
12
+ id: batch.id,
13
+ index: batch.index,
14
+ total: batch.prompts.length,
15
+ prompt: collecting ? batch.prompts[batch.index] : null,
16
+ status: batch.status,
17
+ };
18
+ }
19
+
20
+ // Record a click for the current step. Ignores a non-collecting batch or an index
21
+ // that isn't the one we're waiting on (stale/duplicate click guard).
22
+ export function resolve(batch, index, selection) {
23
+ if (batch.status !== "collecting" || index !== batch.index) return batch;
24
+ batch.picks.push({ prompt: batch.prompts[batch.index], selection });
25
+ batch.index += 1;
26
+ if (batch.index >= batch.prompts.length) batch.status = "done";
27
+ return batch;
28
+ }
29
+
30
+ export function cancel(batch) {
31
+ if (batch.status === "collecting") batch.status = "cancelled";
32
+ return batch;
33
+ }
34
+
35
+ export function timeout(batch) {
36
+ if (batch.status === "collecting") batch.status = "timeout";
37
+ return batch;
38
+ }
39
+
40
+ export function result(batch) {
41
+ return { status: batch.status, picks: batch.picks };
42
+ }
@@ -0,0 +1,100 @@
1
+ // Browser side of request-a-pick: subscribe to the pick-server, show a prompt banner,
2
+ // arm the existing picker on demand, and POST each click back. Self-created DOM; the
3
+ // look (position, theme colours, slide-in animation) lives in app.css (#pf-pick-banner),
4
+ // so it follows the app's light/dark theme. Visibility is toggled via display.
5
+ import { attachPicker } from "../selection/pick.js";
6
+
7
+ export function createPickRequestClient({ serverUrl = "http://127.0.0.1:4518", viewer, part, getContext }) {
8
+ let active = null; // { id, index } of the prompt we're waiting on
9
+
10
+ // --- prompt banner: chat-style (avatar + message) ---------------------------
11
+ const banner = document.createElement("div");
12
+ banner.id = "pf-pick-banner";
13
+ banner.style.display = "none";
14
+
15
+ const row = document.createElement("div");
16
+ row.className = "pf-pick-row";
17
+ const avatar = document.createElement("div");
18
+ avatar.className = "pf-pick-avatar";
19
+ avatar.textContent = "🤖";
20
+ const msg = document.createElement("div");
21
+ msg.className = "pf-pick-msg";
22
+ const label = document.createElement("div");
23
+ label.className = "pf-pick-label";
24
+ const prompt = document.createElement("div");
25
+ prompt.className = "pf-pick-prompt";
26
+ msg.append(label, prompt);
27
+ row.append(avatar, msg);
28
+
29
+ const close = document.createElement("button");
30
+ close.id = "pf-pick-close";
31
+ close.type = "button";
32
+ close.textContent = "×";
33
+ close.setAttribute("aria-label", "Dismiss");
34
+
35
+ banner.append(row, close);
36
+ document.body.appendChild(banner);
37
+
38
+ const show = () => { banner.style.display = "block"; };
39
+ const hide = () => { banner.style.display = "none"; };
40
+ const showError = (text) => { label.textContent = ""; prompt.textContent = text; show(); };
41
+
42
+ // Render a prompt as a chat message. The prompt text comes from the agent, so it goes
43
+ // through textContent (never innerHTML) to stay injection-safe.
44
+ const showPrompt = (v) => {
45
+ label.textContent = v.total > 1 ? `Your agent asks: (${v.index + 1} of ${v.total})` : "Your agent asks:";
46
+ prompt.textContent = v.prompt;
47
+ show();
48
+ };
49
+
50
+ const postJson = (path, body) =>
51
+ fetch(`${serverUrl}${path}`, {
52
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
53
+ }).catch(() => showError("⚠ couldn't reach pick-server — click not sent"));
54
+
55
+ const picker = attachPicker(viewer, {
56
+ part, getContext,
57
+ onPick: (selection) => {
58
+ if (!active) return;
59
+ postJson("/resolve", { id: active.id, index: active.index, selection });
60
+ },
61
+ });
62
+
63
+ // The × dismisses the request (cancels the active batch on the server) and hides the
64
+ // banner immediately for responsiveness.
65
+ close.addEventListener("click", () => {
66
+ if (active) postJson("/cancel", { id: active.id });
67
+ hide();
68
+ });
69
+
70
+ // --- dev test button: preview the banner locally, no agent round-trip -------
71
+ const test = document.createElement("button");
72
+ test.id = "pf-pick-test";
73
+ test.type = "button";
74
+ test.textContent = "🤖 test prompt";
75
+ test.title = "Preview the agent prompt banner (local, no agent)";
76
+ test.addEventListener("click", () => {
77
+ if (banner.style.display !== "none") { hide(); return; } // toggle off (and re-arm the animation)
78
+ showPrompt({ index: 0, total: 1, prompt: "Click a face — this is a test prompt" });
79
+ });
80
+ document.body.appendChild(test);
81
+
82
+ const es = new globalThis.EventSource(`${serverUrl}/events`);
83
+ es.addEventListener("prompt", (e) => {
84
+ const v = JSON.parse(e.data);
85
+ active = { id: v.id, index: v.index };
86
+ showPrompt(v);
87
+ picker.setActive(true);
88
+ });
89
+ es.addEventListener("cleared", () => {
90
+ active = null;
91
+ hide();
92
+ picker.setActive(false);
93
+ });
94
+ es.onerror = () => { showError("⚠ agent pick-server not reachable"); };
95
+ es.onopen = () => { if (!active) hide(); };
96
+
97
+ return {
98
+ detach: () => { es.close(); picker.detach(); banner.remove(); test.remove(); },
99
+ };
100
+ }
@@ -0,0 +1 @@
1
+ export { createPickRequestClient } from "./client.js";
@@ -0,0 +1,158 @@
1
+ // src/framework/pick-request/server.js
2
+ // The Node side of request-a-pick: an http+SSE server holding one active batch,
3
+ // a blocking CLI client (requestPicks), and CLI output formatting. 127.0.0.1 only.
4
+ import { createServer, request as httpRequest } from "node:http";
5
+ import { createBatch, view, resolve, cancel, timeout, result } from "./batch.js";
6
+ import { formatSelection } from "../selection/format.js";
7
+
8
+ const json = (res, code, obj, origin) => {
9
+ res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": origin || "*" });
10
+ res.end(JSON.stringify(obj));
11
+ };
12
+ const readBody = (req) => new Promise((resolve_) => {
13
+ let b = "";
14
+ req.on("data", (c) => (b += c));
15
+ req.on("end", () => {
16
+ if (!b) { resolve_({}); return; }
17
+ try { resolve_(JSON.parse(b)); } catch { resolve_({ _parseError: true }); }
18
+ });
19
+ });
20
+
21
+ export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
22
+ let batch = null; // the one active batch (or null)
23
+ let pending = null; // { res, timer } — the held POST /request response
24
+ const sseClients = new Set();
25
+ const allSockets = new Set(); // track every socket for forceful teardown
26
+
27
+ const sse = (event, data) => {
28
+ for (const res of sseClients) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
29
+ };
30
+ const finish = () => { // resolve the held /request with the result
31
+ if (pending) {
32
+ clearTimeout(pending.timer);
33
+ json(pending.res, 200, result(batch));
34
+ pending = null;
35
+ }
36
+ sse("cleared", {});
37
+ batch = null;
38
+ };
39
+
40
+ const server = createServer(async (req, res) => {
41
+ const origin = req.headers.origin;
42
+ if (req.method === "OPTIONS") {
43
+ res.writeHead(204, {
44
+ "access-control-allow-origin": origin || "*",
45
+ "access-control-allow-methods": "GET,POST,OPTIONS",
46
+ "access-control-allow-headers": "content-type",
47
+ });
48
+ return res.end();
49
+ }
50
+ const url = req.url.split("?")[0];
51
+
52
+ if (req.method === "POST" && url === "/request") {
53
+ if (batch) return json(res, 409, { status: "busy" }, origin);
54
+ 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) };
61
+ sse("prompt", view(batch));
62
+ return; // held open until finish()
63
+ }
64
+ if (req.method === "GET" && url === "/events") {
65
+ res.writeHead(200, {
66
+ "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive",
67
+ "access-control-allow-origin": origin || "*",
68
+ });
69
+ res.write(": connected\n\n"); // SSE comment — flushes headers, makes fetch() resolve
70
+ sseClients.add(res);
71
+ if (batch) res.write(`event: prompt\ndata: ${JSON.stringify(view(batch))}\n\n`); // replay current
72
+ req.on("close", () => sseClients.delete(res));
73
+ return;
74
+ }
75
+ if (req.method === "POST" && url === "/resolve") {
76
+ const body = await readBody(req);
77
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
78
+ const { id, index, selection } = body;
79
+ if (batch && id === batch.id) {
80
+ resolve(batch, index, selection);
81
+ if (view(batch).status === "collecting") sse("prompt", view(batch));
82
+ else finish();
83
+ }
84
+ return json(res, 200, { ok: true }, origin);
85
+ }
86
+ if (req.method === "POST" && url === "/cancel") {
87
+ const body = await readBody(req);
88
+ if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
89
+ const { id } = body;
90
+ if (batch && id === batch.id) { cancel(batch); finish(); }
91
+ return json(res, 200, { ok: true }, origin);
92
+ }
93
+ return json(res, 404, { error: "not found" }, origin);
94
+ });
95
+
96
+ server.on("connection", (socket) => {
97
+ allSockets.add(socket);
98
+ socket.on("close", () => allSockets.delete(socket));
99
+ });
100
+
101
+ return {
102
+ start: () => new Promise((res_) => server.listen(port, "127.0.0.1", () => res_({ port: server.address().port }))),
103
+ stop: () => new Promise((res_) => {
104
+ // If a batch is active, cancel it and resolve the held /request cleanly
105
+ // so any awaiting requestPicks() gets a result instead of a socket hang-up.
106
+ if (batch) { cancel(batch); finish(); }
107
+ for (const c of sseClients) c.end();
108
+ server.close(() => res_());
109
+ // destroy any lingering sockets (SSE keep-alive) so server.close resolves
110
+ for (const s of allSockets) s.destroy();
111
+ }),
112
+ };
113
+ }
114
+
115
+ // CLI client: POST the prompts and await the held response (blocks until the batch
116
+ // reaches a terminal status server-side). Fails fast with a hint if nothing answers.
117
+ export function requestPicks({ port = 4518, host = "127.0.0.1", prompts }) {
118
+ return new Promise((resolve_, reject) => {
119
+ const payload = JSON.stringify({ prompts });
120
+ const req = httpRequest(
121
+ { host, port, path: "/request", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } },
122
+ (res) => {
123
+ let b = "";
124
+ res.on("data", (c) => (b += c));
125
+ res.on("end", () => {
126
+ try {
127
+ resolve_(JSON.parse(b));
128
+ } catch {
129
+ reject(new Error(`unexpected response from pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?)`));
130
+ }
131
+ });
132
+ },
133
+ );
134
+ req.on("error", (e) => reject(new Error(`could not reach pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?) — ${e.message}`)));
135
+ req.end(payload);
136
+ });
137
+ }
138
+
139
+ // Human-readable CLI output: one summary line per pick, then the raw JSON to parse.
140
+ export function formatPickResult({ status, picks }) {
141
+ const safePicks = Array.isArray(picks) ? picks : [];
142
+ const lines = [`status: ${status} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
143
+ for (const { prompt, selection } of safePicks) {
144
+ let summary;
145
+ try {
146
+ if (selection && selection.point && selection.normal && selection.params) {
147
+ summary = formatSelection(selection, { style: "prompt" });
148
+ } else {
149
+ summary = selection?.subPart ?? JSON.stringify(selection);
150
+ }
151
+ } catch {
152
+ summary = JSON.stringify(selection);
153
+ }
154
+ lines.push(`• "${prompt}" → ${summary}`);
155
+ }
156
+ lines.push("", JSON.stringify({ status, picks }, null, 2));
157
+ return lines.join("\n");
158
+ }
@@ -0,0 +1,31 @@
1
+ // Pure serializer. No three.js, no DOM. Three styles for the same Selection:
2
+ // token — compact clipboard/CLI line
3
+ // json — the structured object (embedded tool-call transport)
4
+ // prompt — one natural-language sentence an LLM ingests well
5
+ const AXIS_LABEL = { "1,0,0": "+X", "-1,0,0": "-X", "0,1,0": "+Y", "0,-1,0": "-Y", "0,0,1": "+Z", "0,0,-1": "-Z" };
6
+
7
+ const fmtNormal = (n) => AXIS_LABEL[n.join(",")] ?? n.join(",");
8
+ const fmtParams = (p) => Object.entries(p).map(([k, v]) => `${k}:${v}`).join(",");
9
+
10
+ function tokenStyle(s) {
11
+ const head = `@${s.subPart}`;
12
+ const feat = s.feature
13
+ ? ` · ${s.feature.kind === "cylinder"
14
+ ? `cyl-face r=${s.feature.radius} axis=${s.feature.axis}`
15
+ : `${s.feature.kind}-face`}`
16
+ : "";
17
+ return `${head}${feat} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
18
+ }
19
+
20
+ function promptStyle(s) {
21
+ const params = Object.entries(s.params).map(([k, v]) => `${k}: ${v}`).join(", ");
22
+ const feat = s.feature ? ` a ${s.feature.kind} face,` : "";
23
+ return `On sub-part **${s.subPart}**, the user pointed at${feat} local point (${s.point.join(", ")}), `
24
+ + `normal ${fmtNormal(s.normal)}, with params {${params}}.`;
25
+ }
26
+
27
+ export function formatSelection(selection, { style = "token" } = {}) {
28
+ if (style === "json") return selection;
29
+ if (style === "prompt") return promptStyle(selection);
30
+ return tokenStyle(selection);
31
+ }
@@ -0,0 +1,5 @@
1
+ // Public surface for the click-to-select module. The future agent harness depends
2
+ // only on attachPicker's onPick callback + the Selection contract — nothing else.
3
+ export { resolveSelection, quantizePoint, snapNormal } from "./resolve.js";
4
+ export { formatSelection } from "./format.js";
5
+ export { attachPicker, worldToSubPartLocal } from "./pick.js";
@@ -0,0 +1,48 @@
1
+ // Viewer adapter — the ONLY three.js/DOM-aware file in the selection module.
2
+ // Raycasts a click against the visible sub-meshes, converts the hit to the
3
+ // sub-part's local CAD frame, and hands a resolved Selection to onPick.
4
+ import * as THREE from "three";
5
+ import { resolveSelection } from "./resolve.js";
6
+
7
+ // Invert the mesh's world transform (pivot rotation + per-view recentring) to recover
8
+ // shared-frame CAD coords — the same frame build() models in.
9
+ export function worldToSubPartLocal(mesh, world) {
10
+ const v = Array.isArray(world) ? new THREE.Vector3(world[0], world[1], world[2]) : world.clone();
11
+ mesh.worldToLocal(v);
12
+ return [v.x, v.y, v.z];
13
+ }
14
+
15
+ export function attachPicker(viewer, { part, getContext, onPick }) {
16
+ const raycaster = new THREE.Raycaster();
17
+ const ndc = new THREE.Vector2();
18
+ let active = false;
19
+
20
+ function onClick(ev) {
21
+ if (!active) return;
22
+ const rect = viewer.domElement.getBoundingClientRect();
23
+ ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
24
+ ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
25
+ raycaster.setFromCamera(ndc, viewer.camera);
26
+
27
+ const meshes = Object.values(viewer._subMeshes).filter((m) => m.visible);
28
+ const hit = raycaster.intersectObjects(meshes, false)[0];
29
+ if (!hit) return;
30
+
31
+ const selection = resolveSelection(part, getContext(), {
32
+ subPart: hit.object.name,
33
+ pointLocal: worldToSubPartLocal(hit.object, hit.point),
34
+ // face.normal is in the geometry's local frame, which equals the CAD frame here
35
+ // (the mesh carries no local transform; only its parents rotate/recentre).
36
+ normalLocal: hit.face ? [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z] : [0, 0, 0],
37
+ // hit.face metadata (kind/axis/radius) is the L1 increment — not populated yet.
38
+ });
39
+ viewer.flashPoint([hit.point.x, hit.point.y, hit.point.z]);
40
+ onPick(selection);
41
+ }
42
+
43
+ viewer.domElement.addEventListener("click", onClick);
44
+ return {
45
+ setActive: (on) => { active = !!on; },
46
+ detach: () => viewer.domElement.removeEventListener("click", onClick),
47
+ };
48
+ }