partforge 0.134.0 → 0.136.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 +45 -14
- package/package.json +1 -1
- package/src/app-materials.js +3 -1
- package/src/framework/fonts.js +8 -0
- package/src/framework/jobs.js +38 -11
- package/src/framework/lint/rules-materials.js +9 -2
- package/src/framework/materials/assets/SOURCES.md +10 -4
- package/src/framework/materials/assets/pattern-plywood-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-plywood-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-plywood-rough.jpg +0 -0
- package/src/framework/materials/assets.js +3 -0
- package/src/framework/materials/patterns.js +103 -0
- package/src/framework/materials/physical.js +5 -2
- package/src/framework/materials/presets.js +11 -5
- package/src/framework/materials/resolve.js +4 -2
- package/src/framework/materials/sheet-look.js +204 -0
- package/src/framework/mount.js +61 -32
- package/src/framework/panel/font-picker.js +76 -5
- package/src/framework/panel/widgets/font.js +13 -1
- package/src/framework/sheet/part.js +32 -17
- package/src/framework/vectors.js +3 -2
- package/src/framework/viewer.js +53 -29
- package/src/parts/laser-box.js +1 -1
- package/src/parts/material-swatches.js +58 -2
- package/types/part.d.ts +4 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/framework/materials/sheet-look.js
|
|
2
|
+
// How a laser-cut sheet part looks in realistic mode — plain data and pure math, three-free
|
|
3
|
+
// and DOM-free like resolve.js, so the viewer, mount, lint and the tests all read ONE
|
|
4
|
+
// statement of it. patterns.js turns it into GLSL; nothing here knows about three.
|
|
5
|
+
//
|
|
6
|
+
// A laser sheetPart in a wood shows its burns: charred cut walls, scorched engrave and
|
|
7
|
+
// score floors, faces untouched. The shader tells those surfaces apart in the sheet's
|
|
8
|
+
// CANONICAL frame — profile in XY, material over z ∈ [0, t], laser face at z = t — but the
|
|
9
|
+
// viewer holds the DELIVERED display mesh. With no author `place`, sheetPart's generated
|
|
10
|
+
// place is exactly the rigid steps poseSteps(pose, t) (and there is no place at all with no
|
|
11
|
+
// pose), so the delivered mesh is that motion applied to the canonical solid and the frame
|
|
12
|
+
// is its inverse: computed from data, with no pose probe and no geometry. A sheet with an
|
|
13
|
+
// author place, a replaced place or a custom build is not trusted with a frame — it draws
|
|
14
|
+
// plain wood, never a frame that could char a face.
|
|
15
|
+
//
|
|
16
|
+
// It also says what a laser sheet part with no usable material looks like: not a PLA print
|
|
17
|
+
// (resolve.js's default for every other sub-part) but the sheet its stock label names —
|
|
18
|
+
// realisticDisplay, from the ONE keyword table STOCK_LOOKS. Realistic-only, like the PLA
|
|
19
|
+
// default: the CAD view, 3MF colours and declaresMaterials read sp.display itself.
|
|
20
|
+
import { isSheetPart, sheetMeta, MARK_DEPTH } from "../sheet/constants.js";
|
|
21
|
+
import { poseSteps, validatePose } from "../sheet/pose.js";
|
|
22
|
+
import { composePose, invertRigid } from "../geometry/pose.js";
|
|
23
|
+
import { resolveMaterial } from "./resolve.js";
|
|
24
|
+
import { PRESETS } from "./presets.js";
|
|
25
|
+
|
|
26
|
+
const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
27
|
+
|
|
28
|
+
// The burn's numbers, ONE statement: patterns.js templates the GLSL from them, and the JS
|
|
29
|
+
// twins below (classifySheetSurface, burnAlbedo) read the same object.
|
|
30
|
+
// wallNz a surface whose canonical |n.z| is below this band is a cut wall (the
|
|
31
|
+
// shader blends across the band; the JS twin splits at its middle). Creased
|
|
32
|
+
// normals are hard at a sheet's 90° arris, so real walls sit at n.z = 0.
|
|
33
|
+
// floorDepth an up-facing surface at least this far below the laser face is an engrave
|
|
34
|
+
// or score floor: half the preview's MARK_DEPTH, so the laser face (z = t) is
|
|
35
|
+
// never one and a mark's floor (z = t − MARK_DEPTH) always is.
|
|
36
|
+
// Char is RELATIVE to the face, all in linear RGB. A cut wall is the face's average colour
|
|
37
|
+
// mixed toward a char TARGET by k — kThin at ≤ tThin mm, kThick at ≥ tThick (thick stock takes
|
|
38
|
+
// a longer dwell) — plus up to `exit` more toward the exit side (z = 0); an engrave or score
|
|
39
|
+
// floor mixes `engraveLess` less. The target is PER CHANNEL: min(charcoal_i, charOfDark ·
|
|
40
|
+
// face_i) (burnAlbedo's `target`). For oak and plywood that is `charcoal` itself; charOfDark
|
|
41
|
+
// takes a channel over wherever face_i < charcoal_i / charOfDark — walnut's green and blue —
|
|
42
|
+
// so a dark or strongly tinted face still chars darker in every channel. Mixing toward
|
|
43
|
+
// `charcoal` alone would RAISE walnut's near-black blue, since charcoal's is lighter. Char and
|
|
44
|
+
// scorched floors take `roughness`, a matte finish, and shed any clearcoat; on plywood's cut
|
|
45
|
+
// walls a cross ply is `crossPly` of a face-grain ply.
|
|
46
|
+
// grain how much of the wood's grain shows through the char: each texel's ratio to
|
|
47
|
+
// the face average, blended in by `grain`, multiplies the average char (1, a
|
|
48
|
+
// pure multiply: every texel darkens by the same per-channel factor, so the
|
|
49
|
+
// average is unchanged and no texel comes out lighter; burnTexel is the twin).
|
|
50
|
+
// relief the share of the wood's surface relief (its normal map) that char and
|
|
51
|
+
// scorched floors keep; the rest fades to the geometric normal.
|
|
52
|
+
//
|
|
53
|
+
// The numbers were tuned by eye on the contact sheet in all four environments, inside the
|
|
54
|
+
// calibration floors test/framework/sheet-look.test.js holds them to. The twins see only the
|
|
55
|
+
// albedo. The render adds specular reflection — the same at every thickness — which no albedo
|
|
56
|
+
// can take away, and on walnut, whose char is near black, it is most of what a lit wall shows.
|
|
57
|
+
// So judge a retune on captures (scripts/capture-contact-sheet.mjs) compared at the SAME place
|
|
58
|
+
// on the sheet, never on the twins alone: swatches in different places reflect different
|
|
59
|
+
// light. The design spec records the measurements.
|
|
60
|
+
export const BURN = Object.freeze({
|
|
61
|
+
wallNz: Object.freeze([0.35, 0.65]),
|
|
62
|
+
floorDepth: MARK_DEPTH / 2,
|
|
63
|
+
charcoal: 0x262220,
|
|
64
|
+
kThin: 0.74,
|
|
65
|
+
kThick: 0.97,
|
|
66
|
+
tThin: 3,
|
|
67
|
+
tThick: 9,
|
|
68
|
+
exit: 0.1,
|
|
69
|
+
engraveLess: 0.1,
|
|
70
|
+
charOfDark: 0.37,
|
|
71
|
+
roughness: 0.68,
|
|
72
|
+
crossPly: 0.78,
|
|
73
|
+
grain: 1,
|
|
74
|
+
relief: 0.35,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// sRGB 0xRRGGBB → linear [r, g, b]: the conversion three applies to a colour uniform.
|
|
78
|
+
export function srgbToLinear(hex) {
|
|
79
|
+
return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255].map((c) => {
|
|
80
|
+
const s = c / 255;
|
|
81
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Plies in a birch-plywood sheet t mm thick: 3 mm → 3, 6 → 5, 9 → 7, 12 → 9. Always odd, so
|
|
86
|
+
// both outer plies run with the face grain.
|
|
87
|
+
const PLY_STEPS = Object.freeze([[4.5, 3], [7.5, 5], [10.5, 7]]);
|
|
88
|
+
export function plyCount(t) {
|
|
89
|
+
for (const [below, plies] of PLY_STEPS) if (t < below) return plies;
|
|
90
|
+
return 9;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The material realistic mode draws this sub-part in.
|
|
94
|
+
const lookOf = (sp) => resolveMaterial(realisticDisplay(sp));
|
|
95
|
+
|
|
96
|
+
// A laser sheet with no usable material takes its look from its stock label: the first row
|
|
97
|
+
// with a stem that STARTS a word of the label (ignoring case; a hyphenated word is also read
|
|
98
|
+
// joined, so "poly-carbonate" is one word and "clear-acrylic" still has "acrylic"), else
|
|
99
|
+
// DEFAULT_STOCK_LOOK — plywood for birch, basswood, poplar, MDF, hardboard or anything
|
|
100
|
+
// unlabelled. Word starts, not substrings, so "Plexiglas" and "Acrylite" match while
|
|
101
|
+
// "perplexing" does not. A residual, named in the guide: stock that is neither wood nor
|
|
102
|
+
// acrylic (felt, leather, card, cork) takes the plywood look too, burns and all. Only a
|
|
103
|
+
// string label is read: a (p, d) function would make a sub-part's look (and its program)
|
|
104
|
+
// depend on the params it was first drawn at, so it counts as unlabelled.
|
|
105
|
+
export const STOCK_LOOKS = Object.freeze([
|
|
106
|
+
Object.freeze({ look: "clear-acrylic", words: Object.freeze([
|
|
107
|
+
"acryl", "perspex", "plexi", "pmma", "methacryl", "polycarb", "lexan", "makrolon", "lucite",
|
|
108
|
+
]) }),
|
|
109
|
+
]);
|
|
110
|
+
export const DEFAULT_STOCK_LOOK = "plywood";
|
|
111
|
+
|
|
112
|
+
const labelWords = (label) => {
|
|
113
|
+
const lower = label.toLowerCase();
|
|
114
|
+
return [...lower.split(/[^\p{L}]+/u), ...lower.replace(/-/g, "").split(/[^\p{L}]+/u)].filter(Boolean);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export function stockLook(stock) {
|
|
118
|
+
if (typeof stock !== "string") return DEFAULT_STOCK_LOOK;
|
|
119
|
+
const words = labelWords(stock);
|
|
120
|
+
return STOCK_LOOKS.find((row) => row.words.some((stem) => words.some((w) => w.startsWith(stem))))?.look ?? DEFAULT_STOCK_LOOK;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The stock's look for a laser sheet part, else null (lint names it in its fallback hint).
|
|
124
|
+
export function sheetStockLook(sp) {
|
|
125
|
+
return isSheetPart(sp) && sp.sheet.process === "laser" ? stockLook(sp.sheet.material) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The display realistic mode draws: sp.display itself, except that a laser sheet part
|
|
129
|
+
// naming no KNOWN material — none, or one the library does not know, the same "no usable
|
|
130
|
+
// material" resolve.js treats as one case — gets its stock's look. A `color` rides along,
|
|
131
|
+
// so a bare colour tints that look (stained plywood, coloured acrylic).
|
|
132
|
+
export function realisticDisplay(sp) {
|
|
133
|
+
const display = sp?.display;
|
|
134
|
+
const look = sheetStockLook(sp);
|
|
135
|
+
if (!look) return display;
|
|
136
|
+
if (typeof display?.material === "string" && Object.hasOwn(PRESETS, display.material)) return display;
|
|
137
|
+
return { ...(display && typeof display === "object" ? display : {}), material: look };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Does this sub-part carry the burn pass at all? Static — it reads no params — so the
|
|
141
|
+
// viewer decides a sub-part's PROGRAM once, and every sub-part that answers false keeps
|
|
142
|
+
// exactly the program it had before the burn existed.
|
|
143
|
+
export function burnsFor(sp) {
|
|
144
|
+
if (!isSheetPart(sp) || sp.sheet.process !== "laser") return false;
|
|
145
|
+
if (sp.build !== sp.sheet.generatedBuild) return false; // a custom build is not the canonical sheet
|
|
146
|
+
if (sp.sheet.place != null) return false; // an author place moves it off its pose
|
|
147
|
+
if ((sp.place ?? null) !== (sp.sheet.generatedPlace ?? null)) return false; // place replaced after sheetPart
|
|
148
|
+
try { return lookOf(sp).params.pattern === "wood"; } catch { return false; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// At (p, d) — a delivery's params — the frame that maps the delivered display mesh's
|
|
152
|
+
// object space into the canonical sheet frame, the sheet's thickness, and how many plies
|
|
153
|
+
// its cut walls show (0 unless its preset is `laminated`). null when burnsFor says no or
|
|
154
|
+
// the pose or thickness cannot be read (the build reports those).
|
|
155
|
+
export function sheetFrameFor(sp, { p, d } = {}) {
|
|
156
|
+
if (!burnsFor(sp)) return null;
|
|
157
|
+
const meta = sheetMeta(sp, p, d);
|
|
158
|
+
if (!meta) return null;
|
|
159
|
+
const t = meta.thickness;
|
|
160
|
+
let pose;
|
|
161
|
+
try { pose = typeof sp.sheet.pose === "function" ? sp.sheet.pose(p, d) : sp.sheet.pose; } catch { return null; }
|
|
162
|
+
if (pose != null && validatePose(pose)) return null;
|
|
163
|
+
const frame = pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
|
|
164
|
+
return { frame, t, plies: lookOf(sp).preset.laminated ? plyCount(t) : 0 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The shader's surface classification, in JS: the frame tests hold real geometry to it.
|
|
168
|
+
// `pos` and `normal` are canonical (after the frame); `t` is the thickness.
|
|
169
|
+
export function classifySheetSurface(pos, normal, t) {
|
|
170
|
+
if (Math.abs(normal[2]) < (BURN.wallNz[0] + BURN.wallNz[1]) / 2) return "wall";
|
|
171
|
+
if (normal[2] < 0) return "back";
|
|
172
|
+
return pos[2] <= t - BURN.floorDepth ? "floor" : "face";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
176
|
+
|
|
177
|
+
// The shader's burnt albedo, in JS (the calibration test's twin — same numbers, same
|
|
178
|
+
// formula): `face` is the unburnt face's average colour in linear RGB (the preset's texture
|
|
179
|
+
// average times any tint), `t` the sheet thickness (mm), `kind` "edge" — a cut wall, `zFrac`
|
|
180
|
+
// of the way from the exit side (0) up to the laser face (1) — or "engrave", a mark's floor.
|
|
181
|
+
// Ply bands are left out: they only modulate an edge. It returns the AVERAGE char — what the
|
|
182
|
+
// face's average colour burns to. The shader then multiplies that by each texel's ratio to
|
|
183
|
+
// the face average (texel ÷ face, blended by BURN.grain), so the grain shows through;
|
|
184
|
+
// burnTexel below is that step's twin.
|
|
185
|
+
export function burnAlbedo(face, t, { kind = "edge", zFrac = 1 } = {}) {
|
|
186
|
+
const charcoal = srgbToLinear(BURN.charcoal);
|
|
187
|
+
// The mix target, PER CHANNEL: whichever is darker of raw charcoal or charOfDark of this
|
|
188
|
+
// channel's own face value — never one colour for the whole vector, which a channel darker
|
|
189
|
+
// than that colour would be pulled UP toward (the BURN comment above). Multiplicative in the
|
|
190
|
+
// channel that governs it, so charring a face darker than charcoal in that channel still
|
|
191
|
+
// only ever removes light from it.
|
|
192
|
+
const target = charcoal.map((c, i) => Math.min(c, BURN.charOfDark * face[i]));
|
|
193
|
+
const k = BURN.kThin + (BURN.kThick - BURN.kThin) * clamp01((t - BURN.tThin) / (BURN.tThick - BURN.tThin));
|
|
194
|
+
const amount = kind === "engrave" ? k - BURN.engraveLess : Math.min(1, k + BURN.exit * (1 - clamp01(zFrac)));
|
|
195
|
+
return face.map((f, i) => f + (target[i] - f) * amount);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// One texel's burnt albedo, in JS — the shader's pfGrain step on top of burnAlbedo: `texel`
|
|
199
|
+
// is the unburnt texel in linear RGB (the wood texture times any tint), the rest is
|
|
200
|
+
// burnAlbedo's. The average char times mix(1, texel ÷ face, BURN.grain), per channel, with
|
|
201
|
+
// the shader's 1e-4 floor under the divide. At grain 1 that is texel × (char ÷ face).
|
|
202
|
+
export function burnTexel(texel, face, t, opts) {
|
|
203
|
+
return burnAlbedo(face, t, opts).map((c, i) => c * (1 + (texel[i] / Math.max(face[i], 1e-4) - 1) * BURN.grain));
|
|
204
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -13,6 +13,7 @@ import { attachViewStyleControls } from "./view-style-controls.js";
|
|
|
13
13
|
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
14
14
|
import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
|
|
15
15
|
import { printFrameMatrix } from "./materials/print-frame.js";
|
|
16
|
+
import { burnsFor, realisticDisplay, sheetFrameFor } from "./materials/sheet-look.js";
|
|
16
17
|
import { buildControls } from "./controls.js";
|
|
17
18
|
import { relevantParamKeys } from "./param-deps.js";
|
|
18
19
|
import { createMeshCache } from "./mesh-cache.js";
|
|
@@ -240,7 +241,8 @@ function createCleanupStack() {
|
|
|
240
241
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
241
242
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
242
243
|
//
|
|
243
|
-
// Embedding contract (0.
|
|
244
|
+
// Embedding contract (0.136.0: the font control commits once per picker
|
|
245
|
+
// session, on close, not per pick):
|
|
244
246
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
|
|
245
247
|
// await runtime.ready; // first successful build of the default view
|
|
246
248
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
@@ -863,6 +865,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
863
865
|
// to its own worker in parallel, so a mixed part previews its plain sub-parts at
|
|
864
866
|
// Manifold speed while only the OCCT-routed ones wait. The return value is
|
|
865
867
|
// the job count — the loop holds the cycle open until every group has replied.
|
|
868
|
+
// Each dispatched sub-part remembers the { view, params } its job was sent with
|
|
869
|
+
// (`dispatched`): the frames the realistic look reads must describe the geometry
|
|
870
|
+
// that job builds, and a delivery shown during playback was built at params the
|
|
871
|
+
// live ones have already left (see recordFrames). One cycle is in flight at a time,
|
|
872
|
+
// so a sub-part's entry is its in-flight job's until the cycle's last reply.
|
|
873
|
+
const dispatched = new Map(); // sub-part -> { view, params } its latest job was sent with
|
|
866
874
|
const loop = createRegenLoop({
|
|
867
875
|
missingParts,
|
|
868
876
|
send: (missing) => {
|
|
@@ -870,6 +878,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
870
878
|
// for the overlay; this build reports the poses repaired in its own cycle.
|
|
871
879
|
lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
|
|
872
880
|
pendingPosed.clear(); // consumed — never counted against a second build
|
|
881
|
+
const at = { view: view(), params: { ...params } };
|
|
882
|
+
for (const n of missing) dispatched.set(n, at);
|
|
873
883
|
ui.showBusy("generating");
|
|
874
884
|
const backends = backendPolicy.backendsFor(params);
|
|
875
885
|
let jobs = 0;
|
|
@@ -951,44 +961,58 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
951
961
|
}
|
|
952
962
|
}
|
|
953
963
|
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
// than the way it is displayed.
|
|
957
|
-
//
|
|
958
|
-
// material
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
964
|
+
// Frames the realistic look reads, for the geometry just delivered:
|
|
965
|
+
// - print frames for the layer-line pattern: the display → export map, so layers run
|
|
966
|
+
// the way the part is printed rather than the way it is displayed. Only sub-parts
|
|
967
|
+
// whose material draws layer lines have one — which includes every sub-part naming
|
|
968
|
+
// no material, since realistic mode shows those as PLA (resolve.js) — except a laser
|
|
969
|
+
// sheet part, which it shows as its stock (sheet-look.js realisticDisplay). One
|
|
970
|
+
// without a place() is identity at once; one with it costs two geometry-free probe
|
|
971
|
+
// builds.
|
|
972
|
+
// - sheet frames for the burn pass (materials/sheet-look.js): a laser sheet in a wood,
|
|
973
|
+
// mapped back into its canonical frame — from data, no probe.
|
|
974
|
+
// Each describes the delivered mesh (which a later pose-only repair only moves).
|
|
962
975
|
const layerLined = new Set(Object.keys(part.parts).filter((n) => {
|
|
963
|
-
try { return resolveMaterial(part.parts[n]
|
|
976
|
+
try { return resolveMaterial(realisticDisplay(part.parts[n])).params.pattern === "layer-lines"; } catch { return false; }
|
|
964
977
|
}));
|
|
965
|
-
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
978
|
+
const burning = new Set(Object.keys(part.parts).filter((n) => burnsFor(part.parts[n])));
|
|
979
|
+
// LAZY: a delivery only records the view and params its job was DISPATCHED with
|
|
980
|
+
// (`dispatched`, set where the loop sends) — not the live ones, which a delivery shown
|
|
981
|
+
// during playback was never built at; the viewer pulls the frames (computeFrames)
|
|
982
|
+
// when it is about to draw the realistic look — live, loading, or borrowed by a
|
|
983
|
+
// capture — so a CAD-only session computes nothing (viewer.js setFrameSource). The
|
|
984
|
+
// snapshot keeps a late computation describing the mesh actually delivered.
|
|
970
985
|
const printFrames = {};
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
|
|
986
|
+
const sheetFrames = {};
|
|
987
|
+
const undrawnFrames = new Map(); // sub-part -> { view, params } its delivered mesh was built at
|
|
988
|
+
function recordFrames(names) {
|
|
989
|
+
const wanted = names.filter((n) => layerLined.has(n) || burning.has(n));
|
|
974
990
|
if (!wanted.length) return;
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
viewer.invalidatePrintFrames?.();
|
|
991
|
+
for (const n of wanted) undrawnFrames.set(n, dispatched.get(n) ?? null); // null: built at params nobody recorded
|
|
992
|
+
viewer.invalidateFrames?.();
|
|
978
993
|
}
|
|
979
|
-
function
|
|
994
|
+
function computeFrames() {
|
|
980
995
|
const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
|
|
981
996
|
for (const [n, at] of undrawnFrames) {
|
|
982
|
-
if (!resolvedFor.has(at)) {
|
|
997
|
+
if (at && !resolvedFor.has(at)) {
|
|
983
998
|
try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
|
|
984
999
|
}
|
|
985
|
-
const resolved = resolvedFor.get(at);
|
|
986
|
-
|
|
1000
|
+
const resolved = at ? resolvedFor.get(at) : null;
|
|
1001
|
+
// No params to frame this mesh by: a burning sheet draws plain wood rather than
|
|
1002
|
+
// keep a frame that describes other geometry — a sub-millimetre lag along its
|
|
1003
|
+
// normal is enough to char a whole face. A print frame keeps its last value (the
|
|
1004
|
+
// worst it can do is run layer lines the wrong way).
|
|
1005
|
+
if (!resolved) { delete sheetFrames[n]; continue; }
|
|
1006
|
+
if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
|
|
1007
|
+
if (burning.has(n)) {
|
|
1008
|
+
const f = sheetFrameFor(part.parts[n], resolved);
|
|
1009
|
+
if (f) sheetFrames[n] = f; else delete sheetFrames[n];
|
|
1010
|
+
}
|
|
987
1011
|
}
|
|
988
1012
|
undrawnFrames.clear();
|
|
989
|
-
return { ...printFrames };
|
|
1013
|
+
return { print: { ...printFrames }, sheet: { ...sheetFrames } };
|
|
990
1014
|
}
|
|
991
|
-
viewer.
|
|
1015
|
+
viewer.setFrameSource?.(computeFrames);
|
|
992
1016
|
|
|
993
1017
|
// Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
|
|
994
1018
|
const emptySubParts = new Set();
|
|
@@ -1051,7 +1075,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1051
1075
|
// delivered, which buildDone() true guarantees is at the live params.
|
|
1052
1076
|
fastPath.recordDelivered(m.name);
|
|
1053
1077
|
}
|
|
1054
|
-
|
|
1078
|
+
recordFrames(data.meshes.map((m) => m.name));
|
|
1055
1079
|
// A split dispatch answers in two meshes replies; the busy spinner
|
|
1056
1080
|
// stays up until the view has everything (the other worker's job may
|
|
1057
1081
|
// still be running — often OCCT, the slow one).
|
|
@@ -1082,10 +1106,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1082
1106
|
} else if (lastAnimApplyVersion === loop.version()) {
|
|
1083
1107
|
// Stale ONLY because animation frames kept bumping the version:
|
|
1084
1108
|
// show the delivered meshes anyway — that IS best-effort playback —
|
|
1085
|
-
// but record
|
|
1086
|
-
// geometry built at the live params, and this delivery
|
|
1087
|
-
// fast-path stamp is dropped too, so a later pose-only
|
|
1088
|
-
// never re-pose this newer geometry off an older delivery's
|
|
1109
|
+
// but record nothing about the LIVE params. Cache and fast-path stamps
|
|
1110
|
+
// must describe geometry built at the live params, and this delivery
|
|
1111
|
+
// wasn't; the fast-path stamp is dropped too, so a later pose-only
|
|
1112
|
+
// repair can never re-pose this newer geometry off an older delivery's
|
|
1113
|
+
// stamp. Its material frames are the exception: those are recorded
|
|
1114
|
+
// against the params its job was dispatched with, which is what it was
|
|
1115
|
+
// built at — keeping the last fresh delivery's frame instead would lay a
|
|
1116
|
+
// sheet's burn over geometry that has moved along its normal.
|
|
1089
1117
|
// A user edit mid-play pauses playback and bumps the version WITHOUT
|
|
1090
1118
|
// touching lastAnimApplyVersion, so a genuinely user-stale result
|
|
1091
1119
|
// fails this test and is discarded exactly as before.
|
|
@@ -1093,6 +1121,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1093
1121
|
viewer.setSubGeometry(m.name, m);
|
|
1094
1122
|
fastPath.forget(m.name);
|
|
1095
1123
|
}
|
|
1124
|
+
recordFrames(data.meshes.map((m) => m.name));
|
|
1096
1125
|
ui.hideBusy();
|
|
1097
1126
|
refreshView();
|
|
1098
1127
|
}
|
|
@@ -52,7 +52,19 @@ const listVariant = (f) =>
|
|
|
52
52
|
// through here.
|
|
53
53
|
let openPicker = null;
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// At most this many weights beyond the selected one load their real face per
|
|
56
|
+
// family opened (the previous family's variant faces are released when another
|
|
57
|
+
// opens, so resident variant faces stay <= 1 + this). A COUNT, not bytes: the cloud catalog reports `bytes: 0` for every
|
|
58
|
+
// variant, so a byte budget would never bind (spec 2026-10-01 §3). Rows past
|
|
59
|
+
// the budget render in the menu face with the weight synthesized.
|
|
60
|
+
export const VARIANT_FACE_BUDGET = 6;
|
|
61
|
+
|
|
62
|
+
// The menu face the last closed picker chose to keep (the widget label renders
|
|
63
|
+
// in it). The next picker adopts it, so a session neither re-fetches that face
|
|
64
|
+
// nor orphans it: its own close() deletes it if the selection moved on.
|
|
65
|
+
let keptFace = null;
|
|
66
|
+
|
|
67
|
+
export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPicked, onClose }) {
|
|
56
68
|
// Takeover: the picker covers the rail on desktop and the single visible pane
|
|
57
69
|
// below the narrow breakpoint. One layout for both widths (spec §6).
|
|
58
70
|
const host = anchor?.closest?.(".pf-rail") ?? anchor?.parentElement ?? document.body;
|
|
@@ -148,6 +160,33 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
|
|
|
148
160
|
// missing one must degrade to un-styled rows, never throw.
|
|
149
161
|
const canLoadFaces = () => typeof FontFace === "function" && typeof document.fonts?.add === "function";
|
|
150
162
|
|
|
163
|
+
const addedFaces = []; // every FontFace this picker put in document.fonts
|
|
164
|
+
let variantBudget = VARIANT_FACE_BUDGET;
|
|
165
|
+
const variantList = []; // {face, url} for the ONE family whose variants are open
|
|
166
|
+
let variantFamily = null;
|
|
167
|
+
if (keptFace) {
|
|
168
|
+
addedFaces.push(keptFace);
|
|
169
|
+
faceRequested.add(keptFace.family);
|
|
170
|
+
faceSettled.add(keptFace.family);
|
|
171
|
+
keptFace = null;
|
|
172
|
+
}
|
|
173
|
+
// A face that finishes loading after close() would otherwise land in
|
|
174
|
+
// document.fonts with nobody left to delete it. The selected family's menu
|
|
175
|
+
// face is the one exception: it is kept on close by design.
|
|
176
|
+
const track = (loaded) => {
|
|
177
|
+
if (closed && (loaded.family !== selFamily || keptFace)) return;
|
|
178
|
+
document.fonts.add(loaded);
|
|
179
|
+
if (!closed) addedFaces.push(loaded);
|
|
180
|
+
else keptFace = loaded; // landed after close: hand it on
|
|
181
|
+
};
|
|
182
|
+
function releaseVariantFaces() {
|
|
183
|
+
for (const { face, url } of variantList) {
|
|
184
|
+
variantFaces.delete(url);
|
|
185
|
+
try { document.fonts.delete?.(face); } catch { /* already gone */ }
|
|
186
|
+
}
|
|
187
|
+
variantList.length = 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
151
190
|
function settle(family) {
|
|
152
191
|
faceSettled.add(family);
|
|
153
192
|
if (closed) return;
|
|
@@ -167,7 +206,7 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
|
|
|
167
206
|
let face;
|
|
168
207
|
try { face = new FontFace(f.family, `url(${f.menuUrl})`); } catch { settle(f.family); continue; }
|
|
169
208
|
face.load()
|
|
170
|
-
.then((loaded) => {
|
|
209
|
+
.then((loaded) => { track(loaded); })
|
|
171
210
|
.catch(() => { /* a family that will not load stays in the panel font */ })
|
|
172
211
|
.then(() => settle(f.family));
|
|
173
212
|
}
|
|
@@ -177,14 +216,22 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
|
|
|
177
216
|
// family name's glyphs at one weight, so it cannot show what 700 looks like.
|
|
178
217
|
// Each face is registered under `<family> <variant>` so the weights do not
|
|
179
218
|
// collide with each other or with the menu face.
|
|
180
|
-
function requestVariantFace(family, v) {
|
|
219
|
+
function requestVariantFace(family, v, { selected = false } = {}) {
|
|
181
220
|
if (!canLoadFaces()) return;
|
|
182
221
|
if (variantFaces.has(v.url) || !fontSourceAllowed(v.url, allow)) return;
|
|
183
222
|
if (Number.isFinite(v.bytes) && v.bytes > VARIANT_FACE_MAX_BYTES) return;
|
|
223
|
+
if (!selected) {
|
|
224
|
+
if (variantBudget <= 0) return;
|
|
225
|
+
variantBudget -= 1;
|
|
226
|
+
}
|
|
184
227
|
variantFaces.add(v.url);
|
|
185
228
|
let face;
|
|
186
229
|
try { face = new FontFace(`${family} ${v.variant}`, `url(${v.url})`); } catch { return; }
|
|
187
|
-
face.load().then((loaded) =>
|
|
230
|
+
face.load().then((loaded) => {
|
|
231
|
+
if (closed || variantFamily !== family) { variantFaces.delete(v.url); return; }
|
|
232
|
+
document.fonts.add(loaded);
|
|
233
|
+
variantList.push({ face: loaded, url: v.url });
|
|
234
|
+
}).catch(() => {});
|
|
188
235
|
}
|
|
189
236
|
|
|
190
237
|
// ── the list ────────────────────────────────────────────────────────────
|
|
@@ -337,6 +384,11 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
|
|
|
337
384
|
|
|
338
385
|
function openVariants(f) {
|
|
339
386
|
openFamily = f;
|
|
387
|
+
if (variantFamily !== f.family) {
|
|
388
|
+
releaseVariantFaces();
|
|
389
|
+
variantFamily = f.family;
|
|
390
|
+
variantBudget = VARIANT_FACE_BUDGET;
|
|
391
|
+
}
|
|
340
392
|
vtitle.textContent = f.family;
|
|
341
393
|
vlist.textContent = "";
|
|
342
394
|
for (const v of f.variants) {
|
|
@@ -351,9 +403,15 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
|
|
|
351
403
|
// Commit WITHOUT leaving — you audition weights against the live
|
|
352
404
|
// geometry, so committing and navigating are separate actions (spec §6).
|
|
353
405
|
b.addEventListener("click", () => commit(f, v));
|
|
354
|
-
requestVariantFace(f.family, v);
|
|
355
406
|
vlist.append(b);
|
|
356
407
|
}
|
|
408
|
+
// Nearest weight to the selected one first, so the budget lands on the
|
|
409
|
+
// weights the user is most likely to compare.
|
|
410
|
+
const weightOf = (v) => Number(String(v.variant).replace(/i(talic)?$/, "")) || 400;
|
|
411
|
+
const selV = pickVariant(f);
|
|
412
|
+
const order = [...f.variants].sort((a, b) =>
|
|
413
|
+
(a === selV ? -1 : b === selV ? 1 : Math.abs(weightOf(a) - weightOf(selV)) - Math.abs(weightOf(b) - weightOf(selV))));
|
|
414
|
+
for (const v of order) requestVariantFace(f.family, v, { selected: v === selV });
|
|
357
415
|
vlist.scrollTop = 0;
|
|
358
416
|
picker.classList.add("at-variants");
|
|
359
417
|
}
|
|
@@ -378,8 +436,21 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
|
|
|
378
436
|
closed = true;
|
|
379
437
|
clearTimeout(debounce);
|
|
380
438
|
document.removeEventListener("keydown", onKey);
|
|
439
|
+
// Faces are the picker's, not the page's: every one auditioned would
|
|
440
|
+
// otherwise stay resident until the iframe dies (the 2026-10 Safari tab
|
|
441
|
+
// kill). Keep only the selected family's MENU face — the widget's button
|
|
442
|
+
// label renders in it.
|
|
443
|
+
if (typeof document.fonts?.delete === "function") {
|
|
444
|
+
for (const face of addedFaces) {
|
|
445
|
+
if (face.family === selFamily && !keptFace) { keptFace = face; continue; }
|
|
446
|
+
try { document.fonts.delete(face); } catch { /* already gone */ }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
releaseVariantFaces();
|
|
381
450
|
picker.remove();
|
|
382
451
|
if (openPicker === handle) openPicker = null;
|
|
452
|
+
// After removal, so a host reacting to the close sees the rail as it will be.
|
|
453
|
+
try { onClose?.(); } catch { /* host's problem, not the picker's */ }
|
|
383
454
|
}
|
|
384
455
|
function onKey(ev) {
|
|
385
456
|
if (ev.key !== "Escape") return;
|
|
@@ -145,9 +145,21 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
|
|
|
145
145
|
// the rail, outside the panel root, so tearing the panel down does not take it
|
|
146
146
|
// with it. Without dispose() the element — and the `document` keydown listener
|
|
147
147
|
// that only close() unhooks — would outlive the panel holding a stale `params`.
|
|
148
|
+
//
|
|
149
|
+
// A picker SESSION is one commit (spec 2026-10-01 §2): every pick rebuilds
|
|
150
|
+
// live through onChange, and the commit — "the user finished", the same
|
|
151
|
+
// split the slider makes on release — fires once, when the picker closes
|
|
152
|
+
// (Done, ×, Escape, a re-open superseding it, or dispose), and only if the
|
|
153
|
+
// value actually moved. A host that persists on commit (partforge-cloud
|
|
154
|
+
// vendors and saves) then pays once per session instead of once per pick.
|
|
148
155
|
let picker = null;
|
|
149
156
|
btn.addEventListener("click", () => {
|
|
150
|
-
|
|
157
|
+
const atOpen = params[node.key];
|
|
158
|
+
picker = openFontPicker?.({
|
|
159
|
+
node, params, allow, fontCatalog, anchor: wrap,
|
|
160
|
+
onPicked: () => { paint(); onChange?.(); },
|
|
161
|
+
onClose: () => { picker = null; if (params[node.key] !== atOpen) onCommit?.(); },
|
|
162
|
+
}) ?? null;
|
|
151
163
|
});
|
|
152
164
|
|
|
153
165
|
// Ambient: this branch already has the catalog button as its visible way in, so
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// `sp.sheet.generatedBuild` is the build made here; an author who replaces `build`
|
|
10
10
|
// afterwards is detectable as `sp.build !== sp.sheet.generatedBuild`.
|
|
11
11
|
//
|
|
12
|
+
// Likewise `sp.sheet.generatedPlace` is the place made here (the pose, then the author's
|
|
13
|
+
// own) and `sp.sheet.place` the author's own place, or null: the realistic look
|
|
14
|
+
// (materials/sheet-look.js) trusts a sheet's canonical frame only with no author place
|
|
15
|
+
// and `sp.place === sp.sheet.generatedPlace`.
|
|
16
|
+
//
|
|
12
17
|
// Placement (decision 10): the pose applies for display AND export, then the author's
|
|
13
18
|
// own `place` — so STEP/3MF/STL files of a sheet forge come out assembled.
|
|
14
19
|
import { RENAMED_KEYS, RESERVED_KEYS, SHEET_KEYS, SUBPART_PASSTHROUGH_KEYS } from "./constants.js";
|
|
@@ -67,32 +72,42 @@ export function sheetPart(spec) {
|
|
|
67
72
|
const s = resolveSheet(k, sub, p, d);
|
|
68
73
|
return (processById(s.process).preview ?? sheetPreview)(k, s);
|
|
69
74
|
};
|
|
75
|
+
|
|
76
|
+
// Placement (decision 10): the pose applies for display AND export, then the author's own
|
|
77
|
+
// place. Made BEFORE the record is frozen, so the record can name it. Everything the
|
|
78
|
+
// place reads is taken NOW, into the same values the record freezes — never read off
|
|
79
|
+
// `spec` later, which an author may reuse and change for the next panel: the build and
|
|
80
|
+
// the burn's frame both read the frozen record, and a place that read the spec live would
|
|
81
|
+
// move the panel away from them.
|
|
82
|
+
const pose = spec.pose ?? null;
|
|
83
|
+
const thickness = spec.thickness;
|
|
84
|
+
const authorPlace = spec.place ?? null;
|
|
85
|
+
const generatedPlace = pose !== null || authorPlace ? (solid, ctx) => {
|
|
86
|
+
const resolved = typeof pose === "function" ? pose(ctx.p, ctx.d) : pose;
|
|
87
|
+
let posed = solid;
|
|
88
|
+
if (resolved !== null) {
|
|
89
|
+
const reason = validatePose(resolved);
|
|
90
|
+
if (reason) throw new Error(`sheet pose: ${reason}`);
|
|
91
|
+
const t = typeof thickness === "function" ? thickness(ctx.p, ctx.d) : thickness;
|
|
92
|
+
posed = applyPose(solid, resolved, t);
|
|
93
|
+
}
|
|
94
|
+
return authorPlace ? authorPlace(posed, ctx) : posed;
|
|
95
|
+
} : null;
|
|
96
|
+
|
|
70
97
|
const sheet = Object.freeze({
|
|
71
98
|
process,
|
|
72
99
|
material: spec.material,
|
|
73
|
-
thickness
|
|
100
|
+
thickness,
|
|
74
101
|
profile: spec.profile,
|
|
75
102
|
score: spec.score ?? null,
|
|
76
103
|
engrave: spec.engrave ?? null,
|
|
77
|
-
pose
|
|
104
|
+
pose,
|
|
105
|
+
place: authorPlace,
|
|
78
106
|
generatedBuild,
|
|
107
|
+
generatedPlace,
|
|
79
108
|
});
|
|
80
109
|
sub.build = generatedBuild;
|
|
81
|
-
|
|
82
|
-
const authorPlace = spec.place;
|
|
83
|
-
if (sheet.pose !== null || authorPlace) {
|
|
84
|
-
sub.place = (solid, ctx) => {
|
|
85
|
-
const pose = typeof sheet.pose === "function" ? sheet.pose(ctx.p, ctx.d) : sheet.pose;
|
|
86
|
-
let posed = solid;
|
|
87
|
-
if (pose !== null) {
|
|
88
|
-
const reason = validatePose(pose);
|
|
89
|
-
if (reason) throw new Error(`sheet pose: ${reason}`);
|
|
90
|
-
const t = typeof sheet.thickness === "function" ? sheet.thickness(ctx.p, ctx.d) : sheet.thickness;
|
|
91
|
-
posed = applyPose(solid, pose, t);
|
|
92
|
-
}
|
|
93
|
-
return authorPlace ? authorPlace(posed, ctx) : posed;
|
|
94
|
-
};
|
|
95
|
-
}
|
|
110
|
+
if (generatedPlace) sub.place = generatedPlace;
|
|
96
111
|
sub.sheet = sheet;
|
|
97
112
|
return sub;
|
|
98
113
|
}
|
package/src/framework/vectors.js
CHANGED
|
@@ -19,8 +19,9 @@ const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
|
|
|
19
19
|
// source → { units, shapes }. The bytes memo above stops a refetch; this stops a re-PARSE
|
|
20
20
|
// (UTF-8 decode + JSON.parse + validation + a bbox recomputation that tessellates
|
|
21
21
|
// every contour). Without it every regen of a part with artwork redoes all of that.
|
|
22
|
-
// Fonts solve the same problem with kernel._fontsBySource (jobs.js:
|
|
23
|
-
//
|
|
22
|
+
// Fonts solve the same problem with kernel._fontsBySource (jobs.js:277-312), a bounded
|
|
23
|
+
// LRU keyed by source and the only holder of a parsed font (the resolver's memo is
|
|
24
|
+
// transient); imports use a digest comparison. This is the third pipeline's version.
|
|
24
25
|
// Keyed on the SOURCE, not the name: one worker outlives many parts, and a name is
|
|
25
26
|
// not an identity. Only successes are cached — a parse failure must throw again
|
|
26
27
|
// under the next name that declares it, with that name in the message.
|