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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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: 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";
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(() => 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);
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
- showPrompt(v);
87
- picker.setActive(true);
88
- });
89
- es.addEventListener("cleared", () => {
90
- active = null;
91
- hide();
92
- picker.setActive(false);
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.onerror = () => { showError("⚠ agent pick-server not reachable"); };
95
- es.onopen = () => { if (!active) hide(); };
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(); picker.detach(); banner.remove(); test.remove(); },
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
+ }
@@ -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): 200 mm wide, 20 divisions -> 10 mm squares.
40
- let grid = new THREE.GridHelper(200, 20, THEME.dark.grid[0], THEME.dark.grid[1]);
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(200, 20, t.grid[0], t.grid[1]);
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
  }