partforge 0.137.1 → 0.138.1

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.
@@ -330,6 +330,33 @@ export const SCHEMA_RULES = [
330
330
  return out;
331
331
  },
332
332
  },
333
+ {
334
+ // The legacy section shape (`advanced`, `toggles`, `features`) gives every entry a
335
+ // fixed widget and never reads `type`: a `{ type: "select", options }` there used to
336
+ // draw only two "unrecognised field" warnings and render as a SLIDER, which then
337
+ // wrote numbers into a key the build compared against strings. `control:` is read,
338
+ // but only knows the slider family. Either way the panel shows the wrong widget, so
339
+ // this is an error, not a warning.
340
+ id: "legacy-shape-widget",
341
+ run: ({ part }) => {
342
+ const LEGACY_CONTROLS = ["slider", "number", "text", "textarea"];
343
+ const out = [];
344
+ for (const { d, path, authored, container } of collectDescriptors(part)) {
345
+ if (authored || container) continue;
346
+ const renders = path.includes(".toggles[") ? "checkbox" : (LEGACY_CONTROLS.includes(d.control) ? d.control : "slider");
347
+ const hint = "Move the section to the `controls` shape (`controls: [...]`), where `type` picks the widget — select, radio, checkbox and readout exist only there.";
348
+ if (typeof d.type === "string")
349
+ out.push(err("legacy-shape-widget",
350
+ `\`type: "${d.type}"\` is not read in the legacy section shape — this control renders as a ${renders}`,
351
+ hint, `${path}.type`));
352
+ if (typeof d.control === "string" && !LEGACY_CONTROLS.includes(d.control))
353
+ out.push(err("legacy-shape-widget",
354
+ `\`control: "${d.control}"\` is not a legacy widget (legacy \`control\` takes ${LEGACY_CONTROLS.join(", ")})`,
355
+ hint, `${path}.control`));
356
+ }
357
+ return out;
358
+ },
359
+ },
333
360
  {
334
361
  id: "unknown-control-type",
335
362
  run: ({ part }) => collectDescriptors(part)
@@ -4,20 +4,42 @@
4
4
  // without dragging in the part-model/jobs layer (purity).
5
5
  import { probeSubPartPose } from "./pose-probe-core.js";
6
6
  import { viewSubParts, resolveParams } from "./part-model.js";
7
+ import { fontsFor } from "./fonts.js";
8
+ import { imagesFor } from "./images.js";
9
+ import { vectorsFor } from "./vectors.js";
10
+ import { h, byteAwareReplacer } from "./geometry/solid-hash.js";
11
+
12
+ // The sources behind every NAMED asset a build can reach. A build says
13
+ // `k.text2d(s, { font: "face" })`, `k.heightfield("img")` or `k.vector2d("art")`,
14
+ // and the probe sees only the name — which stays the same whichever font,
15
+ // image or artwork a control has picked. Without this, a font pick hashed
16
+ // identically before and after, so the fast path re-posed the OLD letters
17
+ // instead of rebuilding them (the Lattice Box's raised text kept its previous
18
+ // typeface while the pocket cut from the same text updated, 2026-10-02). Folded
19
+ // into every trusted sub-part's baseHash: coarser than tracking which sub-part
20
+ // names which asset, and the cost of that is only a rebuild on an asset change.
21
+ // Serialized with byteAwareReplacer, not h(): h() reads an ArrayBuffer as {},
22
+ // so two byte-valued fonts would hash alike — the same bug one level down.
23
+ function assetSources(part, p) {
24
+ return [fontsFor(part, p) ?? null, imagesFor(part, p) ?? null, vectorsFor(part, p) ?? null];
25
+ }
7
26
 
8
27
  // Probe every subpart the view shows. Never throws; a failing/queried/weird
9
28
  // subpart yields { trusted: false } and the others still probe.
10
29
  export function probePoses(part, view, params) {
11
30
  const out = new Map();
12
- let resolved;
13
- try { resolved = resolveParams(part, params); }
14
- catch {
31
+ let resolved, assets;
32
+ try {
33
+ resolved = resolveParams(part, params);
34
+ assets = h("assets", JSON.stringify(assetSources(part, resolved.p), byteAwareReplacer));
35
+ } catch {
15
36
  for (const name of viewSubParts(part, view, params)) out.set(name, { trusted: false });
16
37
  return out;
17
38
  }
18
39
  const { p, d } = resolved;
19
40
  for (const name of viewSubParts(part, view, params)) {
20
- out.set(name, probeSubPartPose(part.parts[name], { view, purpose: "display", p, d }));
41
+ const entry = probeSubPartPose(part.parts[name], { view, purpose: "display", p, d });
42
+ out.set(name, entry.trusted ? { ...entry, baseHash: h("with-assets", entry.baseHash, assets) } : entry);
21
43
  }
22
44
  return out;
23
45
  }
@@ -17,7 +17,9 @@ export default {
17
17
  description: "Axial rise per turn. A coarse pitch on a small major diameter runs the root radius down toward zero, which is why Major diameter starts at 4 mm. The 0.5 mm floor is the ISO fine pitch for the smallest diameter offered here — and a floor is needed, because cost scales with turns = length / pitch." },
18
18
  { key: "length", label: "Threaded length", unit: "mm", min: 5, max: 40, step: 1,
19
19
  description: "Length of the threaded shank, excluding the head. Capped at 40 mm so the worst case reachable from these sliders — 40 mm at a 0.5 mm pitch, 80 turns — stays a couple of seconds of preview rather than minutes." },
20
- { key: "lefthand", label: "Left-hand thread", control: "toggle",
20
+ ],
21
+ toggles: [
22
+ { key: "lefthand", label: "Left-hand thread", on: true,
21
23
  description: "Reverses the helix. Rare outside gas fittings and bicycle pedals." },
22
24
  ],
23
25
  },