partforge 0.86.0 → 0.88.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.
@@ -1770,7 +1770,7 @@ framing offscreen at a resolution independent of the window size and devicePixel
1770
1770
  for gallery/preview images, where grabbing the live canvas would be capped at the viewer
1771
1771
  pane's pixel size:
1772
1772
 
1773
- - `runtime.captureCurrent({ size = 2048, hideGrid = true, quality = 0.9 } = {}) → string | null` —
1773
+ - `runtime.captureCurrent({ size = 2048, hideGrid = true, quality = 0.9, recenter = false } = {}) → string | null` —
1774
1774
  one offscreen render from the live camera's pose (position, up, and orbit target — not a
1775
1775
  canonical pose) with the live viewport's aspect ratio, `size` px on the long edge
1776
1776
  (clamped into `[256, maxTextureSize]`). Renders with 4× MSAA and the same
@@ -1783,6 +1783,16 @@ pane's pixel size:
1783
1783
  in the scene, so a dimensioned capture needs no special handling — enable measure
1784
1784
  mode (`runtime.measure.setEnabled(true)`) and call `captureCurrent()`; the dims are
1785
1785
  just part of the rendered frame.
1786
+ `recenter: true` centres the part: the capture becomes the largest centred
1787
+ sub-window of the current framing that still holds every visible vertex (equal
1788
+ margins on both axes, rendered at the full `size` resolution through a view
1789
+ offset, so it is a pixel-exact crop of what the user framed — same
1790
+ perspective, no re-encode). The extent is the projection of the actual mesh
1791
+ vertices, not a bounding box, so it is exact at any angle. The framing is
1792
+ kept as-is when the geometry runs past any frame edge (a user who zoomed in
1793
+ past the part's silhouette cropped it on purpose), when it is already centred,
1794
+ or when measurement dimensions are pinned (their labels sit beside the part and
1795
+ could otherwise be cut off).
1786
1796
  - `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
1787
1797
  counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
1788
1798
  for feeding a vision model, not for display; use `captureCurrent` for showcase images.
@@ -1800,7 +1810,7 @@ button are hidden while Sketch (annotate) mode is active, but that only governs
1800
1810
  The ink is a transparent overlay and the WebGL canvas keeps rendering beneath
1801
1811
  it, so a host that calls `runtime.projection.set()` mid-sketch **visibly
1802
1812
  re-frames the 3D view underneath ink the user may still be drawing**: the
1803
- strokes stay where they were laid down while the model shifts out from under
1813
+ elements stay where they were laid down while the model shifts out from under
1804
1814
  them, and the sketch that gets sent is misaligned, not merely mis-labelled.
1805
1815
  Deliberately unguarded, the same way it's always been free to call
1806
1816
  `setCameraState` during Sketch.
@@ -1811,10 +1821,46 @@ Deliberately unguarded, the same way it's always been free to call
1811
1821
  (replays exactly against the build that produced it) and `parts` (pinned to
1812
1822
  the CAD geometry, so it survives a later rebuild's bbox recentring; reread a
1813
1823
  sketch's camera intrinsics from `parts`, not `world`, once the model has been
1814
- rebuilt). `ANNOTATION_VERSION` is **2**: both frames carry
1824
+ rebuilt). `ANNOTATION_VERSION` is **3**: both frames carry
1815
1825
  `projection: "perspective" | "orthographic"`, and under an orthographic camera
1816
1826
  `fov` is `null` while `orthoHeight` gives the frustum's world height instead.
1817
- (v1 had `fov` only, and predates the projection toggle.)
1827
+ (v1 had `fov` only, and predates the projection toggle; v3 replaced the
1828
+ payload's `strokes` array with `elements` — typed pen/line/rect/ellipse
1829
+ shapes rather than raw ink paths — a change orthogonal to this camera block.)
1830
+ The payload is self-describing for LLM consumers: a top-level `summary` joins
1831
+ every element's plain-language `description`, and `frames` is a legend mapping
1832
+ each payload path to its coordinate convention (element `params` are
1833
+ stage-space, anchor `screen`s are per-axis normalized 0..1, descriptions are
1834
+ viewport percentages — `viewport.aspect` bridges them). Each element carries an
1835
+ `id` (`"e1"`, `"e2"`, …) for unambiguous reference in replies, `rotDeg`
1836
+ alongside the radian `rot`, and erased spans rendered into the description in
1837
+ each type's own vocabulary ("erased top edge", "erased arc 36°–126°"); each
1838
+ anchor of a gapped element carries the `run` index of the visible fragment it
1839
+ sits on.
1840
+
1841
+ **Reconstructing rays from a sketch payload.** Every anchor also carries
1842
+ `ray: { origin, dir }` — a pick ray in the **parts frame** (mm origin, unit
1843
+ direction), computed from the live camera at send time and rounded to 4
1844
+ decimals; it is omitted when `camera.parts` is `null` (no meshes at send
1845
+ time — the same condition under which no `hit` can exist). Unlike `hit`,
1846
+ the ray is present even where the stroke crosses empty space, so any anchor
1847
+ can be projected onto a construction plane. For screen points that have no
1848
+ anchor (a circle's rim, a grid over a region), `partforge/oracle` exports
1849
+ `annotationRay(payload, screenOrAnchor, { frame? })` — the same ray,
1850
+ reconstructed from the payload's camera block (perspective and orthographic
1851
+ both) — and `rayPlane(ray, plane)` intersects either kind of ray with
1852
+ `{ point, normal }` or the shorthand origin planes `"xy" | "yz" | "zx"`,
1853
+ returning `{ point, t }` in mm or `null` on a parallel / behind-origin miss
1854
+ (the same miss semantics as `hit: null`). End to end:
1855
+
1856
+ ```js
1857
+ import { annotationRay, rayPlane } from "partforge/oracle";
1858
+ const anchor = payload.elements.find((e) => e.id === "e3")
1859
+ .anchors.find((a) => a.at === "center");
1860
+ const hit = rayPlane(anchor.ray ?? annotationRay(payload, anchor), "xy");
1861
+ // → boss where the sketched circle's center points, on the z=0 plane:
1862
+ // k.prism({ points: circleProfile(r_mm, [hit.point[0], hit.point[1]]), h })
1863
+ ```
1818
1864
 
1819
1865
  **The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
1820
1866
  `class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.86.0",
3
+ "version": "0.88.0",
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",
@@ -1,16 +1,16 @@
1
- // Viewbar chrome for annotation mode: the pencil toggle + contextual actions
2
- // (Undo / Clear / Send) shown while the mode is on. A direct sibling of
3
- // measure-controls.js same no-op-without-button contract, same attribute
4
- // restore discipline on detach. The mode object (annotate-mode.js) owns all
5
- // behavior; this file only puts it on screen. One extra contract: a host whose
6
- // markup HAS the button but whose mount passed no onAnnotationSend gets the
7
- // button hidden entirely (spec: no dead Send) — mount passes mode = null.
1
+ // Viewbar chrome for annotation mode: just the pencil toggle. A direct
2
+ // sibling of measure-controls.js same no-op-without-button contract, same
3
+ // attribute restore discipline on detach. The mode object (annotate-mode.js)
4
+ // owns all behavior; this file only puts the toggle on screen. One extra
5
+ // contract: a host whose markup HAS the button but whose mount passed no
6
+ // onAnnotationSend gets the button hidden entirely (spec: no dead toggle)
7
+ // mount passes mode = null.
8
8
  //
9
- // `send: "host"` drops the Send button from the row and leaves Undo/Clear.
10
- // It is for a host that draws its own send affordance (partforge-cloud pairs
11
- // the sketch with a typed prompt in its own composer, then calls
12
- // runtime.annotate.send()) two Send buttons in two places, one of which
13
- // ignores the typed message, is the failure this avoids.
9
+ // The Undo/Clear/Send actions that used to live in a row beside this button
10
+ // moved to the sketch toolbar (sketch-toolbar.js, spec 2026-08-27) the
11
+ // toolbar OWNS the top of the stage while sketch mode is on, replacing the
12
+ // whole viewbar rather than sharing it. This file no longer knows about
13
+ // Undo/Clear/Send at all.
14
14
  import { attachButtonTooltips } from "../tooltip.js";
15
15
  import { runCleanupSteps, captureAttributes, restoreAttributes } from "../teardown.js";
16
16
 
@@ -19,7 +19,7 @@ const PENCIL_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
19
19
 
20
20
  const noop = () => {};
21
21
 
22
- export function attachAnnotateControls(viewer, mode, { annotate: button } = {}, { tooltip, escapeScope, send = "viewbar" } = {}) {
22
+ export function attachAnnotateControls(viewer, mode, { annotate: button } = {}, { tooltip, escapeScope } = {}) {
23
23
  if (!button) return { detach: noop };
24
24
 
25
25
  const hostAttributes = captureAttributes(button, BUTTON_ATTRIBUTES);
@@ -42,33 +42,8 @@ export function attachAnnotateControls(viewer, mode, { annotate: button } = {},
42
42
  button.setAttribute("aria-pressed", "false");
43
43
  if (!tooltip && !button.hasAttribute("title")) button.title = "Sketch";
44
44
 
45
- const actions = document.createElement("span");
46
- actions.className = "pf-annotate-actions";
47
- const undoButton = document.createElement("button");
48
- undoButton.type = "button";
49
- undoButton.textContent = "Undo";
50
- undoButton.title = "Remove the last stroke";
51
- undoButton.setAttribute("aria-label", "Remove the last stroke");
52
- const clearButton = document.createElement("button");
53
- clearButton.type = "button";
54
- clearButton.textContent = "Clear";
55
- clearButton.title = "Remove all strokes";
56
- clearButton.setAttribute("aria-label", "Remove all strokes");
57
- let sendButton = null;
58
- if (send !== "host") {
59
- sendButton = document.createElement("button");
60
- sendButton.type = "button";
61
- sendButton.className = "pf-annotate-send";
62
- sendButton.textContent = "Send";
63
- sendButton.title = "Send the annotation";
64
- sendButton.setAttribute("aria-label", "Send the annotation");
65
- }
66
- actions.append(...[undoButton, clearButton, sendButton].filter(Boolean));
67
- button.after(actions);
68
-
69
- const buttons = [button, undoButton, clearButton, sendButton].filter(Boolean);
70
45
  const tooltipBinding = tooltip
71
- ? attachButtonTooltips(tooltip, buttons.map((element) => ({ element })))
46
+ ? attachButtonTooltips(tooltip, [{ element: button }])
72
47
  : null;
73
48
 
74
49
  function sync() {
@@ -76,18 +51,10 @@ export function attachAnnotateControls(viewer, mode, { annotate: button } = {},
76
51
  button.setAttribute("aria-pressed", String(on));
77
52
  button.setAttribute("aria-label", on ? "Stop sketching" : "Sketch");
78
53
  button.classList.toggle("on", on);
79
- actions.hidden = !on;
80
- const empty = mode.strokeCount() === 0;
81
- undoButton.disabled = empty;
82
- clearButton.disabled = empty;
83
- if (sendButton) sendButton.disabled = empty;
84
54
  tooltipBinding?.sync();
85
55
  }
86
56
 
87
57
  const onToggle = () => { mode.setEnabled(!mode.isEnabled()); sync(); };
88
- const onUndo = () => { mode.undo(); sync(); };
89
- const onClear = () => { mode.clear(); sync(); };
90
- const onSendClick = () => { mode.send(); sync(); };
91
58
  const onEscape = (event) => {
92
59
  if (event.key !== "Escape" || !mode.isEnabled()) return;
93
60
  event.preventDefault();
@@ -99,14 +66,10 @@ export function attachAnnotateControls(viewer, mode, { annotate: button } = {},
99
66
  sync();
100
67
  tooltipBinding?.hide();
101
68
  };
102
- const offInk = mode.onInkChange(sync);
103
69
  const offMode = mode.onModeChange(sync);
104
70
 
105
71
  button.addEventListener("click", onToggle);
106
- undoButton.addEventListener("click", onUndo);
107
- clearButton.addEventListener("click", onClear);
108
- sendButton?.addEventListener("click", onSendClick);
109
- const escapeTargets = [escapeScope ?? viewer.domElement, ...buttons];
72
+ const escapeTargets = [escapeScope ?? viewer.domElement, button];
110
73
  for (const element of escapeTargets) element.addEventListener("keydown", onEscape);
111
74
  sync();
112
75
 
@@ -116,15 +79,10 @@ export function attachAnnotateControls(viewer, mode, { annotate: button } = {},
116
79
  if (detached) return;
117
80
  detached = true;
118
81
  runCleanupSteps([
119
- offInk,
120
82
  offMode,
121
83
  () => button.removeEventListener("click", onToggle),
122
- () => undoButton.removeEventListener("click", onUndo),
123
- () => clearButton.removeEventListener("click", onClear),
124
- () => sendButton?.removeEventListener("click", onSendClick),
125
84
  ...escapeTargets.map((element) => () => element.removeEventListener("keydown", onEscape)),
126
85
  () => tooltipBinding?.detach(),
127
- () => actions.remove(),
128
86
  () => restoreAttributes(button, hostAttributes),
129
87
  () => { button.innerHTML = hostHtml; },
130
88
  () => button.classList.toggle("on", hostOn),