partforge 0.5.0 → 0.5.2
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/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -198,13 +198,4 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
198
198
|
@keyframes pf-pick-in {
|
|
199
199
|
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
|
200
200
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
201
|
-
}
|
|
202
|
-
/* dev aid (request-a-pick mode): preview the prompt banner without an agent */
|
|
203
|
-
#pf-pick-test {
|
|
204
|
-
position: fixed; left: 12px; bottom: 12px; z-index: 30; appearance: none;
|
|
205
|
-
padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px;
|
|
206
|
-
background: var(--surface); color: var(--muted-2); cursor: pointer;
|
|
207
|
-
font: 12px/1 -apple-system, system-ui, sans-serif;
|
|
208
|
-
box-shadow: 0 6px 24px rgba(0,0,0,.35);
|
|
209
|
-
}
|
|
210
|
-
#pf-pick-test:hover { color: var(--text); border-color: var(--accent); }
|
|
201
|
+
}
|
|
@@ -1,100 +1,65 @@
|
|
|
1
|
-
// Browser side of request-a-pick:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
1
|
+
// Browser side of request-a-pick: drives the shared prompt banner from two sources —
|
|
2
|
+
// an agent asking for clicks over SSE, and a click-to-copy button in the viewbar.
|
|
3
|
+
// Created only under ?pickserver (see mount.js), so both are present exactly when the
|
|
4
|
+
// user is working with an agent.
|
|
5
|
+
import { createPromptBanner } from "./prompt-banner.js";
|
|
6
|
+
import { formatSelection } from "../selection/format.js";
|
|
6
7
|
|
|
7
8
|
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
|
-
};
|
|
9
|
+
let active = null; // { id, index } of the agent prompt we're waiting on
|
|
10
|
+
const banner = createPromptBanner({ viewer, part, getContext });
|
|
49
11
|
|
|
50
12
|
const postJson = (path, body) =>
|
|
51
13
|
fetch(`${serverUrl}${path}`, {
|
|
52
14
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
53
|
-
}).catch(() =>
|
|
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);
|
|
15
|
+
}).catch(() => banner.message("⚠ couldn't reach pick-server — click not sent"));
|
|
81
16
|
|
|
17
|
+
// --- agent prompts over SSE -------------------------------------------------
|
|
82
18
|
const es = new globalThis.EventSource(`${serverUrl}/events`);
|
|
83
19
|
es.addEventListener("prompt", (e) => {
|
|
84
20
|
const v = JSON.parse(e.data);
|
|
85
21
|
active = { id: v.id, index: v.index };
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
22
|
+
banner.request({
|
|
23
|
+
avatar: "🤖",
|
|
24
|
+
label: v.total > 1 ? `Your agent asks: (${v.index + 1} of ${v.total})` : "Your agent asks:",
|
|
25
|
+
text: v.prompt,
|
|
26
|
+
onResolve: (selection) => postJson("/resolve", { id: active.id, index: active.index, selection }),
|
|
27
|
+
onDismiss: () => { if (active) postJson("/cancel", { id: active.id }); },
|
|
28
|
+
});
|
|
93
29
|
});
|
|
94
|
-
es.
|
|
95
|
-
es.
|
|
30
|
+
es.addEventListener("cleared", () => { active = null; banner.dismiss(); });
|
|
31
|
+
es.onerror = () => { banner.message("⚠ agent pick-server not reachable"); };
|
|
32
|
+
es.onopen = () => { if (!active) banner.dismiss(); };
|
|
33
|
+
|
|
34
|
+
// --- click-to-copy button, prepended into the viewbar (left of the play btn) -
|
|
35
|
+
// Copies the SAME description an agent receives for a click, so the two paths match.
|
|
36
|
+
const viewbar = document.getElementById("viewbar");
|
|
37
|
+
let copyBtn = null;
|
|
38
|
+
if (viewbar) {
|
|
39
|
+
copyBtn = document.createElement("button");
|
|
40
|
+
copyBtn.id = "copy";
|
|
41
|
+
copyBtn.type = "button";
|
|
42
|
+
// Flat single-colour copy glyph (inherits the viewbar button's currentColor).
|
|
43
|
+
copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4V9A1.5 1.5 0 0 0 4 10.5H5.5"/></svg>';
|
|
44
|
+
copyBtn.title = "Click a detail to copy its agent description";
|
|
45
|
+
copyBtn.setAttribute("aria-label", "Copy a detail's agent description");
|
|
46
|
+
copyBtn.addEventListener("click", () => {
|
|
47
|
+
if (banner.isOpen()) { banner.dismiss(); return; } // toggle off
|
|
48
|
+
banner.request({
|
|
49
|
+
avatar: "📋",
|
|
50
|
+
label: "Copy to clipboard",
|
|
51
|
+
text: "Click on a detail to copy its agent description to the clipboard.",
|
|
52
|
+
onResolve: (selection) => {
|
|
53
|
+
navigator.clipboard?.writeText(formatSelection(selection, { style: "prompt" }));
|
|
54
|
+
banner.message("✓ Copied to clipboard");
|
|
55
|
+
setTimeout(() => banner.dismiss(), 1000);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
viewbar.prepend(copyBtn);
|
|
60
|
+
}
|
|
96
61
|
|
|
97
62
|
return {
|
|
98
|
-
detach: () => { es.close();
|
|
63
|
+
detach: () => { es.close(); banner.detach(); copyBtn?.remove(); },
|
|
99
64
|
};
|
|
100
65
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A small chat-style prompt banner that arms the selection picker for one click.
|
|
2
|
+
// Shared by request-a-pick's two drivers (an agent asking via SSE, and the
|
|
3
|
+
// click-to-copy button) so they look and behave identically. The look (position,
|
|
4
|
+
// theme colours, slide-in animation) lives in app.css (#pf-pick-banner).
|
|
5
|
+
import { attachPicker } from "../selection/pick.js";
|
|
6
|
+
|
|
7
|
+
export function createPromptBanner({ viewer, part, getContext }) {
|
|
8
|
+
let onResolve = null; // called with the Selection on the next pick
|
|
9
|
+
let onDismiss = null; // called when the user closes the banner via ×
|
|
10
|
+
|
|
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
|
+
const msg = document.createElement("div");
|
|
20
|
+
msg.className = "pf-pick-msg";
|
|
21
|
+
const label = document.createElement("div");
|
|
22
|
+
label.className = "pf-pick-label";
|
|
23
|
+
const prompt = document.createElement("div");
|
|
24
|
+
prompt.className = "pf-pick-prompt";
|
|
25
|
+
msg.append(label, prompt);
|
|
26
|
+
row.append(avatar, msg);
|
|
27
|
+
|
|
28
|
+
const close = document.createElement("button");
|
|
29
|
+
close.id = "pf-pick-close";
|
|
30
|
+
close.type = "button";
|
|
31
|
+
close.textContent = "×";
|
|
32
|
+
close.setAttribute("aria-label", "Dismiss");
|
|
33
|
+
|
|
34
|
+
banner.append(row, close);
|
|
35
|
+
document.body.appendChild(banner);
|
|
36
|
+
|
|
37
|
+
const isOpen = () => banner.style.display !== "none";
|
|
38
|
+
|
|
39
|
+
function dismiss() {
|
|
40
|
+
onResolve = null;
|
|
41
|
+
onDismiss = null;
|
|
42
|
+
picker.setActive(false);
|
|
43
|
+
banner.style.display = "none";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const picker = attachPicker(viewer, {
|
|
47
|
+
part, getContext,
|
|
48
|
+
onPick: (selection) => {
|
|
49
|
+
const resolve = onResolve;
|
|
50
|
+
onResolve = null; // a pick resolves once; a stray click after is a no-op
|
|
51
|
+
picker.setActive(false); // consumer re-arms via request() if more clicks are wanted
|
|
52
|
+
resolve?.(selection);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
close.addEventListener("click", () => {
|
|
57
|
+
const dismissed = onDismiss;
|
|
58
|
+
dismiss();
|
|
59
|
+
dismissed?.();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Show a prompt and arm the picker for one click. Prompt/label text is rendered via
|
|
63
|
+
// textContent (never innerHTML) — it can carry agent-supplied strings.
|
|
64
|
+
function request({ avatar: glyph, label: text, text: body, onResolve: resolve = null, onDismiss: dismissed = null }) {
|
|
65
|
+
onResolve = resolve;
|
|
66
|
+
onDismiss = dismissed;
|
|
67
|
+
avatar.textContent = glyph;
|
|
68
|
+
label.textContent = text || "";
|
|
69
|
+
prompt.textContent = body;
|
|
70
|
+
banner.style.display = "block";
|
|
71
|
+
picker.setActive(true);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Show a transient note (error or confirmation) with no pending pick. Keeps the
|
|
75
|
+
// current avatar so a "✓ Copied" / "⚠ …" note stays in context.
|
|
76
|
+
function message(body) {
|
|
77
|
+
onResolve = null;
|
|
78
|
+
picker.setActive(false);
|
|
79
|
+
label.textContent = "";
|
|
80
|
+
prompt.textContent = body;
|
|
81
|
+
banner.style.display = "block";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
request, message, dismiss, isOpen,
|
|
86
|
+
detach: () => { picker.detach(); banner.remove(); },
|
|
87
|
+
};
|
|
88
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -36,8 +36,10 @@ export function createViewer(container, part) {
|
|
|
36
36
|
const key = new THREE.DirectionalLight(0xffffff, 1.4);
|
|
37
37
|
key.position.set(8, 14, 10);
|
|
38
38
|
scene.add(key);
|
|
39
|
-
// 1 cm grid (mm units):
|
|
40
|
-
|
|
39
|
+
// 1 cm grid (mm units): 300 mm wide, 30 divisions -> 10 mm (1 cm) squares.
|
|
40
|
+
const GRID_SIZE = 300, GRID_DIVS = 30;
|
|
41
|
+
let floorY = 0; // world Y of the grid plane; set to the part's bbox bottom in frameTo
|
|
42
|
+
let grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, THEME.dark.grid[0], THEME.dark.grid[1]);
|
|
41
43
|
scene.add(grid);
|
|
42
44
|
|
|
43
45
|
// --- material + part groups -----------------------------------------------
|
|
@@ -146,6 +148,10 @@ export function createViewer(container, part) {
|
|
|
146
148
|
const center = _box.getCenter(new THREE.Vector3());
|
|
147
149
|
partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
|
|
148
150
|
const size = _box.getSize(new THREE.Vector3());
|
|
151
|
+
// Drop the grid to the bottom of the bounding box (model Z -> world Y), so it reads
|
|
152
|
+
// as a floor the part sits on rather than a plane through its middle.
|
|
153
|
+
floorY = -size.z / 2;
|
|
154
|
+
grid.position.y = floorY;
|
|
149
155
|
const r = Math.max(size.x, size.y, size.z) || 12;
|
|
150
156
|
camera.position.setLength(r * 2.6 + 6);
|
|
151
157
|
controls.target.set(0, 0, 0);
|
|
@@ -179,7 +185,8 @@ export function createViewer(container, part) {
|
|
|
179
185
|
const t = THEME[mode] ?? THEME.dark;
|
|
180
186
|
scene.background = new THREE.Color(t.bg);
|
|
181
187
|
scene.remove(grid);
|
|
182
|
-
grid = new THREE.GridHelper(
|
|
188
|
+
grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, t.grid[0], t.grid[1]);
|
|
189
|
+
grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
|
|
183
190
|
scene.add(grid);
|
|
184
191
|
lineMaterial.color.set(t.line);
|
|
185
192
|
}
|