partforge 0.44.0 → 0.46.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/bin/cli.js +43 -5
- package/docs/AUTHORING-PARTS.md +50 -4
- package/docs/ERROR-PATTERNS.md +19 -0
- package/docs/KERNEL-CONTRACT.md +34 -1
- package/package.json +2 -2
- package/src/framework/animation-controls.js +27 -16
- package/src/framework/animation.js +67 -13
- package/src/framework/capture-build.js +59 -0
- package/src/framework/geometry/brep-edges.js +124 -0
- package/src/framework/geometry/creased-normals.js +131 -0
- package/src/framework/geometry/kernel.js +2 -2
- package/src/framework/geometry/manifold-backend.js +66 -115
- package/src/framework/geometry/occt-backend.js +17 -3
- package/src/framework/geometry/op-options.js +2 -2
- package/src/framework/geometry/pose.js +13 -0
- package/src/framework/geometry/rim-bevel.js +10 -3
- package/src/framework/geometry/shading-policy.js +36 -0
- package/src/framework/jobs.js +21 -0
- package/src/framework/lint/rules-animations.js +54 -17
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/mount.js +57 -5
- package/src/framework/view-tabs.js +13 -0
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +94 -13
- package/src/framework/worker.js +5 -1
- package/src/testing/render.js +2 -2
- package/types/index.d.ts +23 -4
- package/types/part.d.ts +44 -16
package/src/framework/jobs.js
CHANGED
|
@@ -80,6 +80,27 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
80
80
|
const transfer = meshes.flatMap((m) =>
|
|
81
81
|
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
82
82
|
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
|
|
83
|
+
} else if (msg.type === "capture-generate") {
|
|
84
|
+
// A private, job-correlated one-shot channel for captureView — builds a
|
|
85
|
+
// (possibly non-active) view's meshes off the regen loop, so it can never
|
|
86
|
+
// race or clobber live state. Same per-sub-part build+cache-round as
|
|
87
|
+
// `generate` above (cache:true reuses the worker's geometry memo), but no
|
|
88
|
+
// isStale/superseded polling — there's nothing to supersede a one-shot.
|
|
89
|
+
const useCache = msg.cache !== false;
|
|
90
|
+
const meshes = [];
|
|
91
|
+
for (const name of msg.subparts) {
|
|
92
|
+
if (useCache) kernel.beginSubPart?.(name);
|
|
93
|
+
try {
|
|
94
|
+
const m = posed(name, "display").toMesh({ quality: "preview" });
|
|
95
|
+
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
|
|
96
|
+
} finally {
|
|
97
|
+
if (useCache) kernel.endSubPart?.();
|
|
98
|
+
kernel.cleanup?.();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const captureTransfer = meshes.flatMap((m) =>
|
|
102
|
+
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
103
|
+
post({ type: "capture-meshes", jobId: msg.jobId, meshes }, captureTransfer);
|
|
83
104
|
} else if (msg.type === "export-stl") {
|
|
84
105
|
const names = selected();
|
|
85
106
|
if (names.length === 0) throw new Error("no exportable parts selected");
|
|
@@ -84,14 +84,26 @@ export const ANIMATION_RULES = [
|
|
|
84
84
|
`animations.${name}.steps`));
|
|
85
85
|
continue;
|
|
86
86
|
}
|
|
87
|
-
rawSteps(a)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
const steps = rawSteps(a);
|
|
88
|
+
const trackful = (s) => isPlainObject(s.tracks) && Object.keys(s.tracks).length > 0;
|
|
89
|
+
if (!steps.some(trackful)) {
|
|
90
|
+
out.push(err("animation-tracks-or-steps",
|
|
91
|
+
`animation "${name}" animates nothing`,
|
|
92
|
+
"At least one step needs a non-empty `tracks` object mapping a param key to keyframes.",
|
|
93
|
+
hasSteps ? `animations.${name}.steps` : `animations.${name}.tracks`));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
steps.forEach((s, i) => {
|
|
97
|
+
if (trackful(s)) return;
|
|
98
|
+
// A camera-only step is legal: it holds the pose and just moves the
|
|
99
|
+
// camera — an establishing shot before the motion starts. The runtime
|
|
100
|
+
// emits its cue and evaluate() holds the surrounding values, so lint
|
|
101
|
+
// must not reject what plays correctly.
|
|
102
|
+
if (hasSteps && s.camera != null) return;
|
|
103
|
+
out.push(err("animation-tracks-or-steps",
|
|
104
|
+
`animation "${name}"${hasSteps ? ` step ${i}` : ""} has no tracks`,
|
|
105
|
+
"Every step needs a non-empty `tracks` object mapping a param key to keyframes — or, for a step that only moves the camera, a `camera` angle.",
|
|
106
|
+
hasSteps ? `animations.${name}.steps[${i}].tracks` : `animations.${name}.tracks`));
|
|
95
107
|
});
|
|
96
108
|
}
|
|
97
109
|
return out;
|
|
@@ -201,12 +213,31 @@ export const ANIMATION_RULES = [
|
|
|
201
213
|
},
|
|
202
214
|
{
|
|
203
215
|
id: "animation-loop-invalid",
|
|
204
|
-
run: ({ part }) =>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
`
|
|
216
|
+
run: ({ part }) => {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const [name, a] of animEntries(part)) {
|
|
219
|
+
if (a.loop === undefined) continue;
|
|
220
|
+
// Type first, like `autoplay`. The runtime fails closed (normalizeAnimation
|
|
221
|
+
// reads `spec.loop === true`), so a truthy non-boolean does NOT loop — it
|
|
222
|
+
// silently means `false`. That is the safe default but not an obvious one,
|
|
223
|
+
// so the author has to hear about it here rather than wonder why `loop: 1`
|
|
224
|
+
// does nothing.
|
|
225
|
+
if (typeof a.loop !== "boolean") {
|
|
226
|
+
out.push(err("animation-loop-invalid",
|
|
227
|
+
`animation "${name}" has a non-boolean \`loop\``,
|
|
228
|
+
"`loop` must be `true` or `false`. Any other truthy value still loops at runtime, so it cannot be left to mean something else.",
|
|
229
|
+
`animations.${name}.loop`));
|
|
230
|
+
continue; // one error per field: the check below assumes a real boolean
|
|
231
|
+
}
|
|
232
|
+
if (a.loop && Array.isArray(a.steps) && a.steps.length > 1) {
|
|
233
|
+
out.push(err("animation-loop-invalid",
|
|
234
|
+
`animation "${name}" sets \`loop: true\` on a multi-step animation`,
|
|
235
|
+
"Loop is for continuous single-phase motion (gears). A stepped sequence replays via the transport instead — drop `loop` or collapse to one step.",
|
|
236
|
+
`animations.${name}.loop`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
},
|
|
210
241
|
},
|
|
211
242
|
{
|
|
212
243
|
id: "animation-step-label-duplicate",
|
|
@@ -235,7 +266,11 @@ export const ANIMATION_RULES = [
|
|
|
235
266
|
run: ({ part }) => {
|
|
236
267
|
const out = [];
|
|
237
268
|
const check = (easing, path) => {
|
|
238
|
-
|
|
269
|
+
// Own-key test, not `in`: `"toString" in EASINGS` is true, so `in` would
|
|
270
|
+
// wave through every Object.prototype member. The runtime (easingFor)
|
|
271
|
+
// applies the same test and falls back to the default, so these names are
|
|
272
|
+
// caught here rather than silently mis-animating or throwing mid-frame.
|
|
273
|
+
if (easing !== undefined && !Object.hasOwn(EASINGS, easing)) {
|
|
239
274
|
out.push(err("animation-easing-unknown",
|
|
240
275
|
`unknown easing "${easing}"`,
|
|
241
276
|
`Use one of: ${Object.keys(EASINGS).join(", ")}.`,
|
|
@@ -258,7 +293,7 @@ export const ANIMATION_RULES = [
|
|
|
258
293
|
const stepCameras = Array.isArray(a.steps)
|
|
259
294
|
? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
|
|
260
295
|
: [];
|
|
261
|
-
if (a.camera
|
|
296
|
+
if (a.camera != null && stepCameras.length) {
|
|
262
297
|
out.push(err("animation-camera-invalid",
|
|
263
298
|
`animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
|
|
264
299
|
"One camera mechanism per animation: either the animation-level name/cue-list, or per-step names — not both.",
|
|
@@ -272,7 +307,9 @@ export const ANIMATION_RULES = [
|
|
|
272
307
|
`animations.${name}.steps[${i}].camera`));
|
|
273
308
|
}
|
|
274
309
|
}
|
|
275
|
-
|
|
310
|
+
// An explicit `camera: null` is "no camera", which is how
|
|
311
|
+
// normalizeAnimation reads it — not a malformed value to report.
|
|
312
|
+
if (a.camera == null) continue;
|
|
276
313
|
if (typeof a.camera === "string") {
|
|
277
314
|
if (badName(a.camera)) {
|
|
278
315
|
out.push(err("animation-camera-invalid",
|
|
@@ -83,6 +83,28 @@ export const SCHEMA_RULES = [
|
|
|
83
83
|
return out;
|
|
84
84
|
},
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
id: "features-requires-on",
|
|
88
|
+
run: ({ part }) => {
|
|
89
|
+
const out = [];
|
|
90
|
+
sections(part).forEach((sec, si) => {
|
|
91
|
+
if (!sectionRenders(sec)) return;
|
|
92
|
+
arr(sec?.features).forEach((f, i) => {
|
|
93
|
+
// The panel treats "enabled" as `params[key] > 0`, so `on` has to be a
|
|
94
|
+
// positive number: a missing one writes undefined (NaN in the build),
|
|
95
|
+
// and 0 or a negative writes a value the panel reads straight back as
|
|
96
|
+
// "still off", leaving a checkbox that won't stay ticked.
|
|
97
|
+
if (f && !f.hidden && !(typeof f.on === "number" && f.on > 0)) {
|
|
98
|
+
out.push(err("features-requires-on",
|
|
99
|
+
`section "${sec.id ?? si}" feature ${i}${f.key ? ` ("${f.key}")` : ""} has no positive numeric \`on\` value`,
|
|
100
|
+
"Ticking a feature's checkbox writes `on` into the feature's own parameter, so a missing one writes `undefined` and the build reads it as NaN. Unlike a `toggles` entry — a plain flag that falls back to 1 — a feature's `on` is the real value the parameter takes when enabled (a diameter, a count), so there is no safe default to guess. It must be greater than 0, because the panel reads `> 0` as \"enabled\". Give the feature the value it should switch on to.",
|
|
101
|
+
`parameters[${si}].features[${i}]`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
86
108
|
{
|
|
87
109
|
id: "control-key-not-in-defaults",
|
|
88
110
|
run: ({ part }) => {
|
package/src/framework/mount.js
CHANGED
|
@@ -23,16 +23,24 @@ import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } fr
|
|
|
23
23
|
import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
|
|
24
24
|
import { exportablePartNames, partLabel } from "./export-select.js";
|
|
25
25
|
import { createExportController, backendForFormat } from "./export-controller.js";
|
|
26
|
+
import { createCaptureBuild } from "./capture-build.js";
|
|
26
27
|
import { attachAnimationControls } from "./animation-controls.js";
|
|
28
|
+
import { resolveDefaultView } from "./default-view.js";
|
|
27
29
|
|
|
28
30
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
29
31
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
30
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation }) {
|
|
32
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView }) {
|
|
31
33
|
return {
|
|
32
34
|
ready, dispose, setParams,
|
|
33
35
|
// Part-declared animation playback (spec 2026-08-02): null when the part
|
|
34
36
|
// declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
|
|
35
37
|
animation: animation ?? null,
|
|
38
|
+
// Active view name (never null once mounted). See onViewChange for the push side.
|
|
39
|
+
getView,
|
|
40
|
+
// Programmatic tab switch; false for a name the part doesn't declare.
|
|
41
|
+
setView,
|
|
42
|
+
// Offscreen render of a named view (default when omitted, or on an unknown name).
|
|
43
|
+
captureView,
|
|
36
44
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
37
45
|
captureCurrent: (opts) => viewer.captureCurrent(opts),
|
|
38
46
|
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
@@ -82,10 +90,17 @@ function createCleanupStack() {
|
|
|
82
90
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
83
91
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
84
92
|
//
|
|
85
|
-
// Embedding contract (0.
|
|
86
|
-
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
|
|
93
|
+
// Embedding contract (0.45.0):
|
|
94
|
+
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
|
|
87
95
|
// await runtime.ready; // first successful build of the default view
|
|
88
96
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
97
|
+
// runtime.getView(); // active view name (string), never null once mounted
|
|
98
|
+
// runtime.setView("lid"); // switch tab programmatically; returns false (and leaves the
|
|
99
|
+
// // active tab untouched) for a name the part doesn't declare
|
|
100
|
+
// await runtime.captureView(); // JPEG data URL of the DEFAULT view rendered offscreen (pass
|
|
101
|
+
// // a name for a specific view, falling back to the default for
|
|
102
|
+
// // an unknown one), never disturbing the active tab or the live
|
|
103
|
+
// // scene; null on failure (never throws)
|
|
89
104
|
// runtime.captureCurrent({ size: 2048 }); // one offscreen render of the user's current
|
|
90
105
|
// // framing (live camera pose + viewport aspect) at the
|
|
91
106
|
// // given long-edge resolution → JPEG data URL, or null
|
|
@@ -121,10 +136,13 @@ function createCleanupStack() {
|
|
|
121
136
|
// runtime.dispose(); // full teardown
|
|
122
137
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
123
138
|
// those are repaired in the viewer and produce no build at all.
|
|
139
|
+
// onViewChange fires once synchronously during mount with the initial resolved
|
|
140
|
+
// view (before ready), then again on every subsequent view change (user click
|
|
141
|
+
// or a programmatic setView) — always the new view name.
|
|
124
142
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
125
143
|
// exactly once here — submodules take element refs and never query the document.
|
|
126
144
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
127
|
-
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload,
|
|
145
|
+
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange,
|
|
128
146
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
129
147
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
130
148
|
const byId = (id) => document.getElementById(id);
|
|
@@ -214,10 +232,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
214
232
|
// View tabs (generated from part.views) + live params. A tab switch shows the
|
|
215
233
|
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
216
234
|
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
217
|
-
onChange: () => {
|
|
235
|
+
onChange: (name) => {
|
|
236
|
+
pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
|
|
237
|
+
onViewChange?.(name);
|
|
238
|
+
},
|
|
218
239
|
});
|
|
219
240
|
cleanup.defer(() => tabsCtl.detach());
|
|
220
241
|
const view = () => tabsCtl.current();
|
|
242
|
+
// Tell the embedder the starting tab exactly once, synchronously, so a host
|
|
243
|
+
// (partforge-cloud) never has to poll getView() to learn where we opened.
|
|
244
|
+
onViewChange?.(tabsCtl.current());
|
|
221
245
|
const params = { ...part.defaults };
|
|
222
246
|
|
|
223
247
|
// Current selection context for the pickers: the active view + live params +
|
|
@@ -364,6 +388,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
364
388
|
function onWorkerMessage({ data }) {
|
|
365
389
|
// Headless exportParts() correlation: consume its own replies first.
|
|
366
390
|
if (exportCtl.handleMessage(data, onDownload)) return;
|
|
391
|
+
// captureView's off-loop build channel: consume its replies before the
|
|
392
|
+
// live `meshes` case — capture-meshes must never touch live cache/display.
|
|
393
|
+
if (captureBuild.handleMessage(data)) return;
|
|
367
394
|
switch (data.type) {
|
|
368
395
|
case "ready":
|
|
369
396
|
loop.ready(); // auto-build the default view (keeps the busy spinner up)
|
|
@@ -444,6 +471,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
444
471
|
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
|
|
445
472
|
cleanup.defer(() => service.terminate());
|
|
446
473
|
|
|
474
|
+
const captureBuild = createCaptureBuild({ send: (msg, backend) => service.send(msg, backend) });
|
|
475
|
+
cleanup.defer(() => captureBuild.dispose());
|
|
476
|
+
|
|
447
477
|
const exportCtl = createExportController({
|
|
448
478
|
send: (msg, backend) => service.send(msg, backend),
|
|
449
479
|
currentView: () => view(),
|
|
@@ -558,9 +588,31 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
558
588
|
cleanup.dispose();
|
|
559
589
|
}
|
|
560
590
|
|
|
591
|
+
// Off-loop offscreen thumbnail: builds `viewName` (or the part's resolved
|
|
592
|
+
// default when omitted/unknown) via captureBuild's correlated channel, then
|
|
593
|
+
// renders it in a throwaway scene via viewer.renderMeshPayloads. Never
|
|
594
|
+
// touches the active tab, getView(), or the live scene — best-effort: any
|
|
595
|
+
// failure, including a resolved-null from a worker build failure (4A
|
|
596
|
+
// settles rather than throwing), returns null.
|
|
597
|
+
const captureView = async (viewName, opts = {}) => {
|
|
598
|
+
try {
|
|
599
|
+
const target = (viewName && part.views?.[viewName]) ? viewName : resolveDefaultView(part);
|
|
600
|
+
const subparts = viewSubParts(part, target, params);
|
|
601
|
+
if (!subparts.length) return null;
|
|
602
|
+
const meshes = await captureBuild.request({ subparts, view: target, params, backend: backendFor() });
|
|
603
|
+
if (!meshes || !meshes.length) return null; // 4A resolves null on a worker build failure
|
|
604
|
+
return viewer.renderMeshPayloads(meshes, { size: 640, quality: 0.8, angle: "iso", ...opts });
|
|
605
|
+
} catch {
|
|
606
|
+
return null; // best-effort: a failed thumbnail never breaks the caller
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
561
610
|
return makeHandle({
|
|
562
611
|
ready, dispose, viewer, setParams,
|
|
563
612
|
setHostPane: paneTabs.setHostPane,
|
|
613
|
+
getView: view, // () => tabsCtl.current()
|
|
614
|
+
setView: (name) => tabsCtl.select(name),
|
|
615
|
+
captureView,
|
|
564
616
|
listExportableParts: () =>
|
|
565
617
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
566
618
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
@@ -48,6 +48,19 @@ export function createViewTabs(el, part, { onChange }) {
|
|
|
48
48
|
|
|
49
49
|
return {
|
|
50
50
|
current: () => view,
|
|
51
|
+
// Programmatic switch — the click path without the click. Used by an
|
|
52
|
+
// embedder (mount's handle.setView) to change tabs from outside the DOM.
|
|
53
|
+
// Returns false for a name that isn't a tab so callers can validate.
|
|
54
|
+
select: (name) => {
|
|
55
|
+
if (name === view) return true; // already active — nothing to do
|
|
56
|
+
const btn = [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === name);
|
|
57
|
+
if (!btn) return false;
|
|
58
|
+
view = name;
|
|
59
|
+
saveView(partKey, view);
|
|
60
|
+
setActive(btn);
|
|
61
|
+
onChange(view);
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
51
64
|
detach: () => {
|
|
52
65
|
el.removeEventListener("click", onClick);
|
|
53
66
|
if (generated) el.innerHTML = ""; // we generated these buttons; hand-written markup stays
|
|
@@ -3,8 +3,15 @@ import * as THREE from "three";
|
|
|
3
3
|
const KEY_COLOR = 0xffffff, KEY_INTENSITY = 1.45;
|
|
4
4
|
const FILL_COLOR = 0xe5efff, FILL_INTENSITY = 0.65;
|
|
5
5
|
|
|
6
|
+
// The persistent sky/ground ambient. Shared with the offscreen thumbnail path
|
|
7
|
+
// (viewer.js renderMeshPayloads), which builds its own throwaway scene and so needs
|
|
8
|
+
// the same hemisphere fill to avoid rendering faces outside the key/fill cones near-black.
|
|
9
|
+
export function createHemisphereLight() {
|
|
10
|
+
return new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export function addViewerLights(scene) {
|
|
7
|
-
const hemisphere =
|
|
14
|
+
const hemisphere = createHemisphereLight();
|
|
8
15
|
const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
|
|
9
16
|
key.position.set(8, 14, 10);
|
|
10
17
|
const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
|
package/src/framework/viewer.js
CHANGED
|
@@ -6,7 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
|
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
8
|
import { createCameraTween } from "./camera-tween.js";
|
|
9
|
-
import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
|
|
9
|
+
import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
|
|
10
10
|
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
11
11
|
|
|
12
12
|
// three renders into a render target in the LINEAR working colour space: as of r184
|
|
@@ -166,7 +166,7 @@ export function createViewer(container, part) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
// CAD-style feature edge lines (anti-aliased "fat" lines), one per sub-part.
|
|
169
|
-
const EDGE_ANGLE = 35; // deg —
|
|
169
|
+
const EDGE_ANGLE = 35; // deg — last-ditch threshold for payloads with no kernel edge data
|
|
170
170
|
const lineMaterial = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 }); // ~10% lighter, 1 px
|
|
171
171
|
lineMaterial.resolution.set(1, 1); // real size set by resize() below
|
|
172
172
|
const subLines = Object.fromEntries(
|
|
@@ -246,10 +246,10 @@ export function createViewer(container, part) {
|
|
|
246
246
|
controls.addEventListener("start", onControlsStart);
|
|
247
247
|
function onCameraStart(cb) { cameraStartListeners.add(cb); return () => cameraStartListeners.delete(cb); }
|
|
248
248
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
249
|
+
// Fallback creasing for payloads with no kernel normals. Both backends now
|
|
250
|
+
// ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
|
|
251
|
+
// analytic B-rep normals), so this path is last-ditch only — it must not be
|
|
252
|
+
// "improved" in lieu of fixing a backend that stopped sending normals.
|
|
253
253
|
const CREASE_ANGLE = Math.PI / 6; // 30°
|
|
254
254
|
|
|
255
255
|
// --- geometry builder -----------------------------------------------------
|
|
@@ -262,20 +262,23 @@ export function createViewer(container, part) {
|
|
|
262
262
|
const triCount = triangles ?? (indices ? indices.length : positions.length / 3) / 3;
|
|
263
263
|
let out;
|
|
264
264
|
if (normals?.length) {
|
|
265
|
-
// kernel-computed normals (
|
|
265
|
+
// kernel-computed normals (both backends) — smooth within a surface, hard at cut seams
|
|
266
266
|
geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
|
|
267
267
|
geo.computeBoundingBox();
|
|
268
268
|
out = geo;
|
|
269
269
|
} else {
|
|
270
|
-
// fallback (no kernel normals
|
|
270
|
+
// fallback (payload with no kernel normals — no current backend does this): crease from the triangle soup
|
|
271
271
|
out = toCreasedNormals(geo, CREASE_ANGLE);
|
|
272
272
|
out.computeBoundingBox();
|
|
273
273
|
}
|
|
274
274
|
out.userData.triangles = triCount;
|
|
275
275
|
if (featureIds) { out.userData.featureIds = featureIds; out.userData.features = features; }
|
|
276
|
-
// feature edge lines:
|
|
276
|
+
// feature edge lines: kernel-supplied segments are authoritative — an EMPTY
|
|
277
|
+
// array means "this solid has no feature edges" (e.g. a lone sphere), so
|
|
278
|
+
// draw none rather than falling back. Only a payload with NO edge data at
|
|
279
|
+
// all (edges === undefined; no current backend does this) derives by angle.
|
|
277
280
|
const lg = new LineSegmentsGeometry();
|
|
278
|
-
if (edges
|
|
281
|
+
if (edges) lg.setPositions(edges); // edges is already a well-formed (possibly zero-length) Float32Array
|
|
279
282
|
else lg.fromEdgesGeometry(new THREE.EdgesGeometry(out, EDGE_ANGLE));
|
|
280
283
|
out.userData.edges = lg;
|
|
281
284
|
return out;
|
|
@@ -427,7 +430,8 @@ export function createViewer(container, part) {
|
|
|
427
430
|
// no error, live view unaffected, wrong only in the capture.
|
|
428
431
|
const RT_OPTIONS = { samples: 4, stencilBuffer: true };
|
|
429
432
|
function renderOffscreen({ position, up, target },
|
|
430
|
-
{ width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}
|
|
433
|
+
{ width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {},
|
|
434
|
+
renderScene = scene) {
|
|
431
435
|
const cachedSize = width === _rtSize && height === _rtSize;
|
|
432
436
|
const rt = cachedSize
|
|
433
437
|
? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
|
|
@@ -451,7 +455,7 @@ export function createViewer(container, part) {
|
|
|
451
455
|
scene.add(capKey, capKey.target, capFill, capFill.target);
|
|
452
456
|
try {
|
|
453
457
|
renderer.setRenderTarget(rt);
|
|
454
|
-
renderer.render(
|
|
458
|
+
renderer.render(renderScene, cam);
|
|
455
459
|
// render() resolves the multisample renderbuffer into the target texture, so this
|
|
456
460
|
// reads antialiased pixels.
|
|
457
461
|
renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
|
|
@@ -510,6 +514,76 @@ export function createViewer(container, part) {
|
|
|
510
514
|
});
|
|
511
515
|
}
|
|
512
516
|
|
|
517
|
+
// Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
|
|
518
|
+
// Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
|
|
519
|
+
// canonical angle, renders through the parameterized renderOffscreen, and disposes
|
|
520
|
+
// everything. Never touches the live scene, camera, subMesh, or subCache. `payloads`
|
|
521
|
+
// is the worker's [{name, positions, normals, indices, …}] array — placement is
|
|
522
|
+
// already baked into shared-frame coords, so meshes are NOT recentred.
|
|
523
|
+
function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8 } = {}) {
|
|
524
|
+
if (disposed) return null; // same guard as captureCurrent/captureCanonicalViews — never touch a torn-down renderer
|
|
525
|
+
const tmpScene = new THREE.Scene();
|
|
526
|
+
const tmpPivot = new THREE.Group();
|
|
527
|
+
tmpPivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical, same as live pivot
|
|
528
|
+
tmpScene.add(tmpPivot);
|
|
529
|
+
|
|
530
|
+
const built = [];
|
|
531
|
+
for (const payload of payloads) {
|
|
532
|
+
const geo = buildGeometry(payload); // shared-frame coords, NOT recentred
|
|
533
|
+
const mesh = new THREE.Mesh(geo, materialFor(payload.name));
|
|
534
|
+
tmpPivot.add(mesh);
|
|
535
|
+
built.push(mesh);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Frame in WORLD space, AFTER the pivot rotation. The meshes are built in model
|
|
539
|
+
// coords but rendered rotated by tmpPivot, so a model-space bbox centre would aim
|
|
540
|
+
// the camera at the wrong point — an off-origin part would render off-centre or blank.
|
|
541
|
+
tmpPivot.updateMatrixWorld(true);
|
|
542
|
+
const box = new THREE.Box3().setFromObject(tmpPivot);
|
|
543
|
+
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
544
|
+
const radius = box.getSize(new THREE.Vector3()).length() / 2 || 1;
|
|
545
|
+
const pose = cameraPoseForView(angle, { center, radius });
|
|
546
|
+
|
|
547
|
+
// Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
|
|
548
|
+
// persistent hemisphere) live in the LIVE scene, which is never rendered here — so
|
|
549
|
+
// without our own ambient + camera-relative key/fill it comes back near-black.
|
|
550
|
+
const hemi = createHemisphereLight();
|
|
551
|
+
const capLights = createCaptureLights();
|
|
552
|
+
const poses = captureLightPoses(pose);
|
|
553
|
+
capLights.key.position.set(poses.key[0], poses.key[1], poses.key[2]);
|
|
554
|
+
capLights.fill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
|
|
555
|
+
for (const light of [capLights.key, capLights.fill]) {
|
|
556
|
+
light.target.position.set(pose.target[0], pose.target[1], pose.target[2]);
|
|
557
|
+
}
|
|
558
|
+
tmpScene.add(hemi, capLights.key, capLights.key.target, capLights.fill, capLights.fill.target);
|
|
559
|
+
|
|
560
|
+
// Feature-edge lines, so the thumbnail carries the same hole/seam/chamfer outlines the
|
|
561
|
+
// live viewer shows. A dedicated LineMaterial at the render resolution (the live one is
|
|
562
|
+
// sized to the on-screen canvas); added after framing so it can't perturb the bbox.
|
|
563
|
+
const lineMat = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 });
|
|
564
|
+
lineMat.resolution.set(size, size);
|
|
565
|
+
for (const mesh of built) {
|
|
566
|
+
const edges = mesh.geometry.userData.edges;
|
|
567
|
+
if (edges) tmpPivot.add(new LineSegments2(edges, lineMat));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
// fov matches the live camera (and captureViews/captureCurrent) — cameraPoseForView's
|
|
572
|
+
// distance is tuned to it, so a narrower fov would crop long, thin parts.
|
|
573
|
+
return renderOffscreen(pose, { width: size, height: size, fov: camera.fov, quality }, tmpScene);
|
|
574
|
+
} finally {
|
|
575
|
+
for (const mesh of built) {
|
|
576
|
+
mesh.geometry.userData.edges?.dispose();
|
|
577
|
+
mesh.geometry.dispose();
|
|
578
|
+
if (mesh.material !== material) mesh.material.dispose(); // clone only — never the shared singleton
|
|
579
|
+
}
|
|
580
|
+
lineMat.dispose();
|
|
581
|
+
hemi.dispose?.();
|
|
582
|
+
capLights.key.dispose?.();
|
|
583
|
+
capLights.fill.dispose?.();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
513
587
|
// --- render loop ----------------------------------------------------------
|
|
514
588
|
// The tween is applied after controls.update() so the cue wins the frame, and
|
|
515
589
|
// the frame listeners run before render so a playback frame draws its own pose.
|
|
@@ -523,7 +597,13 @@ export function createViewer(container, part) {
|
|
|
523
597
|
camera.position.fromArray(tw.position);
|
|
524
598
|
controls.target.fromArray(tw.target);
|
|
525
599
|
}
|
|
526
|
-
|
|
600
|
+
// Per-listener guard, because three re-arms requestAnimationFrame only AFTER
|
|
601
|
+
// this callback returns (WebGLAnimation.onAnimationFrame): a listener that
|
|
602
|
+
// throws would stop the rAF chain outright and freeze the viewer for good, not
|
|
603
|
+
// just skip a frame. Containment belongs here rather than in every subscriber.
|
|
604
|
+
for (const cb of [...frameListeners]) {
|
|
605
|
+
try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
|
|
606
|
+
}
|
|
527
607
|
if (cutaway.isEnabled) cutaway.updateForCamera();
|
|
528
608
|
renderer.render(scene, camera);
|
|
529
609
|
cutaway.renderOverlay(renderer, camera);
|
|
@@ -662,6 +742,7 @@ export function createViewer(container, part) {
|
|
|
662
742
|
frame,
|
|
663
743
|
captureCanonicalViews,
|
|
664
744
|
captureCurrent,
|
|
745
|
+
renderMeshPayloads,
|
|
665
746
|
onFrame,
|
|
666
747
|
tweenCameraTo,
|
|
667
748
|
cancelCameraTween,
|
package/src/framework/worker.js
CHANGED
|
@@ -110,7 +110,11 @@ export function runWorker(part) {
|
|
|
110
110
|
await handle(kernel, job.part, job.data, gated, { isStale });
|
|
111
111
|
} catch (err) {
|
|
112
112
|
// Same shape jobs.js posts for a failed build, so hosts need no new branch.
|
|
113
|
-
|
|
113
|
+
// Carry the job's jobId when it has one (capture/export are correlated by it):
|
|
114
|
+
// a boot failure hitting kernelFor here must reach the right controller, or a
|
|
115
|
+
// correlated caller (captureView, exportParts) would hang instead of settling.
|
|
116
|
+
const jobId = job.data?.jobId;
|
|
117
|
+
postMessage({ type: "error", message: String(err?.message || err), ...(jobId != null ? { jobId } : {}) });
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
} finally {
|
package/src/testing/render.js
CHANGED
|
@@ -86,8 +86,8 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
|
|
|
86
86
|
|
|
87
87
|
for (const m of meshes) {
|
|
88
88
|
const P = m.positions, N = m.normals, ind = m.indices;
|
|
89
|
-
// Manifold meshes are a non-indexed soup (3 consecutive verts/triangle)
|
|
90
|
-
//
|
|
89
|
+
// Manifold meshes are a non-indexed soup (3 consecutive verts/triangle);
|
|
90
|
+
// OCCT meshes are indexed. Both carry per-vertex normals.
|
|
91
91
|
const triCount = ind?.length ? ind.length / 3 : P.length / 9;
|
|
92
92
|
for (let t = 0; t < triCount; t++) {
|
|
93
93
|
const ai = ind?.length ? ind[t * 3] * 3 : t * 9;
|
package/types/index.d.ts
CHANGED
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
// their geometry helpers from "partforge/geometry".
|
|
6
6
|
|
|
7
7
|
import type { BackendName } from "./kernel.js";
|
|
8
|
-
import type { ParamValue, PartDefinition } from "./part.js";
|
|
8
|
+
import type { CanonicalView, ParamValue, PartDefinition } from "./part.js";
|
|
9
9
|
|
|
10
10
|
export * from "./kernel.js";
|
|
11
11
|
export * from "./part.js";
|
|
12
12
|
|
|
13
|
-
/** A canonical capture angle. */
|
|
14
|
-
export type CanonicalView = "iso" | "front" | "back" | "left" | "right" | "top" | "bottom";
|
|
15
|
-
|
|
16
13
|
/** Which pane a narrow layout shows. `null` hands selection back to partforge. */
|
|
17
14
|
export type HostPane = "stage" | "rail" | null;
|
|
18
15
|
|
|
@@ -110,6 +107,8 @@ export interface MountOptions {
|
|
|
110
107
|
onPick?: (event: PickEvent) => void;
|
|
111
108
|
/** Receive exported bytes instead of partforge's own DOM download. */
|
|
112
109
|
onDownload?: (file: DownloadPayload) => void;
|
|
110
|
+
/** The active view (tab) name — emitted once on mount, then on every change. */
|
|
111
|
+
onViewChange?: (view: string) => void;
|
|
113
112
|
/** @deprecated alias for `elements.viewer`. */
|
|
114
113
|
container?: HTMLElement | null;
|
|
115
114
|
/** @deprecated alias for `elements.controls`. */
|
|
@@ -134,6 +133,15 @@ export interface CaptureCurrentOptions {
|
|
|
134
133
|
quality?: number;
|
|
135
134
|
}
|
|
136
135
|
|
|
136
|
+
export interface CaptureViewOptions {
|
|
137
|
+
/** Square render resolution in px. Default 640. */
|
|
138
|
+
size?: number;
|
|
139
|
+
/** JPEG quality, 0..1. Default 0.8. */
|
|
140
|
+
quality?: number;
|
|
141
|
+
/** Canonical angle to render from. Default `"iso"`. */
|
|
142
|
+
angle?: CanonicalView | string;
|
|
143
|
+
}
|
|
144
|
+
|
|
137
145
|
/** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
|
|
138
146
|
export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
|
|
139
147
|
|
|
@@ -190,6 +198,17 @@ export interface PartRuntime {
|
|
|
190
198
|
* when disposed or nothing is built yet. Never throws.
|
|
191
199
|
*/
|
|
192
200
|
captureCurrent(opts?: CaptureCurrentOptions): string | null;
|
|
201
|
+
/** The active view (tab) name. Never null once mounted. */
|
|
202
|
+
getView(): string;
|
|
203
|
+
/** Switch the active view; `false` if the part declares no such view. Persists per part for the session. */
|
|
204
|
+
setView(name: string): boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Render a named view OFFSCREEN → a `data:image/jpeg;base64,…` string, or `null` on
|
|
207
|
+
* failure (a build error, a view with no sub-parts, or a disposed runtime). Omit
|
|
208
|
+
* `viewName` — or pass an unknown one — to render the part's DEFAULT view. Never
|
|
209
|
+
* disturbs the active tab, the live camera, or the on-screen scene.
|
|
210
|
+
*/
|
|
211
|
+
captureView(viewName?: string, opts?: CaptureViewOptions): Promise<string | null>;
|
|
193
212
|
/**
|
|
194
213
|
* Park/unpark the viewer: stops the render loop and releases the drawing
|
|
195
214
|
* buffer and cached capture target. For a host that hides the canvas without
|