partforge 0.125.0 → 0.127.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
|
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -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. */
|
|
@@ -85,23 +85,79 @@ vec3 pfTriplanarNormal(vec3 p, vec3 n, float scale) {
|
|
|
85
85
|
}
|
|
86
86
|
`;
|
|
87
87
|
|
|
88
|
+
// Layer lines are 0.2 mm, so at an ordinary viewing distance a layer spans a
|
|
89
|
+
// few pixels or less, and point-sampling the bead profile per pixel aliased
|
|
90
|
+
// into moiré (worst in the specular, through the bead normals). So one layer's
|
|
91
|
+
// profile is baked into a small repeating texture sampled by layer height, and
|
|
92
|
+
// the GPU's mipmaps band-limit it. Channels: R = groove shade, G = bead slope
|
|
93
|
+
// (encoded slope/6 + 0.5), B = slope^2 / 9. A mip level averages G and B
|
|
94
|
+
// separately, so B - G^2 is the slope VARIANCE inside the pixel — the ridges it
|
|
95
|
+
// can no longer show — which widens the specular lobe instead (Toksvig / LEAN
|
|
96
|
+
// mapping), so a far wall keeps its satin sheen rather than turning glossy.
|
|
97
|
+
export const LAYER_PROFILE_WIDTH = 256;
|
|
98
|
+
// Gradient scale on the profile lookup (+0.58 mip). A box-filtered mip chain at
|
|
99
|
+
// 1x still carries the full fundamental at 2 px per layer (its 2-texel level),
|
|
100
|
+
// right at Nyquist, which banded across a whole-part view; 1.5 clears that and
|
|
101
|
+
// keeps the lines crisp close up (2 visibly softened them).
|
|
102
|
+
const LAYER_BLUR = "1.5";
|
|
103
|
+
|
|
104
|
+
// One layer's bead, t running -1 (a seam) through 0 (the crest) to 1 (the next
|
|
105
|
+
// seam): the groove shade darkens the 35% of each half-bead nearest a seam, and
|
|
106
|
+
// the slope is a round bead's (t / sqrt(1 - t^2)), clamped to ±3 where it goes
|
|
107
|
+
// vertical. (The procedural version darkened the CREST instead — its ridge was
|
|
108
|
+
// |t|, not 1 - |t| — half a layer off the valleys its own normals drew.)
|
|
109
|
+
export function layerProfileData(width = LAYER_PROFILE_WIDTH) {
|
|
110
|
+
const data = new Uint8Array(width * 4);
|
|
111
|
+
for (let i = 0; i < width; i++) {
|
|
112
|
+
const t = ((i + 0.5) / width) * 2 - 1;
|
|
113
|
+
const g = Math.min(1, (1 - Math.abs(t)) / 0.35);
|
|
114
|
+
const groove = g * g * (3 - 2 * g);
|
|
115
|
+
const slope = Math.min(3, Math.max(-3, t / Math.sqrt(Math.max(1 - t * t, 0.04))));
|
|
116
|
+
data[i * 4] = Math.round(groove * 255);
|
|
117
|
+
data[i * 4 + 1] = Math.round((slope / 6 + 0.5) * 255);
|
|
118
|
+
data[i * 4 + 2] = Math.round((slope * slope / 9) * 255);
|
|
119
|
+
data[i * 4 + 3] = 255;
|
|
120
|
+
}
|
|
121
|
+
return data;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// One texture shared by every layer-lines material, built on first use.
|
|
125
|
+
let layerProfileTex = null;
|
|
126
|
+
function layerProfileTexture() {
|
|
127
|
+
if (layerProfileTex) return layerProfileTex;
|
|
128
|
+
const t = new THREE.DataTexture(layerProfileData(), LAYER_PROFILE_WIDTH, 1, THREE.RGBAFormat, THREE.UnsignedByteType);
|
|
129
|
+
t.wrapS = THREE.RepeatWrapping;
|
|
130
|
+
t.wrapT = THREE.ClampToEdgeWrapping;
|
|
131
|
+
t.magFilter = THREE.LinearFilter;
|
|
132
|
+
t.minFilter = THREE.LinearMipmapLinearFilter;
|
|
133
|
+
t.generateMipmaps = true;
|
|
134
|
+
t.colorSpace = THREE.NoColorSpace;
|
|
135
|
+
t.needsUpdate = true;
|
|
136
|
+
return (layerProfileTex = t);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const FRAG_DECL_LAYERS = "uniform sampler2D pfLayerMap;\n";
|
|
140
|
+
|
|
88
141
|
// Per-kind fragment code, injected after <roughnessmap_fragment> so it can
|
|
89
142
|
// modulate both diffuseColor (already written by <map_fragment>) and roughnessFactor.
|
|
90
143
|
const FRAG_BODY = {
|
|
144
|
+
// Declared at main() scope, not in a block: FRAG_NORMAL reads the same
|
|
145
|
+
// profile sample (pfLayer) rather than looking it up again.
|
|
91
146
|
"layer-lines": `
|
|
147
|
+
float pfH = (pfPrintFrame * vec4(vPfObjPos, 1.0)).z / pfPatternScale; // in layers
|
|
148
|
+
// fract() keeps the texel coordinate small (hundreds of layers x 256 texels
|
|
149
|
+
// loses sub-texel precision in the sampler); the explicit gradients are the
|
|
150
|
+
// unwrapped height's, so the wrap at each seam doesn't drop to the 1x1 mip.
|
|
151
|
+
vec4 pfLayer = textureGrad(pfLayerMap, vec2(fract(pfH), 0.5),
|
|
152
|
+
vec2(dFdx(pfH) * ${LAYER_BLUR}, 0.0), vec2(dFdy(pfH) * ${LAYER_BLUR}, 0.0));
|
|
92
153
|
{
|
|
93
|
-
float
|
|
94
|
-
float ridge = abs(fract(h) - 0.5) * 2.0; // 0 at a layer seam, 1 mid-layer
|
|
95
|
-
float groove = smoothstep(0.0, 0.35, ridge);
|
|
96
|
-
// Anti-alias: fwidth(h) is layers per pixel. Where a layer spans fewer
|
|
97
|
-
// than ~6 px the band fades to its mean over a period (0.825: the groove
|
|
98
|
-
// ramps over 35% of it, the rest is flat), and past ~2.5 px it is gone —
|
|
99
|
-
// point-sampling finer layers than that is what aliased into moiré.
|
|
100
|
-
groove = mix(0.825, groove, 1.0 - smoothstep(0.16, 0.4, fwidth(h)));
|
|
154
|
+
float groove = pfLayer.r;
|
|
101
155
|
diffuseColor.rgb *= mix(0.9, 1.0, groove); // the normal map (FRAG_NORMAL) carries most of the relief
|
|
102
156
|
roughnessFactor = clamp(roughnessFactor + (1.0 - groove) * 0.18, 0.0, 1.0);
|
|
103
157
|
// Filament mottling: a fine grain (~0.17 mm) over slow blotches (~2 mm),
|
|
104
158
|
// fixed to the part, so a print doesn't read as flat injection-moulded plastic.
|
|
159
|
+
// Hash noise, not a noise volume: a mipmapped 64^3 texture measured ~10%
|
|
160
|
+
// slower per frame on an M1 Max (a filtered 3D fetch reads 16 texels).
|
|
105
161
|
float mottle = pfNoise(vPfObjPos * 6.0) * 0.6 + pfNoise(vPfObjPos * 0.51) * 0.4;
|
|
106
162
|
diffuseColor.rgb *= mix(0.893, 1.047, mottle);
|
|
107
163
|
roughnessFactor = clamp(roughnessFactor + (mottle - 0.5) * 0.084, 0.0, 1.0);
|
|
@@ -149,15 +205,14 @@ const FRAG_BODY = {
|
|
|
149
205
|
// layer is a round bead, so across one layer the surface normal swings from
|
|
150
206
|
// facing down (bottom of the bead) through straight out to facing up, along
|
|
151
207
|
// the print direction projected onto the surface. Faces parallel to the layers
|
|
152
|
-
// (tops and bottoms) get no ridges, as on a real print.
|
|
153
|
-
// colour bands,
|
|
208
|
+
// (tops and bottoms) get no ridges, as on a real print. The slope comes from the
|
|
209
|
+
// same mip-filtered profile sample as the colour bands, and the slope variance
|
|
210
|
+
// the filter averaged away goes into roughness: alpha^2 += var(tilt).
|
|
154
211
|
const FRAG_NORMAL = {
|
|
155
212
|
"layer-lines": `
|
|
156
213
|
{
|
|
157
|
-
float
|
|
158
|
-
float
|
|
159
|
-
float t = fract(h) * 2.0 - 1.0; // -1 at a seam, 0 bead crest, 1 next seam
|
|
160
|
-
float slope = clamp(t / sqrt(max(1.0 - t * t, 0.04)), -3.0, 3.0);
|
|
214
|
+
float slope = pfLayer.g * 6.0 - 3.0;
|
|
215
|
+
float slopeVar = max(pfLayer.b * 9.0 - slope * slope, 0.0);
|
|
161
216
|
vec3 up = normalize(vPfPrintUpView);
|
|
162
217
|
// NOT normalised: its length is how steeply the surface cuts across the
|
|
163
218
|
// layers (1 on a vertical wall, 0 on a face parallel to them), so ridges fade
|
|
@@ -165,7 +220,10 @@ const FRAG_NORMAL = {
|
|
|
165
220
|
// full-strength tilt in a direction set by rounding noise, flipping sign
|
|
166
221
|
// wherever the face sat on a layer boundary — a visible patch.
|
|
167
222
|
vec3 along = up - normal * dot(normal, up);
|
|
168
|
-
normal = normalize(normal + along * slope * 0.35
|
|
223
|
+
normal = normalize(normal + along * slope * 0.35);
|
|
224
|
+
// tilt = slope * 0.35 * |along|, so var(tilt) = slopeVar * 0.35^2 * |along|^2
|
|
225
|
+
float a = roughnessFactor * roughnessFactor;
|
|
226
|
+
roughnessFactor = min(sqrt(sqrt(a * a + slopeVar * 0.1225 * dot(along, along))), 1.0);
|
|
169
227
|
}`,
|
|
170
228
|
// Wood replaces the view-space normal with its triplanar normal map, built in
|
|
171
229
|
// object space and carried to view space through the normal matrix. A
|
|
@@ -216,7 +274,11 @@ export function setGrainAxis(material, axis) {
|
|
|
216
274
|
export function applyPattern(material, { kind, scale = 1, printFrame, texture, normalMap, roughnessMap, roughnessMean = 0.5, normalScale = 1, grain = "u" } = {}) {
|
|
217
275
|
if (!kind || !FRAG_BODY[kind]) return material;
|
|
218
276
|
const wood = kind === "wood";
|
|
277
|
+
const layers = kind === "layer-lines";
|
|
219
278
|
const uniforms = {
|
|
279
|
+
...(layers ? {
|
|
280
|
+
pfLayerMap: { value: layerProfileTexture() },
|
|
281
|
+
} : {}),
|
|
220
282
|
pfPatternScale: { value: scale },
|
|
221
283
|
pfPrintFrame: { value: new THREE.Matrix4().fromArray(printFrame ?? new THREE.Matrix4().toArray()) },
|
|
222
284
|
pfPatternMap: { value: texture ?? null },
|
|
@@ -236,7 +298,7 @@ export function applyPattern(material, { kind, scale = 1, printFrame, texture, n
|
|
|
236
298
|
.replace("#include <common>", `#include <common>\n${VERT_DECL}${wood ? VERT_DECL_NM : ""}`)
|
|
237
299
|
.replace("#include <begin_vertex>", `#include <begin_vertex>\n${VERT_BODY}${wood ? VERT_BODY_NM : ""}`);
|
|
238
300
|
shader.fragmentShader = shader.fragmentShader
|
|
239
|
-
.replace("#include <common>", `#include <common>\n${FRAG_DECL}${wood ? FRAG_DECL_WOOD : ""}`)
|
|
301
|
+
.replace("#include <common>", `#include <common>\n${FRAG_DECL}${wood ? FRAG_DECL_WOOD : ""}${layers ? FRAG_DECL_LAYERS : ""}`)
|
|
240
302
|
.replace("#include <roughnessmap_fragment>", `#include <roughnessmap_fragment>\n${FRAG_BODY[kind]}`)
|
|
241
303
|
.replace("#include <normal_fragment_maps>", `#include <normal_fragment_maps>\n${FRAG_NORMAL[kind] ?? ""}`);
|
|
242
304
|
};
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
-
|
|
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(""));
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// Always-on hover inspection: a cursor-following tooltip naming the feature +
|
|
2
|
-
// sub-part under the pointer
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
},
|
package/src/framework/tooltip.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|