partforge 0.121.0 → 0.122.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/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/arc-math.js +27 -0
- package/src/framework/geometry/contour-corners.js +130 -0
- package/src/framework/geometry/contour-ops.js +11 -125
- package/src/framework/geometry/paper-bridge.js +4 -21
- 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 +195 -0
- package/src/framework/materials/physical.js +82 -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 +23 -0
- package/src/framework/mount.js +121 -4
- package/src/framework/oracle/shape-probe.js +2 -2
- 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,169 @@
|
|
|
1
|
+
// src/framework/materials/print-bed.js
|
|
2
|
+
// The print-bed environment's ground: not a disc fading into the backdrop but
|
|
3
|
+
// a real build plate — a rounded, square PEI sheet of a standard size, cut out
|
|
4
|
+
// of nothing, with white markings printed on it: a millimetre ruler along the
|
|
5
|
+
// left and back edges, the partforge wordmark in the front-left corner, the
|
|
6
|
+
// plate's own size in the front-right, and a lift tab with diagonal sides off
|
|
7
|
+
// the middle of the front edge. Like the disc, it comes to the
|
|
8
|
+
// part (centred under its footprint) and never moves it.
|
|
9
|
+
import * as THREE from "three";
|
|
10
|
+
|
|
11
|
+
// Common square bed sizes, smallest first; a footprint past the last rounds up
|
|
12
|
+
// to the next 50 mm.
|
|
13
|
+
export const BED_SIZES_MM = [180, 220, 256, 350];
|
|
14
|
+
const MARGIN_MM = 16; // clear plate kept around the footprint when picking a size
|
|
15
|
+
const THICKNESS_MM = 2.5;
|
|
16
|
+
const CORNER_MM = 6;
|
|
17
|
+
// The lift tab on the front edge: wide where it meets the plate, narrower at
|
|
18
|
+
// its tip, so its two sides run diagonally.
|
|
19
|
+
const TAB_BASE_MM = 132;
|
|
20
|
+
const TAB_TIP_MM = 96;
|
|
21
|
+
const TAB_DEPTH_MM = 9;
|
|
22
|
+
|
|
23
|
+
export function bedSizeFor(footprintMm) {
|
|
24
|
+
const need = footprintMm + MARGIN_MM * 2;
|
|
25
|
+
return BED_SIZES_MM.find((s) => s >= need) ?? Math.ceil(need / 50) * 50;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function plateGeometry(size) {
|
|
29
|
+
const h = size / 2, r = CORNER_MM;
|
|
30
|
+
const shape = new THREE.Shape();
|
|
31
|
+
// Shape y = -h is the FRONT edge (it lands at +Z once laid flat), and the
|
|
32
|
+
// lift tab sticks out of its middle: a trapezoid with diagonal sides.
|
|
33
|
+
const tb = TAB_BASE_MM / 2, tt = TAB_TIP_MM / 2, td = TAB_DEPTH_MM;
|
|
34
|
+
shape.moveTo(-h + r, -h);
|
|
35
|
+
shape.lineTo(-tb, -h); shape.lineTo(-tt, -h - td); shape.lineTo(tt, -h - td); shape.lineTo(tb, -h);
|
|
36
|
+
shape.lineTo(h - r, -h); shape.quadraticCurveTo(h, -h, h, -h + r);
|
|
37
|
+
shape.lineTo(h, h - r); shape.quadraticCurveTo(h, h, h - r, h);
|
|
38
|
+
shape.lineTo(-h + r, h); shape.quadraticCurveTo(-h, h, -h, h - r);
|
|
39
|
+
shape.lineTo(-h, -h + r); shape.quadraticCurveTo(-h, -h, -h + r, -h);
|
|
40
|
+
// Extruded along +Z then laid flat: the top cap ends up at y = 0, the plate
|
|
41
|
+
// hangs below it, and the caps' UVs are shape coordinates — millimetres.
|
|
42
|
+
const g = new THREE.ExtrudeGeometry(shape, { depth: THICKNESS_MM, bevelEnabled: false, curveSegments: 6 });
|
|
43
|
+
g.rotateX(-Math.PI / 2);
|
|
44
|
+
g.translate(0, -THICKNESS_MM, 0);
|
|
45
|
+
return g;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultCanvas(w, h) {
|
|
49
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(w, h);
|
|
50
|
+
if (typeof document !== "undefined") return Object.assign(document.createElement("canvas"), { width: w, height: h });
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Draws the markings for a plate `size` mm square onto a transparent canvas
|
|
55
|
+
// (top of the canvas = back of the bed). Returns null where there is no 2D
|
|
56
|
+
// canvas (tests, a worker): the plate is then just unmarked PEI.
|
|
57
|
+
export function drawBedMarkings(size, createCanvas = defaultCanvas) {
|
|
58
|
+
const px = 2048;
|
|
59
|
+
const canvas = createCanvas(px, px);
|
|
60
|
+
const ctx = canvas?.getContext?.("2d");
|
|
61
|
+
if (!ctx) return null;
|
|
62
|
+
const k = px / size; // pixels per millimetre
|
|
63
|
+
const ink = "rgba(255,255,255,0.88)";
|
|
64
|
+
const mono = (mm, weight = 600) => `${weight} ${Math.round(mm * k)}px "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace`;
|
|
65
|
+
ctx.clearRect(0, 0, px, px);
|
|
66
|
+
ctx.strokeStyle = ink;
|
|
67
|
+
ctx.fillStyle = ink;
|
|
68
|
+
ctx.lineCap = "round";
|
|
69
|
+
|
|
70
|
+
// Printable-area outline, inset from the edge.
|
|
71
|
+
const inset = 5;
|
|
72
|
+
ctx.lineWidth = 0.35 * k;
|
|
73
|
+
ctx.beginPath();
|
|
74
|
+
ctx.roundRect(inset * k, inset * k, (size - inset * 2) * k, (size - inset * 2) * k, (CORNER_MM - 2) * k);
|
|
75
|
+
ctx.stroke();
|
|
76
|
+
|
|
77
|
+
// Ruler: a tick every 10 mm, a longer numbered one every 50 mm, along the
|
|
78
|
+
// back edge (left to right) and the left edge (front to back), measured
|
|
79
|
+
// from the front-left corner the way a slicer's bed is.
|
|
80
|
+
const ruler = inset + 1;
|
|
81
|
+
ctx.font = mono(3.2, 500);
|
|
82
|
+
for (let mm = 10; mm < size - inset; mm += 10) {
|
|
83
|
+
const major = mm % 50 === 0;
|
|
84
|
+
const len = (major ? 4 : 2) * k;
|
|
85
|
+
ctx.lineWidth = (major ? 0.45 : 0.3) * k;
|
|
86
|
+
ctx.beginPath(); // back edge: x = mm
|
|
87
|
+
ctx.moveTo(mm * k, ruler * k); ctx.lineTo(mm * k, ruler * k + len);
|
|
88
|
+
ctx.stroke();
|
|
89
|
+
ctx.beginPath(); // left edge: z = mm from the front
|
|
90
|
+
ctx.moveTo(ruler * k, (size - mm) * k); ctx.lineTo(ruler * k + len, (size - mm) * k);
|
|
91
|
+
ctx.stroke();
|
|
92
|
+
if (!major) continue;
|
|
93
|
+
ctx.textAlign = "center"; ctx.textBaseline = "top";
|
|
94
|
+
ctx.fillText(String(mm), mm * k, ruler * k + len + 1 * k);
|
|
95
|
+
ctx.textAlign = "left"; ctx.textBaseline = "middle";
|
|
96
|
+
ctx.fillText(String(mm), ruler * k + len + 1 * k, (size - mm) * k);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A small centre cross.
|
|
100
|
+
ctx.lineWidth = 0.35 * k;
|
|
101
|
+
const c = (size / 2) * k, arm = 3 * k;
|
|
102
|
+
ctx.beginPath();
|
|
103
|
+
ctx.moveTo(c - arm, c); ctx.lineTo(c + arm, c);
|
|
104
|
+
ctx.moveTo(c, c - arm); ctx.lineTo(c, c + arm);
|
|
105
|
+
ctx.stroke();
|
|
106
|
+
|
|
107
|
+
// Front strip: the wordmark in the left corner, the plate's size in the
|
|
108
|
+
// right, leaving the middle clear for the lift tab.
|
|
109
|
+
const front = (size - inset - 7) * k;
|
|
110
|
+
ctx.textBaseline = "middle";
|
|
111
|
+
ctx.font = mono(Math.min(8, size * 0.03));
|
|
112
|
+
ctx.textAlign = "left";
|
|
113
|
+
ctx.fillText("partforge", (inset + 5) * k, front);
|
|
114
|
+
ctx.textAlign = "right";
|
|
115
|
+
ctx.font = mono(Math.min(5, size * 0.02), 500);
|
|
116
|
+
ctx.fillText(`${size} × ${size} mm`, (size - inset - 5) * k, front);
|
|
117
|
+
return canvas;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `pei` is the plate's surface material (the PEI colour + roughness maps with
|
|
121
|
+
// UVs in millimetres, repeat 1/tileMm); the rest is built and owned here.
|
|
122
|
+
export function createPrintBed({ pei, tileMm, createCanvas }) {
|
|
123
|
+
const group = new THREE.Group();
|
|
124
|
+
const edge = new THREE.MeshStandardMaterial({ color: 0x2a2b2d, metalness: 0.6, roughness: 0.45 });
|
|
125
|
+
const plate = new THREE.Mesh(new THREE.BufferGeometry(), [pei, edge]);
|
|
126
|
+
const inkMat = new THREE.MeshStandardMaterial({
|
|
127
|
+
transparent: true, roughness: 0.6, metalness: 0,
|
|
128
|
+
polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1,
|
|
129
|
+
});
|
|
130
|
+
const ink = new THREE.Mesh(new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), inkMat);
|
|
131
|
+
ink.visible = false;
|
|
132
|
+
group.add(plate, ink);
|
|
133
|
+
group.renderOrder = -1;
|
|
134
|
+
let size = 0;
|
|
135
|
+
let inkTex = null;
|
|
136
|
+
|
|
137
|
+
function setSize(next) {
|
|
138
|
+
if (next === size) return;
|
|
139
|
+
size = next;
|
|
140
|
+
plate.geometry.dispose();
|
|
141
|
+
plate.geometry = plateGeometry(size);
|
|
142
|
+
for (const t of [pei.map, pei.roughnessMap]) t?.repeat.set(1 / tileMm, 1 / tileMm);
|
|
143
|
+
inkTex?.dispose();
|
|
144
|
+
inkTex = null;
|
|
145
|
+
const canvas = drawBedMarkings(size, createCanvas);
|
|
146
|
+
if (canvas) {
|
|
147
|
+
inkTex = new THREE.CanvasTexture(canvas);
|
|
148
|
+
inkTex.colorSpace = THREE.SRGBColorSpace;
|
|
149
|
+
inkTex.anisotropy = 8;
|
|
150
|
+
}
|
|
151
|
+
inkMat.map = inkTex;
|
|
152
|
+
inkMat.needsUpdate = true;
|
|
153
|
+
ink.visible = !!inkTex;
|
|
154
|
+
ink.scale.set(size, 1, size);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
object: group,
|
|
159
|
+
get sizeMm() { return size; },
|
|
160
|
+
place({ y, centerX = 0, centerZ = 0, footprintMm }) {
|
|
161
|
+
setSize(bedSizeFor(footprintMm));
|
|
162
|
+
group.position.set(centerX, y - 0.01, centerZ);
|
|
163
|
+
},
|
|
164
|
+
dispose() {
|
|
165
|
+
plate.geometry.dispose(); ink.geometry.dispose();
|
|
166
|
+
edge.dispose(); inkMat.dispose(); inkTex?.dispose();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The frame 3D-print layer lines are drawn in. Delivered display meshes carry
|
|
2
|
+
// the DISPLAY pose (place(..., {purpose:"display"})); the part is printed in the
|
|
3
|
+
// EXPORT pose. partforge already requires the two to differ by a rigid motion
|
|
4
|
+
// (lint place-not-rigid), and the geometry-free pose probe reports each as a
|
|
5
|
+
// list of rigid steps — so the display→export map is E · D⁻¹, with no geometry.
|
|
6
|
+
// Three-free and DOM-free: the pose probe and pose.js are pure.
|
|
7
|
+
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
8
|
+
import { poseDelta } from "../geometry/pose.js";
|
|
9
|
+
|
|
10
|
+
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
11
|
+
|
|
12
|
+
export function printFrameMatrix(subPart, { view, p, d }) {
|
|
13
|
+
if (!subPart?.place) return [...IDENTITY];
|
|
14
|
+
const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d });
|
|
15
|
+
const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d });
|
|
16
|
+
if (!disp.trusted || !exp.trusted || disp.baseHash !== exp.baseHash) return [...IDENTITY];
|
|
17
|
+
return poseDelta(exp.pose, disp.pose);
|
|
18
|
+
}
|
|
@@ -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,23 @@
|
|
|
1
|
+
// Box-projected UVs, added only for realistic mode. three's anisotropy (brushed
|
|
2
|
+
// metal) builds its tangent frame from UV derivatives, and CAD meshes carry no
|
|
3
|
+
// UVs. Projection is in OBJECT space along each vertex normal's dominant axis, so
|
|
4
|
+
// it rides with the sub-part and never swims. Seams on curved surfaces are
|
|
5
|
+
// acceptable: nothing samples a texture through these UVs — triplanar patterns
|
|
6
|
+
// (patterns.js) ignore them.
|
|
7
|
+
import * as THREE from "three";
|
|
8
|
+
|
|
9
|
+
export function ensureBoxUVs(geometry, scaleMm = 20) {
|
|
10
|
+
if (geometry.userData.boxUVs && geometry.attributes.uv) return geometry;
|
|
11
|
+
const pos = geometry.attributes.position, nrm = geometry.attributes.normal;
|
|
12
|
+
const uv = new Float32Array(pos.count * 2);
|
|
13
|
+
for (let i = 0; i < pos.count; i++) {
|
|
14
|
+
const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
|
|
15
|
+
const ax = Math.abs(nrm ? nrm.getX(i) : 0), ay = Math.abs(nrm ? nrm.getY(i) : 0), az = Math.abs(nrm ? nrm.getZ(i) : 1);
|
|
16
|
+
let u, v;
|
|
17
|
+
if (az >= ax && az >= ay) { u = x; v = y; } else if (ay >= ax) { u = x; v = z; } else { u = y; v = z; }
|
|
18
|
+
uv[i * 2] = u / scaleMm; uv[i * 2 + 1] = v / scaleMm;
|
|
19
|
+
}
|
|
20
|
+
geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
|
|
21
|
+
geometry.userData.boxUVs = true;
|
|
22
|
+
return geometry;
|
|
23
|
+
}
|
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 {
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
// out at rings[] → ring → arcs[] → arc → center[] — 4 containers below the summary
|
|
41
41
|
// object itself (5 including it), against the cloud's `DESCRIBE_MAX_DEPTH = 8`, with
|
|
42
42
|
// headroom even wrapped in the paired-probe `{pair: {mine, ref}}` form.
|
|
43
|
-
import { arcCenterAndSweep } from "../geometry/
|
|
44
|
-
import { cubicAt, profileCorners } from "../geometry/contour-
|
|
43
|
+
import { arcCenterAndSweep } from "../geometry/arc-math.js";
|
|
44
|
+
import { cubicAt, profileCorners } from "../geometry/contour-corners.js";
|
|
45
45
|
|
|
46
46
|
export const MAX_ARCS = 64;
|
|
47
47
|
export const MAX_CORNERS = 64;
|