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.
- package/README.md +5 -0
- package/bin/cli.js +57 -35
- package/docs/AUTHORING-PARTS.md +36 -1
- package/package.json +6 -2
- package/skills/partforge/SKILL.md +58 -0
- package/src/framework/app.css +45 -1
- package/src/framework/debug-overlay.js +40 -0
- package/src/framework/geometry/kernel.js +4 -1
- package/src/framework/geometry/manifold-backend.js +61 -24
- package/src/framework/geometry/occt-backend.js +16 -3
- package/src/framework/geometry/polygon.js +14 -0
- package/src/framework/geometry/probe.js +2 -0
- package/src/framework/geometry/solid-cache.js +39 -0
- package/src/framework/geometry/solid-hash.js +21 -0
- package/src/framework/jobs.js +11 -4
- package/src/framework/mount.js +102 -7
- package/src/framework/param-deps.js +31 -0
- package/src/framework/pick-request/batch.js +42 -0
- package/src/framework/pick-request/client.js +100 -0
- package/src/framework/pick-request/index.js +1 -0
- package/src/framework/pick-request/server.js +158 -0
- package/src/framework/selection/format.js +31 -0
- package/src/framework/selection/index.js +5 -0
- package/src/framework/selection/pick.js +48 -0
- package/src/framework/selection/resolve.js +54 -0
- package/src/framework/viewer.js +15 -2
|
@@ -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
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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)) {
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
|
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
|
|
68
|
-
const isCurrent = (n) => viewer._subCache[n] &&
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|