partforge 0.121.1 → 0.123.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/docs/AUTHORING-PARTS.md +164 -3
- package/docs/KERNEL-CONTRACT.md +10 -0
- package/package.json +3 -1
- package/src/app-materials.js +23 -0
- package/src/framework/app.css +16 -1
- package/src/framework/cutaway.js +12 -0
- package/src/framework/geometry/blend-surfaces.js +256 -0
- package/src/framework/geometry/creased-normals.js +121 -7
- package/src/framework/geometry/manifold-backend.js +56 -15
- package/src/framework/geometry/mesh-fillet.js +55 -5
- package/src/framework/geometry/threemf.js +32 -3
- package/src/framework/jobs.js +2 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-materials.js +56 -0
- package/src/framework/materials/assets/SOURCES.md +90 -0
- package/src/framework/materials/assets/env-outdoor.jpg +0 -0
- package/src/framework/materials/assets/env-print-bed.jpg +0 -0
- package/src/framework/materials/assets/env-studio.jpg +0 -0
- package/src/framework/materials/assets/env-workshop.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-paper.jpg +0 -0
- package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
- package/src/framework/materials/assets.js +39 -0
- package/src/framework/materials/contact-shadow.js +155 -0
- package/src/framework/materials/environment.js +157 -0
- package/src/framework/materials/environments.js +82 -0
- package/src/framework/materials/patterns.js +243 -0
- package/src/framework/materials/physical.js +88 -0
- package/src/framework/materials/presets.js +89 -0
- package/src/framework/materials/print-bed.js +169 -0
- package/src/framework/materials/print-frame.js +18 -0
- package/src/framework/materials/resolve.js +117 -0
- package/src/framework/materials/tonemap-readback.js +36 -0
- package/src/framework/materials/uv.js +42 -0
- package/src/framework/mount.js +121 -4
- package/src/framework/realistic-controls.js +78 -0
- package/src/framework/selection/feature-highlight.js +3 -0
- package/src/framework/view-state.js +27 -1
- package/src/framework/viewer.js +623 -34
- package/src/materials-worker.js +3 -0
- package/src/parts/bracket.js +2 -1
- package/src/parts/hinged-box.js +3 -1
- package/src/parts/material-swatches.js +40 -0
- package/src/parts/planter.js +2 -1
- package/src/parts/propeller.js +2 -1
- package/src/parts/scott-label.js +3 -2
- package/src/parts/screw.js +2 -1
- package/types/index.d.ts +48 -3
- package/types/part.d.ts +15 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// src/framework/materials/resolve.js
|
|
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,
|
|
4
|
+
// an out-of-range override clamps, an unknown key is ignored — and every one of
|
|
5
|
+
// those is returned as an `issue` so lint can warn about it. Appearance must
|
|
6
|
+
// never be able to fail a build.
|
|
7
|
+
import { PRESETS, DEFAULT_PRESET_ID } from "./presets.js";
|
|
8
|
+
import { ENVIRONMENTS, DEFAULT_ENVIRONMENT_ID } from "./environments.js";
|
|
9
|
+
|
|
10
|
+
export const OVERRIDE_RANGES = {
|
|
11
|
+
roughness: [0, 1],
|
|
12
|
+
metalness: [0, 1],
|
|
13
|
+
clearcoat: [0, 1],
|
|
14
|
+
clearcoatRoughness: [0, 1],
|
|
15
|
+
anisotropy: [0, 1],
|
|
16
|
+
textureScale: [0.01, 1000],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const DISPLAY_KEYS = ["color", "opacity", "material", ...Object.keys(OVERRIDE_RANGES)];
|
|
20
|
+
|
|
21
|
+
const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
|
|
22
|
+
|
|
23
|
+
export function resolveMaterial(display) {
|
|
24
|
+
const d = display && typeof display === "object" ? display : {};
|
|
25
|
+
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
|
+
}
|
|
31
|
+
const params = {
|
|
32
|
+
color: preset.color,
|
|
33
|
+
metalness: preset.metalness,
|
|
34
|
+
roughness: preset.roughness,
|
|
35
|
+
clearcoat: preset.clearcoat ?? 0,
|
|
36
|
+
clearcoatRoughness: preset.clearcoatRoughness ?? 0,
|
|
37
|
+
anisotropy: preset.anisotropy ?? 0,
|
|
38
|
+
transmission: preset.transmission ?? 0,
|
|
39
|
+
thickness: preset.thickness ?? 0,
|
|
40
|
+
ior: preset.ior ?? 1.5,
|
|
41
|
+
pattern: preset.pattern ?? null,
|
|
42
|
+
textureScale: preset.textureScale ?? 1,
|
|
43
|
+
envIntensity: preset.envIntensity ?? 1,
|
|
44
|
+
specularIntensity: preset.specularIntensity ?? 1,
|
|
45
|
+
textures: preset.textures ?? null,
|
|
46
|
+
opacity: 1,
|
|
47
|
+
};
|
|
48
|
+
if (isColor(d.color)) params.color = d.color;
|
|
49
|
+
if (typeof d.opacity === "number" && Number.isFinite(d.opacity)) params.opacity = Math.min(1, Math.max(0, d.opacity));
|
|
50
|
+
for (const [key, [lo, hi]] of Object.entries(OVERRIDE_RANGES)) {
|
|
51
|
+
if (!(key in d)) continue;
|
|
52
|
+
const v = d[key];
|
|
53
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
54
|
+
issues.push({ kind: "material-override-clamped", key, value: v, clampedTo: params[key] });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const c = Math.min(hi, Math.max(lo, v));
|
|
58
|
+
if (c !== v) issues.push({ kind: "material-override-clamped", key, value: v, clampedTo: c });
|
|
59
|
+
params[key] = c;
|
|
60
|
+
}
|
|
61
|
+
for (const key of Object.keys(d)) {
|
|
62
|
+
if (!DISPLAY_KEYS.includes(key)) issues.push({ kind: "material-key-unknown", key, value: d[key] });
|
|
63
|
+
}
|
|
64
|
+
// `tinted`: the author named a colour. A textured preset's colour map carries
|
|
65
|
+
// its own colour, so only an explicit tint multiplies it (physical.js).
|
|
66
|
+
return { preset, params, issues, tinted: isColor(d.color) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// CAD-view appearance: the resolved colour on the CAD MeshStandardMaterial,
|
|
70
|
+
// flattened so it reads without an environment map (a metalness-1 material with
|
|
71
|
+
// no environment renders near-black), and lifted to a minimum luminance so a
|
|
72
|
+
// black-rubber part doesn't hide its geometry in the agent's screenshots.
|
|
73
|
+
const MIN_CAD_LUMA = 60; // 0..255, Rec. 709
|
|
74
|
+
export function cadAppearance(display) {
|
|
75
|
+
const d = display && typeof display === "object" ? display : {};
|
|
76
|
+
const { params } = resolveMaterial(d);
|
|
77
|
+
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 };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
color: liftLuma(params.color, MIN_CAD_LUMA),
|
|
83
|
+
metalness: Math.min(params.metalness, 0.5),
|
|
84
|
+
roughness: Math.max(params.roughness, 0.3),
|
|
85
|
+
opacity: params.opacity,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function liftLuma(color, min) {
|
|
90
|
+
let r = (color >> 16) & 255, g = (color >> 8) & 255, b = color & 255;
|
|
91
|
+
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
92
|
+
if (luma >= min) return color;
|
|
93
|
+
// Mix toward mid-grey just far enough to reach `min`, keeping hue.
|
|
94
|
+
const t = (min - luma) / (128 - luma || 1);
|
|
95
|
+
r = Math.round(r + (128 - r) * t); g = Math.round(g + (128 - g) * t); b = Math.round(b + (128 - b) * t);
|
|
96
|
+
return (r << 16) | (g << 8) | b;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 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.
|
|
102
|
+
export function printColor(display) {
|
|
103
|
+
if (!display || typeof display !== "object") return null;
|
|
104
|
+
if (isColor(display.color)) return display.color;
|
|
105
|
+
if (display.material == null) return null;
|
|
106
|
+
return resolveMaterial(display).params.color;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function declaresMaterials(part) {
|
|
110
|
+
return Object.values(part?.parts ?? {}).some((sp) => sp?.display?.material != null);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resolveEnvironmentId(id) {
|
|
114
|
+
if (id == null) return { id: DEFAULT_ENVIRONMENT_ID, known: true };
|
|
115
|
+
if (typeof id === "string" && Object.hasOwn(ENVIRONMENTS, id)) return { id, known: true };
|
|
116
|
+
return { id: DEFAULT_ENVIRONMENT_ID, known: false };
|
|
117
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// three applies tone mapping and output colour space ONLY on the canvas path
|
|
2
|
+
// (r184: a bound render target gets NoToneMapping and linear output). Realistic
|
|
3
|
+
// captures therefore render HDR into a half-float target and finish here, in JS:
|
|
4
|
+
// exposure → Khronos PBR Neutral (the same curve THREE.NeutralToneMapping uses on
|
|
5
|
+
// the live canvas) → sRGB. Pure; no three import.
|
|
6
|
+
const START = 0.8 - 0.04, DESAT = 0.15;
|
|
7
|
+
|
|
8
|
+
function neutral(r, g, b) {
|
|
9
|
+
const x = Math.min(r, g, b);
|
|
10
|
+
const offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
|
|
11
|
+
r -= offset; g -= offset; b -= offset;
|
|
12
|
+
const peak = Math.max(r, g, b);
|
|
13
|
+
if (peak < START) return [r, g, b];
|
|
14
|
+
const d = 1 - START;
|
|
15
|
+
const newPeak = 1 - (d * d) / (peak + d - START);
|
|
16
|
+
const s = newPeak / peak;
|
|
17
|
+
r *= s; g *= s; b *= s;
|
|
18
|
+
const t = 1 - 1 / (DESAT * (peak - newPeak) + 1);
|
|
19
|
+
return [r + (newPeak - r) * t, g + (newPeak - g) * t, b + (newPeak - b) * t];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const srgb = (l) => {
|
|
23
|
+
const c = Math.min(1, Math.max(0, l));
|
|
24
|
+
return Math.round(255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Linear RGBA floats → sRGB RGBA bytes. Alpha is forced opaque: a realistic
|
|
28
|
+
// capture always has the environment backdrop behind it.
|
|
29
|
+
export function neutralToneMapToSrgb8(src, exposure = 1) {
|
|
30
|
+
const out = new Uint8ClampedArray(src.length);
|
|
31
|
+
for (let i = 0; i < src.length; i += 4) {
|
|
32
|
+
const [r, g, b] = neutral(src[i] * exposure, src[i + 1] * exposure, src[i + 2] * exposure);
|
|
33
|
+
out[i] = srgb(r); out[i + 1] = srgb(g); out[i + 2] = srgb(b); out[i + 3] = 255;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Box-projected UVs and brush tangents, added only for realistic mode. three's
|
|
2
|
+
// anisotropy (brushed metal) needs a tangent frame, and CAD meshes carry neither
|
|
3
|
+
// UVs nor tangents. Projection is in OBJECT space along each vertex normal's
|
|
4
|
+
// dominant axis, so it rides with the sub-part and never swims. Nothing samples a
|
|
5
|
+
// texture through these UVs — triplanar patterns (patterns.js) ignore them.
|
|
6
|
+
//
|
|
7
|
+
// The TANGENT attribute is what the brush actually follows. Without one, three
|
|
8
|
+
// derives the frame per pixel from screen-space UV derivatives, which is constant
|
|
9
|
+
// across each triangle: on a fillet or a corner every facet read as its own flat
|
|
10
|
+
// brushed patch, and a triangle whose corners picked different projection axes
|
|
11
|
+
// got a garbage frame outright. The per-vertex tangent is the projection's u-axis
|
|
12
|
+
// (world X, or Y where X dominates the normal) flattened into the tangent plane, so
|
|
13
|
+
// it interpolates smoothly with the (analytic) vertex normals. It turns 90° only
|
|
14
|
+
// across the one facet row where the dominant axis changes, and never through
|
|
15
|
+
// zero — the two candidate axes are orthogonal.
|
|
16
|
+
import * as THREE from "three";
|
|
17
|
+
|
|
18
|
+
export function ensureBoxUVs(geometry, scaleMm = 20) {
|
|
19
|
+
if (geometry.userData.boxUVs && geometry.attributes.uv && geometry.attributes.tangent) return geometry;
|
|
20
|
+
const pos = geometry.attributes.position, nrm = geometry.attributes.normal;
|
|
21
|
+
const uv = new Float32Array(pos.count * 2);
|
|
22
|
+
const tangent = new Float32Array(pos.count * 4);
|
|
23
|
+
for (let i = 0; i < pos.count; i++) {
|
|
24
|
+
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
|
|
25
|
+
const nx = nrm ? nrm.getX(i) : 0, ny = nrm ? nrm.getY(i) : 0, nz = nrm ? nrm.getZ(i) : 1;
|
|
26
|
+
const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
|
|
27
|
+
let u, v;
|
|
28
|
+
if (az >= ax && az >= ay) { u = x; v = y; } else if (ay >= ax) { u = x; v = z; } else { u = y; v = z; }
|
|
29
|
+
uv[i * 2] = u / scaleMm; uv[i * 2 + 1] = v / scaleMm;
|
|
30
|
+
// u-axis minus its normal component; |that| ≥ √½ because the axis is never the
|
|
31
|
+
// dominant one, so the normalize is always well-conditioned
|
|
32
|
+
const uxAxis = !(ax > ay && ax > az);
|
|
33
|
+
const d = uxAxis ? nx : ny;
|
|
34
|
+
let tx = (uxAxis ? 1 : 0) - d * nx, ty = (uxAxis ? 0 : 1) - d * ny, tz = -d * nz;
|
|
35
|
+
const L = Math.hypot(tx, ty, tz) || 1;
|
|
36
|
+
tangent[i * 4] = tx / L; tangent[i * 4 + 1] = ty / L; tangent[i * 4 + 2] = tz / L; tangent[i * 4 + 3] = 1;
|
|
37
|
+
}
|
|
38
|
+
geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
|
|
39
|
+
geometry.setAttribute("tangent", new THREE.BufferAttribute(tangent, 4));
|
|
40
|
+
geometry.userData.boxUVs = true;
|
|
41
|
+
return geometry;
|
|
42
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -7,12 +7,16 @@ import { attachRail } from "./rail.js";
|
|
|
7
7
|
import { declaredSourceLookup } from "./panel/declared-source.js";
|
|
8
8
|
import { attachMobileTabs } from "./mobile-tabs.js";
|
|
9
9
|
import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
|
|
10
|
-
import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
|
|
10
|
+
import { loadCamera, loadProjection, saveProjection, loadRenderMode, loadEnvironment } from "./view-state.js";
|
|
11
|
+
import { attachRealisticControls } from "./realistic-controls.js";
|
|
12
|
+
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
13
|
+
import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
|
|
14
|
+
import { printFrameMatrix } from "./materials/print-frame.js";
|
|
11
15
|
import { buildControls } from "./controls.js";
|
|
12
16
|
import { relevantParamKeys } from "./param-deps.js";
|
|
13
17
|
import { createMeshCache } from "./mesh-cache.js";
|
|
14
18
|
import { createGeometryService } from "./geometry-service.js";
|
|
15
|
-
import { viewSubParts } from "./part-model.js";
|
|
19
|
+
import { viewSubParts, resolveParams } from "./part-model.js";
|
|
16
20
|
import { resolveDerived } from "./derive.js";
|
|
17
21
|
import { createBackendPolicy } from "./backend-select.js";
|
|
18
22
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
@@ -63,7 +67,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
63
67
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
64
68
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
65
69
|
|
|
66
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors }) {
|
|
70
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, renderMode, environment, declaresMaterials, renderViews }) {
|
|
67
71
|
return {
|
|
68
72
|
ready, dispose, setParams,
|
|
69
73
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -77,11 +81,19 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
77
81
|
setView,
|
|
78
82
|
// Offscreen render of a named view (default when omitted, or on an unknown name).
|
|
79
83
|
captureView,
|
|
84
|
+
// Always CAD, whatever the live render mode: these are the agent's renders.
|
|
80
85
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
86
|
+
// captureViews in a chosen appearance: renderMode "cad" (the default) is
|
|
87
|
+
// exactly captureViews() in either live mode; "realistic" borrows the realistic look for the
|
|
88
|
+
// capture (waiting on the environment's assets) without switching the live
|
|
89
|
+
// view. Rejects if realistic's assets fail to load.
|
|
90
|
+
renderViews: renderViews ?? (async (viewNames) => viewer.captureCanonicalViews(viewNames)),
|
|
81
91
|
// `recenter` reads the sub-part geometry only, so with measurement pins on
|
|
82
92
|
// screen the dimension labels — which sit beside the part, not on it —
|
|
83
93
|
// could land outside the centred window. A dimensioned capture therefore
|
|
84
94
|
// keeps the user's exact framing; a host wanting both re-frames first.
|
|
95
|
+
// Follows the live render mode unless opts.renderMode pins one ("realistic"
|
|
96
|
+
// only once the environment has loaded — see viewer.captureCurrent).
|
|
85
97
|
captureCurrent: (opts) => viewer.captureCurrent(
|
|
86
98
|
opts?.recenter && (measure ?? NOOP_MEASURE).isEnabled() && (measure ?? NOOP_MEASURE).pinCount() > 0
|
|
87
99
|
? { ...opts, recenter: false }
|
|
@@ -100,6 +112,15 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
100
112
|
camera: viewer.getCameraState(),
|
|
101
113
|
projection: viewer.getProjection?.() ?? "perspective",
|
|
102
114
|
cutaway: viewer.getCutawayState?.() ?? null,
|
|
115
|
+
// The mode the user is headed for, not only the one on screen: a
|
|
116
|
+
// realistic restore or switch still loading reads "realistic", so a
|
|
117
|
+
// remount mid-load (the cloud remounts on every edit) doesn't drop it.
|
|
118
|
+
// A failed load settles back to "cad".
|
|
119
|
+
renderMode: viewer.isRealisticPending?.() ? "realistic" : viewer.getRenderMode?.() ?? "cad",
|
|
120
|
+
// Only an environment someone CHOSE: carrying the one merely seeded
|
|
121
|
+
// from meta.environment (or the default) would outrank the part's own
|
|
122
|
+
// meta.environment on the next mount, masking an edit to it.
|
|
123
|
+
...(viewer.isEnvironmentChosen?.() ? { environment: viewer.getEnvironment() } : {}),
|
|
103
124
|
}),
|
|
104
125
|
// Every custom control's transient state (selection, a scroll position),
|
|
105
126
|
// keyed by param, as plain JSON — the panel's twin of getViewerState. Hand
|
|
@@ -170,6 +191,18 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
170
191
|
release: () => {},
|
|
171
192
|
onAnchorChange: () => () => {},
|
|
172
193
|
},
|
|
194
|
+
// Realistic render mode, a viewer-wide display mode like `projection` and
|
|
195
|
+
// in the same shape. set() resolves to the mode actually in effect — "cad"
|
|
196
|
+
// if the realistic assets failed to load; onChange receives the viewer's
|
|
197
|
+
// {mode, busy, error} event (busy while assets load).
|
|
198
|
+
renderMode: renderMode ?? { get: () => "cad", set: async () => "cad", onChange: () => () => {} },
|
|
199
|
+
// The realistic environment. set() resolves to the id in effect (a failed
|
|
200
|
+
// switch while realistic reverts to the one still shown, and onChange hears
|
|
201
|
+
// both); list() is every environment as {id, label}, for a host picker.
|
|
202
|
+
environment: environment ?? { get: () => "studio", set: async (id) => id, onChange: () => () => {}, list: () => [] },
|
|
203
|
+
// True when any sub-part names a display.material — a host can offer the
|
|
204
|
+
// realistic toggle only where it shows something a CAD view does not.
|
|
205
|
+
declaresMaterials: declaresMaterials ?? false,
|
|
173
206
|
};
|
|
174
207
|
}
|
|
175
208
|
|
|
@@ -285,6 +318,24 @@ function createCleanupStack() {
|
|
|
285
318
|
// // "perspective" | "orthographic". Drives the LIVE view
|
|
286
319
|
// // and captureCurrent only — captureCanonicalViews,
|
|
287
320
|
// // renderMeshPayloads and the CLI stay perspective.
|
|
321
|
+
// runtime.renderMode: { get, set, onChange }
|
|
322
|
+
// // "cad" | "realistic". set() resolves to the mode in
|
|
323
|
+
// // effect ("cad" if the realistic assets failed);
|
|
324
|
+
// // onChange gets {mode, busy, error}. Same shape as
|
|
325
|
+
// // projection. Drives the live view; captureCurrent
|
|
326
|
+
// // follows it unless given { renderMode }. captureViews
|
|
327
|
+
// // is always CAD — the live mode never changes what
|
|
328
|
+
// // the agent sees.
|
|
329
|
+
// runtime.environment: { get, set, onChange, list }
|
|
330
|
+
// // the realistic environment ("studio" | "workshop" |
|
|
331
|
+
// // "print-bed" | "outdoor"); list() → [{id, label}].
|
|
332
|
+
// // A failed switch while realistic reverts, and onChange
|
|
333
|
+
// // hears the revert.
|
|
334
|
+
// runtime.declaresMaterials // true when any sub-part names a display.material
|
|
335
|
+
// await runtime.renderViews(["front"], { renderMode: "realistic" });
|
|
336
|
+
// // captureViews in a chosen appearance, without
|
|
337
|
+
// // switching the live view; "cad" is exactly captureViews(),
|
|
338
|
+
// // CAD in either live mode
|
|
288
339
|
// runtime.dispose(); // full teardown
|
|
289
340
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
290
341
|
// those are repaired in the viewer and produce no build at all.
|
|
@@ -307,7 +358,8 @@ function createCleanupStack() {
|
|
|
307
358
|
// // KB of base64 apiece, so a host should not assume this
|
|
308
359
|
// // payload is small, only that it is bounded.
|
|
309
360
|
// viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
|
|
310
|
-
// // resume the camera, projection
|
|
361
|
+
// // resume the camera, projection, cutaway, render mode and
|
|
362
|
+
// // environment where that mount
|
|
311
363
|
// // left them. For a host that applies edits by REMOUNTING: the
|
|
312
364
|
// // part changed, the user's view of it should not. Omit on a
|
|
313
365
|
// // first mount — the viewer then restores its own persisted
|
|
@@ -338,6 +390,13 @@ function createCleanupStack() {
|
|
|
338
390
|
// // not a degraded one). A rejection is reported through the
|
|
339
391
|
// // control's own onError; the widget keeps the converted blob
|
|
340
392
|
// // so a retry costs a network call, not a reconvert.
|
|
393
|
+
// elements.chrome.realistic / .environment // optional realistic-mode viewbar controls — a
|
|
394
|
+
// // <button> toggle and an (empty) <select> the mount
|
|
395
|
+
// // fills with the environments; default ids #realistic
|
|
396
|
+
// // and #environment. Without them the host drives
|
|
397
|
+
// // runtime.renderMode / runtime.environment itself.
|
|
398
|
+
// // Both preferences persist like the theme (and carry
|
|
399
|
+
// // in viewerState, which outranks what is stored).
|
|
341
400
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
342
401
|
// exactly once here — submodules take element refs and never query the document.
|
|
343
402
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
@@ -380,6 +439,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
380
439
|
measure: elements.chrome?.measure ?? byId("measure"),
|
|
381
440
|
annotate: elements.chrome?.annotate ?? byId("annotate"),
|
|
382
441
|
railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
|
|
442
|
+
realistic: elements.chrome?.realistic ?? byId("realistic"),
|
|
443
|
+
environment: elements.chrome?.environment ?? byId("environment"),
|
|
383
444
|
},
|
|
384
445
|
};
|
|
385
446
|
|
|
@@ -592,6 +653,24 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
592
653
|
// A carried state outranks the persisted preference: it is this session's
|
|
593
654
|
// live answer, where the stored one is the last page-reload's.
|
|
594
655
|
viewer.setProjection(viewerState?.projection ?? loadProjection());
|
|
656
|
+
// The environment by the same precedence (carried, stored, then the part's
|
|
657
|
+
// own meta.environment — which the viewer already starts on, unchosen). A
|
|
658
|
+
// carried or stored one was chosen, so it stays chosen and carries on. In
|
|
659
|
+
// CAD this only records the id, so the realistic restore below loads the
|
|
660
|
+
// right rig first time rather than the default and then this one.
|
|
661
|
+
const initialEnv = viewerState?.environment ?? loadEnvironment();
|
|
662
|
+
if (initialEnv) viewer.setEnvironment(initialEnv);
|
|
663
|
+
// Realistic mode by the same precedence, requested HERE rather than on the
|
|
664
|
+
// first accepted build: every mount has a fresh renderer, so the
|
|
665
|
+
// environment loads again, and starting now runs that alongside the build
|
|
666
|
+
// instead of showing CAD until the build lands and only then waiting on
|
|
667
|
+
// the assets (a CAD flash on every edit for a host that remounts per
|
|
668
|
+
// edit). Nothing needs a part yet — unbuilt sub-parts compile when first
|
|
669
|
+
// shown, and showAssembly brings the ground to the part when it arrives.
|
|
670
|
+
// While it loads the viewer reports it pending, which is what
|
|
671
|
+
// getViewerState carries; an explicit mode change supersedes it, and a
|
|
672
|
+
// failed load settles to CAD.
|
|
673
|
+
if ((viewerState?.renderMode ?? loadRenderMode() ?? "cad") === "realistic") viewer.setRenderMode("realistic");
|
|
595
674
|
const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
|
|
596
675
|
cleanup.defer(() => viewcube.detach());
|
|
597
676
|
cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
|
|
@@ -810,6 +889,25 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
810
889
|
}
|
|
811
890
|
}
|
|
812
891
|
|
|
892
|
+
// Print frames for the layer-line pattern: the display → export map of the
|
|
893
|
+
// geometry just delivered, so layers run the way the part is printed rather
|
|
894
|
+
// than the way it is displayed. Computed at delivery (the frame describes
|
|
895
|
+
// the delivered mesh, which a later pose-only repair only moves) and only
|
|
896
|
+
// for sub-parts whose material draws layer lines — each one costs two
|
|
897
|
+
// geometry-free probe builds.
|
|
898
|
+
const layerLined = new Set(Object.keys(part.parts).filter((n) => {
|
|
899
|
+
try { return resolveMaterial(part.parts[n].display).params.pattern === "layer-lines"; } catch { return false; }
|
|
900
|
+
}));
|
|
901
|
+
const printFrames = {};
|
|
902
|
+
function recordPrintFrames(names) {
|
|
903
|
+
const wanted = names.filter((n) => layerLined.has(n));
|
|
904
|
+
if (!wanted.length) return;
|
|
905
|
+
let resolved;
|
|
906
|
+
try { resolved = resolveParams(part, params); } catch { return; } // diagnosed by the build
|
|
907
|
+
for (const n of wanted) printFrames[n] = printFrameMatrix(part.parts[n], { view: view(), ...resolved });
|
|
908
|
+
viewer.setPrintFrames({ ...printFrames });
|
|
909
|
+
}
|
|
910
|
+
|
|
813
911
|
// Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
|
|
814
912
|
const emptySubParts = new Set();
|
|
815
913
|
|
|
@@ -869,6 +967,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
869
967
|
// delivered, which buildDone() true guarantees is at the live params.
|
|
870
968
|
fastPath.recordDelivered(m.name);
|
|
871
969
|
}
|
|
970
|
+
recordPrintFrames(data.meshes.map((m) => m.name));
|
|
872
971
|
// A split dispatch answers in two meshes replies; the busy spinner
|
|
873
972
|
// stays up until the view has everything (the other worker's job may
|
|
874
973
|
// still be running — often OCCT, the slow one).
|
|
@@ -1164,6 +1263,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1164
1263
|
// Optional host-page viewer chrome (reframe / theme) + camera persistence.
|
|
1165
1264
|
const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
|
|
1166
1265
|
cleanup.defer(() => chrome.detach());
|
|
1266
|
+
// Optional realistic-mode chrome: the toggle and the environment picker.
|
|
1267
|
+
const realisticChrome = attachRealisticControls(viewer, {
|
|
1268
|
+
toggle: els.chrome.realistic, envMenu: els.chrome.environment,
|
|
1269
|
+
}, { tooltip });
|
|
1270
|
+
cleanup.defer(() => realisticChrome.detach());
|
|
1167
1271
|
|
|
1168
1272
|
// Full teardown of everything this mount created. Idempotent. A disposed runtime
|
|
1169
1273
|
// can never surface a late build result (workers are terminated, the loop is
|
|
@@ -1244,6 +1348,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1244
1348
|
release: () => viewer.releaseFlashPoints(),
|
|
1245
1349
|
onAnchorChange: (cb) => viewer.onFlashAnchorChange(cb),
|
|
1246
1350
|
},
|
|
1351
|
+
renderMode: {
|
|
1352
|
+
get: () => viewer.getRenderMode(),
|
|
1353
|
+
set: (mode) => viewer.setRenderMode(mode),
|
|
1354
|
+
onChange: (cb) => viewer.onRenderModeChange(cb),
|
|
1355
|
+
},
|
|
1356
|
+
environment: {
|
|
1357
|
+
get: () => viewer.getEnvironment(),
|
|
1358
|
+
set: (id) => viewer.setEnvironment(id),
|
|
1359
|
+
onChange: (cb) => viewer.onEnvironmentChange(cb),
|
|
1360
|
+
list: () => Object.values(ENVIRONMENTS).map(({ id, label }) => ({ id, label })),
|
|
1361
|
+
},
|
|
1362
|
+
declaresMaterials: declaresMaterials(part),
|
|
1363
|
+
renderViews: (viewNames, opts) => viewer.renderViews(viewNames, opts),
|
|
1247
1364
|
});
|
|
1248
1365
|
} catch (error) {
|
|
1249
1366
|
try {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Viewbar chrome for realistic mode: a toggle button and an environment <select>.
|
|
2
|
+
// Both optional (a host without the elements simply has no built-in controls and
|
|
3
|
+
// drives runtime.renderMode / runtime.environment itself). Same shape as
|
|
4
|
+
// viewer-controls.js. The state shown is always the viewer's: the toggle is
|
|
5
|
+
// pressed only once realistic has actually landed, and the menu shows the
|
|
6
|
+
// environment in effect (a failed switch snaps it back).
|
|
7
|
+
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
8
|
+
import { saveRenderMode, saveEnvironment } from "./view-state.js";
|
|
9
|
+
import { attachButtonTooltips } from "./tooltip.js";
|
|
10
|
+
|
|
11
|
+
const LABEL = "Realistic view";
|
|
12
|
+
const ERROR_LABEL = "Couldn't load realistic view";
|
|
13
|
+
|
|
14
|
+
export function attachRealisticControls(viewer, { toggle, envMenu } = {}, { tooltip } = {}) {
|
|
15
|
+
const binding = tooltip && toggle ? attachButtonTooltips(tooltip, [{ element: toggle }]) : null;
|
|
16
|
+
if (envMenu && !envMenu.options.length) {
|
|
17
|
+
for (const env of Object.values(ENVIRONMENTS)) {
|
|
18
|
+
const o = document.createElement("option");
|
|
19
|
+
o.value = env.id;
|
|
20
|
+
o.textContent = env.label;
|
|
21
|
+
envMenu.append(o);
|
|
22
|
+
}
|
|
23
|
+
envMenu.setAttribute("aria-label", "Environment");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The last mode event's busy/error, so an environment event or sync() —
|
|
27
|
+
// which carry neither — re-render without clearing them.
|
|
28
|
+
let busy = false;
|
|
29
|
+
let error = null;
|
|
30
|
+
|
|
31
|
+
function render() {
|
|
32
|
+
const mode = viewer.getRenderMode();
|
|
33
|
+
if (toggle) {
|
|
34
|
+
const on = mode === "realistic";
|
|
35
|
+
toggle.classList.toggle("on", on);
|
|
36
|
+
toggle.setAttribute("aria-pressed", String(on));
|
|
37
|
+
if (busy) toggle.dataset.busy = "true"; else delete toggle.dataset.busy;
|
|
38
|
+
const label = error ? ERROR_LABEL : LABEL;
|
|
39
|
+
toggle.setAttribute("aria-label", label);
|
|
40
|
+
if (!tooltip) toggle.title = label;
|
|
41
|
+
}
|
|
42
|
+
if (envMenu) {
|
|
43
|
+
envMenu.hidden = mode !== "realistic";
|
|
44
|
+
envMenu.value = viewer.getEnvironment();
|
|
45
|
+
}
|
|
46
|
+
binding?.sync();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A click while realistic is still loading is a change of mind: back to CAD.
|
|
50
|
+
// What persists is what the viewer says took effect, not what was asked for.
|
|
51
|
+
const onToggle = () => {
|
|
52
|
+
const next = viewer.getRenderMode() === "realistic" || busy ? "cad" : "realistic";
|
|
53
|
+
viewer.setRenderMode(next).then(saveRenderMode, () => {});
|
|
54
|
+
};
|
|
55
|
+
const onEnv = () => {
|
|
56
|
+
viewer.setEnvironment(envMenu.value).then(saveEnvironment, () => {});
|
|
57
|
+
};
|
|
58
|
+
toggle?.addEventListener("click", onToggle);
|
|
59
|
+
envMenu?.addEventListener("change", onEnv);
|
|
60
|
+
const offMode = viewer.onRenderModeChange((e) => {
|
|
61
|
+
busy = !!e?.busy;
|
|
62
|
+
error = e?.error ?? null;
|
|
63
|
+
render();
|
|
64
|
+
});
|
|
65
|
+
const offEnv = viewer.onEnvironmentChange(() => render());
|
|
66
|
+
render();
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
sync: render,
|
|
70
|
+
detach() {
|
|
71
|
+
toggle?.removeEventListener("click", onToggle);
|
|
72
|
+
envMenu?.removeEventListener("change", onEnv);
|
|
73
|
+
offMode();
|
|
74
|
+
offEnv();
|
|
75
|
+
binding?.detach();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -40,6 +40,8 @@ export function createFeatureHighlight(viewer) {
|
|
|
40
40
|
const overlay = new THREE.Mesh(emptyOverlayGeometry, material);
|
|
41
41
|
overlay.visible = false;
|
|
42
42
|
overlay.renderOrder = CUTAWAY_OVERLAY_RENDER_ORDER;
|
|
43
|
+
// Pointer feedback, never part of a capture (it tints what it covers).
|
|
44
|
+
const unregisterCapture = viewer.registerCaptureHidden?.(overlay) ?? (() => {});
|
|
43
45
|
let overlayParent = null;
|
|
44
46
|
// Subset cache per sub-part: rebuilt when the sub-part's geometry object
|
|
45
47
|
// changes (i.e. after a regenerate) — keyed on the geometry instance.
|
|
@@ -91,6 +93,7 @@ export function createFeatureHighlight(viewer) {
|
|
|
91
93
|
() => subsets.clear(),
|
|
92
94
|
() => emptyOverlayGeometry?.dispose(),
|
|
93
95
|
unregisterCutaway,
|
|
96
|
+
unregisterCapture,
|
|
94
97
|
() => material.dispose(),
|
|
95
98
|
);
|
|
96
99
|
runCleanupSteps(steps, "feature highlight cleanup failed");
|
|
@@ -6,12 +6,17 @@
|
|
|
6
6
|
// and a fresh session opens on the part's own default (see default-view.js).
|
|
7
7
|
// Reads/writes are guarded: if storage is unavailable (private mode, disabled) or a
|
|
8
8
|
// value is corrupt, reads return the documented default and writes are no-ops —
|
|
9
|
-
// persistence never throws.
|
|
9
|
+
// persistence never throws. `renderMode` and `environment` are viewer
|
|
10
|
+
// preferences like `theme`; in the cloud's opaque-origin sandbox storage throws,
|
|
11
|
+
// so there the host carries them in viewerState instead.
|
|
12
|
+
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
10
13
|
|
|
11
14
|
const KEY = {
|
|
12
15
|
camera: "partforge:camera",
|
|
13
16
|
theme: "partforge:theme",
|
|
14
17
|
projection: "partforge:projection",
|
|
18
|
+
renderMode: "partforge:renderMode",
|
|
19
|
+
environment: "partforge:environment",
|
|
15
20
|
};
|
|
16
21
|
|
|
17
22
|
const viewKey = (partKey) => `partforge:view:${partKey}`;
|
|
@@ -64,6 +69,27 @@ export function saveProjection(mode) {
|
|
|
64
69
|
if (mode === "perspective" || mode === "orthographic") write(KEY.projection, mode);
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
// null = nothing (valid) stored — the caller falls back to "cad".
|
|
73
|
+
export function loadRenderMode() {
|
|
74
|
+
const raw = read(KEY.renderMode);
|
|
75
|
+
return raw === "realistic" || raw === "cad" ? raw : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function saveRenderMode(mode) {
|
|
79
|
+
if (mode === "cad" || mode === "realistic") write(KEY.renderMode, mode);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// null = nothing stored, or an id this version doesn't know — the caller falls
|
|
83
|
+
// back to the part's own meta.environment.
|
|
84
|
+
export function loadEnvironment() {
|
|
85
|
+
const raw = read(KEY.environment);
|
|
86
|
+
return raw && Object.hasOwn(ENVIRONMENTS, raw) ? raw : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function saveEnvironment(id) {
|
|
90
|
+
if (typeof id === "string" && Object.hasOwn(ENVIRONMENTS, id)) write(KEY.environment, id);
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
// `partKey` identifies the part — createViewTabs passes `meta.title`. Without one
|
|
68
94
|
// there is nothing safe to key on, so both calls no-op rather than falling back to a
|
|
69
95
|
// shared key (the cross-part bleed this scoping exists to remove).
|