partforge 0.124.0 → 0.126.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.
package/README.md CHANGED
@@ -108,6 +108,7 @@ const runtime = mount(part, {
108
108
  },
109
109
  onBuild: ({ status, ms, error }) => {}, // per accepted build: "success" | "error"
110
110
  onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
111
+ pickHint: "Click to edit", // hover-tooltip nudge while onPick is set (null = none)
111
112
  });
112
113
  await runtime.ready; // first successful build (rejects on a first-build error)
113
114
  runtime.setHostPane("rail"); // narrow layout only: show just the controls
@@ -221,7 +222,10 @@ full contract.
221
222
  back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
222
223
  sentence, `token` the compact form, `selection` the raw object. When `onPick` is
223
224
  set, the `?pick` / `?pickserver` URL modes are ignored (one click listener ever
224
- live); hover labels stay always-on.
225
+ live); hover labels stay always-on, and gain a `pickHint` line under the name
226
+ ("Click to edit" by default; pass your own wording, or `null` for none). Hover
227
+ never tints the surface under the pointer — a per-face highlight read as "only
228
+ this spot is clickable".
225
229
 
226
230
  ## Authoring guide
227
231
 
@@ -309,8 +309,14 @@ parts: {
309
309
  meta: { title: "…", units: "mm", environment: "studio" },
310
310
  ```
311
311
 
312
- - `material` — a preset id from the table below. Without one the sub-part keeps
313
- the default look.
312
+ - `material` — a preset id from the table below. Without one (or with one the
313
+ library does not know) the sub-part keeps the CAD view's look — its `color`,
314
+ else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
315
+ (`pla-print`'s finish and layer lines) in that same colour. So an untouched
316
+ part looks printed in realistic mode; name a material when it is made some
317
+ other way. The PLA look is realistic-only: the CAD view, 3MF colours and
318
+ `declaresMaterials` are unaffected, and a part that names no material is not
319
+ treated as declaring one.
314
320
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
315
321
  marked tintable are normally coloured this way (anodizing, plastic, paint);
316
322
  the others have an intrinsic colour (brass) but still accept one.
@@ -2740,7 +2746,10 @@ pane's pixel size:
2740
2746
  target are restored after the render. Measurement-mode dimensions render directly
2741
2747
  in the scene, so a dimensioned capture needs no special handling — enable measure
2742
2748
  mode (`runtime.measure.setEnabled(true)`) and call `captureCurrent()`; the dims are
2743
- just part of the rendered frame.
2749
+ just part of the rendered frame. With the cutaway on, the capture shows the section
2750
+ the way the user sees it — the part clipped, its cut faces hatched — but never the
2751
+ cutaway's translucent plane or its handles, which are controls, not the part (the
2752
+ same holds for `captureViews` and the view style popover's thumbnails).
2744
2753
  `recenter: true` centres the part: the capture becomes the largest centred
2745
2754
  sub-window of the current framing that still holds every visible vertex (equal
2746
2755
  margins on both axes, rendered at the full `size` resolution through a view
@@ -2815,8 +2824,8 @@ addition to) the generated view style button above:
2815
2824
  revert too.
2816
2825
  - `runtime.declaresMaterials` — `true` when any sub-part names a
2817
2826
  `display.material`. A part with none still supports realistic mode (every
2818
- sub-part just renders under the library's `default` look), so use this to
2819
- decide whether to surface your own realistic control at all, not whether it works.
2827
+ sub-part renders as a PLA print in its CAD colour), so use this to decide
2828
+ whether to surface your own realistic control at all, not whether it works.
2820
2829
  Feature lines are not a preference: they draw whenever `runtime.renderMode`
2821
2830
  reads `"cad"` and never while it reads `"realistic"` — there is no switch
2822
2831
  to drive independently of it.
@@ -3302,7 +3311,8 @@ nothing either way and stays silent, matching `animation-track-rebuilds`'s own
3302
3311
  trust handling.
3303
3312
 
3304
3313
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3305
- library does not know; the viewer shows the default look), `unknown-environment`
3314
+ library does not know; the viewer draws it as if it named none — a PLA print
3315
+ in realistic mode), `unknown-environment`
3306
3316
  (`meta.environment` not known; realistic mode uses `studio`),
3307
3317
  `material-key-unknown` (a `display` key that is not colour, opacity, material
3308
3318
  or one of the six overrides; ignored), `material-override-clamped` (an override
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.124.0",
3
+ "version": "0.126.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",
@@ -1039,6 +1039,10 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
1039
1039
  .pf-hover-tip .pf-hover-sub { color: var(--pf-muted); font-size: 11px; margin-left: 7px; }
1040
1040
  #pf-hover-tip .pf-hover-sub:empty,
1041
1041
  .pf-hover-tip .pf-hover-sub:empty { display: none; }
1042
+ #pf-hover-tip .pf-hover-hint,
1043
+ .pf-hover-tip .pf-hover-hint { color: var(--pf-muted); font-size: 10.5px; margin-top: 1px; }
1044
+ #pf-hover-tip .pf-hover-hint:empty,
1045
+ .pf-hover-tip .pf-hover-hint:empty { display: none; }
1042
1046
 
1043
1047
  /* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
1044
1048
  laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
@@ -144,6 +144,29 @@ export function createCutaway({
144
144
  handleHoverSubscribers.clear();
145
145
  }
146
146
 
147
+ // "What the section looks like changed": the mode went on or off, or the
148
+ // plane moved (a drag, flip, reset or restore). For anything that caches a
149
+ // picture of the part — the view style popover's thumbnails — which would
150
+ // otherwise keep showing a section the user has since moved or closed.
151
+ // Fires often during a drag, so a listener must be cheap; no argument, since
152
+ // every caller only needs to know that it happened. A throwing listener is
153
+ // reported, never allowed to interrupt the controller work that fired it.
154
+ const changeListeners = new Set();
155
+ function notifyChange() {
156
+ for (const listener of [...changeListeners]) {
157
+ try {
158
+ listener();
159
+ } catch (error) {
160
+ try { console.error("Cutaway change listener failed", error); } catch { /* reporting only */ }
161
+ }
162
+ }
163
+ }
164
+ function onChange(listener) {
165
+ if (disposed || typeof listener !== "function") return () => {};
166
+ changeListeners.add(listener);
167
+ return () => changeListeners.delete(listener);
168
+ }
169
+
147
170
  function selected(name) {
148
171
  return selectedNames == null || selectedNames.has(name);
149
172
  }
@@ -213,6 +236,7 @@ export function createCutaway({
213
236
  gizmo.setFlipped(flipped);
214
237
  gizmo.setPose(pose);
215
238
  if (activeAppearance) showActive();
239
+ notifyChange();
216
240
  }
217
241
 
218
242
  function onPoseChange(nextPose) {
@@ -314,6 +338,7 @@ export function createCutaway({
314
338
  } finally {
315
339
  disabling = false;
316
340
  }
341
+ if (wasEnabled) notifyChange();
317
342
  if (firstError) throw firstError;
318
343
  return true;
319
344
  }
@@ -340,6 +365,7 @@ export function createCutaway({
340
365
  gizmo.setVisible(true);
341
366
  gizmo.updateForCamera();
342
367
  showActive();
368
+ notifyChange(); // applyPose above fired too, but before `enabled` meant on
343
369
  return true;
344
370
  }
345
371
 
@@ -360,6 +386,7 @@ export function createCutaway({
360
386
  planeFromPose(plane, planeNormal, pose.position, pose.quaternion, flipped);
361
387
  gizmo.setFlipped(flipped);
362
388
  showActive();
389
+ notifyChange();
363
390
  return true;
364
391
  }
365
392
 
@@ -579,6 +606,7 @@ export function createCutaway({
579
606
  publishHandleHover(null);
580
607
  drainHandleHoverPublications();
581
608
  clearHandleHoverSubscribers();
609
+ changeListeners.clear();
582
610
  attempt(() => overlayScene.clear());
583
611
  attempt(() => capGeometry.dispose());
584
612
  if (firstError) throw firstError;
@@ -605,6 +633,11 @@ export function createCutaway({
605
633
  updateForCamera,
606
634
  renderOverlay,
607
635
  onHandleHoverChange,
636
+ onChange,
637
+ // The gizmo's in-scene half (the ghost plane and its border). It is
638
+ // interactive UI drawn in the part's own scene, so the viewer keeps it out
639
+ // of every offscreen render (viewer.js's captureHidden).
640
+ captureExcluded: [gizmo.group],
608
641
  setCamera(next) {
609
642
  if (!next) return;
610
643
  camera = next;
@@ -69,8 +69,9 @@ function weld(positions, indices) {
69
69
  }
70
70
 
71
71
  // Default for an uncoloured object in an export where some other object IS
72
- // coloured: the viewer's own default look (presets.js "default"). Written as a
73
- // literal so this module keeps its fflate-only import closure.
72
+ // coloured: the viewer's own no-material colour (resolve.js
73
+ // NO_MATERIAL_COLOR). Written as a literal so this module keeps its
74
+ // fflate-only import closure; viewer-materials.test.js pins the copies equal.
74
75
  const DEFAULT_DISPLAY_COLOR = 0x9fb4cc;
75
76
  // Base-material group id: outside the object id range (objects are 1..n).
76
77
  const BASE_MATERIALS_ID = 1000;
@@ -3,12 +3,12 @@
3
3
  // block geometry, and the viewer already falls back (resolve.js). Pure — imports
4
4
  // only the three-free materials data (test/lint-purity.test.js).
5
5
  import { warn } from "./finding.js";
6
- import { PRESETS, DEFAULT_PRESET_ID } from "../materials/presets.js";
6
+ import { PRESETS } from "../materials/presets.js";
7
7
  import { ENVIRONMENTS } from "../materials/environments.js";
8
8
  import { resolveMaterial, resolveEnvironmentId, OVERRIDE_RANGES } from "../materials/resolve.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
10
 
11
- const listedPresets = () => Object.keys(PRESETS).filter((id) => id !== DEFAULT_PRESET_ID);
11
+ const listedPresets = () => Object.keys(PRESETS);
12
12
  const subParts = (part) => Object.entries(part?.parts ?? {}).filter(([, sp]) => sp && typeof sp === "object");
13
13
  const issuesOf = (part, kind) => subParts(part).flatMap(([name, sp]) =>
14
14
  resolveMaterial(sp.display).issues.filter((i) => i.kind === kind).map((i) => ({ name, ...i })));
@@ -20,7 +20,7 @@ export const MATERIAL_RULES = [
20
20
  const near = typeof value === "string" ? suggest(value, listedPresets()) : null;
21
21
  return warn("unknown-material",
22
22
  `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
23
- `${near ? `Did you mean "${near}"? ` : ""}The viewer shows the default look instead. Known materials: ${listedPresets().join(", ")}.`,
23
+ `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (a PLA print in realistic mode). Known materials: ${listedPresets().join(", ")}.`,
24
24
  `parts.${name}.display.material`);
25
25
  }),
26
26
  },
@@ -110,7 +110,12 @@ export function createContactShadow({ renderer, sizeMm = 400 }) {
110
110
  // While a part moves (lowRes) only the soft layer is redrawn and the tight
111
111
  // one is hidden: an outline left where the part WAS would be wrong, and the
112
112
  // settle render that follows brings it back.
113
- function render(scene, casters, { lowRes = false } = {}) {
113
+ //
114
+ // `clippingPlanes` (world space) clip the casters the way the cutaway clips
115
+ // what is on screen. The depth pass draws every caster with the one override
116
+ // material, which carries none of the casters' own clipping planes, so
117
+ // without this the half the cutaway removed still cast its shadow.
118
+ function render(scene, casters, { lowRes = false, clippingPlanes = null } = {}) {
114
119
  const bg = scene.background;
115
120
  const overrideBefore = scene.overrideMaterial;
116
121
  const clear = renderer.getClearAlpha();
@@ -123,6 +128,7 @@ export function createContactShadow({ renderer, sizeMm = 400 }) {
123
128
  renderer.setClearAlpha(0);
124
129
  for (const l of layers) {
125
130
  if (lowRes && l.spec.name === "tight") continue;
131
+ setClipping(l.depthMaterial, clippingPlanes);
126
132
  scene.overrideMaterial = l.depthMaterial;
127
133
  renderer.setRenderTarget(l.rt);
128
134
  renderer.render(scene, cam);
@@ -143,6 +149,27 @@ export function createContactShadow({ renderer, sizeMm = 400 }) {
143
149
  }
144
150
  }
145
151
 
152
+ // A new plane count needs a new program; the same planes moved do not (three
153
+ // reads their values every frame).
154
+ //
155
+ // A clipped pass also draws BOTH sides, depth-tested. The pass looks UP at
156
+ // the casters, and a cut that removes a part's underside (a kept side facing
157
+ // up, e.g. a top-view cut after Flip) leaves no downward-facing faces at the
158
+ // cut — from below only back faces remain, which FrontSide culls, and the
159
+ // shadow shrinks to a faint ring of side walls. The interior back faces fill
160
+ // the silhouette instead, and the depth test keeps the LOWEST surface under
161
+ // each texel (the one the ground's darkness is measured from) rather than
162
+ // whichever face drew last. Unclipped, the pass is exactly as before:
163
+ // front faces, no depth test.
164
+ function setClipping(material, planes) {
165
+ const next = planes?.length ? planes : null;
166
+ const side = next ? THREE.DoubleSide : THREE.FrontSide;
167
+ if ((material.clippingPlanes?.length ?? 0) !== (next?.length ?? 0) || material.side !== side) material.needsUpdate = true;
168
+ material.clippingPlanes = next;
169
+ material.side = side;
170
+ material.depthTest = material.depthWrite = !!next;
171
+ }
172
+
146
173
  return {
147
174
  group,
148
175
  render,
@@ -8,15 +8,12 @@
8
8
  // converted to sRGB. Adding a preset is one record here plus a look at the
9
9
  // contact sheet (materials.html) in every environment.
10
10
 
11
- export const DEFAULT_PRESET_ID = "default";
12
-
13
11
  const P = (id, label, category, use, fields) => ({ id, label, category, use, tintable: false, ...fields });
14
12
 
15
13
  export const PRESETS = {
16
- // The viewer's look before this library existed. Not documented as a choice:
17
- // it is what a sub-part with no `material` renders as.
18
- default: P("default", "Default", "default", "The viewer's neutral blue-grey, used when a sub-part names no material.",
19
- { color: 0x9fb4cc, metalness: 0.25, roughness: 0.55 }),
14
+ // Every record here is a documented, nameable material. A sub-part that names
15
+ // none is drawn by resolve.js (the blue-grey CAD look; a PLA print in that
16
+ // colour in realistic mode) — there is no hidden "default" record any more.
20
17
 
21
18
  "machined-aluminum": P("machined-aluminum", "Machined aluminium", "aluminium", "Bare CNC-milled aluminium, fine tool marks, satin sheen.",
22
19
  { color: 0xf5f6f6, metalness: 1, roughness: 0.32 }),
@@ -1,10 +1,11 @@
1
1
  // src/framework/materials/resolve.js
2
2
  // Pure resolution of a sub-part's `display` block against the library. Never
3
- // throws and never rejects a value: an unknown preset falls back to the default,
3
+ // throws and never rejects a value: an unknown preset falls back to the
4
+ // no-material look (below),
4
5
  // an out-of-range override clamps, an unknown key is ignored — and every one of
5
6
  // those is returned as an `issue` so lint can warn about it. Appearance must
6
7
  // never be able to fail a build.
7
- import { PRESETS, DEFAULT_PRESET_ID } from "./presets.js";
8
+ import { PRESETS } from "./presets.js";
8
9
  import { ENVIRONMENTS, DEFAULT_ENVIRONMENT_ID } from "./environments.js";
9
10
 
10
11
  export const OVERRIDE_RANGES = {
@@ -20,16 +21,34 @@ export const DISPLAY_KEYS = ["color", "opacity", "material", ...Object.keys(OVER
20
21
 
21
22
  const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
22
23
 
24
+ // A sub-part with no usable material — none named, or one the library does not
25
+ // know. The CAD view draws it in the viewer's neutral blue-grey drafting look
26
+ // (the look every part had before the library existed; viewer.js's base
27
+ // material and threemf.js's uncoloured-object colour are the same literal).
28
+ // Realistic mode draws it as a PLA print — the pla-print finish, layer lines
29
+ // and all — in that same colour, or the part's own `color`, so a part is the
30
+ // same colour in both views, just printed. `declaresMaterials` still counts
31
+ // only a NAMED material: this default never makes a part "declare" one.
32
+ export const NO_MATERIAL_COLOR = 0x9fb4cc;
33
+ export const NO_MATERIAL_PRESET_ID = "pla-print";
34
+ const NO_MATERIAL_CAD = { color: NO_MATERIAL_COLOR, metalness: 0.25, roughness: 0.55 };
35
+
36
+ const knownMaterial = (m) => typeof m === "string" && Object.hasOwn(PRESETS, m);
37
+
23
38
  export function resolveMaterial(display) {
24
39
  const d = display && typeof display === "object" ? display : {};
25
40
  const issues = [];
26
- let preset = PRESETS[DEFAULT_PRESET_ID];
27
- if (d.material != null) {
28
- if (typeof d.material === "string" && Object.hasOwn(PRESETS, d.material)) preset = PRESETS[d.material];
29
- else issues.push({ kind: "unknown-material", key: "material", value: d.material });
30
- }
41
+ if (d.material != null && !knownMaterial(d.material)) issues.push({ kind: "unknown-material", key: "material", value: d.material });
42
+ const preset = knownMaterial(d.material) ? PRESETS[d.material] : PRESETS[NO_MATERIAL_PRESET_ID];
43
+ const baseColor = knownMaterial(d.material) ? preset.color : NO_MATERIAL_COLOR;
44
+ return { preset, ...applyDisplay(d, preset, baseColor, issues) };
45
+ }
46
+
47
+ // A preset record's numbers with the display block's colour, opacity and clamped
48
+ // overrides applied. Pushes onto `issues`; returns them with the params.
49
+ function applyDisplay(d, preset, baseColor, issues) {
31
50
  const params = {
32
- color: preset.color,
51
+ color: baseColor,
33
52
  metalness: preset.metalness,
34
53
  roughness: preset.roughness,
35
54
  clearcoat: preset.clearcoat ?? 0,
@@ -63,7 +82,7 @@ export function resolveMaterial(display) {
63
82
  }
64
83
  // `tinted`: the author named a colour. A textured preset's colour map carries
65
84
  // its own colour, so only an explicit tint multiplies it (physical.js).
66
- return { preset, params, issues, tinted: isColor(d.color) };
85
+ return { params, issues, tinted: isColor(d.color) };
67
86
  }
68
87
 
69
88
  // CAD-view appearance: the resolved colour on the CAD MeshStandardMaterial,
@@ -73,11 +92,15 @@ export function resolveMaterial(display) {
73
92
  const MIN_CAD_LUMA = 60; // 0..255, Rec. 709
74
93
  export function cadAppearance(display) {
75
94
  const d = display && typeof display === "object" ? display : {};
76
- const { params } = resolveMaterial(d);
77
95
  if (d.material == null) {
78
- // Byte-for-byte today's behaviour for a part without `material`.
79
- return { color: params.color, metalness: 0.25, roughness: 0.55, opacity: params.opacity };
96
+ // The drafting look, untouched by overrides — as it always was.
97
+ const { params } = resolveMaterial(d);
98
+ return { color: params.color, metalness: NO_MATERIAL_CAD.metalness, roughness: NO_MATERIAL_CAD.roughness, opacity: params.opacity };
80
99
  }
100
+ // An unknown material flattens the drafting look rather than the realistic
101
+ // PLA default, exactly as it did when it fell back to the retired "default"
102
+ // preset.
103
+ const { params } = knownMaterial(d.material) ? resolveMaterial(d) : applyDisplay(d, NO_MATERIAL_CAD, NO_MATERIAL_COLOR, []);
81
104
  return {
82
105
  color: liftLuma(params.color, MIN_CAD_LUMA),
83
106
  metalness: Math.min(params.metalness, 0.5),
@@ -97,8 +120,9 @@ function liftLuma(color, min) {
97
120
  }
98
121
 
99
122
  // The colour a 3MF object carries: the explicit tint, else the preset's own
100
- // colour. Null when the sub-part says nothing about appearance, which is what
101
- // keeps an appearance-free export byte-for-byte unchanged.
123
+ // colour (the blue-grey for an unknown material, never the realistic PLA
124
+ // default's). Null when the sub-part says nothing about appearance, which is
125
+ // what keeps an appearance-free export byte-for-byte unchanged.
102
126
  export function printColor(display) {
103
127
  if (!display || typeof display !== "object") return null;
104
128
  if (isColor(display.color)) return display.color;
@@ -419,7 +419,7 @@ function createCleanupStack() {
419
419
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
420
420
  // exactly once here — submodules take element refs and never query the document.
421
421
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
422
- export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
422
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pickHint = "Click to edit", onDownload, onViewChange, onParamsCommit, onAnnotationSend,
423
423
  fontCatalog,
424
424
  imageCatalog,
425
425
  onAssetUpload,
@@ -497,7 +497,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
497
497
  onRailLayout: () => railChrome.layoutChanged?.(),
498
498
  });
499
499
  cleanup.defer(() => paneTabs.detach());
500
- const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
500
+ // Always-on hover inspection (no-op on touch-only devices). With onPick the
501
+ // host acts on a click, so the tooltip nudges toward it with `pickHint`
502
+ // (a host passes its own wording, or null for none).
503
+ const hover = attachHoverLabels(viewer, { part, tooltip, hint: onPick ? pickHint : null });
501
504
  cleanup.defer(() => hover.detach());
502
505
  const ui = createStatusUi({ ...els.status, exports: [els.exports.stl, els.exports.step, els.exports.threeMf] });
503
506
  cleanup.defer(() => ui.setStatus(""));
@@ -943,22 +946,42 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
943
946
 
944
947
  // Print frames for the layer-line pattern: the display → export map of the
945
948
  // geometry just delivered, so layers run the way the part is printed rather
946
- // than the way it is displayed. Computed at delivery (the frame describes
947
- // the delivered mesh, which a later pose-only repair only moves) and only
948
- // for sub-parts whose material draws layer lines — each one costs two
949
- // geometry-free probe builds.
949
+ // than the way it is displayed. Each frame describes the delivered mesh
950
+ // (which a later pose-only repair only moves), and only sub-parts whose
951
+ // material draws layer lines have one — which includes every
952
+ // sub-part naming no material, since realistic mode shows those as PLA
953
+ // (resolve.js). One without a place() is identity at once; one with it
954
+ // costs two geometry-free probe builds.
950
955
  const layerLined = new Set(Object.keys(part.parts).filter((n) => {
951
956
  try { return resolveMaterial(part.parts[n].display).params.pattern === "layer-lines"; } catch { return false; }
952
957
  }));
958
+ // LAZY: a delivery only records the view and params it was built at; the
959
+ // viewer pulls the frames (computePrintFrames) when it is about to draw the
960
+ // realistic look — live, loading, or borrowed by a capture — so a CAD-only
961
+ // session never probes (viewer.js setPrintFrameSource). The snapshot keeps
962
+ // a late computation describing the mesh actually delivered.
953
963
  const printFrames = {};
964
+ const undrawnFrames = new Map(); // sub-part -> { view, params } at its delivery
954
965
  function recordPrintFrames(names) {
955
966
  const wanted = names.filter((n) => layerLined.has(n));
956
967
  if (!wanted.length) return;
957
- let resolved;
958
- try { resolved = resolveParams(part, params); } catch { return; } // diagnosed by the build
959
- for (const n of wanted) printFrames[n] = printFrameMatrix(part.parts[n], { view: view(), ...resolved });
960
- viewer.setPrintFrames({ ...printFrames });
968
+ const at = { view: view(), params: { ...params } };
969
+ for (const n of wanted) undrawnFrames.set(n, at);
970
+ viewer.invalidatePrintFrames?.();
961
971
  }
972
+ function computePrintFrames() {
973
+ const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
974
+ for (const [n, at] of undrawnFrames) {
975
+ if (!resolvedFor.has(at)) {
976
+ try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
977
+ }
978
+ const resolved = resolvedFor.get(at);
979
+ if (resolved) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
980
+ }
981
+ undrawnFrames.clear();
982
+ return { ...printFrames };
983
+ }
984
+ viewer.setPrintFrameSource?.(computePrintFrames);
962
985
 
963
986
  // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
964
987
  const emptySubParts = new Set();
@@ -1,15 +1,19 @@
1
1
  // Always-on hover inspection: a cursor-following tooltip naming the feature +
2
- // sub-part under the pointer, and an overlay mesh highlighting the feature's
3
- // surface. Feature names come from Solid.label() in the part's build, carried
4
- // per-triangle in the mesh payload (geometry.userData.featureIds/features).
2
+ // sub-part under the pointer. Feature names come from Solid.label() in the
3
+ // part's build, carried per-triangle in the mesh payload
4
+ // (geometry.userData.featureIds/features).
5
+ //
6
+ // There is deliberately NO surface highlight: tinting just the feature under
7
+ // the pointer read as "only this face is clickable", and people stopped
8
+ // clicking elsewhere on the part. `hint` (e.g. "Click to edit") adds a line
9
+ // under the name when a click does something — mount passes it with onPick.
5
10
  import { createTooltipPresenter } from "../tooltip.js";
6
11
  import { raycastViewer } from "./raycast.js";
7
- import { createFeatureHighlight } from "./feature-highlight.js";
8
12
  import { runCleanupSteps } from "../teardown.js";
9
13
 
10
14
  export function attachHoverLabels(
11
15
  viewer,
12
- { part, schedule = (cb) => requestAnimationFrame(cb), tooltip } = {},
16
+ { part, schedule = (cb) => requestAnimationFrame(cb), tooltip, hint = null } = {},
13
17
  ) {
14
18
  // Hover is a mouse idiom — skip entirely on touch-only devices. The stub
15
19
  // still answers the WHOLE interface: mount.js calls setSuppressed from the
@@ -25,8 +29,6 @@ export function attachHoverLabels(
25
29
  let presentationToken;
26
30
  let hasPresented = false;
27
31
 
28
- const highlight = createFeatureHighlight(viewer);
29
-
30
32
  const subLabel = (name) => part.parts[name]?.label ?? name;
31
33
 
32
34
  function hide() {
@@ -35,7 +37,6 @@ export function attachHoverLabels(
35
37
  tooltipPresenter.hide(presentationToken);
36
38
  presentationToken = undefined;
37
39
  }
38
- highlight.clear();
39
40
  }
40
41
 
41
42
  function show(hit, x, y) {
@@ -45,7 +46,7 @@ export function attachHoverLabels(
45
46
  } else {
46
47
  content = { title: subLabel(hit.subPart), subtitle: "" };
47
48
  }
48
- highlight.show(hit);
49
+ if (hint) content.hint = hint;
49
50
  if (hasPresented) {
50
51
  hasPresented = false;
51
52
  tooltipPresenter.hide(presentationToken);
@@ -119,7 +120,6 @@ export function attachHoverLabels(
119
120
  () => viewer.domElement.removeEventListener("pointerup", onUp),
120
121
  () => viewer.domElement.removeEventListener("pointerleave", onLeave),
121
122
  hide,
122
- () => highlight.dispose(),
123
123
  () => { if (ownsTooltip) tooltipPresenter.dispose(); },
124
124
  ], "feature hover cleanup failed");
125
125
  },
@@ -14,7 +14,10 @@ export function createTooltipPresenter({ id = "pf-hover-tip" } = {}) {
14
14
  const title = document.createElement("b");
15
15
  const subtitle = document.createElement("span");
16
16
  subtitle.className = "pf-hover-sub";
17
- element.append(title, subtitle);
17
+ // A call-to-action line under the name ("Click to edit"); empty = hidden.
18
+ const hint = document.createElement("div");
19
+ hint.className = "pf-hover-hint";
20
+ element.append(title, subtitle, hint);
18
21
  document.body.appendChild(element);
19
22
  let disposed = false;
20
23
  const claims = [];
@@ -22,6 +25,7 @@ export function createTooltipPresenter({ id = "pf-hover-tip" } = {}) {
22
25
  function setContent(content) {
23
26
  title.textContent = content.title;
24
27
  subtitle.textContent = content.subtitle ?? "";
28
+ hint.textContent = content.hint ?? "";
25
29
  }
26
30
 
27
31
  function measureVisible(content, anchored) {
@@ -150,6 +150,7 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
150
150
  if (!cache.isStale()) return;
151
151
  cache.markFresh();
152
152
  const token = ++loopToken;
153
+ let missed = false;
153
154
  // One at a time: each realistic style may load an environment.
154
155
  for (const s of STYLES) {
155
156
  if (detached || token !== loopToken) return;
@@ -161,9 +162,15 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
161
162
  let url = null;
162
163
  try { url = await viewer.renderStyleThumbnail(s.id, { size: 256 }); } catch { url = null; }
163
164
  if (detached || token !== loopToken) return;
165
+ // A failed or empty render (null) blanks its tile rather than leaving an
166
+ // older picture up, and leaves the cache stale so the next open tries
167
+ // again: caching it made one bad moment permanent until a part or theme
168
+ // change came along.
164
169
  cache.set(s.id, url);
170
+ if (!url) missed = true;
165
171
  paint(s.id);
166
172
  }
173
+ if (missed && token === loopToken) cache.invalidate();
167
174
  }
168
175
 
169
176
  // --- open / close ------------------------------------------------------------
@@ -225,6 +232,10 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
225
232
  viewer.onEnvironmentChange(() => render()),
226
233
  viewer.onAssemblyChange?.(() => cache.invalidate()) ?? (() => {}),
227
234
  viewer.onThemeChange?.(() => cache.invalidate()) ?? (() => {}),
235
+ // A thumbnail shows the section as it was when it was taken, so turning
236
+ // the cutaway on or off, or moving its plane, stales them all — without
237
+ // this a set taken with the cutaway on outlived the cutaway itself.
238
+ viewer.onCutawayChange?.(() => cache.invalidate()) ?? (() => {}),
228
239
  ];
229
240
  render();
230
241
  // The popover closes whenever the button's host is hidden: #viewbar hides
@@ -11,8 +11,8 @@ export const STYLES = [
11
11
 
12
12
  export const styleFor = (renderMode, environmentId) => (renderMode === "realistic" ? environmentId : "cad");
13
13
 
14
- // Thumbnails are rendered on OPEN and cached. `invalidate` (a part or theme
15
- // change) only marks them stale — the next open re-renders. A render already
14
+ // Thumbnails are rendered on OPEN and cached. `invalidate` (a part, theme or
15
+ // cutaway change, or a render that came back empty) only marks them stale — the next open re-renders. A render already
16
16
  // in flight keeps painting (view-style-controls.js says why).
17
17
  export function createThumbnailCache() {
18
18
  const images = new Map();
@@ -562,6 +562,8 @@ export function createViewer(container, part) {
562
562
  const textureLoader = new THREE.TextureLoader();
563
563
  let pmrem = null;
564
564
  let printFrames = {};
565
+ let printFrameSource = null; // () => frames; mount's lazy provider (setPrintFrameSource)
566
+ let printFramesStale = false; // the source has frames the materials have not seen
565
567
  const modeListeners = new Set();
566
568
  const envListeners = new Set();
567
569
  const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
@@ -750,11 +752,14 @@ export function createViewer(container, part) {
750
752
  .map((n) => subMesh[n]);
751
753
  // `force` renders even while parked: a capture is offscreen work a parked
752
754
  // viewer still does, and it must not bake a stale (or absent) shadow.
755
+ // The cutaway's plane clips the shadow's casters too, so the half it cut
756
+ // away casts no shadow (cutaway.onChange below re-renders it as it moves).
757
+ const _shadowClipPlane = new THREE.Plane();
753
758
  function renderShadow(opts, { force = false } = {}) {
754
759
  if (!realisticRig || (!active && !force)) return;
755
760
  try {
756
- if (opts) realisticRig.shadow.render(scene, castersNow(), opts);
757
- else realisticRig.shadow.render(scene, castersNow());
761
+ const plane = cutaway.getPlane?.(_shadowClipPlane) ?? null;
762
+ realisticRig.shadow.render(scene, castersNow(), { ...opts, clippingPlanes: plane ? [plane] : null });
758
763
  } catch (e) {
759
764
  console.warn("partforge: contact shadow failed", e);
760
765
  }
@@ -787,6 +792,10 @@ export function createViewer(container, part) {
787
792
  renderShadow({ lowRes: true });
788
793
  }
789
794
  }
795
+ // A cutaway turned on or off, or its plane moved, changes what casts the
796
+ // contact shadow: low-res while the plane is dragged, full once it settles —
797
+ // the same schedule as a moving sub-part.
798
+ cutaway.onChange?.(() => { if (realisticRig) shadowMovedAt = performance.now(); });
790
799
 
791
800
  const livePixelRatio = (mode) =>
792
801
  Math.min(devicePixelRatio, mode === "realistic" && isCoarsePointer() ? 1.5 : 2);
@@ -805,6 +814,7 @@ export function createViewer(container, part) {
805
814
  // ground only moves on showAssembly, never because a capture happened.
806
815
  function enterRealistic(rig, { live = true, reground = true } = {}) {
807
816
  try {
817
+ syncPrintFrames(); // before the swap: the first realistic frame has the export-pose layers
808
818
  renderMode = "realistic";
809
819
  // A regen can land between the proxy compile and here, while the mode
810
820
  // was still CAD, so setSubGeometry skipped the UVs. Idempotent.
@@ -874,6 +884,7 @@ export function createViewer(container, part) {
874
884
  // target bound — each only for the synchronous compile() inside
875
885
  // compileAsync, so the view on screen is untouched while it waits.
876
886
  function compileRealistic(rig, { forCapture = false } = {}) {
887
+ syncPrintFrames();
877
888
  const proxy = new THREE.Scene();
878
889
  proxy.environment = rig.envMap;
879
890
  for (const n of names) {
@@ -974,6 +985,32 @@ export function createViewer(container, part) {
974
985
  for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
975
986
  }
976
987
 
988
+ // The lazy form mount uses. Computing a frame runs the sub-part's pose probe
989
+ // (two geometry-free builds for a placed sub-part), and every sub-part with
990
+ // no material draws layer lines, so frames are PULLED only when something is
991
+ // about to draw the realistic look: the live view (or a switch loading),
992
+ // and any capture that borrows it — compileRealistic and enterRealistic are
993
+ // the two doors every such path goes through. A delivery just marks them
994
+ // stale (invalidatePrintFrames); in CAD nothing is computed.
995
+ function setPrintFrameSource(source) {
996
+ printFrameSource = typeof source === "function" ? source : null;
997
+ invalidatePrintFrames();
998
+ }
999
+ function invalidatePrintFrames() {
1000
+ printFramesStale = true;
1001
+ if (renderMode === "realistic" || realisticPending) syncPrintFrames();
1002
+ }
1003
+ function syncPrintFrames() {
1004
+ if (!printFrameSource || !printFramesStale) return;
1005
+ printFramesStale = false;
1006
+ let frames;
1007
+ try { frames = printFrameSource(); } catch (e) {
1008
+ console.warn("partforge: computing print frames failed", e);
1009
+ return;
1010
+ }
1011
+ setPrintFrames(frames);
1012
+ }
1013
+
977
1014
  // Resolves once the current environment's rig is loaded (captures wait on it).
978
1015
  const whenRealisticReady = () => rigFor(environmentId).then(() => {});
979
1016
 
@@ -1825,7 +1862,7 @@ export function createViewer(container, part) {
1825
1862
  for (const dot of [...flashDots, ...captureHidden]) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
1826
1863
  try {
1827
1864
  renderer.setRenderTarget(rt);
1828
- renderer.render(renderScene, cam);
1865
+ renderWithBackdrop(renderScene, cam);
1829
1866
  // render() resolves the multisample renderbuffer into the target texture, so this
1830
1867
  // reads antialiased pixels.
1831
1868
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
@@ -1860,6 +1897,14 @@ export function createViewer(container, part) {
1860
1897
  // Pointer feedback excluded from EVERY offscreen render (renderOffscreen),
1861
1898
  // showcase captures included — unlike canonicalCaptureHidden below.
1862
1899
  const captureHidden = new Set();
1900
+ // The cutaway's ghost plane is its gizmo, not its section: the part as the
1901
+ // user sees it (clipped, with its hatched caps) goes into a capture, the
1902
+ // translucent plane in front of it does not. It sits between the camera and
1903
+ // everything the cut keeps, and a capture blends it in linear light, where
1904
+ // it lands several times stronger than on the canvas — the view style
1905
+ // popover's thumbnails came back with the part washed out behind a blue
1906
+ // sheet, a dark part all but gone.
1907
+ for (const obj of cutaway.captureExcluded ?? []) captureHidden.add(obj);
1863
1908
  function registerCaptureHidden(obj) {
1864
1909
  captureHidden.add(obj);
1865
1910
  return () => captureHidden.delete(obj);
@@ -2144,6 +2189,64 @@ export function createViewer(container, part) {
2144
2189
  }
2145
2190
  }
2146
2191
 
2192
+ // --- the environment backdrop under an orthographic camera ---------------
2193
+ // three draws a texture `scene.background` (the realistic environment photo)
2194
+ // as a unit box around the camera, projected through the camera's own
2195
+ // projection (WebGLBackground + the backgroundCube shader). Under a
2196
+ // PerspectiveCamera that box fills the frame; under an OrthographicCamera it
2197
+ // projects to a one-unit square in a frustum tens of millimetres wide or
2198
+ // more — a speck behind the part — so an ortho face view (the view cube
2199
+ // settles into one) lost its whole backdrop to the clear colour, on the
2200
+ // canvas and in every capture of that framing. So under ortho the backdrop is
2201
+ // its own pass: three's own background draw (same PMREM, blur, intensity,
2202
+ // rotation) through a perspective camera with the ortho camera's orientation
2203
+ // and the live perspective camera's fov, then the scene through the ortho
2204
+ // camera on top, clearing depth and stencil but not colour. The backdrop is
2205
+ // exactly what perspective shows looking the same way. A colour background
2206
+ // (CAD) clears the same under either projection and keeps the one pass.
2207
+ // Residual: a transmissive material in the scene pass refracts the clear
2208
+ // colour rather than the environment (its transmission pass reads
2209
+ // scene.background, which is null for that pass) — under ortho only.
2210
+ const _backdropCam = new THREE.PerspectiveCamera(45, 1, 0.1, 10);
2211
+ const _backdropScene = new THREE.Scene();
2212
+ function renderWithBackdrop(renderScene, cam) {
2213
+ const background = renderScene.background;
2214
+ if (!cam.isOrthographicCamera || !background?.isTexture) {
2215
+ renderer.render(renderScene, cam);
2216
+ return;
2217
+ }
2218
+ cam.updateMatrixWorld();
2219
+ cam.matrixWorld.decompose(_backdropCam.position, _backdropCam.quaternion, _backdropCam.scale);
2220
+ _backdropCam.up.copy(cam.up);
2221
+ _backdropCam.fov = camera.fov;
2222
+ _backdropCam.aspect = (cam.right - cam.left) / (cam.top - cam.bottom) || 1;
2223
+ // A recentred capture renders a sub-window of a larger frame: the backdrop
2224
+ // is cropped the same way, so it lines up with the scene pass.
2225
+ if (cam.view?.enabled) {
2226
+ const { fullWidth, fullHeight, offsetX, offsetY, width, height } = cam.view;
2227
+ _backdropCam.setViewOffset(fullWidth, fullHeight, offsetX, offsetY, width, height);
2228
+ } else {
2229
+ _backdropCam.clearViewOffset();
2230
+ }
2231
+ _backdropCam.updateProjectionMatrix();
2232
+ _backdropCam.updateMatrixWorld(true);
2233
+ _backdropScene.background = background;
2234
+ _backdropScene.backgroundBlurriness = renderScene.backgroundBlurriness;
2235
+ _backdropScene.backgroundIntensity = renderScene.backgroundIntensity;
2236
+ _backdropScene.backgroundRotation.copy(renderScene.backgroundRotation);
2237
+ const clearColorWas = renderer.autoClearColor;
2238
+ try {
2239
+ renderer.render(_backdropScene, _backdropCam);
2240
+ renderScene.background = null;
2241
+ renderer.autoClearColor = false;
2242
+ renderer.render(renderScene, cam);
2243
+ } finally {
2244
+ renderScene.background = background;
2245
+ renderer.autoClearColor = clearColorWas;
2246
+ _backdropScene.background = null; // hold no reference to a rig's texture
2247
+ }
2248
+ }
2249
+
2147
2250
  // --- render loop ----------------------------------------------------------
2148
2251
  // The tween is applied after controls.update() so the cue wins the frame, and
2149
2252
  // the frame listeners run before render so a playback frame draws its own pose.
@@ -2181,7 +2284,7 @@ export function createViewer(container, part) {
2181
2284
  const next = projectPoint([anchorDot.position.x, anchorDot.position.y, anchorDot.position.z]);
2182
2285
  if (anchorMoved(lastAnchor, next)) publishAnchor(next);
2183
2286
  }
2184
- renderer.render(scene, activeCamera);
2287
+ renderWithBackdrop(scene, activeCamera);
2185
2288
  cutaway.renderOverlay(renderer, activeCamera);
2186
2289
  }
2187
2290
  renderer.setAnimationLoop(renderFrame);
@@ -2543,6 +2646,8 @@ export function createViewer(container, part) {
2543
2646
  registerCanonicalCaptureHidden,
2544
2647
  registerCaptureHidden,
2545
2648
  onCutawayHandleHover: cutaway.onHandleHoverChange,
2649
+ // The section changed: on, off, or the plane moved. Listener gets no args.
2650
+ onCutawayChange: cutaway.onChange,
2546
2651
  setRenderMode,
2547
2652
  getRenderMode: () => renderMode,
2548
2653
  onRenderModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
@@ -2557,6 +2662,8 @@ export function createViewer(container, part) {
2557
2662
  isRealisticPending: () => realisticPending,
2558
2663
  onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
2559
2664
  setPrintFrames,
2665
+ setPrintFrameSource,
2666
+ invalidatePrintFrames,
2560
2667
  whenRealisticReady,
2561
2668
  dispose,
2562
2669
  };
@@ -3,7 +3,7 @@
3
3
  // judged by eye in every environment (materials.html). Not a reference part.
4
4
  import { PRESETS } from "../framework/materials/presets.js";
5
5
 
6
- const ids = Object.keys(PRESETS).filter((id) => id !== "default");
6
+ const ids = Object.keys(PRESETS);
7
7
  const COLS = 6, PITCH = 40;
8
8
 
9
9
  const withHole = (k) =>
@@ -20,6 +20,13 @@ export default {
20
20
  build: (k) => withHole(k).translate([(i % COLS) * PITCH, -Math.floor(i / COLS) * PITCH, 0]),
21
21
  display: { material: id },
22
22
  }])),
23
+ // No material at all: the blue-grey CAD look, a PLA print in that colour in
24
+ // realistic mode (resolve.js).
25
+ no_material: {
26
+ label: "No material",
27
+ views: ["all"],
28
+ build: (k) => withHole(k).translate([(ids.length % COLS) * PITCH, -Math.floor(ids.length / COLS) * PITCH, 0]),
29
+ },
23
30
  upright_pla: {
24
31
  label: "PLA, displayed upright, printed flat",
25
32
  views: ["print"],