partforge 0.118.0 → 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.
@@ -1534,20 +1534,32 @@ its coordinates, and the max that would work — rather than silently clamping:
1534
1534
  |---|---|
1535
1535
  | `filletProfile(input, r, opts?)` | `r`: number, or an array matched positionally with `opts.corners.indices` |
1536
1536
  | `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
1537
- | `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 |
1538
1538
 
1539
1539
  `opts.corners` selects which corners an op touches (default `"all"`):
1540
1540
 
1541
1541
  - `"all"` · `"convex"` · `"concave"`
1542
- - `{indices: [...]}` — positions into `profileCorners(input)`'s own return order; pair
1543
- with an array `r`/`dist` for per-corner radii (the `roundedProfile` pattern)
1544
- - `{near: [x,y], count?: 1}` — nearest-corner selection; the hook for a human pick or
1545
- 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`.
1546
1558
 
1547
1559
  ```js
1548
1560
  // Fillet only the two corners nearest the profile's top edge, 3mm and 1.5mm:
1549
1561
  const corners = profileCorners(outline);
1550
- 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
1551
1563
  const rounded = filletProfile(outline, [3, 1.5], { corners: { indices: top } });
1552
1564
 
1553
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.118.0",
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",
@@ -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;