partforge 0.117.1 → 0.119.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.
@@ -1060,12 +1060,41 @@ export function tilePicker(host) {
1060
1060
  listener you attach yourself with `addEventListener`, rather than through `host.h`,
1061
1061
  is **not** guarded — wire listeners through `host.h`, or wrap your own in try/catch.
1062
1062
 
1063
- **Looking native.** The slot inherits the rail's font, colours and light/dark theme.
1064
- Bare `<button>`, `<input>` and `<select>` elements pick up the built-in looks
1065
- automatically; the built-in classes are available by name for the exact thing:
1063
+ **Looking native.** The slot inherits the rail's font and text colour, and the
1064
+ built-in looks come for free: bare `<button>`, `<input>` and `<select>` elements
1065
+ are already styled, the built-in classes are there by name for the exact thing —
1066
1066
  `row`, `seg` (a segmented row of buttons), `action`, `ghost`, `num`, `text-input`,
1067
- `select-input`; and for SVG, `pf-hit` (clickable, with a `selected` state) and
1068
- `pf-drag`. Sub-controls mounted through `host.controls` *are* the built-in widgets.
1067
+ `select-input` — and sub-controls mounted through `host.controls` *are* the
1068
+ built-in widgets. For SVG there are two classes: `pf-hit` (a clickable region,
1069
+ with a `selected` state) and `pf-drag`.
1070
+
1071
+ **Colour only with the rail's tokens.** Inheriting the rail's colours covers the
1072
+ text you did not style; it does not cover anything you colour yourself. For that,
1073
+ use a `--pf-*` custom property and never a hex literal or a named colour — the
1074
+ tokens flip with the light/dark theme and a literal cannot, so a `#333` border is
1075
+ a widget that is unreadable in one of the two themes. The ones worth knowing:
1076
+ `var(--pf-text)` and `var(--pf-text-2)` for text, `var(--pf-muted)` for a
1077
+ secondary label, `var(--pf-border)` for a rule or an outline,
1078
+ `var(--pf-surface-2)` for a filled chip, `var(--pf-accent)` with
1079
+ `var(--pf-on-accent)` for the selected or primary thing, and `var(--pf-err)` for
1080
+ a problem. That palette is the whole palette: the accent blue for the one thing
1081
+ that is chosen or primary, the surface and border greys for everything else. A
1082
+ widget that reaches past it for a red or a green is a widget that has stopped
1083
+ looking like the rest of the panel.
1084
+
1085
+ **Nest a region's label inside the region.** The common case then needs no colour
1086
+ from you at all: an SVG shape you leave unfilled inherits `var(--pf-text-2)`
1087
+ rather than SVG's own black, a `pf-hit` region is filled and outlined at rest,
1088
+ and a `<text>` *inside* that region stays readable through all three states —
1089
+ the rail gives it `var(--pf-text-2)` at rest and `var(--pf-on-accent)` once the
1090
+ region is selected and has gone solid accent underneath it, and clears the
1091
+ stroke it would otherwise inherit from the region's outline (SVG paints that
1092
+ around every glyph, which at label sizes leaves a pale ghost of the number).
1093
+ Draw the label as a sibling instead and none of that reaches it: a dark number
1094
+ sitting on the selected blue is the result, and you have to colour it yourself.
1095
+ To colour a
1096
+ region from your own data, set an inline `style` — a `fill="…"` attribute loses
1097
+ to the rail's rule.
1069
1098
 
1070
1099
  **Reading the part's own files.** Artwork can live beside the code (the tree is text,
1071
1100
  so an SVG, a `partforge-vector` JSON document or a JSON data file — not a PNG).
@@ -1505,20 +1534,32 @@ its coordinates, and the max that would work — rather than silently clamping:
1505
1534
  |---|---|
1506
1535
  | `filletProfile(input, r, opts?)` | `r`: number, or an array matched positionally with `opts.corners.indices` |
1507
1536
  | `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
1508
- | `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
1537
+ | `profileCorners(input)` | `[{index, position, point, interiorAngleDeg, convex, segTypes}]` — `position` is the entry's place in this list, `index` the joint's vertex number within its contour |
1509
1538
 
1510
1539
  `opts.corners` selects which corners an op touches (default `"all"`):
1511
1540
 
1512
1541
  - `"all"` · `"convex"` · `"concave"`
1513
- - `{indices: [...]}` — positions into `profileCorners(input)`'s own return order; pair
1514
- with an array `r`/`dist` for per-corner radii (the `roundedProfile` pattern)
1515
- - `{near: [x,y], count?: 1}` — nearest-corner selection; the hook for a human pick or
1516
- an agent resolving "the top-left corner" from bbox reasoning
1542
+ - `{indices: [...]}` — each corner's **`position`** in `profileCorners(input)`'s return
1543
+ list, never its `index`; pair with an array `r`/`dist` for per-corner radii (the
1544
+ `roundedProfile` pattern). Any entry out of range throws, naming the range.
1545
+ - `{near: [x,y], count?: 1, within?: mm}` — nearest-corner selection; the hook for a
1546
+ human pick or an agent resolving "the top-left corner" from bbox reasoning. **Without
1547
+ `within` the nearest corner is always selected, however far away** — `count: 4`
1548
+ applied around a small pocket rounds the four corners nearest it, which may all
1549
+ belong to the outer wall. Pass `within` when the pick must be local; a `near` with
1550
+ nothing inside `within` throws instead of reaching further.
1551
+
1552
+ **`index` and `position` are different numbers.** `index` is the vertex number within
1553
+ the contour; `position` is the corner's place in the returned list. They agree only
1554
+ while every joint is a corner — the moment a contour has a smooth joint (a collinear
1555
+ midpoint, a G1 arc-to-line join, a fillet already applied) the vertex numbers skip
1556
+ ahead of the positions, and `.map((c) => c.index)` fed to `{indices}` fillets the
1557
+ wrong corners or throws out of range. Always map to `c.position`.
1517
1558
 
1518
1559
  ```js
1519
1560
  // Fillet only the two corners nearest the profile's top edge, 3mm and 1.5mm:
1520
1561
  const corners = profileCorners(outline);
1521
- const top = corners.filter((c) => c.point[1] > 20).map((c) => c.index);
1562
+ const top = corners.filter((c) => c.point[1] > 20).map((c) => c.position); // position, NOT index
1522
1563
  const rounded = filletProfile(outline, [3, 1.5], { corners: { indices: top } });
1523
1564
 
1524
1565
  // Fillet every convex corner of a Shape2D by the same amount:
@@ -614,9 +614,9 @@ backwards for the holes (see the migration note below).
614
614
  | `simple()` | `toRegions()` unwrapped — throws unless the result is exactly one region. |
615
615
  | `regions()` | Scission: each disjoint region as its own live `Shape2D[]` (each further boolean-able), vs `toRegions()` which returns raw `{outer, holes}` data. Goes through `toRegions()`, so the pieces are tessellated at the backend's LOD — curves do not survive scission. |
616
616
  | `translate([dx,dy])` / `rotate(deg, center?)` / `scale(f\|[sx,sy], center?)` / `mirror(axis)` | Rigid/similarity transforms on the contours (curve-preserving). `center` defaults to the origin; `axis` is `"x"`, `"y"`, or `{point, dir}`. `scale`'s factor is uniform when a bare number, per-axis when `[sx,sy]`. |
617
- | `fillet(r, {corners?})` / `chamfer(d, {corners?})` | Round (true arcs) or bevel (straight chords) selected corners. `corners` = `"all"` (default) / `"convex"` / `"concave"` / `{indices}` / `{near, count?}`; `r`/`d` may be an array paired positionally with `{indices}`. Throws when no corner matches. |
617
+ | `fillet(r, {corners?})` / `chamfer(d, {corners?})` | Round (true arcs) or bevel (straight chords) selected corners. `corners` = `"all"` (default) / `"convex"` / `"concave"` / `{indices}` / `{near, count?, within?}`; `r`/`d` may be an array paired positionally with `{indices}`. `{indices}` entries are each corner's `position` in `corners()` and any entry out of range throws (never silently dropped). `{near}` takes the `count` (default 1) nearest corners; without `within` (mm) the nearest is always selected however far away, with it a pick that finds nothing inside the radius throws. Throws when no corner matches. |
618
618
  | `simplify(tolerance)` | Corner-preserving decimation/refit within `tolerance` mm — dense point rings become fewer segments (and refit arcs/cubics) without moving corners. |
619
- | `corners()` | The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`. This positional order is what `fillet`/`chamfer`'s `{indices}` selects into. |
619
+ | `corners()` | The corner list — `{index, position, point, interiorAngleDeg, convex, segTypes}[]`. `position` is the entry's place in this list and is what `fillet`/`chamfer`'s `{indices}` selects by; `index` is the joint's vertex number within its own contour (what smooth joints are skipped from), and the two diverge past any smooth joint. |
620
620
  | `contains([x,y])` | Point-in-shape test (inside an outer, not inside a hole). |
621
621
  | `isEmpty()` | `true` when the shape has no regions at all — a `cut`/`intersect` legitimately removed everything. Pure JS on the stored IR, backend-identical. See "Empty shapes" below. |
622
622
  | `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` → `Solid`. Throws on an empty shape (see "Empty shapes"). |
@@ -766,7 +766,7 @@ being single-contour by nature, they throw on a region. The full set: `translate
766
766
  | | `mirrorProfile(input, axis)` | `axis: "x" \| "y" \| {point, dir}` |
767
767
  | Corners | `filletProfile(input, r, opts?)` | `r` may be an array paired with `{indices}` |
768
768
  | | `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
769
- | | `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
769
+ | | `profileCorners(input)` | `[{index, position, point, interiorAngleDeg, convex, segTypes}]`; `{indices}` selects by `position` |
770
770
  | Queries | `profileLength(contour)` | mm; single contour only |
771
771
  | | `profilePointAt(contour, {t} \| {length})` | single contour only |
772
772
  | | `profileTangentAt(contour, {t} \| {length})` | unit vector; single contour only |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.117.1",
3
+ "version": "0.119.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",
@@ -387,10 +387,10 @@ button.action:disabled { opacity: .5; cursor: default; }
387
387
  /* Custom controls (type: "custom"): a part-authored widget in the rail. The
388
388
  slot inherits the rail's cascade (font, colours, --pf-* tokens); these rules
389
389
  give BARE elements the built-in controls' look so a widget written with
390
- plain <button>/<input>/<select> still reads as native, and provide two
391
- opt-in classes for SVG: .pf-hit (a clickable region) and .pf-drag (a
392
- dragged one — touch-action: none is what lets a finger drag it on a phone
393
- instead of scrolling the sheet). */
390
+ plain <button>/<input>/<select> still reads as native, supply a themed
391
+ floor for hand-drawn SVG, and provide two opt-in classes for SVG: .pf-hit
392
+ (a clickable region) and .pf-drag (a dragged one — touch-action: none is
393
+ what lets a finger drag it on a phone instead of scrolling the sheet). */
394
394
  .pf-custom { margin: 9px 0; }
395
395
  .pf-custom.hidden { display: none; }
396
396
  .pf-custom-slot { display: flow-root; }
@@ -400,7 +400,12 @@ button.action:disabled { opacity: .5; cursor: default; }
400
400
  like .text-input (0,1,0) or .row .num (0,2,0). At true zero specificity,
401
401
  any class rule wins, so a named built-in class (.seg button, .text-input,
402
402
  select.select-input, …) always beats these bare-element defaults. */
403
- :where(.pf-custom) :where(svg) { display: block; max-width: 100%; height: auto; }
403
+ /* `fill` inherits in SVG, so this is the floor for everything the widget draws
404
+ and did not colour — including <text>. SVG's own initial fill is BLACK, which
405
+ is invisible on the dark theme, and that is what an author gets for free
406
+ unless the rail supplies a token. Inherited rather than matched, so a child
407
+ carrying its own fill="…" attribute still wins. */
408
+ :where(.pf-custom) :where(svg) { display: block; max-width: 100%; height: auto; fill: var(--pf-text-2); }
404
409
  :where(.pf-custom) :where(button) {
405
410
  font: inherit; font-size: 12px; padding: 5px 9px; cursor: pointer;
406
411
  background: transparent; border: 1px solid var(--pf-border); color: var(--pf-text-2);
@@ -417,9 +422,29 @@ button.action:disabled { opacity: .5; cursor: default; }
417
422
  outline: none; border-color: var(--pf-accent);
418
423
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
419
424
  }
420
- .pf-custom .pf-hit { cursor: pointer; }
425
+ /* A region the reader clicks. The RESTING fill and stroke are what make it read
426
+ as a control: without them a .pf-hit polygon is a black slab with no border
427
+ until it is hovered or selected. Zero specificity, so a widget that colours
428
+ regions from its own data still wins — with an inline `style`, since a
429
+ `fill="…"` attribute loses to any rule that matches the element, even this
430
+ one. Hover and selected below keep their own (0,2,x) weight. */
431
+ :where(.pf-custom) :where(.pf-hit) { cursor: pointer; fill: var(--pf-surface-2); stroke: var(--pf-border); }
421
432
  .pf-custom .pf-hit:hover { fill: color-mix(in oklab, var(--pf-accent) 25%, transparent); }
422
- .pf-custom .pf-hit.selected { fill: color-mix(in oklab, var(--pf-accent) 45%, transparent); stroke: var(--pf-accent); }
433
+ /* Selected is the SOLID accent, not a wash of it. A translucent accent lands on a
434
+ different value in each theme — pale over the light background, dark over the
435
+ dark one — so no single label colour is readable on both. Solid accent is one
436
+ colour in both, which is what makes --pf-on-accent below a rule rather than a
437
+ guess. A widget that would rather tint its own region overrides with `style`. */
438
+ .pf-custom .pf-hit.selected { fill: var(--pf-accent); stroke: var(--pf-accent); }
439
+ /* A label NESTED inside the region — the structure to recommend, because it is the
440
+ only one where the rail can keep the text readable as the region changes colour
441
+ underneath it. `fill` inherits, so without these the label takes the region's OWN
442
+ fill: grey on grey at rest, and accent on accent once selected. `stroke` inherits
443
+ too, and that one is worse than it looks: the region's outline colour gets painted
444
+ around every glyph, which at label sizes is most of the glyph — pale ghosts of the
445
+ numbers at rest, blue ones when selected. Hence stroke: none, not just a fill. */
446
+ :where(.pf-custom) :where(.pf-hit) text { fill: var(--pf-text-2); stroke: none; }
447
+ .pf-custom .pf-hit.selected text { fill: var(--pf-on-accent); }
423
448
  .pf-custom .pf-drag { touch-action: none; }
424
449
  .pf-custom-error {
425
450
  margin: 4px 0; padding: 6px 8px; border: 1px solid var(--pf-err); border-radius: var(--pf-radius-control);
@@ -188,15 +188,23 @@ function contourCorners(contour) {
188
188
  return out;
189
189
  }
190
190
 
191
+ // Two numbers on every corner, and they are NOT interchangeable. `index` is the joint's
192
+ // vertex number within its own contour (what buildCornerOpRing, simplify and the lofts
193
+ // key on); `position` is the corner's place in THIS returned list — the one
194
+ // `{corners: {indices}}` selects by. They diverge as soon as a contour has a smooth
195
+ // joint (a collinear midpoint, a G1 arc-line join), and on region input `index`
196
+ // restarts per ring while `position` runs on through the flattened order.
197
+ const withPositions = (corners) => corners.map((c, position) => ({ ...c, position }));
198
+
191
199
  export function profileCorners(input) {
192
200
  const { kind, regions } = liftProfile(input);
193
- if (kind === "points" || kind === "contour") return contourCorners(regions[0].outer);
201
+ if (kind === "points" || kind === "contour") return withPositions(contourCorners(regions[0].outer));
194
202
  const out = [];
195
203
  regions.forEach((rg, regionIndex) => {
196
204
  for (const c of contourCorners(rg.outer)) out.push({ regionIndex, ring: "outer", ...c });
197
205
  rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) out.push({ regionIndex, ring: { hole: hi }, ...c }); });
198
206
  });
199
- return out;
207
+ return withPositions(out);
200
208
  }
201
209
 
202
210
  // ── Fillet / chamfer ─────────────────────────────────────────────────────────
@@ -205,6 +213,19 @@ export function profileCorners(input) {
205
213
  // indexes this array POSITIONALLY, matching profileCorners' documented contract).
206
214
  // r/dist may be an array paired positionally with {indices}; every other selector
207
215
  // broadcasts the scalar to every match. Throws when nothing matches.
216
+ //
217
+ // {indices} REJECTS any entry outside 0…corners.length-1 rather than dropping it.
218
+ // It used to filter silently, and the author who wrote `.map((c) => c.index)` —
219
+ // a vertex number, not a position — then filleted the wrong corners with no error
220
+ // once the out-of-range ones fell away (partforge-cloud feedback #95: ~15 applies
221
+ // chasing an L-bend whose arcs would not come out concentric). A throw that names
222
+ // the two fields is the coaching that ends that loop on the first attempt.
223
+ //
224
+ // {near} takes an optional `within` (mm). WITHOUT it the nearest `count` corners
225
+ // are always selected, however far away — "round the four outer corners" applied per
226
+ // compartment happily rounds the four corners nearest a small pocket that touches
227
+ // none of them. `within` turns a far pick into the same no-match error as any
228
+ // other empty selector.
208
229
  function resolveCornerSelector(corners, param, opts, label) {
209
230
  const sel = (opts && opts.corners) ?? "all";
210
231
  const isArrayParam = Array.isArray(param);
@@ -218,14 +239,28 @@ function resolveCornerSelector(corners, param, opts, label) {
218
239
  const perCorner = isArrayParam ? param : null;
219
240
  if (perCorner && perCorner.length !== sel.indices.length)
220
241
  throw new Error(`${label}: per-corner radius array has ${perCorner.length} entries but {indices} has ${sel.indices.length}`);
221
- picked = sel.indices
222
- .map((idx, j) => ({ corner: corners[idx], param: perCorner ? perCorner[j] : param }))
223
- .filter((p) => p.corner);
242
+ const n = corners.length;
243
+ picked = sel.indices.map((idx, j) => {
244
+ if (!Number.isInteger(idx))
245
+ throw new Error(`${label}: {indices} entry ${idx} is not an integer — {indices} takes each corner's \`position\` from profileCorners()`);
246
+ if (idx < 0 || idx >= n)
247
+ throw new Error(
248
+ `${label}: {indices} entry ${idx} is out of range — profileCorners() reported ${n} corner${n === 1 ? "" : "s"} (positions 0…${Math.max(0, n - 1)}). ` +
249
+ "{indices} takes each corner's `position` (its place in that list), not its `index` (its vertex number within the contour)",
250
+ );
251
+ return { corner: corners[idx], param: perCorner ? perCorner[j] : param };
252
+ });
224
253
  } else if (sel && Array.isArray(sel.near)) {
225
254
  const [nx, ny] = sel.near;
226
255
  const count = sel.count ?? 1;
256
+ const within = sel.within;
257
+ if (within !== undefined && !(Number.isFinite(within) && within > 0))
258
+ throw new Error(`${label}: {near} within must be a positive number of mm (got ${within})`);
227
259
  const distSq = (c) => (c.point[0] - nx) ** 2 + (c.point[1] - ny) ** 2;
228
- picked = corners.slice().sort((a, b) => distSq(a) - distSq(b)).slice(0, count).map((corner) => ({ corner, param }));
260
+ const candidates = within === undefined ? corners.slice() : corners.filter((c) => distSq(c) <= within * within);
261
+ if (candidates.length === 0)
262
+ throw new Error(`${label}: no corner within ${within}mm of (${nx}, ${ny}) — the nearest is ${Math.sqrt(Math.min(...corners.map(distSq))).toFixed(3)}mm away`);
263
+ picked = candidates.sort((a, b) => distSq(a) - distSq(b)).slice(0, count).map((corner) => ({ corner, param }));
229
264
  } else picked = [];
230
265
  if (picked.length === 0) throw new Error(`${label}: no corner matched selector ${JSON.stringify(sel)}`);
231
266
  return picked;
@@ -175,9 +175,11 @@ export function filletProfile(input: ProfileInput, r: number | number[], opts?:
175
175
  export function chamferProfile(input: ProfileInput, dist: number | number[], opts?: { corners?: CornerSelector }): ProfileInput;
176
176
 
177
177
  /**
178
- * The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`,
179
- * plus `{regionIndex, ring}` for region/regions input. This positional order
180
- * is what `filletProfile`/`chamferProfile`'s `{indices}` selects into.
178
+ * The corner list — `{index, position, point, interiorAngleDeg, convex,
179
+ * segTypes}[]`, plus `{regionIndex, ring}` for region/regions input. `position`
180
+ * (the entry's place in this list) is what `filletProfile`/`chamferProfile`'s
181
+ * `{indices}` selects by; `index` is the joint's vertex number within its
182
+ * contour, and the two diverge past any smooth joint.
181
183
  */
182
184
  export function profileCorners(input: ProfileInput): Corner2D[];
183
185
 
package/types/kernel.d.ts CHANGED
@@ -103,8 +103,15 @@ export interface ContourRegion {
103
103
 
104
104
  /** One corner of a `Shape2D`, as `Shape2D.corners()` reports it. */
105
105
  export interface Corner2D {
106
- /** Index of the joint within its own contour. */
106
+ /**
107
+ * The joint's vertex number within its own contour. NOT what `fillet`/
108
+ * `chamfer`'s `{ indices }` takes — that is `position`. The two diverge as
109
+ * soon as a contour has a smooth joint (a collinear midpoint, a G1 arc-line
110
+ * join), and on region input `index` restarts per ring.
111
+ */
107
112
  index: number;
113
+ /** This corner's place in the `corners()` list — what `{ indices }` selects by. */
114
+ position: number;
108
115
  point: Point2;
109
116
  interiorAngleDeg: number;
110
117
  convex: boolean;
@@ -121,9 +128,17 @@ export type CornerSelector =
121
128
  | "all"
122
129
  | "convex"
123
130
  | "concave"
124
- /** Positional indices into `corners()`; a per-corner `r`/`d` array pairs with these. */
131
+ /**
132
+ * Each corner's `position` in `corners()` (never its `index`); a per-corner
133
+ * `r`/`d` array pairs with these. Any entry out of range throws.
134
+ */
125
135
  | { indices: number[] }
126
- | { near: Point2; count?: number };
136
+ /**
137
+ * The `count` (default 1) corners nearest `near`, optionally only those
138
+ * `within` mm of it. Without `within` the nearest corner is always selected,
139
+ * however far away it is.
140
+ */
141
+ | { near: Point2; count?: number; within?: number };
127
142
 
128
143
  /** A mirror line for `Shape2D.mirror`. */
129
144
  export type MirrorAxis2 = "x" | "y" | { point: Point2; dir: Point2 };
@@ -195,7 +210,7 @@ export interface Shape2D {
195
210
  chamfer(d: number | number[], opts?: { corners?: CornerSelector }): Shape2D;
196
211
  /** Corner-preserving decimation/refit within `tolerance` mm. */
197
212
  simplify(tolerance: number): Shape2D;
198
- /** The corner list — the positional order `fillet`/`chamfer`'s `{ indices }` selects into. */
213
+ /** The corner list; each entry's `position` is what `fillet`/`chamfer`'s `{ indices }` selects by. */
199
214
  corners(): Corner2D[];
200
215
  /** Is `[x, y]` inside the shape (inside an outer, not inside a hole)? */
201
216
  contains(p: Point2): boolean;